对象存储后端 data_server 的关系存储只有 PostgreSQL(物理双库:内容库 rakull_data 与用户收藏库 rakull_collection,配置键 DATA_DATABASE_URL / COLLECTION_DATABASE_URL)。媒体字节的位置由 OBJECT_STORE 切换:local(默认,字节内联在 rakull_data.media_objects.data 的 BYTEA 列,上传文件落在本地 UPLOAD_DIR 目录)与 cos(字节在腾讯云 COS,行内只存 storage_key,data 为 NULL)。切换发生在 app/storage/factory.py,上层 Repository 调用方式保持不变。
RakuLLApp 的媒体(音频、图片、视频)和用户上传文件默认保存在 data_server:媒体字节内联在 PostgreSQL rakull_data 库的 media_objects 表(data BYTEA 列),上传文件在本地 UPLOAD_DIR 目录。生产环境推荐切换到腾讯云 COS(Tencent Cloud Object Storage),以减轻数据库体积、支持跨实例共享并利用 COS 生命周期规则做过期清理。
关系型存储拓扑、双库边界与 ER 图见存储架构 ;本页聚焦 COS 对象生命周期、审计与迁移运维。
为什么用对象存储
数据库瘦身 :音频字节从 media_objects.data 迁出后,表里只保留 storage_key、cache_hash 等元数据,rakull_data 的 pg_dump 备份与恢复更快。
跨实例共享 :多实例部署时所有 data_server 指向同一个 COS 桶,TTS 音频天然去重。
生命周期管理 :通过 COS 控制台配置 tmp/ 前缀的过期规则(例如 7 天自动删除)即可清理临时文件。
与现有开发流程兼容 :本地开发仍默认走 local,无需 COS 凭证即可跑全部测试。
启动服务后打开 http://localhost:8015 ,进入 COS storage 。填写 Object store、SecretId、SecretKey、Region 和 Bucket,先运行六步真实探测,再保存并热应用。标准开发流程不需要复制或编辑 config.local.yaml。
字段说明:
字段 含义 OBJECT_STORElocal(默认)或 cos,控制工厂注入哪种实现COS_SECRET_ID / COS_SECRET_KEY仅 cos 模式需要;建议使用权限最小化的 CAM 子账户,授予目标桶的读写/删除权限 COS_REGION桶地域,例如 ap-shanghai、ap-beijing、ap-singapore COS_BUCKET桶全名,必须包含 APPID 后缀(形如 bucketname-appid),SDK 会据此自动构造 endpoint,不要 手动拼接 URL
SecretId 与 SecretKey 只写不回显。测试只使用当前表单,不会保存;保存会覆盖运行时配置,空凭证按清空处理。部署回退值只能放在环境变量、gitignored config.local.yaml 或 .env;示例文件、容器镜像和日志中都不能出现真实值。任何曾粘贴到聊天或工单的密钥都必须禁用并轮换。
环境变量与 gitignored YAML 仅用于基础设施部署或兼容回退,例如:
OBJECT_STORE = cos \
COS_SECRET_ID= \
COS_SECRET_KEY= \
COS_REGION=ap-shanghai \
COS_BUCKET= \
uv run uvicorn app.main:app --port 8014
从管理后台配置与探测
root 管理员在 manager_server 的 COS storage 标签页管理这些字段。控制台配置只保存在 data_server 的 COS_RUNTIME_CONFIG_PATH(默认 .data/cos-config.json,已 gitignore);写入采用原子替换,文件权限为 0600。这五个 COS 字段的优先级为:后台运行时设置 > 环境变量 > config.local.yaml > config.yaml > 默认值 。保存表单后,后台值直接覆盖部署回退值。
SecretId 与 SecretKey 是只写字段:API 仅返回“是否已配置”,不会回传明文。表单始终作为一个完整候选配置处理;空凭证表示清空。保存使用 revision 乐观锁,避免两个管理员静默覆盖彼此的修改。切换成功后会热应用到后续请求;已经开始的请求继续使用原来的存储实例。
「测试候选配置」不会先保存,而是用 tmp/admin-probe/<随机值>.txt 依次执行 Bucket 可达性、PUT、HEAD、GET 内容校验、LIST 可见性与 DELETE,并报告每一步耗时。失败时仍会尽力删除临时对象;错误信息会移除 SecretId/SecretKey。因探测包含写入和删除,它验证的是实际运行所需权限,而不只是网络连通。
对象浏览器支持前缀与分页游标。每个条目保留完整原始 key,同时解析出对象类型和归属:句子 TTS 按文章 ID、标题和句子数聚合;情景音频显示 scenario_id、模板版本以及 fixed variant 或 dynamic session;上传文件显示 key 中的用户和用途。共享内容寻址对象会列出多个归属(最多五条摘要并附精确总数),不会被强行归到某一篇文章。
浏览器把 COS 对象、media_objects 行和业务引用分开计数,状态含义如下:
状态 含义 可直接删除 in_use存在句子或 scenario_audio_assets 业务引用 否 tracked_unused仍有 media_objects 行指向 key,但没有业务引用 否;应先清理数据库记录 cos_orphan已知系统生成格式,但当前数据库中没有匹配媒体行 是 untracked用户上传/legacy 文件未进入 media_objects 引用索引 否;须由所属工作流删除 temporarytmp/ 处理对象或遗留管理探测对象是 system_assetfiles/sys/assets/ 下的钉版数据资产(catalog/runtime/api,来自 RakullDataAssets 发布流水线)否;由发布流水线管理,禁止在控制台删除,详见数据资产仓库与 COS/CDN 同步 unknown / externalkey 不符合当前 Rakull 格式,或不在管理前缀中 否
原来的 unreferenced 只表达“当前数据库中没有相同的 media_objects.storage_key”,并不自动等于安全删除:数据库重建、桶与环境配错、清理中断以及未跟踪上传都可能产生零计数。服务端会在删除时重新计算状态,只有 cos_orphan 与 temporary 可删;界面仍要求输入完整 key 二次确认。下载只生成 5 分钟有效的签名 URL。该页面不是批量清理工具,数据库仍是媒体对象的权威索引。
命令行完整审计与安全清理
部署前使用快照工具分页对账整个桶。报告不含凭据和媒体字节,同时把当前数据库与保留快照中的 storage_key 都计入保护范围:
bash scripts/stop_all_servers.sh
./scripts/db_snapshot.sh audit-media --source local --output /safe/path/cos-audit.json
报告中的 ready_for_snapshot 只有在以下四类计数全部为零时才为真:media_objects.data 仍非空的内联 BYTEA、数据库 storage_key 引用但 COS 中缺失的对象、本地上传目录(data_server 与 immersive_study_server 的 UPLOAD_DIR)中的残留文件,以及已配置但缺失的可选 user_manager 数据库。historical_only 表示对象不再被当前库使用,但仍被可恢复的历史数据库快照引用,不能作为孤儿删除。
清理必须显式使用同一份报告和 --apply;命令会重新生成审计并核对数据库指纹、桶和对象状态,以免使用过期清单:
./scripts/db_snapshot.sh prune-media --source local \
--report /safe/path/cos-audit.json \
--temporary-older-than-days 7 --apply
临时对象年龄使用 COS last_modified,不是文件名中的日期。合法的 tmp/u{uid}/transcription/YYYY/MM/DD/... 与 tmp/admin-probe/... 都属于 temporary,但未满七个完整日不会删除。untracked、unknown、external 和任何数据库/历史快照引用始终受保护。
Key 架构
所有 COS 对象 key 由单一事实来源 app/storage/keys.py 生成(禁止手动拼接字符串),采用三级层次:
{type}/{scope}/{business}/{...details...}{ext}
段 取值 含义 typeaudio / video / image / files / tmp顶层按物理媒体类型划分,便于配置生命周期规则 scopesys / u{uid}sys 为系统/公共资源(如 TTS、情景音频、词库资源),u{uid} 为用户私有资源businesstts / scenario / sources / uploads / avatars / assets / legacy业务维度,用于按来源区分对象
例子:
Key 含义 audio/sys/tts/e6c7b990e5fb5a01.wav句子 TTS 音频,内容寻址 hash e6c7b990e5fb5a01 audio/sys/scenario/ab12...cd34_ja-JP.mp3情景训练 NPC 音频,按 utterance hash + voice 寻址 files/u42/uploads/2026/08/my_article_....pdf用户 42 的普通 PDF 上传 tmp/u42/transcription/2026/08/27/lesson_....mp4用户 42 的待转写临时源文件 video/u42/sources/2026/08/lesson_....mp4转写成功且用户选择保留的源视频 files/sys/assets/revisions/2026.09.0/manifest.json钉版数据资产清单(catalog/runtime/api,来自 RakullDataAssets,受保护不可删)
文件扩展名从 content_type 动态推导(例如 audio/wav → .wav、audio/mpeg → .mp3),不在代码里硬编码后缀。
内容寻址音频缓存
句子 TTS 与情景 NPC 音频都以内容寻址方式存储:key 由 (text_hash, voice, content_type) 推导,相同三元组 → 同一个 COS 对象。这是对用户完全透明 的缓存层:
多篇文章出现同一句子 + 同一声优 → 数据库只保留一个 media_objects 行,COS 只保留一个对象,TTS 不会重复生成,COS 不会重复上传。
情景训练中相同 NPC 台词重复出现时同理。
media_objects.cache_hash 列上建有部分索引(WHERE cache_hash IS NOT NULL),命中时 O(1) 复用。
缓存键计算函数 compute_cache_hash(sentence_text, voice, content_type) 使用 sha256(sentence_text + "\\x00" + voice + "\\x00" + content_type)[:16],取前 16 个十六进制字符作为 key 片段。
用户上传与转写生命周期
FileStore 对本地和 COS 后端提供同一组流式上传、删除、短期签名 URL 与临时对象转永久对象能力。上传接口只把 opaque handle 交给客户端,不返回可长期复用的本地路径或 COS URL。
POST /api/videos/file 把音频/视频写入 tmp/u{uid}/transcription/...,返回 file_handle。
POST /api/videos/upload 只提交 input_handle;input_path 仅在 data_server 确认为 local 后端时兼容。
转写前 data_server 生成 10 分钟签名 URL。agent_server 还要求用户 Bearer 与 Controller 短期断言同时匹配,并只下载配置的 COS HTTPS 主机。
下载禁止重定向,限制声明大小和实际流大小,并拒绝解析到内网或环回地址的主机;Agent 转写后删除自己的临时文件。
任务成功且 keep_audio=true 时对象移动到 audio|video/u{uid}/sources/...,handle 写入文章 metadata.source_media;否则删除。失败或取消也会尽力删除,tmp/ 的 7 天生命周期作为最终兜底。
agent_server 只保存非秘密的下载主机白名单:
TRANSCRIBE_SOURCE_HOSTS :
- bucket-appid.cos.ap-shanghai.myqcloud.com
MAX_TRANSCRIBE_SIZE_MB : 500
仓储内事务 API
ObjectStore 提供两类 API:
Standalone 方法 (put_media、get_bytes、delete 等):内部自己开连接、提交事务,适合句子仓储等简单场景。
conn_* 事务内方法 (conn_insert_media、conn_replace_media、conn_get_bytes、conn_find_by_cache_hash、conn_delete):接收调用方传入的 DB connection,不自行 commit,适合像 scenario_repo 这种在同一连接事务里连续写入多行、最后统一 commit / 异常 rollback 的仓储,保证“插入 media 行 + 写业务行”在同一 PostgreSQL 事务里原子生效。
本地(local,MediaObjectStore)与 COS(cos,CosObjectStore)两种后端实现同一套 API 签名;在 COS 模式下 conn_insert_media 的流程是:
在事务内 INSERT 一行占位(data=NULL, storage_key=NULL, cache_hash=?);
用 media_key(kind, cache_hash, content_type, voice_id=...) 算出 key;
同步 PUT 对象到 COS;
UPDATE 该行写入 storage_key;
返回,由调用方 commit。
conn_delete 在本地模式下直接删 DB 行;在 COS 模式下会先做引用计数(同 storage_key 被多少其他行引用),只有当引用数为 0 时才同步删除 COS 对象,保证多对一场景下不会误删共享对象。
数据迁移
scripts/migrate_to_cos.py 负责把 media_objects 里的历史数据从内联 BYTEA(或旧 storage_key 指向的对象)迁移到 COS 的内容寻址 key:
cd rakull_server/data_server
# 1. 预览并生成不含密钥的清单(不会写入 COS 或改 DB)
uv run python scripts/migrate_to_cos.py --dry-run --manifest /safe/path/cos-dry-run.json
# 2. 停服、用 pg_dump 备份 rakull_data 后执行迁移
uv run python scripts/migrate_to_cos.py --manifest /safe/path/cos-cutover.json
另有两个可选开关:--media-only 只处理媒体行、跳过本地上传目录清点,--files-only 只清点 UPLOAD_DIR、不连接数据库,二者互斥;--manifest 缺省为当前目录的 migration-manifest.json。脚本结束时会打印清单路径、媒体对象数量与无法确认所有者的历史上传数量:dry-run 提示未改动数据库或 COS,正式运行成功时输出 COS verification and transactional database cutover completed.;任何前置条件失败都会以非零退出码终止并照样写清单。
脚本特性:
映射先行、失败关闭 :句子音频必须找到唯一的 sentence/audio_voice;情景音频必须找到唯一的 source_text_hash/voice_id,且 64 位 hash 与 content_type 与资产行一致。缺失或冲突会在修改数据库前停止。启动时还会确认 media_objects 已具备 cache_hash 列,否则拒绝执行(先初始化当前 schema)。
配置一致 :迁移与服务、快照命令读取同一套管理台运行时配置优先级,数据库连接使用 DATA_DATABASE_URL,不需要把管理台密钥再复制到命令行。
规范对象优先 :同一逻辑 key 已在 COS 时保留并回读该对象;否则旧字节内容不一致时只接受严格多数 SHA-256,平票保持失败关闭并写入清单。
分阶段校验 :所有分组先解析完毕才开始上传;先上传全部对象,再从 COS 回读并逐项比较大小与 SHA-256;全部通过后才继续。
单事务切换 :所有 storage_key/cache_hash 更新和 data=NULL 在单个 PostgreSQL 事务中提交(每行校验 rowcount=1),任一行失败即整体回滚。
幂等重跑 :已经位于 canonical key 的对象仍会被回读校验,不会因重复运行破坏引用。
历史上传不猜所有者 :本地 UPLOAD_DIR 中的未知文件保持原样,并以 unresolved-owner-kept-local 写入清单。
迁移只触碰 rakull_data 库(media_objects 及只读引用的 sentences、scenario_audio_assets),不涉及 rakull_collection。迁移完成后可用 psql 确认所有行都已后置:
psql " $DATA_DATABASE_URL " -c \
"SELECT count(*) AS total,
count(*) FILTER (WHERE data IS NULL AND storage_key IS NOT NULL) AS cos_backed,
count(*) FILTER (WHERE data IS NOT NULL) AS inline_blobs
FROM media_objects;"
期望 inline_blobs = 0 且 cos_backed = total。
生产切换顺序
禁用任何已公开的旧密钥,为同一个受限 CAM 子用户生成新密钥;先用隔离前缀验证 Put/Get/Head/签名 URL/Delete。
运行 dry-run 并检查清单;任何缺失映射或未知所有者都先处理,不要带警告切换。
停止六个服务(bash scripts/stop_all_servers.sh),用 pg_dump 自定义格式备份 rakull_data,再用 pg_restore --list 验证转储可读:
pg_dump --format=custom --no-owner --no-privileges \
--file /safe/path/rakull_data-cutover.pgdump " $DATA_DATABASE_URL "
pg_restore --list /safe/path/rakull_data-cutover.pgdump > /dev/null
迁移不写 rakull_collection,但如需一致性全集也可同时备份该库。注意 ./scripts/db_snapshot.sh push 会校验所有媒体行都已指向 COS,不能 用于迁移前备份;它是切换成功后的快照打包工具。
执行迁移;只接受全部对象大小和 SHA-256 校验通过、脚本输出事务切换完成、清单条目标记为 committed 的结果。
在服务器 gitignored .env 中设置 OBJECT_STORE=cos 和轮换后的凭证,配置 Agent 精确主机白名单,重建并启动服务。
冒烟旧句子音频、情景音频、普通上传和转写上传;确认新字节写入 COS(media_objects.data 为 NULL)而非本地 UPLOAD_DIR,并用上面的 psql 查询复核。
回滚时先停服,用 pg_restore 恢复迁移前快照,再以 OBJECT_STORE=local 启动(恢复出的行字节内联在 data 列):
bash scripts/stop_all_servers.sh
pg_restore --clean --if-exists --no-owner --no-privileges \
--dbname " $DATA_DATABASE_URL " /safe/path/rakull_data-cutover.pgdump
迁移期间上传但未引用的 COS 对象留待确认稳定后清理,不在回滚过程中删除。
端到端验证
scripts/e2e_cos_sanity.py 使用当前有效 COS 配置做端到端冒烟。标准流程先在 COS storage 保存并探测;命令行环境/YAML 仅用于独立脚本的部署回退:
cd rakull_server/data_server
OBJECT_STORE = cos uv run python scripts/e2e_cos_sanity.py
该脚本连接 DATA_DATABASE_URL / COLLECTION_DATABASE_URL 所指的 PostgreSQL 实例,临时创建一对隔离数据库(rakull_e2e_data_<随机> 与 rakull_e2e_coll_<随机>),结束时强制删除;验证句子音频 round-trip、内容寻址去重(同一句子 + 同一声音只产生一个 media_objects 行)、用户上传的类型路由、路径穿越防护以及非默认 sources 前缀。文件 handle 生命周期、越权访问与 URL 下载安全由各服务常规测试覆盖;真实桶冒烟必须使用隔离前缀并在验证后清理。
测试套件也支持直接跑 COS 模式(与 CI 相同的筛选方式):
OBJECT_STORE = cos uv run pytest -q -m "not integration" --ignore=tests/integration_test
本地开发默认 OBJECT_STORE=local,无需 COS 凭证即可运行测试。只有明确标记的真实 COS 冒烟才应接触部署桶。
运维建议
桶安全 :桶保持私有并启用 SSE-COS 默认加密;应用只通过短期签名 URL 读取私有对象。
生命周期 :为 tmp/ 配置 7 天过期,为未完成分块上传配置 1 天清理;永久媒体不设置自动过期。
桶权限 :CAM 子账户仅授予目标桶所需的 cos:PutObject、cos:GetObject、cos:DeleteObject、cos:HeadObject 与管理端列表所需的 cos:GetBucket;不要授予删桶或其他云资源权限。
备份 :启用 COS 版本控制或跨地域复制以应对误删;PostgreSQL 双库(至少包含 media_objects 的 rakull_data)需定期用 pg_dump -Fc 备份,或切换完成后用 ./scripts/db_snapshot.sh push 生成经回读校验的快照包,因为数据库持有 storage_key 等权威元数据。
切回本地 :把 OBJECT_STORE 改回 local 后,新写入的字节会内联到 media_objects.data BYTEA;但已迁移行 storage_key 非空而 data 为 NULL,local 后端只读 data 列,历史音频会读不到。生产回滚不要只翻开关,应按上文用 pg_restore 恢复迁移前快照再以 local 启动。
相关模块
路径 职责 app/storage/keys.py 所有 COS key 的单一事实来源 app/storage/factory.py 按 OBJECT_STORE 注入具体实现 app/storage/cos/ COS 后端实现(CosClient、CosObjectStore、CosFileStore) app/storage/pg/object_store.py 本地后端 MediaObjectStore:字节内联 media_objects.data BYTEA app/storage/pg/file_store.py 本地后端 LocalFileStore:UPLOAD_DIR 上传目录 scripts/migrate_to_cos.py 存量数据迁移脚本 scripts/e2e_cos_sanity.py 端到端冒烟脚本