跳转至

数据分发方案设计:同步分层 + 数据出 git(GitHub Releases)

状态:待审阅(v1.4,2026-09-29 修订) 范围:仅 xgb_wiki 链路。test_xuangubao / 内网 GitLab 链路本期不动。 配套架构图:E:\Quant_projs\4. xuangubao\data-sync-architecture.html v1.1 变更:根据讨论新增同步链路分层(§5.1,原表先镜像成 SQLite,加工放到之后做),实施阶段相应调整为 P1a/P1b。 v1.2 变更:staging 表名/字段与 MySQL 源表完全一致(运行时动态建表强制对齐,废弃 src_ 前缀);补第 5 张源表 plate_rank_infos;小表改为每轮整表刷新;新增 staging_schema.json(agent 机读)与 --selftest;staging 工具拆为独立最小交付物(见 docs/data-sync/staging-sync-tool-plan.md)。 v1.3 变更:新增 §5.7 staging 分片版本化——按主键 id 分片 + 内容寻址命名 + 火山引擎 TOS 通道,实现跨设备增量同步(换设备不再全量拉);成品库维持整库快照不分片;实施阶段新增 P1c。 v1.4 变更:TOS 接入定型——改用官方 Python SDK(包名 tos,原生端点);资源切换至用户指定的上海桶 arkclaw-tos-2101988067-cn-shanghai(cn-shanghai),分片前缀调整为桶内 xgb-wiki/staging/;.env 六键与新凭证已全链路验证。

1. 背景与问题

分发环节的问题:xgb_wiki.db(99MB)、graph.db(4.6MB)目前跟着 git 提交一起分发——

  • .git 已膨胀到 860MB,每次数据同步都会新增约 70MB 的整库快照,历史只增不减;
  • xgb_wiki.db 99MB 已贴近 GitHub 单文件 100MB 的硬上限,slim filter 只能压小单次快照,挡不住历史累积;
  • WeKnora 服务器取一次数据要 clone 整个仓库(860MB,代码、历史、数据混在一起);
  • CI(deploy-site.yml)依赖 checkout 出来的瘦版 db,和 git 分发方式绑得太死。

同步环节的问题:当前 src/pipeline/etl.py 把"抽取"和"加工"挤在同一步——从 MySQL 拉原始行的同时就完成正文清洗、个股/板块解析、本体化落库。隐患在于:

  • 加工逻辑(extractors、chain_map)一出 bug,整条同步链路跟着失败,本体库停在原地;
  • 全量重建(--full)必须重新连 MySQL 拉全量,对 VPN 和内网依赖重、耗时长;
  • 想改加工逻辑做实验,必须连着源库一起动,没法离线反复重算。

2. 目标与非目标

目标

  1. 同步链路分层:先把 MySQL 原表原样同步成本地 staging SQLite(逻辑极简、出错环节少),加工 ETL 改从 staging 读取、放在同步之后做——两段互不牵连、各记各的进度;
  2. 数据分发与代码版本控制分开:数据不再进 git,.git 停止增长;
  3. WeKnora 及任何取数机器:一条命令拿到最新数据(分钟级,不用 clone);
  4. CI 改为数据发版后自动构建部署,触发方式从"收到数据提交"变为"数据发版";
  5. 解除 100MB 单文件风险(发布 zip 预计 30–50MB)。

非目标

  • 不改 MySQL 抽取范围和加工逻辑本身(extractors、聚合规则原样复用,只换数据来源和执行顺序);
  • 不动 test_xuangubao / 内网 GitLab 链路(后续视本期效果另议);
  • 本期不做 Parquet 增量分片(二期展望,§7)。

3. 决策记录(2026-09-27)

