# dsh Mobile App 设计文档(手机远程连接 dsh) > 结论先行:**做 PWA(移动 Web App),由 dsh 服务端自带、零额外部署**,已实现并通过手机视口实测。 > 团队私有工具不值得走应用商店分发;等需求稳定后再考虑用同一套 API 套原生壳。 --- ## 1. 方案选型 | 方案 | 评价 | |---|---| | **A. PWA 移动 Web App(已实现)** | 服务端直接在 `GET /app` 托管页面,手机浏览器打开即用、"添加到主屏幕"后全屏运行。零安装、零上架、更新即刷新。单文件无外部依赖(LAN 内网也能用,不依赖 CDN) | | B. Flutter / React Native 原生 App | 体验和推送能力更好,但要维护双端构建与分发(MDM/内测分发),对"管理设置和插件"这类低频管理场景过度 | | C. 套壳 WebView(如 Capacitor) | 折中:复用 PWA 代码换原生分发与推送。作为二期可选 | **PWA 与未来原生不冲突**:所有功能都走 §3 的 REST API,原生 App 只是换一个 UI 客户端。 --- ## 2. 信息架构(已实现) ``` 登录页 ├─ 服务端地址(自动预填当前域名) ├─ 方式一:邀请码注册(新成员,POST /v1/tokens) └─ 方式二:粘贴已有 Token(admin 发放 / /v1/tokens/self 加签) 主界面(底部 4 Tab) ⚙ 设置 生效配置列表(团队/个人来源与版本号标签)、编辑值、新增项; 顶部「📱 连接手机」卡片(admin):展示连接二维码、邀请码复制、多网卡地址候选切换 ⬇ 插件 插件列表(版本/渠道/sha256/大小)、下载制品、admin 发布与删除 🛡 管理 仅 admin:创建成员(Token 只显示一次)、审计日志 👤 我的 身份信息、Token 列表与撤销、退出登录 登录页 服务端地址 + 邀请码/Token;「📷 扫码连接」按钮(HTTPS 下可应用内扫码); 支持 dsh 桌面端生成的二维码深链 /app#invite=… 自动预填 ``` 交互细节: - **冲突处理**:编辑保存带 `base_version`,遇 409 弹确认框展示"服务器已被他人更新",用户可选强制覆盖(LWW)或放弃——与桌面客户端同一语义。 - **值即 JSON**:输入框接受 JSON 或普通文本,与服务端 `value_json` 模型一致。 - 权限自适应:非 admin 隐藏"管理"Tab、团队设置选项与插件删除按钮(服务端仍兜底校验)。 ## 3. API 映射(全部复用现有服务端,零后端新增) | App 功能 | API | |---|---| | 邀请码登录 | `POST /v1/tokens` | | 身份 / 会话校验 | `GET /v1/me`(401 即回登录页) | | 设置列表(合并视图 + ETag) | `GET /v1/settings` | | 编辑/新增设置(乐观并发) | `PUT /v1/settings/{scope}/{key}` | | 插件列表 / 制品下载 | `GET /v1/plugins`、`GET /v1/plugins/{id}/{ver}/download` | | 发布 / 删除插件(admin) | `POST /v1/plugins/{id}`、`DELETE /v1/plugins/{id}/{ver}` | | 成员管理 / 审计 / Token 撤销 | `POST /v1/users`、`GET /v1/audit`、`DELETE /v1/tokens/{hash}` | ## 4. 手机如何连上(按推荐顺序) 0. **扫码连接(最省事,已实现)**:桌面浏览器打开 `/app` 并用 admin 登录 → 设置 Tab → 「📱 连接手机」→ 手机**系统相机**对准二维码 → 自动打开本页并填好服务端地址与邀请码 → 输入用户名 → 连接。二维码内容由服务端 `GET /v1/connect/qr` 生成(深链 `http(s)://<地址>/app#invite=<邀请码>`,segno 渲染 SVG);多网卡机器会在卡片里列出全部候选地址,点选即切换二维码。应用内扫码按钮仅在 HTTPS(或 localhost)下可用——LAN HTTP 环境浏览器禁止网页调用摄像头,系统相机是唯一稳定通道。 1. **同一 Wi-Fi/局域网(当前运行模式)**:手机浏览器打开 `http://<服务机IP>:8020/app(8000/8010 已被本机 Django 开发占用,dsh 固定用 8020)`。Windows 首次启动 uvicorn 会弹防火墙授权,勾选"专用网络"允许。 2. **异地团队 → Tailscale**:服务机与手机都装 Tailscale,手机访问 `http://:8020/app`。零公网暴露,最推荐。 3. **公网 + HTTPS**:用仓库里的 docker compose(Caddy 自动证书绑定 `DSH_SYNC_DOMAIN`)。**PWA 的"添加到主屏幕"与 Token 存储在 HTTPS 下体验最稳**,纯 HTTP 下 iOS Safari 会限制部分能力。 连接后:iOS Safari → 分享 → "添加到主屏幕";Android Chrome → 菜单 → "添加到主屏幕",之后从桌面图标全屏进入。 ## 5. 安全设计 - Token 只存手机 `localStorage`,随请求 Bearer 头发送;401 自动清会话回登录页。 - "我的"Tab 可随时**远程撤销**任意设备 Token(丢手机立刻处置)。 - admin 创建成员的 Token 只显示一次,提示立即复制。 - 传输安全依赖网络形态(§4):公网必须走 compose + Caddy 的 HTTPS;LAN 内网 HTTP 可接受(团队私有数据 + 可随时撤销的 Token)。 - **DeepSeek API Key 依然只在各设备本地**,App 与服务端都不存(见 ARCHITECTURE.md §7)。 ## 6. 后续路线(按需做,不建议一次性) | 优先级 | 能力 | 说明 | |---|---|---| | ✅ 已完成 | 扫码连接 | 服务端生成含地址+邀请码的二维码(`/v1/connect/qr`,segno SVG),PWA 设置 Tab「连接手机」卡片展示;登录页支持 `#invite=` 深链自动预填 + 应用内扫码(BarcodeDetector,需 HTTPS) | | ✅ 已完成 | 安卓 APK | `android/` 原生 WebView 壳,`bash android/build.sh` 构建;`/apk` 免登录安装页 + 下载二维码。见 ARCHITECTURE.md §12 | | P1 | Service Worker 离线缓存 | 断网时仍可查看上次拉到的设置/插件清单 | | P2 | Capacitor 套壳 | 复用本 PWA 换原生分发,获得推送能力 | | P2 | 插件发布审核流 | 发布走"待审核"状态,admin 在手机上批准 | | P3 | 推送通知(插件更新/审计告警) | 需要原生或统一推送服务,PWA Web Push 在国内生态不可靠 | ## 7. 已实现与已验证 - ✅ `GET /app`:服务端自带单文件 PWA(`server/dsh_sync/static/mobile.html`,无外部依赖) - ✅ 扫码连接:`connect.py`(内网地址探测,RFC1918 优先)+ `GET /v1/connect/info` / `GET /v1/connect/qr`(admin-only)+ 设置 Tab「连接手机」卡片 + 登录页深链预填/应用内扫码 - ✅ 手机视口(390×844)实测:Token 登录 → 新增团队设置(写入 PG 成功)→ 插件列表/下载/发布 → 管理页审计日志渲染;连接二维码卡片渲染、候选地址切换、`#invite=` 深链预填实测通过 - ✅ 截图:`dsh-app-settings.png`、`dsh-app-plugins.png`、`dsh-app-admin.png`、`dsh-app-connect-qr.png`、`dsh-app-connect-phone.png` ## 8. 运维坑(2026-09-10 实测踩到并修复) ### 8.1 pytest 会洗掉生产库(已修) `tests/conftest.py` 原先默认 `DSH_TEST_DATABASE_URL=postgresql://dsh@127.0.0.1:15433/postgres` —— 与 `server-worker.cmd` 的生产 DSN **完全同一库**,且每个用例调 `db.reset_schema()` 执行 `DROP SCHEMA public CASCADE`。**跑一次 pytest,生产数据全没。** 现在的行为: - 默认测试库 = 生产实例的同名库加 `_test` 后缀(`postgres_test`),不存在则自动 `CREATE DATABASE`; - 若 `DSH_TEST_DATABASE_URL` 指向生产库,**直接拒绝运行**(`pytest.exit`,退出码 4),不靠注释约定; - 测试库独立,`reset_schema` 只影响它自己。 数据被洗后用 `python data/restore_state.py` 恢复(幂等):重建 bootstrap admin token、 按磁盘上的制品重算 sha256 重建 demo 插件行、清理测试残留用户。 ### 8.2 重复 worker 自旋刷日志(已修) `server-worker.cmd` 有个 `:loop` + `timeout /t 3 /nobreak` 的自动重启循环。两个问题叠在一起: 1. **`timeout` 在无控制台的分离进程里不等待** —— 实测 `timeout /t 3` 约 1.2s 就返回, 循环基本没有节流; 2. **没有单例守卫** —— 第二个 worker 启动 uvicorn 绑定失败、立即退出、马上重来, 每次迭代写两行日志,实测 **~1760 行/秒**,足以吃满磁盘。 修复:worker 启动前用 `netstat` 检查 `192.168.5.2:8020` 是否已被监听,是则 sleep 20s 空转; 用 `Start-Sleep` 替代 `timeout`;连续 5 次退出后退避 60s;启动时轮转超过 5MB 的日志 (`server.log` → `server.1.log`)。 同时 `start-server.bat` 也加了同样的前置检查并拒绝重复启动。注意两处的匹配串都必须是 **`192.168.5.2:8020`** 而不是 `:8020` —— Django 开发栈占着 `127.0.0.1:8020`, 只匹配端口号会让 dsh 误判"已在运行"而永远不启动。 ### 8.3 验收要点(真机复测用) `dsh` 固定绑 LAN IP,**本机回环访问不到**(`127.0.0.1:8020` 永远是 Django): | 检查项 | 命令 | 期望 | |---|---|---| | 服务健康 | `curl http://192.168.5.2:8020/healthz` | `{"status":"ok",...}` | | PWA 可达 | `curl -o /dev/null -w "%{http_code}" http://192.168.5.2:8020/app` | `200` | | 连接信息 | `curl -H "Authorization: Bearer " .../v1/connect/info` | `base_urls` + `invite_code` | | 二维码 | `.../v1/connect/qr?host=192.168.5.2` | 2069 字节 `image/svg+xml` | | 日志不再暴走 | 隔 5s 对比 `logs/server.log` 大小 | 增长 ≈0 | | 单一实例 | `netstat -ano \| findstr "192.168.5.2:8020"` | 只有 1 条 LISTENING |