chore(docs): 文档类文件下架仓库,只留本地(政策入 .gitignore)

用户定:除 README / LICENSE / .env.example 外,文档类文件只在本地保存,
除非项目运行离不开才提交。docs/ 全部 11 个文件(架构方案、专项设计、
腾讯云运维篇、改名记录、性能基准 JSON、两份旧名历史存档)无一是运行
依赖(引用全是注释与文档间链接),整体移出。

本地存档:~/WorkBuddy/水墨屏工具/inkwatch-docs-local/(412K)。

同时清掉 patch_relay_v2.py —— 一次性字符串手术脚本(给旧 relay 修复
用),现在跑只会损坏 relay.js,属运行无关物。

连带修正(不留悬空引用):
· README:目录树去 docs/、单容器取舍改为内联、perf-bench 数字改本地存档
· client/README.md:三处 docs 引用去路径留实质
· eink_board.dart / config_migration.dart:注释去路径留实质
· docker-compose{,.all,.prod}.yml:三处"详见 docs/…"改为内联关键纪律
· privacy-scan.js:quotepath 判据的例句随中文名文件消失而改写,判据保留
· .gitignore:加 docs/ 与 patch_*.py 段(政策机械固化,防再误提交),
  顺手去掉尾部重复的 .git-rewrite 条目

验证:隐私扫描 207 文件零命中;镜像覆盖 43;Dart 392 全过;
全仓库 docs/ 悬空引用为 0。
This commit is contained in:
PMon Dev committed 2026-10-17 02:23:37 +08:00
1 parent 1238e5d530
commit c060efa4fe
21 files changed
+23 -3392

No files matched your search

