CafeDaily
04 — SYSTEM ARCHITECTURE

系统架构

CafeDaily 现在是一台服务端权威的冲煮仪器:PostgreSQL 是唯一事实来源,所有读写都经 API 落回数据库。前端退化为瘦客户端——Zustand 只持有界面与会话态,配上请求级缓存与鉴权 token,不再把 IndexedDB 当权威存储。下图自上而下呈现真实拓扑:多端客户端穿过 nginx 网关,进入 Docker 网络内的 Express API 与 PostgreSQL,数据双向流经同一条主线。

数据模型Server-authoritative
事实来源PostgreSQL 16
部署nginx + Docker Compose @ ten
离线依赖网络 · 缓存降级
01 · 客户端 CLIENTS 四端共享同一份 Next.js 16 静态导出(output: 'export')· React 19 · TypeScript 02 · 瘦客户端态 THIN CLIENT · 非权威 03 · 边缘 / 网关 EDGE 系统 nginx · @ ten (49.234.61.24) 静态站点 /var/www/html · /api/ → 反向代理 · TLS: Let's Encrypt / certbot 04 · 服务 SERVICE · docker net: cafedaily-net Express 4 API · :3001 helmet · cors · JWT(HS256) · bcrypt(12) authenticate 保护 · 按 user_id 多租户隔离 · 分页 page/limit≤100 PostgreSQL 16 · alpine UUID 主键 · TIMESTAMPTZ · 软删除 deleted_at JSONB params + GIN · updated_at 触发器 127.0.0.1 读写请求 · Bearer JWT JSON 响应 /api → :3001 SQL 双向读写

分层详解

下面按层给出上图的可读等价内容。核心原则:PostgreSQL 是唯一事实来源,前端不再持有权威数据,所有读写都经 Express API 落回数据库。每一层标注真实技术栈,与 TRD 中的需求一一对应。

01

客户端层 · Clients

Web/PWA、iOS、Android、桌面四种运行时共用同一份 Next.js 静态导出:一次构建,四处运行。原生壳仅提供文件系统、分享、通知等桥接能力,业务逻辑与 UI 完全一致。数据不再随壳打包,一律向服务端请求。

Next.js 16React 19TypeScriptTurbopackCapacitorTaurinext-themes
02

瘦客户端态 · Thin Client(非权威)

前端只持有派生态,不再是数据源。Zustand 保管界面与会话态(选中项、弹层、主题、当前用户);请求级缓存(SWR 式/内存缓存)缓存最近的 API 响应并支撑乐观更新;鉴权 token 存 localStorage,按 Authorization: Bearer 附带。IndexedDB 不再作为权威存储,原 dbUtils 水合链路被移除或降级为只读缓存。

ZustandSWR / 请求缓存乐观更新JWT (localStorage)no local-authoritative
03

边缘 / 网关层 · Edge

服务器 ten 上的系统级 nginx 从 /var/www/html 提供生产站点,并把 /api/ 反向代理到 Docker 内的 cafedaily-api:3001。TLS 证书由 Let's Encrypt / certbot 签发(webroot /var/www/certbot)并自动续期;CORS 限定 https://cafedaily.top。它同时承载去程请求与回程 JSON 响应。

nginxLet's Encryptcertbotreverse proxyTLS
04

服务层 · Service

