diff --git a/app/backend/application.py b/app/backend/application.py index c7bb74d..8aff479 100644 --- a/app/backend/application.py +++ b/app/backend/application.py @@ -54,11 +54,8 @@ from backend.features.accounts.http import AccountHttpMixin from backend.features.accounts.security import SecretVault from backend.features.accounts.service import AccountService from backend.features.pools import PoolServiceMixin +from backend.features.rotation import RotationServiceMixin from backend.features.sentiment import SentimentServiceMixin -from backend.features.sentiment.engine import ( - build_sentiment_history, - latest_contiguous_history, -) from backend.features.system import SystemHttpMixin from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue @@ -140,7 +137,12 @@ MENTOR_ETF_UNIVERSE = ( ) -class DashboardService(MarketServiceMixin, SentimentServiceMixin, PoolServiceMixin): +class DashboardService( + MarketServiceMixin, + SentimentServiceMixin, + PoolServiceMixin, + RotationServiceMixin, +): def __init__(self) -> None: runtime = load_runtime_settings() self.vault = SecretVault(runtime.encryption_key) @@ -734,158 +736,6 @@ class DashboardService(MarketServiceMixin, SentimentServiceMixin, PoolServiceMix - def rotation_history(self, trade_date: str, limit: int = 9) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - # 板块轮动固定展示最近 9 个交易日,按由近到远排列。 - limit = 9 - snapshots = self.database.list_snapshot_payloads(normalized_date, 240) - by_trade_date: dict[str, dict[str, Any]] = {} - for snapshot in snapshots: - meta = snapshot.get("meta") or {} - actual_date = str(meta.get("trade_date") or snapshot.get("_snapshot_date") or "") - compact_date = actual_date.replace("-", "") - if len(compact_date) == 8: - by_trade_date[compact_date] = snapshot - - sentiment_dates = { - str(row.get("trade_date") or "").replace("-", "") - for row in latest_contiguous_history(build_sentiment_history(snapshots)) - } - ordered_dates = sorted( - date_key for date_key in by_trade_date - if not sentiment_dates or date_key in sentiment_dates - )[-limit:][::-1] - rows = [] - for date_key in ordered_dates: - snapshot = by_trade_date[date_key] - sector_context = { - str(item.get("name") or ""): item - for item in snapshot.get("sectors") or [] - } - sectors = [] - for item in (snapshot.get("sector_rotation") or [])[:12]: - name = str(item.get("name") or "").strip() - context = sector_context.get(name, {}) - sectors.append( - { - "name": name, - "rank": int(item.get("rank") or len(sectors) + 1), - "trend": item.get("trend") or "持平", - "count": int(item.get("count") or 0), - "strength": float(item.get("strength") or context.get("strength") or 0), - "change": float(context.get("change") or 0), - "leader": item.get("leader") or context.get("leader") or "--", - } - ) - rows.append( - { - "trade_date": f"{date_key[:4]}-{date_key[4:6]}-{date_key[6:]}", - "sectors": sectors, - } - ) - return { - "trade_date": rows[0]["trade_date"] if rows else normalized_date, - "available_days": len(ordered_dates), - "requested_days": limit, - "rows": rows, - } - - def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - sector_name = validate_text(sector_name, "板块名称", 60, required=True) - dashboard = self.get_dashboard(normalized_date) - actual_date = normalize_date( - str((dashboard.get("meta") or {}).get("trade_date") or normalized_date) - ) - cache_key = f"{actual_date}:{sector_name}" - cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key) - if cached: - cached["meta"] = {**(cached.get("meta") or {}), "cached": True} - return cached - if not self.configured: - raise ValueError("板块成分数据暂不可用。") - - representative = next( - ( - item for item in dashboard.get("limits") or [] - if str(item.get("sector") or "").strip() == sector_name - ), - None, - ) - if not representative: - raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。") - raw_code = str(representative.get("ts_code") or representative.get("code") or "") - if "." in raw_code: - ts_code = raw_code - elif raw_code.startswith(("4", "8", "92")): - ts_code = f"{raw_code}.BJ" - elif raw_code.startswith(("6", "68", "90")): - ts_code = f"{raw_code}.SH" - else: - ts_code = f"{raw_code}.SZ" - client = self._tushare_client() - try: - industry = client.sw_stock_industry(ts_code, actual_date) - sector_code = str(industry.get("l2_code") or "") - members = client.sw_sector_members(sector_code, actual_date) - except TushareError as exc: - raise ValueError(f"该板块成分股暂不可用:{exc}") from exc - - daily_rows = self.database.daily_bars_for_date(actual_date) - if len(daily_rows) < 1000: - try: - daily_rows = client.query( - "daily", - {"trade_date": actual_date}, - "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", - ) - if daily_rows: - self.database.upsert_daily_bars(daily_rows) - except TushareError: - daily_rows = self.database.daily_bars_for_date(actual_date) - daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows} - rows = [] - for member in members: - member_code = str(member.get("ts_code") or "") - quote = daily_map.get(member_code) or {} - rows.append( - { - "code": member_code.split(".")[0], - "ts_code": member_code, - "name": str(member.get("name") or "--"), - "change": quote.get("pct_chg"), - "open": quote.get("open"), - "close": quote.get("close"), - "amount_billion": ( - round(float(quote.get("amount") or 0) / 100000, 2) - if quote else None - ), - "quoted": bool(quote), - } - ) - rows.sort( - key=lambda item: ( - bool(item.get("quoted")), - float(item.get("change") or -999), - float(item.get("amount_billion") or 0), - ), - reverse=True, - ) - result = { - "meta": { - "trade_date": self._display_compact_date(actual_date), - "sector_name": str(industry.get("l2_name") or sector_name), - "sector_code": sector_code, - "member_count": len(rows), - "quoted_count": sum(bool(item.get("quoted")) for item in rows), - "cached": False, - }, - "rows": rows, - } - self.database.save_data_snapshot( - "rotation_sector_members_v1", cache_key, "tushare", result - ) - return result def status(self) -> dict[str, Any]: llm_access = self.llm_access_status() diff --git a/app/backend/features/rotation/__init__.py b/app/backend/features/rotation/__init__.py new file mode 100644 index 0000000..13128d4 --- /dev/null +++ b/app/backend/features/rotation/__init__.py @@ -0,0 +1,5 @@ +"""Sector rotation history and constituent detail feature.""" + +from .service import RotationServiceMixin + +__all__ = ["RotationServiceMixin"] diff --git a/app/backend/features/rotation/service.py b/app/backend/features/rotation/service.py new file mode 100644 index 0000000..f173667 --- /dev/null +++ b/app/backend/features/rotation/service.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from typing import Any + +from backend.bootstrap.config import normalize_date, validate_text +from backend.data.providers.tushare_client import TushareError +from backend.features.sentiment.engine import ( + build_sentiment_history, + latest_contiguous_history, +) + + +class RotationServiceMixin: + def rotation_history(self, trade_date: str, limit: int = 9) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + # 板块轮动固定展示最近 9 个交易日,按由近到远排列。 + limit = 9 + snapshots = self.database.list_snapshot_payloads(normalized_date, 240) + by_trade_date: dict[str, dict[str, Any]] = {} + for snapshot in snapshots: + meta = snapshot.get("meta") or {} + actual_date = str(meta.get("trade_date") or snapshot.get("_snapshot_date") or "") + compact_date = actual_date.replace("-", "") + if len(compact_date) == 8: + by_trade_date[compact_date] = snapshot + + sentiment_dates = { + str(row.get("trade_date") or "").replace("-", "") + for row in latest_contiguous_history(build_sentiment_history(snapshots)) + } + ordered_dates = sorted( + date_key for date_key in by_trade_date + if not sentiment_dates or date_key in sentiment_dates + )[-limit:][::-1] + rows = [] + for date_key in ordered_dates: + snapshot = by_trade_date[date_key] + sector_context = { + str(item.get("name") or ""): item + for item in snapshot.get("sectors") or [] + } + sectors = [] + for item in (snapshot.get("sector_rotation") or [])[:12]: + name = str(item.get("name") or "").strip() + context = sector_context.get(name, {}) + sectors.append( + { + "name": name, + "rank": int(item.get("rank") or len(sectors) + 1), + "trend": item.get("trend") or "持平", + "count": int(item.get("count") or 0), + "strength": float(item.get("strength") or context.get("strength") or 0), + "change": float(context.get("change") or 0), + "leader": item.get("leader") or context.get("leader") or "--", + } + ) + rows.append( + { + "trade_date": f"{date_key[:4]}-{date_key[4:6]}-{date_key[6:]}", + "sectors": sectors, + } + ) + return { + "trade_date": rows[0]["trade_date"] if rows else normalized_date, + "available_days": len(ordered_dates), + "requested_days": limit, + "rows": rows, + } + + def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + sector_name = validate_text(sector_name, "板块名称", 60, required=True) + dashboard = self.get_dashboard(normalized_date) + actual_date = normalize_date( + str((dashboard.get("meta") or {}).get("trade_date") or normalized_date) + ) + cache_key = f"{actual_date}:{sector_name}" + cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key) + if cached: + cached["meta"] = {**(cached.get("meta") or {}), "cached": True} + return cached + if not self.configured: + raise ValueError("板块成分数据暂不可用。") + + representative = next( + ( + item for item in dashboard.get("limits") or [] + if str(item.get("sector") or "").strip() == sector_name + ), + None, + ) + if not representative: + raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。") + raw_code = str(representative.get("ts_code") or representative.get("code") or "") + if "." in raw_code: + ts_code = raw_code + elif raw_code.startswith(("4", "8", "92")): + ts_code = f"{raw_code}.BJ" + elif raw_code.startswith(("6", "68", "90")): + ts_code = f"{raw_code}.SH" + else: + ts_code = f"{raw_code}.SZ" + client = self._tushare_client() + try: + industry = client.sw_stock_industry(ts_code, actual_date) + sector_code = str(industry.get("l2_code") or "") + members = client.sw_sector_members(sector_code, actual_date) + except TushareError as exc: + raise ValueError(f"该板块成分股暂不可用:{exc}") from exc + + daily_rows = self.database.daily_bars_for_date(actual_date) + if len(daily_rows) < 1000: + try: + daily_rows = client.query( + "daily", + {"trade_date": actual_date}, + "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", + ) + if daily_rows: + self.database.upsert_daily_bars(daily_rows) + except TushareError: + daily_rows = self.database.daily_bars_for_date(actual_date) + daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows} + rows = [] + for member in members: + member_code = str(member.get("ts_code") or "") + quote = daily_map.get(member_code) or {} + rows.append( + { + "code": member_code.split(".")[0], + "ts_code": member_code, + "name": str(member.get("name") or "--"), + "change": quote.get("pct_chg"), + "open": quote.get("open"), + "close": quote.get("close"), + "amount_billion": ( + round(float(quote.get("amount") or 0) / 100000, 2) + if quote else None + ), + "quoted": bool(quote), + } + ) + rows.sort( + key=lambda item: ( + bool(item.get("quoted")), + float(item.get("change") or -999), + float(item.get("amount_billion") or 0), + ), + reverse=True, + ) + result = { + "meta": { + "trade_date": self._display_compact_date(actual_date), + "sector_name": str(industry.get("l2_name") or sector_name), + "sector_code": sector_code, + "member_count": len(rows), + "quoted_count": sum(bool(item.get("quoted")) for item in rows), + "cached": False, + }, + "rows": rows, + } + self.database.save_data_snapshot( + "rotation_sector_members_v1", cache_key, "tushare", result + ) + return result diff --git a/app/tests/test_preservation_slice_ladder_rotation.py b/app/tests/test_preservation_slice_ladder_rotation.py new file mode 100644 index 0000000..63e6205 --- /dev/null +++ b/app/tests/test_preservation_slice_ladder_rotation.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import ast +import hashlib +import unittest +from pathlib import Path + + +APP_ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_ROOT = APP_ROOT.parent + +ROTATION_METHODS = { + "rotation_history", + "rotation_sector_members", +} +LADDER_ROTATION_BUILDERS = { + "_build_ladders", + "_build_sector_rotation", +} + + +def class_methods(path: Path, class_name: str) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + owner = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == class_name + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in owner.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def top_level_functions(path: Path) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + return { + node.name: ast.dump(node, include_attributes=False) + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name in LADDER_ROTATION_BUILDERS + } + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase): + def test_rotation_service_methods_are_exact_original_ast(self) -> None: + original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService") + migrated = class_methods( + APP_ROOT / "backend" / "features" / "rotation" / "service.py", + "RotationServiceMixin", + ) + self.assertEqual(set(migrated), ROTATION_METHODS) + for name in sorted(ROTATION_METHODS): + self.assertEqual(migrated[name], original[name], name) + + def test_dashboard_service_no_longer_duplicates_rotation_methods(self) -> None: + remaining = class_methods( + APP_ROOT / "backend" / "application.py", "DashboardService" + ) + self.assertTrue(ROTATION_METHODS.isdisjoint(remaining)) + + def test_ladder_and_rotation_builders_are_exact_original_ast(self) -> None: + self.assertEqual( + top_level_functions(ORIGINAL_ROOT / "tushare_client.py"), + top_level_functions( + APP_ROOT / "backend" / "data" / "providers" / "tushare_client.py" + ), + ) + + def test_api_and_frontend_assets_are_unchanged(self) -> None: + for relative in ( + "config/api.config.json", + "static/index.html", + "static/app.js", + "static/styles.css", + "static/pages/ladder/page.js", + "static/pages/rotation/page.js", + ): + self.assertEqual( + sha256(APP_ROOT / relative), + sha256(ORIGINAL_ROOT / relative), + relative, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/migration/evidence/slice-04/README.md b/docs/migration/evidence/slice-04/README.md new file mode 100644 index 0000000..80a429c --- /dev/null +++ b/docs/migration/evidence/slice-04/README.md @@ -0,0 +1,58 @@ +# 切片 04:市场天梯与板块轮动 + +> 基线:`b3555d2`(切片 03) +> 回档标签:`xiaobai-preservation-slice-04-20260731` +> 结论:源码、API、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片从原版副本机械移动板块轮动服务,没有从 `next/` 取用代码,也没有修改天梯、轮动的计算、 +排序、展开、配色、页面结构或交互。 + +| 原位置 | 新的唯一实现位置 | 原位置兼容 | +|---|---|---| +| `app/backend/application.py` 的 2 个轮动方法 | `app/backend/features/rotation/service.py` | `DashboardService` 继承 `RotationServiceMixin` | +| Tushare Provider 的天梯与轮动构造函数 | 保持 `app/backend/data/providers/tushare_client.py` | 切片 02 已归位的公共数据实现 | + +市场天梯没有独立后端 API 或第二套计算,直接展示 `/api/dashboard` 中原 Tushare 实现生成的 +`ladders`;因此没有为目录形式建立空的天梯服务。 + +## 2. 等价证据 + +- `test_preservation_slice_ladder_rotation.py` 对 2 个轮动服务方法逐项执行无位置信息 AST 比较, + 全部与根目录原版 `server.py` 完全相同。 +- `_build_ladders` 与 `_build_sector_rotation` 两个原数据构造函数的 AST 与根目录原版完全相同。 +- 原版 `8784` 与迁移版 `8785` 在相同账号、日期和数据库副本上返回的天梯数据及 9 日轮动历史 + JSON 逐字段完全相同。 +- 成分股接口在当前外部网络状态下两版均返回 HTTP 400、`bad_request` 和相同的 + `该板块成分股暂不可用:Tushare request failed:`,没有改变错误或增加静默降级。 +- `config/api.config.json`、API 路径、鉴权、数据库 schema 和 `app/static/` 未修改。 + +## 3. 真实运行检查 + +- 市场天梯:8 个层级(含断层)、18 个首屏股票单元格、3 个结构分析模块正常;1920×1080 下 + 页面宽度无溢出,首板展开入口保留。 +- 板块轮动:9 个交易日、每日 Top 12 共 108 个板块单元格、由远到近/由近到远两个排序入口正常; + 1920×1080 下页面宽度无溢出并保持全页滚动。 +- 日间模式页面控制台没有错误或警告。 +- `app-light-ladder-1920x1080.png` SHA-256: + `9e57d18d92e745fd92131f7bf08f21faaaa745476dd942cdaa2a703b9a7a303a`。 +- `app-light-rotation-1920x1080.png` SHA-256: + `03c092bb40bd0eb672136dff853abffc87e9790840107c2f22e6cf31d5f83c09`。 + +## 4. 自动验证 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 252 项通过 | +| `python -m unittest tests.test_preservation_slice_ladder_rotation -q` | 4 项通过 | +| 切片 02 至 04 与总览缓存专项集合 | 24 项通过 | +| `npx.cmd playwright test --reporter=dot` | 45 项通过 | +| `git diff --check` | 通过 | + +## 5. 保留边界 + +- 成分股接口依赖的日行情与因子持久化方法仍由原 `ReviewDatabase` 提供,因其同时服务智能选股, + 待切片 06 随完整共享职责归位。 +- 天梯和轮动前端资产保持原位,切片 10 再按页面职责归档;当前没有复制或改写。 +- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。 diff --git a/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png b/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png new file mode 100644 index 0000000..a3ddc25 Binary files /dev/null and b/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png differ diff --git a/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png b/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png new file mode 100644 index 0000000..344b821 Binary files /dev/null and b/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png differ diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json index ea722dc..e8f0f1e 100644 --- a/docs/migration/保真迁移状态.json +++ b/docs/migration/保真迁移状态.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-07-31T01:38:00+08:00", + "updated_at": "2026-07-31T01:57:00+08:00", "status": "active", "migration_mode": "behavior_preserving_source_migration", "source_of_truth": "current_original_webapp_runtime_and_source", @@ -9,10 +9,10 @@ "failed_roots": [ "next" ], - "current_slice": "slice-04-ladder-rotation", - "last_completed_slice": "slice-03-sentiment-pools-performance", - "last_checkpoint": "xiaobai-preservation-slice-03-20260731", - "next_action": "capture_slice-04_ladder_rotation_contracts_then_move_original_implementations", + "current_slice": "slice-05-auction-themes-popularity-dragon-tiger", + "last_completed_slice": "slice-04-ladder-rotation", + "last_checkpoint": "xiaobai-preservation-slice-04-20260731", + "next_action": "capture_slice-05_auction_theme_popularity_dragon_tiger_contracts_then_move_original_implementations", "authoritative_documents": [ "AGENTS.md", "docs/migration/原版保真迁移总纲.md", diff --git a/docs/migration/保真迁移账本.md b/docs/migration/保真迁移账本.md index b2af77d..3128a10 100644 --- a/docs/migration/保真迁移账本.md +++ b/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:正式迁移,切片03“情绪周期、五类股池与涨停表现”已完成 +> 当前状态:正式迁移,切片04“市场天梯与板块轮动”已完成 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -23,6 +23,7 @@ | 2026-07-31 | `xiaobai-preservation-slice-01-20260731` | 启动、HTTP、账号、会员与系统管理原实现归位 | 自动差分通过,进入切片02 | | 2026-07-31 | `xiaobai-preservation-slice-02-20260731` | 公共行情、搜索、详情、图表与数据适配原实现归位 | 自动与浏览器差分通过,进入切片03 | | 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 | +| 2026-07-31 | `xiaobai-preservation-slice-04-20260731` | 市场天梯与板块轮动原实现归位 | 自动、API与浏览器差分通过,进入切片05 | ## 资产处置登记 @@ -40,6 +41,8 @@ | `sentiment_engine.py` | 情绪周期计算 | 总览、轮动、选股 | 移动并保留兼容别名 | `app/backend/features/sentiment/engine.py` | 文件哈希与原版一致;248项Python与45项Playwright通过 | 已移动 | | `DashboardService`情绪及股池原因方法 | 业务服务 | 情绪页、五类股池、涨停表现 | 按职责机械移动 | `app/backend/features/sentiment/`、`app/backend/features/pools/` | 8个方法AST与原版一致;真实API完全一致 | 已移动 | | `ReviewDatabase`原因覆盖方法 | 持久化 | 股池原因人工覆盖 | 按职责机械移动 | `app/backend/features/pools/repository.py` | 2个方法AST与原版一致;数据库schema哈希一致 | 已移动 | +| `DashboardService`板块轮动方法 | 业务服务 | 板块轮动页 | 按职责机械移动 | `app/backend/features/rotation/service.py` | 2个方法AST、真实API与原版一致 | 已移动 | +| Tushare天梯与轮动构造函数 | 公共数据计算 | 市场天梯、板块轮动 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个构造函数AST与原版一致 | 已归位 | 处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。 @@ -87,6 +90,16 @@ - 回档:标签`xiaobai-preservation-slice-03-20260731`。 - 完整证据:`docs/migration/evidence/slice-03/README.md`。 +已完成切片:`slice-04-ladder-rotation`。 + +- 原版基线:提交`b3555d2`,即切片03回档点。 +- 迁移范围:2个板块轮动服务方法;市场天梯继续使用切片02已归位的原Tushare数据构造实现。 +- 兼容边界:`DashboardService`通过`RotationServiceMixin`保持所有原调用;天梯不制造空服务或第二套计算。 +- API与错误:天梯与9日轮动历史JSON完全一致;成分股两版均返回同一Tushare外部失败语义。 +- 验收:252项Python测试、4项切片源码等价测试、45项Playwright测试及两个真实页面流程通过。 +- 回档:标签`xiaobai-preservation-slice-04-20260731`。 +- 完整证据:`docs/migration/evidence/slice-04/README.md`。 + ## 决策记录 | 日期 | 决策 | 原因 |