此前账号体系只到"你是谁",这一轮补"你能做什么"与"账号出事怎么办": 忘了口令等于报废、授权只能发配对码(等于公开招租)、配额只报数不拦。 服务端 - shared/permissions.js:13 能力 × 3 角色 + PLAN_TABLE + authorize(), canViewDevice 降为 authorize(...,'VIEW_SNAPSHOT') 薄封装(单一真相源) - auth_tokens 一表统管 verify/reset/invite,只存 sha256,原子消费 - 新端点:会话列表/踢人、改密、验证与找回、注销(30 天冷静期)、 成员 CRUD + 角色、邀请接受、转移归属、解除归属、审计 - 配额 ENFORCE_DEVICE_QUOTA 改默认开启(唯一破坏性变更);用尽返回 402; 未验证邮箱收紧到 1 台 - server/shared/mailer.js:outbox/console/smtp(smtp 未实现,如实回 delivered:false); 令牌绝不进 HTTP 响应 客户端 - role_catalog.dart(Dart 侧镜像,跨语言一致性由测试锁定) - device_members_card.dart + account_card 增验证状态/改密/注销/找回 - ViewerDevice.role 透传;成员卡只对"当前查看的那台 + 已登录 + 有角色"出现 护栏 - permissions-test 29 / user-lifecycle-test 66 / schema-consistency 第 14·15 段 - account_security_test 13 / viewer_page_test ⑩ 3 - 全量回归 34 套件 / 1838 项 / 0 失败 顺带修(同一类"环境冒充缺陷") - user-lifecycle-test 改为自起临时实例(随机端口 + 自己的 outbox + 放宽限流) - persistence-restart 端口不再写死 3100(被同机另一项目占用→假红) - auth-mode-test 走真实邮箱验证,配额期待从 403 改为 402 - run-all/README 的套件计数与实跑对齐(Dart 416→598,标签长期漂移)
墨观 InkWatch — 全平台设备性能监控系统(V4.0 骨架实现)
按《全平台设备性能监控系统商业化实施方案 V4.0》落地的可运行骨架。目标不是功能完备,而是把 V4.0 的五条架构主线用代码固定下来:
- 职责下沉 —— 采集/保活/传输收敛为单一 Rust 核心(
rust-core/),经 FFI 供五端复用 - 网关无状态 —— 连接态、订阅关系、在线态全部外置 Redis(
server/gateway/) - 存储精简 —— PostgreSQL + TimescaleDB 承载元数据与时序(
deploy/init.sql) - 有界优先 —— 任何缓存/队列/集合都有明确上限(
store.js、buffer.rs、transport.rs、gateway.js) - 可靠性内置 —— 服务器时间权威、seq 去重补传、指数退避+抖动、优雅关闭、跨进程缓存失效
主线完成度:第 1 条已把 Rust 核心的采集 / 传输 / 保活三块补齐
(collector.rs / transport.rs / keepalive.rs),共 39 个 C ABI 导出符号
(nm -gU target/release/libpmon_core.dylib | grep -c _pmon_ = 39)、
59 项 Rust 单测、79 项真实加载产物的 FFI 冒烟。剩余未接入项集中列在文末「已知边界」。
目录结构
inkwatch/
├── README.md # 本文件
├── LICENSE # MIT
├── .env.example # 环境变量示例(真实 .env 不入库)
├── shared/
│ ├── metrics-schema.js # ★ 指标字段单一真相源(协议/Rust/表列/仓储全由它派生)
│ ├── protocol.js # 协议 v1:信封校验 / 白名单 / 版本兼容 / 编码协商
│ ├── codec.js # JSON / MessagePack 双模(含数值量化 + 结构指纹)
│ ├── accounts.js # 账号口径 / 令牌前缀 / 限流常量 / 口令强度
│ └── permissions.js # ★ 能力矩阵与档位表单一真相源(13 能力 × 3 角色 + ownerOnly)
├── server/
│ ├── gateway/ # 无状态 Socket.io 网关
│ │ ├── index.js # 入口:健康检查 / 优雅关闭 / 授权失效订阅
│ │ ├── gateway.js # 事件处理:hello / metrics / heartbeat / subscribe
│ │ └── store.js # Redis 访问层(定长缓冲 / 在线态 / 订阅 / 配对)
│ ├── business/index.js # 业务服务:账号 / 配对 / 授权 / 成员 / 配额 / 审计
│ └── shared/
│ ├── repository.js # PostgreSQL 仓储(参数化 SQL / 授权缓存 / 跨进程失效)
│ ├── mailer.js # 发信通道(outbox / console / smtp;令牌**绝不进 HTTP 响应**)
│ └── device-identity.js # "是不是同一台机"的唯一判据(hardware_id 优先 → 后缀 → 歧义不动)
├── rust-core/ # Rust 核心(采集 + 传输 + 保活,五端复用)
│ ├── src/collector.rs # 指标采集(CPU/内存/磁盘/网络/运行时长)+ 单位换算
│ ├── src/buffer.rs # 定长环形缓冲 + 指数退避(含单测)
│ ├── src/transport.rs # ★ 协议信封 / 连接状态机 / 心跳半开检测 / 断线补传
│ ├── src/keepalive.rs # ★ 保活三层模型 / 守护调度 / 五端自启产物生成
│ ├── src/ffi.rs # C ABI 导出(37 个符号,panic 兜底 / 空指针安全)
│ └── src/main.rs # 本地演示程序(8 段)
├── client/ # Flutter 客户端(采集端 + 查看端 + 水墨屏看板)
│ ├── lib/host/ # 宿主层:会话/配置/账号/角色/单位/采样
│ ├── lib/ui/ # 界面层(样式只许来自 ui/theme.dart,有护栏测试)
│ └── test/ # Dart 测试(含视觉令牌护栏与跨语言角色目录校验)
├── deploy/
│ ├── docker-compose.yml # 编排:nginx + gateway + business + redis + pg
│ ├── Dockerfile.gateway # 多阶段构建(ADR-001)
│ ├── Dockerfile.business
│ ├── nginx.conf # WSS 反代 / 长连接调优
│ └── init.sql # TimescaleDB 超表 / 保留策略 / 降采样(含增量迁移段)
└── tests/
├── schema-consistency-test.js # ★ 指标字段三层一致性 + 协议常量/Dart 目录跨语言校验 + init.sql 建表顺序(99 项)
├── uplink-msgpack-test.js # ★ 上行 msgpack 解包顺序契约(52 项)
├── dockerfile-coverage-test.js # ★ 镜像 COPY 覆盖性(按 require 图,45 项)
├── permissions-test.js # ★ 能力矩阵 13×3 全枚举 + ownerOnly + 失败关闭(29 项)
├── user-lifecycle-test.js # ★★ 用户机制端到端(66 项,自起临时实例)
├── ffi-smoke.py # ★ 真实加载 dylib 调 C ABI + 跨语言契约(79 项)
├── pairing-security-test.js # ★★ 配对授权绕过回归网(离线契约 23 / 全量 84 项)
├── rust-frame-pipeline-test.js # ★★ Rust 真实帧 → 白名单 → 时序表列(24 项,需起服务)
├── dump-rust-frame.py # 取一帧真实采集数据的辅助脚本(供上者调用)
├── protocol-test.js # 协议契约(34 项)
├── codec-test.js # 编解码正确性 + 体积收益(64 项)
├── e2e-smoke.js # 端到端冒烟(35 项)
├── viewer-e2e-test.js # ★★ 查看端端到端:真实采集端+配对+真实 Dart CLI(21 项)
├── account-test.js # ★★ 账号端到端:账号/归属/**免配对**/历史回溯/兼容模式(65 项)
├── auth-mode-test.js # ★★ 第二步开关:强制登录/配额/设备令牌防冒充(36 项)
├── persistence-restart.js # 持久化与重启恢复(24 项)
├── race-residue.js # 断连竞态残留(3 项)
└── leak-check.js # 泄漏专项(重连 500 次 / 内存红线,8 项)
★★
pairing-security-test.js是唯一以"No"型断言为主体的套件: 错误码必须被拒、重放必须被拒、串设备必须被拒、次数用尽必须失效、存储挂掉必须 503。 原因见下文「授权绕过」一节 —— 旧 e2e 只测成功路径,导致"完全不校验"与 "校验正确"在测试眼里毫无区别。
★★
rust-frame-pipeline-test.js是本仓库最重要的一道防线:其余套件用的都是手写样本载荷, 测不出"采集层字段名与协议白名单漂移"——这正是当年 P0 能全绿通过的原因。 它用 ctypes 调pmon_collect_frame()取真实帧,原样上报,再查库逐项比对。
★ 标记为本轮新增。
server/shared/keepalive.js已移除 —— 保活按方案 3.4 下沉至rust-core/src/keepalive.rs,原 JS 骨架是无人引用的重复真相源(五端方法体全是console.log)。如需恢复:git checkout HEAD -- server/shared/keepalive.js。
快速开始
方式 A:Docker 一键起(推荐)
cd deploy
docker compose up -d --build
docker compose ps # 5 个容器应全部 healthy
curl http://localhost/healthz # ok
默认仅 HTTP,开箱可用。启用 HTTPS 见 deploy/nginx.ssl.conf.example。
无 TimescaleDB 镜像时(国内拉取 1GB 级镜像可能极慢):init.sql 已做成
扩展可缺省,把 PG 换成普通的即可,表结构与指标列完全一致:
POSTGRES_IMAGE=postgres:15-alpine docker compose up -d --build
此时 init.sql 会打印 NOTICE 并跳过三项存储优化(不影响任何功能测试):
| 跳过项 | 影响 | 生产是否需要 |
|---|---|---|
create_hypertable 超表分区 |
无按时间自动分区/drop chunk | 需要(数据量大后) |
add_retention_policy 自动保留 |
需自行写清理任务 | 需要 |
| 连续聚合自动刷新 | device_metrics_10s 降级为普通物化视图,需手动 REFRESH |
需要 |
对外端口(Redis / PG 仅绑回环,供本机测试直连):
| 服务 | 端口 | 用途 |
|---|---|---|
| nginx | 80 | 接入层(/socket.io/ → 网关,/api/ → 业务) |
| gateway | 3000 | Socket.io 网关(调试直连用) |
| business | 4000 | 业务 REST(调试直连用) |
| postgres / redis | 5432 / 6379 | 仅 127.0.0.1 绑定 |
凭据经
.env注入,默认值为本地开发用。生产务必覆盖POSTGRES_PASSWORD。
想一个容器跑全部? 单容器路径(postgres + redis + gateway + business 四进程一个容器,supervisord 各自守护;数据卷同名,与四容器随时互切、 数据不动):
cd deploy
docker compose -f docker-compose.all.yml up -d --build
docker exec pmon-app-1 supervisorctl status # 四进程 RUNNING
取舍(单容器是单机运维形态):进程隔离弱于四容器、容器重启四进程齐动、 升级要重建整镜像;好处是常驻容器从四个变成一个,数据卷同名可随时互切。
方式 B:本地开发(不走容器)
docker run -d --name pmon-redis-dev -p 6379:6379 redis:7.4-alpine \
redis-server --save "" --appendonly no
docker run -d --name pmon-pg-dev -p 5432:5432 \
-e POSTGRES_USER=pmon -e POSTGRES_PASSWORD=pmon_dev_only -e POSTGRES_DB=pmon \
timescale/timescaledb:2.17.2-pg16
# 初始化表结构
docker exec -i pmon-pg-dev psql -U pmon -d pmon < deploy/init.sql
启动服务
REDIS_URL=redis://127.0.0.1:6379
PG_URL=postgres://pmon:pmon_dev_only@127.0.0.1:5432/pmon
# 网关(端口 3000)—— 必须带 --expose-gc 才能周期回收堆
cd server/gateway && npm install && \
PORT=3000 REDIS_URL=$REDIS_URL DATABASE_URL=$PG_URL \
node --expose-gc --max-old-space-size=256 index.js
# 业务服务(端口 4000)—— 需 REDIS_URL 才能发配对码与广播授权失效
# 缺 REDIS_URL 时配对接口会失败关闭(/health 报 degraded、配对返回 503),
# 这是有意为之:配对码是一次性凭据,不接受"无共享存储也能跑"的降级
cd server/business && npm install && \
PORT=4000 REDIS_URL=$REDIS_URL DATABASE_URL=$PG_URL node index.js
验证
cd rust-core && cargo test --release # Rust 核心单测(59 项)
cd tests && npm install
bash run-all.sh # 一把梭:25 个套件(离线即可跑 + 需起服务)
# 离线套件(无需任何外部依赖)
node dockerfile-coverage-test.js # ★ 镜像 COPY 覆盖性(按 require 图)
node uplink-msgpack-test.js # ★ 上行 msgpack 解包顺序契约
node schema-consistency-test.js # ★ 指标字段三层一致性 + 协议常量跨语言校验
node protocol-test.js # 协议契约
node codec-test.js # 编解码 + 体积
node pairing-security-test.js --offline-only # ★ 配对契约与判定顺序(源码级)
node permissions-test.js # ★ 能力矩阵 13×3 全枚举 + ownerOnly + 三条失败关闭
node user-lifecycle-test.js # ★★ 用户机制端到端(自起临时实例,只需 PG + Redis)
python3 ffi-smoke.py # ★ 真实加载 dylib 调 C ABI
# 客户端(Dart):查看端逻辑 48 + 查看端界面 17 + 配对卡界面 6 + 自适应单位 24 + FFI 冒烟 28
cd ../client && bash scripts/test.sh # 入口脚本已负责清代理变量(否则本地 WS 会假失败)
# 需起服务(Redis 6379 + PG 5432 + 网关 3000 + 业务 4000)
node pairing-security-test.js # ★★ 配对授权绕过回归网(含 D 段 HTTP 端到端)
node viewer-e2e-test.js # ★★ 查看端端到端(真实 Dart CLI 订阅真实采集端)
node account-test.js # ★★ 账号体系(注册/登录/归属/免配对/向后兼容)
node auth-mode-test.js # ★★ 安全开关(就地拉起严格模式临时实例,跑完即停)
# 用户机制(user-lifecycle-test.js)已在上面的离线区:它自带实例,不依赖这两个服务
# 查看端 CLI 单独跑(真人可读输出):
# cd ../client && dart run bin/pmon_viewer.dart --account acct_demo --device <id> --seconds 8
node rust-frame-pipeline-test.js # ★★ Rust 真实帧全链路落库断言
node e2e-smoke.js # 端到端冒烟
node persistence-restart.js # 持久化与重启恢复(自带启停网关)
node race-residue.js # 断连竞态残留
ROUNDS=500 node leak-check.js # 泄漏专项(约 80s)
node perf-bench.js # ★ 性能基准(动网关热路径前后各跑一次,数字本地存档)
⚠️ 改过
shared/metrics-schema.js后必须重启网关:白名单在进程启动时载入并缓存, 只改文件不重启会让"新代码 + 旧白名单"并存 —— 这正是全链路测试会报红的场景。
ffi-smoke.py 需要能 import ctypes 的 Python 3;产物缺失时它会自动尝试
cargo build --release。指定解释器:PMON_PYTHON=/path/to/python3 bash run-all.sh。
已验证结果
| 验证项 | 结果 |
|---|---|
| 指标字段一致性(三层命名 + 协议常量 + Dart 显示目录跨语言 + init.sql 建表顺序 + 能力矩阵跨语言 + init/migrate 列类型) | 116 / 116 通过 |
| 上行 msgpack 链路(解包顺序 / 两形态 / 错误契约) | 52 / 52 通过 |
| 镜像构建覆盖性(按 require 图 × 2 镜像 + 安全开关通路 + 单容器端口回环) | 45 / 45 通过 |
| FFI 冒烟(真实加载 dylib + 跨语言契约 + 空指针 + 反复建销) | 79 / 79 通过 |
| Rust 核心单测(采集/缓冲/传输/保活/FFI 安全) | 61 / 61 通过 |
| 协议契约(白名单/版本/数值规整/协商) | 34 / 34 通过 |
| 编解码(正确性 + 边界 + 体积) | 64 / 64 通过 |
| 配对码安全(错误码/重放/串设备/次数用尽/限流/失败关闭/审计) | 84 / 84 通过(含离线契约 23 项) |
| 真实帧全链路(Rust 采集 → 白名单 → 时序表列) | 24 / 24 通过 |
| 端到端冒烟(闭环/授权/降级/清理) | 35 / 35 通过 |
| 账号体系(注册/登录/登出/归属/认领/免配对订阅/历史回溯/兼容模式) | 74 / 74 通过 |
权限矩阵(13 能力 × 3 角色全枚举 / ownerOnly 授权≠夺权 / 三条失败关闭 / 旧新入口一致) |
29 / 29 通过 |
| 用户机制端到端(注册验证 / 会话踢人 / 改密 / 找回防枚举 / 邀请与角色 / 转移归属 / 配额真拦 / 注销冷静期 / 审计) | 66 / 66 通过 |
| 安全开关(强制登录 / 配额上限(402)/ 邮箱验证后按档位放开 / 令牌轮换与防冒充 / 全新设备首次接入 / 上行通道不得绕过令牌) | 39 / 39 通过 |
| 客户端 Dart 测试(查看端逻辑+界面 / 配对卡 / 账号与角色目录 / 单位自适应 / 格式化唯一出口 / 配置变更 / 视觉令牌护栏 / 机型采集 / 连接域 / 中继 / 采样调度 / 历史 / 成员卡) | 598 / 598 通过 |
| 查看端端到端(真实采集端 + 配对 + 真实 CLI 查看端) | 21 / 21 通过 |
| 持久化与重启恢复 | 24 / 24 通过 |
| 断连竞态残留(100 轮零等待重连) | 3 / 3 通过 |
| 泄漏专项(重连 500 次,GC 后稳态) | 8 / 8 通过;稳态 heap_used 0.0% / −12.5%,会话与订阅零残留 |
| 镜像构建 + 容器内模块解析 | 两镜像均构建成功并在容器内 require() 成功 |
| Docker 全栈实跑(nginx+gateway+business+redis+postgres 全容器化) | 34 套件 / 0 失败,容器日志零 error |
全量口径:bash tests/run-all.sh → 34 套件 / 1838 项断言全通过 / 0 跳过
(另有安装器 e2e 38 项因缺采集端可执行文件明确跳过、未计入 —— 它打印 ⚠ 并说明原因,
不冒充通过。配对安全套件的离线契约段与其全量段有 23 项重叠,去重后为 1815 项唯一断言)。
上表的服务端套件已在 Docker 全栈(5 容器 healthy、经 nginx :80 入口)上复跑通过,
非仅本机进程直跑。
真实帧实测值(rust-frame-pipeline-test.js 打印,本机 M3 Max):
cpu_usage=30.73 mem_usage=36.41 mem_used_mb=47721.27
disk_free_gb=3251.79 net_rx_mbps=4.55 net_tx_mbps=1.71 uptime_s=106001
关键不变量(已被测试锁定):
- 环形缓冲任何时刻
len <= capacity(Redis 定长 60,Rust 侧默认 120) - 退避延迟严格封顶于
max_ms(抖动只向下取,不突破上限) - 未声明字段一律丢弃(
screen_content等敏感键无法穿透) - 连接断开后订阅集合与在线态零残留,含
await期间断连的竞态场景 - 上行
ts一律由服务端改写(服务器时间权威) - 授权关系与指标重启后不丢(PG 持久化)
- 断线补传同一
seq不产生重复行(有界滚动窗口去重) - ★ 采集帧全部 17 个权威指标字段一个都不被白名单丢弃
- ★ Rust
MetricsFrame字段集合 ≡ JS 权威清单 ≡ 时序表指标列(三处集合相等) - ★ Rust 与 JS 的协议版本、事件集合、时间常量逐项相等(由产物实测比对)
- ★ 重连退避跨 12 次连续失败单调不减且次次 ≤ 60s;连上即归零
- ★ 半开检测:静默 30s 转黄、90s 转灰、任意入站消息即复活
- ★ 补传窗口溢出必须可观测(
dropped计数),不允许静默吞掉 - ★ 用户主动退出后守护永不重启;连续崩溃达上限即放弃(防崩溃循环)
- ★ iOS 平台不生成任何自启伪产物(返回明确错误)
- ★ 网关入站必须先解包后校验(源码契约 + 真实形态数据穿透白名单,双重锁定)
- ★ 解包失败一律显式报错,绝不退化成空对象(未知指纹 / 损坏载荷各有专属错误码)
- ★ 两种 msgpack 包裹形态(整帧 / 仅 payload)都能还原,且 JSON 默认路径不受影响
- ★ 镜像内每个被
require到的文件都必须被某条 COPY 规则覆盖(按 require 图强制) - ★ 每条 COPY 规则必须指向真实存在的文件(防止删文件后漏改 Dockerfile)
- ★★ Rust 产出的真实帧整帧 17 字段都能穿过白名单并落进对应时序列,数值逐项一致
- ★★ 帧中为
null的指标(平台不可得的增强项)落库必须为NULL,不得编造 0 - ★ 能力矩阵 JS ↔ Dart 双向相等(13 能力 × 3 角色 +
ownerOnly集合 + 档位表) - ★ 授权 ≠ 夺权:被授予
owner角色的成员仍不能转移归属 / 删除设备 - ★ 一次性凭据只存哈希、原子消费(
UPDATE ... WHERE used_at IS NULL RETURNING); 重放、跨账号使用、换人代接受邀请一律拒绝 - ★ 找回口令对存在与不存在的邮箱返回完全相同的键(防账号枚举)
- ★ 任何响应体都不得出现令牌本体(
pm_rst_/pm_vfy_/pm_inv_前缀一律不出现) - ★ 配额用尽返回 402 而非 403,且 body 带
used / quota / plan - ★ 邮箱未验证只能绑 1 台设备;验证后按档位放开(验证本身零成本)
- ★ 注销有 30 天冷静期:期内登录即撤销注销,期满才由清理逻辑真正删除
三条实测工程结论
① MessagePack 的收益前提是「先量化」
原文预估 msgpack 可省 40-60% 带宽,实测证伪:性能指标几乎全是浮点数, JSON
"37.5"占 4-5 字节,而 msgpackfloat64固定 9 字节 —— 裸 msgpack 反而膨胀, 单帧实测 −1.5% ~ +0.6%。修正方案:浮点先量化为定点整数(×100,两位小数)再走 msgpack。 实测收益(
codec-test.js可复现):
场景 JSON 裸 msgpack 量化载荷 稳态上行 单帧 405B — −1.5% 24.2% 15.6% 10 帧 — −0.2% 24.7% 23.9% 60 帧 — −0.3% 24.6% 24.4% 另:量化把
78.125舍成78.13—— 性能指标约定两位小数,这是设计约束而非缺陷。
② 量化标记必须「按位置」而非「按数值类型」
早期实现靠"是否为浮点"决定是否 ×100,触发三类静默误缩放: 混类型数组
[78.13, 91]中整数被乘、纯整数数组[1,2,3]膨胀成[100,200,300]、 同名字段先浮点后整数时后者被多除一次。正确做法:路径一旦登记,该路径下所有数值一律量化/还原,与数值类型完全解耦。
③ 泄漏判定必须用 GC 后稳态 heap_used,绝不可用 RSS
高频连接建立/销毁会让
heap_used短暂冲高(实测 +46%),但待 GC 周期触发后回落至基线以下。 V8 的回收是延迟惰性的,RSS中含大量"已分配但空置"的预留空间。 本项目早期版本正因在中间态取 RSS 而误判为泄漏(当时得 20.5%/53.4%)。 落地要求:网关以--expose-gc --max-old-space-size=256启动并每 30s 周期回收。详见方案第 12.6 节。
分布式一致性与并发正确性的三个坑(均已修复并锁定)
坑 1:授权缓存无法跨进程失效
网关与业务服务是两个进程,各自持有进程内授权缓存。
业务服务 grantAccess 只能清自己的缓存,网关在 TTL 内仍返回旧结果 —— 用户"刚授权却看不到"。
修法:授权变更后经 Redis pub/sub 广播失效(pmon:auth:invalidate),
网关订阅该频道并清本地缓存。TTL 从 5s 降到 2s 仅作兜底。
e2e-smoke.js 步骤 [3] 专门验证「授权后立即订阅即成功,未等 TTL」。
坑 2:msgpack 结构指纹表是「每进程独立」的
编码端首次下发完整路径清单、之后只发 8 字节指纹。但每个订阅端都是独立进程, 各自指纹表初始为空 —— 编码端一旦进入稳态只发指纹,新订阅端永远解不开。
修法:新订阅端接入时,服务端为该订阅端单独补发一帧带完整清单的首帧(forcePaths),
此后并入稳态流;解码端遇到未知指纹返回 UNKNOWN_PATH_FP 错误对象(不抛异常、不断连)。
坑 3(严重):await 期间的断连竞态导致 Redis 状态永久残留
onHello 在 touchPresence 之前有 await registerDevice()(PG 往返)。
若客户端在此期间断开,onDisconnect 会先执行完毕 —— 而那一刻 session.deviceId
尚未赋值(或 subscribed 还是空),于是清理逻辑被跳过;随后 hello 处理恢复执行,
把在线态写了进去,再也没人清理。
实测影响:50 轮「hello 后立即断开」残留 47 个在线态条目(94% 泄漏率)。 真实场景下,移动网络抖动、客户端进程重启都会触发高频短连接 —— 这会持续污染 Redis。
修法:session 增加 gone 标记,onDisconnect 置位;onHello / onSubscribe
在每个 await 之后复检 session.gone || socket.disconnected,已断开则跳过状态写入。
race-residue.js 以 100 轮零等待重连锁定该不变量。
容器化部署的两个坑(均已修复)
坑 A:network_mode: host 会让服务名解析全部失效
网关若用 network_mode: host,就脱离了 compose 桥接网络,
REDIS_URL=redis://redis:6379 里的 redis 无人解析,日志刷屏:
[redis] error: getaddrinfo ENOTFOUND redis
宿主机视角下没有名为 redis 的 DNS 记录,只有 compose 内网才有。
修法:用默认桥接网络 + ports 映射;容器间用服务名通信,
nginx.conf 的 upstream 也从 127.0.0.1:3000 改为 gateway:3000。
(host 网络确实能省一层 NAT,但与 compose 服务发现互斥,单机也要二选一。)
坑 B:nginx 强依赖证书导致「clone 下来必崩」
certs/ 被 .gitignore 排除(私钥不能入库),但 nginx.conf 硬引用
/etc/nginx/certs/fullchain.pem —— 容器进入 Restarting 死循环:
[emerg] cannot load certificate "/etc/nginx/certs/fullchain.pem"
修法:默认配置改为 HTTP-only 开箱可用,HTTPS 独立为
deploy/nginx.ssl.conf.example;.gitignore 改为 certs/* + !certs/.gitkeep,
保留挂载点目录而不放行内容。
职责下沉补完:传输与保活(本轮落地)
方案 3.2 表 3 把「采集 / 保活 / 传输」三件事划给 Rust 核心,但骨架期只落了两件半:
采集已下沉,传输与保活仍是空的(server/shared/keepalive.js 五端方法体全是 console.log,
且无人引用)。本轮按方案 3.4 修订项补齐。
ADR-002:Rust 核心不做 socket I/O
transport.rs 只实现状态机,不持有任何 socket。理由是方案 3.2 要求的四件事
(协议编解码、重连退避、心跳、断线缓存补传)没有一件需要 socket:
| 若在 Rust 内置网络 reactor | 只做状态机 |
|---|---|
| 每个端被塞进一套异步运行时 | 产物仍是纯静态库,无运行时绑定 |
| 与「核心常驻 < 30 MB」硬指标冲突 | 内存占用可预测 |
| socket 生命周期归 Rust,无法与 UI 生命周期绑定 | 宿主(Dart)持 socket,随 UI 启停 |
| 状态机难以单测(要起真实连接) | 纯函数式,57 项单测离线可跑 |
接口形态:宿主把事实喂进来(on_connected / on_disconnected / on_rx / on_ack /
on_network_available),状态机吐出该做什么(next_action() → 发心跳 / 立即重连 /
N 毫秒后重连 / 补传这些帧)。
保活下沉的落地形态
keepalive.rs 生成真实的平台产物内容,而不是占位注释:
| 平台 | 生成物 | 关键语义 |
|---|---|---|
| macOS | LaunchAgent .plist + launchctl 装卸命令 |
KeepAlive.SuccessfulExit=false —— 系统层面只对崩溃重启,用户主动退出不重启 |
| Linux | systemd user unit + systemctl 装卸命令 |
Restart=on-failure + StartLimitIntervalSec/Burst —— 崩溃循环有界;含 MemoryMax=5M 与 enable-linger 提示 |
| Windows | 注册表 Run 项 + 计划任务兜底 |
注册表项不提供崩溃重启能力,故必须另建计划任务 |
| Android | AndroidManifest.xml 片段 + 电池优化引导 |
自启是构建期声明,运行期无法动态注册;附各厂商引导路径 |
| iOS | 明确返回错误,不生成任何产物 | 系统策略硬限制,虚构自启配置只会让客户端误判能力 |
守护层(方案 8.3)落在 Rust 而非各端:检查间隔 3000ms / 验收期限 5000ms(方案 8.5 要求强杀后 5 秒内拉起)、崩溃退避 1s→2s→…→封顶 60s、连续失败达上限即放弃、 用户主动退出置位后永不重启。
静默丢数据:指标字段三层命名分裂(已修复并锁定)
本轮在动传输模块之前做了一轮核对,发现一处不报错、不打日志、但链路实际断掉的缺陷。
现象
同一批指标在四处各写了一套命名:
| 位置 | CPU | 内存占用 | 网络 |
|---|---|---|---|
rust-core/src/collector.rs(采集) |
cpu_total |
mem_used_pct |
net_tx_bps(字节/秒) |
shared/protocol.js(白名单) |
cpu_usage |
mem_usage |
net_tx_mbps(Mbps) |
deploy/init.sql(表列) |
cpu_usage |
mem_usage |
net_tx_mbps |
server/shared/repository.js(写入) |
cpu_usage |
mem_usage |
net_rx_mbps |
三处交集只有 cpu_cores / mem_used_mb / mem_total_mb 三个字段。
后果
真实采集帧进入 sanitizePayload() 后,CPU 使用率、内存占用、磁盘空间、网络速率
全部被判为"未声明字段"静默丢弃;device_metrics.cpu_usage 等列恒为 NULL。
协议测试、编解码测试、端到端测试全是绿的 —— 因为它们各自用自己那套字段名的样本,
没有一处校验过"采集端的真实字段能否穿透白名单"。
单位也在漂移:Rust 给的是字节/秒,表列口径是 Mbps,中间少一次换算(差 8 个数量级)。
修法
- 建立
shared/metrics-schema.js作为单一真相源,四处全部由它派生: 协议白名单、Rust 结构体字段名、表列、仓储 INSERT 列清单。 - 网络速率统一按 Mbps 口径,Rust 侧补
* 8 / 10^6换算。 - 补齐表结构缺列(
mem_used_mb/mem_total_mb/disk_free_gb/disk_total_gb/disk_write_mbps/battery_temp)与降采样视图,并附可重复执行的增量迁移段。 - 新增
tests/schema-consistency-test.js(48 项):解析 Rust 源码、SQL 原文与 JS 清单做集合相等校验;再用一个"17 字段全有值"的样本帧过一遍白名单, 断言一个字段都不丢 —— 把这类缺陷从"线上静默"变成"提交时红灯"。 - 新增
tests/ffi-smoke.py(79 项):真实加载编译产物,调pmon_metric_keys_json()与pmon_protocol_constants_json()与 JS 侧事实比对,锁住跨语言契约。 - 新增
tests/rust-frame-pipeline-test.js(24 项):用 Rust 核心真产出的那一帧 跑完整链路并查库逐项比对 —— 手写样本永远测不出这类漂移,这是唯一能兜住的防线。
教训:协议测试全绿 ≠ 链路通。凡是"多层各自持有一份清单"的地方,都必须有一个 用真实形态数据端到端穿透的测试,否则漂移是静默的。
协议声明与实现不一致:msgpack 上行从未解码(已修复并锁定)
现象
方案 5.5 规定 agent:metrics 高频上行走 MessagePack,网关也在 server:config 里
回告客户端 encoding: "msgpack"。但网关从来没有解码过上行:
socket.on('agent:metrics', (raw) => this.safe(socket, () => this.onMetrics(socket, raw)));
// onMetrics 第一行就是 P.validateEnvelope(raw, now) —— 没有任何解码步骤
于是两种包裹形态各有各的坏法:
| 客户端写法 | 后果 |
|---|---|
整帧包裹 codec.encode(envelope) |
缺 v 字段 → 被 E_BAD_VERSION 拒(有声音,但链路完全不可用) |
仅载荷包裹 {...env, payload: encode(payload)} |
信封完整 → 校验"通过",而 __mp/__q 不在白名单 → 指标被静默清空 |
第二种是最危险的:服务端不报错、返回正常,客户端自以为按指示启用了 msgpack,数据却全是空的。
修法
shared/codec.js新增unwrapInbound()/unwrapDownlinkMetrics(),把线格式的判定 集中到一处(调用方不要自己判__mp),并明确记录两种包裹形态。- 网关的事件绑定改为统一封装
bind():先解包 → 再校验 → 才进 handler, 四个上行事件全部走同一入口,不留裸socket.on。 - 解包失败必须显式报错,绝不允许退化成空对象:未知指纹 →
E_UNKNOWN_PATH_FP(新增错误码,提示带完整清单重发或退回 JSON);载荷损坏 →E_BAD_ENVELOPE。 tests/e2e-smoke.js的下行取值改用codec.unwrapDownlinkMetrics()—— 原先它自己判payload.metrics.__mp,与网关"整帧包裹"的实际写法不一致,属于测试与实现各持一套契约。- 新增
tests/uplink-msgpack-test.js(52 项):含反例断言(证明顺序颠倒真的会丢数据)、 两形态还原、错误契约、下行取值三形态、以及网关源码契约(unwrapInbound必须先于handler)。
授权绕过:配对码从不校验(已修复并锁定)
四类事故里唯一一个安全类,也是唯一一个"测试全绿但功能完全没实现"的事故。
现象
POST /api/pairing/verify {"code":"000000","device_id":"mac-autostart-test"} →
{"ok":true,"authorized":true}。任意 6 位数字、任意 device_id,直接拿到该设备的订阅权限。
根因:业务服务的 verify 只做了 if (!/^\d{6}$/.test(code)) return 400,
从未比对配对码本身;/api/pairing/code 生成后也只 res.json({code}) 返回,从未写入存储。
而网关 server/gateway/store.js 里恰好躺着一套完整的
setPairingCode / consumePairingAttempt / checkPairingRateLimit(键名、TTL、
尝试次数、限流一应俱全)——全仓库无人调用。
于是同一份契约两处实现、交集为空:一处只生成不存储,一处能存储但没人用。 这与指标字段分裂、msgpack 线格式、Dockerfile COPY 是同一个工程缺陷的第四次复发: 多层共享的契约,被多处各写一套。
为何测试全绿
tests/e2e-smoke.js 的第 3 步硬编码 code: '000000' 去换授权 —— 它验证的是
"授权后能否订阅"(缓存失效广播),从不提交一个错误码。
在只测正向的测试眼里,"完全不校验"与"校验正确"的行为完全一致。
推论:任何鉴权/校验逻辑,若测试里没有一条"No"型断言(错误凭据必须被拒), 这条逻辑就等于没有测试覆盖。
修法
- 新建
shared/pairing.js作为唯一真相源:键名(pairing:{code}/pairing:device:{id}/ratelimit:pairing:acct|ip:*)、TTL 300 秒、 失败上限 5 次、账号限流 10 次/小时、IP 限流 30 次/小时全部在此定义, 网关与业务服务一律派生,禁止再硬编码。 - 授权判定链收敛为一处,顺序即安全语义:
参数 → 格式 → 存储可用性(失败关闭) → IP 限流 → 账号限流 →
比对配对码(一次性消费) → 才
grantAccess。 授权调用排在拒绝分支之后,并由源码结构断言锁死(删掉比对就会红)。 - 一次性消费用 Redis
MULTI(GET+DEL)原子取走:并发提交同一码时恰好一次成功, 不留"先查后删"之间的重放窗口(C 段断言)。 - 失败也计数:旧限流统计的是"审计日志里成功记录数",而成功才写审计 → 计数恒 0, 10 次/小时的约束形同虚设。现改为 Redis 计数进入即计,失败尝试同样消耗额度。
- 删除
store.js里的重复实现,只留注释说明为何删除(保留事故记录)。 - 存储不可用时失败关闭返回 503:宁可配对整体不可用,也不出现"谁都能授权"的窗口。
/health的checks.pairing如实反映,避免编排层以为健康而继续放流量。
一个被方案原文掩盖的语义细节
「单个配对码验证失败 5 次即作废」的扣次对象必须是目标设备当前展示的那个码。 最初实现扣的是"提交上来的那个码",但攻击者猜的码在 Redis 里根本不存在 (每个猜测值都是一条独立记录)→ 扣空气 → 5 次作废完全失效 (本套件 D19 当场报红才发现)。代价是攻击者可连猜 5 次作废用户屏幕上的码 (轻量 DoS,重新生成即可),真正的护栏是账号级与 IP 级限流。
失败关闭有三种形态,都会静默放行(逐一实测并堵住)
「Redis 挂了就 503」这句话掩盖了三种完全不同的故障形态,只堵第一种是不够的:
| 形态 | 现象 | 只做基础判断的后果 | 处置 |
|---|---|---|---|
| ① 未配置 / 连接失败 | pairingStore === null |
曾经直接跳过校验 → 任意码通过 | 启动即判空 → 503 |
| ② 连接被断(socket 关闭) | node-redis 默认把命令塞进离线队列等重连 | 请求挂起,既不拒也不通过 → 接口假死;并发一上来连接被占满 | 客户端加 disableOfflineQueue: true,断连即失败 → 503 |
| ③ 连接在但对面不应答(容器 pause / 网络黑洞) | TCP 仍是 ESTABLISHED,离线队列机制完全不适用,命令无限期挂起 | 同上,且永远不会自愈 | 存储层自带 1.5 秒操作上限 → 503 |
第③种是最隐蔽的:它既不是"连接失败"也不是"连接断开",写惯了
try { redis.get() } catch { 503 } 的人会以为已经覆盖了。
实测(docker pause pmon-redis-1):
Redis 卡住 → HTTP 503 用时 1.5156s ← 有界失败关闭,不是挂到客户端超时
恢复后 → HTTP 400 用时 0.0071s ← 7ms 自愈,无需重启服务
通用推论:任何安全判定链路上的外部依赖,都必须同时回答三个问题 —— 没配上时怎么办、断开时怎么办、卡住时怎么办。答案必须是同一个:明确拒绝。
实测证据
| 项目 | 结果 |
|---|---|
| 修复前跑新套件(变异验证) | 20 项红,并当场复现 wrong code → {"ok":true,"authorized":true} |
| 修复后 | 84 / 84 绿,连续两轮重复运行同样绿(限流键自清理) |
| 端到端冒烟 | 由 24 项增至 27 项(新增"生成真码 / 错码被拒 / 错码未授权") |
| 无 REDIS_URL 直起业务服务 | /health → 503 degraded(checks.pairing: unavailable);提交配对码 → 503 E_PAIRING_UNAVAILABLE,/api/auth 查授权为空 |
| Redis 容器 pause 4 秒 | 提交配对码 → 503,1.516s(失败关闭且等待有上界);unpause 后 → 400,7ms(自愈) |
Redis 连接被 CLIENT KILL |
命令不再排队等重连,立刻失败 → 503 |
新增 tests/pairing-security-test.js(84 项,5 段):A 源码契约 23 项(离线可跑)、
B 存储语义 30 项、C 并发双花 2 项、D HTTP 端到端 24 项、E 审计留痕 5 项。
账号与设备归属:同账号的设备免配对(方案 6.4 第一步,已落地)
为什么做这个
在此之前,"看设备"只有一条路:别人给你配对码。但自己的设备本该直接可见 —— 这正是方案 6.4「账号与计费体系」的内容,而库里其实早就躺着半套骨架:
| 已有 | 当时的状态 |
|---|---|
users(id, email UNIQUE, display_name, plan) |
表建好了,业务层从未写入过 |
devices.owner_id → users(id) |
字段与外键都在,从未写入过 |
device_grants.account_id 是 TEXT |
存的是客户端自报的 acct_xxx |
于是有两套互不相通的身份空间:设备归属走 BIGINT→users,授权走 TEXT 自报串。
改法:把身份口径统一成一处
登录身份 = String(users.id),设备归属与配对授权都用它。权限规则收敛为一条:
可看 = 设备归属我(devices.owner_id == 我) 或 存在 device_grants(device_id, 我)
前半句就是「同账号的设备直接记录」——免配对;后半句保留跨账号配对授权。
规则本体是 shared/accounts.js 的 canViewDevice()(纯函数、有单测),
repository.canView() 只负责取数据,业务服务与网关共用同一份判定 ——
避免"权限规则"在两处各写一份(本仓库已因清单分裂翻车五次)。
三条失败关闭纪律(都不是可选项)
- 带了令牌但无效 → 明确拒绝,绝不降级成匿名。
否则任何人发一个废令牌就能把认证关掉 —— 那是"失败开放",
与配对码的失败关闭三形态同一类问题。有测试专门钉住
(假令牌订阅必须回
E_UNAUTHORIZED,不是"当成匿名")。 - 认领必须登录:兼容模式的"身份"是自报字符串,无法证明设备归属,
若允许它认领,知道
device_id就能抢设备(那就是第二次授权绕过)。 - 账号功能必须有数据库:内存模式下账号端点直接 503, 而不是"另写一套内存实现" —— 两套实现必然漂移(本仓库的第五次教训仍记忆犹新)。
与方案原文的一处有意偏离(别当成漏做)
方案写的是「短效 JWT(15 分钟)+ 刷新令牌轮换」,同时要求设备令牌"云端可随时吊销"。 JWT 是无状态的、天然难吊销(要靠黑名单或极短 TTL 兜),而本项目当前规模 (V1 ≤10 用户、单机)并不需要无状态校验,反而更需要"点一下就失效"。故实现为:
- opaque 令牌存库(只存 sha256 哈希)+ 服务端可吊销 + 滑动过期(7 天)
- 吊销语义:标记一行即失效,无需等 TTL、无需黑名单;脱库也拿不到可用令牌
- 设备令牌(
pm_dev_)与查看会话(pm_sess_)分离,各自可吊销
实质要求(短效可轮换、与设备令牌分离、云端可吊销)全部满足。
限流分三档(不要合成一个)
| 桶 | 粒度 | 默认 | 为什么这样分 |
|---|---|---|---|
| 账号 | 邮箱 | 10 次/小时 | 挡针对某个账号的爆破 |
| 登录 IP | 来源 IP | 50 次/小时 | NAT 后合法用户多,桶太紧只误伤好人;攻击者换 IP 就绕过了 |
| 注册 IP | 来源 IP | 20 次/小时 | 挡批量注册 |
口令用 crypto.scrypt(N=2^15, r=8)——零新依赖,且必须显式给 maxmem:
Node 默认上限 32MB 而该参数需 33.5MB,不给就直接抛 ERR_CRYPTO_INVALID_SCRYPT_PARAMS
(实测踩过,报错发生在登录路径上很容易被误判成网络问题)。
实测数字
| 项目 | 结果 |
|---|---|
| 账号端到端套件 | 51 / 51 通过(注册/登录/登出、归属、认领、免配对订阅、兼容模式、审计留痕) |
| 关键反向断言 | 假令牌被拒(401/E_UNAUTHORIZED)、账号 B 看不到账号 A 的设备、抢他人设备 409、登出后订阅立即失效 |
| 兼容性 | 旧客户端(不带令牌)+ 配对码流程行为不变;pairing-security-test 84 项、e2e-smoke 35 项均未回归 |
| 客户端 | 账号卡(登录/注册/登出)+ 设备列表关系徽标(我的 / 他人授权)+ 采集页「认领本机」 |
第二步:安全开关(已落地,默认全关,改环境变量即切换)
强制认证与配额限制都是破坏性变更(现网还有旧客户端在不带令牌跑),故做成开关, 把"切换时机"交给运维 —— 改环境变量重启即可,不必改代码重新评审:
| 开关 | 作用 | 打开后 |
|---|---|---|
REQUIRE_AUTH=1 |
强制登录 | 业务服务与网关都拒收无令牌请求(明确 401,不是降级放行);同时关闭自助注册(要开另设 ALLOW_OPEN_REGISTER=1) |
ENFORCE_DEVICE_QUOTA=1 |
档位配额 | 注册/认领设备超过档位上限(免费 3 / 专业 10 / 团队 30)→ 403 并说明怎么处置 |
REQUIRE_DEVICE_TOKEN=1 |
采集端必须带设备令牌 | 无令牌的 agent:hello 直接拒绝 |
两个服务的 /health 都会报 auth_mode: compat|required,运维一眼可辨当前模式。
设备令牌:堵住 device_id 冒充(这一步的真正价值)
账号一出现,device_id 就会出现在别人的设备列表里(我的设备页能看到自己的 id)。
而 agent:hello 只要报个 id 就能注册为那台设备 —— 也就是说,
任何人拿你的 device_id 发几帧 hello+metrics,就能往你的账号名下灌假数据、污染历史。
故引入设备令牌(方案 6.4 本来就要求"采集端独立设备令牌,可吊销与轮换"):
- 签发:
POST /api/devices/:id/token,只允许设备归属者本人签发 (否则拿别人 device_id 就能领令牌,机制形同虚设) - 校验:
agent:hello带device_token→ 必须存在、未吊销、且令牌绑定的 device_id 与本次声明一致;不一致直接拒绝 - 轮换:再次签发会吊销该设备全部旧令牌,旧采集端随即被识别为失效
- 客户端:认领成功后自动签发并注入采集端(立即重连生效),令牌存配置文件的
device_token
实测(auth-mode-test.js 就地拉起严格模式实例):用本机令牌冒名其它 device_id
被拒;轮换后旧令牌失效、新令牌可用。
但这把锁把钥匙也锁在了门里(全新设备死锁,2026-09-15 首装暴露)
开启 REQUIRE_DEVICE_TOKEN=1 后,全新设备永远进不来 —— 三件事互为前提:
| 环节 | 依赖 |
|---|---|
POST /api/devices/claim |
要求 devices 表里已有该设备 |
| 设备行入库 | 只发生在网关 onHello → registerDevice |
onHello |
无令牌一律拒,且在入库之前就 return |
| 设备令牌 | 只能由已归属设备签发(/api/devices/:id/token) |
真机上表现为:点「认领到当前账号」得到「服务端没有这台设备,请确认设备已开始采集」,
而设备明明正在采集(devices 表里 0 行,users 表里有账号)。
修法:认领即注册(claim 时设备不在库就登记,owner = 当前登录用户)。
这比"网关放行未认领 hello"更安全 —— 建设备行的权限从"任何网络客户端"收紧到"已登录用户";
归属冲突仍由 claimDevice 的 SQL 兜底,且 registerDevice 的 upsert 不碰 owner_id。
为什么没被测出来:account-test.js 里原本写着「认领不存在的设备 → 404」——
测试不但没拦,还把这个死锁钉成了期望行为(因为本地一直是兼容模式、设备早就注册过)。
现该断言改为"认领全新设备成功",并在 auth-mode-test.js 新增 [E] 段在严格模式下
跑完整链路(未认领被拒 → 认领即注册 → 签发令牌 → 带令牌 hello 被接受)。
更严重的一处:令牌只守了握手,指标通道完全裸奔(同日修复)
上线后发现一台不在设备表、没有令牌的安卓机仍在写帧。受控验证证实:
不 hello、不带任何令牌,只发 agent:metrics 就能写任意 device_id 的时序
(进环形缓冲 → 推给正在观看的人)、把它标成在线、并落 30 天历史。
根因是校验只写在 onHello,而 onMetrics 直接用 msg.device_id。
现由 _agentUplinkGuard() 统一守卫 metrics/heartbeat:必须是已 hello 的采集会话、
开关开启时须在 hello 验过令牌、上报的 device_id 必须与会话一致
(身份以会话为准,不读消息字段——那是攻击者可任意填的)。
回归见 auth-mode-test.js [F] 段(含"合法采集端仍正常",防修过头)。
教训:任何"失败关闭"的开关都要追问一句 "它覆盖了该身份能做的全部操作吗?" —— 只守入口而放行后续动作等于没守。
残留风险:已登录者仍可抢注"别人机器的 device_id"(该 id 可读可猜)。
彻底解法是让认领具备"我确实在这台机器旁"的证明(设备端生成一次性码,与配对码同思路)。
还没做
- 自助注册在强制登录下默认关闭;正式发行还需要"邀请制/管理员建号"的界面(目前是 REST)。
- 第三方登录(Google/GitHub)。
- 配额升级路径(现在超额只提示,没有自助升档)。
- 设备令牌的吊销界面(REST 已有轮换,界面只有"认领/签发",没有"吊销这台机器"按钮)。
用户机制:角色 / 邀请 / 自救 / 配额(第 25 轮落地)
上一轮的账号体系只做到"你是谁"(注册登录、设备归属)。这一轮补的是**"你能做什么"** 与**"账号出事怎么办":在此之前,一个账号只有"能看"和"不能看"两态,忘了口令等于 账号报废,设备权限只能发配对码(拍照发过去 = 公开招租),配额只用来报数**不生效。
能力矩阵只有一个真相源
shared/permissions.js 定义 13 个能力 × 3 个角色(viewer / manager / owner),
Dart 侧 client/lib/host/role_catalog.dart 是它的镜像 —— 两边双向相等由测试锁定
(schema-consistency-test.js 第 14 段逐条比对能力名、最低角色、ownerOnly 集合)。
这一条不是洁癖:界面按自己的表隐藏按钮、服务端按自己的表判权,两表一旦漂移,
用户看到的就是"按钮点了没反应"或"根本没按钮但接口允许"。
授权 ≠ 夺权(ownerOnly)
把某人提为 owner 角色,意味着他能管成员、轮换令牌、看审计 ——
但不能转移归属、不能删除设备。这两件事只认 devices.owner_id 本人,
因为"被授予的权力"不该包含"把授予者踢出去"。实现上不是特判,而是能力表里的
ownerOnly: true 标记,由 authorize() 统一裁决;测试里对 3 个角色 × 2 个能力
逐一断言(含"被授予 owner 的成员仍被拒"这条反直觉组合)。
账号自救四件事
| 场景 | 做法 | 关键取舍 |
|---|---|---|
| 怀疑令牌泄露 | 列出全部登录记录,按 12 位前缀踢掉某一条 / 一键踢其它 | 列表只给哈希前缀,绝不回显可用凭据 |
| 想改口令 | 验原口令 → 改 → 吊销其它全部会话 | 改密即清场:旧令牌不该继续有效 |
| 忘了口令 | 邮件一次性令牌,15 分钟、用一次即焚 | 见下"防枚举" |
| 不要这个号了 | 注销 → 30 天冷静期内登录即撤销 | 期内设备变无主但数据保留,界面如实说明 |
一次性凭据(验证邮箱 / 重置口令 / 邀请)统管在 auth_tokens 一张表里,
只存 sha256 哈希,消费用 UPDATE ... WHERE used_at IS NULL RETURNING 保证原子
(并发两次只会有一个成功)。重放、跨账号使用、换人代接受 —— 全部明确拒绝。
防账号枚举
"这个邮箱注册过吗"不该被一个陌生人问出来。故 reset/request 对存在与
不存在的邮箱返回完全相同的键与文案(测试逐键比对,不是比状态码),
只有 has-more 信息的那一侧才真的发信。
令牌绝不进 HTTP 响应
验证/重置/邀请的令牌只走邮件通道。响应体里连前缀都不许出现 ——
测试直接断言响应 JSON 里匹配不到 pm_rst_ / pm_vfy_ / pm_inv_。
这不是洁癖:一旦回显,任何能看到响应的地方(代理日志、前端 console、崩溃上报)
都成了凭据泄露点。
发信通道三模式:outbox(默认,落 JSONL 供本地调试)、console、smtp
(未实现 —— 选了它只会在响应里如实回报 delivered:false,绝不假装发出去了)。
配额:402 而不是 403
额度用尽不是"没权限",是"该付钱/该删设备",故用手册里少见的 402 Payment Required,
body 带 used / quota / plan 三个字段让客户端能直接渲染进度。免费档在邮箱未验证时
收紧到 1 台 —— 验证本身是零成本的,这比"注册就送 3 台"更能挡住批量刷号。
2026-10-30 起 ENFORCE_DEVICE_QUOTA 默认开启(=0 可关)。这是本轮唯一的
破坏性变更:之前它关着,于是 PLAN_TABLE 那行数字只被用来报数,免费号能挂无限设备 ——
那不是"宽松",是机制不存在。只有"新增"(注册/认领)会被拦,已有设备的查看不受影响。
端到端证据(user-lifecycle-test.js,66 项)
九段全绿,其中一半是"No"型断言(必须被拒):viewer 邀请 → 403、manager 踢人 → 403、 被授予 owner 者转移归属 → 403、邀请令牌重放 → 401、代别人接受邀请 → 403、 注销后原会话 → 401、第 4 台设备 → 402。反面对照同样有:合法路径必须能过, 否则"全拒"也能让测试全绿。
这套测试自带隔离实例(随机空闲端口 + 自己的 outbox 路径 + 放宽的限流额度,跑完即停)。
原因是踩了三个坑,根因都是"被测服务的环境不归测试管":
① 写死 :4010 撞上上次遗留的旧进程 → 测的是改动前的代码,36 项假红;
② outbox 路径测试用 os.tmpdir()、服务端用 /tmp/... → 永不相等,整段假红;
③ 注册限流(每小时 20 次/IP)被反复重跑耗光 → 429 假红。
三者都不是产品缺陷,却各要花一轮排查。让测试掌握环境,这类"环境冒充缺陷"一次消掉。
还没做
- SMTP 真的发信:现在只有 outbox/console,生产要么接 SES 要么自建; 在此之前"发一封验证邮件"只是写了个文件,不该对外宣称已支持。
- 邀请的接受界面:REST 已有(
POST /api/invites/accept),客户端只有粘贴令牌的入口。 - 注销 30 天后的定时清理:
purgeExpiredDeletedAccounts()已实现但没有 cron 调它, 现在靠"期满后首次登录"顺手清理。 - 第三方登录、配额自助升档、审计日志的导出。
显示层:单位自适应与历史回溯(第 23 轮落地,含两处真机才暴露的缺陷)
用户真机反馈三条:网速单位切换"效果还行,但内存和磁盘没做到自适应"、"采集速度看起来太快、 做成可调"、"折线图的开始/结束时间不可调节"。前两条与第三条的性质不同 —— 第三条不是没做界面,而是数据层根本没有出口:
| 需求 | 数据层真相 | 落法 |
|---|---|---|
| 折线起止时间可调 | 实时层是定长 60 帧 Redis 环形缓冲(方案 10.2 有界优先), | |
窗口长度由架构决定;长时段数据一直在 device_metrics(30 天保留)但从未有查询接口 |
方案 10.3 时序查询:GET /api/devices/:id/history |
|
| 采集速度可调 | 采样间隔原为服务端下发、客户端照抄 | 客户端 0.5/1/2/5 秒可选,人工优先(samplingIntervalManual,重连不被下发打回) |
| 内存/磁盘单位自适应 | 界面硬写 MB/GB,出现 84281 MB、7992.7 GB、225024 s |
1000 进制量级换算 + 滞回 + 时长人话化 |
历史档位(白名单,粒度由档位表推出而非客户端指定;点数上限 400):
| 档位 | 桶宽 | 说明 |
|---|---|---|
1h |
60 s | 与实时窗口同粒度,便于对照 |
24h |
600 s | |
7d |
3600 s | |
30d |
10800 s | 与 30 天保留期对齐 |
非法档位 400 E_BAD_WINDOW(含可选清单);聚合用 floor(epoch/bucket) 而非 TimescaleDB 的
time_bucket(普通 PG 也能跑,符合"扩展可缺省"约定);缺桶即断点(标 gapBefore 让折线断开,
不插值 —— 与实时窗口同一条纪律)。指标与 SQL 列名双白名单:列名无法参数化,
故必须映射自 METRICS_SCHEMA 的列定义,值全部参数化。
缺陷 ①:单位换算被写了三份,各缺一块(第 6 次同类复发)
真机上采集页显示 磁盘可用 239 GB(正确),查看页显示 84710 MB / 131072 MB /
3112.0 GB(原始值直出)。不是漏写换算,而是同一个逻辑写三份:
| 位置 | 速率(adaptiveRate) | 量级(scaleBase) | 时长(duration) |
|---|---|---|---|
main.dart 采集页 |
✅ | ✅ | ✅ |
viewer_page.dart 指标卡片 |
✅ | ❌ | ❌ |
viewer_page.dart 坐标轴 |
❌ | ✅ | ✅ |
三份各配自己的测试 → 三份测试全绿,用户在两个页面看到同一指标的不同单位。 这与指标字段清单(Rust/协议/表列/仓储)、配对码契约、客户端指标目录是同一个病: 多层共享的判定没有单一真相源(本仓库第 6 次)。
修法:唯一出口 client/lib/host/metric_format.dart(MetricFormatter),
三个调用点全部改调它;纯 Dart 的 adaptive_unit.dart 从 ui/ 移到 host/ 与
scaled_unit.dart 同层。两处语义也在同一轮修对:
- 卡片读平滑值、轴与 tooltip 读真实值。速率类为了不被毛刺带着翻单位而做了 EMA 平滑, 但拿平滑值去渲染"某一时刻的采样"就是编造数据;两条路径共用同一套选档阈值, 故轴上的单位与卡片不会互相矛盾。
- 轴内单位必须统一:
formatStable要传axisRef(该轴最大绝对值), 否则轴上会混排0 MB与3.11 TB(这两个数差 10⁶,并排看着像两个量)。 - 速率为 0 时显示
0 KB而非0.00 TB式的噪声(零单独处理)。
护栏:client/test/metric_format_test.dart 第五段是源码结构断言 ——
剥掉注释后扫描两个页面,出现 ScaledUnit( / AdaptiveRateUnit( / scaleBase /
adaptiveRate / formatDuration( 即判红。(剥注释是刻意的:本项目要求把事故写进注释,
护栏若把注释也算进来,等于逼后来人删掉解释。)
缺陷 ②:折线标题与请求的指标不是同一个
真机切到「1 小时」档,标题写 CPU 使用率 · 1 小时,界面却弹
「指标 uptime_s 没有独立时序列,暂不支持回溯」。根因是默认指标被解析了两次:
图表用 _orderKeys(...).first(按显示目录排序 → cpu_usage),历史查询用
_st.availableKeys.first(能力集原始顺序 → uptime_s)。而且默认落在了
chartable: false 的 uptime_s 上(数组型与单调量画折线无意义,device_metrics
里也没有这两列)。
修法:ViewerPage._chartKey 作为图表 / 历史查询 / 卡片高亮共用的唯一解析,
默认跳过不可画的指标取第一个 chartable: true;卡片高亮也改为按生效指标
(否则会出现"图上画着 CPU,却没有任何卡片是高亮的")。
实测证据(F50 / Android 13 / arm64)
已用内存 84.7 GB(原 84710 MB) 内存总量 131 GB(原 131072 MB)
磁盘可用 3.11 TB(原 3112.0 GB) 磁盘总量 8.00 TB
运行时长 2 天 14 小时(原 225024 s) 下行 365 kb/s / 上行 50 kb/s(速率档位没被内存的 GB 带跑)
折线卡:CPU 使用率 · 最近 60 秒 ▾ / Y 轴 68.0% 66.0% 64.0%(与卡片同源)
切「1 小时」→ CPU 使用率 · 1 小时 / 来源:数据库降采样 · 每 60 秒取平均 · 覆盖 08:04:00 → 09:04:00
镜像构建:点名式 COPY 的两起事故(已修复并锁定)
| 事故 | 后果 | 为何本地测不出来 |
|---|---|---|
新增 shared/metrics-schema.js 后未加进 COPY 列表 |
镜像构建成功,容器一启动就 Cannot find module |
本地 node 直接读源码,不经过 COPY |
server/shared/keepalive.js 删除后 Dockerfile 仍 COPY 它 |
COPY 不存在的文件是硬错误,构建直接失败 |
同上 |
根因与指标字段分裂同源:Dockerfile 用逐个点名的方式拷贝源码,等于又多出一份 必须人工与代码同步的文件清单。
修法:两个 Dockerfile 改为通配拷贝(COPY shared/*.js ./shared/ 等),
并新增 tests/dockerfile-coverage-test.js(19 项):从入口模块出发递归解析 require() 图,
断言镜像里真正需要的每个文件都被 COPY 规则覆盖;反向断言每条 COPY 都指向真实文件;
再校验 .dockerignore 不会误排除必需文件。同时补上原本缺失的 .dockerignore
(此前构建上下文会把 node_modules、rust-core/target、.git 全量上传给 daemon)。
验证:已实际 docker compose build 两个镜像并在容器内 require() 成功,
容器内读到白名单 17 字段、仓储 17 列 —— 不是靠推断。
| 方案章节 | 落地位置 |
|---|---|
| 3.2 / 3.4 职责下沉 | rust-core/src/(采集 collector / 传输 transport / 保活 keepalive) |
| 第 5 章 通信协议 | shared/protocol.js + rust-core/src/transport.rs(信封与版本校验双端同源) |
| 第 7 章 指标可得性 | shared/metrics-schema.js(权威清单)、Collector::capabilities() |
| 第 8 章 保活机制 | rust-core/src/keepalive.rs(三层模型 + 守护 + 五端自启产物) |
| 第 9 章 安全设计 | 白名单过滤、WSS 强制、FFI 边界约束(ffi-smoke.py 逐项验空指针)、参数化 SQL |
| 第 10 章 数据存储 | deploy/init.sql(含增量迁移段)、server/shared/repository.js |
| 第 11 章 部署运维 | deploy/(含 ADR-001 多阶段构建) |
| 第 12 章 资源与内存安全 | store.js 有界容器、buffer.rs 环形缓冲、transport.rs 补传窗口、leak-check.js |
| 第 13 章 连接可靠性 | transport.rs(心跳 / 半开检测 / 退避+抖动 / 网络恢复主动重连 / 补传窗口)+ 服务端去重与优雅关闭 |
多端客户端的测试环境选型(以 Apple Silicon 主机为例)
方案要求五端一致,但主机是 arm64 时"能不能在本机验证另一端"要按架构算。以下结论依据 Parallels Desktop 26 官方系统要求与限制说明(KB 124223 / 129497 / 128914)实测口径整理:
| 目标端 | 本机可行性 | 说明与陷阱 |
|---|---|---|
| macOS | ✅ 原生 + VM | 原生直跑即真机;另可在 Parallels 里跑 macOS 12–26 客户机,用于验证干净环境下的自启与保活(LaunchAgent 注册) |
| Linux arm64 | ✅ Parallels | Ubuntu/Fedora/Debian/RHEL/Kali 等 arm 版均支持 |
| Windows arm64 | ✅ Parallels | Windows 11 Pro/Enterprise on Arm;x86/x64 应用靠 Prism 转译运行 |
| Linux x86_64 | ⚠️ 仅 PD26 预览 x86 模拟 | 属预览特性,且采集到的性能数据失真 |
| Windows x86_64 | ⚠️ 仅 Prism 转译 | x64 二进制可跑,但指标不可信 |
| iOS | ❌ | 需 Xcode + Simulator(macOS 原生),Parallels 帮不上 |
| Android | ❌ 在 VM 内 | Android 模拟器需嵌套虚拟化 |
一条硬限制:Apple Silicon 上的 Parallels 不支持嵌套虚拟化(官方明确列为不可用项)。 由此推出三条实操结论:
- VM 里跑不了 Docker / WSL2 / Hyper-V(WSL1 可作降级)—— 别指望在 Windows VM 里 再起一套容器化环境做联调。
- VM 里也跑不了 Android 模拟器 —— Android 端请在 macOS 宿主机上用 Android Studio(arm64 系统镜像)或真机验证。
- 性能类指标必须区分"功能验证"与"数据验证": 在本产品里这尤其致命 —— 跑在 Prism / 预览 x86 模拟上的客户端, 采到的 CPU/内存数据是模拟层与宿主混合后的结果,与真实 Windows 机器无对应关系。 用 VM 验证"能装、能连、能保活、能补传"(流程正确性), 但要出可对外承诺的性能数据,仍需一台真机/真云主机。
跨平台产物构建:macOS 上交叉编译需显式装 target;Linux/Windows 目标建议用
cross(cargo 子命令,借 Docker 提供链接器),避免在本机堆多套工具链:
# Linux arm64(与本机架构一致,最省事)
rustup target add aarch64-unknown-linux-gnu
# Windows arm64:需 LLVM 链接器。用 gnullvm 目标可从 macOS 交叉编译,
# msvc 目标则必须在 Windows 上构建(需要 link.exe / Windows SDK)
rustup target add aarch64-pc-windows-gnullvm
⚠️ 客户端与服务端之间的链路是 Socket.io(engine.io)。Rust 侧的 socket.io 客户端生态 很弱,这是"客户端宿主该用什么语言"的决定性约束 —— Dart(
socket_io_client)或 Node 才是顺路选择,纯 Rust 宿主需要自研 engine.io 客户端,不建议。
客户端(设备端)
client/ 是 Flutter 单代码库,覆盖 macOS 与 Android,同时提供采集端与查看端两个角色
(方案 3.1:任一设备上的应用既可上报本机数据,也可订阅查看已授权的其他设备)。
采集/传输/保活/重连策略全部复用 Rust 核心,客户端只做宿主该做的三件事:
socket I/O、平台差异落地、界面。
| 组成 | 说明 |
|---|---|
bin/pmon_agent.dart |
无界面常驻采集进程(方案 8.3「采集核心作为独立进程」),可编成小可执行文件(构建命令见 client/scripts/build_cli.sh) |
bin/pmon_viewer.dart |
★ 无界面查看端 CLI:连真实网关、走真实配对、订阅并打印快照/实时帧/断点/能力集,退出码反映结果 |
lib/host/link.dart |
★ 宿主 socket 适配层:真 socket 驱动 Rust 状态机(喂事件、执行动作) |
lib/host/viewer.dart |
★ 查看端宿主层:独立 socket + 配对 REST + 60 帧窗口 + 断点判定 + 能力集 + 离线明示(纯 Dart) |
lib/host/metric_display.dart |
指标显示目录(标签/单位/分组/是否可画/不可得原因,纯 Dart) |
lib/ui/viewer_page.dart |
查看端界面:设备切换器 / 仪表盘 / 折线 / 配对交互 / 空状态 |
lib/ui/metric_catalog.dart |
显示目录的图标映射(唯一 Flutter 强耦合部分)+ 转出纯层目录 |
lib/ui/pairing_card.dart |
采集页「谁能看我」卡:生成配对码 / 已授权账号 / 随时解除(方案 6.3 的采集端两步) |
lib/host/pairing_api.dart |
配对与授权 REST 客户端(采集端与查看端共用,含错误码翻译) |
lib/host/account_api.dart |
账号 REST 客户端(注册/登录/登出/我的设备/认领) |
lib/ui/account_card.dart |
账号卡(登录/注册/登出;未登录时明说只能看他人授权的设备) |
lib/host/link.dart |
采集端 agent:hello 携带设备令牌(认领后自动注入并重连) |
lib/main.dart |
双角色外壳:底部导航「采集 / 查看」;查看页懒创建(切页签不中断采集) |
lib/host/ffi_bindings.dart |
C ABI 绑定(37 个;另有 pmon_next_backoff_ms / pmon_backoff_reset 两个仅供 Rust 自测使用,客户端未绑定) |
test/viewer_test.dart |
查看端逻辑 48 项(断点 / 能力集 / 错误码文案 / 快照 / 实时流 / 配对,全部离线,注入假 socket) |
test/viewer_page_test.dart |
查看端界面渲染 17 项(空状态 / 设备切换器 / 卡片取舍 / 断点提示 / 离线横幅 / 配对错误,真实构建 widget 树) |
test/viewer_fakes.dart |
两套查看端测试共用的假件(假 socket / 假策略 / 假 REST) |
test/pairing_card_test.dart |
采集页「谁能看我」卡 6 项(发码 / 失败不静默 / 清单 / 解除) |
test/account_test.dart |
账号 21 项(REST 客户端 + 账号卡界面,未登录/失效/失败文案都有断言) |
test/host_ffi_test.dart |
21 项 FFI 冒烟,真实加载编译产物 |
双角色为什么是两条连接(改动前必读)
网关的 session.role 是单值(onHello 置 agent、onSubscribe 置 viewer),断开清理
按 role 分支:role === 'agent' 才清在线态、role === 'viewer' 才清订阅关系。
若在采集连接上再发 viewer:subscribe,role 会被改写成 viewer → 断开时在线态永远清不掉,
该设备将永久显示"在线"(幽灵设备)。故查看端独占一条连接,采集端行为完全不受影响。
同理,查看端刻意不发 agent:heartbeat:那会经网关 touchPresence 把"只想看别人"
的本机标成在线设备,制造一台状态灯绿、却没有任何指标的幽灵设备。查看端连接的保活由
engine.io 自身的 ping/pong 承担。该决策已由端到端测试钉住
(断言 presence:viewer-cli 不存在)。
协议没有 unsubscribe(所以切换设备要重连)
表 5.2 的上行事件只有 viewer:subscribe,网关按 session.subscribed 累积订阅、
仅在断开时清理。若切设备时只再发一次 subscribe,旧设备的帧会一直推过来、
subs:{旧设备} 里也永远留着本账号。查看端因此在切换设备时重建连接一次,
让服务端清理干净(代价一次 RTT)。
实测结果(本机):flutter test 100/100;查看端端到端
(真实采集端 + 真实配对 + 真实 Dart CLI)21/21 —— 快照 4 帧 → 恢复后断点=1、
能力集隐藏 2 项、撤销授权后列表即刻不可见。
详细环境要求、联调注意事项(必须清代理变量,否则 localhost 会走代理导致握手失败)
与 Android 打包见 client/README.md。
已知边界
- Android 采集端已落地并实测联调:
aarch64-linux-android交叉编译、Kotlin 前台服务 与常驻通知(PmonCollectorService.kt)、平台通道(PmonChannel.kt,TrafficStats / ANDROID_ID / filesDir)、BOOT_COMPLETED接收器(BootReceiver.kt)均已实现, 模拟器与真机均能连上网关上报(服务端可见android-*设备在线)。 仍待补:正式签名与 AAB 产物、各厂商 ROM 的自启引导文案核对、Android 侧查看端 UI 验收。 - 查看端已落地(方案 6.2 MVP 项:设备切换器 / 快照首屏 / 实时流 / 服务器时间轴 /
能力集驱动隐藏 / 离线明示 / 断点不插值 + 配对交互)。两端 UI 共用一份指标显示目录,
一致性由
tests/schema-consistency-test.js第 11 段跨语言锁定(Dart ↔ JS 权威清单)。 采集端侧同样补齐(ui/pairing_card.dart:生成并展示配对码 / 已授权账号清单 / 随时解除), 至此方案 6.3 的四个环节(设备发码 → 查看端输码 → 建授权 → 设备解除)在 GUI 里全部可走通。 仍待补:查看端的历史回溯(方案 10.3 的时序查询接口尚未提供 REST/下行事件)。 - TimescaleDB 分支已验证:用真实
timescale/timescaledb:2.17.2-pg16镜像跑通, 扩展 2.17.2、超表、30 天保留策略、连续聚合(12 列)+ 30 分钟刷新策略全部建成。 国内拉取该镜像(2.74GB)实测极慢(~46 KB/s),本地开发建议用POSTGRES_IMAGE=postgres:15-alpine(init.sql已做成扩展可缺省)。 - 传输状态机已接入客户端:
client/lib/host/link.dart已把真 socket 喂给transport.rs(退避/心跳/半开/补传不再由 JS 或 Dart 实现)。服务端测试套件 仍走 Socket.io 直连,未经过 Rust 状态机。 - 保活产物已生成,但落盘/执行未接入:
plan_autostart()产出的 plist / unit / reg 命令是真实内容,但"写文件 + 执行命令"这一步要由 Dart 侧完成, 目前只能在演示程序与 FFI 测试里打印核对。 - 未接入采集的指标共 7 项:
cpu_temp/battery_temp(方案 7.2 的平台受限路径)、gpu_usage/gpu_temp/gpu_mem_usage、disk_read_mbps/disk_write_mbps(需平台级 IO 计数器)。这 7 项在capabilities()中如实声明为不可得, 不按平台理论能力虚报;协议字段已占位以冻结契约。其中 5 项有独立列,落库为NULL(真实帧全链路测试会断言"不得编造 0")。 - 快照新增了设备元信息(
capabilities/device_name/platform/meta_available): 方案 6.1 要求查看端按能力位隐藏卡片,但此前网关只下发 frames,查看端只能从"全 null" 反推能力(区分不了"平台永久不支持"与"本次没取到")。现由快照随附,缺失时客户端 自动退回反推路径并在界面如实标注推定来源。追加字段不改变既有语义(方案 5.4)。 - 降采样连续聚合(
device_metrics_10s)已建并补齐主力指标列,但实时刷新策略 需按实际写入量调参;视图定义变更后需重建(IF NOT EXISTS不会更新既有定义)。 - 配对码校验已完整落地(方案 6.3/9.4 全项:一次性、300 秒、设备绑定、
5 次作废、账号 10 次/小时、IP 30 次/小时、失败全量审计、存储故障失败关闭)。
仍待补的一项:授权成功时被查看设备本地弹窗提示并留痕(方案 9.4)——
这需要业务服务经 Redis pub/sub 通知网关、网关再向采集端推一个下行事件,
会牵动
shared/protocol.js的下行事件白名单与 Rust 侧常量(由一致性测试锁定), 故单独作为一步实施,不与安全修复混在一起。 - 业务服务按来源 IP 限流依赖
trust proxy配置:默认TRUST_PROXY_HOPS=1(对应deploy/nginx.conf的一层反代)。当前docker-compose.yml把 4000 端口 发布到所有网卡,绕过 nginx 直连业务服务时,客户端伪造的X-Forwarded-For会被当作真实来源 IP。生产环境应把 business 的端口映射改为127.0.0.1:4000:4000(与 redis/postgres 一致),或设TRUST_PROXY_HOPS=0。 - 五端真实保活调用(Android 前台服务、Windows 托盘、macOS 菜单栏等 UI 层)未接入, 当前完成的是"契约 + 平台产物 + 守护策略"三件,UI 载体属客户端工程。
本文档为技术骨架说明,不构成投资建议或商业承诺。排期与容量需按实测校准。