本页回答一个问题:RakullApp 的一份数据到底落在哪里、表与表怎么关联、字节内容(音频、视频、图片、上传文件)又存在哪里。 它是全系统存储的架构总览,按域给出全部 65 张表的 ER 图;字段级紧凑参考见数据库表字段参考,COS 的开通与运维操作见对象存储运维

两条正交的存储轴

系统里有两条互相独立、不要混为一谈的轴:
  1. 关系型数据轴(结构化行):文章、句子、学习事件、情景会话、收藏夹、用户账号……全部是关系型数据库。data_server 只支持 PostgreSQL(单一事实来源,没有运行时 fallback、没有双栈开关),user_manager 的本地账号仍用 SQLite,另外可选挂两个远程 Supabase PostgreSQL 库。
  2. 对象/文件轴(字节块):TTS 音频、情景音频、用户上传的音视频/图片/文件。元数据行始终在 PostgreSQL 的 media_objects 表里,但字节本体落在哪由 OBJECT_STORE 配置决定:local(内联在 PG 的 BYTEA 列 + 本地磁盘目录)或 cos(腾讯云 COS 对象桶,行内只存 key)。
应用代码不直接依赖具体实现:Storage 数据类暴露的 objects / files 两个成员在 local / cos 下是同接口的不同实现(鸭子类型),路由层只面向接口编程(见 data_server/app/storage/factory.py)。

总览拓扑

关键边界:
  • data_server 是唯一的持久化属主。 immersive_study_servercollection_server 都不打开业务数据库,只经内部 HTTP 调 data_server
  • 两个 PostgreSQL 库物理隔离:独立 schema SQL、独立连接工厂(conn_factory / collection_conn_factory),应用从不跨库 JOIN,也没有跨库外键。
  • 客户端永远不直接访问 agent_server / data_server

物理库清单

库(逻辑名)引擎 / 位置属主服务表数配置项
rakull_dataPostgreSQL 18,自建data_server58DATA_DATABASE_URL,默认 postgresql://rakull:rakull@localhost:5432/rakull_data
rakull_collectionPostgreSQL 18,自建,与上者物理隔离data_servercollection_server 只走内部 API)2COLLECTION_DATABASE_URL,默认 postgresql://rakull:rakull@localhost:5432/rakull_collection
local_auth.dbSQLite(WAL),服务根目录文件user_manager5LOCAL_AUTH_DATABASE_PATH
UserDB / RakuLLDB远程 Supabase PostgreSQL,可选user_manager(经 Supabase 客户端)不在本仓库 DDL 内USER_DATABASE_URL / RAKULL_DATABASE_URL 等,未配置时服务正常启动
三个本仓库自管 schema SQL 是新建库的事实来源:
  • rakull_server/data_server/app/db/schema.sqlrakull_data(58 表,1163 行 DDL)
  • rakull_server/data_server/app/db/collection_schema.sqlrakull_collection(2 表)
  • rakull_server/user_manager/app/db/schema.sqllocal_auth.db(5 表,SQLite 方言)
data_server 启动时 get_storage(init=True) 对两个 PG 库各跑一次幂等建表(CREATE ... IF NOT EXISTS),再幂等导入情景与旅程夹具;没有运行时补列机制

rakull_data:58 张表的五个域

58 张表按业务边界分成五个域。下表给出导航,后面逐域给 ER 图。
表数
核心内容与媒体7articlessentencesmedia_objectsweekly_recommendationsarticle_favoritesfeedbackmodel_call_usage_records
自适应学习 v112learner_profilesskillslexical_occurrenceslexical_material_statesvocabulary_entriesvocabulary_sourcesvocabulary_review_cardsvocabulary_review_logslearner_skill_stateslearning_eventslearner_snapshotsadaptive_admin_access_audit
旅程 Journey5journey_templatesjourney_template_versionsjourney_runsjourney_node_commitsjourney_run_checkpoints
情景训练 Scenario11scenario_templatesscenario_template_versionsscenario_knowledge_packsscenario_sessionsscenario_audio_assetsscenario_objective_statesscenario_turnsscenario_turn_commitsscenario_action_commitsscenario_scene_action_commitsscenario_reports
学习者画像基础 v2238 张画像记录表、术语分类 2 表、learner_profile_changes、技能图谱 3 表、能力维度 2 表、学习事件附属 3 表、材料画像 3 表、learner_foundation_migrations
ER 图约定:实线是同库内 PostgreSQL 强制的物理外键(标注删除动作),虚线是无约束的逻辑引用(应用层保证);属性块只列代表性列,不是完整字段表。

