这一页的读法
本页以一个用户的真实操作顺序为主线,回答一个最让人没底的问题:上传之后,PostgreSQL 和 COS 分别存了什么?
静态的全表与 ER 图见存储架构,服务间的调用时序见数据流,文章/句子/TTS 的写入细节见文章处理持久化。
三个去处,先记住
| 去处 | 存什么 | 大白话 |
|---|
| PostgreSQL | 用户、文章、每一句、收藏、学习进度、用量账单 | 要查、要排序、要关联的”表格数据” |
| 腾讯 COS | 句子读音、情景音频、用户上传的音视频/图片、数据资产整包 | 大块的”文件” |
| 本地兜底 | 上传文件落磁盘目录、音频内联进 PG 的 BYTEA 字段 | 没配 COS 时也能跑 |
两条正交的存储轴(关系型数据轴 / 对象文件轴)以及”上层只依赖同一接口、切换只换实现”的设计,在存储架构里有完整说明。
一个用户会经过的阶段
全程铁律:客户端只连 user_manager(8010)、immersive_study_server(8012)、collection_server(8013);真正的持久化属主只有 data_server。
阶段 1:注册与登录
路径 A:本地账号(邀请制,当前实际跑通的闭环)
POST /api/auth/register:先写一行注册申请,状态 pending,等 root 管理员审批。
- 审批通过后进入
users(含 role:user / root)。
POST /api/auth/login:校验密码后签发 JWT,claims 为 {user_id, username, role, exp},有效期 7 天。
| 数据 | 落点 |
|---|
| 账号、密码哈希、角色、邀请码、注册审批 | SQLite local_auth.db:users invite_codes registration_requests |
| 登录后的凭证 | JWT(不落库,全服务共享 JWT_SECRET 本地解码) |
签发逻辑见 security.py,账号库 DDL 见 schema.sql。
路径 B:Supabase 三方 OAuth(较新,尚未与内容库对齐)
用户在 Google / 微信 / GitHub 等授权后,POST /register/finalize 会同时写两个 PostgreSQL 库:
| 库 | 表 | 存什么 |
|---|
| 身份库 UserDB | user_identities provider_accounts app_sessions | 身份主体、绑定的 OAuth 账号、刷新会话 |
| 业务库 RakuLLDB | app_users | 昵称、头像、订阅档位、用户配置 |
见 register_service.py。
当前必须知道的一处技术债——两套用户 ID 类型不一致。 本地账号的 users.id 是整数,JWT 里的 user_id 也是整数,能对上 data_server 的 BIGINT user_id;而 OAuth 用户的主键 uid 是 UUID 字符串(见 models.py),与内容库的整数 user_id 目前没有统一映射。因此现在用本地/管理员账号走”上传 → 处理 → 收藏”是完整闭环;OAuth 与内容库的 ID 对齐是后续必须补的一块。
阶段 2:上传文本文章
入口 POST /api/articles/upload。控制器登记一个内存任务(进度通过 SSE 推送),真正的处理在 flows.py。每一步落点:
| 步骤 | 做什么 | 落点 |
|---|
| ① 建文章 | 写一行文章,status="processing"、progress=0 | PG articles(库 rakull_data) |
| ② 分句 | 调 agent split 切句 | 结果批量写 PG sentences 占位 |
| ③ 逐句分析 | 分块调 agent:语法、翻译 translation、讲解 explanation、注音 furigana | 更新 PG sentences 各字段 |
| ④ 生成读音 | 调 agent tts(见下) | COS 或 BYTEA + PG media_objects |
| ⑤ 收尾 | status="completed"(读音有失败则 partially_completed)、progress=100 | 更新 PG articles |
| ⑥ 记账 | 每次大模型调用的 token / 费用 / 耗时 | PG model_call_usage_records |
articles 关键字段:user_id、genre(article/interview/song)、status、progress、is_public、guest_visible。表定义见 schema.sql。
句子读音 TTS:内容寻址,一份读音全用户复用
读音不是”每篇文章各存一份”,而是按内容算哈希:
cache_hash = sha256(句子文本 ‖ 0x00 ‖ 音色 ‖ 0x00 ‖ content_type)[:16]
key = audio/sys/tts/{cache_hash}.wav
同样的句子、同样的声音、同样的格式永远得到同一个 key,只合成一次,之后全部命中复用。两种部署:
| 模式 | 字节存哪 | PG media_objects 行 |
|---|
OBJECT_STORE=cos(线上) | COS audio/sys/tts/{hash}.wav | data=NULL,只回填 storage_key |
OBJECT_STORE=local(兜底) | PG media_objects.data(BYTEA)内联 | storage_key=NULL,字节在行里 |
sentences.audio_object_id 指向对应 media_objects 行。实现见 COS 版 cos/object_store.py 与本地版 pg/object_store.py,分流点在 factory.py。
COS 写入是安全三步:先插 data=NULL 占位行 → put_object 上传 → 回填 storage_key。即使中途失败,也只留下可清理的占位行,不会出现”桶里有孤儿对象、数据库没记录”。
阶段 3:上传音视频:临时区 → 转录 → 转正
比文本多了”把语音转成日文文字”这一步,之后与阶段 2 完全相同。
POST /api/videos/file:源文件先进临时区。
- COS:
tmp/u{uid}/transcription/年/月/日/…(桶侧配置 7 天生命周期自动过期)
- 本地:临时目录
POST /api/videos/upload:COS 模式用临时对象的预签名 URL 调 Whisper 转录(transcribe_url),得到日文文本。
- 转正:
POST /v1/data/files/promote 把源文件从临时区原子转存到正式区 video/u{uid}/sources/年/月/…。
- 有了文本后:分句 → 逐句分析 → TTS,与阶段 2 一模一样。
文件相关端点(上传 / resolve / promote / 删除)见 routes/data.py,key 规则见 keys.py。
阶段 4:阅读
GET /api/articles/{id} 时,控制器先做可见性判断(本人 / is_public / guest_visible),再向 data_server 取文章与句子:
- 文章正文、句子、翻译、讲解来自 PG
articles + sentences。
- 点喇叭取读音:data_server 按
media_objects 行决定从 COS(预签名/取字节)还是 BYTEA 返回。
阶段 5:阅读之后的行为
这里要分清两种”收藏”,落在不同的库:
| 行为 | 入口 | 落点 |
|---|
| 文章收藏(心形,整篇) | PUT/DELETE /articles/{id}/favorite | PG article_favorites(rakull_data),主键 (user_id, article_id),重复 ON CONFLICT DO NOTHING |
| 句子加入收藏夹/集合 | 经 collection_server | PG collection_items(独立库 rakull_collection),保存收藏时刻的 front/back 快照 |
| 加入词汇本 | adaptive vocabulary | PG vocabulary_entries(user_id+skill_id 唯一) |
| 单词的来源句子 | 同上 | PG vocabulary_sources(原句、位置、上下文快照) |
| 单词间隔复习(FSRS) | 复习接口 | PG vocabulary_review_cards(记忆状态、due_at)+ vocabulary_review_logs(每次打分流水) |
| 技能与出现位置 | 分析/学习 | PG skills(全用户共享技能目录)、lexical_occurrences |
| 学习事件 / 能力状态 | 各学习动作 | PG learning_events learner_skill_states |
| 反馈、求发布 | feedback | PG feedback(bug / suggestion / publish_request) |
| 每周推荐 | 管理员配置 | PG weekly_recommendations |
| 头像、图片 | 文件接口 | COS image/u{uid}/avatars/…;本地兜底落磁盘 |
文章收藏实现见 article_favorite_repo.py。各张自适应表的关系与 ER 图见存储架构。
为什么有两个物理 PG 库? 文章收藏与内容同在 rakull_data;而”句子收藏夹/集合”是独立限界上下文,在 rakull_collection,与内容库物理隔离、不能跨库 JOIN。collection_items.article_id 跨库指向 articles.id,只是无约束的逻辑引用,拼卡片正文由 data_server 在应用层分两次查询完成。
总表:什么数据进哪里
| 数据 | 有 COS | 没 COS(兜底) |
|---|
| 用户身份 / 账号 | PG(+SQLite 本地账号) | 同左 |
| 文章 / 句子 / 收藏 / 学习进度 | PG | PG |
| 句子读音、情景音频 | COS(sys,内容寻址去重) | PG BYTEA |
| 用户上传的音视频 / 图片 / 文件 | COS(u) | 本地 uploads 目录 |
| 数据资产整包 | COS(sys/assets 双槽) | 本地 .data-assets/ |
| 处理中的临时源 | COS tmp(7 天) | 本地临时目录 |
隔离与一致性边界
- 多用户隔离双保险:PG 每张业务表带
user_id,查询强制过滤;COS key 带 u{uid},非本人且非 root 访问直接 403。
- 删文章级联:
sentences、文章收藏等随之删除;而读音对象 / 用量账单可能被复用或需留存,用 SET NULL / 账单比文章活得久。
- 删 COS 读音有引用计数:没有其他
media_objects 行共用同一 storage_key 时才真正删对象。
- 内容库与收藏库不跨库 JOIN:需要关联就在应用层拼装。
- 数据资产整包是独立链路:
files/sys/assets/* 的 active/candidate 双槽与用户媒体互不影响,见数据资产 COS 双槽发布。
延伸阅读