Express 4(CommonJS)监听 :3001,挂载在 /api/*,是前端与数据库之间唯一的读写通道。helmet 设安全头,cors 校验来源,JWT(HS256,7 天)承载 sub/email/username/role,bcrypt(SALT_ROUNDS=12)存密码。资源路由用 authenticate 保护,按 user_id 做多租户隔离,分页 page/limit(≤100)/offset

Express 4helmetcorsjsonwebtokenbcryptjspgNode.js 20
05

数据层 · Data(唯一事实来源)

PostgreSQL 16(postgres:16-alpine)是全系统的 single source of truth——每一次读都命中它,每一次写都落到它。启用 pgcrypto,UUID 主键 gen_random_uuid(),时间用 TIMESTAMPTZ,updated_at 触发器自动更新,coffee_beans / brewing_notes 用 deleted_at 软删除。索引齐全:email、user_id 部分索引(WHERE deleted_at IS NULL)、brew_date、params 的 GIN 索引。图片走 ./backend/uploads 挂载卷,仅监听 127.0.0.1。

PostgreSQL 16pgcryptoUUIDJSONB + GINDocker Composehealthcheck

同步层 · Sync(已弃用移除)

历史上通过 Supabase(含 realtime,约 23 文件)做本地优先的双向同步。在服务端权威模型下,同步层不再需要:PostgreSQL 已是唯一事实来源,前端直接读写它。Supabase 客户端、UnifiedSyncService / BaseSyncManager 等同步编排随迁移一并移除。此节点在架构图中以灰色虚线保留,仅作迁移注记。

Supabase (removed)UnifiedSyncService (removed)local-first (removed)

请求生命周期

在服务端权威模型下,没有本地兜底:登录与取豆列表都必须命中服务端才有数据。弱网时靠请求缓存呈现旧值、靠重试收敛,而不是靠本地库离线读写。

登录 + 取豆列表:两次都必须命中服务端

  1. 用户在任一客户端提交邮箱与密码,前端向 POST /api/auth/login 发起请求。请求先到达 ten 上的 nginx——离线时此步直接失败,前端弹出断网提示并允许重试,不存在本地登录。
  2. nginx 命中 /api/ 前缀,经 TLS 终止后把请求反向代理到 Docker 网络 cafedaily-net 内的 cafedaily-api:3001
  3. Express 依次过 helmet 与 cors 中间件;auth 路由用 bcrypt 校验密码哈希,签发 HS256 JWT(payload sub/email/username/role,有效期 7d)。
  4. 前端把 token 存入 localStorage(key cafedaily_token),后续请求由 apiClient 附带请求头 Authorization: Bearer <token>
  5. 随后前端请求 GET /api/beans?page=1&limit=20数据不来自本地:即便有缓存,也仅用于首屏占位,真值以本次服务端响应为准。
  6. Express 的 authenticate 中间件校验 JWT,把 user_id 注入上下文;beans 路由据此拼接查询,只取该租户、deleted_at IS NULL 的行,套用分页与过滤。
  7. PostgreSQL 命中 user_id 部分索引返回结果集,Express 序列化为 JSON,原路经 nginx 回到客户端,写入请求缓存并驱动界面渲染。
  8. 若某次响应为 401,apiClient 清除 token 并派发 auth:unauthorized 事件,前端据此引导重新登录。

写入:乐观更新 + 服务端裁决

  1. 用户新增或修改豆仓、笔记时,前端先在请求缓存里做乐观更新,界面即时反映。
  2. 同时发出 POST/PUT /api/…;成功则以服务端返回的行(含 updated_at、UUID)替换乐观值,失败则回滚缓存并提示重试。
  3. PostgreSQL 始终是最终裁决者——本地缓存只是它的临时投影,任何冲突都以数据库为准。

AI 调用:后端代理 + 按角色限额

  1. 客户端要识别咖啡豆包装、生成方案或对话时,请求 POST /api/ai/recognize/api/ai/chat(附 Bearer)——不直接连模型厂商
  2. Express 校验 JWT 与 RBAC 角色(requireRole):按爱好者/咖啡师/管理者分配可用能力与调用配额,并记录用量。
  3. 服务端用仅存于环境变量的密钥转发到 OpenAI 兼容的视觉/对话模型(默认 doubao-seed-2.0-mini,国内可用),复用既有 beanRecognition 的结构化提取契约。
  4. 模型返回结构化 JSON,服务端可校验/落库后再回客户端。密钥、prompt 版本与配额都收归服务端,客户端只见结果。

注:/api/ai/*users.role 列为本轮新增,尚未落地;当前仅有客户端 BYOK 版识别(src/lib/api/beanRecognition.ts)。

取舍 · Server-authoritative 与离线降级

把唯一事实来源收敛到 PostgreSQL,换来了强一致、多端一致与简单的心智模型:不再有本地/远端谁对谁的分叉,也不再需要维护脆弱的双向同步。代价是弱网与离线体验下降——原来离线可读可写,现在关键操作依赖网络。我们的应对是分层降级:请求级缓存(SWR/内存)在弱网下先呈现最近值;写操作走乐观更新并在失败时回滚重试;彻底断网时给出明确的断网提示与手动重试入口,而非静默失败。未来可选补一层只读离线缓存(把最近的 API 响应持久化,仅供浏览、不参与写入),在不重新引入本地权威的前提下改善离线浏览;它是缓存,不是事实来源。同时需偿还既有技术债:路由与 01-schema.sql 的列漂移(beans 读写 roastery/is_favorite 等未定义列、flavor_ratings 关联 note_id 而非 bean_id)、JWT 的 role/meavatar_url 在 users 表尚无列、sessions 表存在但无状态 JWT 未启用、JWT_SECRET 默认值不安全且缺速率限制与刷新令牌。这些应在正式切换为主后端前收敛,详见 TRD 与 Roadmap。