一句话原则

资产型数据(与代码无关、所有部署/所有用户共享、可按版本整体替换的静态内容)不进 App 代码仓库。 它们的唯一编辑源是独立仓库 RakullDataAssets。资产仓 CI 每次 PR / 合 main 都把整棵树打成一个包放进 COS 的 candidate(候选)槽;线上始终只吃 active(在线)槽。只有 root 管理员在后台点 promote,candidate 才会晋升为 active——这一步是唯一的激活门禁,但激活后的换树、重灌数据库、热重载语法卡全部自动完成,无需重启服务。

为什么单独搞一个仓库

历史上种子夹具与 JLPT N1~N5 语法卡直接放在 App monorepo 里,导致代码仓被上千个数据文件撑大、数据改动要走代码评审与发版、多实例共享困难。现在拆成三类职责:
编辑源   git: AgentEndeavour/RakullDataAssets(独立数据仓,不放密钥/业务代码)
发布源   CI:  PR 或合 main → 资产仓单边打包,整树一个 tar.gz 覆盖 COS candidate 槽
运行源   data_server : 部署/重启只 GET active 单对象落盘;promote 时重灌 PostgreSQL
         controller  : 读本地物化的 runtime,promote 后热重载语法卡(不重启)
         client      : 默认走 controller;开关打开后直取 CDN 并缓存
  • monorepo 不包含任何被跟踪的资产数据,资产统一物化到 gitignored 的 rakull_server/.data-assets/
  • 消费端从不 clone 资产仓、不需要任何 git/deploy key,只读 COS 上的 active 对象。
  • 不再用代码常量“钉死”资产版本:版本号只用于展示、审计与归档路径;激活与否完全由“当前 active 槽里是什么包”决定。

资产仓布局

仓库根的 REVISION 是单行版本号(如 2026.09.0),会写进包的 meta 与归档路径。它现在是信息性字段,不再需要与代码里的常量对齐,也不参与激活门禁;但仍建议每次改数据都抬版本,方便后台辨认与回滚。
RakullDataAssets/
├─ REVISION                          # 单行:2026.09.0(信息性,建议每次抬号)
├─ README.md
├─ requirements.txt                  # 发布脚本依赖(钉版 cos-python-sdk-v5)
├─ .github/workflows/sync-cos.yml    # PR/合 main 触发,打包覆盖 candidate 槽
├─ scripts/publish_candidate_slot.py # 自包含发布脚本(默认 dry-run,--apply 才写 COS)
├─ catalog/                          # data_server 导入 PostgreSQL 的种子数据(219 个文件)
│  ├─ scenarios/{knowledge_packs,templates,localizations}/
│  ├─ journeys/templates/
│  └─ test_corpus.json               # 离线/功能测试语料
└─ runtime/                          # controller 运行时静态资产(808 个文件)
   ├─ grammar_cards/                 # JLPT N1~N5 + foundation 语法卡(catalog.json + 每卡 md)
   └─ verb_type/                     # 动词变形卡
两类消费者对应两棵子树:catalog 是要写进数据库的结构化种子;runtime 是 controller 直接读盘、并可预渲染成 JSON 发给客户端的静态资产。

双槽模型:active 与 candidate

桶里用固定 key 存两个槽位,每个槽位是“一个整包 + 一个旁挂 meta”:
files/sys/assets/slots/active.tar.gz        # 线上正在用的唯一整包
files/sys/assets/slots/active.meta.json     # 它的来源/校验信息
files/sys/assets/slots/candidate.tar.gz     # 等待激活的整包(CI 每次覆盖)
files/sys/assets/slots/candidate.meta.json
files/sys/assets/revisions/<rev>/bundle.tar.gz  # promote 时归档的旧 active(可回滚)
  • 固定前缀 files/sys/assets 是代码常量 ASSETS_PREFIX,发布端(资产仓脚本)和读取端(data_server)共用同一套 key 函数。
  • 整树单包:把 catalog/+runtime/ 用确定性方式(gzip mtime=0、USTAR、固定顺序与权限)打成一个 tar.gz,替代旧版上千次串行 GET;端到端用 meta 里的 sha256 + size 校验,损坏/缺对象直接 fail-closed。
  • meta 字段:schema_versionrevisiongit_shagit_refeventpr_numberbuilt_atsha256sizecatalog_filesruntime_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_requestpr_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 的任何凭据。
也可在资产仓本地 dry-run(不带 --apply,不写任何对象)核对文件数/大小/sha:
cd RakullDataAssets
python3 scripts/publish_candidate_slot.py --git-sha <sha> --git-ref main --event push

消费链路:部署与 CI 只拉 active 单对象

monorepo 里没有数据,跑服务或测试前先把 active 整包物化到本地。脚本 scripts/fetch_data_assets.sh 内部执行 python -m app.storage.assets.package fetch-active只 GET active 槽这一个对象(消费端不 clone 资产仓):
bash scripts/fetch_data_assets.sh          # 拉 active,幂等(同版本零网络)
bash scripts/fetch_data_assets.sh --force  # 强制重新下载
source rakull_server/.data-assets/env.sh   # 导出资产路径变量
  • env.sh 导出四个变量:SCENARIO_FIXTURES_PATHJOURNEY_FIXTURES_PATHTEST_CORPUS_PATHASSETS_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 updateremote-updateprepare 之后、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 需二次确认(防误触),随后整条链路自动完成:
