## 做了什么(顺序有意义) 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` 未编译)。
369 lines
17 KiB
JavaScript
369 lines
17 KiB
JavaScript
/**
|
||
* 埋点(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();
|
||
}
|
||
})();
|