两条正交的存储轴
系统里有两条互相独立、不要混为一谈的轴:- 关系型数据轴(结构化行):文章、句子、学习事件、情景会话、收藏夹、用户账号……全部是关系型数据库。
data_server只支持 PostgreSQL(单一事实来源,没有运行时 fallback、没有双栈开关),user_manager的本地账号仍用 SQLite,另外可选挂两个远程 Supabase PostgreSQL 库。 - 对象/文件轴(字节块):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_server、collection_server都不打开业务数据库,只经内部 HTTP 调data_server。- 两个 PostgreSQL 库物理隔离:独立 schema SQL、独立连接工厂(
conn_factory/collection_conn_factory),应用从不跨库 JOIN,也没有跨库外键。 - 客户端永远不直接访问
agent_server/data_server。
物理库清单
| 库(逻辑名) | 引擎 / 位置 | 属主服务 | 表数 | 配置项 |
|---|---|---|---|---|
rakull_data | PostgreSQL 18,自建 | data_server | 58 | DATA_DATABASE_URL,默认 postgresql://rakull:rakull@localhost:5432/rakull_data |
rakull_collection | PostgreSQL 18,自建,与上者物理隔离 | data_server(collection_server 只走内部 API) | 2 | COLLECTION_DATABASE_URL,默认 postgresql://rakull:rakull@localhost:5432/rakull_collection |
local_auth.db | SQLite(WAL),服务根目录文件 | user_manager | 5 | LOCAL_AUTH_DATABASE_PATH |
| UserDB / RakuLLDB | 远程 Supabase PostgreSQL,可选 | user_manager(经 Supabase 客户端) | 不在本仓库 DDL 内 | USER_DATABASE_URL / RAKULL_DATABASE_URL 等,未配置时服务正常启动 |
rakull_server/data_server/app/db/schema.sql→rakull_data(58 表,1163 行 DDL)rakull_server/data_server/app/db/collection_schema.sql→rakull_collection(2 表)rakull_server/user_manager/app/db/schema.sql→local_auth.db(5 表,SQLite 方言)
data_server 启动时 get_storage(init=True) 对两个 PG 库各跑一次幂等建表(CREATE ... IF NOT EXISTS),再幂等导入情景与旅程夹具;没有运行时补列机制。
rakull_data:58 张表的五个域
58 张表按业务边界分成五个域。下表给出导航,后面逐域给 ER 图。
| 域 | 表数 | 表 |
|---|---|---|
| 核心内容与媒体 | 7 | articles、sentences、media_objects、weekly_recommendations、article_favorites、feedback、model_call_usage_records |
| 自适应学习 v1 | 12 | learner_profiles、skills、lexical_occurrences、lexical_material_states、vocabulary_entries、vocabulary_sources、vocabulary_review_cards、vocabulary_review_logs、learner_skill_states、learning_events、learner_snapshots、adaptive_admin_access_audit |
| 旅程 Journey | 5 | journey_templates、journey_template_versions、journey_runs、journey_node_commits、journey_run_checkpoints |
| 情景训练 Scenario | 11 | scenario_templates、scenario_template_versions、scenario_knowledge_packs、scenario_sessions、scenario_audio_assets、scenario_objective_states、scenario_turns、scenario_turn_commits、scenario_action_commits、scenario_scene_action_commits、scenario_reports |
| 学习者画像基础 v2 | 23 | 8 张画像记录表、术语分类 2 表、learner_profile_changes、技能图谱 3 表、能力维度 2 表、学习事件附属 3 表、材料画像 3 表、learner_foundation_migrations |
域 1:核心内容与媒体(7 表)
articles是内容聚合根:删文章级联删掉句子、周推荐位、收藏行;model_call_usage_records.article_id刻意用SET NULL,用量账单据比文章活得久。sentences.audio_object_id也是SET NULL:媒体对象被删时句子保留,只是丢音频。feedback的article_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_profiles、learner_skill_states、learner_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是每个节点一份的断点快照。- 条件唯一索引保证一个用户对同一旅程至多一个
activerun。
域 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 张域表(articles、skills、learning_events 是跨域复用的外部实体)。其中 8 张画像记录表(图中下方孤立的一组)结构同构且没有任何外键——统一是 (user_id, record_id) 复合主键 + version + source + 时间戳的事件溯源式行模型:
learner_language_backgrounds、learner_goals、learner_exam_records、learner_preferences、learner_self_assessments、learner_reading_settings、learner_insights、learner_material_feedback。
要点:
- 画像记录是只追加的新版本行,更新 = 插新行 +
version+1,learner_profile_changes记录每次变更请求((user_id,request_id)幂等)。 learning_event_targets/contexts/retractions是事件流的三张附属表:多目标拆解、1:1 上下文、以及对(user_id,event_id)的复合外键撤回;触发器写入时会排除已撤回事件。material_profiles是文章材料画像(主题、句法、词汇特征),文章一改就由触发器把旧画像置为非当前;material_skill_features挂在画像版本下。ability_dimensions有 10 条种子维度(识义、读音、听辨、回忆、产出等),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_at、interval_minutes、ease默认 2.5、reps、lapses、last_reviewed_at、card_version)与三个业务索引全部直接建在collection_schema.sql中;card_version是乐观锁,复习提交版本冲突返回 409。
local_auth.db:账号域(5 表,SQLite)
这是本期唯一保留 SQLite 的域:本地账号、邀请码、注册审批、用量事件、赞助登记。连接固定执行 PRAGMA journal_mode=WAL 与 PRAGMA 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_id、session_id、skill_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 TEXT:now_compact() 复刻 YYYY-MM-DD HH:MM:SS,now_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_feedback | learning_events 插入后 | 「标认识/模糊/不认识」事件同步成一条自评记录 |
foundation_event_context | learning_events 插入后 | 把 payload(jsonb)拆成上下文行与多目标行 |
foundation_observations_insert/update | learner_skill_states 插入/更新后 | 把回忆/产出/听辨概率投影到 learner_ability_states |
foundation_observations_delete | learner_skill_states 删除后 | 清掉对应能力状态 |
foundation_article_changed | articles 更新 raw_content/genre 后 | 旧材料画像失效、理解力估计作废 |
foundation_sentence_insert/update/delete | sentences 插入/更新/删除后 | 同上,句子变动连带文章画像失效 |
grammar_sentence_insert / grammar_source_edit | sentences 插入/改句后 | 重置 grammar_revision(随机 16 字节)、清空语法缓存 |
grammar_context_edit | articles 改 intro/output_language 后 | 整篇句子语法缓存作废 |
DISABLE TRIGGER USER)并临时摘除外键,迁完按 pg_get_constraintdef 重建;系统触发器(RI 约束触发器)不归业务管。
对象存储:一张元数据表,两种字节落点
media_objects:两种模式的行内差异
kind 的 CHECK 允许 5 类:sentence_audio、scenario_audio、upload_audio、upload_video、upload_image。
| 模式 | data(BYTEA) | storage_key | 读取方式 | 删除 |
|---|---|---|---|---|
local | 字节直接内联 | NULL | 查询直接返回字节 | 删行即删字节 |
cos | NULL | COS 对象 key | 1 小时有效的预签名 URL | 按 storage_key 引用计数,没有其他行引用时才删 COS 对象 |
data=NULL 占位行 → put_object 上传 → 回填 storage_key。这样即使上传中途失败,数据库里也只留下可清理的占位行,绝不会出现「桶里有孤儿对象但数据库无记录」。TTS 句子音频是内容寻址的共享对象(见下),所以删除必须引用计数。
Key 架构(app/storage/keys.py,单一事实来源)
对象 key 统一三级结构:
type:audio|video|image|files|tmpscope:sys(系统生成)|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)。
文件存储(用户上传)
local:LocalFileStore写在UPLOAD_DIR(默认data_server/uploads),目录布局与 COS key 一一镜像,因此 local→cos 迁移就是按相同 key 逐个搬运。cos:CosFileStore对外只暴露不透明 handle(handle 即 COS key),上传先进临时区再promote原子转存;读取/删除前用_is_safe_handle校验,拒绝..、绝对路径等路径穿越尝试。
相关配置项
| 配置项 | 默认值 | 说明 |
|---|---|---|
OBJECT_STORE | local | local / cos;解析结果来自管理台运行时配置(.data/cos-config.json),优先级遵循 运行时设置 > 环境变量 > config.local.yaml > config.yaml > 默认 |
DATA_DATABASE_URL / COLLECTION_DATABASE_URL | 本机 rakull 角色两库 | 两个 PG 库各自独立的连接串 |
UPLOAD_DIR | data_server/uploads | local 模式文件根目录 |
COS_SECRET_ID / COS_SECRET_KEY | 空 | 桶凭证;生产只走环境变量或管理台,不进提交文件 |
COS_REGION | ap-shanghai | 桶地域 |
COS_BUCKET | 空 | 桶名 |
备份、迁移与初始化
| 对象 | 方式 |
|---|---|
rakull_data / rakull_collection | pg_dump -Fc 自定义格式逐库备份,pg_restore --clean 恢复;快照工具与部署脚本均已双引擎化,SQLite 旧备份不会被前移恢复 |
local_auth.db | SQLite 在线 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 建角色与两个库 |