本文档定义仓库结构、各端职责边界、共享代码规则与安全约束。所有客户端都遵循本文档;与本文档冲突的实现视为架构违规。
仓库结构#
DEEIX Chat 是 pnpm + Turborepo 单仓库:一个后端,每个客户端平台一个目录。
text
依赖方向只允许向下:
text
apps/*之间不得互相 import。packages/core不得 import 任何apps/*。packages/api-contract只包含生成产物,不得手写逻辑。
各端职责边界#
每个端有固定范围。目标是每条规则只有一份实现,而不是让每个平台功能最大化。逐功能支持情况见平台矩阵。
| 端 | 定位 | 功能范围 | 不做 |
|---|---|---|---|
Web(apps/web) | 完整产品 | 全部功能,包括 Admin | — |
桌面(apps/desktop) | Web 的安装版。可本地独立运行或连接服务器,一个标签页一个服务器 | Web 全部功能,外加托盘、自动更新、系统钥匙串、OAuth 回环、内置本地服务器和每服务器一个 webview 的标签页 | 不写任何业务 UI 或业务逻辑。Tauri 工程只包含 Rust 壳、窗口、托盘、更新器、sidecar 进程管理与会话命令。 |
| 移动(规划中) | 消费端 | 聊天、会话、文件上传、相机/语音、推送通知、账户设置 | Admin、账单后台、知识库管理、MCP/Skill 配置 |
出现以下两种情况,说明抽象放错了层:
- 要在桌面工程里写聊天逻辑。Web 端缺了一个抽象。
- 移动端和 Web 端各写一份登出规则。
packages/core缺了一个模块。
本地模式#
桌面端把 Go 后端打进安装包,无需部署即可运行。设计约束:
- 同一份后端代码、同一套安全策略。 本地模式就是 SQLite 方案加按安装实例生成的密钥(权限
0600),以及完整的生产校验。后端只新增--local、--data-dir两个标志和一个仅在本地模式挂载的 grant 兑换端点(POST /api/v1/auth/local/exchange),不存在任何“本地就放宽”的分支。 - 只监听回环、端口随机。 父进程从 sidecar stdout 上的一行 JSON 拿到 origin 与一次性登录 grant,stdout 之后不再写任何东西(日志走 stderr)。
- grant 单次、两分钟、只在 Rust 手里。 壳用它换会话并把 refresh token 存入钥匙串,webview 从头到尾看不到 grant。要拿新 grant 只能重启 sidecar,因此 grant 不可能通过网络签发。
- 本地用户无密码。
PasswordEnabled=false。密码登录不可用,也不会触发首次登录引导。 - 切换服务器即登出。 每个服务器一条钥匙串记录。离开或关闭服务器时删除该记录,token 不会被重放到另一家运营方。
多标签页#
一个窗口、一条 40px 的标签栏 webview、每个标签页一个内容 webview。隔离单位是 webview,而不是前端状态:每个标签页都是完整的 Web 应用实例,有自己的 DOM、缓存、SSE 连接和内存会话。前端不需要知道“同时有多个服务器”。约束:
- 一个标签页最多绑定一个服务器。两个标签页不会指向同一个服务器;再次打开已存在的服务器时切到该标签页。
- 会话命令按调用方 webview 的 label 找服务器,标签页只能碰自己的凭据。桌面端禁用
BroadcastChannel,避免 token 从一个服务器同步到另一个。 - 关闭标签页等于忘记该服务器:删除 refresh token;最后一个本地标签页关闭时停 sidecar。
- 标签栏页面(
/desktop/tabs)是壳 UI,走同一套设计系统,但只拿到tabs_*命令;内容标签页拿不到。
共享代码规则#
packages/core#
- 零平台依赖。
tsconfig.json设置lib: ["ES2022"],不含 DOM 与环境类型;biome.jsonc通过noRestrictedImports禁止react、react-dom、react-native、next、expo、@tauri-apps/*。违规会导致pnpm check失败。 - 不做 I/O。 不直接调用
fetch、localStorage、SecureStore、Tauri command。所有 I/O 由宿主应用通过接口注入。 - 可在 Node 里单测。
pnpm --filter @deeix/core test使用 Node 内置 test runner,不需要浏览器或设备。 - 按需增长。 只在第二个客户端真的需要时才把逻辑从
apps/web抽进来,不预建空目录。
UI 不共享,逻辑必须共享#
- Web 用 React DOM,移动端用 React Native,两者 UI 层完全独立。
- 鉴权、token 续期、服务器发现、SSE 解析、消息 reducer 只允许存在于
packages/core。
客户端与服务端的契约#
所有客户端都连接用户自己部署的服务器,因此:
- API 地址是运行时配置。 优先级为运行时覆盖 → 构建期变量 → 页面 origin,由
packages/core的resolveApiBaseUrl实现。Web 端通过registerRuntimeApiBaseURLResolver从平台层读取用户选择的服务器;桌面壳按标签页持久化服务器,并通过get_server命令返回。 - 凭据投递方式由后端按请求头决定。 请求携带
X-Client-Platform: desktop|mobile时,后端把 refresh token 放进响应体;不带该头时使用 HttpOnly cookie。两条路径共用同一套轮换与吊销逻辑。 - 后端不为单一客户端开特例。 桌面与移动端复用同一条路径:服务器发现 → 登录 → refresh → 会话。
- 后端报告自身构建。
/api/v1/version返回产品名、版本、commit、构建时间与构建 ID,客户端用它展示正在运行的构建。OAuth 交接使用 provider auth bridge 自带的protocolVersion。
安全约束#
加入桌面/移动端时,以下约束不得放松:
- CORS 保持显式 allowlist。
middleware/cors.go用请求 Origin 匹配cors_allow_origin,并设置Access-Control-Allow-Credentials: true。Tauri webview 的 origintauri://localhost、http://tauri.localhost在默认值中;本地模式把列表收窄到 webview。生产环境拒绝*。 - Access token 只存内存。 各端都用
Authorization: Bearer携带内存中的 access token,任何端都不得把它写入持久存储。 - Refresh token 的存放按端区分,读取路径互不回退。
- Web:HttpOnly cookie,生命周期由服务端管理。
- 桌面:操作系统钥匙串。Service 是 bundle identifier(
com.deeix.chat.desktop,开发环境为com.deeix.chat.desktop.dev),Account 是refresh-token:<origin>,本地模式为refresh-token:local。JS 没有读取入口:store_session只写一次;refresh_session在 Rust 侧读取 token,对固定 origin 发起续期,轮换存储的 token,并且只返回{accessToken, sessionID}。页面被 XSS 也拿不到长期凭据。 - 移动(规划中):
SecureStore/ Keystore。 X-Client-Platform声明为原生客户端时,后端只从请求体读 refresh token,且只经响应体下发;浏览器路径只认 cookie。不做交叉回退。- 轮换写回由持有 token 的一方完成(浏览器:服务端
Set-Cookie;桌面:Rust 在refresh_session内部)。AuthHost.refreshSession返回{accessToken, sessionID};refresh token 不经过packages/core。
- 续期与登出的顺序只有一份。
createAuthClient(host)实现并发续期去重、会话 revision 守卫,以及 401 → 续期 → 重试一次 → 终态错误时清理。各端只注入存储、网络和可选的跨上下文锁。 - 桌面壳不向页面暴露 shell、文件系统或 HTTP 插件。
capabilities/main.json授予core:default、窗口聚焦、updater 与 process 插件、在系统浏览器中打开 http(s) URL,以及会话与 OAuth 回环命令。capabilities/chrome.json只给标签栏tabs_*命令和窗口拖动。CSP 把script-src限定为'self',并禁止 object 与 frame;connect-src允许 http(s),因为服务器由用户选择。 - 刷新令牌重用检测。 轮换后旧 token 的哈希保留 15 秒有效期(
refreshTokenPreviousHashGrace),用于容忍丢失的轮换响应。超出该窗口再次使用旧 token 会吊销整个会话(revoke_reason = refresh_token_reuse),并记录refresh_token_reuse_detected鉴权事件。吊销在事务内提交、事务外报告,避免被回滚。 - 密钥与证书只进 CI secrets。 Apple Developer ID、notarization、Windows 代码签名证书、updater 密钥与 Android keystore 都不入库。
版本与发布#
- 根目录
VERSION是唯一版本号来源。scripts/sync-version.mjs列出所有目标:根与各包清单、apps/web、apps/desktop(package.json、tauri.conf.json、Cargo.toml),以及backend/package.json和 Swagger 的@version注解。新增客户端只需加一个目标。 node scripts/sync-version.mjs --check在predev/prebuild与 CI 中执行,版本不同步即失败。- 一个 tag(
v*.*.*)构建全部产物:GHCR 与 Docker Hub 的 amd64 / arm64 镜像,以及桌面端四个目标的安装包。桌面工作流创建 draft GitHub Release,发布该 Release 才算真正上线更新。 - 桌面通道:
vX.Y.Z是 stable,vX.Y.Z-beta.N是 beta,两者共用同一把签名 updater 密钥。发布时desktop-channel.yml刷新滚动的desktop-beta清单。移动端 EAS Build 尚未接入。 - Docker 镜像从
/app/frontend/out(FRONTEND_DIST_DIR)提供静态产物,仓库内构建路径是apps/web/out。
CI 门禁#
text
turbo.json 按包声明依赖,新增 apps/* 或 packages/* 会自动纳入 pnpm check / pnpm test。apps/desktop 的 check 脚本会运行 cargo check,因此 Rust 编译错误在 PR 阶段就会暴露,而不必等到发布。