# DeepSeek Harness 远程同步服务端 — 架构方案 > 目标:为团队私有使用的 DeepSeek Harness 提供 **账号设置、团队配置、插件内容** 的远程同步。 > 规模假设:2–50 人团队,单机部署即可,不追求高可用。 --- ## 0. 落地后的代码结构(2026-09-10 重构) 方案本身不变,`main.py` 曾是 530 行单体,按资源拆成了包: ``` server/dsh_sync/ ├── main.py # 仅组装:配置 + 中间件 + include_router(~55 行) ├── config.py # 环境变量 → Config ├── db.py # SCHEMA + connect/init_db/reset_schema/record_audit ├── auth.py # token 签发、请求鉴权、Ctx ├── deps.py # FastAPI 依赖(get_cfg / get_ctx) ├── storage.py # 制品与 APK 的路径解析(相对 data_dir) ├── ratelimit.py # 滑动窗口限流(单进程内存态) ├── connect.py # 内网地址探测 ├── routers/ │ ├── pages.py # /app /apk /sw.js /manifest /icon + APK 分发 │ ├── connect.py # /v1/connect/{info,qr} │ ├── tokens.py # /v1/tokens* │ ├── users.py # /v1/users /v1/me │ ├── settings.py # /v1/settings* │ ├── plugins.py # /v1/plugins* │ └── audit.py # /v1/audit └── static/ # mobile.html(PWA)、apk.html(安装页)、sw.js、图标 ``` Android 客户端在 `android/`,产物 `data/dist/dsh-sync.apk`,见 §12。 --- ## 1. 可行性评估 **结论:完全可行,建议做,但要做"小"。** | 维度 | 评估 | |---|---| | 技术难度 | 低。本质是「带版本号的 KV 存储 + 带校验和的文件分发」,没有实时协同、没有海量并发 | | 成本 | 一台 1C1G VPS 或一台内网机器 + Docker Compose 即可运行 | | 主要风险 | ① 同步敏感信息(API Key)的安全设计;② 插件与 harness 版本的兼容管理;③ 离线时的客户端行为。三者都有成熟解法,见 §5–§7 | | 不建议的方向 | 不要做成实时双向同步/CRDT(团队场景不存在并发编辑冲突的真实需求),不要上微服务/消息队列 | 一句话定位:**这不是"云盘",而是一个"带权限和版本的配置/插件仓库",客户端以拉取为主、带本地缓存。** --- ## 2. 总体架构 ``` ┌──────────────────────────────────────┐ │ 同步服务端(单机) │ │ │ ┌──────────┐ HTTPS │ ┌─────────┐ ┌──────────────────┐ │ │ 团队成员 │──────────┼─▶│ Caddy │──▶│ 同步 API 服务 │ │ │ 的 dsh │ Token │ │ (TLS) │ │ (FastAPI/Node) │ │ └──────────┘ │ └─────────┘ └───────┬──────────┘ │ │ │ │ │ ┌─────────┴──────────┐ │ │ │ │ │ │ ┌─────▼─────┐ ┌───────▼──────┐ │ │ PostgreSQL │ │ 磁盘目录 │ │ │ 用户/设置/ │ │ 插件制品文件 │ │ │ 插件元数据 │ │ (带 sha256) │ │ └───────────┘ └──────────────┘ └──────────────────────────────────────┘ 客户端分层配置(dsh 本地): 内置默认 < 团队设置(缓存) < 用户设置(服务端) < 本地覆盖(不同步) ``` --- ## 3. 技术选型(及为什么) | 方案 | 评价 | |---|---| | **A. 轻量自研 API(推荐)** | FastAPI + PostgreSQL + 磁盘存储,约 600–1000 行代码。完全掌控数据结构,能精确适配 harness 的设置/插件格式 | | B. Git 仓库当同步源 | 零服务端代码,但权限粒度粗、无法区分"每用户的私有设置"、插件二进制分发体验差。可作为 MVP 的过渡方案,不适合作为终态 | | C. 现成 SaaS(ConfigCat 等)/ Gitea + OCI registry | 引入外部依赖或运维负担,且字段模型不贴合 harness,团队私有场景下反而更复杂 | **推荐栈**:Python FastAPI(或 Node Hono)+ PostgreSQL(psycopg 3)+ 本地磁盘 + Caddy(自动 HTTPS)+ Docker Compose。全流程无外部服务依赖;备份 = `pg_dump` + 打包插件制品目录。 --- ## 4. 数据模型 ```sql -- 用户与凭证 users (id, username, role['admin'|'member'], created_at) tokens (token_hash, user_id, name, created_at, last_used_at, revoked) -- 设置:按 key 分条存储,团队设置只有 admin 可写 settings (scope['team'|'user'], scope_id, key, value_json, version, updated_by, updated_at, PRIMARY KEY(scope, scope_id, key)) -- 插件:不可变版本,发布后不修改(只能删或发新版本) plugins (id, name, description, latest_version, updated_at) plugin_versions (plugin_id, version, channel['stable'|'beta'], sha256, size_bytes, manifest_json, min_harness_version, artifact_path, published_by, published_at) ``` 要点: - **设置按 key 分条**而非整包 JSON,是为了做细粒度的乐观并发控制(见 §6)。 - **插件版本不可变** + sha256,客户端可放心缓存和校验。 - `min_harness_version` 解决"插件比客户端新"的兼容问题。 - 数据库为独立 PostgreSQL 实例(compose 内置 postgres:16 服务),便于备份、升级与后续扩容。 --- ## 5. API 设计 所有请求走 `Authorization: Bearer `。 ``` GET /healthz # 无需认证,部署探活 POST /v1/tokens # 用邀请码/初始密码换长期 token GET /v1/me # 校验 token,返回身份和角色 GET /v1/settings # 合并视图:team ∪ user(user 优先), # 响应带整体 ETag 和各 key 的 version PUT /v1/settings/user/{key} # 写自己的设置 # body: {value, base_version} # 版本不符 → 409 Conflict PUT /v1/settings/team/{key} # 仅 admin GET /v1/plugins?harness_version=x.y # 列出插件及最新版本(按渠道过滤) GET /v1/plugins/{id}/{version} # 插件清单(manifest + sha256 + 大小) GET /v1/plugins/{id}/{version}/download # 制品文件(支持 Range/断点续传) POST /v1/plugins/{id} # 仅 admin,multipart 上传 tar.gz + manifest DELETE /v1/plugins/{id}/{version} # 仅 admin GET /v1/audit?since=... # 变更审计日志(可选) ``` ### 插件制品格式 ``` plugin-{id}-{version}.tar.gz ├── manifest.json # {id, version, min_harness_version, entry, files[], channel} └── files/... # 插件实际内容(skills / commands / hooks 等) ``` 服务端上传时校验 manifest 与目录一致性,计算 sha256 入库。 --- ## 6. 同步协议设计(核心) **模式:拉取为主 + 乐观并发写。** 团队场景写入频率极低,无需实时推送。 ### 客户端启动流程 ``` 1. 读本地缓存(上次同步快照 + 各 key version) 2. GET /v1/settings(带 If-None-Match)→ 304 则直接用缓存 3. 合并到生效配置:defaults < team < user < local_overrides 4. 若开启了自动同步插件:比对已装插件 sha256 → 增量下载 → 校验 → 原子安装 5. 服务器不可达 → 使用缓存并标记 stale(在 UI/日志里提示"配置可能过期") ``` ### 写冲突规则 - 每个 key 有单调递增 `version`。客户端提交 `base_version`,不匹配返回 409。 - 409 时客户端拉取最新值,**默认提示用户**(不静默覆盖)——团队设置尤其如此。 - 团队设置仅 admin 可写,天然把冲突面缩到最小。 ### 插件分发规则 - 版本不可变;客户端按 sha256 决定是否需要下载(本地已有同校验和则跳过)。 - 安装 = 解压到临时目录 → 校验 sha256 与 manifest → 原子替换插件目录(rename)。 - `channel: stable/beta`,客户端配置里选择跟随哪个渠道。 --- ## 7. 安全设计(重点:API Key 怎么办) **强烈建议:不同步明文 API Key。** 推荐分层策略: | 数据 | 策略 | |---|---| | 每人自己的 DeepSeek API Key | **只存本地**(系统 keychain / 环境变量 / 本地配置文件 600 权限),永不入服务端 | | 团队级供应商配置(base_url、可用模型列表、默认参数、代理地址)| 同步,admin 维护 | | 其余偏好设置(主题、权限策略、快捷键等)| 同步,无敏感信息 | 如果团队确实有"统一管理 DeepSeek Key"的诉求,正确做法是走**网关代理**:团队部署一个轻量 LLM 网关(如 one-api / LiteLLM),harness 里只配置网关地址 + 网关发的个人 token,真实 Key 只在网关侧。这比把 Key 放进同步服务端安全得多,还附带用量统计。 其他安全基线: - Token 只存哈希(服务端);Token 可撤销、可命名、记录 last_used。 - 全程 HTTPS(Caddy 自动证书;纯内网部署可自签或 Tailscale 内网直达)。 - 插件上传仅限 admin;下载制品时客户端**必须校验 sha256**(防传输损坏与服务端被篡改的纵深防御)。 - 上传大小上限(如 20MB)+ 解压路径校验(防 zip-slip)。 - 审计日志记录所有 team 设置变更和插件发布。 --- ## 8. 部署方案 ```yaml # docker-compose.yml(示意,实际文件已含 postgres 服务与健康检查) services: postgres: image: postgres:16-alpine volumes: - pg_data:/var/lib/postgresql/data dsh-sync: build: . depends_on: postgres: { condition: service_healthy } environment: - DSH_DATABASE_URL=postgresql://dsh:${POSTGRES_PASSWORD}@postgres:5432/dsh - DSH_BOOTSTRAP_ADMIN_TOKEN=... # 首次启动生成 admin token volumes: - ./data:/data # 插件制品 caddy: image: caddy:2 ports: ["443:443"] ``` - **网络隔离(满足"只为团队使用")**:首选部署在内网/Tailscale 网络内,不暴露公网;若必须公网,Caddy + HTTPS + token 已足够,因为攻击面只有这几个 API。 - **备份**:`pg_dump -U dsh dsh` + 打包 `data/plugins/`,cron 每日一次。 - **资源**:1C/1G VPS 足够支撑百人以下团队(PG 与应用同机)。 --- ## 9. 客户端集成(dsh 侧需要做什么) 新增一个 `sync` 模块,约几百行: ``` 配置层叠:defaults < team(缓存) < user(缓存) < local(本地覆盖,永不上传) dsh sync login # 一次性:输入服务端地址 + 邀请码 → 存 token dsh sync pull # 手动拉取(默认启动时自动做) dsh sync push # 把本地"用户设置"的改动推上去 dsh sync status # 显示同步状态、落后版本、stale 标记 dsh plugin install/update # 走服务端分发,带 sha256 校验 ``` 本地状态目录(如 `~/.dsh/`): ``` ~/.dsh/ ├── config.local.toml # 本地覆盖 + 服务端地址 + token(600 权限) ├── sync-cache.json # 上次同步快照:各 key version + 插件 sha256 清单 └── plugins/ # 原子安装的插件目录 ``` --- ## 10. 实施里程碑 | 阶段 | 内容 | 工作量 | |---|---|---| | M1 | API 骨架 + token 认证 + team/user 设置读写(含 409 冲突) | 0.5–1 天 | | M2 | 插件发布/分发(上传、sha256、清单、下载) | 0.5 天 | | M3 | 客户端 `sync` 模块 + 分层配置合并 + 离线缓存 | 1–1.5 天 | | M4 | Docker Compose 部署 + Caddy + 备份脚本 + 审计日志 | 0.5 天 | | 合计 | 可用的 MVP | **约 2.5–4 天** | M2 之后即可让团队试用"团队设置 + 插件分发"两条最有价值的主线;用户级设置的 push 可以放最后。 --- ## 11. 明确不做的事(防止过度设计) - ❌ 实时推送/WebSocket —— 拉取 + 启动时同步足够 - ❌ CRDT/双向合并 —— key 级 last-writer-wins + 409 提示已覆盖真实场景 - ❌ 多租户、计费、注册开放 —— 团队内部,admin 手工管理成员 - ❌ 同步明文 API Key —— 见 §7,走本地存储或 LLM 网关 - ❌ 会话/对话历史同步 —— 属于另一类数据(大、私有、价值存疑),一期不做 --- ## 12. 安卓客户端与 APK 分发 ### 12.1 为什么是 WebView 壳,而不是原生重写 移动端 UI 只有一份实现:`static/mobile.html`。原生 App 若是重写一套,就会长期存在 "设置页改了、App 没改"的漂移。壳方案下 App 打开即是服务端当前版本,改前端不用重新发版。 壳比手机浏览器多提供三件事,这也是它存在的全部理由: | 能力 | 浏览器 | App | |---|---|---| | 记住服务端地址 | 每次输 IP(或靠书签) | 首启填一次,之后直进 | | 发布插件 tar.gz | 手机端文件选择器不认 tar.gz | `onShowFileChooser` 原生选择器 | | 扫二维码 | 只能跳浏览器 | 认领 `/app#invite=` 深链,直进 App 并预填邀请码 | ### 12.2 构建 ```bash bash android/build.sh ``` 工具链:JDK 17 + Android SDK(build-tools 34 / platform 34)+ Gradle 8.7。 脚本默认从 `~/Desktop/android-tools` 找,可用 `ANDROID_TOOLS` / `GRADLE_BIN` 覆盖。 产物写入 `data/dist/dsh-sync.apk` + 同名 `.json` 元数据(版本、sha256、大小、构建时间)。 签名密钥 `android/app/dsh-release.jks` 首次构建自动生成(口令 `dshsync`)—— 固定密钥的意义只是让后续版本能覆盖安装;这个包走内网分发,不进任何商店。 ### 12.3 分发页面 `GET /apk` 是**免登录**的独立页面(和 `/app` 一样): | 端点 | 说明 | |---|---| | `GET /apk` | 安装页:下载按钮、版本/大小/sha256、安装步骤、下载页二维码 | | `GET /apk/info` | 构建元数据;未构建时返回 `{"available": false, "build_hint": ...}` | | `GET /apk/dsh-sync.apk` | 制品本体,未构建则 404(不是 500) | | `GET /apk/qr?host=` | 指向 `/apk` 的二维码,手机扫码直接到安装页 | 页面在 APK 不存在时不会给死链:按钮变成"服务端尚未构建 APK"并显示构建命令。 ### 12.4 安装路径 `/app` 登录页底部有"🤖 装安卓 App"入口。团队新人的最短路径是: **扫码进 `/apk` → 下载安装 → 首启填服务器地址**,或直接扫设置页的连接二维码 (深链 `http://:8020/app#invite=<码>`)一步预填邀请码。 ### 12.5 在 MuMu 模拟器里实测(2026-09-11) `android/emulator.sh` 把整套驱动封起来了,MuMu 的两个特性是它存在的理由: ```bash bash android/emulator.sh start # 启动 VM、等 adb、清残留代理 bash android/emulator.sh install # 构建 + 安装 + 启动 bash android/emulator.sh deeplink "http://192.168.5.2:8020/app#invite=<码>" bash android/emulator.sh tap X Y # 坐标是 App 的 1080x1920 坐标系 bash android/emulator.sh ui # 直接 dump 页面文字(不需要截图) bash android/emulator.sh status ``` **坑 1:每个 App 有独立的虚拟 display,且 id 每次开机都变。** 一次会话里见过 0 → 3 → 6 → 8 → 11。`adb shell input tap` 不带 `-d` 时默认打到 display 0(桌面),表现是"App 明明在屏幕上,点了没反应"。 `emulator.sh` 每次都从 `dumpsys window` 里按包名解析当前 display。 **坑 2:`screencap` 抓不了 App 的 display**,对 MuMu 的应用 display 直接返回 `Status: -2`。截图只能走宿主窗口(CUA)或 `screencap -a`,因此 `ui` 子命令用 uiautomator dump 读文本,比截图可靠。 **坑 3(最容易误判成 App bug):模拟器里可能残留 HTTP 代理。** 本次实测时系统里配着 `http_proxy=127.0.0.1:8080` 而该端口无人监听 → WebView 全部 请求死在代理上,页面全黑。**但 `ping` 和 `nc` 是通的**(它们不走系统代理), 所以网络看起来完全正常。`start` 子命令会主动清掉这三个设置项。 模拟器实测通过的链路: | 步骤 | 结果 | |---|---| | 安装 + 冷启动 | 无崩溃,WebView 110.0.5481.154 | | 深链 `#invite=` | 地址/模式/邀请码三项自动预填 | | 邀请码注册(member) | `POST /v1/tokens` → 主界面,无管理 Tab、无扫码卡片 | | Token 登录(admin) | `admin · admin`,「📱 连接手机」卡片与🛡管理 Tab 出现 | | 扫码卡片展开 | `GET /v1/connect/{info,qr}` 200,二维码真实渲染(1352px 高) | | 设置/插件/我的 Tab | 插件列表含 sha256、下载链接;Token 列表可撤销 | | `/apk` 页下载 | 文件落盘 `/sdcard/Download/dsh-sync.apk`,sha256 与服务端一致 | ### 12.6 已知边界 - 明文 HTTP:`usesCleartextTraffic="true"`,因为内网部署就是 HTTP。若对外暴露必须上 HTTPS。 - 自签名包:Android 会拦一次"未知来源",安装步骤里写明了怎么放行。 - 深链匹配:intent-filter 只能匹配 scheme/host/path(Android 不匹配 fragment), 所以 `#invite=` 由 `MainActivity` 从原始 intent data 里读,不靠 pathPattern; 另外 Android 只把**带 host 的** http URL 交给浏览器候选,无 host 的深链必须 用显式 component 启动(`emulator.sh deeplink` 就是这么做的)。 - 未做:推送、离线缓存、原生手势返回(返回键已接 WebView history)。 ### 12.7 实测中发现并修掉的两个 App 缺陷 1. **`/apk` 深链打不开安装页**:`navigate()` 无论输入什么路径都强制拼 `/app`。 改为保留调用方给的 path,且只在 `/app` 上附 `#invite=` fragment。 2. **App 内点"下载 APK"没反应**:WebView 字节拿到了(服务端 200)但没有 `DownloadListener`,没人把数据写盘。已接系统 `DownloadManager`, 并在 `/apk` 页对 `DshSyncApp/` UA 显示"你正在用 dsh App"提示卡。