Skip to content

Latest commit

 

History

History
166 lines (121 loc) · 18.2 KB

File metadata and controls

166 lines (121 loc) · 18.2 KB

多端架构

本文档定义 DEEIX Chat 的仓库结构、各端职责边界、共享代码规则与安全约束。新增客户端(桌面、移动)必须遵循本文档;与本文档冲突的实现视为架构违规。

1. 仓库结构

DEEIX-Chat/
├── backend/                  Go 服务,唯一的业务真相(鉴权、授权、模型路由、文件、计费、审计)
├── apps/                     客户端应用,每个平台一个目录
│   ├── web/                  Next.js 静态导出(现有)
│   ├── desktop/              ✅ Tauri 2 壳,加载 apps/web 的构建产物(Rust + keychain + 托盘 + 更新)
│   └── mobile/               Expo / React Native(规划中)
├── packages/
│   ├── api-contract/         后端 Swagger → TypeScript 类型,所有客户端的唯一 API 来源
│   └── core/                 平台无关的客户端逻辑:鉴权状态机、服务器地址解析、能力声明解析
├── deploy/                   Docker 部署:compose 方案、配置模板、可选服务;可整体拷到服务器
├── scripts/sync-version.mjs  根目录 VERSION 驱动所有包与 Go 版本号
├── docs/
├── Dockerfile                应用镜像(唯一留在根目录的部署文件,构建上下文是仓库根)
└── .github/workflows/

依赖方向只允许向下:

