数据分发方案设计:同步分层 + 数据出 git(GitHub Releases)
状态:待审阅(v1.4,2026-09-29 修订) 范围:仅 xgb_wiki 链路。test_xuangubao / 内网 GitLab 链路本期不动。 配套架构图:
E:\Quant_projs\4. xuangubao\data-sync-architecture.htmlv1.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. 目标与非目标
目标
- 同步链路分层:先把 MySQL 原表原样同步成本地 staging SQLite(逻辑极简、出错环节少),加工 ETL 改从 staging 读取、放在同步之后做——两段互不牵连、各记各的进度;
- 数据分发与代码版本控制分开:数据不再进 git,
.git停止增长; - WeKnora 及任何取数机器:一条命令拿到最新数据(分钟级,不用 clone);
- CI 改为数据发版后自动构建部署,触发方式从"收到数据提交"变为"数据发版";
- 解除 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 串联调用)。
流程:
- 发布前检查:
xgb_wiki.db的sync_log最新一条为success,且数据截止时间在 24 小时内(防止把旧数据当成新版本发出去); - 把两个库复制到临时目录(不动工作区文件,Web 服务照常运行);
- 对副本执行
VACUUM(回收已删行的空间,体积更稳定); - 打包:
xgb_wiki.db.zip、graph.db.zip(SQLite 压缩比约 2:1,预计合计 30–50MB); - 生成
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}
}
}
- 发布:
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
- 清理旧版本:保留最近 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):
sync_staging.py --full之后,staging 五张镜像表行数与 MySQL 源表(is_deleted=0口径)完全一致;--selftest离线自检通过;staging_schema.json与库内实际结构一致;- 两段式同步跑完后,
xgb_wiki.db的 report/theme 等表行数与改造前单段 ETL 的产出完全一致(相同数据截止点下对比,不允许有差异); - 断开 VPN 的状态下执行 ETL 全量重建(
--full读 staging)成功——证明加工从此不依赖 MySQL 在线。
分发通道(P1b):
publish_data.py发布后 5 分钟内,GitHub 出现新 release(zip + manifest),且.git无新增内容(git count-objects -v前后一致);- CI 由 release 发布自动触发,构建并部署 pages.dev 成功;
- WeKnora 不执行 clone,2 分钟内完成下载、校验 md5、投入使用;
- 新 release zip ≤ 60MB;回退演练:撤销 §5.3 变更后,原 git 分发模式可正常提交推送。
staging 分片(P1c):
- 同步两次、导出两次分片后,TOS 上第二次只新增"最新分片 + 变更小表 + manifest + deleted_ids",已归档旧分片字节不变(md5 不变);
- 在另一台设备(或清空本地分片目录模拟)执行
--pull --assemble,产出的 staging.db 行数与同步机一致(含删除标记一致); - 分片总链路传输量:第二次同步的增量分片 < 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)。