+6 -2
View File
@@ -60,5 +60,9 @@ coverage/
# filter-branch 的临时工作树(曾被误提交,务必忽略)
.git-rewrite/
# filter-branch 的临时工作树(曾被误提交,务必忽略)
.git-rewrite/
# ── 文档类文件只留本地(2026-10-17 定)────────────────────────────
# 除 README / LICENSE / .env.example 外,文档不进仓库(本地存档于
# ~/WorkBuddy/水墨屏工具/inkwatch-docs-local/)。除非项目运行离不开
# (如部署配置模板),那时也该放 deploy/ 并说明理由,不要放 docs/。
docs/
patch_*.py
+3 -6
View File
@@ -22,10 +22,6 @@ pmon/
├── README.md # 本文件
├── LICENSE # MIT
├── .env.example # 环境变量示例(真实 .env 不入库)
├── docs/
│ ├── inkwatch-v4-architecture.md # V4.0 方案正文(17 章 / 33 表)
│ ├── pmon-v4-架构方案-V4.0.html # 单页报告版
│ └── 全平台设备性能监控系统商业化实施方案V4.0.docx
├── shared/
│ ├── metrics-schema.js # ★ 指标字段单一真相源(协议/Rust/表列/仓储全由它派生)
│ ├── protocol.js # 协议 v1:信封校验 / 白名单 / 版本兼容 / 编码协商
@@ -135,7 +131,8 @@ docker compose -f docker-compose.all.yml up -d --build
docker exec pmon-app-1 supervisorctl status # 四进程 RUNNING
```
动机与取舍见 `docs/deploy-tencent-runbook.md` 第七节。
取舍(单容器是单机运维形态):进程隔离弱于四容器、容器重启四进程齐动、
升级要重建整镜像;好处是常驻容器从四个变成一个,数据卷同名可随时互切。
### 方式 B:本地开发(不走容器)
@@ -199,7 +196,7 @@ node e2e-smoke.js # 端到端冒烟
node persistence-restart.js # 持久化与重启恢复(自带启停网关)
node race-residue.js # 断连竞态残留
ROUNDS=500 node leak-check.js # 泄漏专项(约 80s)
node perf-bench.js # ★ 性能基准(动网关热路径前后各跑一次,数字存 docs/perf-baseline-*.json)
node perf-bench.js # ★ 性能基准(动网关热路径前后各跑一次,数字本地存档)
```
> ⚠️ 改过 `shared/metrics-schema.js` 后**必须重启网关**:白名单在进程启动时载入并缓存,
+4 -3
View File
@@ -292,7 +292,7 @@ subscribe,旧设备的帧会一直推过来(移动端流量翻倍),`subs
| 规则 | 理由 |
|---|---|
| `config.json` 加 `config_version`;`load()` 是配置**唯一入口**,规范化与迁移都在这里 | 没有版本号就无法区分"字段本来没有"与"旧版本用别的语义写的" |
| 采样间隔**夹到方案规定的 0.5–5 秒**(`docs/inkwatch-v4-architecture.md` 6.1),越界回落 1000ms | 唯一能压满 UI 线程的字段,必须拦 |
| 采样间隔**夹到方案规定的 0.5–5 秒**,越界回落 1000ms | 唯一能压满 UI 线程的字段,必须拦 |
| 地址只在**明显非法**(解析不出/无 host/非 http(s))时回落编译期默认值 | `io.io('')` 是真连不出去的坏值;回落是无损的 |
| `device_id` 只拦**结构上不可能**的值(含空格/中文/超长) | 它会污染服务端主键;合法值一律不动 |
| **凭据(account_id / 令牌 / 邮箱)一律不校验** | 见下 |
@@ -849,7 +849,8 @@ flutter build apk --release --target-platform android-arm64 \
cp build/app/outputs/flutter-apk/app-release.apk \
../dist/inkwatch-arm64-$(grep -E '^version:' pubspec.yaml | cut -d' ' -f2 | cut -d+ -f1).apk
# 交付包统一叫 inkwatch-arm64-<版本>.apk(项目对外名是"墨观 InkWatch";
# 但**应用包名与 FFI 符号仍是 pmon** —— 两件事,见 docs/rename-inkwatch.md)
# 但**应用包名与 FFI 符号仍是 pmon** —— 显示名与内部标识是两件事,
# 内部标识(含卷名/包名/FFI 符号)永不随显示名改)
```
| 要点 | 说明 |
@@ -890,7 +891,7 @@ grep -cF "203.0.113.10" /tmp/chk/lib/arm64-v8a/libapp.so # 用 -cF,别用 -q
产品负责人要把一台 **iReader Neo 2**(6″ Carta 1300 · 1072×1448 · 300 PPI ·
**黑白 256 灰阶** · MT8175B 四核 2GHz + **2GB RAM** · **Android 10 (API 29)** ·
**零传感器**)当**副屏**常驻看一台设备。实测参数见 `docs/eink-device-neo2.md`。
**零传感器**)当**副屏**常驻看一台设备(参数为实测值,非资料推测)。
> 装包路径(实测可行):电脑下载 apk → 拷进设备的 `iReader/APK` 文件夹 →
> 拔线 → 打开设备的「应用市场」→ 右上角菜单 → 安装。Neo2 在"支持任意安装第三方软件"的机型名单内。
+1 -1
View File
@@ -42,7 +42,7 @@ import 'app_config.dart';
/// · URL / device_id / 令牌开始做形状校验
/// 采样间隔合法区间 —— **数值来自方案,不是拍脑袋**:
/// `docs/inkwatch-v4-architecture.md` 6.1「采集频率默认 1 秒,云端可动态下发 0.5–5 秒」。
/// 采集频率默认 1 秒,云端可动态下发 0.5–5 秒。
///
/// 下限尤其重要:它是"UI 线程被采样占满"的唯一闸门(见文件头实测表)。
const int kMinSamplingMs = 500;
+1 -1
View File
@@ -3,7 +3,7 @@
/// ══════════════════════════════════════════════════════════════════════
/// 这个页面为 **iReader Neo 2**(6″ Carta 1300 · 1072×1448 · 300 PPI ·
/// **黑白 256 灰阶** · MT8175B 四核 2GHz + **2GB RAM** · **Android 10 (API 29)** ·
/// **零传感器**)当**副屏**而做。实测参数见 `docs/eink-device-neo2.md`。
/// **零传感器**)当**副屏**而做(参数为实测值,非资料推测)。
///
/// 墨水屏的物理决定了它与彩色版的取向**相反**,四条约束贯穿全文件:
///
+1 -1
View File
@@ -10,7 +10,7 @@
#
# ⚠️ 与四容器路径共用同一个 compose project(name: pmon)→ 数据卷名不变
# (pmon_pg_data / pmon_redis_data)→ 两条路径可随时互切,数据不动。
# 切换与回退步骤见 docs/deploy-tencent-runbook.md 第七节。
# 切换 = 换 -f 参数(down 不删卷;同一时刻只跑一条路径,端口 3000/4000 会冲突)。
#
# ⚠️ 端口一律绑回环:该机云防火墙是「全部 TCP 1-65535 放通」,
# 绑 0.0.0.0 等于把 gateway/business/PG 直接暴露公网(业务服务带
+1 -1
View File
@@ -10,7 +10,7 @@
# → 生产**不启动** 墨观 自己的 nginx 容器,接入层交给 1Panel 站点。
# → 代价:deploy/nginx.conf 里那几条承载性指令必须搬进 1Panel 站点配置
# (WebSocket 升级头 / proxy_read_timeout 600s / proxy_buffering off /
# proxy_http_version 1.1)。站点片段见 docs/deploy-tencent-assessment.md。
# proxy_http_version 1.1)—— 按上述四条搬进 1Panel 站点配置。
#
# ② 该实例云防火墙是「全部 TCP 1-65535 放通」。
# → gateway(3000) / business(4000) **必须**绑回环:业务服务带
+2 -1
View File
@@ -5,7 +5,8 @@
# ⚠️⚠️ 这个 name 是 **Docker 卷名的锚点**(`pmon_pg_data` 等),
# **绝对不要**因为"项目改名叫墨观了"就把这里改成 inkwatch ——
# 改了之后 compose 会去找一套**新的空卷**,数据库数据全丢(等于把生产清库)。
# 项目显示名(界面/文档/APK)与这里的内部标识是两件事,详见 docs/rename-inkwatch.md。
# 项目显示名(界面/APK)与这里的内部标识是两件事 —— 内部标识(含卷名)
# 永不随显示名改。
name: pmon
services:
-165
View File
@@ -1,165 +0,0 @@
# 墨观 服务端部署到腾讯云:可行性实测评估
> **状态更新(2026-09-15 当日):已按方案 A 完成部署(无域名版,走 1Panel OpenResty:80)。
> 实际操作步骤、验收记录、运维命令与"部署时才暴露的两个真缺陷"见
> [`deploy-tencent-runbook.md`](deploy-tencent-runbook.md)。本文保留评估期的
> 逐项对表与决策依据,供日后扩容/换机复用。**
> 目标机:`tencent` → **203.0.113.10**(腾讯云轻量 `lhins-EXAMPLE`,ap-shanghai)
> 凭据:`~/.ssh/<YOUR_PRIVATE_KEY>`(`ssh <your-host>`,sudo 免密)
> 评估日期:2026-09-15。**本文所有数字均为该机上现场实测**,非推算。
---
## 一、结论
**能部署,且余量充足。** 唯一需要改动的是「接入层」:**80/443 已被 1Panel OpenResty 占用**,
故 墨观 自带的 nginx 容器不能直接映射 80;走"服务发布到 `127.0.0.1:端口` + 1Panel 反代"
这一既有模式即可(该机上 searxng 就是这么跑的,`network_mode: host` 的 OpenResty 反代
`127.0.0.1:8080`)。
另有 **一项安全约束必须先处理**:该实例的云防火墙是「**全部 TCP 1-65535 放通**」
(不是只开 80/443)。`deploy/docker-compose.yml` 默认把 gateway → `0.0.0.0:3000`、
business → `0.0.0.0:4000`,照原样上就是**公网裸奔**。必须绑回 `127.0.0.1`。
---
## 二、逐项对表(实测值 vs 墨观 要求)
| 项目 | 服务端实测 | 墨观 要求 | 结论 |
|---|---|---|---|
| 架构 | `x86_64` | 镜像全部多架构(node:22-alpine / redis:7.4-alpine / nginx:1.27-alpine / timescale-pg16),**无原生扩展编译** | ✅ |
| Docker | **29.7.2** | ≥ 20 | ✅ |
| Compose | **v5.4.0** | v2 语法(`depends_on.condition`) | ✅ |
| CPU | 2 核 AMD EPYC,load **0.01** | 无硬要求(长连接转发) | ✅ 富余 |
| 内存 | 3591 MB,现用 **1205 MB**,可用 **2385 MB** | 本地实测整套 **406 MB**(pg 297 / gw 38 / biz 38 / redis 18 / nginx 15) | ✅ 部署后约占 1.6/3.5 GB |
| **Swap** | **无** | 无要求 | ⚠️ 建议加 2 GB swapfile(防 OOM 杀 PG) |
| 磁盘 | 60 GB,可用 **45 GB**(26% 已用),inode 1% | 镜像 3.4 GB + 构建缓存 ~1.5 GB + 数据 | ✅ |
| 端口 80/443 | **1Panel OpenResty 占用**(host 网络) | compose 默认映射 80 | ❌ **必须改** |
| 端口 3000/4000 | 空闲 | gateway / business | ✅ |
| 端口 5432/6379 | 空闲(既有 searxng-valkey 只在容器网络内 6379) | 仅绑 127.0.0.1(compose 已如此) | ✅ |
| 镜像加速 | `docker.1panel.live` / `docker.1ms.run` 已配 | — | ✅ |
| **大镜像实拉** | **timescale/timescaledb:2.17.2-pg16 = 2.67 GB,耗时 2 分 29 秒(≈18 MB/s)** | 同一镜像 | ✅ 最大不确定项已排除 |
| npm registry | npmjs.org 200 / 640 KB/s;npmmirror 200 / 0.15 s | `npm ci`(构建期) | ✅(走 npmmirror 更快) |
| sudo | **免密可用** | 需配 nginx / swap / 防火墙 | ✅ |
| 域名 | `example.com` **NS 在 Cloudflare**;`www.searxng` / `www.rbook` 为 A 记录直指本机 | 客户端要连 WSS | ⚠️ 需加一条 DNS 记录 |
| 证书 | 各站**单域** Let's Encrypt(无通配符),1Panel 自动续期 | 生产 HTTPS | ⚠️ 新站需各自签发 |
| 现有邻居 | `market-sentiment`(8088)、`searxng-core`(8080)、`meilisearch`(7700)、`1Panel-openresty`、`searxng-valkey`(内网)、`reader_default` 网络 | — | 无端口冲突 |
| 既有 pmon 痕迹 | **无**(`/home/devuser/pmon*`、`/opt/pmon*` 均不存在) | — | ✅ 干净首装 |
---
## 三、必须做的 5 处改动
### 1) 不发布 80(二选一)
**方案 A(推荐)**:**去掉 墨观 的 nginx 容器**,由 1Panel OpenResty 直接反代
`127.0.0.1:3000`(`/socket.io/`、`/health`)与 `127.0.0.1:4000`(`/api/`)。
- 少一个容器、少一跳;`TRUST_PROXY_HOPS=1` **天然正确**(前面只有 OpenResty)。
- 代价:墨观 `deploy/nginx.conf` 里那几条**承载性指令必须搬进 1Panel 站点配置**
(WebSocket 升级头、`proxy_read_timeout 600s`、`proxy_buffering off`、
`proxy_http_version 1.1`),否则长连接会被代理层提前掐断。
**方案 B**:保留 墨观 nginx,改映射 `127.0.0.1:8081:80`,OpenResty 全部反代到 8081。
- 保留原配置零改动,但**多一跳**:`TRUST_PROXY_HOPS` 必须改成 **2**,
否则业务服务的 `X-Forwarded-For` 解析少取一层,IP 限流会把所有请求算到反代头上。
### 2) gateway / business 绑回回环(**安全必需**)
`deploy/docker-compose.yml`:
```yaml
gateway: ports: ["0.0.0.0:3000:3000"] → ["127.0.0.1:3000:3000"]
business: ports: ["0.0.0.0:4000:4000"] → ["127.0.0.1:4000:4000"]
```
理由:云防火墙全端口放通,且业务服务带 `TRUST_PROXY_HOPS=1`——
直接暴露时客户端可伪造 `X-Forwarded-For` 绕过配对码 IP 限流(这正是 README「已知边界」第 2 条)。
**建议做成 `deploy/docker-compose.prod.yml` 覆盖文件**,不要再改默认文件(本地开发仍需 0.0.0.0 让手机连)。
### 3) 新增 1Panel 站点 + 证书
- 站点 `pmon.example.com`,照 `www.searxng.example.com` 的既有模式:
站点目录 `/opt/1panel/www/sites/pmon.example.com/`,代理片段放 `proxy/root.conf`。
- SSL 用 1Panel 的 Let's Encrypt(HTTP-01,DNS 解析生效后即可签)。
- 反代片段需含(照抄并改端口):
```nginx
location ^~ /socket.io/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection;
proxy_read_timeout 600s; proxy_send_timeout 600s; proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; }
location ^~ /api/ { proxy_pass http://127.0.0.1:4000; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 256k; }
location = /healthz { return 200 'ok'; add_header Content-Type text/plain; }
location = /health { proxy_pass http://127.0.0.1:3000/health; }
```
### 4) DNS(需产品负责人操作,我无 Cloudflare 凭据)
Cloudflare 加 `pmon.example.com` **A → 203.0.113.10**(与 `www.searxng` 同款,直连不走橙云,
否则 WebSocket 与 Let's Encrypt 都会被 Cloudflare 代理干扰)。
> 若暂时不加域名,可先用 `http://203.0.113.10:<高位端口>` 跑通——
> 但这与方案 9.1「强制 WSS」冲突,只适合当过渡验证。
### 5) 客户端指向
- Android release 重打包:`--dart-define=PMON_DEFAULT_SERVER=https://pmon.example.com`
- Mac 端在采集页/查看页改服务器地址即可(配置只写自己负责的字段,见客户端纪律)。
---
## 四、需要产品负责人定夺的两件事
**(a) 现有数据是否迁移?** 本地已有:账号 `dev@example.com`(id=33) + 名下 2 台设备
(Mac / `android-oldmodel-x-abc123`)+ 配对授权 + 指标历史(pg_data 283 MB)。
服务端首装是空库,两种走法:`pg_dump | ssh | pg_restore` 迁移(账号/设备/授权都在),
或服务端开新库、设备在真机上重新认领。
**(b) 公网部署是否打开安全开关?** 现在是兼容模式(三个开关全关)。
放到公网后建议至少开 `REQUIRE_DEVICE_TOKEN=1`(否则任何人拿到可见的 device_id
就能灌假指标、污染历史),`REQUIRE_AUTH=1` 视是否需要强制登录。
按纪律:改开关只改环境变量重启,不必改代码。
---
## 五、部署步骤(方案 A + 已预拉镜像)
```bash
# 0) 前置(本次评估已完成:timescale 镜像已预拉到服务端 2.67GB)
# 1) 代码上机(排除本地构建产物与 node_modules)
cd pmon && rsync -av --delete \
--exclude 'client/build' --exclude '**/node_modules' --exclude '.git' \
-e ssh ./ <your-host>:/home/devuser/pmon/
# 2) 生产 compose 覆盖(改端口绑定 + 去掉 nginx 容器)
# deploy/docker-compose.prod.yml 已在仓库内提供
ssh <your-host> 'cd /home/devuser/pmon/deploy && \
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build'
# 3) 校验
ssh <your-host> 'curl -s localhost:3000/health | head -c 200; echo; curl -s localhost:4000/health | head -c 200'
# 4) 1Panel 建站 + 证书 + DNS(见上)
# 5) 客户端指向新域名,真机复验
```
> 注意:**不要**在该机上带 `POSTGRES_IMAGE=postgres:15-alpine` 起卷后又不带地起一次
> (卷是 TimescaleDB 初始化的,换普通 PG 会导致 PG 起不来 —— 本项目已踩过)。
---
## 六、遗留风险
1. **无 swap**,PG 在内存压力下会被 OOM Killer 选中 → 建议先加 2 GB swapfile。
2. **同机已有 4 个业务容器**(market-sentiment / searxng / valkey / meilisearch)。
墨观 加进来后总内存约 1.6/3.5 GB,正常;但 searxng 抓取高峰 + 墨观 构建并行时会紧张,
构建建议在低峰期做(构建期峰值内存明显高于运行期)。
3. **构建期 npm ci 走 npmjs.org 仅 640 KB/s** → 建议在构建环境注入
`npm_config_registry=https://registry.npmmirror.com`(或本地构建镜像后 `docker save | load`)。
4. **该机 2027-04-22 到期且需手动续费**(`NOTIFY_AND_MANUAL_RENEW`)。
5. 腾讯云轻量出带宽 5 Mbps:墨观 是长连接 + 小帧,够用;但**客户端若通过公网看历史大档位**
(30 天档一次 400 点)影响可忽略,抓取 SeXNG 的大流量才是带宽大头,注意别同时打满。
---
> 本文为技术评估,不构成投资建议或商业承诺。
-345
View File
@@ -1,345 +0,0 @@
# 墨观 腾讯云部署运维手册(无域名版)
> 目标机:`ssh <your-host>` → **203.0.113.10**(腾讯云轻量 `lhins-EXAMPLE`,OpenCloudOS 9.6)
> 部署日期:**2026-09-15** 部署方式:无域名,经 1Panel OpenResty 暴露 80
> 代码位置:`/home/devuser/pmon`(部署目录 `/home/devuser/pmon/deploy`)
---
## 一、当前形态(一眼看懂)
```
客户端 App ──http──▶ 203.0.113.10:80(1Panel OpenResty,host 网络)
│ /socket.io/ → 127.0.0.1:3000 gateway
│ /api/ → 127.0.0.1:4000 business
└ /health, /healthz
↓
pmon-postgres-1(127.0.0.1:5432, TimescaleDB)
pmon-redis-1 (127.0.0.1:6379)
```
- **没有** 墨观 自己的 nginx 容器(`deploy/docker-compose.prod.yml` 用 `profiles:["never"]` 停用)——
该机 80/443 被 1Panel OpenResty 独占,接入层由它承担。
- gateway / business / postgres / redis **全部只绑 127.0.0.1**。
这是硬要求:该实例云防火墙是「全部 TCP 1-65535 放通」,绑 0.0.0.0 就是公网裸奔
(业务服务带 `TRUST_PROXY_HOPS=1`,直连可伪造 `X-Forwarded-For` 绕过配对码 IP 限流)。
- 安全开关(`deploy/.env`,权限 600):`REQUIRE_DEVICE_TOKEN=1`,其余保持兼容模式。
### 接入层配置文件
| 文件 | 说明 |
|---|---|
| `/opt/1panel/www/conf.d/pmon-ip.conf` | **按裸 IP 匹配的站点**(`server_name 203.0.113.10`)。手工落盘,**不在 1Panel 站点列表里**,1Panel 的界面操作不会碰它 |
| `deploy/openresty-pmon-ip.conf`(仓库内) | 上面那份的**版本备份**。改动流程:改仓库里这份 → `rsync` 到服务器 → `nginx -t` → `nginx -s reload` |
| `/opt/1panel/www/sites/pmon-ip/log/` | 访问与错误日志 |
> 加域名时:在 1Panel 建站 `pmon.example.com`,把 `openresty-pmon-ip.conf` 里的 4 个 location 搬进去,
> 删掉 `pmon-ip.conf`,再 `docker exec 1Panel-openresty-<后缀> nginx -s reload`。
> 同时把客户端的服务器地址改成 `https://pmon.example.com`。
**只对根路径给人话,其余一律 404**:公网上扫描器极多,实测日志里 `/webui/`、`/geoserver/web/`、
`/favicon.ico` 全是境外 IP 的探测。兜底返回 200 会让本机看起来"装了那些应用",反而招来更多探测,
还会把访问日志淹掉、真问题看不见(这条是实测发现后收紧的)。
---
## 二、日常操作
```bash
ssh <your-host>
cd ~/pmon/deploy
# 看状态
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
curl -s localhost:3000/health | head -c 300 # 网关(含 device_token_required)
curl -s localhost:4000/health | head -c 300 # 业务服务
# 看日志(日志有界:json-file max-size 10m / max-file 3)
docker logs --tail 100 pmon-gateway-1
docker logs --tail 100 pmon-business-1
# 改安全开关:只改 .env 再重启对应服务,不必改代码
vi .env # REQUIRE_AUTH / ENFORCE_DEVICE_QUOTA / REQUIRE_DEVICE_TOKEN
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d gateway business
# 从 Mac 更新代码(只传三块,约 650KB)
# 本地执行:
# cd <pmon> && rsync -az --exclude node_modules --exclude build --exclude target --exclude .git \
# shared server deploy docs README.md .env.example <your-host>:/home/devuser/pmon/
# 服务端执行(**必须脱离 ssh 会话**,否则前台构建会被本地 SIGKILL(137)):
# cd ~/pmon/deploy && setsid nohup docker compose -f docker-compose.yml -f docker-compose.prod.yml \
# up -d --build > /tmp/pmon-up.log 2>&1 &
```
`.env` 里 `POSTGRES_PASSWORD` 是 **48 位随机十六进制**(部署时生成,未在聊天/仓库中出现)。
如需查看:`sudo -n cat ~/pmon/deploy/.env`。
---
## 三、客户端接入(首次)
因为选的是"**服务端开新库**",客户端要重新走一遍注册与认领(**实测过的完整步骤**):
1. **装包**:`pmon-android-arm64-1.0.2.apk`(默认服务器已在编译期指向 `http://203.0.113.10`)。
2. ⚠️ **已装过旧版的机器,升级后会沿用存盘的旧配置**(`server_url` 优先于编译期默认值)——
界面显示的还是 `192.168.0.15` 之类。两个办法二选一:
- 在**采集页**把"服务器地址"改成 `http://203.0.113.10`;或
- `adb shell pm clear com.pmon.pmon_client` 清数据后重启(编译期默认值才生效)。
**服务端是空库,反正要重新注册认领,清数据没有损失。**
3. **注册并登录**:查看页/采集页顶部的账号卡 → 填邮箱 + 口令(≥8 位)→ 点「注册并登录」。
当前 `REQUIRE_AUTH=0`,自助注册可用。
4. **认领本机**(**不能跳**):采集页下方的配对卡 → 点「认领到当前账号」。
认领成功后客户端会**自动签发设备令牌并注入采集端**,随即重连生效。
在此之前,采集端会一直处于"在线(socket 已连接)但服务端不认它"的状态。
5. 查看端:登录后同账号设备直接出现在列表(免配对码);他人设备仍需配对码。
> Android release 包要换默认服务器地址,只能重新构建:
> `flutter build apk --release --target-platform android-arm64 --dart-define=PMON_DEFAULT_SERVER=http://203.0.113.10`
> (release 包不可 `run-as`,没法事后往应用私有目录塞配置。)
---
## 三点五、排障(这几种现象都实测遇到过)
| 现象 | 根因 | 怎么办 |
|---|---|---|
| 查看端「链路 离线 · **连接网关失败:timeout**」,且配置里是 `192.168.0.15` 这类**局域网地址** | 手机当时在**蜂窝数据**(或热点主机)上,局域网地址根本不通。实测:手机 ping 局域网 100% 丢包、ping 公网 0% 丢包 | 改指公网 `http://203.0.113.10`。**公网地址在任何网络下都通**,这正是部署的意义 |
| 点「认领到当前账号」→「**服务端没有这台设备**」 | 服务端版本旧,没有"认领即注册"(见第四节 ⓪ 的死锁) | 部署新版业务服务:`rsync server/` 后 `docker compose ... up -d --build business`。客户端**无需更新**(修在服务端) |
| 采集端显示「**在线 · socket 已连接**」,但服务端 `devices` 表**没有这台设备** | 设备令牌校验把 hello 拒了(`REQUIRE_DEVICE_TOKEN=1`)。**网关只在 hello 成功时记日志,拒绝路径不写日志** —— 所以服务端日志里"什么也没有" | 看**真机日志**:`adb logcat \| grep "\[墨观\]"`,会看到 `服务端错误: {"code":"E_UNAUTHORIZED",...}`。先认领(认领会登记设备并签发令牌),再让它连 |
| OpenResty 日志里 `/socket.io/` 请求迟迟不出现 | WebSocket 是长连接,**nginx 在请求结束时才落一行日志**——只要连接还活着就不会有记录 | 想看是否连上,用 `ss -tn \| grep :3000`(会看到 `127.0.0.1:xxxxx → 127.0.0.1:3000`,那是 OpenResty 在反代),或直接看真机日志 |
| `nginx -s reload` 之后真机掉线 | reload 会掐断长连接(预期行为) | 无需处理:客户端 Rust 状态机自动重连(实测 reload 后自动恢复为「在线」) |
---
## 四、部署与首用中踩到并已修掉的四个真缺陷
这四个都是"本地测试全绿、真机/首装才炸"的类型,**已在仓库里修好并加护栏**:
### ⓪′ ★ 指标通道完全绕过设备令牌(生产漏洞,2026-09-15)
`REQUIRE_DEVICE_TOKEN=1` 的校验原先**只写在 `onHello`**,而 `onMetrics`/`onHeartbeat`
直接用 `msg.device_id`。**不 hello、不带任何令牌**只发 `agent:metrics` 就能:
写任意 device_id 的时序、把它标成在线、并把帧**推给正在观看的人**(实测伪造 id 三帧全部入库、
presence ttl=53s)。也就是说开关实际只守住了握手,"防冒充灌假数据"并不成立。
- **维修**:`_agentUplinkGuard(socket, msg)` 作为 metrics/heartbeat 的唯一守卫 ——
必须是已 hello 的采集会话、开关开启时该会话须在 hello 验过令牌、且上报的 device_id
必须与会话一致。**身份以会话为准,不读消息字段**。
- **回归**:`tests/auth-mode-test.js` **[F] 段 7 项**(含"合法采集端仍正常"防修过头)。
- **教训**:任何"失败关闭"的开关都要追问 **"它覆盖了该身份能做的全部操作吗?"**
只守入口而放行后续动作 = 没守。
### ⓪ 安全开关与认领流程**死锁**(全新设备永远进不来,2026-09-15 真机暴露)
真机上点「认领到当前账号」得到 **「服务端没有这台设备。请确认被查看设备已经开始采集并连上网关。」**
—— 而设备明明正在采集。三件事互为前提:
| 环节 | 依赖 |
|---|---|
| `POST /api/devices/claim` | 要求 `devices` 表里**已有**该设备(旧版 404) |
| 设备行入库 | 只发生在网关 `onHello → registerDevice` |
| `onHello` | `REQUIRE_DEVICE_TOKEN=1` 时无令牌**一律拒**,且在入库之前就 return |
| 设备令牌 | 由 `/api/devices/:id/token` 签发 —— 也要求设备已在库且已归属 |
→ **钥匙锁在门里**,全新设备在严格模式下永远走不完"认领 → 令牌 → hello"。
- **维修**:`claim` 改为**认领即注册**(设备不在库就登记,owner = 当前登录用户)。
这比"网关侧放行未认领 hello"**更安全**:建设备行的权限从"任何网络客户端"
收紧到"已登录用户";归属冲突仍由 `claimDevice` 的 SQL 兜底(非我 → 0 行 → 409),
且 `registerDevice` 的 upsert **不碰 owner_id**,后续 hello 冲不掉归属。
- **残留风险(诚实记下)**:已登录者仍可抢注"别人机器的 device_id"(该 id 可读可猜)。
彻底解法是让认领具备"我确实在这台机器旁"的证明(例如设备端生成一次性码,与配对码同思路)。
- **为什么本地一直没暴露**:`tests/account-test.js` 里原本赫然写着
**「认领不存在的设备 → 404」** —— 测试不只是没拦住,而是**把这个死锁钉成了期望行为**。
首装路径从来没在严格模式下跑过。该断言已改为"认领全新设备成功(认领即注册)",
并在 `tests/auth-mode-test.js` 新增 **[E] 段**(8 项)在严格模式下跑完整链路。
- **生产验证(6/6)**:全新探针设备 → 未认领时 hello 仍被拒 → 认领成功(`auto_registered: true`)
→ 签发出 `pm_dev_` 令牌 → **带令牌 hello 被接受** → 另一账号抢注被 409 拒。
验证用的临时账号与设备已清理(生产库恢复为"1 个账号 / 0 台设备 / 1 条审计")。
### ① `init.sql` 建表顺序前向引用(严重:部署看起来成功、功能全废)
`device_tokens` 原写在 `devices` **之前**,却带 `REFERENCES devices(device_id)` →
全新库上 psql 报 `ERROR: relation "devices" does not exist` →
entrypoint 因 `ON_ERROR_STOP` 中止 → 容器被 restart 拉起后**跳过初始化** →
库里只剩 `users` / `sessions`,其余表与超表全缺,**而健康检查是绿的**。
现场取证(修复前):
```
$ docker exec pmon-postgres-1 psql -U pmon -d pmon -c "\dt"
public | sessions | table | pmon
public | users | table | pmon
(2 rows) ← devices / device_tokens / device_grants / subscriptions / audit_log / device_metrics 全缺
```
- **维修**:把 `devices` 提到 `device_tokens` 之前(顺序即执行语义)。
- **护栏**:`tests/schema-consistency-test.js` 第 12 段「建表顺序」——
任何 `CREATE TABLE` 的 `REFERENCES` 都必须指向已定义的表,并单列 `devices 先于 device_tokens`。
已做变异测试:把顺序改回去,该段立刻变红。
- **为什么本地没暴露**:本地数据卷是很早建好的,后续新表靠 `init.sql` 末尾的增量迁移段补,
**全新库这条路径从来没跑过**。教训:凡"只在首次安装才执行"的脚本,必须有一条对着空库的测试。
### ② compose 漏传安全开关给网关(静默失效)
网关 `onHello` 里的设备令牌校验读 `REQUIRE_DEVICE_TOKEN`,但 `docker-compose.yml`
**只把它传给了 business、没给 gateway** → `.env` 里写 `1` 完全无效,设备令牌形同虚设。
(`tests/auth-mode-test.js` 直接 spawn 进程传 env,测不到 compose 这条通路。)
- **维修**:gateway 段补齐 `REQUIRE_AUTH` 与 `REQUIRE_DEVICE_TOKEN`;business 段补 `ALLOW_OPEN_REGISTER`。
- **护栏**:`.env.example` 登记全部安全开关 +
`tests/dockerfile-coverage-test.js` 第 5 段「安全开关通路」——
清单**从代码派生**(扫 `process.env.(REQUIRE_|ENFORCE_|ALLOW_)` 并归属到服务),
断言 compose 把它们传给了**每一个读它的服务**。已做变异测试(删掉那行即红)。
- **生产侧证据**:`http://203.0.113.10/health` 现在如实回报
`"device_token_required": true`,且不带令牌的 `agent:hello` 被 `E_UNAUTHORIZED` 拒。
---
## 五、验收记录(2026-09-15,全部从公网发起)
```
[A] HTTP 层(经 1Panel OpenResty:80)
✓ /healthz 返回 ok ✓ /health 是网关健康 JSON
✓ device_token_required=true(开关真生效) ✓ checks.redis/postgres 均 ok
✓ /api/* 已路由到业务服务(不是 502) ✓ / 返回人话落地页(不是 404)
[B] Socket.io 握手:✓ engine.io 轮询握手成功
[C] WebSocket 传输 + 令牌校验:✓ WS 连通(Upgrade 头配对) ✓ 无令牌 hello 被 E_UNAUTHORIZED 拒
[D] 未授权订阅:✓ 被 E_NOT_PAIRED 拒
通过 10 / 失败 0
```
库结构与策略:
```
8 张表齐全:users / sessions / devices / device_tokens / device_grants /
subscriptions / audit_log / device_metrics
超表:device_metrics(1 维);TimescaleDB 策略任务 5 个(保留/压缩/连续聚合)
容器:4 个 healthy,内存 gateway 73MB / business 74MB
```
**真机(F50 / Android 13 / 蜂窝数据)走通** —— 客户端侧证据(`adb logcat | grep "\[墨观\]"`):
```
[墨观] 已连接网关 http://203.0.113.10
[墨观] 已上报 hello(platform=android)
[墨观] 服务端错误: {"v":1,"type":"error","ts":...,"payload":{"code":"E_UNAUTHORIZED",
"reason":"本网关要求采集端携带设备令牌(在采集页登录并认领本机后可取得)"}}
```
界面侧同时显示「在线 · 设备 android-samplephone-6f0d2e · 目标 http://203.0.113.10 · socket 已连接」。
**这条日志就是 `REQUIRE_DEVICE_TOKEN=1` 在生产环境生效的直接证据**,也说明
"公网可达 + OpenResty 的 WS 升配 + 设备令牌校验"三段都通了;
只差"登录 + 认领"这一步(需用户本人身份,见第三节)。
---
## 六、遗留与注意
1. **无域名 → 无 TLS**:客户端目前走明文 `http://`,与方案 9.1「强制 WSS」冲突。
属过渡形态;加 `pmon.example.com`(Cloudflare A 记录)+ 1Panel Let's Encrypt 后即转生产形态。
2. **该机无 swap**:部署后内存约 1.6/3.5 GB,正常;建议加 2 GB swapfile 防 OOM 杀 PG。
3. **云防火墙全端口放通**:真正的边界靠"只绑回环 + 只由 OpenResty 暴露 80/443"。
若日后有人把 gateway/business 改回 `0.0.0.0`,等于开门 —— 已在
`docker-compose.prod.yml` 注释里写明理由。
4. **同机邻居**(2026-10-14 复核):`searxng-core`(8080) / `searxng-valkey` / `meilisearch`(7700) / 1Panel
(`market-sentiment` 看板已停用,不在跑)。部署与构建时注意别在它们抓取高峰做重活。
5. **`pmon-ip.conf` 不在 1Panel 管理内**:1Panel 的站点操作不会覆盖它,但也**不会帮你备份**。
内容已同时留在仓库(`docs/deploy-tencent-assessment.md` 与本文件)。
6. 该实例 **2027-04-22 到期且需手动续费**。
---
## 七、单容器部署(all-in-one,2026-10-14 新增,可选)
> 动机:一个容器跑全部四个进程(postgres / redis / gateway / business),
> 常驻容器从四个变成一个(外加一个跑完即退的 migrate)。
> **应用代码零改动** —— 合容器不合进程:四个进程仍是独立进程,由 supervisord
> 各自守护、各自重启,因此全部安全测试原样有效(本地已用项目测试套件
> 对单容器实跑过:配对安全 84 / 账号 65 / 安全开关 36 / 端到端 35 /
> 设备归并 31 / 竞态残留 3,共 254 项全绿)。
### 7.1 与四容器路径的关系(关键:数据卷共用,可随时互切)
| | 四容器路径 | 单容器路径 |
|---|---|---|
| 编排文件 | `docker-compose.yml` + `docker-compose.prod.yml` | `docker-compose.all.yml` |
| 镜像定义 | `Dockerfile.gateway` + `Dockerfile.business` + 官方 pg/redis 镜像 | `Dockerfile.all`(一个) |
| 常驻容器 | gateway / business / postgres / redis 四个 | 一个 `pmon-app-1` |
| 一次性容器 | migrate(跑完即退) | migrate(跑完即退,同一设计) |
| 数据卷 | `pmon_pg_data` / `pmon_redis_data` | **同名同卷**(project name 都是 `pmon`) |
| 接入层 | 1Panel OpenResty 反代不变 | 不变(`/socket.io/` → 3000、`/api/` → 4000) |
**两条路径共用同一个 compose project(`name: pmon`)→ 数据卷名不变 →
可随时互切,数据不动。** 切换就是换 `-f` 参数(同一时刻只能跑一条路径,
端口 3000/4000 绑定冲突;`down` 不删卷):
```bash
cd ~/pmon/deploy
# 四容器 → 单容器
docker compose -f docker-compose.yml -f docker-compose.prod.yml down
docker compose -f docker-compose.all.yml up -d --build
# 单容器 → 四容器(回退)
docker compose -f docker-compose.all.yml down
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
### 7.2 构建与部署
`Dockerfile.all` 以 `timescale/timescaledb:2.17.2-pg16` 为基底(**Alpine,
apk 装 redis/supervisor**,COPY node:22-alpine 的 node 二进制),
四个进程由 `deploy/supervisord.conf` 守护:
```bash
# 服务端(必须脱离 ssh 会话,否则前台构建会被本地 SIGKILL(137))
cd ~/pmon/deploy && setsid nohup docker compose -f docker-compose.all.yml \
up -d --build > /tmp/pmon-all-up.log 2>&1 &
docker compose -f docker-compose.all.yml ps # app healthy + migrate Exited(0)
docker exec pmon-app-1 supervisorctl status # 四进程 RUNNING(可单进程重启)
curl -s localhost:3000/health | head -c 300 # 网关(persistence 应为 postgresql)
curl -s localhost:4000/health | head -c 300 # 业务
docker logs pmon-app-1 # 四进程日志一把看全
```
日常运维:
```bash
docker exec pmon-app-1 supervisorctl restart business # 只重启业务进程,不动其他
docker exec pmon-app-1 psql -U pmon -d pmon # 直连库(PG 发布了回环 5432)
docker exec pmon-app-1 redis-cli # redis 未发布到宿主机,进容器用
```
### 7.3 与四容器路径的差异(如实清单)
| 项 | 差异 | 说明 |
|---|---|---|
| 健康检查 | 四合一 | 一个 healthcheck 同时判 pg/redis/gateway/business(`deploy/healthcheck-all.sh`);任一不掉谎。实测:进程降级运行时检查拒绝转绿 |
| 启动门 | 自己写 | 单容器没有 compose 依赖图,「等 pg/redis 就绪再起 node」由 `start-gateway.sh` / `start-business.sh` 承担(复刻 `depends_on: service_healthy`)。否则全新卷首启时 node 抢在 PG 初始化前,重试耗尽后 FATAL 停摆 |
| redis 宿主机映射 | **取消** | redis 只绑容器内回环,不暴露给同桥接网络的其他容器;需要时 `docker exec` 进容器 |
| PG 宿主机映射 | 保留(回环) | `127.0.0.1:5432`,本机 psql 排障照旧 |
| 端口绑定 | 回环 | 与四容器路径同一纪律(云防火墙全端口放通);由 `tests/dockerfile-coverage-test.js` 第 6 段断言看守 |
| 安全开关 | 一处传全 | 两个进程同读一份环境,不存在「传了一个漏一个」;覆盖性仍由测试第 5 段按代码派生清单断言 |
| 升级成本 | 重建整镜像 | 含数据库引擎;Alpine 基底镜像约 500MB 级,构建约半分钟 |
| 重启影响 | 四进程齐重启 | 容器重启才齐重启;单个进程崩只拉那个(supervisord 独立守护) |
| redis 版本 | 7.2(Alpine 源) | 四容器路径锁 7.4-alpine;用法兼容(GET/SET/EX/PUBSUB/allkeys-lru) |
| 进程隔离 | 弱于四容器 | redis/两个 node 进程共享容器边界;postgres 仍由官方 entrypoint 降权,两个 node 进程跑 `node` 用户 |
| business 启动参数 | 单容器多给堆上限 | 四容器 business 是裸 `node index.js`(Dockerfile.business CMD,与 gateway 的 `--expose-gc --max-old-space-size=256` **不对称**);单容器 start-business.sh 传全套(2026-10-16 审计记录)。方向是增强:四进程共宿给每个 node 256MB 上限属资源纪律,且业务服务是 REST 控制面、负载远低于网关。代价是 business 堆超 256MB 会 FATAL —— 四容器路径下不会(用默认上限)。**两条路径行为差异仅此一处,记在这里备查** |
### 7.4 护栏(都在 tests/ 里,删掉即红,已做变异测试)
- `tests/dockerfile-coverage-test.js` 第 1/2 段:`Dockerfile.all` 的 require 图
覆盖性(两个入口分别断言,漏拷即红 —— 复刻「镜像构建成功但容器启动
Cannot find module」那类事故的防线);
- 第 5 段:安全开关通路(代码派生清单 → .env.example → 单容器 compose 的 app 服务);
- 第 6 段:单容器 compose 端口必须全部绑 127.0.0.1。
### 7.5 扩容口径
单容器是**单机运维形态**。将来若要水平扩容(多 gateway 实例 + Redis Adapter),
回到四容器路径按方案 V4.0 第 11 章扩 —— 数据卷直接复用,无需迁移。
---
> 本文为技术运维说明,不构成投资建议或商业承诺。
-159
View File
@@ -1,159 +0,0 @@
# 目标设备事实卡:iReader Neo 2
> 来源:2026-09-16 设备上「电纸书助手」导出的设备报告(**实测,非资料推测**)。
> 此后调版面一律以本卡为准,不再靠网上参数猜。
## 一、硬件与系统
| 项 | 实测值 | 对墨观的意义 |
|---|---|---|
| 型号 | **iReader Neo 2**(代号 `RM06E-CKCW`) | 唯一目标机型 |
| 系统 | **Android 10(API 29)** | ⚠️ 此前注释写成 Android 11,**已订正** |
| 构建指纹 | `iReader/RM06E-CKCW/RM06E-CKCW:10/QP1A.190711.020/eng.songho.20260819.172813:user/release-keys` | **eng. 工程版**,非 user 正式版;可用于精确识别 |
| CPU | 联发科 **MT8175B** · 4 核 @2.00GHz · **aarch64** | 与 APK 的 `arm64-v8a` 匹配 ✅ |
| RAM | **2.0 GB**(报告显示可用约 1.4 GB) | Flutter 常驻要盯内存 —— 自检页可直读 RSS |
| 存储 | **32 GB**(可用约 14 GB) | 19MB 的 APK 无压力 |
| 屏幕 | **1072 × 1448 px · 6.0″ · 300 PPI** | 见下「逻辑尺寸」 |
| **传感器** | **0 个** | ★ 见下「无传感器」 |
| OTG | 支持 | (未用到) |
| 电量 | 100% · 循环 50 次 · 4.36V · 31.3℃ | 电池健康不可读 |
## 二、★ 无传感器 → 一个设计结论
**这台设备没有任何传感器**(无加速度计/陀螺仪)。后果:
- **不能靠"转动设备"换方向** —— 系统拿不到姿态,自动旋转在这台机器上等于不存在;
- 因此横屏/竖屏**只能由软件锁定**,看板里的**「转屏」按钮不是便利功能,而是唯一的方向入口**。
谁能省,它不能省 —— 省掉它,用户在竖屏机器上永远看不到横屏版面。
- 现状:由 `viewer_page` 的 `SystemChrome.setPreferredOrientations` 锁(不依赖传感器,反而更可靠),
看板只管发指令、保持纯展示。**这条设计经由本卡确认是对的,不是过度设计。**
## 三、逻辑尺寸:一个仍待实测的数(`densityDpi`)
看板版面(列数、字号、格高)完全取决于**逻辑 dp**,而 dp = 物理像素 ÷ (`densityDpi`/160)。
报告给出的「密度: 300 dpi」有两种可能,**含义不同**:
| 若 300 是… | density | 逻辑尺寸 | 落到的档位 |
|---|---|---|---|
| **系统 `densityDpi`** | 1.875 | **≈ 572 × 772 dp** | 竖屏 2 列 / 横屏 3 列 |
| 由分辨率+尺寸算出的 **PPI**(300.3)→ 真实 densityDpi 未知 | 2.0(常见档) | ≈ 536 × 724 dp | 竖屏 2 列 / 横屏 3 列 |
**好消息:两种假设下版面决策完全一致**(都由"宽高比"决定列数),
所以不必先确定它就能把看板调对 —— 只影响字号微调。
**事后核对**(不阻塞开发):看板底部点「自检」,第一行就是
`逻辑尺寸 xx × xx dp` 与 `设备像素比 x.xxx`(= `densityDpi`/160)。
(自检页的来由:这台机器 USB 调试打不开,**屏幕是唯一的取数通道**;
但它还有更实际的用途 —— 内存占用与实际刷新计数。)
### ★ 不阻塞:用现有参数已把版面算出来了
不必等上机 —— 把两种假设的逻辑尺寸喂给看板自己的代码(widget 测试),
就能量出真机版面:
| 假设 | 方向 | 格区 (dp) | **数值字号 (dp)** | 列数 |
|---|---|---|---|---|
| A(572×772) | 竖 | 540 × 647 | **40.0** | 2 |
| A | 横 | 772×572 → 740 × 447 | **37.2** | 3 |
| B(536×724) | 竖 | 504 × 599 | **38.5** | 2 |
| B | 横 | 724×536 → 692 × 411 | **33.2** | 3 |
**两种假设下都合格**:列数一致(竖 2 / 横 3,由宽高比决定);数值字号 33~40dp,
是标签(17dp)的两倍以上 —— 「数值是主角」这条立得住。
⚠️ 一个容易读错的点:**widget 测试的字形是"正方形"的**(每字宽 ≈ 字号),
比真机字体宽得多,所以上表的字号是**下限**;真机上 `239.4 GB` 这类长值
基本能开到满档 40dp。**真机效果只会更好,不会更差。**
所以 `densityDpi` 的精确值**不影响任何设计决策**,只影响"事后核对"。
自检页仍保留 —— 它还有别的用途(内存占用、实际刷新计数)。
## 四、Android 10(API 29)兼容性 —— 已核
| 关注点 | 结论 |
|---|---|
| `minSdk` | 满足(APK 要求远低于 29) |
| `POST_NOTIFICATIONS`(API 33) | 代码用 `SDK_INT >= TIRAMISU` 守卫,29 上走 else ✅ |
| `foregroundServiceType` | `dataSync` 正是 API 29 引入的,29 上有效 ✅;`specialUse`(API 34) 在 29 被忽略 ✅ |
| `FLAG_KEEP_SCREEN_ON` | 可用 ✅ |
| 存储 | 只用应用私有 `filesDir`,不涉及分区存储 ✅ |
| Kotlin 里的版本判定 | `>= O` / `>= TIRAMISU` / `>= UPSIDE_DOWN_CAKE` 全部有守卫,29 上取安全分支 ✅ |
| ABI | 设备 aarch64 与 APK `arm64-v8a` 匹配 ✅ |
## 五、取数通道(重要)
**这台设备开不了 USB 调试**:`adb devices` 看不到它。
用 `locationID` 关联确认它只暴露 `255/255/0`(掌阅自己的 PC 传输接口),
而全系统唯一的 ADB 接口(子类 66)属于另一台设备。
所以:
- 装包走**设备官方路径**:把 APK 拷进 `iReader/APK` → 应用市场右上角菜单 → 安装;
- 取数据走**应用内「自检」页**(见 `ui/eink_board.dart` 的 `showDiagnostics`),
一张截图即可拿到 dp / dpr / 列数 / 内存。
## 六、★ 系统不支持转屏 → 必须"应用内旋转"(2026-09-17 实测)
真机反馈「**转屏功能无法使用**」。排查结论:**不是代码没接上,是系统不理** ——
`SystemChrome.setPreferredOrientations` 发出去之后窗口尺寸**纹丝不动**,
界面上当然"没反应"。这与本卡第二节的「零传感器」完全自洽:
没有加速度计,SmartOS 层面就不做方向变化。
⚠️ 由此得一条通用认识:**发了请求不等于生效**。
能验证的只有窗口尺寸本身 —— 所以现在请求后会**回读尺寸**,
没变成想要的那样就切到应用内旋转。
### 兜底方案:应用内旋转
`RotatedBox(quarterTurns: 1)` 把内容自己转 90°。它的关键性质是
**交换子节点的约束**(窗口 1072×1448 → 子节点拿到 1448×1072),
于是横屏布局正好按 1448×1072 排布、再整体旋转 90° **铺满物理屏**:
- ✅ **不需要缩放,字号一点不损失**(若用 `Transform.rotate` + 缩放就做不到了)
- ✅ 命中测试跟着旋转,按钮照常可点
- ⚠️ 代价:系统 UI 不参与旋转,**用户要把设备物理横置**才是正的
### 怎么验证它在生效(自检页已能一眼看出)
自检页新增三行:
| 行 | 含义 |
|---|---|
| 窗口(物理) | `MediaQuery` 的真实尺寸 —— 系统转屏成功时才会变 |
| 布局(看板用) | 看板拿到的约束 —— 应用内旋转时它与窗口**不相等** |
| 转屏方式 | `系统` / `应用内(请把设备横置)` |
**两尺寸不相等 = 在用应用内旋转**;相等 = 系统转屏成功。
测试守护:`eink_board_test` 里有一条「竖屏窗口里也排出 3 列」——
断言物理窗口是 536×724(竖),而看板列数是 3(横)。
**一竖一横并存**,即证明 `RotatedBox` 真的交换了约束。
> ⚠️ 写这条测试时踩到一个坑:不能沿用按屏幕 y 坐标分行的 `columnsOf()`
> —— `RotatedBox` 之后 `getTopLeft` 返回的是**旋转后的**屏幕坐标,
> 同一行会变成同一列。**度量方法本身会失效,别把"量不出来"当成"功能没实现"。**
## 七、看板要全屏:系统栏与主壳导航**都要**收起来(2026-09-17 实测)
真机反馈「开看板后底下的导航键没隐藏」。**两个成因,都要治**:
| 层 | 成因 | 修法 |
|---|---|---|
| **系统导航栏** | 代码里**从未**调过 `setEnabledSystemUIMode` | 进看板 → `immersiveSticky`;退出 → `manual` + 全部 overlays |
| **应用自己的底部导航** | 主壳是 `IndexedStack` + `bottomNavigationBar`,而那是 Scaffold 的**独立区域** —— 看板渲染在 body 里,它一无所知 | 主壳订阅 `PmonEinkBoardMode.active`,看板前台时不渲染导航 |
细节取舍:
- **`immersiveSticky` 而非 `immersive`**:前者在用户误触屏幕边缘后**会自动重新隐藏**。
副屏是常驻的,不该被一次滑动永久唤出系统栏。
- **恢复用 `manual` + `SystemUiOverlay.values` 而非 `edgeToEdge`**:后者要求 API 29+,
前者全版本可用,且语义就是"把系统栏都还回来"。
- **判定只有一处**:`_syncBoardChrome()` 用同一个 `_eink && !_einkPaused` 同时驱动
系统栏显隐与主壳导航 —— 本项目已因"同一判据写两处"反复踩坑,不再重演。
- `PmonEinkBoardMode` 用 `ValueNotifier`(可监听),而 `PmonEinkMode` 是静态 bool ——
因为前者要**触发主壳重建**,后者只在整树重建时被读一次。
> ⚠️ 两个遗留:① 系统栏显隐**无法在 widget 测试里断言**(那是平台通道),
> 故测试守的是"唯一真相源" `PmonEinkBoardMode.active`;
> ② 顺带修掉一个隐患:转屏校验原先用 `Future.delayed`(**不可取消**),
> 已改成可取消的 `Timer` 并在 `dispose` 里取消 —— 否则页面销毁后回调仍会醒来。
-215
View File
@@ -1,215 +0,0 @@
# 文件传输与局域网专项功能 · 技术方案
> 2026-09-18 立。**基于现有代码与部署实况**(非通用方案):
> 网关 `socket.io` 的 agent→gateway→viewers 订阅模型、`shared/protocol.js` 的净水规则、
> 生产为腾讯云轻量 **2C / 3.5G 内存 / 60G 盘(可用 45G)**。
## 零、结论先给
| 诉求 | 判定 | 依据 |
|---|---|---|
| **不落服务端存储** | ✅ **完全可行** | 三条路径都不需要服务端写盘——数据面可以完全绕开服务器 |
| **端到端传输** | ✅ 可行,但**分场景** | 同网段直连 100% 成功;公网 P2P 受 NAT 影响,**必须有兜底** |
| **支持超出服务器磁盘的大文件** | ✅ 可行 | 流式分片;服务端**不参与数据面**,容量就不再是瓶颈 |
### 但先要说清三条硬约束(否则设计会打脸)
**① 现有协议不能承载文件 —— 这不是"要不要改"的问题,是"根本走不通"**
`shared/protocol.js` 的净水规则(实测常量):
| 边界 | 值 | 位置 |
|---|---|---|
| 字符串长度上限 | **512 字节** | `MAX_STRING_LEN` |
| 单层键数上限 | **64** | `MAX_PAYLOAD_KEYS` |
| 嵌套深度 | **1** | `MAX_NEST_DEPTH` |
它是为**小控制消息**设计的(`capabilities` / `keepalive` 这类结构化字段)。
一个 1MB 的分片连第一个约束都过不去。
→ **文件必须走独立通道**,不能塞进 `agent:metrics` 那套。
(顺带:`sanitizePayload` 里那段"**对象一律丢弃**"的注释是**历史缺陷记录、已修** ——
现在允许一层嵌套对象。`capabilities` 机制是可用的,这点后面要用。)
**② 比磁盘更该担心的是"流量包",不是"存储"**
腾讯云轻量按**流量包**计费。若让服务端做中继:
**一个 10GB 文件 = 10GB 上行 + 10GB 下行 = 双倍计费**。
所以"不落盘"只是第一步 —— **"不经过服务器"才是关键**。这两件事必须分开说,
否则会做出一个"省了磁盘、烧光流量"的方案。
**③ P2P 不是总能打通**
双方都在对称 NAT / 运营商级 NAT(CGNAT)后面时,直连会**失败**。
失败就得走中继 —— 而中继就是 ②里那个烧流量的东西。
**这个取舍必须显式做出来、并且让用户知情**,不能默默降级。
---
## 一、文件传输:三方案对比与推荐
| | **A 同网段直连** | **B 公网 P2P(WebRTC)** | **C 服务端流式中继** |
|---|---|---|---|
| 服务端存储 | 0 | 0 | 0 |
| 服务端流量 | **0** | ≈0(仅信令,几 KB) | **文件大小的 2 倍** |
| 成功率 | 100%(同网段) | 约 70~90%(看 NAT) | 100% |
| 实现成本 | 低 | 中高(STUN/TURN + 原生插件) | 低 |
| 大文件 | ✅ 不受限 | ✅ 不受限 | ⚠️ 受流量包限制 |
### 推荐顺序:**A 优先 → B 兜底 → C 默认关闭**
理由是**收益/代价比**,不是技术偏好:
- **A 零成本零风险**:局域网千兆,速度最快,服务端一分钟都不参与
- **B 零服务端流量**,但要引入 `flutter_webrtc`(原生插件,**显著增重安装包**)——
对一个已经 19MB 的 APK 来说,这笔账要单独算
- **C 会烧流量包**,只能作为"用户明确同意"时的兜底
> ⚠️ 诚实提示:如果目标只是"把文件从 A 传到 B",**A 方案 + 一个可选的中继**
> 往往比引入 WebRTC 全家桶更划算。B 的价值在**跨公网不走服务器**,
> 若实际场景大多是"同一 Wi-Fi 下传",B 可以先不做。
### 共同骨架:信令三拍(服务端只做媒人)
```
发起端 → 服务端 : transfer:offer {toDeviceId, name, size, sha256, mode:'lan'|'p2p'}
服务端 → 接收端 : transfer:offer (转发,仅信令)
接收端 → 服务端 : transfer:accept {sessionId, endpoint} 或 reject
服务端 → 发起端 : transfer:accepted {endpoint}
── 此后数据面双方直连,服务端不再参与 ──
```
**信令复用现有通道即可**:`transfer:*` 是标量 + 一层对象的形状,
**不需要动 `protocol.js` 的边界**(那 512/64/1 的规则照样满足)。
### A. 局域网直连(主路径,建议先做)
- **传输**:客户端内置一个**只绑内网**的 HTTP 服务(Dart 原生 `HttpServer`,零新依赖)
- 接收端 `GET` + `Range` 头 ⇒ **断点续传白送**
- 一次性 token 鉴权(**不能只凭同网段**,见第二节的红线)
- **地址交换**:双方把 `192.168.x.x:port` 告诉服务端,服务端只做媒人
- **不做 mDNS/SSDP**:跨平台差异大、且会主动广播"我在这里";
用现成通道交换地址已经够,还少一份要维护的发现协议
- **数据不经过服务器** ⇒ 不需要 TURN、不产生任何流量 ✅
### B. 公网 P2P(跨公网时)
- WebRTC DataChannel;信令走现成 socket.io
- **STUN 用自建 `coturn` 只开 STUN**(几乎零流量)
- **TURN 默认不开**;要开必须叠加:并发上限 + 限速 + 单次配额 + **用户勾选确认**
- ⚠️ 依赖代价:`flutter_webrtc` 会给包体与构建复杂度加一块原生负担
### C. 服务端流式中继(兜底,默认关闭)
- 网关加 `transfer:pipe`:**纯流转发、不落盘**(`stream.pipe`)
- 必须配套 4 件事:并发上限(如 2)、限速、单次配额、开关
- **默认关闭**;开启时界面写明"**本次传输会消耗服务器流量,约 X**"
### 可靠性与校验(三方案共用)
| 机制 | 做法 |
|---|---|
| 分片校验 | 每片 sha256,失败**只重传单片** |
| 整体对账 | 收完比对整体 sha256 |
| 断点续传 | A/C 用 `Range`;B 用 offset 协商 |
| 中断恢复 | **服务端不持有副本**,恢复依据是双方各自的分片状态 |
### 改动落点
| 位置 | 改动 |
|---|---|
| `shared/protocol.js` | **不动**(信令形状在现有边界内) |
| `server/gateway/` | 加 `transfer:*` 信令转发(与现有订阅转发同构) |
| `client/lib/host/` | 新增 `transfer.dart`:会话状态机 + A 方案的 HTTP 服务/客户端 |
| `client/lib/ui/` | 新增传输卡片(进度 / 校验 / 重试) |
| `deploy/` | 仅在启用 C 时才要动 nginx;**A/B 零改动** |
---
## 二、局域网专项功能
### ⚠️ 先划一条合规红线(**必须**)
> **同网段不能成为"免授权"的理由。**
方案 2.1 的定位是「监控**自己或经明确授权的**设备」,且**不提供任何绕过对方操作的
隐蔽监控能力** —— 这是过审与杀软信誉的前提。
若做成"**连上同一 Wi-Fi 就能看到**",等于把"物理接近"变成了授权依据 ——
那正是**隐蔽监控的形态**,与产品定位直接冲突。
所以三条底线:
- ✅ 局域网功能**可以做**,但**授权关系仍由配对码 / 账号归属决定**
- ✅ 局域网只降低**流量与延迟**,**不降低授权门槛**
- ❌ **不做**"同网段自动发现并自动进入设备列表"
### 五项功能与实现
**① 局域网直连(LAN 优先)** — 收益最大,**先做**
- 有内网地址就走内网,公网地址作备用
- **收益**:延迟从几十 ms 降到几 ms;**1 秒一刷不再担心烧流量**
(对水墨屏常驻看板这项最有感)
- **实现**:内网 endpoint 随 `agent:hello` 的 `capabilities` 上报(一层对象,已支持);
查看端连接时**先试内网、超时再回公网**
- **切换必须无感且不丢帧** —— `seq` 去重与补传机制已具备,可直接承接
**② 局域网高速采样** — 顺带就有
- 公网默认 10 秒节流(水墨屏),**局域网允许 1 秒**(或用户选)
- 只是配置下发的一个分支:`capabilities.lan === true` → 下发更短间隔
- **不需要任何新协议**
**③ 局域网大文件传输** — 即第一节的 A 方案,局域网下无需任何中继
**④ 断公网可用(本地兜底)** — 最后做,需单独评估
- 公网断了,局域网内仍可看**最近历史**
- 实现:采集端本地保留**有界环形缓冲**(如最近 2 小时原始帧),
查看端断网时直连拉取
- ⚠️ 代价:采集端要落盘。必须**有界 + 配额 + 可一键清空**,且只存指标
**⑤ 链路自检:让用户看得见走了哪条路**
- 显示「当前走内网 / 公网」、内网 IP、估计带宽
- **没有它,①②的效果就无法被验证** —— 与本项目"可验证"的纪律一致
- 复用已有诊断面板(自检页那一套)
### 实现顺序
1. **① + ②** —— 收益最大、改动最小
2. **⑤** —— 紧接着,否则①②无法验证
3. **③**(A 方案)—— 独立特性,可并行
4. **④** —— 涉及采集端落盘,最后评估
### 明确不做(附理由)
| 不做 | 理由 |
|---|---|
| 同网段自动发现 + 免授权 | **合规红线**(见上) |
| mDNS / SSDP 广播发现 | 跨平台差异大,且主动暴露"我在这里";服务端交换地址已足够 |
| 局域网内绕过服务端建会话 | 会话建立仍需服务端裁决授权;局域网只承载**数据面** |
| 默认开启 TURN / 中继 | 会烧流量包,必须用户显式选择 |
---
## 三、风险清单
| 风险 | 影响 | 处置 |
|---|---|---|
| 中继吃光流量包 | 服务不可用 | **默认禁用**;开启时限速 + 配额 + 用户确认 |
| 内置 HTTP 服务被同网段扫描 | 信息泄露 | 一次性 token + **只绑内网** + 传完即关 |
| 采集端落盘撑爆手机存储 | 应用不可用 | 有界环形缓冲 + 配额 + 一键清空 |
| `flutter_webrtc` 增重 | 安装包变大 | **先算实际增重再定**;优先 A 方案 |
| 局域网误判(公司网/同名网段) | 连到别人的设备 | 校验 `device_id` + 令牌,**不能只凭 IP** |
---
## 四、给决策的两句话
1. **A 方案(同网段直连)几乎零代价、零风险,可以先落地** —— 它同时解决了
"不落服务端""端到端""大文件不限"三个诉求中最实在的部分。
2. **跨公网的大文件传输要不要做,取决于一个取舍**:要么接受"直连失败就传不了",
要么接受"用中继、烧流量包"。**这个取舍得由人拍板,不该由代码默默决定。**
File diff suppressed because it is too large. Load diff
-138
View File
@@ -1,138 +0,0 @@
{
"gateway": "http://127.0.0.1:3000",
"business": "http://127.0.0.1:4000",
"at": "2026-10-14T13:41:51.476Z",
"results": {
"metrics": {
"scenario": "metrics",
"device_id": "dev_bench_mv85sbn8",
"viewers": 3,
"steady": {
"sent": 1000,
"received_per_viewer": [
1000,
1000,
1000
],
"wall_sec": 14.5,
"send_rate": 69,
"latency_ms": {
"n": 3000,
"min": 2,
"p50": 3,
"p90": 3,
"p99": 11,
"max": 22,
"mean": 2.84
}
},
"burst": {
"sent": 2000,
"received_per_viewer": [
2000,
2000,
2000
],
"wall_sec": 0.21,
"receive_rate": 9302.3,
"latency_ms": {
"n": 6000,
"min": 1,
"p50": 2,
"p90": 3,
"p99": 4,
"max": 36,
"mean": 2.37
}
},
"memory": {
"heap_used_before": 14,
"heap_used_after": 16,
"heap_used_delta": 2,
"heap_total_after": 72,
"rss_before": 76,
"rss_after": 133,
"sessions_before": 5,
"sessions_after": 5
},
"server_counters": {
"duplicate_seq_dropped": 0,
"storage_dropped": 0,
"storage_queue_after": 1,
"seq_window_devices_after": 7
}
},
"churn": {
"scenario": "churn",
"cycles": 25,
"subscribe_ms": {
"n": 25,
"min": 0,
"p50": 1,
"p90": 2,
"p99": 4,
"max": 4,
"mean": 1.16
},
"unsubscribe_ms": {
"n": 25,
"min": 0,
"p50": 1,
"p90": 1,
"p99": 1,
"max": 1,
"mean": 0.64
},
"subs_left": 0
},
"offer": {
"scenario": "offer",
"sessions": 200,
"baseline": {
"n": 50,
"min": 1,
"p50": 3,
"p90": 3,
"p99": 4,
"max": 4,
"mean": 2.6
},
"sessions_actual": 203,
"with_sessions": {
"n": 50,
"min": 2,
"p50": 3,
"p90": 4,
"p99": 5,
"max": 5,
"mean": 2.88
},
"delta_p50": 0
},
"scan": {
"scenario": "scan",
"sizes": {
"100": {
"scan_us": 2.389,
"index_us": 0.018,
"ratio": 134.1
},
"1000": {
"scan_us": 20.106,
"index_us": 0.038,
"ratio": 527.8
},
"5000": {
"scan_us": 88.053,
"index_us": 0.033,
"ratio": 2631.7
},
"10000": {
"scan_us": 167.132,
"index_us": 0.011,
"ratio": 14662.8
}
}
}
}
}
-138
View File
@@ -1,138 +0,0 @@
{
"gateway": "http://127.0.0.1:3000",
"business": "http://127.0.0.1:4000",
"at": "2026-10-14T12:41:35.625Z",
"results": {
"metrics": {
"scenario": "metrics",
"device_id": "dev_bench_mv83mtmx",
"viewers": 3,
"steady": {
"sent": 1000,
"received_per_viewer": [
1000,
1000,
1000
],
"wall_sec": 14.56,
"send_rate": 68.7,
"latency_ms": {
"n": 3000,
"min": 1,
"p50": 2,
"p90": 3,
"p99": 5,
"max": 14,
"mean": 2.28
}
},
"burst": {
"sent": 2000,
"received_per_viewer": [
2000,
2000,
2000
],
"wall_sec": 0.21,
"receive_rate": 9345.8,
"latency_ms": {
"n": 6000,
"min": 1,
"p50": 8,
"p90": 15,
"p99": 15,
"max": 36,
"mean": 8.61
}
},
"memory": {
"heap_used_before": 14,
"heap_used_after": 36,
"heap_used_delta": 22,
"heap_total_after": 85,
"rss_before": 77,
"rss_after": 143,
"sessions_before": 5,
"sessions_after": 5
},
"server_counters": {
"duplicate_seq_dropped": 0,
"storage_dropped": 0,
"storage_queue_after": 0,
"seq_window_devices_after": 3
}
},
"churn": {
"scenario": "churn",
"cycles": 25,
"subscribe_ms": {
"n": 25,
"min": 0,
"p50": 1,
"p90": 2,
"p99": 3,
"max": 3,
"mean": 1.16
},
"unsubscribe_ms": {
"n": 25,
"min": 0,
"p50": 1,
"p90": 1,
"p99": 3,
"max": 3,
"mean": 0.68
},
"subs_left": 0
},
"offer": {
"scenario": "offer",
"sessions": 200,
"baseline": {
"n": 50,
"min": 1,
"p50": 3,
"p90": 7,
"p99": 13,
"max": 13,
"mean": 3.84
},
"sessions_actual": 203,
"with_sessions": {
"n": 50,
"min": 2,
"p50": 4,
"p90": 5,
"p99": 9,
"max": 9,
"mean": 3.6
},
"delta_p50": 1
},
"scan": {
"scenario": "scan",
"sizes": {
"100": {
"scan_us": 2.434,
"index_us": 0.018,
"ratio": 134.6
},
"1000": {
"scan_us": 18.33,
"index_us": 0.033,
"ratio": 547.4
},
"5000": {
"scan_us": 81.705,
"index_us": 0.011,
"ratio": 7427.8
},
"10000": {
"scan_us": 183.167,
"index_us": 0.012,
"ratio": 15795.3
}
}
}
}
}
-852
View File
@@ -1,852 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全平台设备性能监控系统商业化实施方案 V4.0</title>
<style>
:root{
--bg:#0b1020; --bg2:#111834; --card:#151d3b; --card2:#1b2547;
--line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.14);
--txt:#e8ecf8; --dim:#98a3c4; --dim2:#6f7ba0;
--acc:#4f8cff; --acc2:#7c5cff; --ok:#34d399; --warn:#fbbf24; --danger:#f87171;
--up:#ff4d5e; --down:#22c55e;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg); color:var(--txt); line-height:1.85;
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
font-size:15px; -webkit-font-smoothing:antialiased;
}
.hero{
position:relative; padding:64px 40px 52px;
background:
radial-gradient(900px 420px at 12% -10%, rgba(79,140,255,.28), transparent 60%),
radial-gradient(760px 420px at 88% 0%, rgba(124,92,255,.26), transparent 62%),
linear-gradient(160deg,#0d1430 0%,#121a3c 55%,#0b1020 100%);
border-bottom:1px solid var(--line2); overflow:hidden;
}
.hero::after{
content:""; position:absolute; inset:0;
background-image:linear-gradient(rgba(255,255,255,.028) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.028) 1px,transparent 1px);
background-size:44px 44px; pointer-events:none;
}
.hero .inner{position:relative; z-index:2; max-width:1240px; margin:0 auto}
.badge{
display:inline-flex; align-items:center; gap:8px; font-size:12px; letter-spacing:.14em;
color:#cfe0ff; background:rgba(79,140,255,.16); border:1px solid rgba(79,140,255,.4);
padding:5px 14px; border-radius:100px; text-transform:uppercase; font-weight:600;
}
.hero h1{
font-size:40px; line-height:1.24; margin:20px 0 14px; font-weight:800; letter-spacing:-.4px;
background:linear-gradient(96deg,#ffffff 0%,#bcd2ff 52%,#9f8bff 100%);
-webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
}
.hero .sub{color:var(--dim); font-size:15px; max-width:900px}
.hero .meta{margin-top:22px; display:flex; flex-wrap:wrap; gap:10px}
.chip{
font-size:12.5px; color:var(--dim); background:rgba(255,255,255,.05);
border:1px solid var(--line2); padding:5px 13px; border-radius:8px;
}
.chip b{color:var(--txt)}
.wrap{max-width:1240px; margin:0 auto; padding:0 40px 90px}
.kpis{display:grid; grid-template-columns:repeat(auto-fit,minmax(232px,1fr)); gap:16px; margin:-30px 0 44px; position:relative; z-index:3}
.kpi{
background:linear-gradient(165deg,var(--card2),var(--card)); border:1px solid var(--line2);
border-radius:16px; padding:20px 22px;
box-shadow:0 14px 34px rgba(0,0,0,.34);
}
.kpi .k-top{display:flex; align-items:center; justify-content:space-between; gap:10px}
.kpi .k-label{font-size:12.5px; color:var(--dim); letter-spacing:.04em}
.kpi .k-dot{width:8px;height:8px;border-radius:50%;background:var(--acc);box-shadow:0 0 12px var(--acc)}
.kpi .k-dot.g{background:var(--ok);box-shadow:0 0 12px var(--ok)}
.kpi .k-dot.w{background:var(--warn);box-shadow:0 0 12px var(--warn)}
.kpi .k-dot.p{background:var(--acc2);box-shadow:0 0 12px var(--acc2)}
.kpi .k-val{font-size:23px; font-weight:800; margin:10px 0 4px; letter-spacing:-.3px}
.kpi .k-val small{font-size:13px; font-weight:600; color:var(--dim); margin-left:4px}
.kpi .k-note{font-size:12.5px; color:var(--dim2); line-height:1.6}
.layout{display:grid; grid-template-columns:236px 1fr; gap:36px; align-items:start}
nav.toc{position:sticky; top:22px; background:rgba(21,29,59,.6); border:1px solid var(--line);
border-radius:14px; padding:14px 10px; max-height:calc(100vh - 60px); overflow-y:auto}
nav.toc a{display:flex; gap:10px; align-items:baseline; text-decoration:none; color:var(--dim);
font-size:13px; padding:7px 10px; border-radius:8px; line-height:1.45}
nav.toc a b{font-size:11.5px; color:var(--dim2); min-width:20px; font-variant-numeric:tabular-nums}
nav.toc a:hover{background:rgba(79,140,255,.12); color:var(--txt)}
nav.toc a:hover b{color:var(--acc)}
main{min-width:0}
.sec{margin-bottom:46px; scroll-margin-top:20px}
h2{
font-size:22px; font-weight:750; margin:0 0 20px; padding-bottom:13px;
border-bottom:1px solid var(--line2); display:flex; align-items:center; gap:13px;
}
h2 .num{
display:inline-flex; align-items:center; justify-content:center; min-width:38px; height:32px;
padding:0 9px; font-size:14px; font-weight:800; color:#fff; border-radius:9px;
background:linear-gradient(135deg,var(--acc),var(--acc2));
box-shadow:0 5px 16px rgba(79,140,255,.34);
}
h3{font-size:16px; font-weight:700; margin:26px 0 12px; color:#dbe4fb; display:flex; align-items:baseline; gap:9px}
h3 .n3{font-size:12.5px; color:var(--acc); font-variant-numeric:tabular-nums; font-weight:700}
p{margin:11px 0; color:#ccd5ee}
strong{color:#fff; font-weight:700}
code{font-family:"SF Mono",Menlo,Consolas,monospace; font-size:12.8px; background:rgba(79,140,255,.14);
color:#a9c8ff; padding:2px 6px; border-radius:5px}
ul,ol{margin:11px 0 11px 8px; padding-left:22px}
li{margin:7px 0; color:#ccd5ee}
blockquote{
margin:16px 0; padding:14px 18px; background:linear-gradient(100deg,rgba(79,140,255,.1),rgba(124,92,255,.05));
border-left:3px solid var(--acc); border-radius:0 10px 10px 0; color:#c6d3f2; font-size:14.4px;
}
blockquote p{margin:6px 0}
.tcap{font-size:12.8px; color:var(--acc); font-weight:700; margin:20px 0 9px; letter-spacing:.02em}
.tw{overflow-x:auto; margin:0 0 20px; border:1px solid var(--line2); border-radius:12px; background:rgba(21,29,59,.5)}
table{width:100%; border-collapse:collapse; font-size:13.6px; min-width:520px}
thead th{
background:rgba(79,140,255,.14); color:#dfe8ff; font-weight:700; text-align:left;
padding:11px 14px; border-bottom:1px solid var(--line2); white-space:nowrap; font-size:13px;
}
tbody td{padding:10px 14px; border-bottom:1px solid var(--line); color:#c8d2ec; vertical-align:top}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover{background:rgba(79,140,255,.07)}
pre{background:#080d1c; border:1px solid var(--line2); border-radius:12px; padding:16px 18px;
overflow-x:auto; margin:16px 0}
pre code{background:none; color:#9fb6e8; padding:0; font-size:13px; line-height:1.7}
hr{border:none; border-top:1px solid var(--line); margin:30px 0}
.doc-title{display:none}
footer{border-top:1px solid var(--line); padding:26px 40px 40px; text-align:center; color:var(--dim2); font-size:12.6px}
@media(max-width:980px){
.layout{grid-template-columns:1fr}
nav.toc{position:static; max-height:none; display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
.hero{padding:44px 22px 40px} .hero h1{font-size:28px}
.wrap{padding:0 22px 70px} .kpis{margin-top:-24px}
}
</style>
</head>
<body>
<header class="hero">
<div class="inner">
<span class="badge">Architecture Baseline · V4.0</span>
<h1>全平台设备性能监控系统<br>商业化实施方案 V4.0</h1>
<p class="sub">Rust 采集核心 · 无状态网关 · 精简存储拓扑 · 内存安全与连接可靠性架构级约束。在保留「云端转发 + 端端一体」主架构前提下,做渐进式纵向深化重构。</p>
<div class="meta">
<span class="chip">文档定位 <b>开发执行基线</b></span>
<span class="chip">基于 <b>V3.0 重构</b></span>
<span class="chip">章节 <b>17 章</b></span>
<span class="chip">预估周期 <b>约 20 周</b></span>
</div>
</div>
</header>
<div class="wrap">
<div class="kpis">
<div class="kpi">
<div class="k-top"><span class="k-label">采集核心重构</span><span class="k-dot p"></span></div>
<div class="k-val">五端 → 一套<small>Rust</small></div>
<div class="k-note">采集 / 保活 / 传输下沉为单一 Rust 核心,FFI 供 Flutter 调用</div>
</div>
<div class="kpi">
<div class="k-top"><span class="k-label">资源占用</span><span class="k-dot g"></span></div>
<div class="k-val">&lt; 30<small>MB 常驻</small></div>
<div class="k-note">采集核心常驻内存红线,守护进程 &lt; 5 MB</div>
</div>
<div class="kpi">
<div class="k-top"><span class="k-label">存储组件</span><span class="k-dot g"></span></div>
<div class="k-val">3 → 2<small>件套</small></div>
<div class="k-note">VictoriaMetrics 并入 PostgreSQL + TimescaleDB</div>
</div>
<div class="kpi">
<div class="k-top"><span class="k-label">扩容方式</span><span class="k-dot"></span></div>
<div class="k-val">加实例<small>无需改码</small></div>
<div class="k-note">网关无状态化,连接态与订阅关系外置 Redis</div>
</div>
<div class="kpi">
<div class="k-top"><span class="k-label">断连恢复</span><span class="k-dot w"></span></div>
<div class="k-val">秒级<small>自动重连</small></div>
<div class="k-note">指数退避加随机抖动,抗重连风暴</div>
</div>
<div class="kpi">
<div class="k-top"><span class="k-label">新增核心章节</span><span class="k-dot p"></span></div>
<div class="k-val">2<small>章工程命门</small></div>
<div class="k-note">《资源与内存安全设计》《连接可靠性设计》</div>
</div>
</div>
<div class="layout">
<nav class="toc"><a href="#sec-1"><b>1</b><span>执行摘要与版本变更说明</span></a><a href="#sec-2"><b>2</b><span>产品定位与交付范围</span></a><a href="#sec-3"><b>3</b><span>总体架构设计</span></a><a href="#sec-4"><b>4</b><span>技术栈选型</span></a><a href="#sec-5"><b>5</b><span>通信协议规范</span></a><a href="#sec-6"><b>6</b><span>核心功能模块设计</span></a><a href="#sec-7"><b>7</b><span>多端适配与指标可得性</span></a><a href="#sec-8"><b>8</b><span>保活与后台存活机制</span></a><a href="#sec-9"><b>9</b><span>安全设计</span></a><a href="#sec-10"><b>10</b><span>数据存储与历史数据</span></a><a href="#sec-11"><b>11</b><span>部署与运维</span></a><a href="#sec-12"><b>12</b><span>资源与内存安全设计</span></a><a href="#sec-13"><b>13</b><span>连接可靠性设计</span></a><a href="#sec-14"><b>14</b><span>质量保障与发布策略</span></a><a href="#sec-15"><b>15</b><span>里程碑与排期</span></a><a href="#sec-16"><b>16</b><span>风险与合规</span></a><a href="#sec-17"><b>17</b><span>成本估算与总结</span></a></nav>
<main>
<h1 class="doc-title">全平台设备性能监控系统商业化实施方案 V4.0</h1>
<blockquote><strong>文档定位</strong>:本文档为 V3.0 的架构重构版。在保留「云端转发 + 端端一体」主架构前提下,对<strong>采集层、保活层、网关可扩展性、存储拓扑、协议传输</strong>五个方向做渐进式改造,并新增《资源与内存安全设计》《连接可靠性设计》两章,补齐 V3.0 未覆盖的两大工程命门。 本文档自本版起作为<strong>开发执行的基线文档</strong>(Baseline)。</blockquote>
<hr>
<section class="sec" id="sec-1">
<h2><span class="num">1</span>执行摘要与版本变更说明</h2>
<h3><span class="n3">1.1</span>项目概述</h3>
<p>本方案定义全平台设备性能监控系统的商业化开发路径。产品以 Flutter 统一实现查看端 UI,以 <strong>Rust 跨端核心库</strong>承载采集、保活、传输三类底层职责,通过云端(Node.js + Socket.io + Redis + PostgreSQL/TimescaleDB)转发实时指标:任一设备上的应用既上报本机数据,也可订阅查看已授权的其他设备。</p>
<p>V4.0 与 V3.0 的最大区别在于<strong>架构重心从"功能罗列"转向"工程可信"</strong>:采集与保活不再是五端各写一遍的散装代码,而是下沉为单一 Rust 核心;网关连接态从进程内存外置到 Redis,实现真正的无状态可扩容;存储组件由三件套精简为两件套;并首次将<strong>内存安全</strong>与<strong>连接可靠性</strong>提升为架构级设计约束而非事后注意事项。</p>
<h3><span class="n3">1.2</span>核心结论(执行摘要)</h3>
<p><strong>架构方向确认</strong>:云端转发、端端一体的主架构成立,V4.0 不推翻,只做纵向深化。</p>
<p><strong>采集层重构</strong>:五端各自实现的原生采集,收敛为单一 Rust 核心库(<code>pmon-core</code>),经 FFI 供 Flutter 调用。采样逻辑只写一遍,行为五端一致,内存占用显著低于 Dart 轮询方案。</p>
<p><strong>保活层下沉</strong>:自启注册、守护看护、进程管理统一抽象为可复用契约,各端只做薄封装,跨端保活行为可预期、可测试。</p>
<p><strong>网关无状态化</strong>:连接状态、订阅关系、在线态全部外置 Redis 并自带 TTL,网关进程内零长生命周期对象,可随意增减实例而不改代码。</p>
<p><strong>存储做减法</strong>:VictoriaMetrics 换为 <strong>PostgreSQL + TimescaleDB 扩展</strong>,同一实例承载时序与元数据,组件数由 3 降至 2,运维与资源占用双降。</p>
<p><strong>部署形态定案</strong>:新增 ADR-001 明确「Docker 为主 + 构建期本地编译」的三段式——多阶段构建在镜像内完成编译(镜像缩小 60–80%),运行期用 Docker Compose 编排(5 组件声明式管理、一行命令横向扩展),宿主机只装 Docker;开发环境仍本地直跑保留热重载效率。同时正视 Docker 在长连接场景的代价(fd 限制、网络 NAT、日志膨胀)并给出对冲配置。</p>
<p><strong>温度风险架构规避</strong>:不再自研 Windows 内核驱动(投入产出比差、触发杀软风险),Windows 温度默认不支持,通过能力集优雅降级;确有需求走"用户显式安装的已签名外置插件"路径,不污染主客户端信任链。</p>
<p><strong>协议双模</strong>:JSON 信封作为契约基线保留,高频 <code>agent:metrics</code> 增加二进制编解码选项(MessagePack),预计下行带宽再降 40–60%。</p>
<p><strong>两大工程命门前置</strong>:新增《资源与内存安全设计》《连接可靠性设计》两章,把内存泄漏与断连问题从"注意事项"升级为<strong>验收红线</strong>。骨架实测已验证:重连 500 次后稳态活跃堆增长 <strong>0.0%</strong>,订阅与在线态零残留,退避严格封顶。</p>
<p><strong>排期</strong>:单人全栈条件下约 20 周,五阶段推进;MVP(Windows + Android + 云端)在第 6 周末交付。V4.0 因引入 Rust 核心,M0 阶段增加约 1 周预研(与温度预研并行)。</p>
<h3><span class="n3">1.3</span>版本变更记录</h3>
<div class="tcap">表 1 V3.0 到 V4.0 主要变更清单</div>
<div class="tw"><table>
<thead><tr><th>涉及章节</th><th>V4.0 变更</th><th>修订原因</th></tr></thead>
<tbody>
<tr><td>3 总体架构</td><td>采集/保活/传输下沉为 Rust 跨端核心</td><td>五端各写一套原生采样,迭代成本高、行为不一致</td></tr>
<tr><td>3 / 11 网关</td><td>连接态与订阅关系外置 Redis,网关无状态化</td><td>V3.0 连接态在进程内存,扩容需重写代码</td></tr>
<tr><td>9 数据存储</td><td>VictoriaMetrics 换为 PostgreSQL+TimescaleDB</td><td>单机小规模下三组件偏重,PG 扩展即可承载时序</td></tr>
<tr><td>7 多端适配</td><td>温度由"自研驱动"改为"架构规避 + 可选外置插件"</td><td>自研 Windows 内核驱动投入大、触发杀软误报</td></tr>
<tr><td>5 通信协议</td><td>新增二进制编解码选项,JSON 保留为契约基线</td><td>高频上行 JSON 解析与带宽开销偏高</td></tr>
<tr><td>8 保活机制</td><td>新增第 8 章《保活与后台存活机制》,契约化</td><td>V3.0 保活散落各端,无统一契约</td></tr>
<tr><td>11 部署与运维</td><td>新增 ADR-001 部署形态决策(Docker 为主 + 构建期本地编译)</td><td>澄清"本地编译 vs Docker"非对立选项,明确多阶段构建与长连接调优</td></tr>
<tr><td>12 资源与内存安全</td><td><strong>新增章节</strong>;12.6 补充 V8 堆回收实测结论</td><td>内存泄漏是长连接系统头号命门,需架构级约束;实测发现 RSS 判定会误报</td></tr>
<tr><td>13 连接可靠性</td><td><strong>新增章节</strong></td><td>断连是物理必然,需系统性抗断设计</td></tr>
<tr><td>14 里程碑排期</td><td>M0 增加 Rust 核心预研(与温度预研并行)</td><td>引入新语言栈需前置验证 FFI 与交叉编译</td></tr>
<tr><td>15 风险与合规</td><td>新增内存泄漏、断连风暴、FFI 边界三类工程风险</td><td>工程风险补齐</td></tr>
</tbody>
</table></div>
<hr>
</section>
<section class="sec" id="sec-2">
<h2><span class="num">2</span>产品定位与交付范围</h2>
<h3><span class="n3">2.1</span>产品定位</h3>
<p>产品定位为面向技术人员与进阶用户的自有设备性能监控工具。核心叙事是<strong>监控自己或经明确授权的设备</strong>:所有查看权限均由被查看设备主动生成配对码授予,用户可随时解除授权;产品不提供任何绕过对方操作的隐蔽监控能力。该定位既是法律合规底线,也是应用商店审核通过、杀毒软件建立信誉的前提,所有对外文案必须与该定位保持一致。</p>
<h3><span class="n3">2.2</span>版本交付范围</h3>
<div class="tcap">表 2 版本范围界定</div>
<div class="tw"><table>
<thead><tr><th>版本</th><th>交付范围</th><th>阶段目标</th></tr></thead>
<tbody>
<tr><td>MVP(V1.0)</td><td>Windows 采集端、Android 采集端、云端转发、Android 与 Windows 查看端、配对鉴权、P0 打磨清单全项</td><td>验证采集到查看的完整闭环,跑通配对与留存</td></tr>
<tr><td>V1.5</td><td>macOS 与 Linux 采集端、iOS 查看端、账号体系、时序历史与档位配额</td><td>覆盖主流桌面与移动查看场景</td></tr>
<tr><td>V2.0</td><td>iOS 小组件快照、GPU 与风扇等扩展指标、团队协作与计费完善</td><td>商业化放量</td></tr>
</tbody>
</table></div>
<h3><span class="n3">2.3</span>商业模式概要</h3>
<p>采用免费加订阅的组合模式:免费档提供基础指标与少量设备配额用于获客,付费档解锁更长历史回看与更多设备配额。档位结构见 6.4,定价待商业验证后确定,成本基线见第 17 章。</p>
<h3><span class="n3">2.4</span>V4.0 的四项硬指标</h3>
<p>本版所有架构决策均需服务于以下四项可验收指标:</p>
<div class="tw"><table>
<thead><tr><th>硬指标</th><th>具体要求</th><th>验证方式</th></tr></thead>
<tbody>
<tr><td><strong>资源占用小</strong></td><td>采集核心常驻内存 &lt; 30 MB;云端单实例支持万级长连接</td><td>长稳测试 + 压测报告</td></tr>
<tr><td><strong>保活完善</strong></td><td>各端保活契约化、可测试;移动端尽最大努力后台存活</td><td>各端保活验收用例</td></tr>
<tr><td><strong>兼容性高</strong></td><td>协议 N-1 兼容;指标能力集优雅降级;五端行为一致</td><td>版本回归 + 降级用例</td></tr>
<tr><td><strong>可维护迭代</strong></td><td>单一职责分层;新指标/新端接入成本可控</td><td>架构评审 + 新端接入演练</td></tr>
</tbody>
</table></div>
<hr>
</section>
<section class="sec" id="sec-3">
<h2><span class="num">3</span>总体架构设计</h2>
<h3><span class="n3">3.1</span>架构总览</h3>
<p>系统由四层组成:</p>
<p><strong>设备端</strong>:Flutter App(UI 与查看角色)+ Rust 核心库(采集、保活、传输)。</p>
<p><strong>云端接入层</strong>:Nginx(SSL 终结 + WSS 反代)+ Socket.io 网关(无状态,连接态外置 Redis)。</p>
<p><strong>云端业务层</strong>:账号服务、配对鉴权、房间管理、在线状态、发布更新服务。</p>
<p><strong>存储层</strong>:Redis(实时层)+ PostgreSQL/TimescaleDB(元数据 + 时序历史)。</p>
<p>核心数据流:Rust 采集核心经 WSS 每秒上报一帧指标;网关按设备标识写入 Redis 环形缓冲(保留最近 60 帧),异步批量写入 TimescaleDB,并实时转发给该设备的全部订阅者;查看端订阅成功后,先从 Redis 快照获取最近 60 秒数据完成首屏渲染,再接入实时流。</p>
<h3><span class="n3">3.2</span>关键架构理念:职责下沉与核心复用</h3>
<p>V4.0 最重要的一条架构理念是<strong>「能复用的绝不复写五遍」</strong>。传统跨端方案里,采集、保活、重连这三件事最容易退化成一端一套代码,最终五份行为不一致、五份 bug、五份维护成本。V4.0 用 Rust 核心库把这部分收敛为单一真相源(Single Source of Truth)。</p>
<div class="tcap">表 3 分层职责划分</div>
<div class="tw"><table>
<thead><tr><th>层级</th><th>组件</th><th>核心职责</th><th>技术栈</th></tr></thead>
<tbody>
<tr><td>设备端</td><td>App 查看角色</td><td>设备列表、实时仪表盘、订阅管理、授权解除</td><td>Flutter / Dart</td></tr>
<tr><td>设备端</td><td>Rust 核心 · 采集</td><td>本机指标采样、差值计算、能力集探测</td><td>Rust</td></tr>
<tr><td>设备端</td><td>Rust 核心 · 保活</td><td>自启注册、守护看护、进程保活、后台存活</td><td>Rust</td></tr>
<tr><td>设备端</td><td>Rust 核心 · 传输</td><td>协议编解码、重连退避、心跳、断线缓存补传</td><td>Rust</td></tr>
<tr><td>设备端</td><td>FFI 桥接层</td><td>Rust ↔ Dart 内存所有权交接、回调注册/解绑</td><td>Rust + Dart</td></tr>
<tr><td>云端接入</td><td>Nginx + Socket.io 网关</td><td>SSL 终结、WSS 反代、连接管理、消息路由、限流</td><td>Node.js</td></tr>
<tr><td>云端业务</td><td>业务服务</td><td>设备注册鉴权、配对流程、房间管理、账号与档位</td><td>Node.js / Express</td></tr>
<tr><td>存储层</td><td>Redis</td><td>最新一帧、60 条环形缓冲、在线状态、订阅关系、配对码</td><td>Redis</td></tr>
<tr><td>存储层</td><td>PostgreSQL + TimescaleDB</td><td>元数据(用户/设备/授权/订单/审计)+ 时序历史</td><td>PostgreSQL</td></tr>
</tbody>
</table></div>
<h3><span class="n3">3.3</span>核心设计原则</h3>
<p><strong>单一真相源</strong>:采集、保活、传输逻辑只存在于 Rust 核心,Flutter 侧不重复实现。</p>
<p><strong>采集与查看解耦</strong>:同一应用内两种角色权限独立,采集端不上报超出声明范围的任何数据。</p>
<p><strong>服务器时间权威</strong>:所有图表时间轴使用服务器时间戳,客户端时钟仅作诊断参考(见 5.3)。</p>
<p><strong>降级可用</strong>:任一指标不可得时,查看端隐藏对应卡片或显示设备不支持,客户端不报错、不阻塞、不崩溃。</p>
<p><strong>协议向后兼容</strong>:所有消息携带版本号,服务端同时兼容当前与上一版本客户端(见 5.4)。</p>
<p><strong>无状态优先</strong>:网关等可横向扩展的组件,进程内存中不存放任何长生命周期业务状态。</p>
<p><strong>有界优先</strong>:任何缓存、队列、集合都必须有明确上限与超限策略(见第 12 章)。</p>
<h3><span class="n3">3.4</span>相对 V3.0 的架构修订</h3>
<div class="tw"><table>
<thead><tr><th>修订项</th><th>V3.0</th><th>V4.0</th><th>收益</th></tr></thead>
<tbody>
<tr><td>采集实现</td><td><code>system_info_plus</code> + 五端各写 MethodChannel</td><td>单一 Rust 核心库 + FFI</td><td>复用、行为一致、内存低</td></tr>
<tr><td>保活实现</td><td>各端自行实现,无统一契约</td><td>契约化下沉至 Rust 核心</td><td>可测试、可预期</td></tr>
<tr><td>网关状态</td><td>连接态在进程内存</td><td>全部外置 Redis(带 TTL)</td><td>可随意扩容,无需改码</td></tr>
<tr><td>时序存储</td><td>VictoriaMetrics 独立组件</td><td>PostgreSQL + TimescaleDB</td><td>少一个组件与一份运维</td></tr>
<tr><td>温度方案</td><td>自研 Windows 内核驱动</td><td>架构规避 + 可选外置插件</td><td>规避高投入与杀软风险</td></tr>
<tr><td>传输编码</td><td>纯 JSON</td><td>JSON + 二进制双模</td><td>带宽与解析开销双降</td></tr>
<tr><td>工程风险</td><td>未专项覆盖内存/断连</td><td>新增两章架构级约束</td><td>长稳可信</td></tr>
</tbody>
</table></div>
<hr>
</section>
<section class="sec" id="sec-4">
<h2><span class="num">4</span>技术栈选型</h2>
<div class="tcap">表 4 技术栈选型(V4.0)</div>
<div class="tw"><table>
<thead><tr><th>层级</th><th>选型</th><th>选型理由</th></tr></thead>
<tbody>
<tr><td>跨端 UI 框架</td><td>Flutter 3.x</td><td>一套代码覆盖五端查看场景,UI 一致性高</td></tr>
<tr><td>采集/保活/传输核心</td><td><strong>Rust(静态库,FFI 导出)</strong></td><td>内存安全、零成本抽象、五端复用、常驻占用低</td></tr>
<tr><td>FFI 桥接</td><td><code>flutter_rust_bridge</code> 或手写 C ABI</td><td>成熟桥接方案,支持异步与回调</td></tr>
<tr><td>云端服务</td><td>Node.js + Express + Socket.io</td><td>长连接高并发,生态完善,与网关同源</td></tr>
<tr><td>实时缓存</td><td>Redis</td><td>环形缓冲、在线状态、订阅关系、带 TTL 配对码</td></tr>
<tr><td>存储</td><td><strong>PostgreSQL + TimescaleDB</strong></td><td>元数据(强关系)+ 时序历史(超表 + 自动分区/保留)</td></tr>
<tr><td>图表</td><td>fl_chart</td><td>轻量流畅,满足秒级折线渲染</td></tr>
<tr><td>状态管理</td><td>Riverpod</td><td>设备列表与实时数据流跨组件共享清晰</td></tr>
<tr><td>部署</td><td>Docker Compose(多阶段构建)+ Nginx</td><td>单机编排可平滑扩多实例;构建期镜像内编译,运行期容器交付(见 11.2 ADR-001)</td></tr>
</tbody>
</table></div>
<h3><span class="n3">4.1</span>关键选型说明</h3>
<p><strong>为什么用 Rust 而非 Dart 做采集</strong>:Dart 侧轮询与 JSON 解析会占用 Flutter 主 isolate 附近资源,且无法优雅处理需要特权的传感器读取;Rust 核心可做到常驻 &lt; 30 MB,且一套代码编译到五端,避免五份采样逻辑漂移。</p>
<p><strong>为什么合并时序存储</strong>:单机小规模(千级设备)下,TimescaleDB 完全能承载 10 秒粒度、30 天保留的数据量,且与元数据同库便于关联查询(如"某设备某时段指标 + 其归属账号"),省掉 VictoriaMetrics 一个组件的部署与监控成本。当规模达到需要独立时序集群时,TimescaleDB 的数据模型可平滑迁移。</p>
<p><strong>为什么移除 PM2</strong>:容器重启策略与健康检查已覆盖进程守护职责,容器内再叠加 PM2 属双重进程管理,徒增故障排查复杂度。</p>
<p><strong>为什么保留 Socket.io</strong>:自带重连、房间、传输降级能力成熟,与 Node 生态同源。其连接的"有状态"缺陷由 V4.0 的外置 Redis 适配器方案弥补(见 11.3)。</p>
<hr>
</section>
<section class="sec" id="sec-5">
<h2><span class="num">5</span>通信协议规范</h2>
<h3><span class="n3">5.1</span>消息封装</h3>
<p>所有业务消息统一信封字段如下。信封由服务端校验与改写,客户端不信任自身上报的时间戳字段。</p>
<div class="tcap">表 5 消息信封字段定义</div>
<div class="tw"><table>
<thead><tr><th>字段</th><th>类型</th><th>说明</th></tr></thead>
<tbody>
<tr><td>v</td><td>int</td><td>协议版本号,当前为 1,破坏性变更时递增</td></tr>
<tr><td>type</td><td>string</td><td>事件名,见 5.2</td></tr>
<tr><td>ts</td><td>int</td><td>服务器时间戳(毫秒);上行消息由服务端接收时改写</td></tr>
<tr><td>seq</td><td>int</td><td>采集端单调递增序列号,用于去重与断线补传</td></tr>
<tr><td>device_id</td><td>string</td><td>设备唯一标识,首次注册时由服务端颁发</td></tr>
<tr><td>payload</td><td>object</td><td>事件负载,字段白名单制(见 8.6)</td></tr>
</tbody>
</table></div>
<h3><span class="n3">5.2</span>核心事件定义</h3>
<div class="tcap">表 6 核心事件一览</div>
<div class="tw"><table>
<thead><tr><th>事件</th><th>方向</th><th>说明</th></tr></thead>
<tbody>
<tr><td>agent:hello</td><td>上行</td><td>设备注册:上报设备名、平台、协议版本、能力集</td></tr>
<tr><td>agent:metrics</td><td>上行</td><td>指标数据帧,默认 1 秒一条,与心跳并行</td></tr>
<tr><td>agent:heartbeat</td><td>上行</td><td>10 秒一次,维持在线状态与连接保活</td></tr>
<tr><td>server:config</td><td>下行</td><td>云端下发采集配置:采样频率(0.5–5 秒)、指标开关</td></tr>
<tr><td>viewer:subscribe</td><td>上行</td><td>查看端订阅某设备,服务端实时校验配对授权关系</td></tr>
<tr><td>server:snapshot</td><td>下行</td><td>订阅成功后下发 Redis 最近 60 帧,完成首屏渲染</td></tr>
<tr><td>server:metrics</td><td>下行</td><td>实时指标转发给订阅者</td></tr>
<tr><td>server:device_status</td><td>下行</td><td>设备上线、离线、置灰状态变化广播</td></tr>
<tr><td>error</td><td>下行</td><td>统一错误码与原因说明</td></tr>
</tbody>
</table></div>
<h3><span class="n3">5.3</span>时间戳规范</h3>
<p>图表时间轴统一使用服务器时间戳:上行消息的 ts 由服务端在接收时改写,查看端按服务器时间渲染。设备时钟偏移在 agent:hello 阶段由服务端计算并记录,仅用于诊断,不参与渲染。跨设备切换时时间轴保持连续依赖本规范。</p>
<h3><span class="n3">5.4</span>版本兼容策略</h3>
<ul><li>服务端同时兼容当前与上一版本(N-1)客户端,升级服务端不强制用户立即升级客户端。</li><li>新增字段不得改变既有字段语义;客户端对未知字段一律忽略。</li><li>破坏性变更递增版本号,新旧行为并行一个发布周期后下线旧版。</li></ul>
<h3><span class="n3">5.5</span>双模编解码设计(V4.0 新增)</h3>
<p>V3.0 全部使用 JSON,在高频上行场景(每秒一帧 × 万级设备)下,JSON 的文本冗余与解析开销不可忽视。V4.0 引入双模编解码:</p>
<div class="tw"><table>
<thead><tr><th>消息类型</th><th>编码</th><th>理由</th></tr></thead>
<tbody>
<tr><td><code>agent:metrics</code>(高频上行)</td><td><strong>MessagePack 二进制</strong></td><td>体积约为 JSON 的 50–60%,解析更快</td></tr>
<tr><td><code>server:metrics</code>(高频下行)</td><td>MessagePack(订阅者协商)</td><td>下行带宽随订阅比放大,收益显著</td></tr>
<tr><td>控制类消息(hello/subscribe/config 等)</td><td>JSON</td><td>低频、可读、便于调试</td></tr>
</tbody>
</table></div>
<p><strong>协商机制</strong>:客户端在 <code>agent:hello</code> 中声明 <code>encoding: ["json", "msgpack"]</code>,服务端按双方交集选择;未声明则回退 JSON。该机制保证 V3.0 老客户端(仅 JSON)无缝兼容。</p>
<h3><span class="n3">5.6</span>断线缓存与补传</h3>
<p>采集端在 Rust 核心内缓存最近 <strong>120 帧(约 2 分钟)</strong>,采用<strong>定长环形缓冲</strong>(非无界队列,见 12.2),重连成功后按 seq 补传,服务端按 seq 去重后写入。超出缓存窗口的时段在图表上显示<strong>断点,不做插值伪装</strong>。重连采用指数退避:1 秒起步、每次乘 2、上限 60 秒,并叠加随机抖动防重连风暴(详见第 13 章)。</p>
<hr>
</section>
<section class="sec" id="sec-6">
<h2><span class="num">6</span>核心功能模块设计</h2>
<h3><span class="n3">6.1</span>采集模块</h3>
<div class="tcap">表 7 采集指标清单</div>
<div class="tw"><table>
<thead><tr><th>指标</th><th>说明</th><th>采集方式</th></tr></thead>
<tbody>
<tr><td>CPU 使用率</td><td>总使用率与单核占用</td><td>Rust 原生 API,采样窗口内差值计算</td></tr>
<tr><td>内存占用</td><td>已用比例与绝对值</td><td>系统内存统计接口</td></tr>
<tr><td>磁盘剩余</td><td>各分区剩余空间与总量</td><td>文件系统统计接口</td></tr>
<tr><td>网络速率</td><td>上行与下行速率</td><td>接口字节计数器按采样周期差值计算</td></tr>
<tr><td>电池温度</td><td>移动端稳定可得</td><td>Android BatteryManager 等系统接口</td></tr>
<tr><td>CPU 温度</td><td>平台强受限指标</td><td>原生路径,各端差异大,见第 7 章</td></tr>
</tbody>
</table></div>
<p>采集降级原则:设备在 <code>agent:hello</code> 中声明能力集,某指标不可得时上报 null 并将对应能力位置为 false,查看端据此隐藏对应卡片,不显示报错。采集频率默认 1 秒,云端可动态下发 0.5–5 秒。</p>
<h3><span class="n3">6.2</span>查看模块</h3>
<ul><li><strong>设备切换器</strong>:下拉展示所有在线与历史设备,带状态灯(绿为在线、黄为心跳超时、灰为离线)与最后心跳时间。</li><li><strong>实时仪表盘</strong>:指标数值卡片加 fl_chart 折线图,默认展示最近 60 秒趋势。</li><li><strong>启动即见</strong>:冷启动优先渲染本地缓存的上次会话数据,避免白屏。</li><li><strong>空状态设计</strong>:无数据时展示动效占位与引导说明,不出现空白或错误提示。</li></ul>
<h3><span class="n3">6.3</span>设备注册与配对</h3>
<p>配对码由被查看设备生成:<strong>6 位数字,300 秒有效,一次性使用</strong>。流程为:被查看设备生成并展示配对码 → 查看端输入 → 服务端校验 → 通过后建立设备与查看账号的授权关系并持久化到 PostgreSQL → 查看端获得订阅权限 → 被查看设备可随时解除授权,解除即时生效。</p>
<p><strong>防爆破约束</strong>:单个配对码验证失败 5 次即作废;单账号每小时最多发起 10 次验证;网关按来源 IP 限流;全部失败尝试写入审计日志。</p>
<h3><span class="n3">6.4</span>账号与计费体系</h3>
<p>账号采用邮箱注册加第三方登录(Google、GitHub,手机号登录视发行地区增补)。会话采用短效 JWT(15 分钟)加刷新令牌轮换;采集端使用独立设备令牌,首次注册时颁发,云端可吊销与轮换,与查看会话权限分离。</p>
<div class="tcap">表 8 档位与配额结构</div>
<div class="tw"><table>
<thead><tr><th>档位</th><th>设备配额</th><th>历史保留</th><th>定位</th></tr></thead>
<tbody>
<tr><td>免费</td><td>3 台</td><td>24 小时</td><td>获客与口碑</td></tr>
<tr><td>专业</td><td>10 台</td><td>7 天</td><td>个人进阶用户主力付费档</td></tr>
<tr><td>团队</td><td>30 台</td><td>30 天</td><td>小团队与实验室场景</td></tr>
</tbody>
</table></div>
<h3><span class="n3">6.5</span>在线状态管理</h3>
<p>连接断开即标记离线;心跳超时 30 秒先置灰为疑似离线(黄),超时上限后转灰。状态变化实时广播给该设备的全部订阅者。</p>
<hr>
</section>
<section class="sec" id="sec-7">
<h2><span class="num">7</span>多端适配与指标可得性</h2>
<h3><span class="n3">7.1</span>指标可得性矩阵</h3>
<div class="tcap">表 9 各平台指标可得性矩阵</div>
<div class="tw"><table>
<thead><tr><th>指标</th><th>Windows</th><th>macOS</th><th>Linux</th><th>Android</th><th>iOS</th></tr></thead>
<tbody>
<tr><td>CPU 使用率</td><td>可得</td><td>可得</td><td>可得</td><td>可得</td><td>受限·仅前台可采样</td></tr>
<tr><td>内存占用</td><td>可得</td><td>可得</td><td>可得</td><td>可得</td><td>可得</td></tr>
<tr><td>磁盘空间</td><td>可得</td><td>可得</td><td>可得</td><td>可得</td><td>可得·沙盒内可见总量</td></tr>
<tr><td>网络速率</td><td>可得</td><td>可得</td><td>可得</td><td>可得·TrafficStats</td><td>可得·接口计数差值</td></tr>
<tr><td>CPU 温度</td><td>受限·需外置驱动</td><td>受限·需特权 helper</td><td>可得·hwmon 多数发行版</td><td>不可得·多数机型受限</td><td>不可得</td></tr>
<tr><td>电池温度</td><td>受限·仅笔记本近似值</td><td>不可得</td><td>不可得·服务器无电池</td><td>可得</td><td>不可得·仅开放电量</td></tr>
</tbody>
</table></div>
<h3><span class="n3">7.2</span>温度采集路径与架构决策(V4.0 核心调整)</h3>
<p>V3.0 把自研 Windows 内核驱动(3–4 周 + 微软认证 + 杀软风险)列为商业化路径。V4.0 的结论是:<strong>这是一个投入产出比极差的选项,应当用架构规避,而非排期硬扛。</strong></p>
<div class="tcap">表 10 各平台温度策略(V4.0)</div>
<div class="tw"><table>
<thead><tr><th>平台</th><th>策略</th><th>工作量</th><th>交付版本</th></tr></thead>
<tbody>
<tr><td>Windows</td><td><strong>默认不支持</strong>。确有需求走可选外置插件:用户显式下载安装已签名小驱动,独立于主客户端签名与信任链</td><td>主线 0;插件按需</td><td>V1.0 默认关闭</td></tr>
<tr><td>macOS</td><td>特权 helper 读取 SMC(SMAppService / SMJobBless),需签名与公证</td><td>1–2 周</td><td>V1.5</td></tr>
<tr><td>Linux</td><td>读取 /sys/class/hwmon + libsensors,多数发行版用户态可读</td><td>2–3 天</td><td>V1.5</td></tr>
<tr><td>Android</td><td>电池温度经 BatteryManager 全量可得;CPU 温度按机型白名单尽力而为</td><td>1 周(探测框架)</td><td>V1.0 电池温度</td></tr>
<tr><td>iOS</td><td>系统不开放温度接口</td><td>无</td><td>永久不支持,界面显示设备不支持</td></tr>
</tbody>
</table></div>
<p><strong>决策原则</strong>:宁可少承诺,避免差评与退款风险。产品主打指标调整为 <strong>CPU、内存、磁盘、网络四项全端一致指标</strong>,温度为增强项。</p>
<h3><span class="n3">7.3</span>各端保活与打包</h3>
<p>详见第 8 章《保活与后台存活机制》。打包分发方案如下:</p>
<div class="tcap">表 11 各端打包与签名</div>
<div class="tw"><table>
<thead><tr><th>平台</th><th>打包与签名</th></tr></thead>
<tbody>
<tr><td>Windows</td><td>exe 安装包,EV 代码签名证书</td></tr>
<tr><td>macOS</td><td>app 加 DMG 分发,开发者签名加公证</td></tr>
<tr><td>Linux</td><td>AppImage 与 deb 双格式</td></tr>
<tr><td>Android</td><td>AAB 上架 Play;前台服务类型需在 Play Console 申报</td></tr>
<tr><td>iOS</td><td>App Store,仅前台采集,小组件作补充</td></tr>
</tbody>
</table></div>
<h3><span class="n3">7.4</span>产品承诺原则</h3>
<p>宣传物料与应用内文档按 7.1 矩阵表述,温度类指标统一标注视设备与系统权限支持情况,不做绝对化承诺。</p>
<hr>
</section>
<section class="sec" id="sec-8">
<h2><span class="num">8</span>保活与后台存活机制(V4.0 新增)</h2>
<p>保活是跨端产品的「脏活」,也是用户留存的关键。V3.0 把保活散落在各端描述中,缺乏统一契约,导致行为不可预期、问题难以复现。V4.0 将保活抽象为<strong>三层职责分离</strong>,并由 Rust 核心统一调度。</p>
<h3><span class="n3">8.1</span>保活三层模型</h3>
<div class="tw"><table>
<thead><tr><th>层</th><th>职责</th><th>实现位置</th></tr></thead>
<tbody>
<tr><td><strong>自启层</strong></td><td>系统启动/用户登录后自动拉起</td><td>各端平台机制(Rust 核心统一注册接口)</td></tr>
<tr><td><strong>守护层</strong></td><td>进程意外退出后被重新拉起</td><td>Rust 核心守护逻辑(父进程看护子进程)</td></tr>
<tr><td><strong>存活层</strong></td><td>被系统省电策略限制时的对抗与引导</td><td>Rust 核心策略 + 各端权限引导</td></tr>
</tbody>
</table></div>
<p>核心思想:<strong>守护与存活逻辑写在 Rust 核心,各端只实现平台差异的薄封装(注册自启、申请权限、创建前台服务)</strong>。这样"进程崩溃后 3 秒内重启"这类策略在五端行为一致,且只需维护一份。</p>
<h3><span class="n3">8.2</span>各端保活方案</h3>
<div class="tcap">表 12 各端保活机制</div>
<div class="tw"><table>
<thead><tr><th>平台</th><th>自启</th><th>存活</th><th>备注</th></tr></thead>
<tbody>
<tr><td>Windows</td><td>注册表开机自启</td><td>系统托盘常驻,后台无窗口运行</td><td>采集进程独立于 UI 进程可选</td></tr>
<tr><td>macOS</td><td>登录项自启 / LaunchAgent</td><td>菜单栏驻留</td><td>遵循系统后台任务策略</td></tr>
<tr><td>Linux</td><td>systemd 用户服务</td><td>常驻服务</td><td>无桌面环境亦可用</td></tr>
<tr><td>Android</td><td>开机广播(BOOT_COMPLETED)</td><td>前台服务 + 常驻通知 + 引导关闭电池优化</td><td>需申报前台服务类型</td></tr>
<tr><td>iOS</td><td>不支持后台常驻</td><td>仅前台采集;小组件快照(系统调度)</td><td>系统策略硬限制</td></tr>
</tbody>
</table></div>
<h3><span class="n3">8.3</span>守护机制设计</h3>
<p><strong>父子看护</strong>:采集核心作为独立进程运行,由守护进程看护;守护进程异常退出则由系统自启机制兜底。守护进程仅做健康检查与重启,常驻内存 &lt; 5 MB。</p>
<p><strong>崩溃退避</strong>:连续崩溃时采用退避重启(1s → 2s → 4s → … → 上限 60s),避免崩溃循环耗尽资源,同时上报崩溃事件供诊断。</p>
<p><strong>优雅退出</strong>:用户主动退出不触发守护重启(区分"崩溃"与"用户意图")。</p>
<h3><span class="n3">8.4</span>移动端保活的诚实边界</h3>
<p>必须明确:Android 国内 ROM 与 iOS 的后台限制属于<strong>系统策略层面</strong>,保活只能"尽最大努力",无法 100% 保证。产品界面需如实提示:"系统可能限制后台运行,建议将本应用加入电池优化白名单"。不做虚假承诺。</p>
<h3><span class="n3">8.5</span>保活验收标准</h3>
<div class="tw"><table>
<thead><tr><th>用例</th><th>验收标准</th></tr></thead>
<tbody>
<tr><td>进程被强杀</td><td>守护进程在 5 秒内重新拉起采集</td></tr>
<tr><td>崩溃循环</td><td>退避重启生效,不出现 CPU 打满</td></tr>
<tr><td>系统重启(桌面端)</td><td>自启生效,采集自动恢复</td></tr>
<tr><td>用户主动退出</td><td>不触发守护重启</td></tr>
<tr><td>移动端切后台</td><td>前台服务存活,通知常驻</td></tr>
</tbody>
</table></div>
<hr>
</section>
<section class="sec" id="sec-9">
<h2><span class="num">9</span>安全设计</h2>
<h3><span class="n3">9.1</span>传输安全</h3>
<p>强制 WSS 加密,服务端拒绝明文 WebSocket 连接;证书采用 Let's Encrypt 并自动续期。不做证书绑定(pinning),避免证书轮换期误伤正常客户端。</p>
<h3><span class="n3">9.2</span>身份与鉴权</h3>
<ul><li><strong>查看端</strong>:账号 JWT 短效令牌(15 分钟)加刷新令牌轮换。</li><li><strong>采集端</strong>:设备令牌独立颁发,与查看会话权限分离,云端可随时吊销或轮换。</li><li><strong>订阅鉴权</strong>:<code>viewer:subscribe</code> 时服务端实时校验设备与账号的授权关系,未授权即拒绝并记审计日志。</li></ul>
<h3><span class="n3">9.3</span>本地数据安全</h3>
<p>设备标识、令牌、配对信息写入系统安全区域:iOS 与 macOS 用 Keychain,Android 用 Keystore,Windows 用 DPAPI,Linux 用 libsecret。任何敏感信息禁止明文落盘。</p>
<h3><span class="n3">9.4</span>配对安全</h3>
<p>执行 6.3 的全部防爆破约束;配对验证接口独立于业务接口限流;授权关系建立时被查看设备本地弹窗提示并留痕。</p>
<h3><span class="n3">9.5</span>FFI 边界安全(V4.0 新增)</h3>
<p>Rust 核心与 Flutter 之间的 FFI 边界是新的攻击面与故障面,需专项约束:</p>
<ul><li><strong>所有权协议</strong>:跨边界传递的字符串/数组由<strong>传递方分配、接收方释放</strong>(或统一由 Rust 分配并提供显式 free 接口),规则写入代码规范。</li><li><strong>Ffi 边界禁止 panic 传播</strong>:Rust 侧所有导出函数用 <code>catch_unwind</code> 兜底,panic 转错误码返回,避免 UB。</li><li><strong>输入校验</strong>:Dart 侧传入的指针与长度需在 Rust 侧做边界校验。</li><li><strong>回调生命周期</strong>:注册的回调句柄必须有对应的解绑接口,Flutter <code>dispose</code> 时强制解绑(见 12.3)。</li></ul>
<h3><span class="n3">9.6</span>数据最小化</h3>
<p>协议字段采用白名单制,仅传输 6.1 声明的指标。用户协议与隐私政策逐项列明采集范围,并以显著方式承诺不采集键盘输入、屏幕内容、浏览器历史与通讯录。该承诺写入客户端隐私自查清单,每次发版核对。</p>
<hr>
</section>
<section class="sec" id="sec-10">
<h2><span class="num">10</span>数据存储与历史数据</h2>
<h3><span class="n3">10.1</span>存储拓扑(V4.0 精简)</h3>
<p>V4.0 将存储组件由三件套精简为两件套:</p>
<div class="tw"><table>
<thead><tr><th>层</th><th>组件</th><th>职责</th></tr></thead>
<tbody>
<tr><td>实时层</td><td>Redis</td><td>最新一帧、60 帧环形缓冲、在线状态、订阅关系、配对码</td></tr>
<tr><td>元数据 + 时序层</td><td>PostgreSQL + TimescaleDB</td><td>用户/设备/授权/订单/审计 + 指标时序超表</td></tr>
</tbody>
</table></div>
<p><strong>精简理由</strong>:单机千级设备规模下,TimescaleDB 完全可承载时序写入与保留清理,且与元数据同库便于关联查询,省掉 VictoriaMetrics 的部署、监控与备份成本。当规模增长到需要独立时序集群时,数据模型可平滑迁移。</p>
<h3><span class="n3">10.2</span>Redis 实时层</h3>
<div class="tcap">表 13 Redis 键设计</div>
<div class="tw"><table>
<thead><tr><th>键</th><th>类型</th><th>说明</th><th>上限/TTL</th></tr></thead>
<tbody>
<tr><td>device:{id}:latest</td><td>HASH</td><td>该设备最新一帧指标</td><td>无上限(单帧)</td></tr>
<tr><td>device:{id}:ring</td><td>LIST</td><td>最近 60 帧,LPUSH + LTRIM</td><td><strong>定长 60</strong></td></tr>
<tr><td>presence:online</td><td>SET</td><td>在线设备集合</td><td>成员带 TTL</td></tr>
<tr><td>subs:{device_id}</td><td>SET</td><td>该设备的订阅者集合</td><td><strong>离线即清(见 12.4)</strong></td></tr>
<tr><td>pairing:{code}</td><td>STRING</td><td>配对码</td><td>TTL 300 秒</td></tr>
</tbody>
</table></div>
<h3><span class="n3">10.3</span>时序历史层</h3>
<p>TimescaleDB 超表按设备与时间分区:</p>
<ul><li><strong>写入路径</strong>:云端异步批量消费指标消息写入,避免阻塞转发链路。</li><li><strong>保留策略</strong>:与档位挂钩——免费档 24 小时、专业档 7 天、团队档 30 天,超期自动 drop chunk。</li><li><strong>降采样</strong>:超过 1 天的数据按 10 秒粒度连续聚合(continuous aggregate),控制存储成本。</li><li><strong>容量估算</strong>:1000 台设备、10 秒粒度并启用压缩,30 天保留为数十 GB 量级,上线后按实测校准。</li></ul>
<h3><span class="n3">10.4</span>元数据层</h3>
<p>PostgreSQL 承载用户、设备、设备与账号的授权关系、订阅与订单状态,以及审计日志(配对尝试、令牌吊销、授权变更)。</p>
<h3><span class="n3">10.5</span>备份与恢复</h3>
<p>PostgreSQL 每日全量加 WAL 归档(时序与元数据同库,一次备份全覆盖);Redis 属可重建的实时层,不做持久化备份。备份恢复流程每季度演练一次。</p>
<hr>
</section>
<section class="sec" id="sec-11">
<h2><span class="num">11</span>部署与运维</h2>
<h3><span class="n3">11.1</span>部署拓扑</h3>
<p>单机 Docker Compose 起步:</p>
<pre><code>[Nginx: SSL + WSS 反代]
│
[Socket.io 网关] ── [业务服务]
│
┌────┴─────┐
[Redis] [PostgreSQL+TimescaleDB]</code></pre>
<p>全部服务配置健康检查与 <code>unless-stopped</code> 重启策略。相比 V3.0 少一个存储容器。</p>
<h3><span class="n3">11.2</span>部署形态决策(ADR-001)</h3>
<p><strong>背景</strong>:项目存在"云端服务器用本地编译方式还是 Docker 部署方式"的选型疑问。需澄清:二者<strong>不在同一抽象层级</strong>,不是互斥选项——本地编译解决"代码如何变成可运行产物",Docker 解决"运行环境如何隔离与交付"。可以且应当并存。</p>
<div class="tcap">表 14 部署形态对比</div>
<div class="tw"><table>
<thead><tr><th>维度</th><th>本地编译(裸机直跑)</th><th>Docker 部署</th></tr></thead>
<tbody>
<tr><td>解决的问题</td><td>代码 → 可运行产物</td><td>运行环境隔离与交付</td></tr>
<tr><td>能否并存</td><td>✅ 可在镜像构建阶段执行本地编译</td><td>✅ 运行时用容器承载</td></tr>
<tr><td>环境一致性</td><td>依赖宿主机环境,易漂移</td><td>环境随镜像固化,一致</td></tr>
<tr><td>组件编排(本项目 5 组件)</td><td>需手写进程管理与端口分配</td><td>Compose 声明式编排</td></tr>
<tr><td>横向扩展</td><td>多实例需自建管理</td><td><code>--scale</code> 一行命令</td></tr>
<tr><td>启动开销</td><td>无额外开销</td><td>有容器层开销(约 1–2%)</td></tr>
<tr><td>迁移成本</td><td>需重建环境</td><td><code>docker compose up</code> 即可</td></tr>
<tr><td>长连接调优</td><td>直接改宿主机参数</td><td>需显式配置 <code>ulimit</code> 与网络模式</td></tr>
</tbody>
</table></div>
<p><strong>决策</strong>:采用<strong>「Docker 为主 + 构建期本地编译」的三段式</strong>。</p>
<p><strong>① 构建期:多阶段构建(Multi-stage Build),编译在镜像内完成</strong></p>
<pre><code># 阶段 1:builder —— 完整环境,装 devDependencies,编译
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 阶段 2:runtime —— 只拷贝产物,不带编译器与 devDeps
FROM node:22-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev &amp;&amp; npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]</code></pre>
<p>收益:最终镜像体积较单阶段<strong>缩小 60–80%</strong>;构建结果确定可复现;与宿主机环境彻底解耦。</p>
<p><strong>② 运行期:Docker Compose 编排</strong></p>
<ul><li>全部服务配置健康检查与 <code>unless-stopped</code> 重启策略;</li><li>数据卷持久化 Redis 与 PostgreSQL 数据;</li><li>网关无状态(见 11.3),天然可 <code>--scale</code>。</li></ul>
<p><strong>③ 宿主机层:只装 Docker,不装 Node/PostgreSQL/Redis</strong>,避免版本冲突与环境污染,迁移成本归零。</p>
<p><strong>Docker 代价与对冲</strong>(必须正视,不可只讲好处):</p>
<div class="tcap">表 15 Docker 部署代价与对冲方案</div>
<div class="tw"><table>
<thead><tr><th>代价</th><th>影响</th><th>对冲</th></tr></thead>
<tbody>
<tr><td>长连接受 fd 限制(默认可能 1024)</td><td>连接数卡死</td><td>Compose 显式 <code>ulimits.nofile: 65536</code></td></tr>
<tr><td>多一层网络 NAT</td><td>Socket.io 延迟与端口暴露</td><td>本项目采用 <code>network_mode: host</code> 降低延迟</td></tr>
<tr><td>镜像体积影响部署速度</td><td>拉取慢</td><td>多阶段构建 + alpine 基础镜像</td></tr>
<tr><td>容器日志易膨胀</td><td>磁盘打满</td><td><code>logging</code> 驱动限制单文件大小与数量</td></tr>
<tr><td>调试略麻烦</td><td>排查成本</td><td>开发环境仍本地直跑(见下)</td></tr>
</tbody>
</table></div>
<p><strong>开发 / 生产双模式</strong>:</p>
<div class="tcap">表 16 开发与生产模式对照</div>
<div class="tw"><table>
<thead><tr><th>场景</th><th>形态</th><th>命令</th><th>理由</th></tr></thead>
<tbody>
<tr><td>开发</td><td>本地直跑</td><td><code>npm run dev</code></td><td>热重载友好,改码即生效</td></tr>
<tr><td>生产</td><td>Docker Compose</td><td><code>docker compose up -d</code></td><td>环境一致、可扩展、可回滚</td></tr>
</tbody>
</table></div>
<p>结论:<strong>不是二选一,而是各取所长</strong>——生产取 Docker 的一致性、可扩展与可交付,开发取本地直跑的热重载效率,同时用多阶段构建把"编译确定性"收入容器。</p>
<p><strong>备选方案(何时改口)</strong>:仅当出现以下情况才考虑纯裸机部署——① 微秒级延迟敏感场景(本项目为秒级采样,不适用);② 宿主机禁用容器(本项目不适用);③ 组件极少且数据库托管(本项目 5 组件,不适用)。</p>
<h3><span class="n3">11.3</span>可扩展性与无状态化(V4.0 核心)</h3>
<p>网关<strong>不把连接状态放在进程内存</strong>,而是:</p>
<ul><li>在线状态写 Redis(<code>presence:online</code>,带 TTL)</li><li>订阅关系写 Redis(<code>subs:{device_id}</code>)</li><li>跨节点广播启用 Socket.io 的 <strong>Redis Adapter</strong></li></ul>
<p><strong>扩容方式</strong>:单机 1 实例起步,扩容时直接增加网关实例(<code>docker compose up --scale gateway=N</code>),无需改代码。<strong>扩容触发条件</strong>:CPU 持续高于 60% 或连接数超过 8000。</p>
<p><strong>滚动更新</strong>:更新前先将实例从负载均衡摘除(健康检查失败)→ 等待该实例连接自然迁移(客户端指数退避重连会落到其他实例)→ 再停实例。将"服务端重启"的断连影响降到最低。</p>
<h3><span class="n3">11.4</span>监控与告警</h3>
<ul><li><strong>健康检查</strong>:<code>/health</code> 覆盖网关、Redis、数据库连通性。</li><li><strong>核心指标</strong>:连接数、消息速率、错误率、各存储延迟、<strong>各进程 RSS 内存</strong>(见 12.5)。</li><li><strong>告警规则</strong>:连接数骤降、错误率超过 1%、磁盘用量超过 80%、<strong>单实例内存增长超过基线 20%</strong>、SSL 证书剩余有效期不足 14 天;通知走邮件与钉钉 Webhook。</li></ul>
<h3><span class="n3">11.5</span>容量评估</h3>
<p>1000 台设备按每秒 1 条上报约产生每秒 1000 条消息,单节点 4 核 8G 余量充足;转发量随订阅比放大,按 1:3 估算峰值出向带宽约 2–3 Mbps(启用二进制编码后进一步下降)。</p>
<h3><span class="n3">11.6</span>进程管理</h3>
<p>进程守护职责由容器编排承担,不引入 PM2。版本升级采用 Compose 拉取新镜像滚动替换;单实例阶段接受分钟级中断,安排在凌晨低峰窗口。</p>
<hr>
</section>
<section class="sec" id="sec-12">
<h2><span class="num">12</span>资源与内存安全设计(V4.0 新增 · 核心章节)</h2>
<blockquote>本章回应一个关键质疑:<strong>这套架构会不会内存泄漏?</strong> 明确回答:<strong>这套架构不会天然泄漏,但会天然地"允许"泄漏——能否守住,取决于本章约束有没有被写进代码规范与验收红线。</strong> Rust 的借用检查器只能消灭"释放后使用"与"双重释放",对循环引用、资源句柄、逻辑泄漏、FFI 边界泄漏、后台线程泄漏<strong>一概管不住</strong>。本章将这些高危点逐条击破。</blockquote>
<h3><span class="n3">12.1</span>五类泄漏风险与对应防线</h3>
<div class="tcap">表 17 泄漏类型与防护</div>
<div class="tw"><table>
<thead><tr><th>泄漏类型</th><th>Rust 是否自动防</th><th>本方案防线</th></tr></thead>
<tbody>
<tr><td>内存泄漏(对象不释放)</td><td>❌</td><td>禁止 <code>Arc</code> 循环引用;全局状态最小化</td></tr>
<tr><td>资源句柄泄漏(fd/线程/子进程)</td><td>❌</td><td>RAII 强制;句柄数纳入长稳监控</td></tr>
<tr><td>逻辑泄漏(内存持续增长)</td><td>❌</td><td>所有容器有界(见 12.2)</td></tr>
<tr><td>FFI 边界泄漏</td><td>❌</td><td>显式所有权协议(见 12.3)</td></tr>
<tr><td>事件监听器泄漏</td><td>❌</td><td>生命周期与连接绑定,断开即解绑(见 12.4)</td></tr>
</tbody>
</table></div>
<h3><span class="n3">12.2</span>客户端有界原则(强制)</h3>
<p><strong>任何缓存、队列、集合,上线前必须回答两个问题:上限是多少?超了怎么办?</strong></p>
<div class="tcap">表 18 客户端关键结构的有界设计</div>
<div class="tw"><table>
<thead><tr><th>结构</th><th>上限</th><th>超限策略</th></tr></thead>
<tbody>
<tr><td>断线缓存 ring buffer</td><td>120 帧</td><td>覆盖最旧帧(定长环形,非无界队列)</td></tr>
<tr><td>重连调度器</td><td>单例</td><td>复用单一调度器,<strong>禁止嵌套 spawn 定时器/线程</strong></td></tr>
<tr><td>指标订阅回调</td><td>与 UI 组件同生命周期</td><td>组件 dispose 即解绑</td></tr>
<tr><td>图表数据窗口</td><td>可视区间 + 少量冗余</td><td>滑窗裁剪,只 append 不裁剪视为缺陷</td></tr>
<tr><td>日志缓冲</td><td>10 MB 滚动</td><td>超限滚动覆盖</td></tr>
</tbody>
</table></div>
<h3><span class="n3">12.3</span>FFI 边界所有权协议(强制)</h3>
<ul><li><strong>谁分配谁释放</strong>:跨边界的内存块由分配方负责释放,或统一由 Rust 分配并提供 <code>pmon_free_*</code> 显式释放接口。规则写入代码规范,代码评审强制检查。</li><li><strong>禁止 panic 跨界</strong>:所有 <code>#[no_mangle] extern "C"</code> 导出函数用 <code>catch_unwind</code> 包裹。</li><li><strong>回调成对管理</strong>:<code>register_callback</code> 与 <code>unregister_callback</code> 必须成对出现,Flutter 侧 <code>dispose()</code> 中强制解绑。</li><li><strong>禁止在导出函数中使用不透明全局可变状态</strong>。</li></ul>
<h3><span class="n3">12.4</span>服务端无长生命周期对象(强制)</h3>
<div class="tcap">表 19 服务端高危点与防线</div>
<div class="tw"><table>
<thead><tr><th>高危点</th><th>风险</th><th>防线</th></tr></thead>
<tbody>
<tr><td>订阅关系表</td><td>设备离线后订阅者未清理</td><td>订阅关系存 Redis,连接断开即清理并带 TTL 兜底</td></tr>
<tr><td>在线状态集合</td><td>成员无限累积</td><td>成员带 TTL,过期自动移除</td></tr>
<tr><td>EventEmitter 监听器</td><td>Node 经典泄漏(监听器只增不减)</td><td>监听器与连接绑定,断开即 <code>removeAllListeners</code></td></tr>
<tr><td>写入队列</td><td>上游断连时生产快于消费</td><td><strong>有界队列</strong>,满则降采样或丢弃(宁丢点不 OOM)</td></tr>
<tr><td>Socket.io room</td><td>房间解散成员未移除</td><td>连接断开时显式 leave room</td></tr>
<tr><td>定时器</td><td>定时任务未清</td><td>统一调度器,组件销毁时清理</td></tr>
</tbody>
</table></div>
<h3><span class="n3">12.5</span>验证手段(不可只靠自觉)</h3>
<ul><li><strong>72 小时长稳测试</strong>(保留 V3.0 要求,但明确判定标准):内存曲线必须<strong>收敛</strong>,RSS 波动区间 &lt; 5%;不接受"看着没崩就算过"。</li><li><strong>泄漏回归</strong>:连续重连 1000 次、连续采集 24 小时后与基线对比内存。</li><li><strong>服务端</strong>:heap snapshot 对比 + Redis 内存监控 + 各容器 RSS 采集,双重兜底。</li><li><strong>CI 内置</strong>:每此合并触发短时泄漏冒烟(连续重连 100 次)。</li></ul>
<h3><span class="n3">12.6</span>V8 堆回收特性(实测结论 · 必读)</h3>
<p>本项目在泄漏专项验证中实测发现一条<strong>极易误判</strong>的特性,必须写入判定规范:</p>
<p><strong>现象</strong>:Node.js 网关在 500 次高频连接建立/销毁后,<code>heap_used</code> 一度冲高 46%,但待 GC 周期触发后<strong>回落至基线以下(−7.7%)</strong>。</p>
<p><strong>根因</strong>:V8 的内存回收是<strong>延迟且惰性</strong>的——堆扩容后不会立即归还 OS,<code>RSS</code> 中相当一部分是"已分配但空置"的预留空间,不代表活跃对象。</p>
<p><strong>判定规范(据此修正)</strong>:</p>
<div class="tw"><table>
<thead><tr><th>指标</th><th>含义</th><th>是否可用于泄漏判定</th></tr></thead>
<tbody>
<tr><td><code>RSS</code></td><td>含 V8 预留堆,不归还 OS</td><td>❌ 会严重误报</td></tr>
<tr><td><code>heap_used</code></td><td>真实活跃对象占用</td><td>✅ <strong>唯一正确口径</strong></td></tr>
<tr><td><code>heap_total</code></td><td>V8 已申请堆上限</td><td>❌ 只增不减,无参考价值</td></tr>
</tbody>
</table></div>
<p><strong>三条落地要求</strong>:</p>
<ol><li><strong>判定必须取 GC 后的稳态值</strong>:在中间态取样会把"延迟回收"误判为泄漏(本项目早期版本即踩此坑)。</li><li><strong>网关必须带 <code>--expose-gc</code> 启动并周期性触发回收</strong>:否则堆只增不降,长稳测试必然不收敛。本项目实现为 30 秒周期。</li><li><strong>必须硬性封顶堆上限</strong>(<code>--max-old-space-size</code>):防止失控增长耗尽宿主内存,本项目设为 256 MB。</li></ol>
<blockquote>该结论已落地为代码:<code>server/gateway/gateway.js</code> 的周期性 GC、<code>index.js</code> 健康检查暴露 <code>heap_used_mb</code>、<code>tests/leak-check.js</code> 的稳态判定逻辑。</blockquote>
<h3><span class="n3">12.7</span>验收红线</h3>
<div class="tw"><table>
<thead><tr><th>指标</th><th>红线</th></tr></thead>
<tbody>
<tr><td>采集核心常驻内存</td><td>&lt; 30 MB</td></tr>
<tr><td>守护进程常驻内存</td><td>&lt; 5 MB</td></tr>
<tr><td>72 小时长稳 RSS 波动</td><td>&lt; 5%</td></tr>
<tr><td>重连 1000 次后内存增长</td><td>≈ 0(允许测量噪声)</td></tr>
<tr><td>服务端无界容器数量</td><td>0</td></tr>
</tbody>
</table></div>
<hr>
</section>
<section class="sec" id="sec-13">
<h2><span class="num">13</span>连接可靠性设计(V4.0 新增 · 核心章节)</h2>
<blockquote>本章回应第二个关键质疑:<strong>会不会出现断连?</strong> 明确回答:<strong>断连是物理必然,不是 bug。</strong> 只要跨越公网,断连就是常态。架构目标不是"不断",而是<strong>"断了能秒级自动恢复,用户几乎无感、数据不丢不重"</strong>。真正致命的是三件事:①重连风暴打垮服务端 ②TCP 半开连接"假在线" ③重连后令牌过期逼用户重登。本章逐条击破。</blockquote>
<h3><span class="n3">13.1</span>断连根因分类</h3>
<div class="tcap">表 20 断连根因与可根除性</div>
<div class="tw"><table>
<thead><tr><th>层级</th><th>根因</th><th>频率</th><th>能否根除</th></tr></thead>
<tbody>
<tr><td>客户端网络</td><td>WiFi 切换、信号弱、飞行模式、休眠唤醒</td><td>高</td><td>❌ 只能快速恢复</td></tr>
<tr><td>中间设备</td><td>NAT 超时、防火墙掐长连接、运营商网关重置</td><td>中高</td><td>❌ 需心跳续命</td></tr>
<tr><td>服务端</td><td>网关滚动更新、实例重启、存储抖动</td><td>低</td><td>⚠️ 可降级为瞬断</td></tr>
<tr><td>客户端进程</td><td>系统省电查杀、用户退出、内存压力回收</td><td>中</td><td>⚠️ 靠保活降低(第 8 章)</td></tr>
<tr><td>协议层</td><td>TCP 半开连接、socket 假死</td><td>中</td><td>✅ 心跳可检出</td></tr>
</tbody>
</table></div>
<h3><span class="n3">13.2</span>抗断连五层设计</h3>
<p><strong>第一层 · 心跳与半开检测</strong></p>
<ul><li>双向心跳:采集端 10 秒一次 <code>agent:heartbeat</code>,服务端记录最后心跳时间。</li><li>半开连接检测:服务端 30 秒未收到心跳即判「疑似离线」(黄灯)。<strong>TCP 假死时操作系统不会通知应用层,必须靠应用层心跳兜底。</strong></li><li>容忍丢 1–2 个心跳再判定,避免弱网误杀。</li></ul>
<p><strong>第二层 · 智能重连</strong></p>
<ul><li>指数退避:1 秒起步 → ×2 → 上限 60 秒。</li><li><strong>随机抖动</strong>:叠加随机量,防止万级设备同时重连形成"重连风暴"(比断连本身更致命)。</li><li><strong>网络状态感知</strong>:监听系统网络变化事件,网络恢复<strong>立即主动重连</strong>,不等退避计时器(从"被动等"升级到"主动探")。</li></ul>
<p><strong>第三层 · 数据完整</strong></p>
<ul><li>断线缓存 120 帧(约 2 分钟),<code>seq</code> 单调递增。</li><li>重连后按 <code>seq</code> 补传,服务端按 <code>seq</code> 去重。</li><li>超出窗口的时段<strong>如实显示断点,绝不插值伪装</strong>。</li></ul>
<p><strong>第四层 · 会话不失效</strong></p>
<ul><li>断连期间 JWT 过期是常见坑 → 刷新令牌机制保证重连后<strong>静默续期</strong>,不逼用户重登。</li><li>设备令牌独立于查看会话,断连重连不触发鉴权抖动。</li></ul>
<p><strong>第五层 · 服务端无感迁移</strong></p>
<ul><li>网关无状态,实例重启后设备重连到任意节点都能续上订阅(状态在 Redis)。</li><li>滚动更新先摘流量再停实例(见 11.3)。</li><li>扩容用 Redis Adapter 跨节点广播,新增实例不中断既有连接。</li></ul>
<h3><span class="n3">13.3</span>用户感知设计</h3>
<ul><li><strong>状态灯三态</strong>:绿(在线)/ 黄(心跳超时,疑似离线)/ 灰(离线)。</li><li><strong>最后心跳时间</strong>:查看端明示"该设备 3 分钟未上报",而非干等卡住的图。</li><li><strong>断点标注</strong>:图表断点处显式标注,不欺骗用户。</li></ul>
<h3><span class="n3">13.4</span>必须承认的两处天然不可靠</h3>
<ol><li><strong>移动端后台</strong>:Android 国内 ROM 与 iOS 的后台限制属系统策略,保活只能尽最大努力,做不到 100%。文案如实写。</li><li><strong>公网极端弱网</strong>:地铁隧道、电梯井等长时间无信号场景,超出 2 分钟缓存窗口的数据<strong>必然丢失</strong>,架构上如实显示断点。</li></ol>
<h3><span class="n3">13.5</span>可靠性验收标准</h3>
<div class="tw"><table>
<thead><tr><th>场景</th><th>验收标准</th></tr></thead>
<tbody>
<tr><td>断网 10 分钟</td><td>恢复后自动重连,补传不重复、不丢失</td></tr>
<tr><td>强制重连 1000 次</td><td>无内存增长、无线程泄漏、无句柄泄漏</td></tr>
<tr><td>服务端滚动重启</td><td>客户端 30 秒内自动恢复,用户无感</td></tr>
<tr><td>弱网模拟(丢包 20%)</td><td>连接不中断,指标连续可读</td></tr>
<tr><td>万级设备重连风暴</td><td>服务端不雪崩(抖动机制生效)</td></tr>
<tr><td>TCP 半开连接</td><td>30 秒内检出并标记疑似离线</td></tr>
<tr><td>72 小时长稳</td><td>连接零人工干预,状态灯准确</td></tr>
</tbody>
</table></div>
<hr>
</section>
<section class="sec" id="sec-14">
<h2><span class="num">14</span>质量保障与发布策略</h2>
<h3><span class="n3">14.1</span>测试策略</h3>
<ul><li><strong>单元测试</strong>:指标差值计算、协议编解码(JSON 与二进制双模)、配对状态机、退避算法。</li><li><strong>集成测试</strong>:本地起 Redis 与网关的采集到查看闭环自动化用例。</li><li><strong>真机矩阵</strong>:Windows 10/11、macOS 13+、不少于 5 台主流厂商 Android 真机、iOS 16+。</li><li><strong>长稳测试</strong>:72 小时连续运行(判定标准见 12.5)。</li><li><strong>可靠性专项</strong>:断网恢复、重连风暴、半开检测、滚动更新(见 13.5)。</li><li><strong>泄漏专项</strong>:重连 1000 次 + 24 小时采集内存回归(见 12.5)。</li></ul>
<h3><span class="n3">14.2</span>CI 与 CD</h3>
<p>采用 GitHub Actions:提交触发静态检查(Rust <code>clippy</code> + Dart analyze)与单元测试;打标签触发多端构建产物;桌面端构建流程内置签名步骤;云端镜像构建推送后由 Compose 滚动更新。</p>
<h3><span class="n3">14.3</span>版本更新策略</h3>
<p><strong>确认式更新</strong>(V3.0 已废弃静默热更新,本版沿用):</p>
<div class="tcap">表 21 各平台更新机制</div>
<div class="tw"><table>
<thead><tr><th>平台</th><th>更新机制</th><th>交互原则</th></tr></thead>
<tbody>
<tr><td>Windows</td><td>Squirrel.Windows 或 MSIX 安装器更新</td><td>提示新版本,用户确认后安装并重启采集</td></tr>
<tr><td>macOS</td><td>Sparkle 框架(公证后分发)</td><td>同上,更新前明确提示</td></tr>
<tr><td>Android</td><td>Play in-app update;侧载包提示下载由用户确认</td><td>遵循商店政策,不静默安装</td></tr>
<tr><td>iOS</td><td>App Store 更新</td><td>应用内检测并引导跳转</td></tr>
</tbody>
</table></div>
<p>采集进程更新执行顺序:停止采集 → 完成更新 → 重新拉起,期间界面状态条明确提示,全程由用户发起或确认。</p>
<h3><span class="n3">14.4</span>日志与诊断</h3>
<p>客户端内置 10 MB 滚动日志,提供一键导出,导出前自动脱敏(令牌、账号信息剔除)。服务端输出结构化 JSON 日志,每条请求携带追踪标识,贯穿网关、业务与存储三层。</p>
<hr>
</section>
<section class="sec" id="sec-15">
<h2><span class="num">15</span>里程碑与排期</h2>
<h3><span class="n3">15.1</span>阶段划分</h3>
<div class="tcap">表 22 里程碑规划(单人全栈估算,约 20 周)</div>
<div class="tw"><table>
<thead><tr><th>阶段</th><th>周期</th><th>关键任务</th><th>产出与验收</th></tr></thead>
<tbody>
<tr><td>M0 技术验证</td><td>第 1–2 周</td><td><strong>Rust 核心预研</strong>(FFI 桥接、五端交叉编译、采集原型);三端温度路径验证;协议 v1 冻结;账号模型设计</td><td>Rust 核心可编译到两端并采集出 CPU/内存;温度 Go/No-Go 报告;协议 schema v1 定稿</td></tr>
<tr><td>M1 MVP 闭环</td><td>第 3–6 周</td><td>Windows 与 Android 采集端、云端网关与配对、查看端、P0 全项</td><td>双端闭环演示;断网重连与配对全流程验收通过</td></tr>
<tr><td>M2 多端与账号</td><td>第 7–12 周</td><td>macOS 与 Linux 采集端、iOS 查看端、账号体系、时序历史与档位、P1 全项</td><td>五端可用;免费与专业档位生效</td></tr>
<tr><td>M3 商业化打磨</td><td>第 13–16 周</td><td>计费支付接入、签名与公证打包、<strong>内存与可靠性专项验收</strong>、文档与隐私政策</td><td>内测版分发 50–100 名用户;专项验收通过</td></tr>
<tr><td>M4 公测发布</td><td>第 17–20 周</td><td>商店提审、官网上线、反馈迭代、线上指标观测</td><td>全平台公测包发布</td></tr>
</tbody>
</table></div>
<h3><span class="n3">15.2</span>关键决策点与验收标准</h3>
<ul><li><strong>M0 结束</strong>:Rust 核心 FFI 与交叉编译必须验证通过(否则退回纯 Flutter + 原生插件方案);温度承诺据验证结论定稿(见 7.2)。</li><li><strong>M1 结束</strong>:断网 10 分钟自动恢复且补传不重复;配对全流程 60 秒内完成;云端 72 小时零人工干预。</li><li><strong>M3 结束</strong>:内存与可靠性专项验收全部通过(见 12.6、13.5)。</li><li>每个里程碑结束安排半周缓冲,缓冲优先消化前序阶段遗留缺陷。</li></ul>
<hr>
</section>
<section class="sec" id="sec-16">
<h2><span class="num">16</span>风险与合规</h2>
<h3><span class="n3">16.1</span>技术风险</h3>
<div class="tcap">表 23 主要技术风险与缓解措施</div>
<div class="tw"><table>
<thead><tr><th>风险</th><th>等级</th><th>缓解措施</th></tr></thead>
<tbody>
<tr><td>Rust FFI 桥接与五端交叉编译不成熟</td><td>中高</td><td>M0 前置验证;备选退回原生插件方案</td></tr>
<tr><td>FFI 边界内存泄漏</td><td>中高</td><td>显式所有权协议 + CI 泄漏冒烟 + 长稳回归</td></tr>
<tr><td>重连风暴打垮服务端</td><td>中高</td><td>指数退避加随机抖动;连接数骤降告警</td></tr>
<tr><td>TCP 半开连接假在线</td><td>中</td><td>应用层心跳检测,30 秒判疑似离线</td></tr>
<tr><td>服务端监听器/订阅关系泄漏</td><td>中</td><td>状态外置 Redis 带 TTL,断开即清理</td></tr>
<tr><td>Android 后台采集被查杀</td><td>中</td><td>前台服务 + 电池优化引导 + 设置路径文档</td></tr>
<tr><td>iOS 审核被拒</td><td>中</td><td>明确前台采集定位,审查备注说明数据用途</td></tr>
<tr><td>Windows 温度插件被误报</td><td>中</td><td>温度默认关闭;插件独立签名,不污染主客户端</td></tr>
<tr><td>时序存储成本超预期</td><td>低</td><td>降采样 + 档位保留,按实测调整粒度</td></tr>
</tbody>
</table></div>
<h3><span class="n3">16.2</span>法律与隐私合规</h3>
<ul><li>用户协议以显著方式告知采集数据范围,仅限系统性能指标。</li><li>隐私政策包含数据清单、用途、存储位置、保留期限与删除途径。</li><li>被查看设备必须主动配对授权,解除授权即时生效;授权记录可追溯。</li><li>如服务欧盟用户,按 GDPR 补充数据主体权利响应流程。</li></ul>
<h3><span class="n3">16.3</span>平台政策要点</h3>
<ul><li><strong>Android</strong>:前台服务类型在 Play Console 如实申报。</li><li><strong>Windows</strong>:用户态安装包用 EV 证书签名;温度插件如涉及内核驱动需通过微软认证,独立签名。</li><li><strong>macOS</strong>:所有分发产物完成开发者签名与公证,特权 helper 单独公证。</li><li><strong>iOS</strong>:仅前台采集,审核备注说明采集目的与数据范围。</li></ul>
<h3><span class="n3">16.4</span>对外叙事统一</h3>
<p>官网、商店描述与应用内文案统一表述为<strong>监控自己的或经授权的设备</strong>;不使用"远程监控他人"等易触发误判的表述;宣传截图不出现任何未经授权的监控场景。该原则贯穿市场、客服与文档全部出口。</p>
<hr>
</section>
<section class="sec" id="sec-17">
<h2><span class="num">17</span>成本估算与总结</h2>
<h3><span class="n3">17.1</span>成本估算</h3>
<div class="tcap">表 24 成本估算(按月活 1000 台设备规模)</div>
<div class="tw"><table>
<thead><tr><th>项目</th><th>类型</th><th>估算</th></tr></thead>
<tbody>
<tr><td>云服务器(4 核 8G)</td><td>月度</td><td>约 300 元/月</td></tr>
<tr><td>带宽与流量</td><td>月度</td><td>100–300 元/月(二进制编码后可下调)</td></tr>
<tr><td>存储(PG + TimescaleDB 同机)</td><td>月度</td><td>随档位保留增长,数十 GB 量级</td></tr>
<tr><td>EV 代码签名证书</td><td>年度</td><td>约 2000 元/年</td></tr>
<tr><td>Apple Developer</td><td>年度</td><td>99 美元/年</td></tr>
<tr><td>Google Play 开发者账号</td><td>一次性</td><td>25 美元</td></tr>
<tr><td>域名与 SSL</td><td>年度</td><td>域名数十元/年;SSL 用 Let's Encrypt 免费</td></tr>
</tbody>
</table></div>
<p><strong>相比 V3.0 的成本变化</strong>:移除 VictoriaMetrics 独立组件(省服务器资源与运维),移除 Windows 内核驱动认证费用(数千元一次性,转为按需插件)。</p>
<h3><span class="n3">17.2</span>总结</h3>
<p>V4.0 相对 V3.0 的本质变化有四条:</p>
<ol><li><strong>把复用做到极致</strong>——采集、保活、传输下沉为单一 Rust 核心,五端只写一遍,行为一致、内存可控。</li><li><strong>把扩展做成免费</strong>——网关无状态化,连接态与订阅关系外置 Redis,扩容从"重构"变成"加实例"。</li><li><strong>把风险架构化规避</strong>——Windows 温度驱动、时序存储组件两大高投入项,用架构设计替代硬扛。</li><li><strong>把命门前置</strong>——内存安全、连接可靠性与部署形态三项决策首次成为独立的架构章节与验收红线,而非事后补丁。</li></ol>
<p>方案自本版起可作为开发执行的基线文档。</p>
<h3><span class="n3">17.3</span>下一步行动</h3>
<ol><li><strong>启动 M0 双预研</strong>:Rust 核心 FFI/交叉编译可行性 + 三端温度路径,两周内产出结论。</li><li><strong>冻结通信协议 v1</strong>:按第 5 章发布 schema 文档(含 JSON 与二进制双模),作为客户端与服务端共同契约。</li><li><strong>搭建云端骨架</strong>:按第 11 章完成 Docker Compose 编排、无状态网关与 Redis 环形缓冲,为 MVP 联调就绪。</li></ol>
<hr>
<blockquote><strong>免责声明</strong>:本文档为技术方案设计文件,不构成任何形式的投资建议或商业承诺。文中涉及的排期、成本、容量均为估算值,需在实际研发与运营中按实测数据校准。</blockquote>
</section>
</main>
</div>
</div>
<footer>
全平台设备性能监控系统商业化实施方案 V4.0 · 技术方案设计文件,不构成投资建议或商业承诺<br>
排期、成本、容量均为估算值,需按实测数据校准
</footer>
</body>
</html>
-109
View File
@@ -1,109 +0,0 @@
# 发布与签名(墨观 InkWatch · Android)
> 2026-09-16 立。此前 release 一直用 Flutter 模板的默认行为 —— **debug 签名**。
## ⚠️ 为什么签名这件事必须先说
`client/android/app/build.gradle.kts` 里原本是模板原样:
```kotlin
buildTypes { release { signingConfig = signingConfigs.getByName("debug") } }
```
后果有三条,且**都不报错**:
1. **debug 密钥是公开的**(AOSP 测试密钥,人人都有)→ 任何人都能签一个
"同签名"的包,冒充你的应用升级;
2. **无法上架**(Google Play 拒绝 debug 签名的包);
3. 一旦改用正式密钥,**已装的 debug 签名版本无法覆盖升级** —— Android 只认签名一致的包。
第 3 条是不可逆的:**越早锁正式签名越好**。故本次一并改造。
## 一、密钥(keystore)
| 项 | 值 |
|---|---|
| 路径 | `~/keystores/inkwatch-release.jks`(**仓库外**) |
| 别名 | `inkwatch` |
| 类型 / 长度 | PKCS12 · RSA 2048 |
| 有效期 | 至 **2054-02-01** |
| SHA-256 指纹 | `0D:58:F8:B5:74:9B:6A:60:C8:04:2F:6F:43:33:A0:C8:47:19:2B:F3:7D:44:E5:6C:DD:47:56:F3:97:0A:D6:79` |
| 凭据记录 | `~/keystores/inkwatch-release-CREDENTIALS.txt`(权限 600) |
> ⚠️⚠️ **keystore 与口令必须永久备份(离线副本、异地各一份)。**
> 它们丢了就**永远无法更新已发布的应用** —— Android 的签名校验不可绕过,
> 只能换包名重新上架、老用户全部流失。这是本仓库**唯一不可再生**的东西。
对外只公布**指纹**,绝不公布 keystore 与口令。
## 二、`key.properties` 契约
release 构建从 `client/android/key.properties` 读凭据(**已被 `.gitignore` 排除**):
```properties
storeFile=/绝对路径/inkwatch-release.jks
storePassword=...
keyAlias=inkwatch
keyPassword=...
```
**缺这个文件时,release 构建会明确失败**(`build.gradle.kts` 末尾的 task-graph 守卫),
报错信息里带着修复指引。
> 为什么不学 Flutter 模板"回落到 debug 签名":那会造出一条
> **"看着构建成功、其实签的是公开密钥"** 的静默失败路径。
> 本仓库多次栽在静默失败上(迁移从不执行、首次部署路径无人测),故这里宁可让它红。
变异验证:移走 `key.properties` → `flutter build apk --release` 在 **2 秒内失败**并打印指引;
放回 → 构建成功。签名指纹确认为上表值(`apksigner verify --print-certs`)。
## 三、出包
```bash
cd client
# 通用版(主干 main)
git checkout main
flutter build apk --release --target-platform android-arm64 \
--dart-define=PMON_DEFAULT_SERVER=https://<你的服务器>
cp build/app/outputs/flutter-apk/app-release.apk \
../dist/inkwatch-arm64-$(grep -E '^version:' pubspec.yaml | cut -d' ' -f2 | cut -d+ -f1).apk
# 水墨屏版(分支 feature/eink)—— 包名不同,产出也另起名
git checkout feature/eink
flutter build apk --release --target-platform android-arm64 ...
cp build/app/outputs/flutter-apk/app-release.apk \
../dist/inkwatch-eink-arm64-$(...).apk
```
**验证签名**(每次发版都要做一次,5 秒):
```bash
~/Library/Android/sdk/build-tools/*/apksigner verify --print-certs dist/<包>.apk | grep -E "DN:|SHA-256"
# 期望 DN: CN=InkWatch, OU=Mobile, O=InkWatch, L=Shenzhen, ST=Guangdong, C=CN
```
## 四、两个包名的分工(**刻意不同**)
| 版本 | 分支 | applicationId | 说明 |
|---|---|---|---|
| 通用版 | `main` | `com.pmon.pmon_client` | 已装机的包名,**语义上不可更改**(改了等于另一个应用) |
| 水墨屏版 | `feature/eink` | `com.inkwatch.eink` | 刻意独立 → 两个版本**可在同一台手机上共存**、各自升级 |
**为什么必须分开**:两条分支的 `versionCode` 各自递增(1.0.9+9 vs 1.1.2+12)。
若共用一个包名,它们会互相覆盖 —— 且 versionCode 小的那个会被系统当成**降级**而拒绝安装。
两版并行的产品线(通用版给用户、水墨屏版自用/专用)必须各占一个包名。
> 注:`namespace`(Kotlin 包名)两版仍是 `com.pmon.pmon_client` —— 它只影响
> R 类与源码包声明,与"装成哪个应用"无关,故不动以免大改 Kotlin 源码。
## 五、换签名的后果(一次性)
本次换正式签名后,此前用 debug 密钥装的设备(如 F50)**无法覆盖升级**,需要:
```bash
adb uninstall com.pmon.pmon_client # 或水墨屏版对应包名
adb install dist/inkwatch-arm64-<版本>.apk
```
**数据不会丢**:`device_id` 自 1.0.9 起只由硬件标识(ANDROID_ID)派生,
且服务端支持设备归并 —— 重装后重新登录并认领即可,历史帧仍在原设备名下。
-56
View File
@@ -1,56 +0,0 @@
# 项目改名记录:PMon → 墨观 InkWatch
> 决策日期:2026-09-16 决策人:产品负责人
> 对外名 **墨观 / InkWatch**;仓库目录 `inkwatch/`。
## 一、为什么只改"看得见的那一半"
`pmon` 在这个项目里有**两种身份**,它们必须分开处理:
| 身份 | 出现在哪 | 改的代价 |
|---|---|---|
| **显示名** | 界面标题、应用名、通知、文档抬头、APK 名 | 低 —— 改了立刻见效 |
| **内部标识** | 包名、FFI 符号、applicationId、`docker compose` 的 project name、卷名、数据库名 | **高到危险** —— 见下表 |
`PMon`(大写 P)只出现在名称语境,`pmon` / `Pmon` 才是标识符 —— 所以"改大写、留小写"这条规则天然安全。
## 二、已改(显示名)
| 位置 | 现值 |
|---|---|
| `client/lib/main.dart` | `MaterialApp.title = '墨观'`、采集页标题 `'墨观 采集端'` |
| `client/lib/ui/viewer_page.dart` | 查看页标题 `'墨观 查看端'` |
| `client/android/.../AndroidManifest.xml` | `android:label="墨观"`(桌面图标名,刻意短) |
| `client/android/.../PmonCollectorService.kt` | 前台通知 `'墨观 正在采集'` |
| 日志前缀 | `[inkwatch]` / `[inkwatch][boot]` —— **刻意用 ASCII**:中文在 Dart AOT 里按 UTF-16LE 存,拿 `grep -F` 核验恒为 0(踩过) |
| `client/pubspec.yaml` | `description` 换成正式描述(顺手清掉 Flutter 模板残留) |
| `README.md` / `docs/*.md` | 抬头与标题 |
| 方案文档 | 文件改名:`docs/pmon-v4-architecture.md` → `docs/inkwatch-v4-architecture.md`(引用同步) |
## 三、**刻意没改**(内部标识)—— 每一条都说明了原因
| 标识 | 现值 | 改了会怎样 |
|---|---|---|
| Dart 包名 | `name: pmon_client` | 全部 `import 'package:pmon_client/...'` 失效(数百处),改动面巨大、纯风险无收益 |
| Android applicationId / namespace | `com.pmon.pmon_client` | **已装机变成"另一个 App"**:无法覆盖升级,本地配置、会话令牌、设备归属全部断链(要重装 + 重新登录认领)。**越接近上架越不该改** |
| Rust FFI 符号 | 39 个 `_pmon_*` | Rust + Dart 绑定 + Python 冒烟 + 全部测试一起改,且 .so 与客户端版本必须严格配对 |
| `docker compose` project name | `name: pmon` | ⚠️ **它是卷名锚点**(`pmon_pg_data`)。改成 `inkwatch` → compose 去找一套新的空卷 → **生产数据库等于被清库** |
| 数据库名 / 连接串 | `pmon` / `.../pmon` | 同上,且要迁移数据 |
| 测试文件与用例前缀 | `pmon_*` | 不影响用户,改了只会制造 diff |
| 历史方案文档文件名 | `docs/pmon-v4-架构方案-V4.0.html`、`docs/全平台设备性能监控系统商业化实施方案V4.0.docx` | 这两份是 PMon 时代的方案原文,**内容本身带旧名**(正文里的产品名、界面名都是当时的写法)。改名文件只会造出"新文件名 + 旧内容"的另一种不一致;留作历史存档,2026-10-16 审计登记在此,后来者不必反复怀疑"是不是漏改了" |
⚠️ **因此 `deploy/docker-compose.yml` 的 `name: pmon` 上方加了防呆注释**:
后来者看到"项目已改名墨观",最顺手的动作就是把这行也改掉 —— 那一下就是清库。
## 四、若要彻底改名(连内部标识一起)
这不是"改个标题",是一次独立改造,顺序必须如下:
1. 全量引用清单(哪些文件引用了该标识)+ 护栏测试先行(防止漏改却编译通过)
2. **先改 Rust FFI 符号**并保持旧符号别名一段时间(否则新旧客户端对不上 .so)
3. **最后**动 applicationId —— 且必须接受:所有已装机要重装、重新登录认领(历史数据不丢:
1.0.9 起 `device_id` 只由硬件标识派生,且服务端支持设备归并)
4. **绝不**动 compose 的 `name` 与卷名;若非要改,必须显式 `external` 复用旧卷,否则先备份
5. 逐条跑全量回归
按现状评估:**不建议做**(收益是"顺眼",代价是链路与数据风险)。
-159
View File
@@ -1,159 +0,0 @@
import io
# ═══ ① relay.js:PUT 若对端未到,挂起等(谁先到都行)═══
p = 'server/gateway/relay.js'
t = io.open(p, encoding='utf-8').read()
# 删掉 isDownloaderReady(改用挂起等待后不再需要)
i = t.index(' /**\n * 接收端**在不在等**?')
j = t.index(' /**\n * 发送端接入。')
t = t[:i] + t[j:]
old = """ attachUploader(id, token, req, res) {
const r = this._auth(id, token, 'up');
if (!r.ok) return { ok: false, code: r.code };
const s = r.session;
if (s.uploader) return { ok: false, code: 'E_ALREADY_UPLOADING' };
const d = s.downloader;
if (!d) return { ok: false, code: 'E_NO_DOWNLOADER' };
s.uploader = { req, res };
clearTimeout(d.timer);"""
new = """ async attachUploader(id, token, req, res) {
const r = this._auth(id, token, 'up');
if (!r.ok) return { ok: false, code: r.code };
const s = r.session;
if (s.uploader) return { ok: false, code: 'E_ALREADY_UPLOADING' };
// ── 对端还没到就**挂起等**,而不是回 404 ────────────────────────────
//
// ⚠️ 这一版是**简化后的设计**。之前要求"发送端先探一次对端在不在",
// 那套预检本身成了 bug 温床(HEAD 会被 Express 的 app.get 抢走,
// 拿上传令牌去验下载令牌)。而"谁先到都行"本来就是中继的自然语义 ——
// 发送端早到了,服务端把它挂着,等接收端一连上就立刻对接。
//
// 期间 req 的 body 由 TCP 窗口与 Node 的读缓冲自然背压住,
// 不需要客户端做任何协调。
let d = s.downloader;
if (!d) {
d = await new Promise((resolve) => {
const timer = setTimeout(() => {
if (s.downloader === waiter) s.downloader = null;
resolve(null);
}, WAIT_PEER_MS);
const waiter = { resolve: null, reject: null, res: null, timer, pending: true, settle: (x) => { clearTimeout(timer); resolve(x); } };
s.downloader = waiter; // 占位:接收端若这时到来会看到它
});
if (!d) return { ok: false, code: 'E_WAIT_TIMEOUT' };
}
s.uploader = { req, res };
clearTimeout(d.timer);"""
assert t.count(old) == 1, 'attachUploader 锚点不唯一'
t = t.replace(old, new, 1)
# 接收端到达时:若已有"等待中的 uploader 占位",直接交接
old2 = """ return new Promise((resolve, reject) => {
const entry = { resolve, reject, res, timer: null };
entry.timer = setTimeout(() => {
if (s.downloader === entry) s.downloader = null;
reject(new Error('E_WAIT_TIMEOUT'));
}, WAIT_PEER_MS);
s.downloader = entry;
});"""
new2 = """ return new Promise((resolve, reject) => {
const prev = s.downloader;
const entry = {
resolve, reject, res, timer: null,
settle(x) { clearTimeout(this.timer); resolve(x); },
};
entry.timer = setTimeout(() => {
if (s.downloader === entry) s.downloader = null;
reject(new Error('E_WAIT_TIMEOUT'));
}, WAIT_PEER_MS);
s.downloader = entry;
// 若发送端早已在等(挂起占位),立刻把它叫醒 —— 谁先到都行
if (prev && prev.pending && typeof prev.settle === 'function') {
prev.settle(entry);
}
});"""
assert t.count(old2) == 1, 'waitDownloader 锚点不唯一'
t = t.replace(old2, new2, 1)
io.open(p, 'w', encoding='utf-8').write(t)
print('① relay.js 已改为「谁先到都行」')
# ═══ ② index.js:PUT 路由改 async,删掉 HEAD 分流 ═══
p = 'server/gateway/index.js'
t = io.open(p, encoding='utf-8').read()
i = t.index(" // ⚠️ **HEAD 必须在这里分流,不能靠单独的 app.head()**:")
j = t.index(" res.set('content-type', 'application/octet-stream');")
t = t[:i] + t[j:]
old3 = """ app.put('/relay/:id', (req, res) => {
const a = relay.attachUploader(req.params.id, req.query.t, req, res);
if (!a.ok) {"""
new3 = """ app.put('/relay/:id', async (req, res) => {
// 对端还没到就在这里挂着等(见 relay.js attachUploader 的说明)——
// 客户端因此可以**直接写完 body 再 close**,不必做任何协调。
const a = await relay.attachUploader(req.params.id, req.query.t, req, res);
if (!a.ok) {"""
assert t.count(old3) == 1, 'PUT 路由锚点不唯一'
t = t.replace(old3, new3, 1)
io.open(p, 'w', encoding='utf-8').write(t)
print('② index.js:PUT 改 async、删掉 HEAD 分流')
# ═══ ③ 客户端:直接写完 body 再 close(取消预检)═══
p = 'client/lib/host/lan_service.dart'
t = io.open(p, encoding='utf-8').read()
i = t.index('Future<void> uploadViaRelay({')
j = t.index('/// 经中继下载(接收端)。')
new4 = '''Future<void> uploadViaRelay({
required LanRelayChannel channel,
required String path,
Duration totalTimeout = const Duration(minutes: 10),
void Function(int sent, int total)? onProgress,
}) async {
final file = File(path);
final total = await file.length();
final uri = Uri.parse('${channel.serverBase}/relay/${channel.id}'
'?t=${Uri.encodeQueryComponent(channel.token)}');
// ⚠️ **顺序:先写完 body,再 close。反过来会死锁。**
//
// 臣在这里栽过两次,值得写下来:
// 第一版是"先 `await req.close()` 拿响应头、确认对端在了再写 body" ——
// 但 Dart 的 `close()` 要等 body 完整才把请求发出去,于是它等一个永远不会
// 来的响应,**请求根本没出门**,服务端一直等到超时。
// 服务端用 curl 手测全绿(curl 是先发 body),**只有真客户端能暴露它**。
//
// 现在服务端改成"对端没到就挂着等",客户端因此**什么都不用协调**:
// 直接推即可。这是更简单、也更接近"流式转发"本意的形态。
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final req = await client.putUrl(uri);
req.headers.contentLength = total;
var sent = 0;
await for (final chunk in file.openRead()) {
req.add(chunk);
sent += chunk.length;
onProgress?.call(sent, total);
}
final resp =
await req.close().timeout(totalTimeout); // 对端就绪前会一直挂着
if (resp.statusCode != 200) {
await resp.drain<void>().catchError((_) {});
throw HttpException(
'中继上传失败:HTTP ${resp.statusCode}(对端可能一直没来收)', uri: uri);
}
await resp.drain<void>().catchError((_) {});
} finally {
client.close(force: true);
}
}
'''
io.open(p, 'w', encoding='utf-8').write(t[:i] + new4 + t[j:])
print('③ 客户端已取消预检,改为直接推')
+4 -2
View File
@@ -31,8 +31,10 @@
* ① 受 cwd 约束:`git ls-files` / `git grep <rev> -- .` 在 tests/ 里跑就只覆盖
* tests/**(实测 26/189,少 86%)。而 run-all.sh:27 正是 `cd "$(dirname "$0")"`。
* → 开跑先 `git rev-parse --show-toplevel`,所有 git 调用带 cwd=ROOT。
* ② 非 ASCII 路径:`git ls-files` 默认加引号转义输出("docs/xxx-\346\236\266.html"),
* 照原样 readFileSync 打不开 → 被 catch 静默跳过。本仓库有两个中文名文件。
* ② 非 ASCII 路径:`git ls-files` 默认加引号转义输出(中文名会被转成
* \346\236\266 这类八进制序列),照原样 readFileSync 打不开 → 被 catch
* 静默跳过。2026-10-17 起文档类文件只留本地(仓库内已无中文名文件),
* 判据留着防回归。
* → 所有 git 调用带 `-c core.quotepath=false`。
* ③ 未跟踪文件:只扫 `ls-files`(纯受跟踪)会漏掉「刚写好、还没 add」的新文件,
* 而那正是下一次提交的原料。→ 加 `--others --exclude-standard`。