本文档定义仓库结构、各端职责边界、共享代码规则与安全约束。所有客户端都遵循本文档;与本文档冲突的实现视为架构违规。

仓库结构#

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。

安全约束#

加入桌面/移动端时,以下约束不得放松:

  1. CORS 保持显式 allowlist。 middleware/cors.go 用请求 Origin 匹配 cors_allow_origin,并设置 Access-Control-Allow-Credentials: true。Tauri webview 的 origin tauri://localhost、http://tauri.localhost 在默认值中;本地模式把列表收窄到 webview。生产环境拒绝 *。
  2. Access token 只存内存。 各端都用 Authorization: Bearer 携带内存中的 access token,任何端都不得把它写入持久存储。
  3. 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。
  4. 续期与登出的顺序只有一份。 createAuthClient(host) 实现并发续期去重、会话 revision 守卫,以及 401 → 续期 → 重试一次 → 终态错误时清理。各端只注入存储、网络和可选的跨上下文锁。
  5. 桌面壳不向页面暴露 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),因为服务器由用户选择。
  6. 刷新令牌重用检测。 轮换后旧 token 的哈希保留 15 秒有效期(refreshTokenPreviousHashGrace),用于容忍丢失的轮换响应。超出该窗口再次使用旧 token 会吊销整个会话(revoke_reason = refresh_token_reuse),并记录 refresh_token_reuse_detected 鉴权事件。吊销在事务内提交、事务外报告,避免被回滚。
  7. 密钥与证书只进 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 阶段就会暴露,而不必等到发布。

相关#