Appearance
第十三章:运维监控后台与设备管理
13.1 背景:既要可观测,又要零隐私泄露
一个上线运行的服务需要基本的可观测性:有多少用户、多少设备、当前谁在线、VPS 内存吃紧没有。但 TodoApp 的立身之本是隐私——待办内容端对端加密,服务端自己都读不到(见 第十章)。这就带来一条不可动摇的红线:
运维后台可以看"有哪些用户、哪些设备、在不在线",但绝不能看到任何一条待办内容(无论明文还是密文),IP 必须脱敏。
本章介绍两块紧密相关的能力:面向运维者的 /dashboard/ 监控后台,以及支撑它的 设备管理机制。
13.2 运维监控后台 /dashboard/
后台由 dashboard_handler.dart 提供,挂载在 /dashboard/ 路径,是一个服务端渲染的单页 HTML(<meta refresh> 每 10 秒自动刷新)。
认证与防护
后台暴露在公网,鉴权机制与 /logs/ 同源但独立 session:
| 机制 | 说明 |
|---|---|
| Basic Auth | 首次访问弹浏览器认证框,账号密码复用 .env 的 LOG_USER / LOG_PASS |
| 签名 Cookie | 认证成功后种一个 7 天有效的 dashboard_session,用 LOG_SECRET 签名;属性 HttpOnly + Secure + SameSite=Strict |
| 失败限速 | 同一 IP 连续认证失败 5 次,锁定 15 分钟,期间返回 429 |
| 退出 | /dashboard/?logout=1 清 Cookie 并要求重新认证 |
展示内容
页面分为三块:
① 统计卡片(4 个)
- 当前在线设备数(SSE 实时连接,内存直读)
- 总注册用户数
- 已绑定设备总数
- VPS 内存负载(读
/proc/meminfo,按 60% / 80% 阈值变色)
其中"总用户数"和"设备总数"是相对昂贵的 COUNT 查询,缓存 10 分钟,避免每次刷新都打数据库——这是 1C/1GB VPS 的资源纪律。
② 实时在线监视器
直接读 EventBroadcaster 内存中的活跃 SSE 连接(不查数据库),展示每台在线设备的:用户 ID(截断)、昵称、设备名、脱敏 IP、地理属地(城市)、连接时间与在线时长。
③ 注册用户与设备总表
分页(每页 20 条)、可按昵称/邮箱搜索,展示每个用户的:ID、昵称、邮箱、密钥版本 key_version、历史绑定设备列表(按设备名去重)、最后活跃时间。设备查询采用"先查用户、再按用户 ID 批量查设备"的两步式,避免 N+1。
隐私红线的落地
- 页面底部固定声明:「本面板绝不收集、不缓存、不展示用户任何明文/密文待办内容」——代码层面 dashboard 也确实从不查询
todos表的title/description。 - 所有用户输入(昵称、邮箱、搜索关键字)插入 HTML 前一律经
_e()转义,防 XSS。 - 所有 IP 经
EventBroadcaster.maskIp()脱敏后才显示(见下文 13.4)。
13.3 设备管理
设备标识与命名
每台客户端在启动时生成一个持久化的 deviceId(存本地,见 第三章 3.2.3),并由 DeviceService 采集一个人类可读的设备名:
| 平台 | 设备名格式 |
|---|---|
| Android | Android: <机型>(如 Android: Pixel 8) |
| Windows | Windows: <计算机名> |
| iOS / macOS | iOS: <machine> / macOS: <计算机名> |
每次请求通过 HTTP 头携带:X-Device-Id(用于 SSE 去重与广播过滤)、X-Device-Name(用于设备管理展示,URL 编码传输)。
device_logins 表
设备登录历史存在 device_logins 表(主键 device_id + user_id):
| 字段 | 说明 |
|---|---|
| device_id | 客户端持久设备 ID |
| user_id | 所属用户(外键,级联删除) |
| device_name | 设备名 |
| last_login_at | 最后登录时间戳 |
| last_city | IP 归属城市,未解析时为「解析中」 |
写入时机:SSE 连接握手成功后异步写入(upsertDeviceLogin),不阻塞 SSE 建连。同一 device_id + user_id 重复登录只更新,不新增行。运维后台展示时还会按 device_name 再做一次去重(同名取最新)。
在线状态
"在线"与"历史设备"是两个数据源:
- 在线:
EventBroadcaster内存中的活跃 SSE 连接,App 一断连即消失,实时准确。 - 历史绑定设备:
device_logins表,持久保存曾登录过的设备。
后台把两者并列展示:上方"实时在线监视器"来自内存,下方用户总表的"历史绑定设备"来自数据库。
13.4 隐私边界
IP 脱敏
任何 IP 在写日志或上后台前都经 maskIp() 处理:
dart
static String maskIp(String ip) {
if (ip.contains(':')) return 'IPv6'; // IPv6 整体隐去
final parts = ip.split('.');
if (parts.length != 4) return ip;
return '${parts[0]}.${parts[1]}.XX.XX'; // IPv4 只保留前两段
}IPv4 只保留前两段(够判断大致网络归属),后两段抹成 XX.XX;IPv6 直接显示为 IPv6。完整 IP 从不落库、不上后台。
城市解析
为了展示"地理属地",后台通过第三方接口(ip-api.com)把 IP 解析为城市名,结果缓存在内存 _ipCityCache,并带队列限流(避免高频外部请求)。解析用的是完整 IP,但只在服务端内存中短暂使用,展示与落库的始终是城市名 + 脱敏 IP,城市未解析出来时显示「解析中」。
为什么这些设计是"隐私优先"的一部分
运维可观测性与用户隐私并不矛盾——关键在于只暴露运营所必需的最小信息:知道"某设备在线、大致在哪个网络"足以支撑运维判断,而"具体 IP、具体在做什么待办"既不必要、也不允许被看到。这与 E2EE 把服务端降级为"存储乱码的网盘"是同一套哲学在运维层的延续。
13.5 小结
| 关注点 | 设计要点 |
|---|---|
| 后台鉴权 | Basic Auth + 7 天签名 Cookie(LOG_SECRET)+ 失败限速,独立 session |
| 展示 | 统计卡片 / 实时在线(内存直读)/ 用户设备总表(分页搜索) |
| 资源纪律 | 昂贵统计缓存 10 分钟,设备查询两步式避免 N+1 |
| 设备管理 | device_logins 表 + X-Device-Id/Name 头,SSE 握手后异步写入 |
| 隐私红线 | 绝不展示待办内容 · IP 经 maskIp 脱敏 · 用户输入转义防 XSS |