Files
jkooe 7c9c2f0605 refactor(rename): pmon → inkwatch 数据层迁移(阶段 B:库名/角色/卷名/连接串)
## 做了什么(顺序有意义)

1. **备份**:`pg_dump -Fc` → 152MB dump,落在仓库外
   `.backup-rename-20261108/`,并留了改名前后的表行数文件。
2. **停应用**(nginx/business/gateway),保留 PG/Redis。
3. `ALTER DATABASE pmon RENAME TO inkwatch` + `ALTER ROLE pmon RENAME TO inkwatch`
   + `ALTER ROLE inkwatch WITH PASSWORD 'inkwatch_dev_only'`。
4. **复制卷**(不是改名):`pmon_pg_data → inkwatch_pg_data`(3.807GB)、
   Redis、nginx 日志三个卷。**旧卷全部保留**,作回滚点。
5. 改 compose 的卷名与环境变量默认值、`deploy/` 下全部脚本/配置、测试默认连接串、
   `.env.example`、`docs/`。
6. 用新 compose 起栈,删旧容器(`pmon-*` 共 6 个,含 9 天前的 migrate 容器)。

## 数据校验:这是本次唯一不能靠"应该没事"的部分

第一次我拿 `pg_stat_user_tables.n_live_tup` 当基线,**那是错的** —— 它是估算值,
实测 `devices` 估 58、实际 14559,`device_metrics` 估 0、实际 257 万。用它做校验
等于没有校验。

改成的办法:把 dump **恢复到一个临时库**,然后两张库**逐表精确 `count(*)` 对比**
(排除 `_hyper_*` chunk,父表计数已覆盖)。结果:

```
唯一差异:device_metrics  当前 2572652 / 备份 2572456  → 当前多 196 行
```

多出来而不是少了 —— 因为 dump 是 20:04:57 取的,而停服在 20:07:53,期间设备仍在
正常上报。最新数据时间戳 `12:07:53 UTC` 佐证了这一点。**196 行是正常写入,不是差异。**
逐表其它 40+ 张表行数完全一致。

校验库随后删除,容器内临时 dump 也删了。

## 两个踩到的坑(都已记录在流程里)

**① `docker exec` 不加 `-i` 时 stdin 不转发。** 我第一次用 heredoc 把四条 SQL 喂给
`psql`,结果**一条都没执行、也不报错** —— 看起来像"执行了但没生效"。改成 `-c` 逐条。

**② `ALTER ROLE` 不能重命名当前会话用户**(`session user cannot be renamed`)。
解法是建一个临时超级用户来执行,用完立刻删(已验证 `pg_roles` 里查不到它)。
注意 `DROP ROLE` 同样不能删当前用户,必须换回原身份再删。

## 有意没做的事

- **旧卷没删**:`pmon_pg_data`(3.807GB) / `pmon_redis_data` / `pmon_nginx_logs` 都留着。
  这是唯一的整体回滚点。确认新栈稳定运行一段时间后,可自行
  `docker volume rm pmon_pg_data pmon_redis_data pmon_nginx_logs`。
- **`pmon_dbg1` / `pmon_claim_verify` 两个遗留库没动**:它们是历史测试/调试残留,
  不是产品库。要清可以直接 `DROP DATABASE`,但删数据这种事我不代做决定。

## 验证

新栈 5 个容器全 `healthy`,`/health` business 与 gateway 均 **200**;
库内 `users=128 devices=14559`,与迁移前一致。

服务端套件(全部自起源码实例或纯离线):
认领 **57/0**、埋点 **29/0**、配对码来源 **16/0**、权限 **60/0**、schema **116/0**
—— 与改名前的数字**完全一致**。

客户端上一提交已验证 **595 通过 / 2 失败**(两条为 `libinkwatch_core.dylib` 未编译)。
2026-11-08 20:12:25 +08:00

