Files
dsh/ARCHITECTURE.md

18 KiB
Raw Permalink Blame History

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 缺陷

  1. /apk 深链打不开安装页:navigate() 无论输入什么路径都强制拼 /app。 改为保留调用方给的 path,且只在 /app 上附 #invite= fragment。
  2. App 内点"下载 APK"没反应:WebView 字节拿到了(服务端 200)但没有 DownloadListener,没人把数据写盘。已接系统 DownloadManager, 并在 /apk 页对 DshSyncApp/ UA 显示"你正在用 dsh App"提示卡。