# 决策 结论 理由
1 数据通道 GitHub Releases WeKnora 可直连公网 GitHub;release 附件不占 git 历史、不受 100MB 单文件限制(附件上限 2GB)、gh CLI 现成好用,不需要引入任何新组件
2 改造范围 仅 xgb_wiki 链路 860MB 痛点的所在;先在这里跑通,再考虑推广
3 .git 860MB 历史 两条路径都写进方案(§6),执行时再定 历史重建不可逆、影响所有已存在的克隆,应等新通道跑稳后再动
4 同步链路分层(v1.1) 原表先镜像、ETL 后置加工 稳定:镜像只做原样搬运,越简单越不容易出错;解耦:改加工逻辑、全量重算都不再要求 VPN 和 MySQL 在线;可回溯:staging 保留原始行
5 跨设备同步机制(v1.3) staging 分片版本化:按主键 id 分片 + 内容寻址命名 + 火山引擎 TOS 通道(§5.7);成品库维持整库快照 换设备只拉缺失分片(MB 级),不再全量;否决备选——Litestream 定位常驻库容灾、消费端要装工具,与批量同步场景不匹配;Dolt 要换数据库引擎;DVC 是文件级去重、SQLite 改一点就是全新对象,没有增量;对象存储选型:用户已有火山引擎账号,指定使用 TOS 桶 arkclaw-tos-2101988067-cn-shanghai(cn-shanghai,官方 Python SDK 接入),替代最初考虑的 Cloudflare R2

4. 方案总览

现状:  MySQL ──(抽取+加工挤在一步)──→ xgb_wiki.db ──→ git 提交 → GitHub(860MB)
                                                    ├→ WeKnora: clone 整仓
                                                    └→ CI: checkout 瘦版db → 静态站

目标:  MySQL ──①镜像──→ staging.db ──②加工ETL──→ xgb_wiki.db ─┐
               (原样搬运)  (本地中间产物)      (逻辑不变)    + graph.db ─┴─→ git(不再进数据)
                                                                      │
                                                      ③publish_data.py → GitHub Release(zip+manifest)
                                                                          ├→ WeKnora: gh release download
                                                                          └→ CI: 发版即构建,下载 → 静态站

三段互不牵连、各记各的进度:①失败不影响已完成过的②③;②失败时①的进度已经保存,修好后单独续跑②;③只在②成功后执行。

5. 详细设计

5.1 同步链路分层(v1.1 新增)

staging.db 是什么

  • 位置:xgb_wiki/data/staging.db(独立文件,不进 git、也不对外发布——它只是本地中间产物,源头永远是 MySQL,真丢了全量重拉一遍即可);
  • 同步范围:ETL 依赖的全部 5 张源表。staging 里的表名、字段名与 MySQL 源表完全一致——建表不靠手写,而是运行时读 SHOW COLUMNS 动态生成,源表加字段自动跟上,从根上杜绝两边对不齐(src/pipeline/sync.py 的 REQUIRED_MYSQL_COLUMNS 用作环境自检下限:缺列报错,多列照搬);
  • 字段全部原样搬运(包括 content 原文,供加工层反复重算和回溯);
MySQL 源表(= staging 表名) 数据形态 同步方式
live_tuoshui_news 大表,含全文 增量组:记住已同步到的最大 id,每次只拉新增行;源端已删除的行(is_deleted=1)每轮把删除标记同步过来
tuoshui_msgs 大表,含全文 增量组:同上,按最大 id 拉新增
live_tuoshui_tag_related 小关联表 整表组:每轮整表刷新(行数小、毫秒级;将来涨到十万行级再改增量)
tuoshui_tag_related 小关联表 整表组:同上
plate_rank_infos 小维表(板块) 整表组:同上
  • 同步进度记在 staging 自己的 staging_meta 表里(增量组两表各一条最大 id + last_synced_at),用法和现有 etl_meta 一致;
  • 镜像层只做原样搬运,不含任何解析加工——逻辑越简单越不容易出错,这正是稳定性诉求的落点;
  • 每次同步结束自动导出 data/staging_schema.json(表、字段、类型、行数、同步进度)——给人看的结构说明在 docs/data-sync/staging-schema.md,给 agent 看的机读版就是这个 json,两个都随工具一并交付。

新脚本 scripts/sync/sync_staging.py

  • 单文件、不依赖项目内其他模块(只依赖 pymysql / dotenv / 标准库),可以整体拷走独立使用;
  • --full:全量拉取,重建 staging;默认增量:只拉上次同步位置之后的新增行,并同步删除标记;
  • --check:只做环境检查(MySQL 可达、源表字段满足下限、磁盘空间够用),不动任何数据;
  • --selftest:离线自检(内置模拟 MySQL → 临时 staging,不连网不碰真实库),供人和 agent 拿到工具后先验证一遍;
  • 同步失败时不更新同步进度,下次自动从原位置重试,做法与现有 sync_data.py 保持一致;
  • 同步结果记入 staging 的 staging_log 表(格式对齐现有 sync_log)。

