系统架构
CafeDaily 现在是一台服务端权威的冲煮仪器:PostgreSQL 是唯一事实来源,所有读写都经 API 落回数据库。前端退化为瘦客户端——Zustand 只持有界面与会话态,配上请求级缓存与鉴权 token,不再把 IndexedDB 当权威存储。下图自上而下呈现真实拓扑:多端客户端穿过 nginx 网关,进入 Docker 网络内的 Express API 与 PostgreSQL,数据双向流经同一条主线。
分层详解
下面按层给出上图的可读等价内容。核心原则:PostgreSQL 是唯一事实来源,前端不再持有权威数据,所有读写都经 Express API 落回数据库。每一层标注真实技术栈,与 TRD 中的需求一一对应。
客户端层 · Clients
Web/PWA、iOS、Android、桌面四种运行时共用同一份 Next.js 静态导出:一次构建,四处运行。原生壳仅提供文件系统、分享、通知等桥接能力,业务逻辑与 UI 完全一致。数据不再随壳打包,一律向服务端请求。
瘦客户端态 · Thin Client(非权威)
前端只持有派生态,不再是数据源。Zustand 保管界面与会话态(选中项、弹层、主题、当前用户);请求级缓存(SWR 式/内存缓存)缓存最近的 API 响应并支撑乐观更新;鉴权 token 存 localStorage,按 Authorization: Bearer 附带。IndexedDB 不再作为权威存储,原 dbUtils 水合链路被移除或降级为只读缓存。
边缘 / 网关层 · 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 响应。
服务层 · 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。
数据层 · 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。
同步层 · Sync(已弃用移除)
历史上通过 Supabase(含 realtime,约 23 文件)做本地优先的双向同步。在服务端权威模型下,同步层不再需要:PostgreSQL 已是唯一事实来源,前端直接读写它。Supabase 客户端、UnifiedSyncService / BaseSyncManager 等同步编排随迁移一并移除。此节点在架构图中以灰色虚线保留,仅作迁移注记。
请求生命周期
在服务端权威模型下,没有本地兜底:登录与取豆列表都必须命中服务端才有数据。弱网时靠请求缓存呈现旧值、靠重试收敛,而不是靠本地库离线读写。
登录 + 取豆列表:两次都必须命中服务端
- 用户在任一客户端提交邮箱与密码,前端向
POST /api/auth/login发起请求。请求先到达 ten 上的 nginx——离线时此步直接失败,前端弹出断网提示并允许重试,不存在本地登录。 - nginx 命中
/api/前缀,经 TLS 终止后把请求反向代理到 Docker 网络cafedaily-net内的cafedaily-api:3001。 - Express 依次过 helmet 与 cors 中间件;auth 路由用 bcrypt 校验密码哈希,签发 HS256 JWT(payload
sub/email/username/role,有效期 7d)。 - 前端把 token 存入 localStorage(key
cafedaily_token),后续请求由 apiClient 附带请求头Authorization: Bearer <token>。 - 随后前端请求
GET /api/beans?page=1&limit=20。数据不来自本地:即便有缓存,也仅用于首屏占位,真值以本次服务端响应为准。 - Express 的
authenticate中间件校验 JWT,把user_id注入上下文;beans 路由据此拼接查询,只取该租户、deleted_at IS NULL的行,套用分页与过滤。 - PostgreSQL 命中
user_id部分索引返回结果集,Express 序列化为 JSON,原路经 nginx 回到客户端,写入请求缓存并驱动界面渲染。 - 若某次响应为
401,apiClient 清除 token 并派发auth:unauthorized事件,前端据此引导重新登录。
写入:乐观更新 + 服务端裁决
- 用户新增或修改豆仓、笔记时,前端先在请求缓存里做乐观更新,界面即时反映。
- 同时发出
POST/PUT /api/…;成功则以服务端返回的行(含updated_at、UUID)替换乐观值,失败则回滚缓存并提示重试。 - PostgreSQL 始终是最终裁决者——本地缓存只是它的临时投影,任何冲突都以数据库为准。
AI 调用:后端代理 + 按角色限额
- 客户端要识别咖啡豆包装、生成方案或对话时,请求
POST /api/ai/recognize或/api/ai/chat(附 Bearer)——不直接连模型厂商。 - Express 校验 JWT 与 RBAC 角色(
requireRole):按爱好者/咖啡师/管理者分配可用能力与调用配额,并记录用量。 - 服务端用仅存于环境变量的密钥转发到 OpenAI 兼容的视觉/对话模型(默认
doubao-seed-2.0-mini,国内可用),复用既有beanRecognition的结构化提取契约。 - 模型返回结构化 JSON,服务端可校验/落库后再回客户端。密钥、prompt 版本与配额都收归服务端,客户端只见结果。
注:/api/ai/* 与 users.role 列为本轮新增,尚未落地;当前仅有客户端 BYOK 版识别(src/lib/api/beanRecognition.ts)。
把唯一事实来源收敛到 PostgreSQL,换来了强一致、多端一致与简单的心智模型:不再有本地/远端谁对谁的分叉,也不再需要维护脆弱的双向同步。代价是弱网与离线体验下降——原来离线可读可写,现在关键操作依赖网络。我们的应对是分层降级:请求级缓存(SWR/内存)在弱网下先呈现最近值;写操作走乐观更新并在失败时回滚重试;彻底断网时给出明确的断网提示与手动重试入口,而非静默失败。未来可选补一层只读离线缓存(把最近的 API 响应持久化,仅供浏览、不参与写入),在不重新引入本地权威的前提下改善离线浏览;它是缓存,不是事实来源。同时需偿还既有技术债:路由与 01-schema.sql 的列漂移(beans 读写 roastery/is_favorite 等未定义列、flavor_ratings 关联 note_id 而非 bean_id)、JWT 的 role 与 /me 的 avatar_url 在 users 表尚无列、sessions 表存在但无状态 JWT 未启用、JWT_SECRET 默认值不安全且缺速率限制与刷新令牌。这些应在正式切换为主后端前收敛,详见 TRD 与 Roadmap。