域 1:核心内容与媒体(7 表)

  • articles 是内容聚合根:删文章级联删掉句子、周推荐位、收藏行;model_call_usage_records.article_id 刻意用 SET NULL,用量账单据比文章活得久。
  • sentences.audio_object_id 也是 SET NULL:媒体对象被删时句子保留,只是丢音频。
  • feedbackarticle_id / user_id 没有任何外键(允许匿名、允许文章已删的反馈留存)。
  • media_objects 是两条存储轴的交汇点,下文对象存储专节展开。

域 2:自适应学习 v1(12 表)

要点:
  • 技能目录 skills 用字符串业务主键skill_id),不是自增数;词汇/语法/汉字/表达/语用五类由 CHECK 约束。
  • 词源是”多源快照”模型:一个 vocabulary_entries(单词本词条)挂多个 vocabulary_sources(每次在文章里见到它的原句快照);源里冗余保存 article_id、句子快照,即使原文章被删也保留溯源信息,只有 occurrence_id 是可空 FK。
  • FSRS 复习状态 vocabulary_review_cards 与词条 1:1;每次打分追加一条不可变的 vocabulary_review_logs
  • learning_events只追加事件流sequence_id 自增、(user_id,event_id) 唯一),skill_id/article_id 都不设外键——事件必须比它引用的实体活得久;撤回事务见域 5 的 learning_event_retractions
  • learner_profileslearner_skill_stateslearner_snapshots、审计表都以 user_id 为键,但用户主键在另一个服务的 SQLite 里,所以全部是跨引擎逻辑引用

域 3:旅程 Journey(5 表)

模板 → 版本 → 运行实例三级结构,全部用字符串业务 ID:
  • 模板/版本删除用 RESTRICT:只要还有 run 引用,已发布版本就不允许删除,保住历史行程的可回放性。
  • journey_runs 用复合外键 (journey_id, template_version) 锁死到具体版本,模板后续改版不影响进行中的旅程。
  • journey_node_commits 是幂等提交日志((run_id,request_id) 主键 + request_hash + committed_version),重试同一个请求不会重复推进节点;journey_run_checkpoints 是每个节点一份的断点快照。
  • 条件唯一索引保证一个用户对同一旅程至多一个 active run。

域 4:情景训练 Scenario(11 表)

  • 会话同时钉死三个版本:情景模板版本 + 知识包版本(都 RESTRICT),并可选用 journey_run_id 挂到某段旅程(CASCADE,旅程删除会话跟着删)。
  • 三类提交日志(对话 turn、动作 action、场景动作 scene_action)结构同旅程节点提交:幂等键 + 版本号,支撑客户端弱网重试。
  • scenario_audio_assets 是音频资产登记表,分 fixed_reply(模板级、跨会话共享)与 dynamic_turn(会话级)两类,由 CHECK 互斥约束;真正的字节在 media_objects(再往外是 BYTEA 或 COS)。
  • 两个条件唯一索引分别约束「自由尝试每个情景只有一个未完成会话」和「旅程节点绑定唯一」。

域 5:学习者画像基础 v2(23 表)

上图包含全部 23 张域表(articlesskillslearning_events 是跨域复用的外部实体)。其中 8 张画像记录表(图中下方孤立的一组)结构同构且没有任何外键——统一是 (user_id, record_id) 复合主键 + version + source + 时间戳的事件溯源式行模型: learner_language_backgroundslearner_goalslearner_exam_recordslearner_preferenceslearner_self_assessmentslearner_reading_settingslearner_insightslearner_material_feedback 要点:
  • 画像记录是只追加的新版本行,更新 = 插新行 + version+1learner_profile_changes 记录每次变更请求((user_id,request_id) 幂等)。
  • learning_event_targets/contexts/retractions 是事件流的三张附属表:多目标拆解、1:1 上下文、以及对 (user_id,event_id)复合外键撤回;触发器写入时会排除已撤回事件。
  • material_profiles 是文章材料画像(主题、句法、词汇特征),文章一改就由触发器把旧画像置为非当前;material_skill_features 挂在画像版本下。
  • ability_dimensions10 条种子维度(识义、读音、听辨、回忆、产出等),DDL 用 ON CONFLICT DO NOTHING 幂等植入。
  • learner_foundation_migrations 记录一次性基础迁移水位,migration_key 主键。

