18 KiB
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. 数据模型
-- 用户与凭证
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 <token>。
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. 部署方案
# 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 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://<ip>:8020/app#invite=<码>)一步预填邀请码。
12.5 在 MuMu 模拟器里实测(2026-09-11)
android/emulator.sh 把整套驱动封起来了,MuMu 的两个特性是它存在的理由:
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 缺陷
/apk深链打不开安装页:navigate()无论输入什么路径都强制拼/app。 改为保留调用方给的 path,且只在/app上附#invite=fragment。- App 内点"下载 APK"没反应:WebView 字节拿到了(服务端 200)但没有
DownloadListener,没人把数据写盘。已接系统DownloadManager, 并在/apk页对DshSyncApp/UA 显示"你正在用 dsh App"提示卡。