apps/*  →  packages/core  →  packages/api-contract  →  backend/docs/swagger.json
  • apps/* 之间不得互相 import。
  • packages/core 不得 import 任何 apps/*。
  • packages/api-contract 只包含生成产物,不得手写逻辑。

2. 各端职责边界

逐功能支持情况见 平台矩阵。

端 定位 功能范围 不做什么
Web (apps/web) 完整产品 全部功能,包括 Admin —
桌面 (apps/desktop) Web 的安装版,可本地独立运行或连接服务器,多标签页同时连多个服务器 Web 全部功能 + 托盘、自动更新、系统 keychain、OAuth 回环、内置本地服务器(sidecar)、每服务器一个 webview 的标签页 不写任何业务 UI 或业务逻辑。Tauri 工程只包含 Rust 壳、窗口、托盘、更新器、sidecar 进程管理与会话命令
移动 (apps/mobile) 消费端 聊天、会话、文件上传、相机/语音、推送通知、账户设置 不做 Admin、账单后台、知识库管理、MCP/Skill 配置

原则:如果发现要在桌面工程里写聊天逻辑,说明 Web 端缺了一个抽象;如果发现移动端和 Web 端各写了一份"什么时候该登出",说明 packages/core 缺了一个模块。

本地模式(桌面端内置服务器)

桌面端把 Go 后端作为 sidecar 打进安装包,用户可以不部署任何东西直接使用。设计约束:

  • 同一份后端代码、同一套安全策略。 本地模式 = SQLite 部署方案 + 按安装实例生成的密钥(0600)+ Env=prod 的全部生产校验。后端只新增 --local --data-dir 两个标志和一个仅在本地模式挂载的 grant 兑换端点,不存在任何"本地就放宽"的分支。
  • 只监听回环、端口随机。 父进程通过 stdout 上的一行握手 JSON 拿到端口与一次性登录 grant;stdout 之后不再写任何东西(日志走 stderr)。
  • grant 单次、两分钟、只在 Rust 手里。 桌面壳用它换会话并把 refresh token 存入 keychain,webview 从头到尾看不到 grant。要拿新 grant 只能重启 sidecar 进程——这是刻意的,grant 不可通过网络签发。
  • 本地用户无密码。 PasswordEnabled=false,密码登录对它天然不可用;也不会触发首次登录引导。
  • 模式切换即登出。 本地与远程各自一个 keychain 条目,切换时删除另一方,token 不会被重放到另一家运营方。
  • 功能边界由服务器声明,客户端不判断平台。 本地模式没有对象的功能(账号管理、权限组、公告、身份提供商、账户安全、计费门禁、内容审核)由 GET /api/v1/capabilities 声明为关闭,对应端点返回 404 feature.disabled,前端据此隐藏入口;用量计量保留。桌面端连远程服务器时能力来自远程,与 Web 端完全一致。详见第 4 节「服务器能力声明」。

多标签页(桌面端)

一个窗口、一条 40px 的标签栏 webview、每个标签页一个内容 webview。隔离单位是 webview 而不是前端状态:每个标签页都是一份完整的 Web 应用实例,有自己的 DOM、缓存、流式连接和内存里的会话,前端代码不需要知道"同时有多个服务器"这件事。约束:

  • 一个标签页最多绑定一个服务器;两个标签页不会指向同一个服务器(再开一次 = 切到已有的那页)。
  • 会话命令按调用方 webview 的 label 找服务器,标签页只能碰自己的凭据。同源 BroadcastChannel 在桌面端禁用,避免一个服务器的 token 同步到另一个。
  • 关闭标签页 = 忘记该服务器:删 refresh token;最后一个本地标签页关闭时停 sidecar。
  • 标签栏页面 /desktop/tabs 是壳 UI,走同一套设计系统,但只拿到 tabs_* 命令;内容标签页拿不到。

3. 共享代码规则

packages/core

  • 零平台依赖。 tsconfig.json 不含 DOM lib,biome.jsonc 通过 noRestrictedImports 禁止 react、next、react-native、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 续期、服务器发现、流式响应(NDJSON)解析、消息 reducer 这类逻辑一旦有第二个客户端需要,只允许存在于 packages/core。当前鉴权状态机、服务器地址与能力解析已在 core;流解析与消息 reducer 仍在 apps/web,移动端立项时抽取。

4. 客户端与服务端的契约

所有客户端都是"连接用户自己部署的服务器",因此:

  • API 地址是运行时配置,不是构建期常量。 优先级为"运行时覆盖 → 构建期变量 → 页面 origin",由 packages/core 的 resolveApiBaseUrl 统一实现。桌面端在首次启动时让用户填写服务器地址,由 Rust 侧持久化;apps/web/shared/platform/desktop-session.ts 通过 registerRuntimeApiBaseURLResolver(readServerOrigin) 把它注入 API 客户端,Web 侧不在 localStorage 保存服务器地址。
  • 凭据投递方式由后端按请求头决定,不由客户端的构建标志决定。 客户端发送 X-Client-Platform: desktop|mobile 时,后端把 refresh token 放进响应体(客户端存入 keychain / SecureStore);不带该头时使用 HttpOnly cookie。两条路径共用同一套轮换与吊销逻辑。
  • 协议版本协商。 后端暴露 serverVersion 与客户端协议版本;客户端启动时协商,不兼容则明确提示,而不是让功能随机失效。
  • 后端不为单一客户端开特例。 桌面与移动端复用同一套:服务器发现 → 登录 → refresh 续期 → 深链接回调。
  • 功能显隐来自服务器声明。 部署形态差异来自能力声明:客户端启动时读取 GET /api/v1/capabilities(失败则视为全部可用),组件通过 useFeature / <FeatureGate>、页面通过 useSectionGuard 决定显示什么;管理员运行时开关(如知识库)通过 useFeaturePolicy 读取。isDesktopApp() 只允许用于调用原生能力,不允许用于功能显隐(apps/web/scripts/check-architecture.mjs 对管理页、设置页和引用能力模块的文件做检查)。

服务器能力声明(capabilities)

同一份前端要同时服务 Web、桌面连远程、桌面本地三种形态。差异不由客户端按平台判断,而由服务器声明"我提供什么",客户端据此渲染,后端据此拒绝。

端点 GET /api/v1/capabilities,公开、Cache-Control: public, max-age=300,响应 data.features 是扁平布尔对象:

键 含义 本地模式 关闭时受影响的路由
multiUser 多个账号:账号管理、权限组 ❌ /admin/users*、/admin/user-auth-events、/admin/permission-groups*、/admin/models/:id/permission-groups
registration 自行注册 ❌ /auth/register/*
identityProviders 第三方登录与身份绑定 ❌ /auth/providers*(公开与管理)、/me/identities*
accountSecurity 密码、邮箱、两步验证、会话、注销 ❌ /auth/password/*、/auth/2fa/*、/me/2fa*、/me/email/*、/me/delete/*、DELETE /me、/auth/sessions*、/auth/logout-all
announcements 系统公告 ❌ /admin/announcements*、/announcements/:id/*;例外:GET /announcements 返回空列表(老客户端轮询它)
billingGating 计费规则可以拒绝请求 ❌ /billing/plans*、/billing/redemption*、/billing/payments/*、/billing/subscriptions、/admin/billing/accounts/*/balance、/admin/payment-orders、/admin/redemptions;billing.mode 与 billing.payment_providers 两个设置项锁定
usageMetering 记录用量与费用 ✅ ——
contentModeration 内容审核 ❌ /admin/content-moderation*
sharing 对话公开分享 ❌ /conversations/:id/share*、/conversations/shares/revoke、/shared-conversations/*

服务器模式全部为 true;"当前是否配置了 X"由各功能自己的端点回答,不进能力位。

推导只有一处:config.Config.Capabilities()。任何"本地模式下是否 X"的判断只允许读能力位,不允许直接读 cfg.LocalMode。

后端执法:middleware.FeatureGate.Require(<键>) 挂在路由组上(不逐条挂),能力关闭时整组返回 404 feature.disabled,details.feature 为键名;设置项被锁定时另带 details.keys。用 404 而不是 403:语义是"这台服务器没有这个功能",对任何身份都一样。Require 对契约外的键名在注册时 panic。

前端消费:packages/core 的 resolveCapabilities 容错解析(缺失或非布尔的键一律视为 true);shared/capabilities 的 CapabilitiesProvider 按服务器地址缓存一次,收到 feature.disabled 自动重拉;组件只通过 useFeature / <FeatureGate> 读取;侧栏与路由守卫共用同一张 section 表(isSectionAvailable / useSectionGuard),被隐藏的页面 URL 直达时重定向。

演进规则:只增不删;新键的默认值必须是"与今天行为一致"的值(通常 true);不表达"为什么"(不加 mode、edition);不表达"多少"(配额有自己的端点)。

计量与门禁分离:本地用户要看自己的花费,但不需要被计费规则拦住。billing.mode = self 下 AuthorizeUsage 直接放行、RecordUsage 照常记账,本地模式锁定该值即得"记账但不拦";价格表保留可编辑。数据模型不变:本地 owner 仍在默认权限组,本地库可被远程服务器直接接管。

5. 安全约束

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

  1. CORS 保持显式 allowlist。 后端 middleware/cors.go 是 Origin 白名单 + Access-Control-Allow-Credentials: true。桌面 webview 的 origin(tauri://localhost、http://tauri.localhost)由中间件内置放行,部署方无需配置;其余来源必须显式加入白名单,禁止改为 *。
  2. Access token 只存内存。 现有 Web 端以 Authorization: Bearer 携带内存中的 access token,这一模型保持不变,任何端都不得把 access token 写入持久存储。
  3. Refresh token 的存放按端区分,读取路径互不回退。
    • Web:HttpOnly cookie(现状),服务端管理生命周期。
    • 桌面:操作系统 keychain(Service com.deeix.chat.desktop,Account refresh-token:<origin>)。JS 没有读取入口:登录时经 store_session 一次性交给 Rust,续期由 Rust 侧 refresh_session 对固定 origin 发起并只返回 access token。这使桌面端与浏览器 HttpOnly cookie 等价——页面被 XSS 也拿不到长期凭据。服务器地址同样由 Rust 持久化,切换服务器即丢弃旧会话,token 不会被重放到另一家运营方。
    • 移动:SecureStore / Keystore。
    • X-Client-Platform 声明为原生客户端时,后端只从请求体读 refresh token,且只经响应体下发;浏览器路径只认 cookie。刻意不做交叉回退,避免两种投递方式混用导致凭据状态不一致。
    • 轮换写回由持有 token 的一方完成(浏览器:服务端 Set-Cookie;桌面:Rust refresh_session 内部)。packages/core 的 AuthHost.refreshSession 只拿到 {accessToken, sessionID}。
  4. 续期与登出的顺序只有一份。 401 判定、并发续期去重、续期失败后的清理顺序都在 packages/core 的状态机里实现,各端只注入存储与网络。
  5. 深链接必须校验。 自定义 scheme 回调携带的 state/nonce 必须与发起时匹配,防止回调劫持。
  6. 桌面端不暴露 Node/系统能力给页面。 capabilities/main.json 只授予 core:default、窗口聚焦、deep-link 与 updater;不开放 shell、fs、http 插件。CSP 的 connect-src 限定为 self、IPC 与本地开发端口,不允许页面被注入的脚本外连任意主机。
  7. 刷新令牌重用检测。 轮换后旧 token 保留 15s 宽限期(refreshTokenPreviousHashGrace),用于容忍丢失的轮换响应。宽限期外再次出现已轮换的 token 视为泄露,整个会话立即吊销(OAuth 2.1 §4.3.1),revoke_reason = refresh_token_reuse,并记录 refresh_token_reuse_detected 审计事件。这保证攻击者即使在宽限期内截获并使用了旧 token,也无法在受害者下一次刷新后继续持有会话。仓储层的吊销在事务内提交、事务外报告,避免被回滚。
  8. 密钥与证书只进 CI secrets。 Apple Developer ID、notarization、Windows 代码签名证书、Android keystore 不入库。
  9. 第三方登录只有一条流程:服务器回调。 登录、注册、账号身份绑定在所有端都走授权桥:客户端 POST /authorize(绑定为 /me/identities/providers/:slug/authorize,需登录态)取得身份源授权 URL → 身份源回调 <PUBLIC_API_BASE_URL>/api/v1/auth/providers/:slug/callback → 服务器用自己的 PKCE 换取身份源令牌并拉取资料 → 把一次性 grant 重定向回客户端 → 客户端用自己的 PKCE verifier POST /exchange。身份源的 code 与 client secret 不经过浏览器;绑定的 grant 记录发起用户,只能由同一用户兑换。没有“前端回调”降级路径:PUBLIC_API_BASE_URL 未配置时第三方登录整体不可用,管理后台给出提示。

6. 版本与发布

  • 根目录 VERSION 是唯一版本号来源。scripts/sync-version.mjs 的 targets 表列出所有需要同步的文件;新增客户端只需在表里加一项(如 tauri.conf.json、app.json)。
  • node scripts/sync-version.mjs --check 在 predev / prebuild 与 CI 中执行,版本不同步即失败。
  • 一次 tag 触发全部构建:Web → Docker 镜像(现有);桌面 → 三平台矩阵 + 签名 + updater manifest;移动 → EAS Build。
  • Windows 桌面端同一套签名二进制产出三种形态:按用户安装的 NSIS setup(自动更新)、按机器安装的 MSI(企业部署,应用内不更新)、便携版 zip(数据随程序目录,只提示新版本)。形态由 Rust 在启动时判定(portable 标记 → bundle 类型),前端只读 get_distribution。企业策略(默认服务器、锁定服务器、禁用本地模式、禁用更新)读注册表并由 Rust 强制执行;详见 apps/desktop/README.md 与 deploy/windows/README.md。每个发布附带 SHA256SUMS 与 GitHub 构建来源证明。
  • Docker 镜像内静态产物路径固定为 /app/frontend/out,与 FRONTEND_DIST_DIR 及现有部署配置兼容;仓库内构建路径为 apps/web/out。

7. CI 门禁

PR:   pnpm check(所有包 lint + typecheck + 桌面 cargo check)
      pnpm test(所有包)
      pnpm api:check(后端 Swagger 变了必须重新生成)
Tag:  上面 + Docker 镜像 + 桌面四目标构建(macOS arm64/x64、Linux、Windows)

turbo.json 已按包声明依赖,新增 apps/* 或 packages/* 会自动纳入 pnpm check / pnpm test。apps/desktop 的 check 脚本会运行 cargo check,因此 PR 阶段即可发现 Rust 侧编译错误,而不必等到发布。

8. 落地顺序

每步独立可交付:

  1. ✅ 目录重构:frontend/ → apps/web/,建立 packages/core 骨架与隔离守卫。
  2. ✅ 鉴权状态机进 core:createAuthClient(host) 承载并发去重、revision 守卫、401→续期→重试→终态清理;Web 端只剩 apps/web/shared/auth/auth-client.ts 这一个 host 适配文件。API 地址改为 resolveApiBaseUrl 三级解析(运行时覆盖 → 构建期变量 → 页面 origin),registerRuntimeApiBaseURLResolver() 是桌面端的接入点。
  3. ✅ 桌面端落地:apps/desktop(Tauri 2)加载 apps/web/out;后端按 X-Client-Platform 经响应体投递 refresh token;apps/web 的平台层负责服务器地址持久化、keychain 适配与首次启动的服务器设置页;AuthHost.onSessionRefreshed 统一处理轮换写回;deploy/ 与后端默认值已加入 Tauri webview Origin。
  4. ✅ CI:PR 阶段 cargo check,tag 触发桌面四目标构建 + 签名 + updater manifest(.github/workflows/desktop-release.yml)。
  5. 移动端立项时再做第二次抽取与 apps/mobile;X-Client-Platform: mobile 与 packages/core 已为它预留。