本页回答三个问题:系统里有哪些业务数据、哪个服务拥有它们、服务之间如何引用。具体处理过程见文章处理的持久化流程,字段默认值与索引见数据库表字段参考。
阅读地图
| 要理解的业务场景 | 数据所有者 | 主要数据 |
|---|
| 登录、注册、权限、用量 | user_manager | 用户、邀请码、注册申请、用量事件 |
| 上传、阅读、编辑、翻译、讲解、音频、反馈、文章收藏、每周推荐 | data_server | 文章、句子、媒体对象、反馈、用户文章收藏、推荐排序 |
| 收藏与复习卡片 | collection_server | 收藏夹、卡片快照与语法分组 |
“拥有”表示该服务是数据的事实来源并负责写入规则。Controller 可以编排流程或组合响应,但不因此成为数据所有者。
三种需要区分的结构
| 层次 | 含义 | 当前实现 |
|---|
| 领域对象 | 业务代码使用的稳定概念,不要求与表一一对应 | data_server 定义 Article、Sentence、MediaObject、Feedback dataclass |
| 数据库行 | 关系库中的物理记录(data_server 是 PostgreSQL 行,user_manager 是 SQLite 行),包含文本编码、默认值和约束 | 例如 sentences.furigana 是 JSON 文本,读取为领域对象后是数组 |
| 逻辑外键 | 跨服务或未声明物理约束的 ID 引用 | 例如 articles.user_id 指向 user_manager.users.id,不能跨库 SQL join |
weekly_recommendations 和 article_favorites 都是关系行:前者表达文章与推荐顺序,后者表达用户与整篇文章的收藏关系;它们不另设领域 dataclass,仓储直接返回 Article。user_manager 当前仍主要把 SQLite 行转换为字典;收藏域的行转换由 data_server 的 PgCollectionRepository 完成,collection_server 已不再访问任何数据库。
user_manager:身份、权限与用量
| 领域概念 | 关键数据 | 说明 |
|---|
| User | id, username, password_hash, role, created_at | role 为 user 或 root;JWT 中携带用户 ID、用户名和角色 |
| InviteCode | id, code, is_active, created_by, created_at | 控制注册入口,可由管理员创建和停用 |
| RegistrationRequest | id, username, password_hash, invite_code, remarks, status, reviewed_by, reviewed_at, created_at | 审核通过后才创建用户;状态为 pending/approved/rejected |
| UsageEvent | id, user_id, kind, ref_id, occurred_at | 每次事件一行;上传类型、LLM 调用等按 kind 聚合 |
管理员账号和预置邀请码属于初始化数据,不是运行时“第一个注册者升级”规则。管理员由 app/db/seed.py 幂等写入,邀请码 zy、wai、zx 由 schema 幂等写入。
data_server:文章处理与阅读数据
| 领域概念 | 关键数据 | 说明 |
|---|
| Article | id, user_id, title, author, intro, summary, raw_content, genre, output_language, explanation_level, primary_binding_id, primary_model, fallback_model, status, progress, metadata, is_public, guest_visible, created_at | summary 是尽力生成的文章总结;output_language 记录后续翻译和讲解应沿用的语言;explanation_level 记录讲解门槛与深度,默认 N3 |
| Sentence | id, article_id, sentence_index, sentence, full_prompt, translation, explanation, furigana, analysis_status, explain_status, audio_object_id, audio_voice, note, metadata, created_at | furigana 在领域中是有序映射数组;空白句也可作为布局分隔符存在 |
| MediaObject | id, kind, content_type, byte_size, data, storage_key, created_at | local 模式句子音频把字节放在 data(PostgreSQL BYTEA);cos 模式 data 为空,由 storage_key 指向腾讯云 COS 对象 |
| Feedback | id, user_id, article_id, sentence_index, category, message, created_at | publish_request 复用反馈行表达“申请公开”,没有单独的申请表 |
| 每周推荐关系 | article_id, sort_order, created_at | 单一的管理员排序列表;入选本身构成公开阅读入口,不受文章其他可见性标志限制 |
| 文章收藏关系 | user_id, article_id, created_at | 当前用户收藏的整篇文章;读取时重新套用文章可见性,文章删除时级联清理 |
Agent 的内部 prompt、模型调用和重试不属于数据层;数据层只保存其产出:切句后的原句、翻译、讲解、假名、音频、总结和状态。full_prompt 是会长期保留的辅助数据,但 Model 不执行它。
data_server:收藏与复习快照(rakull_collection)
收藏域是 data_server 内部的独立限界上下文:物理上是单独的 PostgreSQL 数据库 rakull_collection(独立 schema SQL、独立连接工厂),与内容库 rakull_data 不共享连接、不能跨库 JOIN。collection_server 只是客户端直连的无状态薄网关,经”用户 Bearer + 服务断言”调用 data_server 的内部端点;每次请求都按断言中的 actor_user_id(必须与 Bearer 用户一致)限定所有权。
| 领域概念 | 关键数据 | 说明 |
|---|
| Collection | id, user_id, name, description, created_at | 每个请求都按 actor_user_id 限定所有权 |
| CollectionItem | id, collection_id, article_id, sentence_index, front, back, note, grammar, created_at | front/back 是加入时的反归一化快照;grammar 用于卡片分组与排序 |
| ReviewState(SM-2) | due_at, last_reviewed_at, interval_minutes, ease, reps, lapses, card_version | 与卡片同表;调度算法与排队/提交全部在 data_server 完成,card_version 做乐观锁(过期提交返回 409) |
当客户端未提供 front/back 时,由 data_server 在同进程内直接读 sentences 仓储取原句和翻译后保存快照(失败降级为空串);不再经过跨服务 HTTP。源句之后被编辑或删除,不会自动改写已经收藏的卡片。
服务之间如何引用
articles.user_id 与 collections.user_id 是指向 users.id 的跨库逻辑外键。
collection_items.(article_id, sentence_index) 逻辑指向一条 data_server 的 sentences。注意收藏表在 rakull_collection、句子在 rakull_data:即使归同一个 data_server 进程持有,二者仍是两个物理隔离的 PostgreSQL 数据库,不能跨库 SQL JOIN,article_id 只是逻辑引用;PostgreSQL 只约束它与同库内 collections 的关系。
feedback.user_id 是可空的跨库引用;article_id/sentence_index 也没有物理约束,用作可选上下文。
sentences.article_id、sentences.audio_object_id、weekly_recommendations.article_id 等同库关系由 PostgreSQL 外键约束。
article_favorites.user_id 是跨库逻辑引用;article_id 是同库物理外键并使用级联删除。
当前存储实现
| 服务 | 数据访问边界 | 当前落盘方式 |
|---|
data_server | 明确定义 ArticleRepository、ArticleFavoriteRepository、SentenceRepository、WeeklyRecommendationRepository、ObjectStore 等 Protocol;路由通过 Storage 使用实现;收藏域挂 PgCollectionRepository | 唯一的关系存储实现是 PostgreSQL(app/storage/pg/):句子音频在 local 模式使用 media_objects.data BYTEA;收藏域在独立的 rakull_collection 库 |
user_manager | 路由和用量服务通过 get_conn() 直接执行参数化 SQL | 仍为 SQLite:本地账号库 local_auth.db |
collection_server | 无数据访问层;只做鉴权、请求校验与错误透传,通过 CollectionDataClient(Bearer + 服务断言)转发到 data_server 内部接口 | 不落盘(无状态) |
关系存储只有一套实现:data_server 的仓储全部位于 app/storage/pg/(PgArticleRepository、PgCollectionRepository 等),运行时没有 SQLite fallback,也没有 STORAGE_BACKEND 之类的后端开关。仍然可切换的只有 OBJECT_STORE(local / cos)这一组对象/文件存储实现,二者只是媒体字节的存放位置不同。
二进制数据还有两条不同路径:
- 句子 TTS 音频由
MediaObjectStore 写入 PostgreSQL:OBJECT_STORE=local 时字节落在 media_objects.data 的 BYTEA 列,cos 时字节上传 COS、行内只留 storage_key;句子只保存 audio_object_id 和 audio_voice。
/v1/data/files 的一般上传由 LocalFileStore 写入 data_server 的 UPLOAD_DIR;视频入口 /api/videos/file 也会写入 immersive_study_server 自己的 UPLOAD_DIR,再把本地路径交给转写流程。这些本地文件不是 media_objects 字节。
下一步可按问题继续阅读:文章处理的持久化流程说明数据怎样变化,数据库表字段参考列出全部物理字段和约束。