这一页的读法

本页以一个用户的真实操作顺序为主线,回答一个最让人没底的问题:上传之后,PostgreSQL 和 COS 分别存了什么? 静态的全表与 ER 图见存储架构,服务间的调用时序见数据流,文章/句子/TTS 的写入细节见文章处理持久化

三个去处,先记住

去处存什么大白话
PostgreSQL用户、文章、每一句、收藏、学习进度、用量账单要查、要排序、要关联的”表格数据”
腾讯 COS句子读音、情景音频、用户上传的音视频/图片、数据资产整包大块的”文件”
本地兜底上传文件落磁盘目录、音频内联进 PG 的 BYTEA 字段没配 COS 时也能跑
两条正交的存储轴(关系型数据轴 / 对象文件轴)以及”上层只依赖同一接口、切换只换实现”的设计,在存储架构里有完整说明。

一个用户会经过的阶段

全程铁律:客户端只连 user_manager(8010)、immersive_study_server(8012)、collection_server(8013);真正的持久化属主只有 data_server。

阶段 1:注册与登录

路径 A:本地账号(邀请制,当前实际跑通的闭环)

  1. POST /api/auth/register:先写一行注册申请,状态 pending,等 root 管理员审批。
  2. 审批通过后进入 users(含 roleuser / root)。
  3. POST /api/auth/login:校验密码后签发 JWT,claims 为 {user_id, username, role, exp},有效期 7 天。
数据落点
账号、密码哈希、角色、邀请码、注册审批SQLite local_auth.dbusers invite_codes registration_requests
登录后的凭证JWT(不落库,全服务共享 JWT_SECRET 本地解码)
签发逻辑见 security.py,账号库 DDL 见 schema.sql

路径 B:Supabase 三方 OAuth(较新,尚未与内容库对齐)

用户在 Google / 微信 / GitHub 等授权后,POST /register/finalize 会同时写两个 PostgreSQL 库:
存什么
身份库 UserDBuser_identities provider_accounts app_sessions身份主体、绑定的 OAuth 账号、刷新会话
业务库 RakuLLDBapp_users昵称、头像、订阅档位、用户配置
register_service.py
当前必须知道的一处技术债——两套用户 ID 类型不一致。 本地账号的 users.id整数,JWT 里的 user_id 也是整数,能对上 data_server 的 BIGINT user_id;而 OAuth 用户的主键 uidUUID 字符串(见 models.py),与内容库的整数 user_id 目前没有统一映射。因此现在用本地/管理员账号走”上传 → 处理 → 收藏”是完整闭环;OAuth 与内容库的 ID 对齐是后续必须补的一块。

阶段 2:上传文本文章

入口 POST /api/articles/upload。控制器登记一个内存任务(进度通过 SSE 推送),真正的处理在 flows.py。每一步落点:
步骤做什么落点
① 建文章写一行文章,status="processing"progress=0PG 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_idgenre(article/interview/song)、statusprogressis_publicguest_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}.wavdata=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 完全相同。
  1. POST /api/videos/file:源文件先进临时区
    • COS:tmp/u{uid}/transcription/年/月/日/…(桶侧配置 7 天生命周期自动过期)
    • 本地:临时目录
  2. POST /api/videos/upload:COS 模式用临时对象的预签名 URL 调 Whisper 转录(transcribe_url),得到日文文本。
  3. 转正POST /v1/data/files/promote 把源文件从临时区原子转存到正式区 video/u{uid}/sources/年/月/…
  4. 有了文本后:分句 → 逐句分析 → 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}/favoritePG article_favoritesrakull_data),主键 (user_id, article_id),重复 ON CONFLICT DO NOTHING
句子加入收藏夹/集合经 collection_serverPG collection_items(独立库 rakull_collection),保存收藏时刻的 front/back 快照
加入词汇本adaptive vocabularyPG vocabulary_entriesuser_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
反馈、求发布feedbackPG feedback(bug / suggestion / publish_request)
每周推荐管理员配置PG weekly_recommendations
头像、图片文件接口COS image/u{uid}/avatars/…;本地兜底落磁盘
文章收藏实现见 article_favorite_repo.py。各张自适应表的关系与 ER 图见存储架构
为什么有两个物理 PG 库? 文章收藏与内容同在 rakull_data;而”句子收藏夹/集合”是独立限界上下文,在 rakull_collection,与内容库物理隔离、不能跨库 JOINcollection_items.article_id 跨库指向 articles.id,只是无约束的逻辑引用,拼卡片正文由 data_server 在应用层分两次查询完成。

总表:什么数据进哪里

数据有 COS没 COS(兜底)
用户身份 / 账号PG(+SQLite 本地账号)同左
文章 / 句子 / 收藏 / 学习进度PGPG
句子读音、情景音频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 双槽发布

延伸阅读