Files
dsh/ARCHITECTURE.md
T

380 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"提示卡。