380 lines
18 KiB
Markdown
380 lines
18 KiB
Markdown
# 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 <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. 部署方案
|
||
|
||
```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://<ip>: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"提示卡。
|