加工层改造(src/pipeline/etl.py)

  • build_into(conn, my) / apply_incremental(conn, my) 的第二个参数从 MySQL 连接换成 staging SQLite 连接;因 staging 表名与源表完全一致,LIVE_SQL/DIGEST_SQL 的表名、JOIN 一行都不用改;
  • 抽取、解析、聚合逻辑一行不动(extractors、chain_map、权重规则原样复用);
  • 增量机制照旧(etl_meta 仍记录加工已处理到哪一条);全量重建改为从 staging 读全量——从此不再要求 MySQL 在线。

执行顺序(scripts/sync/sync_data.py 串联)

sync_data.py = ① 镜像(MySQL → staging.db) → ② 加工(staging → xgb_wiki.db) →(可选)③ 发布

三段各记各的进度、互不牵连;哪段失败就明确提示从哪段接着跑。②修好重跑时,①发现没有新数据会自动跳过。

5.2 发布:新脚本 scripts/ops/publish_data.py

与同步脚本分开(同步管把数据搬下来,发布管把数据发出去),同步完成后执行(也可以由 sync_data.py 串联调用)。

流程:

  1. 发布前检查:xgb_wiki.db 的 sync_log 最新一条为 success,且数据截止时间在 24 小时内(防止把旧数据当成新版本发出去);
  2. 把两个库复制到临时目录(不动工作区文件,Web 服务照常运行);
  3. 对副本执行 VACUUM(回收已删行的空间,体积更稳定);
  4. 打包:xgb_wiki.db.zip、graph.db.zip(SQLite 压缩比约 2:1,预计合计 30–50MB);
  5. 生成 manifest.json:
{
  "data_as_of": "2026-09-26",
  "published_at": "2026-09-27T10:00:00+08:00",
  "source_commit": "<发布时本地 git HEAD>",
  "dbs": {
    "xgb_wiki.db": {"md5": "...", "rows": {"report": 7981, "theme": 606}},
    "graph.db":    {"md5": "...", "size_bytes": 4833280}
  }
}
  1. 发布:
gh release create "data-2026-09-27" \
    xgb_wiki.db.zip graph.db.zip manifest.json \
    --title "数据 2026-09-27" \
    --notes "数据截至 2026-09-26 · report 7,981 篇 · theme 606" \
    --latest
  1. 清理旧版本:保留最近 8 个 data-* 版本,更早的 gh release delete <tag> --yes --cleanup-tag。

发布认证(本机):gh auth login 一次性配好;没有 gh 时可退回 GITHUB_TOKEN + REST API(POST /repos/{owner}/{repo}/releases)。

发布完整库(含派生表),不含 staging.db:取数方解压即可使用,不需要 Python 环境重算;CI 同理省去重建步骤。staging 是纯中间产物,不对外。

5.3 git 侧变更

文件 变更
.gitignore 新增 data/xgb_wiki.db、data/graph.db、data/staging.db(wal/shm 此前已忽略)
.gitattributes 删除 data/xgb_wiki.db filter=slimdb 一行
scripts/ops/git_slim_db.py 退役:保留一个发布周期后删除(留作回退保障)
docs/ops/operations.md、docs/data-sync/sync.md 更新为两段式同步 + Release 分发说明

生效后,数据同步不再产生提交;chore(data): 数据同步至... 这类 commit 归零。工作区文件不受影响,本地 Web/ETL 照常读写。

回退保障:filter 配置和脚本在观察期内保留——需要回退时,撤销上述变更即可恢复原模式,完全可逆。

5.4 CI 适配(deploy-site.yml)

触发:两种方式——

on:
  push:
    branches: [master]
    paths: ['scripts/**', 'src/**', 'static/**', 'templates/**',
            'docs/**', 'mkdocs.yml', 'requirements.txt',
            '.github/workflows/deploy-site.yml']
  release:
    types: [published]      # publish_data.py 发版即构建
  workflow_dispatch:         # 保留手动触发

