Skip to content

第十三章:运维监控后台与设备管理

13.1 背景:既要可观测,又要零隐私泄露

一个上线运行的服务需要基本的可观测性:有多少用户、多少设备、当前谁在线、VPS 内存吃紧没有。但 TodoApp 的立身之本是隐私——待办内容端对端加密,服务端自己都读不到(见 第十章)。这就带来一条不可动摇的红线:

运维后台可以看"有哪些用户、哪些设备、在不在线",但绝不能看到任何一条待办内容(无论明文还是密文),IP 必须脱敏。

本章介绍两块紧密相关的能力:面向运维者的 /dashboard/ 监控后台,以及支撑它的 设备管理机制


13.2 运维监控后台 /dashboard/

后台由 dashboard_handler.dart 提供,挂载在 /dashboard/ 路径,是一个服务端渲染的单页 HTML(<meta refresh> 每 10 秒自动刷新)。

认证与防护

后台暴露在公网,鉴权机制与 /logs/ 同源但独立 session

机制说明
Basic Auth首次访问弹浏览器认证框,账号密码复用 .envLOG_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 采集一个人类可读的设备名

平台设备名格式
AndroidAndroid: <机型>(如 Android: Pixel 8
WindowsWindows: <计算机名>
iOS / macOSiOS: <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_cityIP 归属城市,未解析时为「解析中」

写入时机: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