manager  POST /api/manager/assets/promote      (root only,转发超时 300s)
  → study POST /api/admin/assets/promote       (root only)
      → data_server AssetSlotService.promote()
data_server 的安全时序(app/storage/assets/package.py):
  1. 加进程锁:已有 promote 在跑则返回 409 AssetSlotBusy,不允许并发换树。
  2. 读 candidate meta(缺失 → 422),GET candidate 整包,校验 sha256/size(不符 → 422,不留半成品树)。
  3. 解包到临时 stage,原子换树:旧树先移到 backup,再把新树移到 .data-assets/
  4. 重灌 PostgreSQL:重建并原子替换进程内 storage 单例,重新导入 catalog。
    • 一旦重灌失败(含不可变 (id, version) 行漂移、内容 hash 不符),把旧树 move 回来、不发布新单例,并抛错——此时 COS 两个槽完全不动
  5. 重灌成功后丢弃 backup;best-effort 把旧 active 整包归档到 revisions/<old-rev>/bundle.tar.gz(归档失败不阻断)。
  6. 桶内把 candidate 复制成 active,再删除 candidate 的 bundle + meta(候选槽腾空,等待下一次 CI 覆盖)。
study 在 data 调用成功后热重载语法卡:重建 grammar 三全局、回写所有按值引用的模块别名、清空 grammar-linking 的两个 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 页据此渲染:
{
  "active":    { "slot": "active", "present": true, "revision": "2026.09.0", "git_sha": "…" },
  "candidate": { "slot": "candidate", "present": true, "revision": "2026.10.0", "event": "pull_request", "pr_number": 104 },
  "local":     { "revision": "2026.09.0", "catalog_files": 219, "runtime_files": 808, "path": "…/.data-assets" }
}
相关测试:data_server tests/unit_test/test_data_assets.py 用内存假桶覆盖打包确定性、冷启动、幂等拉取、坏包拒绝、promote 全链路、重灌失败回滚且 COS 不动、并发忙、归档失败不阻断等;study 覆盖 admin 编排与语法热重载;manager 覆盖转发与错误码镜像;控制台 JS 覆盖双槽渲染与二次确认。

客户端:CDN 直取 + 浏览器缓存 + 接口回退

公共语法卡/动词变形面向所有用户、无需鉴权,最适合让客户端直取 CDN,避免回源压 controller。预渲染 JSON 是 controller 代码的派生物,不在资产仓单边发布范围内(流水线只发 catalog/+runtime/ 整包);启用 CDN 直取后按 active 版本组织路径:
  1. 客户端启动后请求一次 GET /api/grammar-asset-config,返回 { enabled, revision, public_base },其中 revision 即当前 active 版本。
  2. enabled=truepublic_base 非空(或编译期用 --dart-define=ASSET_PUBLIC_BASE=https://cdn.example.com 覆盖)时,语法/动词请求改打:
    <base>/revisions/<rev>/api/<locale>/grammar-cards.json
    <base>/revisions/<rev>/api/<locale>/grammar-cards/<id>.json
    <base>/revisions/<rev>/api/<locale>/verb-forms.json
    <base>/revisions/<rev>/api/<locale>/verb-forms/<id>.json
    
  3. 列表在客户端按 category/jlpt_level 过滤(与原 query 语义一致)。
  4. 缓存:Web 走浏览器 HTTP 缓存(版本对象 immutable,几乎不再回源);原生端无透明 HTTP 缓存,用 shared_preferences 按 URL 缓存 JSON,active 版本升级自动换 key 并清理旧版。
  5. 任何 CDN 失败(404/5xx/CORS/网络)透明回退到 controller 的 /api/grammar-cards/api/verb-forms;默认 enabled=false 时行为与旧版一致。
CDN/COS 前置要求与字体相同:公开可读、返回 CORS GET 头、不可变缓存头。给私有 COS 桶绑定一个开启 CORS 的公开 CDN/静态域名,把它配到 controller ASSET_PUBLIC_BASE 或客户端 --dart-define=ASSET_PUBLIC_BASE

仍然适用:这些东西不放 COS 资产槽 / 不进资产仓

  • 密钥.data/cos-config.json(SecretId/SecretKey)只通过 :8015 后台写入(只写不回显),或走环境变量 / gitignored config.local.yaml
  • 用户媒体与需要鉴权的私有对象:仍走媒体对象存储与 1 小时预签名直链,不要和公开资产槽混用;资产槽只放用户无关的静态整包。
  • 数据库本体:走 pg_dump / db_snapshot 备份;promote 的重灌不是 DB 备份手段。
  • 代码与评审历史:COS 上的只是某次构建产物;评审与版本化在 git,激活动作在后台审计。
  • 旧的逐文件 revisions/<rev>/...manifest.jsoncurrent.json 对象作为历史产物保留不删,但新链路不再读取或写入它们。
相关阅读:对象存储(COS)存储架构远程部署本地快速开始Web 字体