## 三处前置此前**全部不存在**(我实测,不是文档说)
```
shared/events.js → 不存在
deploy/migrate.sql CREATE TABLE events → 0 命中
server/ 下 "api/events" → 0 命中
```
所以在这之前任何"事件名单差集为空"的断言都是**恒绿的空断言**:文件不存在 → 读到空
集合 → 差集为空,而"文件不存在"与"文件里没有多余事件"在输出上是同一个东西:空。
## 服务端
- `shared/events.js`:5 个名字的**唯一机器副本**(权威在 SPEC §6.1.1)。净化做的是
**形状白名单**而不是字段名白名单 —— SPEC 只锁了事件名、没定义逐事件 props,此时
在这里发明一套字段名等于实现方单方面定契约,然后让客户端照着写。形状约束
(键名 `^[a-z][a-z0-9_]{0,31}$` / 值只接受标量 / ≤12 键 / ≤2KB / 敏感键名直接丢)
能挡住"埋点变成第二张用户表"这条路上最现实的风险,且不假装知道产品要什么。
- `POST /api/events`:未知名与非法条目**丢弃并计 dropped**(AC-20,不是 400)、
超 20 条只受理前 20(AC-21)、**写库失败仍 202**(AC-22)。认证可选但
**带废令牌一律 401**(降级成匿名等于"发个废令牌就关认证");
`body.account_id` 自报**被忽略** —— 否则谁都能往别人账号的漏斗里灌数,而漏斗是
拿来算率的,被灌了就没有意义。
- 管理端 `/api/admin/events/summary`:只出按事件名的计数与首末时间。它的判据不在
"能查出来",而在**查不出来** —— 这个端点没有任何办法取回某一条事件的内容。
## 客户端:必须有真实产生方,否则这张表永远是空的
这是本轮最容易"看起来交付了"的地方:端点全绿、表建好了,而**没有任何上报** →
表恒空、漏斗没有分子分母,而所有测试照样通过。**"以空为通过"的形态里这一种最难发现,
因为每一层单独看都对。**
`client/lib/host/event_reporter.dart` + `main.dart` 两个真实触发点(`app_open` 在配置
就绪、首帧将出时;`first_frame_shown` 用 `addPostFrameCallback`)。三条纪律:
不阻断业务(失败吞掉)、不重试(补报时序已经错了)、有界(丢最旧的)。
`app_open` **不能**放在 `main()` 最前面 —— 那时还不知道业务地址(`businessUrl` 来自
用户配置)。所以它落在"配置已就绪、首帧将出"这一刻;语义仍是"打开了应用",且这一批
能真的发出去。
## 建表只有一个落点
`events` 只写进 `deploy/migrate.sql`,`init.sql` 用 `\i /migrate.sql` 引用它 ——
这是本仓库既有约定(新表进 migrate,init 引用)。我最初给执行方的判据是"两个文件都
必须有"——那会把一份定义复制成两份,是错的,按约定改正。
`account_id` 用 `BIGINT REFERENCES users(id) ON DELETE SET NULL` 而不是 SPEC §6.1
原写的 `TEXT`:`users.id` 是 BIGSERIAL,TEXT 会让"按账号算率"每次都 `::bigint` 强转,
转不动时(LEGACY 自报串)只能静默留 NULL,而 NULL 与"匿名上报"在表里长得一模一样。
`ON DELETE SET NULL` 让注销后**保留计数、解除关联**(CASCADE 会让历史漏斗在注销那一刻
缩水,分母变了率就不可比)。
## 测试
`tests/events-test.js` **29 项全绿**,已接入 `run-all.sh`。关键一条是 **A7「表里真的
长出了行」**——没有它,前面每条都能在一个"什么都没落库"的实现上全绿。
AC-22 不是写注释:把 `events` 表临时改名造一次真实写失败,断言仍 202 **且**
`accepted=0` **且** 带 `write_failed` **且** 恢复后行数未变(只断 202 不够 ——
任何实现都会 202,因为 catch 住了)。
客户端 `event_reporter_test.dart` 6 项全绿,其中一条钉住"先抽干再发":发送期间
`track` 进来的新事件属于下一批,不被这一批的失败连坐(否则是"失败一次丢两批")。
回归:permissions 60/0、account 75/0、schema 116/0、admin-api 80/0、认领 57/0、
档位与历史 99/0;客户端全量 **595 通过 / 2 失败**,两条失败均为
`libpmon_core.dylib` 未编译(Rust 核心未构建,本机环境问题,与本次改动无关)。
134 lines
5.9 KiB
JavaScript
134 lines
5.9 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 埋点(M5)的事件名与净化规则 —— **5 个事件名的唯一机器副本**。
|
||
*
|
||
* ── 权威与副本 ─────────────────────────────────────────────────────
|
||
* 权威名单在 `docs/SPEC-v2.md` §6.1.1(**改名字只能从那里发起**)。本文件是它的
|
||
* 机器副本,承担差集的另一侧。`openapi.yaml` 与设计稿**刻意不复制**:`openapi.yaml`
|
||
* 的 `EventsBatchRequest.name` 明确写"本文件不复制"且不设 `enum` —— 一旦设 `enum`,
|
||
* AC-20 要求的"整条丢弃 + 计入 `dropped`"就会在网关层被变成 400,
|
||
* **埋点失败被升级成业务失败**,正好违反 AC-22 的精神。
|
||
*
|
||
* ── 为什么不做"逐事件 props 白名单" ────────────────────────────────
|
||
* SPEC 只锁了 5 个事件名,**没有定义逐事件的 props 字段**。此时若在这里发明一套
|
||
* (`{app_open: ['platform','app_version']}`),等于把"产品还没定的字段"由实现方
|
||
* 单方面定死,然后让客户端照着写 —— 那份字段名会立刻变成事实上的契约,而它没有
|
||
* 任何依据。所以这里做的是**形状白名单**,不是字段名白名单:
|
||
* · 键名必须 `^[a-z][a-z0-9_]{0,31}$`;
|
||
* · 值只接受标量(string / 有穷 number / boolean),数组与嵌套对象**整条丢弃**;
|
||
* · 单条 props 至多 12 个键、序列化后至多 2KB;
|
||
* · 敏感键名(口令/令牌/邮箱/电话/设备名…)**直接丢弃该键**(隐私边界见 SPEC §10)。
|
||
* 形状约束能挡住"埋点变成第二个用户表"这条路上最现实的风险,且不假装知道产品要什么。
|
||
*
|
||
* ── 净化的方向:丢弃,不报错 ──────────────────────────────────────
|
||
* 净化与白名单的失败一律是"丢弃这一条/这个键",端点**永远 202**(AC-20/AC-22)。
|
||
* 埋点不是业务:让埋点失败去阻断用户动作,是本末倒置。
|
||
*/
|
||
|
||
/** 5 个事件名(逐字,**不要改名、不要增删、不要排序"优化"**)。权威:SPEC §6.1.1 */
|
||
const EVENT_NAMES = Object.freeze([
|
||
'app_open',
|
||
'first_frame_shown',
|
||
'quota_blocked',
|
||
'upgrade_click',
|
||
'upgrade_requested',
|
||
]);
|
||
|
||
const EVENT_NAME_SET = new Set(EVENT_NAMES);
|
||
|
||
/** 单次上报的受理上限(AC-21:超出部分计入 dropped,不是 400) */
|
||
const MAX_BATCH = 20;
|
||
|
||
/** 单条事件的 props 形状上限 */
|
||
const MAX_PROP_KEYS = 12;
|
||
const MAX_PROP_JSON_BYTES = 2048;
|
||
const MAX_PROP_STRING_LEN = 200;
|
||
|
||
/** 键名合法形状 */
|
||
const PROP_KEY_RE = /^[a-z][a-z0-9_]{0,31}$/;
|
||
|
||
/**
|
||
* 敏感键名:命中即丢弃该键。
|
||
*
|
||
* 隐私边界(SPEC §10)写明不采集键盘/屏幕/浏览记录/通讯录/位置。这里额外把
|
||
* "能反查到人的标识"和"凭据"一律挡掉 —— 它们出现在埋点里从来不是"统计需要",
|
||
* 而是某次调试顺手打进去的,然后永久留在库里。
|
||
*/
|
||
const SENSITIVE_PROP_KEYS = new Set([
|
||
'secret', 'password', 'passwd', 'token', 'access_token', 'refresh_token',
|
||
'email', 'phone', 'mobile', 'device_name', 'display_name', 'ip',
|
||
'authorization', 'cookie', 'session',
|
||
]);
|
||
|
||
function isScalar(v) {
|
||
return typeof v === 'string' || typeof v === 'boolean'
|
||
|| (typeof v === 'number' && Number.isFinite(v));
|
||
}
|
||
|
||
/**
|
||
* 净化 props。返回 `{}` 表示"没有可留的"(调用方按"无 props"落库,不算丢弃)。
|
||
* 只丢键、不丢整条:一个多余键不该让整条事件消失(丢整条会丢分母)。
|
||
*/
|
||
function sanitizeProps(raw) {
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||
const out = {};
|
||
let n = 0;
|
||
for (const [k, v] of Object.entries(raw)) {
|
||
if (n >= MAX_PROP_KEYS) break;
|
||
const key = String(k).toLowerCase();
|
||
if (!PROP_KEY_RE.test(key)) continue;
|
||
if (SENSITIVE_PROP_KEYS.has(key)) continue;
|
||
if (!isScalar(v)) continue; // 数组/对象整条丢弃
|
||
if (typeof v === 'string' && v.length > MAX_PROP_STRING_LEN) continue;
|
||
out[key] = v;
|
||
n += 1;
|
||
}
|
||
if (JSON.stringify(out).length > MAX_PROP_JSON_BYTES) return {};
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 规整一批上报。**只返回可落库的条目与丢弃数**,不抛错。
|
||
*
|
||
* @param {unknown} raw 请求体里的 `events`
|
||
* @returns {{ accepted: Array<{name:string, props:object, ts:Date|null}>, dropped: number }}
|
||
*/
|
||
function normalizeBatch(raw) {
|
||
const arr = Array.isArray(raw) ? raw : [];
|
||
const accepted = [];
|
||
let dropped = 0;
|
||
|
||
for (let i = 0; i < arr.length; i += 1) {
|
||
const item = arr[i];
|
||
// 超过上限的部分一律计 dropped(AC-21)。注意这里**继续循环**而不是 break:
|
||
// break 之后 `dropped` 只加到上限那一刻,剩下的条目既不受理也不计数 ——
|
||
// 于是 accepted + dropped !== 上报条数,统计口径对不上。
|
||
if (i >= MAX_BATCH) { dropped += 1; continue; }
|
||
if (!item || typeof item !== 'object' || Array.isArray(item)) { dropped += 1; continue; }
|
||
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
||
if (!EVENT_NAME_SET.has(name)) { dropped += 1; continue; } // AC-20
|
||
|
||
// ts 是**客户端提示**,落库时间以服务端 now() 为准(时钟不可信)。
|
||
// 这里只把它当"可选元数据"保留下来前先做形状校验:非法的直接不带。
|
||
let ts = null;
|
||
if (typeof item.ts === 'string' || typeof item.ts === 'number') {
|
||
const d = new Date(item.ts);
|
||
if (!Number.isNaN(d.getTime())) ts = d;
|
||
}
|
||
accepted.push({ name, props: sanitizeProps(item.props), ts });
|
||
}
|
||
return { accepted, dropped };
|
||
}
|
||
|
||
module.exports = {
|
||
EVENT_NAMES,
|
||
EVENT_NAME_SET,
|
||
MAX_BATCH,
|
||
MAX_PROP_KEYS,
|
||
MAX_PROP_STRING_LEN,
|
||
SENSITIVE_PROP_KEYS,
|
||
sanitizeProps,
|
||
normalizeBatch,
|
||
};
|