rakull_collection:收藏域(2 表)

  • 收藏夹与收藏条目在独立 PostgreSQL 库里,删夹级联删条目;UNIQUE(collection_id,article_id,sentence_index) 防重。
  • 条目保存的是收藏时刻的快照front/back/note),不依赖原文是否还在;article_id 指向另一个库的 articles.id,是跨库逻辑引用,没有外键、不能 JOIN——拼卡片正文由 data_server 在应用层分两次查询完成。
  • 七列 SM-2 调度字段(due_atinterval_minutesease 默认 2.5、repslapseslast_reviewed_atcard_version)与三个业务索引全部直接建在 collection_schema.sql 中;card_version 是乐观锁,复习提交版本冲突返回 409。

local_auth.db:账号域(5 表,SQLite)

这是本期唯一保留 SQLite 的域:本地账号、邀请码、注册审批、用量事件、赞助登记。连接固定执行 PRAGMA journal_mode=WALPRAGMA foreign_keys=ON(SQLite 的外键需要逐连接打开)。users 表后 7 个捐赠汇总是由服务层每次重算的缓存,不与明细累加。

跨库引用规则(全系统最重要的一条)

  • user_id 永远是逻辑引用:ID 由 user_manager 的 SQLite 签发,data_server 各表只存数字,跨引擎没有也不可能有外键;鉴权与可见性由服务代码负责。
  • collection_items.article_id 跨 PostgreSQL 物理库引用 articles.id:无 FK、无 JOIN,原文删除后收藏快照依然存在。
  • 全系统物理外键共 42 个rakull_data 内 41 个、rakull_collection 内 1 个。删除策略只有三种:内容子行用 CASCADE、账单/音频类用 SET NULL、模板版本类用 RESTRICT

PostgreSQL 约定速览

