Files
jkooe f4e24c2c95 feat(events): 最小埋点 5 事件的闭环 —— 白名单、有界、写失败不阻断(M5)
## 三处前置此前**全部不存在**(我实测,不是文档说)

```
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 核心未构建,本机环境问题,与本次改动无关)。
2026-11-08 19:46:29 +08:00

326 lines
20 KiB
SQL
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.
-- ==================== 幂等增量迁移(**每次启动都跑**)====================
--
-- ⚠️ 为什么要单独一个文件(2026-09-15 修的运维坑)────────────────────────
-- 迁移段原先只写在 `init.sql` 末尾,而 `init.sql` 挂在
-- `/docker-entrypoint-initdb.d/` —— **那个目录只在数据目录为空时才执行**。
-- 也就是说:**已有库上,这段代码从来没跑过**,全靠人手敲 SQL。
--
-- 后果不是"少个字段",而是**静默的链路中断**:新增 `devices.hardware_id` 后,
-- `registerDevice` 会写一个不存在的列 → SQL 报错 → 网关侧 catch 掉只记日志
-- (`[gateway] 设备落库失败`)→ **设备不再落库,而服务健康检查全绿**。
-- 与 `init.sql` 里那段"首次部署才走、结果云上全废"是同一个病:
-- 凡是"只在某种时序下执行"的代码,都必须有一个不受时序影响的执行点。
--
-- 故:本文件是**所有 schema 变更的唯一落点**,
-- · 全新库:`init.sql` 末尾 `\i /migrate.sql` 走一遍;
-- · 已有库:compose 里的 `migrate` 一次性服务在 postgres healthy 后跑一遍。
-- 两条路径共用同一份定义(单一真相源),且全部语句幂等(可反复执行)。
--
-- ⚠️ 纪律:**新增任何列/索引/表,都加到这里**(而不是只改上面的 CREATE TABLE,
-- 那只对全新库有效)。改完跑 `bash tests/run-all.sh` ——
-- `schema-consistency-test.js` 会校验建表顺序与清单一致性。
-- ---- 指标列(骨架期只有 7 列,权威清单有 17 个字段,缺列的指标只能落在 payload)----
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS mem_used_mb DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS mem_total_mb DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS disk_free_gb DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS disk_total_gb DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS disk_write_mbps DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS battery_temp DOUBLE PRECISION;
-- ---- 账号体系的凭据列(新表走 CREATE TABLE IF NOT EXISTS,已存在的库只需补列)----
ALTER TABLE users ADD COLUMN IF NOT EXISTS secret_hash TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS secret_salt TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS secret_updated_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ;
-- ---- 设备硬件指纹(2026-09-15)----
-- 用途见 `server/shared/device-identity.js`:让服务端能认出"这还是同一台机",
-- 从而把同一台设备因 device_id 漂移产生的多条记录归并掉。
--
-- ⚠️ 存量行留 NULL,**不要**拿 device_id 回填:device_id 是**派生的、会变的值**,
-- 拿它当"稳定指纹"等于把 bug 固化进数据。NULL 只是让它走"后缀兜底"判据。
ALTER TABLE devices ADD COLUMN IF NOT EXISTS hardware_id TEXT;
CREATE INDEX IF NOT EXISTS idx_devices_hardware ON devices(owner_id, hardware_id);
-- ── 日志与可观测性(2026-10-17)────────────────────────────────────
-- log_line_prefix:单容器里 5 个进程日志共用一个流,PG 默认格式没有标识,
-- 排查时要靠猜。加 [postgres] 前缀后与 [gateway]/[business]/[supervisor] 对齐。
-- log_checkpoints=off:每 5 分钟两条 checkpoint 日志是纯噪音(默认 off,
-- 某些镜像会开),关掉后 docker logs 里业务日志一眼可见。
-- log_min_duration_statement:**慢查询落日志**。不依赖 pg_stat_statements
-- (那要装扩展+重启),超过 200ms 的语句直接出现在日志里,是排查
-- "为什么这次请求慢"最省事的抓手。
ALTER SYSTEM SET log_line_prefix = '[postgres] %m [%p] ';
ALTER SYSTEM SET log_checkpoints = off;
ALTER SYSTEM SET log_min_duration_statement = 200;
SELECT pg_reload_conf();
-- ── 断线补传幂等:session_id 列(2026-10-25)────────────────────────
--
-- 背景见 init.sql 里 idx_metrics_device_seq 的注释:网关进程内的 seq 去重窗口
-- **重启即清空**,而客户端 session_id 不变、会重发最多 120 帧补传
-- → 全部被判成新 seq → 重复入库 → 历史回溯的均值被慢慢拉偏,且不报任何错。
--
-- ⚠️ 为什么**不在 DB 层建唯一索引**(2026-10-25 在生产形态上实测,勿再尝试)
--
-- `device_metrics` 在 TimescaleDB 分支上是 **hypertable**(实测 pg_inherits 里挂着
-- 多个 `_timescaledb_internal._hyper_1_*_chunk`)。hypertable 按 `ts` 分区,
-- 而 PostgreSQL 硬性要求:**分区表上的唯一索引必须包含分区键**。实测报错——
-- ERROR: cannot create a unique index without the column "ts"
-- (used in partitioning)
--
-- 把 ts 加进唯一键同样不行:ts 由服务端改写,补传同一帧时 ts 不同,
-- 唯一键就失去幂等语义 —— 骨架期那条注释「不加唯一约束因为 ts 会被改写」
-- 想说的正是这一点,只是它把结论下成了「所以不能建」,
-- 而正确结论是「所以唯一键不该含 ts,改由应用层承担」。
--
-- 另注意:唯一索引这条路在**普通 postgres 镜像**上本来可行,
-- 但两种镜像必须共用同一份 schema 定义(单一真相源),
-- 不能一边建索引一边不建 —— 否则两套部署的幂等语义就分叉了。
--
-- → 幂等改由**网关侧持久化去重窗口**承担(见 gateway.js 的 `_seqWindow`),
-- `session_id` 是那个窗口 key 的组成部分。
--
-- 本列仍然有价值,不只是给去重当 key:
-- ① 区分「同一会话内重连补传」与「换了进程后的新流」—— 两者去重语义不同
-- (前者要判重,后者要重置窗口,否则新进程的 seq 从 0 开始会被全判成重复);
-- ② 诊断时能看出一次 agent 进程生命周期的边界。
--
-- 两条执行路径(init.sql / 本文件)共用同一份列定义,语句幂等。
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS session_id TEXT;
-- ══════════════════════════════════════════════════════════════════
-- 用户机制(2026-10-30):生命周期 / 角色 / 一次性凭据 / 审计可见性
-- ══════════════════════════════════════════════════════════════════
--
-- 设计稿见本地 `inkwatch-docs-local/user-model-design-2026-10-30.md`(文档不入仓库)。
-- 本段与 init.sql 的对应定义**必须逐列相等**,由 schema-consistency-test 第 14 段锁定 ——
-- 否则又会重演"全新库与已有库两套 schema"的首次部署事故。
-- ---- 用户生命周期列 ----
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
-- ---- 会话:界面展示用的设备标签 + 吊销原因 ----
-- 两者都**不参与任何判定**:device_label 由客户端自报可伪造,revoked_reason 只日志用。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_label TEXT;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS revoked_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_sessions_user_active ON sessions(user_id, last_used_at DESC);
-- ---- 一次性凭据(验证邮箱 / 重置口令 / 邀请)----
CREATE TABLE IF NOT EXISTS auth_tokens (
token_hash TEXT PRIMARY KEY,
purpose TEXT NOT NULL,
subject TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
attempts SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_subject ON auth_tokens(subject, purpose);
-- ---- 授权行升级为带角色 ----
-- 存量行一律回落 viewer:**默认最小权限**,不猜测历史意图。
ALTER TABLE device_grants ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'viewer';
ALTER TABLE device_grants ADD COLUMN IF NOT EXISTS granted_by BIGINT;
ALTER TABLE device_grants ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
ALTER TABLE device_grants ADD COLUMN IF NOT EXISTS grantee_id BIGINT;
-- 数值型 account_id 回填(LEGACY 自报串如 `acct_xxx` 不是数字,留 NULL 走兼容通道)
-- ⚠️ 必须带 EXISTS:历史上可能残留指向已删账号的孤儿授权行,
-- 直接 cast 回填会让下面的外键**在已有库上**失败(全新库反而看不出问题)。
UPDATE device_grants g
SET grantee_id = g.account_id::bigint
WHERE g.grantee_id IS NULL
AND g.account_id ~ '^[0-9]+$'
AND EXISTS (SELECT 1 FROM users u WHERE u.id = g.account_id::bigint);
CREATE INDEX IF NOT EXISTS idx_grants_grantee ON device_grants(grantee_id);
-- 外键用 DO 块做幂等(PostgreSQL 没有 ADD CONSTRAINT IF NOT EXISTS)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'device_grants_grantee_fk'
) THEN
ALTER TABLE device_grants
ADD CONSTRAINT device_grants_grantee_fk
FOREIGN KEY (grantee_id) REFERENCES users(id) ON DELETE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'device_grants_granted_by_fk'
) THEN
ALTER TABLE device_grants
ADD CONSTRAINT device_grants_granted_by_fk
FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
-- ---- 审计:按设备查("谁看过我的设备")----
CREATE INDEX IF NOT EXISTS idx_audit_device ON audit_log(device_id, created_at DESC);
-- ---- 审计:按时间查 / 按保留期清理 ----
-- init.sql 里一直有这一条,**migrate.sql 里没有** —— 也就是说:
-- 建库时跑过 init.sql 的实例有它,而从旧版本 migrate 上来的实例没有。
-- 保留期清理(audit-prune)是按 created_at 范围删的,缺这条索引就是全表扫;
-- 管理端的审计列表也是按 created_at 倒序分页。补上,两条路径对齐。
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
-- ═══════════════════════════════════════════════════════════════════════
-- 实例初始化配置(2026-10-30,第 27 轮)
--
-- 为什么要有这张表:在此之前"这套部署是谁的、允许谁来注册、免费几台设备"
-- 全靠部署时手敲环境变量,敲漏一个就静默降级(默认值往往是最宽松那档)。
-- 且它们**没有任何落库痕迹** —— 半年后没人能回答"这台机器当初是怎么配的"。
--
-- 三条设计约束:
-- ① **单行表**(CHECK id = 1)。允许两行就等于允许两份真相,
-- 而这正是本仓库复发过 6 次的病(见 README「单一真相源」)。
-- ② **密钥一律不入库**。DB 密码、SMTP 口令、ADMIN_TOKEN 都留在 env ——
-- 库会被备份、会被导出、会被我自己的管理端读出来,塞进去等于扩大暴露面。
-- ③ **bootstrap_state 三态**,不是布尔。因为"全新库要初始化"与
-- "存量库升级"必须走不同路径:前者该挡在门外,后者断服就是事故。
-- pending —— 全新库,尚未初始化:业务端点一律 503,只放 /setup
-- adopted —— 存量库升级上来:按默认值继续跑,管理端提示"可补建管理员"
-- ready —— 已走完向导
-- ═══════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS instance_config (
id SMALLINT PRIMARY KEY DEFAULT 1,
instance_name TEXT NOT NULL DEFAULT '墨观',
public_url TEXT, -- 邮件/邀请链接里的对外地址,可空
allow_open_register BOOLEAN NOT NULL DEFAULT FALSE,
require_auth BOOLEAN NOT NULL DEFAULT FALSE,
require_device_token BOOLEAN NOT NULL DEFAULT FALSE,
enforce_device_quota BOOLEAN NOT NULL DEFAULT TRUE,
free_device_limit INTEGER NOT NULL DEFAULT 3,
relay_max_bytes BIGINT NOT NULL DEFAULT 1048576,
mail_mode TEXT NOT NULL DEFAULT 'console',
mail_from TEXT,
audit_retention_days INTEGER, -- NULL = 永久保留
bootstrap_state TEXT NOT NULL DEFAULT 'pending',
initialized_at TIMESTAMPTZ,
initialized_by TEXT, -- 初始化时的来源 IP(审计用,非身份)
revision INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT instance_config_single_row CHECK (id = 1),
CONSTRAINT instance_config_state_chk
CHECK (bootstrap_state IN ('pending', 'adopted', 'ready')),
-- 名称进的是页面标题与邮件抬头,限长防"塞一段 HTML 进去"
CONSTRAINT instance_config_name_chk
CHECK (length(btrim(instance_name)) BETWEEN 1 AND 64),
CONSTRAINT instance_config_free_chk
CHECK (free_device_limit BETWEEN 0 AND 10000),
-- 上限 2 GiB:与中继的硬上限一致,防把库里的值改成 1TB 再被 OOM 打穿
CONSTRAINT instance_config_relay_chk
CHECK (relay_max_bytes BETWEEN 1024 AND 2147483648)
);
-- 管理员标识:与终端用户同表,只多一个布尔位。
-- 不做独立 admin_users 表 —— 那会带来两套口令策略、两套会话、两套审计主体,
-- 而自托管场景的运营者本来就是"一个持有设备的人"。
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-- 部分索引:管理员是极少数,全表扫描也该很快,但管理端每次请求都要查它
CREATE INDEX IF NOT EXISTS idx_users_admin ON users (is_admin) WHERE is_admin;
-- 落首行。**判据是"库里有没有用户",不是"迁移跑了第几次"** ——
-- 存量库升级上来时 users 非空 → adopted,直接按默认值继续服务,不断服。
-- 全新库 users 为空 → pending,业务端点在初始化前一律 503。
-- 已存在则不动(幂等):初始化完成后再跑迁移不会把 ready 打回 pending。
INSERT INTO instance_config (id, bootstrap_state)
SELECT 1, CASE WHEN EXISTS (SELECT 1 FROM users) THEN 'adopted' ELSE 'pending' END
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════
-- v2 / M2:升档申请(`plan_requests`)
--
-- 正文唯一来源是 `docs/migrations/v2.sql`(评审用),此处为**合入副本**,
-- 两份必须逐字一致。**严禁**再往 `init.sql` 里写一遍这两张表:`init.sql`
-- 末尾已有 `\i /migrate.sql`,全新库与已有库自动共用这一份定义,
-- 而双份真相源是本仓库最贵的教训(README 记 6 次)。
--
-- 与 SPEC §6.1 的一处**偏离**(`account_id TEXT` → `BIGINT`):`users.id` 是
-- BIGSERIAL,text 与 bigint 之间没有相等算子,建外键会直接报
-- ERROR: foreign key constraint cannot be implemented
-- Key columns are of incompatible types: text and bigint
-- (本机 PG 16 实测)。保留 ON DELETE CASCADE:删号必须级联清掉挂在它上面的
-- 申请,否则留下指向不存在账号的孤儿申请。
--
-- 与 `subscriptions` 表无关:后者是支付订单的空壳(有表结构、有读取、全仓零写入),
-- v2 不写它,也不删它(管理台把它显示成"本期未启用",消除界面说谎)。
-- ═══════════════════════════════════════════════════════════════════════
-- 状态机只有三态(pending / approved / rejected),由 CHECK 兜住 ——
-- 不用枚举类型:加一个状态要 ALTER TYPE,而 CHECK 改起来是一次纯文本迁移。
-- ⚠️ 加第四态时必须同步改下面 uq_plan_requests_pending 的谓词。
CREATE TABLE IF NOT EXISTS plan_requests (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
from_plan TEXT NOT NULL, -- 提交时刻的档位快照,之后 users.plan 变化不回溯
to_plan TEXT NOT NULL,
reason TEXT, -- 申请人自述(可空,≤500 字符由应用层截断)
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
note TEXT, -- 审批备注
decided_by TEXT, -- 审批人(ADMIN_NAME / 管理员邮箱 / 应急令牌标识)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
decided_at TIMESTAMPTZ
);
-- 审批队列的查询路径:`WHERE status = $1 ORDER BY created_at DESC, id DESC`
CREATE INDEX IF NOT EXISTS idx_plan_requests_status
ON plan_requests(status, created_at DESC);
-- 用户侧"我的申请"(倒序取前 20)与管理端按账号追溯
CREATE INDEX IF NOT EXISTS idx_plan_requests_account
ON plan_requests(account_id, created_at DESC);
-- **同一账号同时只允许一条 pending**(AC-06,防连点刷单)。
-- 这是并发下唯一可靠的防线:应用层"先 SELECT 再 INSERT"在两次点击之间会漏
-- (本机已验证:正确谓词下第二条 pending 被数据库拒绝,SQLSTATE 23505)。
-- 实现侧必须捕获 23505 并返回 409 E_REQUEST_PENDING,不要把 409 交给前端猜。
--
-- 注意:部分唯一索引的谓词必须与业务语义逐字一致(`status = 'pending'`)。
-- 若日后新增一种"未终态"状态,这条索引**不会**自动覆盖它 ——
-- 必须同步改这里,否则防连点失效且没有报错。
CREATE UNIQUE INDEX IF NOT EXISTS uq_plan_requests_pending
ON plan_requests(account_id) WHERE status = 'pending';
-- ══════════════════════════════════════════════════════════════════════
-- M5 埋点(v2)。5 个事件白名单在**服务端**(`shared/events.js`,权威是
-- SPEC §6.1.1),表上**刻意不设** `name` 的 CHECK:设了就是白名单两处真相,
-- 而且写入失败会被 AC-22 吞成 202 —— 没人会知道。
--
-- `account_id` 用 BIGINT 而不是 SPEC §6.1 原写的 TEXT:
-- · `users.id` 是 BIGSERIAL,TEXT 会让"按账号算率"这一步每次都 ::bigint 强转,
-- 转不动时(LEGACY 自报串 `acct_xxx`)只能静默留 NULL —— 而 NULL 与"匿名上报"
-- 在表里长得一模一样,事后分不开。同一处错误上一版在 `plan_requests` 上已经犯过。
-- · 可空是必须的:匿名设备也允许上报(端点认证可选)。
-- · `ON DELETE SET NULL`:账号注销后**保留计数、解除关联**。选 CASCADE 会让
-- 历史漏斗在注销那一刻缩水(分母变了,率就不可比);选 SET NULL 则行还在、
-- 但已经反查不到人 —— 这正是隐私边界(SPEC §10)想要的形状。
-- `device_id` 不做外键:设备被清掉不该导致埋点写不进(AC-22 的方向)。
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
device_id TEXT,
name TEXT NOT NULL,
props JSONB,
ts TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 漏斗查询只有两种形状:按事件名倒序取最近 N、按时间窗聚合计数
CREATE INDEX IF NOT EXISTS idx_events_name_ts ON events(name, ts DESC);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts DESC);