一句话原则
资产型数据(与代码无关、所有部署/所有用户共享、可按版本整体替换的静态内容)不进 App 代码仓库。 它们的唯一编辑源是独立仓库 RakullDataAssets。资产仓 CI 每次 PR / 合 main 都把整棵树打成一个包放进 COS 的 candidate(候选)槽;线上始终只吃 active(在线)槽。只有 root 管理员在后台点 promote,candidate 才会晋升为 active——这一步是唯一的激活门禁,但激活后的换树、重灌数据库、热重载语法卡全部自动完成,无需重启服务。
为什么单独搞一个仓库
历史上种子夹具与 JLPT N1~N5 语法卡直接放在 App monorepo 里,导致代码仓被上千个数据文件撑大、数据改动要走代码评审与发版、多实例共享困难。现在拆成三类职责:- monorepo 不包含任何被跟踪的资产数据,资产统一物化到 gitignored 的
rakull_server/.data-assets/。 - 消费端从不 clone 资产仓、不需要任何 git/deploy key,只读 COS 上的 active 对象。
- 不再用代码常量“钉死”资产版本:版本号只用于展示、审计与归档路径;激活与否完全由“当前 active 槽里是什么包”决定。
资产仓布局
仓库根的REVISION 是单行版本号(如 2026.09.0),会写进包的 meta 与归档路径。它现在是信息性字段,不再需要与代码里的常量对齐,也不参与激活门禁;但仍建议每次改数据都抬版本,方便后台辨认与回滚。
双槽模型:active 与 candidate
桶里用固定 key 存两个槽位,每个槽位是“一个整包 + 一个旁挂 meta”:- 固定前缀
files/sys/assets是代码常量ASSETS_PREFIX,发布端(资产仓脚本)和读取端(data_server)共用同一套 key 函数。 - 整树单包:把
catalog/+runtime/用确定性方式(gzip mtime=0、USTAR、固定顺序与权限)打成一个 tar.gz,替代旧版上千次串行 GET;端到端用 meta 里的 sha256 + size 校验,损坏/缺对象直接 fail-closed。 - meta 字段:
schema_version、revision、git_sha、git_ref、event、pr_number、built_at、sha256、size、catalog_files、runtime_files。后台据此展示“这个候选包来自哪个 commit / 是 PR 还是已合 main”。 - 上传顺序固定为 先 bundle 后 meta,读者看到完整 meta 时包必然已就位。槽位 key 在
:8015对象浏览器里标记为system_asset、受保护不可删除。
发布链路(资产仓 CI,单边完成)
sync-cos.yml 在 self-hosted Linux X64 runner 上只 checkout RakullDataAssets,用 uv 准备 CPython 3.11、安装 requirements.txt,调用自包含的 scripts/publish_candidate_slot.py:
- 触发时机:对 main 的 PR 与 合入 main 的 push 都跑,另加手动
workflow_dispatch。每次都幂等覆盖 candidate 槽。 - PR 构建:meta 带
event=pull_request与pr_number,并传--no-bootstrap——未合并代码永远不可能变成 active;后台对此候选包会显示“来自未合并 PR”的醒目警告。 - 合 main / 手动 dispatch:覆盖 candidate,并且仅当 active 槽为空时用桶内复制做一次冷启动 bootstrap;active 已存在就绝不覆盖。
- 先 dry-run 出计划,再
--apply;需要的密钥只有资产仓自身的 4 个 Actions secrets:COS_SECRET_ID/COS_SECRET_KEY/COS_REGION/COS_BUCKET,不需要 App monorepo 的任何凭据。
--apply,不写任何对象)核对文件数/大小/sha:
消费链路:部署与 CI 只拉 active 单对象
monorepo 里没有数据,跑服务或测试前先把 active 整包物化到本地。脚本scripts/fetch_data_assets.sh 内部执行 python -m app.storage.assets.package fetch-active,只 GET active 槽这一个对象(消费端不 clone 资产仓):
env.sh导出四个变量:SCENARIO_FIXTURES_PATH、JOURNEY_FIXTURES_PATH、TEST_CORPUS_PATH、ASSETS_DIR。- 下载后先在临时目录解包、校验 sha256/size 与
catalog/+runtime/布局,再原子落盘到rakull_server/.data-assets/(已 gitignore);可用DATA_ASSETS_DIR指向别处的物化树。 SKIP_DATA_ASSETS=1可让run_all_servers.sh跳过自动拉取;run_all_servers.sh在建库前会自动 fetch 并 source env.sh。- CI(
.github/workflows/ci-backend.yml)在 pytest 前对 data_server / immersive_study / agent 三条腿执行同一 fetch,再把 env.sh 去引号后追加进GITHUB_ENV;凭据由 RakullApp 仓的 4 个COS_*secrets 注入。 - 服务器部署同样自动拉取:
rakull_dev.sh update与remote-update在prepare之后、migrate之前以rakull-dev用户执行一次 fetch-active,物化树属主随之保持为服务用户;COS 未配置时跳过并继续,COS 已配置但拉取失败则中止部署。首次手工装机不经过remote-update时,可用sudo ./scripts/rakull_dev.sh _fetch-assets手动刷新。 - 媒体存储与资产槽解耦:无论用户媒体走本地还是 COS(
OBJECT_STORE),资产槽始终用配置好的同一套 COS 凭据读写;消费端不会因为媒体留在本地就绕过资产层。 - 本地树的接受条件是“REVISION 非空 + catalog/runtime 布局完整”,不再与代码版本做硬比对;测试用的合成夹具可用显式
SCENARIO_FIXTURES_PATH/JOURNEY_FIXTURES_PATH指向临时目录,优先级最高。
Promote:人工激活 + 全自动热生效(不重启)
在 manager 后台(:8015)的 Data assets 页能看到 active / candidate 两个槽与本地已物化树。点 Promote candidate to active 需二次确认(防误触),随后整条链路自动完成:
app/storage/assets/package.py):
- 加进程锁:已有 promote 在跑则返回
409 AssetSlotBusy,不允许并发换树。 - 读 candidate meta(缺失 →
422),GET candidate 整包,校验 sha256/size(不符 →422,不留半成品树)。 - 解包到临时 stage,原子换树:旧树先移到 backup,再把新树移到
.data-assets/。 - 重灌 PostgreSQL:重建并原子替换进程内 storage 单例,重新导入 catalog。
- 一旦重灌失败(含不可变
(id, version)行漂移、内容 hash 不符),把旧树 move 回来、不发布新单例,并抛错——此时 COS 两个槽完全不动。
- 一旦重灌失败(含不可变
- 重灌成功后丢弃 backup;best-effort 把旧 active 整包归档到
revisions/<old-rev>/bundle.tar.gz(归档失败不阻断)。 - 桶内把 candidate 复制成 active,再删除 candidate 的 bundle + meta(候选槽腾空,等待下一次 CI 覆盖)。
lru_cache;Markdown 正文本来就是请求时读盘。结果里带回 grammar: {grammar_cards, verb_cards, jlpt_cards},全程不需要重启。万一热重载本身失败,也只记录告警,不会回滚已经成功的 promote。
- 开关:
DATA_ASSETS_ALLOW_PROMOTE=false可在某台机器上禁用 promote(返回403),默认true。 - 错误码:未授权/禁用
403、槽位忙409、包缺失/校验失败/版本漂移422、上游 COS 或内部异常502。 - 去钉版后的安全兜底:PostgreSQL 模板
(id, version)不可变 + 内容 hash 校验 + root 显式动作,保证“不是同一份数据就无法悄悄覆盖”,版本号仅用于展示与审计。 - 多机限制:promote 原子替换的是执行该请求的那台 data_server 的本地树与库,不是广播。多实例部署时,其余机器需要各自重跑一次
fetch_data_assets.sh(拉到的已是新 active)并重灌,或让多实例共享同一个物化卷;否则它们要到下次部署/重启拉 active 时才会收敛到新版本。
查看槽位状态
GET /api/admin/assets/status(root)返回三部分,后台 Data assets 页据此渲染:
tests/unit_test/test_data_assets.py 用内存假桶覆盖打包确定性、冷启动、幂等拉取、坏包拒绝、promote 全链路、重灌失败回滚且 COS 不动、并发忙、归档失败不阻断等;study 覆盖 admin 编排与语法热重载;manager 覆盖转发与错误码镜像;控制台 JS 覆盖双槽渲染与二次确认。
客户端:CDN 直取 + 浏览器缓存 + 接口回退
公共语法卡/动词变形面向所有用户、无需鉴权,最适合让客户端直取 CDN,避免回源压 controller。预渲染 JSON 是 controller 代码的派生物,不在资产仓单边发布范围内(流水线只发catalog/+runtime/ 整包);启用 CDN 直取后按 active 版本组织路径:
-
客户端启动后请求一次
GET /api/grammar-asset-config,返回{ enabled, revision, public_base },其中revision即当前 active 版本。 -
enabled=true且public_base非空(或编译期用--dart-define=ASSET_PUBLIC_BASE=https://cdn.example.com覆盖)时,语法/动词请求改打: -
列表在客户端按
category/jlpt_level过滤(与原 query 语义一致)。 -
缓存:Web 走浏览器 HTTP 缓存(版本对象 immutable,几乎不再回源);原生端无透明 HTTP 缓存,用
shared_preferences按 URL 缓存 JSON,active 版本升级自动换 key 并清理旧版。 -
任何 CDN 失败(404/5xx/CORS/网络)透明回退到 controller 的
/api/grammar-cards、/api/verb-forms;默认enabled=false时行为与旧版一致。
ASSET_PUBLIC_BASE 或客户端 --dart-define=ASSET_PUBLIC_BASE。
仍然适用:这些东西不放 COS 资产槽 / 不进资产仓
- 密钥:
.data/cos-config.json(SecretId/SecretKey)只通过:8015后台写入(只写不回显),或走环境变量 / gitignoredconfig.local.yaml。 - 用户媒体与需要鉴权的私有对象:仍走媒体对象存储与 1 小时预签名直链,不要和公开资产槽混用;资产槽只放用户无关的静态整包。
- 数据库本体:走
pg_dump/db_snapshot备份;promote 的重灌不是 DB 备份手段。 - 代码与评审历史:COS 上的只是某次构建产物;评审与版本化在 git,激活动作在后台审计。
- 旧的逐文件
revisions/<rev>/...、manifest.json、current.json对象作为历史产物保留不删,但新链路不再读取或写入它们。