主题约定
主键业务表统一 BIGINT GENERATED ALWAYS AS IDENTITY;模板/旅程/情景/技能等目录型实体用字符串业务主键(journey_idsession_idskill_id 等)
类型分布TEXT 454、INTEGER 83、BIGINT 61、DOUBLE PRECISION 17、DATE 15、SMALLINT 9、BYTEA 1
布尔SMALLINT + CHECK (x IN (0,1)),不使用 BOOLEAN(沿用旧 SQLite 语义)
时间戳一律 UTC TEXTnow_compact() 复刻 YYYY-MM-DD HH:MM:SSnow_iso_ms() 给毫秒精度
JSON表中没有 jsonb 列,JSON 文档存 TEXT;jsonb 只出现在 6 处触发器函数体内做 payload 拆解
扩展schema 头部 CREATE EXTENSION IF NOT EXISTS pgcrypto(触发器用 gen_random_bytes() 生成语法版本号)
索引大量条件唯一索引 / 部分索引,如「每旅程仅一个 active run」「固定回复音频绑定」「cache_hash IS NOT NULL 才入索引」
外键41 个,含 3 组复合外键(旅程 run→版本、情景 session→版本/知识包、事件撤回)与 1 个自引用(profile_taxonomy_terms.redirect_term_id

12 个用户触发器(plpgsql,从旧 SQLite 触发器移植)

触发器挂在作用
foundation_explicit_feedbacklearning_events 插入后「标认识/模糊/不认识」事件同步成一条自评记录
foundation_event_contextlearning_events 插入后payload(jsonb)拆成上下文行与多目标行
foundation_observations_insert/updatelearner_skill_states 插入/更新后把回忆/产出/听辨概率投影到 learner_ability_states
foundation_observations_deletelearner_skill_states 删除后清掉对应能力状态
foundation_article_changedarticles 更新 raw_content/genre旧材料画像失效、理解力估计作废
foundation_sentence_insert/update/deletesentences 插入/更新/删除后同上,句子变动连带文章画像失效
grammar_sentence_insert / grammar_source_editsentences 插入/改句后重置 grammar_revision(随机 16 字节)、清空语法缓存
grammar_context_editarticlesintro/output_language整篇句子语法缓存作废
一次性迁移脚本加载数据时会禁用用户触发器(DISABLE TRIGGER USER)并临时摘除外键,迁完按 pg_get_constraintdef 重建;系统触发器(RI 约束触发器)不归业务管。

对象存储:一张元数据表,两种字节落点

media_objects:两种模式的行内差异

kind 的 CHECK 允许 5 类:sentence_audioscenario_audioupload_audioupload_videoupload_image
模式data(BYTEA)storage_key读取方式删除
local字节直接内联NULL查询直接返回字节删行即删字节
cosNULLCOS 对象 key1 小时有效的预签名 URLstorage_key 引用计数,没有其他行引用时才删 COS 对象
COS 写入是三步:先插 data=NULL 占位行 → put_object 上传 → 回填 storage_key。这样即使上传中途失败,数据库里也只留下可清理的占位行,绝不会出现「桶里有孤儿对象但数据库无记录」。TTS 句子音频是内容寻址的共享对象(见下),所以删除必须引用计数。

Key 架构(app/storage/keys.py,单一事实来源)

对象 key 统一三级结构:
{type}/{scope}/{business}/...
  • typeaudio | video | image | files | tmp
  • scopesys(系统生成)| u{uid}(用户私有)
场景key 形态
句子 TTS(内容寻址,全用户共享)audio/sys/tts/{cache_hash}{ext}
情景音频audio/sys/scenario/{sha256(utterance)[:24]}_{voice_sha256[:6]}{ext}
用户上传{media_type}/u{uid}/{business}/{yyyy}/{mm}/{stem}_{ts}_{hex6}{ext}
无主历史文件兜底files/legacy/...
临时转写源文件tmp/u{uid}/transcription/...(COS 侧配置 7 天生命周期过期)
cache_hash = sha256(句子文本 ‖ 0x00 ‖ 声优 ‖ 0x00 ‖ content_type)[:16]——同一句子、同一声音、同一格式只合成一次,命中后直接复用既有 media_objects 行(部分索引 idx_media_objects_cache_hash WHERE cache_hash IS NOT NULL)。

文件存储(用户上传)

  • localLocalFileStore 写在 UPLOAD_DIR(默认 data_server/uploads),目录布局与 COS key 一一镜像,因此 local→cos 迁移就是按相同 key 逐个搬运。
  • cosCosFileStore 对外只暴露不透明 handle(handle 即 COS key),上传先进临时区再 promote 原子转存;读取/删除前用 _is_safe_handle 校验,拒绝 ..、绝对路径等路径穿越尝试。

相关配置项

配置项默认值说明
OBJECT_STORElocallocal / cos;解析结果来自管理台运行时配置(.data/cos-config.json),优先级遵循 运行时设置 > 环境变量 > config.local.yaml > config.yaml > 默认
DATA_DATABASE_URL / COLLECTION_DATABASE_URL本机 rakull 角色两库两个 PG 库各自独立的连接串
UPLOAD_DIRdata_server/uploadslocal 模式文件根目录
COS_SECRET_ID / COS_SECRET_KEY桶凭证;生产只走环境变量或管理台,不进提交文件
COS_REGIONap-shanghai桶地域
COS_BUCKET桶名

备份、迁移与初始化

对象方式
rakull_data / rakull_collectionpg_dump -Fc 自定义格式逐库备份,pg_restore --clean 恢复;快照工具与部署脚本均已双引擎化,SQLite 旧备份不会被前移恢复
local_auth.dbSQLite 在线 Backup API(文件级备份)
COS 对象桶侧生命周期/版本管理治理(tmp/ 7 天自动过期);数据库备份不含字节本体,cos 模式下必须同时备份桶
旧 SQLite → PG一次性脚本 scripts/migrate_sqlite_to_pg.py(只读旧库、单库单事务、身份列 OVERRIDING SYSTEM VALUE、迁完重置序列),用完即弃
全新环境scripts/setup_postgres.sh + 服务启动自动建表;生产由 install.sh 装/启集群、deploy.py configure 建角色与两个库

延伸阅读