369 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 埋点(M5)回归套件 —— SPEC AC-20 / AC-21 / AC-22
*
* ── 这套测试真正要防的是什么 ──────────────────────────────────────
* 埋点最容易"交付了但没做事":端点返回 202、测试全绿、而**表里一行都没有**。
* 所以本套件除了验三条 AC,还单独有一条 **A7「表里真的长出了行」** ——
* 没有它,前六条都可以在一个"什么都没落库"的实现上全绿通过。
*
* ── 每条"应当被丢弃"的断言都配一条"应当被接受" ────────────────────
* 只验"未知名字被丢",一个"全都丢"的实现也全绿。所以 A1 里同时断言
* 合法名 `accepted=1, dropped=0`。
*
* ── AC-22 怎么"真造一次失败"而不是写注释 ─────────────────────────
* 把 `events` 表临时改名 → 写入必然失败 → 断言仍 202 且响应带 `write_failed`。
* 只断 202 是不够的:**任何**实现都会 202(因为 catch 住了),必须同时看
* `write_failed` 与"确实没落行",才能证明这次是"失败了但没阻断",
* 而不是"成功了所以 202"。
*
* 需要 PG + Redis。库用共享开发库的独立表数据(每轮用唯一 device_id 打标,
* 收尾按标记删除);Redis 连共享 db 0 —— 本套件不写任何 Redis 键。
*
* 运行:cd <repo> && node tests/events-test.js
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const DATABASE_URL = process.env.DATABASE_URL
|| 'postgres://inkwatch:inkwatch_dev_only@127.0.0.1:5432/inkwatch';
// ⚠️ 必须 ≥32 字符:`admin.js` 的纪律 1 是「短于 32 字符视为未设置」,而"未设置"
// 的表现不是 401 而是**全部路由 404** —— 夹具用短令牌就得到一片 404,看起来像
// 「管理端没实现」,实际是令牌根本没生效。
const ADMIN_TOKEN = process.env.ADMIN_TOKEN
|| `evt-admin-${process.pid}-padded-to-be-long-enough-0123456789`;
let pass = 0;
let fail = 0;
const failures = [];
function check(name, cond, detail) {
if (cond) { pass += 1; console.log(` ✓ ${name}`); } else {
fail += 1;
failures.push(name);
console.log(` ✗ ${name}${detail === undefined ? '' : ` ${detail}`}`);
}
}
function section(t) { console.log(`\n=== ${t} ===`); }
const uniq = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const DEVICE = `evt-dev-${uniq}`; // 本轮所有行的标记,收尾按它删
let child = null;
let pg = null;
function loadPg() {
try {
return require(path.join(ROOT, 'server/business/node_modules/pg'));
} catch {
try { return require('pg'); } catch { return null; }
}
}
async function waitHealth(url, timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(`${url}/health`);
if (r.ok || r.status === 503) return true;
} catch { /* 还没起来 */ }
await new Promise((r) => setTimeout(r, 150));
}
return false;
}
function stopChild() {
if (!child) return;
try { child.kill('SIGTERM'); } catch { /* 已退 */ }
child = null;
}
/** 从 `deploy/migrate.sql` 里抽出 events 段并执行(幂等,全 IF NOT EXISTS) */
async function ensureEventsTable() {
const sql = fs.readFileSync(path.join(ROOT, 'deploy/migrate.sql'), 'utf8');
const start = sql.indexOf('CREATE TABLE IF NOT EXISTS events');
if (start < 0) return 'missing-in-migrate';
const tail = sql.slice(start);
const end = tail.indexOf('idx_events_ts');
if (end < 0) return 'incomplete';
const stmt = tail.slice(0, tail.indexOf(';', end) + 1);
const before = await pg.query(
"SELECT to_regclass('public.events') AS t"
);
await pg.query(stmt).catch(async () => {
// 分段执行:CREATE TABLE 与两条 CREATE INDEX 分开,避免一次多语句被拒
const parts = stmt.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)
.map((s) => (s.endsWith(';') ? s : `${s};`));
for (const p of parts) await pg.query(p);
});
return before.rows[0].t ? 'exists' : 'created';
}
async function post(p, body, token) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const r = await fetch(`${BIZ}${p}`, {
method: 'POST', headers, body: JSON.stringify(body || {}),
});
let json = null;
try { json = await r.json(); } catch { /* 非 JSON */ }
return { status: r.status, body: json };
}
async function get(p, token) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
const r = await fetch(`${BIZ}${p}`, { headers });
let json = null;
try { json = await r.json(); } catch { /* 非 JSON */ }
return { status: r.status, body: json };
}
let BIZ = '';
async function main() {
const pgMod = loadPg();
if (!pgMod) {
console.log('\n[skip] 找不到 pg 模块(server/business/node_modules 未安装?)');
return;
}
pg = new pgMod.Client({ connectionString: DATABASE_URL });
try {
await pg.connect();
} catch (e) {
console.log(`\n[skip] 连不上 PostgreSQL(${DATABASE_URL}):${e.message}`);
console.log(' 本套件需要 PG(events 表所在库)。');
return;
}
const schema = await ensureEventsTable();
if (schema === 'missing-in-migrate') {
console.log('\n[skip] deploy/migrate.sql 里找不到 events 建表段 —— 无法准备夹具。');
return;
}
if (schema === 'created') console.log('[setup] 已应用 deploy/migrate.sql 的 events 段');
const port = 59300 + Math.floor(Math.random() * 400);
BIZ = process.env.BUSINESS_URL || `http://127.0.0.1:${port}`;
if (!process.env.BUSINESS_URL) {
child = spawn(process.execPath, ['server/business/index.js'], {
cwd: ROOT,
env: {
...process.env,
BUSINESS_PORT: String(port),
// ⚠️ 必须显式传:服务端是「无 DATABASE_URL 即回退内存模式」,而内存模式下
// `repo` 为 null,所有表操作都变成静默失败 —— 夹具漏这一个变量会得到
// 「端点 202 但表里 0 行」,看起来像埋点没落库,实际是根本没连库。
DATABASE_URL,
ADMIN_TOKEN,
ALLOW_OPEN_REGISTER: '1',
REGISTER_IP_LIMIT: '500',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (d) => { log += d.toString(); });
child.stderr.on('data', (d) => { log += d.toString(); });
if (!(await waitHealth(BIZ))) {
console.log(`\n[skip] 临时业务服务起不来(端口 ${port}):`);
console.log(log.trim().split('\n').slice(-8).map((l) => ` ${l}`).join('\n'));
stopChild();
return;
}
console.log(`[setup] 临时业务服务 :${port}`);
}
const rowCount = async (where = 'device_id = $1', args = [DEVICE]) => {
const r = await pg.query(`SELECT count(*)::int AS n FROM events WHERE ${where}`, args);
return r.rows[0].n;
};
// ══════════════════════════════════════════════════════════════
section('A1. AC-20:白名单外整条丢弃,合法名必须受理(正控制)');
// 合法名 + 未知名混在一批:同时验"不是全丢"与"不是全收"
const a1 = await post('/api/events', {
device_id: DEVICE,
events: [
{ name: 'app_open', props: { platform: 'test' } },
{ name: 'evil_event' },
],
});
check('混批 → 202(埋点失败不上升级成业务失败)', a1.status === 202, `status=${a1.status}`);
check('★ accepted = 1(合法名真被受理,证明白名单不是"恒拒")',
a1.body && a1.body.accepted === 1, JSON.stringify(a1.body));
check('★ dropped = 1(白名单外整条丢,不是 400)',
a1.body && a1.body.dropped === 1, JSON.stringify(a1.body));
check('库里只有 app_open 那一行(未知名确实没落库)', (await rowCount()) === 1);
// ══════════════════════════════════════════════════════════════
section('A2. AC-21:超 20 条只受理前 20 条,溢出计 dropped');
const many = Array.from({ length: 22 }, (_, i) => ({
name: 'first_frame_shown', props: { i },
}));
const a2 = await post('/api/events', { device_id: DEVICE, events: many });
check('22 条 → 202', a2.status === 202, `status=${a2.status}`);
check('★ accepted = 20', a2.body && a2.body.accepted === 20, JSON.stringify(a2.body));
check('★ dropped = 2(溢出被计数,不是静默丢)',
a2.body && a2.body.dropped === 2, JSON.stringify(a2.body));
check('库里累计 21 行(A1 的 1 行 + 本批 20 行)', (await rowCount('device_id=$1', [DEVICE])) === 21,
`实得 ${await rowCount('device_id=$1', [DEVICE])}`);
// ══════════════════════════════════════════════════════════════
section('A3. 净化:敏感键与嵌套值被丢,普通标量留下');
const a3 = await post('/api/events', {
device_id: DEVICE,
events: [{
name: 'upgrade_click',
props: {
from: 'quota_banner',
email: 'a@b.com', // 敏感键 → 丢
token: 'abc', // 敏感键 → 丢
nested: { x: 1 }, // 非标量 → 丢
arr: [1, 2], // 非标量 → 丢
count: 3, // 标量 → 留
'BAD-KEY': 'x', // 键名不合法 → 丢
},
}],
});
check('202 且 accepted=1', a3.status === 202 && a3.body.accepted === 1,
`status=${a3.status} ${JSON.stringify(a3.body)}`);
const p3 = await pg.query(
'SELECT props FROM events WHERE device_id=$1 AND name=$2 ORDER BY id DESC LIMIT 1',
[DEVICE, 'upgrade_click']
);
const got = p3.rows[0] && p3.rows[0].props ? p3.rows[0].props : {};
check('★ 留下 from 与 count', got.from === 'quota_banner' && got.count === 3, JSON.stringify(got));
check('★ 敏感键 email/token 被丢(键都不在)',
!('email' in got) && !('token' in got), JSON.stringify(got));
check('★ 嵌套/数组被丢', !('nested' in got) && !('arr' in got), JSON.stringify(got));
check('★ 非法键名被丢', !('BAD-KEY' in got) && !('bad-key' in got), JSON.stringify(got));
// ══════════════════════════════════════════════════════════════
section('A4. 身份:匿名可用,带废令牌必须 401(不许降级成匿名)');
const a4 = await post('/api/events', {
device_id: DEVICE, events: [{ name: 'app_open' }],
});
check('不带令牌 → 202(匿名上报允许)', a4.status === 202, `status=${a4.status}`);
const anonRow = await pg.query(
'SELECT account_id FROM events WHERE device_id=$1 ORDER BY id DESC LIMIT 1', [DEVICE]
);
check('★ 匿名行的 account_id 为 NULL',
anonRow.rows[0] && anonRow.rows[0].account_id === null,
JSON.stringify(anonRow.rows[0]));
const a4b = await post('/api/events', {
device_id: DEVICE, events: [{ name: 'app_open' }],
}, 'pm_tok_definitely_invalid_000');
check('★ 带无效令牌 → 401(失败关闭;降级成匿名等于"发个废令牌就关认证")',
a4b.status === 401, `status=${a4b.status}`);
const beforeSpoof = await rowCount();
const a4c = await post('/api/events', {
account_id: 1, // 自报账号:必须被忽略
device_id: `${DEVICE}-spoof`,
events: [{ name: 'app_open' }],
});
const spoofRow = await pg.query(
'SELECT account_id FROM events WHERE device_id=$1 ORDER BY id DESC LIMIT 1', [`${DEVICE}-spoof`]
);
check('★ body.account_id 自报被忽略(否则谁都能往别人漏斗里灌数)',
a4c.status === 202 && spoofRow.rows[0] && spoofRow.rows[0].account_id === null,
`status=${a4c.status} row=${JSON.stringify(spoofRow.rows[0])}`);
await pg.query('DELETE FROM events WHERE device_id=$1', [`${DEVICE}-spoof`]);
check('(清理自报夹具)', (await rowCount()) === beforeSpoof);
// ══════════════════════════════════════════════════════════════
section('A5. AC-22:写入失败仍 202,且响应与库里都说明"没落成"');
const cntBefore = await rowCount();
await pg.query('ALTER TABLE events RENAME TO events_tmp_offline');
let a5;
try {
a5 = await post('/api/events', {
device_id: DEVICE, events: [{ name: 'app_open' }, { name: 'app_open' }],
});
} finally {
await pg.query('ALTER TABLE events_tmp_offline RENAME TO events');
}
check('★ 表不可写时仍 202(埋点不许阻断业务)', a5.status === 202, `status=${a5.status}`);
check('★ 且 accepted 不计这批(0),不假装成功',
a5.body && a5.body.accepted === 0, JSON.stringify(a5.body));
check('★ 且带 write_failed 标记(吞掉但可观测;静默吞掉正是"全绿但表恒空"的来源)',
a5.body && a5.body.write_failed === true, JSON.stringify(a5.body));
check('★ 恢复后行数未变(确实没落成,不是"其实成功了所以 202")',
(await rowCount()) === cntBefore, `${cntBefore} → ${await rowCount()}`);
// ══════════════════════════════════════════════════════════════
section('A6. 管理端计数:只出计数,取不到任何一条事件内容');
const a6 = await get(`/api/admin/events/summary?days=7`, ADMIN_TOKEN);
check('带 ADMIN_TOKEN → 200', a6.status === 200, `status=${a6.status}`);
check('返回 items 且每项只有 name/n/first_seen/last_seen',
Array.isArray(a6.body.items)
&& a6.body.items.every((it) => {
const k = Object.keys(it).sort().join(',');
return k === 'first_seen,last_seen,n,name';
}),
JSON.stringify(a6.body.items && a6.body.items[0]));
check('★ 响应里不含 props(拿不回任何一条事件内容)',
!JSON.stringify(a6.body).includes('quota_banner'));
const a6b = await get('/api/admin/events/summary?days=999', ADMIN_TOKEN);
check('days 上界有界 → 400(不设上界就是一个能被外部触发全表扫描的端点)',
a6b.status === 400, `status=${a6b.status}`);
const a6c = await get('/api/admin/events/summary?days=7');
check('不带令牌 → 401/403(管理端不是公开端点)',
a6c.status === 401 || a6c.status === 403, `status=${a6c.status}`);
// ══════════════════════════════════════════════════════════════
section('A7. ★ 表里真的长出了行(这条防"端点全绿但表恒空")');
const total = await rowCount();
check(`★ 本轮共落库 ${total} 行(>0 才算真有产生方)`, total > 0, `实得 ${total}`);
const names = await pg.query(
'SELECT DISTINCT name FROM events WHERE device_id=$1 ORDER BY name', [DEVICE]
);
check('★ 落库的事件名都在白名单内(没有未知名漏进来)',
names.rows.every((r) => ['app_open', 'first_frame_shown', 'quota_blocked',
'upgrade_click', 'upgrade_requested'].includes(r.name)),
JSON.stringify(names.rows.map((r) => r.name)));
return;
}
async function cleanup() {
try {
if (pg) await pg.query('DELETE FROM events WHERE device_id LIKE $1', [`evt-dev-${uniq}%`]);
} catch { /* 表可能正在改名,忽略 */ }
// 兜底:若上一次被 kill 在改名窗口里,把表名恢复
try {
if (pg) {
const r = await pg.query("SELECT to_regclass('public.events_tmp_offline') AS t");
if (r.rows[0].t) await pg.query('ALTER TABLE events_tmp_offline RENAME TO events');
}
} catch { /* ignore */ }
}
function finish() {
stopChild();
console.log('\n' + '='.repeat(52));
console.log(` 埋点(M5):${pass} 通过 / ${fail} 失败`);
if (fail) console.log(` 失败项:\n - ${failures.join('\n - ')}`);
console.log('='.repeat(52));
process.exitCode = fail === 0 ? 0 : 1;
return fail;
}
(async () => {
try {
await main();
} catch (e) {
console.log(`\n ✗ 套件异常中止: ${e && e.stack ? e.stack : e}`);
fail += 1;
} finally {
await cleanup();
try { if (pg) await pg.end(); } catch { /* ignore */ }
finish();
}
})();