(data/** 从 paths 里移除——这个路径不再有提交。)

新增下载步骤(checkout 之后、refresh 之前):

- name: 下载最新数据
  run: |
    gh release download --repo seuzxh/xgb_wiki --pattern '*.zip' --dir /tmp/db
    unzip -o /tmp/db/xgb_wiki.db.zip -d data/
    unzip -o /tmp/db/graph.db.zip -d data/
    python scripts/ops/verify_manifest.py /tmp/db  # 校验 data_as_of 非空、md5 与解压后文件一致(随 publish 脚本一并交付)
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

其余调整:

  • "重建观点单元"步骤(refresh_daily.py)改为可选(发布的是全量库,默认跳过;保留 workflow_dispatch 输入参数 refresh=true 供手动重算场景);
  • 加 concurrency 配置:同一时间只跑一次构建,避免数据发版和代码推送撞车、重复部署。

5.5 取数:WeKnora 服务器

一次性配置:

# 细粒度 PAT:只读当前仓库 contents(尽量缩小泄漏影响面)
gh auth login              # 或 export GH_TOKEN=<fine-grained-pat>

每次取数(替代原来的 clone 流程):

gh release download --repo seuzxh/xgb_wiki --pattern '*.zip' --dir /tmp/xgb
unzip -o /tmp/xgb/xgb_wiki.db.zip -d /path/to/weknora/data/
unzip -o /tmp/xgb/graph.db.zip    -d /path/to/weknora/data/
python -m json.tool /tmp/xgb/manifest.json   # 人工核对 data_as_of / md5

耗时估计:下载 30–50MB 加解压,分钟级;对比 clone 860MB 的十分钟级加磁盘占用。

5.6 保留策略与存储开销

  • Release 保留最近 8 个(按当前每周更新的节奏约 2 个月滚动;若转为每日更新则调到 30 个),总量约 400MB 封顶,旧版删除;
  • GitHub release 附件不占 .git 体积、不受 git 的 100MB 单文件限制(附件上限 2GB),仓库存储配额绰绰有余;
  • GitHub 上的 release 附件天然就是异地备份,接替了原来"每个快照都躺在 git 历史里"的备份职能。

5.7 staging 分片版本化:跨设备增量同步(v1.3 新增)

目标场景:换一台开发设备(或新同事/新 agent 加入)时,不连 VPN、不做 MySQL 全量拉取,只补齐缺失分片就拿到最新 staging;日常同步后其他设备秒级跟进。

核心思想(借 git 的思想,不用 git 这个工具):

  • 分片文件不可变——发布过的分片永不改写;
  • 文件名内容寻址(含 md5 前缀)——内容变才会产生新文件;
  • staging_manifest.json 记录"当前版本由哪些分片组成"——相当于 commit 清单;
  • 拉取端对比本地与远端清单,只下载缺失分片——相当于增量 fetch。
同步机:  staging.db ──导出──→ 分片文件(不可变)+ manifest ──上传──→ TOS
其他设备:              对比本地/远端 manifest ──只拉缺失分片──→ 合并回本地 staging.db
                                                      └─(或 DuckDB 直接查分片目录,不落 SQLite)

分片规则

对象 规则 文件名形态 不可变性如何保证
live_tuoshui_news / tuoshui_msgs(追加型大表) 按主键 id 每 2 万一片(首片从 1 起;粒度按"单片 10–30MB"实测定档) {表}_{起始id}_{md5前8位}.parquet id 单调递增,新增永远落在最新一片;已归档的旧片不碰
两张 tag 关联表 + plate_rank_infos(小表) 整表一片 {表}_full_{md5前8位}.parquet 内容寻址:内容变了生成新文件名,旧文件成为可清理的历史版本
源端删除(is_deleted=1) 不改写旧分片,单独维护 deleted_ids.json(各表已删主键全量清单,每次导出整体刷新) deleted_ids_{md5前8位}.json 全量清单幂等,无需按顺序回放;清单很小(删除量少)

按 id 而不按月份分片的原因:源端可能补录/回填旧数据,按月分片就得改写历史月份的文件,破坏不可变;按 id 切则追加永远在最新片。每片的数据日期范围记进 manifest,按时间查场景用 manifest 先定位分片再读。

manifest 结构(staging_manifest.json,每次导出整体刷新):

{
  "version": "2026-09-29T20:00:00",
  "watermark": {"live_tuoshui_news": 51234, "tuoshui_msgs": 31888},
  "shards": [
    {"table": "live_tuoshui_news", "kind": "id-range", "id_from": 1, "id_to": 19999,
     "date_min": "2019-01-03", "date_max": "2025-11-20",
     "file": "live_tuoshui_news_1_a1b2c3d4.parquet", "md5": "…", "rows": 18342},
    {"table": "plate_rank_infos", "kind": "full", "file": "plate_rank_infos_full_e5f6a7b8.parquet", "md5": "…", "rows": 606}
  ],
  "deleted_ids_file": "deleted_ids_9c8d7e6f.json"
}

传输通道:火山引擎 TOS 对象存储(桶 arkclaw-tos-2101988067-cn-shanghai,cn-shanghai,原生端点 tos-cn-shanghai.volces.com;桶保持私有 ACL)——对象存储与内容寻址天然契合:新分片直接上传,不存在的对象自动跳过。接入用官方 Python SDK(包名 tos):TosClientV2 的 put_object_from_file / download_file / list_objects_type2 覆盖分片上传下载与清单比对。分片统一放桶内 xgb-wiki/staging/ 前缀下。凭证分设:同步机持该桶读写的 IAM 子用户 AK/SK,取数设备持只读 AK/SK,均放 .env 不进 git。备选通道:GitHub 单一滚动 release(gh release upload 到固定 tag,新分片文件名不同不会被覆盖),适合完全不想管对象存储的情况。

✅ 通道已配置并验证(2026-09-29,官方 tos SDK):凭证写入 xgb_wiki/.env(TOS_* 六键,端点为原生域名),TosClientV2 全链路(does_bucket_exist / list_objects_type2 / put_object / get_object / delete_object / does_object_exist)实测通过。SDK 实施要点:list_objects_type2 返回对象不可直接迭代,清单在 .contents、翻页看 .is_truncated。若走 S3 兼容协议(boto3,应急备选):其端点 tos-s3-cn-shanghai.volces.com 仅支持 virtual-hosted style,需显式 s3={"addressing_style": "virtual"},path style 一律 403。

工具形态:并入 sync_staging.py 子命令(复用其连接与 schema 逻辑)——

python scripts/sync/sync_staging.py --export-shards   # 同步后:导出新增/变更分片+manifest,并上传 TOS
python scripts/sync/sync_staging.py --pull            # 其他设备:对比清单,只下缺失分片
python scripts/sync/sync_staging.py --assemble        # 分片 + deleted_ids → 合并回本地 staging.db

--assemble 按 manifest 顺序把分片写入 SQLite(复用动态建表),最后应用 deleted_ids.json 刷删除标记。依赖 pyarrow(导出/合并)与 rclone 或 tosutil(上传,S3 协议),仅同步机与开发设备需要,WeKnora 等 Release 消费端不受影响。

成品库为什么不分片:xgb_wiki.db 的 theme_daily/theme_stock/occurrence_count/decay_score 等是全库聚合口径,按时间切会破坏语义;且取数方"解压即用"是硬需求。50MB 整库快照、周更节奏、保留 8 版,传输量可控。若未来传输量真成为瓶颈,再把 report 等事实表分片化、聚合表做小体积旁车文件——二期再议。

6. .git 860MB 历史处理(两条路径,执行时再定)

路径 A:止血保留(零风险)

什么都不做。新的快照不再产生,860MB 冻结在原地。clone 依旧慢,但不再恶化。 适合:将来没有新克隆需求,或历史里还有要追溯的内容。

路径 B:orphan 重建(干净,但需要协调)

动手条件(建议同时满足):数据通道稳定运行 ≥ 2 周;先处理好 2 个未合并的 trae 旧分支(历史重建会让远端旧分支与 master 失去共同祖先)。

步骤:

git checkout --orphan fresh-master
git add -A && git commit -m "chore: rebuild history (data moved to GitHub Releases)"
git branch -M fresh-master master
git push -f origin master
# 旧克隆全部作废,重新 clone;CI 自动触发(push master)

结果:仓库回到纯代码体积(预计 ~10MB 量级)。不可逆:旧历史(含全部数据快照)从远端消失——执行前确认不会再需要从 git 历史里恢复旧数据(Release 里已保留近 8 版快照可回溯)。

决策时点:P2 观察期结束时(见 §7)。

7. 实施阶段

阶段 内容 验收
P1a 同步分层(1 天) §5.1:staging.db + sync_staging.py、ETL 数据源切换、sync_data.py 两段串联 §8.1–8.3
P1b 分发通道(半天) §5.2–5.5:发布脚本、gitignore/gitattributes、CI 改造、WeKnora 取数命令 §8.4–8.7
P1c staging 分片(1 天,v1.3 新增) §5.7:--export-shards/--pull/--assemble 子命令、TOS 凭证与 xgb-wiki/staging/ 前缀、manifest §8.8–8.10
P2 观察期(≥2 周) 按原节奏执行同步 → 发布 → 分片导出;盯 CI 成功率、release 清理与 TOS 增量体积 连续 8+ 次发布无事故;.git 零增长
P3 历史决策(可选) 按路径 A/B 处理 860MB 历史 若选 B:新 clone < 30 秒
二期展望 成品库事实表分片化(仅当整库快照传输量成为瓶颈时启动,见 §5.7 末段);LLM 辅助的同步异常自愈 届时另立设计

P1a 和 P1b 相互独立:分层改造不依赖 GitHub 通道,做完就能享受"加工离线重算"的好处。

8. 验收标准

同步分层(P1a):

  1. sync_staging.py --full 之后,staging 五张镜像表行数与 MySQL 源表(is_deleted=0 口径)完全一致;--selftest 离线自检通过;staging_schema.json 与库内实际结构一致;
  2. 两段式同步跑完后,xgb_wiki.db 的 report/theme 等表行数与改造前单段 ETL 的产出完全一致(相同数据截止点下对比,不允许有差异);
  3. 断开 VPN 的状态下执行 ETL 全量重建(--full 读 staging)成功——证明加工从此不依赖 MySQL 在线。

分发通道(P1b):

  1. publish_data.py 发布后 5 分钟内,GitHub 出现新 release(zip + manifest),且 .git 无新增内容(git count-objects -v 前后一致);
  2. CI 由 release 发布自动触发,构建并部署 pages.dev 成功;
  3. WeKnora 不执行 clone,2 分钟内完成下载、校验 md5、投入使用;
  4. 新 release zip ≤ 60MB;回退演练:撤销 §5.3 变更后,原 git 分发模式可正常提交推送。

staging 分片(P1c):

  1. 同步两次、导出两次分片后,TOS 上第二次只新增"最新分片 + 变更小表 + manifest + deleted_ids",已归档旧分片字节不变(md5 不变);
  2. 在另一台设备(或清空本地分片目录模拟)执行 --pull --assemble,产出的 staging.db 行数与同步机一致(含删除标记一致);
  3. 分片总链路传输量:第二次同步的增量分片 < 10MB(对比整库快照 50MB+)。

9. 风险与对策

风险 对策
分层切换初期,staging 与 ETL 的同步进度对不齐 切换时先 --full 把两边进度对齐;此后两段各自记录、互不覆盖
tag 关联表增量漏拉(旧消息重新打标) 跟随主表补拉 + 每周整表核对(§5.1 表);核对步骤对比源/目标行数
staging.db 单文件持续增长(含全文 content) 本地中间产物,定期 VACUUM;必要时按时间归档裁剪(保留近 N 个月)
private 仓库附件下载需要 token WeKnora 用细粒度只读 PAT;CI 用内置 GITHUB_TOKEN
把不该发的数据状态发了出去 发布前检查 sync_log + manifest 两端 md5 校验;--latest 只在全部成功后打
通道切换空窗期数据断供 保留 git 模式直到首个 release 验收通过,再删 filter(顺序:先建新通道、后拆旧通道)
release 与代码版本对不上 manifest 记录 source_commit;CI 下载后同时校验 manifest
gh CLI 未安装或未登录(本机或 WeKnora) 前置检查纳入脚本 --check 模式;备好 REST API 退路
误删还在使用的 release 保留 8 版(约 2 个月滚动);删除动作记日志
源端补录旧 id 区间的数据(已归档分片出现新增行) 分片导出时按 id 区间对账:发现已归档区间行数变化 → 生成该区间的新版本分片(新 md5 文件名)并更新 manifest,旧版本文件待清理——内容寻址自动兜底
TOS 凭证泄漏 上传/下载分设 token:上传仅同步机持有;下载可用 bucket 只读或短时效 presigned URL
分片目录无限增长 内容寻址天然去重 + TOS 生命周期规则(旧版本分片 90 天自动清理);总量预警纳入 manifest 检查

附:本方案讨论过程见 2026-09-27 会话(现状扫描 → 架构图 data-sync-architecture.html → 三项决策 → v1 → 同步分层变更 → v1.1)。