- 清单:新增 P0 章节(stuck 误报机制 + 6 条用例 + 撞锁守卫), 并补本批交付汇总表(含 5c 凭据轮换仍须外部操作) - 记忆:stuck 误判 → 自动重启的完整机制与修法;E2E 与采集器撞锁
60 KiB
FinDataHub 未解决问题清单
生成时间:2026-09-17 22:10 | 版本:v1.5.1(源码)/ v1.5.1-08-31 构建(部署位) 配套文档:
FinDataHub-代码与架构复查-2026-09-17.md(完整复查) 每条均含:具体表现 / 影响范围 / 复现步骤 / 修复建议。所有数字为本次实测,非估计。
摘要
2026-09-18 22:45 更新:第二批 6 项已完成(部署 / 凭据 / dividend 数据 / 周月线脱节 / 版本控制 / Swift 编译验证)。下表已标注状态;未完成项仍按原优先级排列。 已完成项的处置细节见
.workbuddy/memory/2026-09-18.md。
本轮完成情况
| 项 | 结果 |
|---|---|
| P0-1 已修代码未部署 | ✅ 已部署 v1.5.2(新包逐项核对修复标记,旧版留 597M 回滚点) |
P0-3 dividend 历史未修 |
✅ 19 行 → 216,364 行(根因:ann_date 是单日点查询而非区间,表结构上就装不下全量) |
| P0-5 凭据明文 | ✅ 移出 7 个 plist → ~/.config/findatahub/env(0600);全部 LaunchAgent 明文归零。⬜ 轮换需 Tushare 后台 |
| P0-7 版本控制失效 | ✅ 未跟踪 159→0;core 索引 0→45;App 74/74;CI 前三步本地实跑全通 |
| P1-10 周/月线脱节 | ✅ 数据补齐(周线 20260727→20260911、月线 20260731→20260831)+ 调度固化防复发 |
| P1-14 Swift 未编译 | ✅ swift build --disable-sandbox 通过(SwiftPM 自身调 sandbox-exec 才是阻塞点) |
2026-09-19 00:00 再更新:P0-5 的格式部分已完成(44,477,754 行 → 全部统一为 YYYYMMDD, 实测范围过滤命中率 376,056/946,221 → 951,774/951,774);其 collation 部分未动,原因见文末。
2026-09-18 23:00 更新:P1-8(门控死循环)、P1-11(搜索 total 契约)已完成并部署验证, 详见本节末「本批更正」与各自条目。
仍未解决、且仍在造成影响的:
- P0-6
trade_date同列混存两种格式 —— 2026 年 60%(570,165 行)会被标准范围过滤静默丢弃 - P0-4 Pipeline/门控不在生产路径 —— 待产品决策,且阻塞 P1-8/P1-9 的排期
- P0-5 的凭据轮换(外部动作)、
com.ths.dailyarchive需一次登录/重启才恢复加载 - P1-9 双源串源(形态需框架级改造,且其所在管线当前不可达 → 待 P0-4 决策)
- P1-12 去重写连接、P1-13 句柄未关、P2 全部
优先级总表
| 优先级 | # | 问题 | 状态 |
|---|---|---|---|
| P0 | 1 | 已修代码未部署到生产 | ✅ 已完成(v1.5.2) |
| P0 | 2 | 生产凭据明文写在 7 个 launchd plist | ✅ 已移出;⬜ 待轮换 |
| P0 | 3 | dividend 表历史数据未修复 |
✅ 已完成(216,364 行) |
| P0 | 4 | 采集器崩溃循环 | ✅ 已部署新版(崩溃日志已激活);崩溃根因待日志捕获后定位 |
| P0 | 5 | trade_date 同列混存两格式 + collation 冲突 |
✅ 全部完成(格式 44,477,754 行 + collation 81 张表;跨库 UNION/JOIN 已恢复) |
| P0 | 6 | 七步 Pipeline/四级门控不在生产路径 | ⬜ 待决策(阻塞 P1-8/P1-9) |
| P0 | 7 | 版本控制失效:核心库未入库、CI 必然失败 | ✅ 已完成(两个提交) |
| P1 | 8 | Gate3→Gate2 reroute 不收敛,整批被阻断 | ✅ 已修(含 3 条证伪用例,已同步部署位) |
| P1 | 9 | 双源并行共享 context.df,分支串源 |
⬜ 已可执行复现,同上 |
| P1 | 10 | 清洗层周/月线脱节 + akshare 日更无告警 | ✅ 数据与调度已修;⬜ akshare 源侧落后 14 天的告警仍缺 |
| P1 | 11 | 搜索 total 契约失真 / FULLTEXT 提速 |
✅ total 已修(实测 63321 与 SQL 直查一致);✅ FULLTEXT 提速已评估并否决(实测 ngram 与 LIKE 不等价、会静默丢结果,见专章) |
| P1 | 12 | 去重写入每条新建 4~5 个 MySQL 连接 | ✅ 已修(7 处建连改复用,3 条证伪用例) |
| P1 | 13 | 连接池位 / 句柄泄漏 | ✅ 已修(backfill 三处连接归还 + 后端 stderr 句柄;6 条证伪用例) |
| P1 | 14 | Swift 改动未通过编译验证 | ✅ 已完成 |
| P1 | 15 | fin_data_clean 缺 2021 年前历史 |
⬜ 未动(应与 P0-5 同批处理) |
| P2 | 16 | bundle-python.sh 版本契约冲突 |
✅ 已修(改读 .python-version,补 pipefail,注明不在构建链路) |
| P2 | 17 | 陈旧可写 venv(3.14 副本) | ✅ 已清理(464M → 12K) |
| P2 | 18 | findatahub-core 侧重复文件未收敛 |
✅ tushare 侧已收敛(574 → 66 行);⬜ akshare 侧是有差异的分支版本,待甄别 |
| P2 | 19 | 超大文件未拆分 | ✅ 首批完成(NewsFlashService.swift 1054→821 行 + 3 个 extension,编译验证通过);⬜ 其余大文件待拆 |
| P2 | 20 | findatahub-client 无 .venv、测试为 0 |
✅ 已修(并发现 SQL 注入;建 venv + 38 条测试 + 登记 CI) |
| P2 | 21 | 构建残留占位 | ✅ 已清理(build/、两个 egg-info) |
| P2 | 22 | 资源占用疑似回归 | ⬜ 未复测(需交易时段) |
| P2 | 23 | 未跟踪文件策略 | ✅ 已定(.agent-teams/ 忽略,.workbuddy/memory 入库) |
P0 — 正在持续造成损害或暴露
P0-1 已修代码未部署到生产
具体表现:5 项代码修复(时间戳截断、增量清表、SSRF、连接池、崩溃诊断)全部只在源码;部署位仍是 08-31 构建。逐项核对结果:
| 修复标记 | 部署位 | 源码 |
|---|---|---|
raw.split(":", 1)(bin/common.py) |
0 处 | 1 处 |
is_incremental(tushare_fetcher/.../fetcher.py) |
0 处 | 3 处 |
follow_redirects=False(kuaixun-service/extend/content_enricher.py) |
0 处 | 2 处 |
_POOL_GET_TIMEOUT(bin/db_pool.py) |
0 处 | 3 处 |
影响范围:全部线上行为。采集器崩溃循环、SSRF 暴露、下一次静态表同步仍会清空历史、连接池仍可能挂死——修复零收益。
复现步骤
cd /Users/mantianfuyun/Projects/项目/FinDataHub
APP=/Applications/FinDataHub.app
grep -c 'raw.split(":", 1)' $APP/Contents/Resources/bin/common.py # 输出 0(应为 1)
grep -c 'is_incremental' $APP/Contents/Resources/tushare_fetcher/src/tushare_fetcher/core/fetcher.py # 0
grep -c 'follow_redirects=False' $APP/Contents/Resources/kuaixun-service/extend/content_enricher.py # 0
grep -c '_POOL_GET_TIMEOUT' $APP/Contents/Resources/bin/db_pool.py # 0
ls -la /Applications/FinDataHub.app/Contents/Resources/ # 构建时间 08-31
修复建议:按 Scripts/build-app.sh 重新打包并安装(注意该脚本只产出 .build/FinDataHub.app,必须手动 mv 到 /Applications);打包前先在正常终端跑一次 swift build 通过类型检查(见 P1-14)。
P0-2 生产凭据明文写在 launchd plist
具体表现:5 个 launchd 任务的 ProgramArguments 内联明文凭据。
| plist | TUSHARE_TOKEN | MYSQL_PASSWORD |
|---|---|---|
com.tushare.dailyupdate |
有 | 有 |
com.tushare.weeklyupdate |
有 | 有 |
com.tushare.monthlyupdate |
有 | 有 |
com.akshare.dailyupdate |
— | 有 |
com.ths.dailyarchive |
— | 有 |
影响范围:同机任意用户态进程可读;会随备份、截图、终端回滚、日志归档外泄(本次复查即因此直接读到,并据此完成了数据库取证)。波及 Tushare token 与 MySQL root 口令。
复现步骤
for f in com.tushare.dailyupdate com.akshare.dailyupdate com.tushare.weeklyupdate \
com.tushare.monthlyupdate com.ths.dailyarchive; do
printf "%-30s TUSHARE_TOKEN:%s MYSQL_PASSWORD:%s\n" "$f" \
"$(plutil -p ~/Library/LaunchAgents/$f.plist | grep -c TUSHARE_TOKEN)" \
"$(plutil -p ~/Library/LaunchAgents/$f.plist | grep -c MYSQL_PASSWORD)"
done
plutil -p ~/Library/LaunchAgents/com.tushare.dailyupdate.plist | grep -o "TUSHARE_TOKEN=......"
注意:
grep -c "A\|B"这种交替写法在 BSD grep 下会假阴性(本次复查中一度因此误判"已清除")。必须分开匹配或用 ripgrep。
修复建议:① 先在 Tushare 后台与 MySQL 轮换凭据;② 凭据移入 ~/.config/findatahub/env(chmod 600),plist 改 set -a; . ~/.config/findatahub/env; set +a;③ 复查 Scripts/bin/install_collector_launchd.py 是否也写入明文。
P0-3 dividend 表历史数据未修复
具体表现:全市场分红表仍只有 19 行,全部集中在 20260604 一天(涉及 16 只股票)。
影响范围:分红历史查询、股息率计算、分红事件回测全部失真且无法自愈——_fetch_static 的增量逻辑只会从 last_date + 1 往后拉,永远补不回被删掉的历史。代码已修(P0-2 那条 upsert 修复)但存量数据未补,且未部署。
复现步骤
export MYSQL_PWD='<见 P0-2 复现>'
mysql -u root -h 127.0.0.1 -e \
"SELECT COUNT(*) 行数, MIN(ann_date) 最早, MAX(ann_date) 最晚 FROM tushare_data.dividend;"
# 期望:数万行、跨多年;实测:19 行、全部 20260604
修复建议:查全 DATE_COLUMN_MAP 中 17 张表的行数/日期跨度,确认损坏范围(stk_holdernumber 17,379 行、repurchase 43,584 行尚存数据);对受损表执行一次指定起始日的全量重拉(不走增量分支)。
P0-4 采集器崩溃循环仍在持续
具体表现:采集器异常停止 累计 205 次(今日 18:10 时为 193 次,4 小时内新增 12 次);采集器 PID 由 19658 换到 44717;stderr 里 jieba 初始化 218 次且无任何堆栈;新加的崩溃日志文件因未部署而不存在。
影响范围:每 10–20 分钟一次采集空窗;每次重启重载 jieba、重连 MySQL、重建通知轮询;日均 200 行 ERROR 淹没真实信号。根因(evaluate() 只要服务 running 就清空故障计数)已修但未部署。
复现步骤
L=~/Library/Logs/FinDataHub/debug.log
grep -c "采集器异常停止" "$L" # 205(持续增长)
grep "采集器异常停止" "$L" | tail -2
pgrep -f "[n]ewsflash_collect" # PID 会随时间变化
grep -c "Building prefix dict" ~/Library/Logs/FinDataHub/newsflash/collector-stderr.log # 218
ls ~/Library/Logs/FinDataHub/newsflash/collector-crash.log # No such file(未部署)
修复建议:先部署(P0-1);部署后观察 collector-crash.log 是否捕捉到真实堆栈——届时才可能定位崩溃本身。
P0-5 trade_date 同列混存两种日期格式 + 跨库排序规则冲突
具体表现(本轮新发现,比首版报告描述严重得多)
tushare_data.stock_daily (8,041,394 行)
'2020-01-02' 样式(带横线) 7,665,338 行 95.3%
'20260612' 样式(无横线) 376,056 行 4.7%
切换边界:带横线止于 2026-06-11,无横线始于 20260612
同一列两种格式并存 → 2026 年 946,221 行中,标准范围过滤只命中 376,056 行
→ 静默丢弃 570,165 行(60%)
跨库排序规则不一致:
akshare_data.stock_daily.trade_date utf8mb4_0900_ai_ci
tushare_data.stock_daily.trade_date utf8mb4_unicode_ci ← 不同
fin_data_clean.stock_daily.trade_date utf8mb4_0900_ai_ci
影响范围
- 任何按
trade_date的等值/范围过滤(含所有增量同步、回测日期切片)会丢掉 2026-06-12 之前的全部 tushare 数据,且不报错 - 跨库 UNION/JOIN 直接报错(排序规则冲突)
MIN()/MAX()结果不可信(字符串比较,-的编码 0x2D 小于0的 0x30)fin_data_clean无带横线行且最早仅到20210104→ 2021 年前历史缺失(见 P1-15)
复现步骤
export MYSQL_PWD='<见 P0-2 复现>'
# ① 同列混存
mysql -u root -h 127.0.0.1 -e "SELECT COUNT(*) 总行, SUM(trade_date LIKE '%-%') 带横线, \
SUM(trade_date NOT LIKE '%-%') 无横线 FROM tushare_data.stock_daily;"
# ② 范围过滤静默丢数(关键)
mysql -u root -h 127.0.0.1 -e "SELECT SUM(trade_date LIKE '2026%') 2026年全部行, \
SUM(trade_date BETWEEN '20260101' AND '20260930') 区间命中 FROM tushare_data.stock_daily;"
# → 946221 vs 376056,差 570165
# ③ 切换边界
mysql -u root -h 127.0.0.1 -e "SELECT '带横线最新' 项, MAX(trade_date) 值 FROM tushare_data.stock_daily \
WHERE trade_date LIKE '%-%' UNION ALL SELECT '无横线最早', MIN(trade_date) FROM tushare_data.stock_daily \
WHERE trade_date NOT LIKE '%-%';"
# → 2026-06-11 / 20260612
# ④ 跨库 UNION 报错
mysql -u root -h 127.0.0.1 -e "(SELECT trade_date FROM akshare_data.stock_daily WHERE trade_date='2026-09-02' LIMIT 1) \
UNION ALL (SELECT trade_date FROM tushare_data.stock_daily WHERE trade_date='20260916' LIMIT 1);"
# → ERROR 1271 (HY000): Illegal mix of collations for operation 'UNION'
修复建议:① 统一为 CHAR(8) 无分隔符(REPLACE(trade_date,'-','') 一次性 UPDATE);② 统一三库该列 collation;③ 加写入侧断言,禁止非 8 位数字格式入库;④ 迁移前后各做一次行数校验(COUNT(*) 必须一致)。
P0-6 七步 Pipeline/四级门控不在生产路径
具体表现:生产日更由 launchd 的 com.tushare.dailyupdate(15:40)/com.akshare.dailyupdate(18:00)/com.ths.dailyarchive(15:35) 驱动,命令是 bash scripts/daily_update.sh,直写 tushare_data/akshare_data;App 侧走 sync_bridge.py → fetcher CLI,同样绕过门控。门控入口 flows/pipeline_runner.py 全仓引用方只有文档与 config/launchd/install.sh,com.findatahub.pipeline.daily.plist 未安装。
影响范围:质量分、血缘、双源交叉验证、四级门控在线上均不生效;fin_data_clean 靠一次性迁移维持,周/月线已脱节(见 P1-10)。
复现步骤
ls ~/Library/LaunchAgents/ | grep -i findatahub # 只有 cloudsync,无 pipeline.daily
grep -n "bash scripts" ~/Library/LaunchAgents/com.tushare.dailyupdate.plist # 直写 raw 库
ps aux | grep "[d]aily_update.sh" | head # 实际调度的是它
grep -rl "pipeline_runner" --include="*.py" --include="*.sh" Services Scripts # 无生产调用方
修复建议:二选一——A(推荐) 把 pipeline_runner.py 装入 launchd 并成为 fin_data_clean 唯一写入方,把 fetcher 直写降级为 raw 层;B 承认现状,把 flows/ 与 Pipeline 相关模块移出主仓或标记为实验代码,文档同步改写。无论选哪条,先给 launchd 日更加失败告警(akshare 已静默失败,见 P1-10)。该项未决会阻塞 P1-8、P1-9 的修复排期。
P0-7 版本控制失效:核心库未入库、CI 必然失败
具体表现
git ls-files Services/findatahub-core/ → 0 条 (核心数据层 18 个模块从未入库)
git ls-files Services/findatahub-client/ → 0 条
App/Sources 磁盘 74 个 .swift、索引 54 → 20 个未入库(含 AutoRecoveryManager)
git status --untracked-files=all | grep -c '^??' → 159(约 8.5 MB)
CI 引用路径与索引不符 → 全新检出必然失败:
.github/workflows/ci.yml:31 python3 Scripts/bin/check_python_version.py --strict ← 文件不在索引
.github/workflows/ci.yml:37 Services/findatahub-core/src ← 索引 0 条
索引路径大小写不一致:索引记 scripts/、工作区为 Scripts/,core.ignorecase=true 使 git diff -- Scripts/... 静默返回空。
影响范围:一次 git clean -fd、误删目录或磁盘故障即可不可恢复地丢失核心库与 20 个 App 源码;CI 无法提供任何回归保护;大小写不一致在 Linux/Docker 检出时会让所有 Scripts/... 引用解析失败。
复现步骤
git ls-files Services/findatahub-core/ | wc -l # 0
git status --untracked-files=all | grep -c '^??' # 159
git ls-files scripts/bin/ | wc -l # 31(小写)
git ls-files Scripts/bin/ | wc -l # 0(大写)
git diff --numstat -- Scripts/bin/common.py # 空 ← 陷阱
git diff --numstat -- scripts/bin/common.py # 有真实 diff
修复建议:① 先修正索引大小写(大小写敏感环境验证);② 分 3 批提交:布局迁移与删除 → 补齐 core/client/App 源码 → 性能与熔断改动;③ 明确决定 .workbuddy/memory、.agent-teams/ 入库或加 .gitignore。
已做:还原点 ~/Library/Application Support/FinDataHub/backups/findatahub-worktree-20260917-184039.tar.gz(7.0 MB / 765 项)。
P1 — 已复现/已实测,需排期
P1-8 Gate3→Gate2 reroute 不收敛,整批被阻断
具体表现:Gate2 对 OHLC 违规只置 severity="warn"/passed=True 且不改数据;Gate3 因此判定"上游漏检"并 reroute 回 Gate2 → 复查结果恒等 → 计数累加到 4 > max_reroute=3 → is_blocked=True,整批数据丢弃不落库。另一处:reroute 分支 continue 前未 remaining.pop(just_completed),本轮已完成步骤以 degree=0 残留、下一轮重复执行(日志中可见 gate3_ingestion 被多跑 4 次)。
影响范围:含任一 OHLC 违规行的批次(NaN 比较恒 False,故含 NaN 即触发)白跑 3 轮后整批不入库。
复现步骤(可直接运行)
cat > /tmp/repro_reroute.py <<'PY'
import sys; sys.path.insert(0, "Services/findatahub-core/src")
import pandas as pd
from findatahub_core.pipeline import Pipeline, Step, PipelineContext, make_gate_step
N = 3000
def fetch(ctx):
df = pd.DataFrame({"ts_code": ["000001.SZ"]*N, "trade_date": ["20260916"]*N,
"open":[10.0]*N, "high":[11.0]*N, "low":[9.0]*N, "close":[10.5]*N,
"pre_close":[10.0]*N, "vol":[100.0]*N, "amount":[1000.0]*N, "pct_chg":[0.5]*N})
df.loc[0, "high"] = 9.5 # 唯一一行违反 high >= max(open,close)
ctx.df = df; ctx.quality_score = 0.90; ctx.touch(); return ctx
p = Pipeline("repro")
p.add_step(Step("fetch", fetch, depends_on=[]))
p.add_step(Step("gate1_collection", make_gate_step("gate1_collection"), depends_on=["fetch"]))
p.add_step(Step("clean", lambda c: c, depends_on=["gate1_collection"]))
p.add_step(Step("gate2_cleaning", make_gate_step("gate2_cleaning"), depends_on=["clean"]))
p.add_step(Step("gate3_ingestion", make_gate_step("gate3_ingestion"), depends_on=["gate2_cleaning"]))
p.add_step(Step("store", lambda c: c, depends_on=["gate3_ingestion"]))
ctx = p.run(PipelineContext(batch_id="repro", source="tushare", table_name="stock_daily"))
print("is_blocked =", ctx.is_blocked, "| gate2 reroute_count =", p.steps["gate2_cleaning"].reroute_count)
PY
/Users/mantianfuyun/.workbuddy/binaries/python/envs/default/bin/python /tmp/repro_reroute.py
实测输出:连续 4 次 pipeline.reroute → pipeline.step_reroute_limit count=4 step=gate2_cleaning → is_blocked = True
修复建议:把 Gate2 的 OHLC 严重度提升为 block(与 Gate3 的期望对齐),或让 Gate3 仅在"Gate2 应阻断却误判"时降级为告警;reroute 分支补 remaining.pop(just_completed, None)。
P1-9 双源并行共享 context.df,分支串源
具体表现:pipeline.py:169-180 的 ThreadPoolExecutor 向同批所有 ready 步骤传入同一个 context 对象,而 context 只有一个 df 字段(pipeline.py:347-349 直接 context.df = df)。双源模式下两个 fetch 步骤 depends_on=[] 同批并行,后完成者覆盖前者。
影响范围:两个数据源的数据互相污染,两条分支的 gate/store 都可能处理对方的数据,且不报错。
复现步骤(可直接运行)
cat > /tmp/repro_ctx.py <<'PY'
import sys, time; sys.path.insert(0, "Services/findatahub-core/src")
import pandas as pd
from findatahub_core.pipeline import Pipeline, Step, PipelineContext
def mk(tag, delay):
def _f(ctx):
time.sleep(delay); ctx.df = pd.DataFrame({"src":[tag]}); ctx.touch(); return ctx
return _f
def gate(ctx):
ctx.seen = ctx.df["src"].iloc[0]; return ctx
p = Pipeline("repro2")
p.add_step(Step("fetch_tushare", mk("tushare", 0.30), depends_on=[]))
p.add_step(Step("fetch_akshare", mk("akshare", 0.05), depends_on=[]))
p.add_step(Step("gate1_ak", gate, depends_on=["fetch_akshare"]))
ctx = p.run(PipelineContext(batch_id="r2", source="akshare", table_name="stock_daily"))
print("akshare 分支的 gate 看到 =", ctx.seen, "(应为 akshare)")
PY
/Users/mantianfuyun/.workbuddy/binaries/python/envs/default/bin/python /tmp/repro_ctx.py
实测输出:akshare 分支的 gate 看到 = tushare ← 串源成立
修复建议:产物按步骤名存放(context.outputs[step_name])而非共享单槽;或在 DAG 层禁止 fetch 并行。
P1-10 清洗层周/月线脱节 + akshare 日更落后 14 天无告警
具体表现
| 表 | 清洗层最新 | 源层最新 | 差距 |
|---|---|---|---|
stock_daily |
20260916 | tushare 20260916 | 同步 |
stock_weekly |
20260727 | akshare 20260824 | 落后约 1 个月 |
stock_monthly |
20260731 | — | 落后 |
akshare_data.stock_daily |
— | 2026-09-02(tushare 到 20260916) | 源侧落后 14 天 |
影响范围:周/月线相关策略与行情查询拿到过期数据;akshare 日更静默失败已 14 天无任何告警。
复现步骤
export MYSQL_PWD='<见 P0-2 复现>'
mysql -u root -h 127.0.0.1 -e "SELECT 'clean.weekly' 项, MAX(trade_date) 最新 FROM fin_data_clean.stock_weekly \
UNION ALL SELECT 'clean.monthly', MAX(trade_date) FROM fin_data_clean.stock_monthly \
UNION ALL SELECT 'akshare.daily', MAX(trade_date) FROM akshare_data.stock_daily \
UNION ALL SELECT 'tushare.daily', MAX(trade_date) FROM tushare_data.stock_daily;"
修复建议:给三个 daily_update launchd 任务加"退出码 + 写入行数"落盘并在 App 展示;对"连续 N 天未更新"加告警;补跑 clean 层周/月线。
P1-11 搜索 total 契约失真 + N 表 LIKE 全表扫描
具体表现:/api/v1/news/search 的 total 返回的是本页行数(len(all_results),恒 ≤ limit),而非命中总数;且查询用前导通配符 %q% 对 6 张 news_* 表 UNION ALL 全表扫描(索引不可用)。
影响范围:前端分页/结果计数完全错误;每次搜索 6 次全表扫描(282,607 行新闻库)。
复现步骤(对运行中的后端 :8100)
curl -s "http://127.0.0.1:8100/api/v1/news/search?q=%E8%82%A1&limit=5" | head -c 200 # total=5
curl -s "http://127.0.0.1:8100/api/v1/news/search?q=%E8%A1%80&limit=20" | head -c 200 # total=20
# 真实命中数(同一个"股"字):
export MYSQL_PWD='<见 P0-2 复现>'
mysql -u root -h 127.0.0.1 -N -e "SELECT COUNT(*) FROM news_flash.news_flash \
WHERE title LIKE '%股%' OR content LIKE '%股%';"
# → 99359
即真实命中 99,359 行,接口报 total: 20。
修复建议:total 单独走 COUNT(*);检索改 MySQL FULLTEXT 或 Meilisearch;app/routers/external.py:42 同构,一并处理。
P1-12 去重写入每条新建 4~5 个 MySQL 连接
具体表现:Services/kuaixun-service/extend/unified_writer.py 有 7 处 pymysql.connect(241/296/329/390/459/512/565 行),由 _process_one() 串行调用 L1→L4 去重层;db_config 未设 connect_timeout/read_timeout。
影响范围:500 条批次约 2,000 次 TCP + 握手;MySQL 卡住时写入线程无限阻塞。
复现步骤
grep -n "pymysql.connect" Services/kuaixun-service/extend/unified_writer.py # 7 处
grep -n -A 12 "db_config" Services/kuaixun-service/extend/unified_writer.py | grep -c "timeout" # 0
修复建议:整批复用一个连接(或注入 Scripts/bin/db_pool.py),统一加超时。
P1-13 日志句柄与子进程流未关闭
具体表现:Scripts/bin/newsflash_collect.py:653 stderr=open(...)、extend/fetchers/collector.py:52 stdout=open(log_path,"a")、:557-559 except Exception as e: pass(e 未用、urlopen 响应未关)、Scripts/bin/backfill.py:63-80 get_conn() 无 finally。
影响范围:反复调用累积文件描述符;_notify_backend 的失败被静默吞掉、不可观测。
复现步骤
grep -n "stderr=open\|stdout=open" Scripts/bin/newsflash_collect.py Services/kuaixun-service/extend/fetchers/collector.py
grep -n -A 3 "except Exception as e:" Scripts/bin/newsflash_collect.py | grep -n "pass"
grep -c "finally" Scripts/bin/backfill.py # 0
修复建议:句柄改 with;except 至少落日志;get_conn() 外包 try/finally。
P1-14 Swift 改动未通过编译验证
具体表现:AutoRecoveryManager.swift(含本次熔断修复)只做了语法解析校验;本会话 swift build 被沙箱拦截——SwiftPM 自身要调 sandbox-exec,报 sandbox_apply: Operation not permitted,加授权开关亦无效。
影响范围:熔断修复存在未检出的类型错误风险;连带 P0-1 的部署前必须先解决此验证缺口。
复现步骤
cd /Users/mantianfuyun/Projects/项目/FinDataHub
swift build # → error: sandbox-exec: sandbox_apply: Operation not permitted
xcrun swiftc -parse App/Sources/FinDataHub/Services/AutoRecoveryManager.swift # 退出码 0(仅语法)
修复建议:在普通终端(非沙箱)执行一次 swift build;确认通过后再走 Scripts/build-app.sh 打包。
P1-15 fin_data_clean 缺 2021 年前历史
具体表现:fin_data_clean.stock_daily 最早 20210104,而源层 tushare_data 最早 2020-01-02、akshare_data 最早 1990-12-19。
影响范围:需要长历史的回测/分位计算在 2021 年前无数据,且与源层不一致。
复现步骤
export MYSQL_PWD='<见 P0-2 复现>'
mysql -u root -h 127.0.0.1 -e "SELECT 'clean' 库, MIN(trade_date) 最早 FROM fin_data_clean.stock_daily \
UNION ALL SELECT 'tushare', MIN(trade_date) FROM tushare_data.stock_daily \
UNION ALL SELECT 'akshare', MIN(trade_date) FROM akshare_data.stock_daily;"
修复建议:按需决定是否补齐;若补,需与 P0-5 的格式统一一并做(否则范围过滤会再次丢数)。
P2 — 工程卫生与覆盖缺口
| # | 问题 | 具体表现 / 复现 | 建议 |
|---|---|---|---|
| 16 | bundle-python.sh 版本契约冲突 |
Scripts/bundle-python.sh:28 写 uv venv --python 3.14,与 .python-version=3.12、各 pyproject >=3.12,<3.13 全部矛盾;全仓无调用方;只有 set -e 无 pipefail |
删除或改 3.12 |
| 17 | 陈旧可写 venv | ~/Library/Application Support/FinDataHub/venvs/kuaixun-service 仍是 3.14,反复触发 [WARN] [Venv] 可写副本 Python 3.14.5 与 App 内置 3.12.13 不一致 |
删除该副本(代码层回退逻辑已正确) |
| 18 | core 侧重复文件 | findatahub-core/src/findatahub_core/{cache,reporter,validator}.py 与 tushare 同名文件逐字节相同(合计 574 行),core 版仅被自身测试引用 |
参照已成型的 db.py 下沉模式收口 |
| 19 | 超大文件 | NewsFlashService.swift 1054 行、PythonProcessManager.swift 842 行、DatabaseSetupGuideView.swift 807 行 |
按职责拆分(SettingsView 2467 行已拆分,延续该模式) |
| 20 | findatahub-client 测试为 0 |
find 该目录无 tests/ |
补 SDK 方法测试 |
| 21 | 构建残留 | Services/findatahub-core/build/lib/(含 21 个 .py)与两处 *.egg-info;均未入库但占位且易误读为源码 |
清理并在文档注明 |
| 22 | 资源疑似回归 | 主进程 RSS 179 MB/UNIX 237/s(基线非交易时段 78–86 MB/0.2–33);MySQL RSS 2860 MB(基线 470 MB) | 须在交易时段复测后再定性,按 perf_snapshot.py 方法论排查 |
| 23 | 未跟踪文件策略未定 | .workbuddy/memory(11)、.agent-teams/(7+) 等既未入库也未 ignore |
明确 decide → 入库或加 .gitignore |
附:已修复但未部署(部署后即可生效)
| # | 修复 | 涉及文件 |
|---|---|---|
| 1 | PID 时间戳截断(split(":", 1)) |
Scripts/bin/common.py |
| 2 | 静态表增量改 upsert(防清表) | Services/tushare_fetcher/src/tushare_fetcher/core/fetcher.py |
| 3 | SSRF 重定向绕过(禁自动跳转 + 逐跳校验) | Services/kuaixun-service/extend/content_enricher.py |
| 4 | 连接池超时/rollback/池位归还 | Scripts/bin/db_pool.py、Scripts/bin/newsflash_backend.py |
| 5 | 采集器崩溃现场捕获(faulthandler + excepthook) | Scripts/bin/newsflash_collect.py |
| 6 | 熔断器识别崩溃循环(长窗口计数 + 稳定期门控) | App/Sources/FinDataHub/Services/AutoRecoveryManager.swift(待编译验证) |
| 7 | 既有偶发用例(TestThrottle 边界断言) |
Services/tushare_fetcher/tests/unit/test_api.py |
| 8 | 补 pytest-asyncio(9 条 async 用例转绿) |
Services/kuaixun-service/.venv |
回归状态:Scripts/bin 207 | findatahub-core 105 | tushare_fetcher 174(连跑 5 次稳定)| kuaixun-service 405 | akshare_fetcher 183 —— 合计 1074 passed。
本文所有数字均来自本次实测(git 索引查询、MySQL 实库统计、运行中进程与日志、可执行复现脚本)。 凭据类证据已脱敏,未记录明文值。
本批更正(臣自身两处表述不准,已在复核中证伪并更正)
更正 1:P1-8 的"附加缺陷"说法不准确
原报告写"reroute 分支未 remaining.pop(just_completed) → 已完成步骤以 degree=0 残留、下一轮重复执行"。
实测不符:构造双分支 DAG 探测(fetch_a/gate_a 与 fetch_b/gate_b/tail_b,
令 gate_a 上报 reroute 到 gate_b),结果 fetch_a 只执行 1 次。
真实行为是:只有与 reroute 触发步骤同批完成的步骤会被重复执行;而对 reroute 目标的
下游而言,重复执行正是"重置并重跑"语义所必需 —— 若强行补 pop 反而会破坏它。
结论:该处不是缺陷,代码未改。原表述已作废。
更正 2:P1-11 的对比口径错了
原报告用 news_flash 表统计出"真实命中 99,359 行"与接口 total=20 对比。
但 ACTIVE_TABLES = [news_cls, news_wallstreetcn, news_sina, news_gelonghui, news_hotlist, news_10jqka]
并不包含 news_flash,所以那是拿两张不同的表在比。
正确口径:6 张 ACTIVE_TABLES 同谓词合计 63,321 行(逐表:cls 8577 / wallstreetcn 441 /
sina 13341 / gelonghui 13431 / hotlist 18147 / 10jqka 9384)。
缺陷依然成立且严重(20 vs 63321,差 3,166 倍),但原始对比数字作废。
更正 3:external.py 无同类问题
原报告称 app/routers/external.py:42 与搜索接口"同构"。实测其走的是
dal.get_live_news(),该函数本就实现了正确的 COUNT 模式(subs, cnts 两套子查询),
返回真实 total。该处无缺陷,属原报告误判。
P0-5 处置说明(2026-09-19 00:00)
已完成:格式统一(44,477,754 行)
缺陷:同一列混存 YYYY-MM-DD 与 YYYYMMDD,按标准 YYYYMMDD 写的等值/范围过滤
静默丢弃另一种格式的行。实测 tushare_data.stock_daily 2026 年 94.6 万行中,
BETWEEN '20260101' AND '20260930' 只命中 37.6 万行 → 丢掉 60%,且不报错。
影响面(全库扫描 39 个日期样字符列):6 个列混存、3 个列仅带横线,共 9 个列 / 44,477,754 行 需迁移。
根因:采集脚本把 pandas datetime64 列直接交给驱动 → 落库被渲染成 '2026-09-02';
另一些脚本显式 strftime('%Y%m%d')。backfill_daily.py / sync_daily_tasks.py
(日更实际执行者)都属于前者。
修法(三层)
- 写库层护栏(
findatahub_core/db.py):normalize_date_columns()挂在upsert()/replace()上。放共享基类可一次覆盖两个 fetcher 与清洗层 —— 比逐个修 20+ 个采集脚本可靠,新脚本也不会再引入。 - 源头修正:
sync_daily_tasks.py/backfill_daily.py/fix_stock_daily.py补.dt.strftime('%Y%m%d'),并把比较口径同步改掉(原先用'YYYY-MM-DD'与 datetime64 比较,格式一变就永远匹配不到)。 - 迁移工具:
Scripts/bin/audit_date_format.py(扫描判定)+Scripts/bin/migrate_date_format.py(report / backup / execute / verify / only)。 分批键选ts_code—— 这些表都没有自增主键(tushare_data 只有uk_(ts_code, trade_date),akshare_data 是PRIMARY(ts_code, trade_date)), 而ts_code是所有相关索引的最左列;按trade_date分批会在扫描中改到分片列本身, 可能重复访问或漏行。
结果:9/9 校验通过(不变量:dashed=0 且 plain+empty=total,即无行丢失)。
迁移后重跑审计:同列混存 6→0、仅带横线 3→0、仅无横线 21→30。
效果实测:2026 年范围过滤命中 951,774 / 951,774(100%),修复前为 376,056 / 946,221。
未完成:collation 统一(需独立的维护窗口)
跨库 UNION/JOIN 仍报 ERROR 1271 Illegal mix of collations:
| 库 | 情况 |
|---|---|
tushare_data |
63 张表 / 316 个列 为 utf8mb4_unicode_ci |
akshare_data |
多数为 utf8mb4_0900_ai_ci,但自身还有 5 个列是 unicode_ci(库内就不一致) |
fin_data_clean |
全部 utf8mb4_0900_ai_ci |
为什么没在本次执行(不是跳过):
- 统一需
ALTER TABLE ... CONVERT TO CHARACTER SET,MySQL 对 collation 变更 只能走 COPY 算法(整表重建),期间该表禁止写入; - 涉及 66 张表,其中 4 张 500 万~1700 万行,重建预计数十分钟;
- 当前整个数据库没有备份(三个库合计 16.89 GB)—— 重建是不可回退操作, 在无备份前提下手动执行不符合工程纪律。磁盘有 1.3 TiB 可用,备份本身可行。
下一步(顺序不可颠倒)
- 先做一次真实库备份(
mysqldump三库,或至少覆盖 66 张待改表); - 在非交易时段(避开 15:35 / 15:40 / 18:00 三个 launchd 任务)逐库逐表执行;
- 每表执行前后记录
information_schema的 collation 与行数,逐表校验; - 建议统一到
utf8mb4_0900_ai_ci(MySQL 8 默认,且 2/3 的库已是它)。
在此之前如需跨库比对,用显式 COLLATE 即可绕过,例如:
SELECT a.ts_code, a.trade_date
FROM akshare_data.stock_daily a
JOIN tushare_data.stock_daily b
ON a.ts_code = b.ts_code COLLATE utf8mb4_0900_ai_ci
AND a.trade_date = b.trade_date COLLATE utf8mb4_0900_ai_ci
LIMIT 10;
新增缺陷:表「启用却从未被调度」(2026-09-19 由用户报告引出)
现象
用户报告 stk_holdertrade(股东增减持)/ repurchase(股票回购)
停摆约 65 个交易日。核实:两表 MAX(ann_date) 均为 20260618,
而行情已到 20260917。
根因
两张表在 cli.py 的 _TYPE_TABLES 任何分组里都不存在,
也不在任何 --tables 列表里 —— 从未被任何调度任务调用过。
config.yaml 里的 tables: 启用开关不救场:该开关只在
「未显式指定要抓哪些表」时才生效(fetcher.py:239-243),
而两个 launchd 调度脚本总是显式传 --type / --tables,开关被完全绕过。
于是「配置写着 true」与「实际在不在跑」是两件事,且没有任何对账机制。
系统排查结果(不止这两张)
新增 Scripts/bin/audit_schedule_coverage.py,对账三份来源
(TABLE_MAP 定义 / 调度实际覆盖 / config 声明启用):
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 调度覆盖表数 | 55 | 61 |
| ★ 启用但无任何排期(真缺陷) | 12 | 7 |
修复前 12 张:fund_div, fund_manager, fund_nav, fund_share, hk_hold, shibor,
stk_factor, stk_holdertrade, stk_managers, stk_rewards, ths_daily, us_daily
已处置
- 补排期(
tushare_sync.sh):daily 增加stk_holdertrade,repurchase; monthly 增加fund_div,fund_manager,fund_share,fund_nav - 补数据(实测):
stk_holdertrade51,030 → 52,937 行(最新 20260618 → 20260918)repurchase43,584 → 45,901 行(最新 20260618 → 20260918) - 建审计机制:
audit_schedule_coverage.py—— 这类缺陷的特征就是静默, 必须有主动对账,不能靠人发现
仍未处置(需单独决策,本次已查明)
- 剩余 7 张中 6 张表根本不存在(从未建表):
hk_hold / shibor / stk_managers / stk_rewards / ths_daily / us_daily→ 需先建表 DDL 再首次全量加载,属独立工作量。 stk_factor:接口限 5 次/天,无法批量抓取(fetcher.py:306已显式跳过)。 已把 config 改为false并注明原因 —— 避免长期"假启用"。- **44 张「孤儿且 config 未声明」**的历史表(
top10_holders、pledge_detail、share_float、report_rc、stk_limit、us_tycr等)需人工确认是否应纳入采集。 清单见python3 Scripts/bin/audit_schedule_coverage.py输出。
P0-5 后半部分:collation 统一(2026-09-19 00:21 完成)
前置:先做完整数据库备份并校验可用性(这是本次能动手的前提)
~/Library/Application Support/FinDataHub/backups/db-20260918-235703/
tushare_data.sql.gz 1.9G gzip -t CRC OK, CREATE TABLE 65 条(= 本库表数)
akshare_data.sql.gz 496M CRC OK, 43 条
fin_data_clean.sql.gz 416M CRC OK, 14 条
新增 Scripts/bin/migrate_collation.py(--report / --execute / --verify),
逐表 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
每表记录前后 collation、行数、残留异类列数,任一异常即 fail-fast。
结果:转换 81 张表,总耗时 336.9 秒,0 失败;
每张表行数前后完全一致(如 stock_daily 8,046,947 → 8,046,947);
复验三库 122 张表全部符合目标。
效果(无需任何显式 COLLATE)
跨库 UNION : ✓ 返回 2 行
跨库 JOIN 同标的对账 : ✓ 命中 5,547 行
→ 跨源校验通路恢复。此前必须在每条 JOIN 上写 COLLATE 才能绕过(1271)。
本批更正(臣先前报告中的不准确引用)
Services/kuaixun-service/extend/fetchers/collector.py并不存在 (该目录下无collector.py,只有app/routers/collector.py)。 先前条目中引用此路径的"句柄未关"实指Scripts/bin/newsflash_collect.py的后端 stderr 句柄,已按后者修复。- fetcher 的
except Exception as e: print(...)并非"静默吞错" —— 抽查wallstreetcn.py/jin10.py等,异常信息都有打印、e也被使用。 先前"静默吞错"的描述不成立。 com.ths.dailyarchive之外的 launchd 任务状态:先前称"5 个 plist 含凭据", 实际为 7 个,已在凭据章节更正。
第二批排查:23 张表「曾经在采集、后来静默停摆」(2026-09-19 01:00)
判据(取代"是否在 config 里写着 true")
先前的排查只看了「config 启用但无排期」,得 7~12 张。本轮换成有原则的判据:
库里有数据吗?数据停在什么时候?
理由:有数据 = 曾经在采集,那么它的停摆就是缺陷(与用户报的
stk_holdertrade 同类);无数据/表不存在 = 从未使用,属决策而非缺陷。
结果
51 张孤儿表 → 23 张有数据且已停摆、28 张无数据/不存在。
停摆最严重的(补数前):
| 表 | 行数 | 停在 | 缺口 |
|---|---|---|---|
stock_margin_secs |
5,536,869 | 20260610 | 约 68 个交易日 |
stock_hk_hold |
3,928,734 | 20260618 | 约 65 个交易日 |
stock_rewards |
1,500,461 | 20260430 | 约 96 个交易日 |
top_inst |
964,214 | 20260618 | 约 65 个交易日 |
fina_mainbz |
797,295 | 20260331 | 约 118 个交易日 |
index_weekly |
542,369 | 20260717 | 约 45 个交易日 |
已处置
- 补排期(
tushare_sync.sh): daily += 11 张、weekly += 2 张、monthly += 10 张 - 补数据:
top_inst964,214→1,008,371 行、index_weekly542,369→559,989 行, 两者最新均到 20260918;stock_margin_secs/stock_hk_hold/stock_daily_info/stock_limit/stock_moneyflow_hsgt亦已到 20260918 或 20260917。 (stock_rewards为逐股票大表,补数耗时约 30 分钟量级,仍在进行) - 排期覆盖审计:调度覆盖 61 → 84 张;「启用但无排期」真缺陷 7 → 5 张 (剩余 5 张均为表不存在:shibor / stk_managers / ths_daily / us_daily + 限流的 stk_factor)
仍未处置
- 剩余 5 张「启用但无排期」全部是从未建表,需先写 DDL 再首次全量加载
- 23 张「无数据/表不存在且 config 未声明」——确认是否纳入采集
stk_factor接口限 5 次/天,无法批量(已改 config 为 false)
P1-12 去重写入连接复用(2026-09-19 01:00 完成)
extend/unified_writer.py 7 处各自 pymysql.connect() + close(),
全指向同一库 news_flash;单条新闻去重链 L1→L3→L4→写入→合并
=每条新建开 4~5 次连接。改为 _conn() 复用一条(带 ping(reconnect=True) 自愈),
写路径的显式 commit 保持不动(事务边界不变)。
过程中被既有测试抓到一处真实回归:首版把 conn = self._conn() 放在
try: 之外,而原实现 pymysql.connect() 在 try: 之内 —— 连接失败原本被
except 吞掉、该层判为"未命中",改后却会把异常抛出(测试报
Access denied for user 'root')。已挪回 try 内。
P2-18 重复实现收敛(tushare 侧)
findatahub_core 抽取公共库后,tushare_fetcher/core/ 下三个旧副本原封未删,
且与 core 版本逐字相同(diff 差异 0 行):
cache.py 167 行、validator.py 246 行、report.py 161 行 = 574 行纯重复
(同目录 db.py 早已改成薄封装,这三个被漏掉)。
改为 sys.modules 别名(不是 from-import 转发):
from findatahub_core import reporter as _core
sys.modules[__name__] = _core
为什么不能用 from-import 转发(首版踩到):那样 monkeypatch 打的是
tushare_fetcher.core.report.REPORT_DIR(转发层上的值副本),
而 ReportGenerator 读的是 core 模块自己的全局 —— 写不进去,4 个报告测试变红。
别名后两个名字指向同一个模块对象,读与写同源。574 → 66 行。
akshare 侧不动:其同名文件是有差异的分支版本(199/150/120 行 vs core 的 246/167/161),可能是裁剪也可能是真实功能差异,需先甄别再收敛 —— 不在无证据时合并。
第三批排查:排期引用但库中不存在的表(2026-09-19 08:50)
起因:排期前飞行检查
下午 15:40 的日更要首次运行本批新增的 23 张表,故先做飞行检查。dry-run 只校验 表名能否解析、不校验表是否存在 —— 于是另做了一次「排期涉及的表在库里是否存在」的检查。
结果:21 张排期引用的表根本不存在
fut_basic/fut_daily/fut_holding/fut_mapping/fut_settle/fut_trade_cal/fut_wsr/
fx_daily/fx_obasic/hk_basic/hk_tradecal/opt_daily/sge_daily/us_basic/us_tradecal/
ths_index/shibor_lpr/libor/hibor/wz_index/gz_index
关键:缺表只记 写入失败 日志、退出码仍为 0,带 pipefail 的脚本也不会 abort
→ 这些行长期静默空转,与用户报的 stk_holdertrade 停摆是同一类缺陷。
处置
- 新增
Scripts/bin/create_missing_tables.py(list / dry-run / create,幂等)tables_registry.json只登记表名/唯一键/日期列、不含字段定义,仓库也无通用 DDL 生成器 → 从 Tushare 接口实际返回的 DataFrame 推导列与类型, 保证 DDL 与写入时的df.columns一致(否则 upsert 整批失败)- 入参用阶梯重试:不同接口要求不同入参,且有 1 次/分钟限流,单次失败≠不可用
- 建成 17 张,首次加载已入库 3,893,727 行(精确
COUNT(*))fut_holding1,800,000 /fut_daily960,979 /fut_settle850,048 /fut_wsr250,207 /fut_trade_cal13,527 /fut_basic10,000 /fut_mapping5,000 /hk_basic2,785 /rate_gz_index1,181;余 8 张后台继续 - 摘除 7 张确认不可采并在 config.yaml 标注实测报文:
- 无接口访问权限:
libor、ths_index、ths_daily、stk_factor - 配额 1 次/小时(无法按日迭代):
hibor、shibor_lpr、us_daily
- 无接口访问权限:
复核
- 排期涉及的 80 张表在库中全部存在 → 日更不再有空转行
- 15 条 fetch 命令 dry-run 全部可解析;config 经 YAML 解析校验
本批修正的两处自身错误
- 探测时把
TABLE_MAP元组里第 1 个引号串当 api_name(实为库表名), 使shibor/stk_managers等被误判「接口名错误」—— 第 2 个才是 api_name。 - DDL 推导对
str(dtype)做字符串匹配:pandas 3.x 字符串列 dtype 显示为str(非object),导致ts_code/name被生成成double。 改用pd.api.types.is_numeric_dtype()等类型谓词后可疑列归零。 —— 与之前normalize_date_columns里np.issubdtype踩的是同一类坑。
新增的可复用工具(本批)
| 工具 | 用途 |
|---|---|
Scripts/bin/audit_schedule_coverage.py |
对账「TABLE_MAP 定义 / 调度覆盖 / config 启用」 |
Scripts/bin/create_missing_tables.py |
为缺表推导 DDL 并幂等建表 |
Scripts/bin/audit_date_format.py |
扫描日期列格式混存 |
Scripts/bin/migrate_date_format.py |
日期格式统一迁移(report/backup/execute/verify) |
Scripts/bin/migrate_collation.py |
collation 统一(report/execute/verify) |
复核中的一处方法学教训(重要)
排查新表加载进度时,臣两次据 information_schema.table_rows 下结论,
两次都错:该字段是估算值,对刚大批量写入的表严重滞后 ——
fut_settle 显示 0 行,而校验日志明确写着 rows=850048。
据此臣一度把正在正常满载的 fut_settle 判为"卡住"并 pkill,
误杀了一个健康的长任务(也顺带中断了外层循环,导致后续 8 张表未开始)。
规则:判断表里有多少数据,一律用 SELECT COUNT(*);
table_rows 只能用于"数量级排序",不能用于"是否为空 / 是否在增长"的判断。
另一条观察:App 的自动恢复不覆盖"主动 stop"
用采集器自带 CLI 正常 stop 后,75 秒内未被 App 自动拉起(日志无恢复事件)。
这是合理行为(不该重启用户主动停掉的服务),但意味着:
主动停采集器后必须手动 start,不要期待自动恢复。
新增:采集器终止信号留痕
装上 faulthandler 后 collector-crash.log 一直为空 → 说明进程不是 Python
异常退出,而是被外部信号终止(faulthandler 不覆盖 SIGTERM)。
已补 SIGTERM/SIGINT 处理:记录信号名 + 所有线程堆栈(区分"被外部终止"与
"自己卡死"的关键证据),记录后按原语义退出。跨进程用例已证伪通过。
部署后观察:10.5 小时内采集器仅停止 2 次(01:36、06:47)且均 20 秒内恢复,
此前是 10–20 分钟一次 —— 需继续观察才能定论是否真正改善。
新增 P0(本清单最高优先级):uv run 运行期联网导致日更整体中止(2026-09-19)
故障
tushare_sync.sh daily 在 15:40 一张表都没抓,日志仅一行:
── T0 每日同步 (日线 + 资金面 + 事件公告) ──
table_group: daily (6 tables)
error: Request failed after 3 retries in 4.5s
cause: Failed to fetch: `https://pypi.tuna.tsinghua.edu.cn/simple/structlog/`
cause: dns error
根因
两个调度脚本都用 uv run <entrypoint>。uv run 会在运行时同步/重建依赖环境,
需要联网下载缺失包;当天该请求 DNS 解析失败,又因脚本带 set -eo pipefail,
这条 error 直接中止整条任务。
手动复现同一条命令还看到额外危害:
Building tushare-fetcher @ file:///...
Downloading mypy (13.0MiB)
Installed 14 packages in 31ms
即生产运行期会联网改写自身 venv 的依赖。
修复
tushare_sync.sh(17 处)与 akshare_fetcher/scripts/sync_all.sh(14 处)
全部改为直调 bundle 内可执行文件:
TUSHARE_BIN="$PROJECT_DIR/.venv/bin/tushare-fetcher" # 原 uv run tushare-fetcher
AKSHARE_BIN="$PROJECT_DIR/.venv/bin/akshare-fetcher" # 原 uv run akshare-fetcher
VENV_PY="$PROJECT_DIR/.venv/bin/python" # 原 uv run python3 / --no-sync python
并在使用前校验文件存在,缺失即明确报错退出(而非让 uv 去"修好它")。 → 不联网、不重建、不依赖 uv 是否在 PATH 上(launchd 的 PATH 极简,这本身也是风险)。
验证(端到端实跑)
- 当日日志:15:40 那次(第 1–8 行含 dns error)之后,17:46:50 起新运行至 17:48:45 完成,exit 0,464 行,0 error
- 6 个 table_group 全部执行到(含本批新增的「事件公告」「补排期-日频」)
- 银层迁移同批完成:11,246 行写入
fin_data_clean.stock_monthly,blocked=False - akshare 路径冒烟通过
影响评估
今天为周六(非交易日),故本次故障的数据影响有限; 但该缺陷在每个工作日都会造成整日零采集,且日志里只有一行 error、极难察觉。
新增缺陷:tables_registry.json 声明的唯一键与真实数据不符
create_missing_tables.py 依据 registry 的 unique_keys 建 UNIQUE KEY,
db.upsert 也依赖同一份声明做幂等写入 —— 但 registry 是人工维护的声明,
未必与接口实际返回一致。
新增 Scripts/bin/verify_unique_keys.py 逐个校验(含 MySQL NULL 语义:
键列含 NULL 时两行不算冲突;单列建议不成立时做 ≤3 列组合搜索),
结果 17 张里 15 张正确、2 张错误:
| 表 | 声明唯一键 | 实测 | 处置 |
|---|---|---|---|
us_basic |
["ts_code"] |
19 行冲突 | 改为 ["ts_code","list_date","name"](0 冲突);DB 索引重建后重载 6,000 行 |
fut_wsr |
["trade_date","symbol","warehouse"] |
181 行冲突,无自然唯一键 | 从排期摘除;已清掉在错键下写入的 1,849,006 行失真数据 |
us_basic冲突原因:同一 ticker 会被不同公司复用(AEM/ABX/AGI等)。 旧键导致静态表replace写入报(1062, Duplicate entry),整批失败。fut_wsr冲突原因:同一仓库同一日存在多条仓单记录(vol 不同), 走唯一键 upsert 会互相覆盖 → 静默丢行(比报错更危险)。
新增 Scripts/bin/inspect_dup_rows.py 用于打印重复行、辅助确定真实唯一键。
P2-22 资源复测(交易时段完成,2026-09-19 10:08,盘中)
| 进程 | 旧交易基线(09:32, 优化前) | 本次盘中(10:08) | 说明 |
|---|---|---|---|
| 主进程 UNIX 调用/s | 5092.5 | 0.2 | 大幅下降 |
| 采集器 UNIX 调用/s | 658.1 | 1.6 | 大幅下降 |
| MySQL UNIX 调用/s | 8932 | 48.2 | 大幅下降 |
| 突发峰值(-g 1 -n 32) | 2503(改造前)/ 15(改造后非交易) | 221 | 盘中本就比非交易忙,221 远低于 2503 |
| MySQL RSS | 1870 MB | 2959.7 MB | 非泄漏:缓冲池配置 2GB,三库数据 17.8GB(较重构前 16.89GB 增加,来自 17 张新表 + 1450 万行) |
结论:无回归。 主进程/采集器/MySQL 的调用率均远低于优化前的交易时段基线; MySQL RSS 上升可由缓冲池命中(2GB 上限)与数据量增长完全解释。
方法学提醒(臣本次踩到):debug.log 的时间戳不含日期,
用「小时区间」过滤会把前几天同小时的记录算进来 —— 臣据此一度误报
「采集器 09:00–10:59 崩溃 40 次」。实际时间线显示当天最后一次停止是 06:47,
当日零次异常停止。按小时过滤前必须先确认时间基准。
P1-11 搜索提速:评估结论为「不采纳」(2026-09-19 23:00)
现状测量(先测再改)
api_search 对 5 张 ACTIVE_TABLES(news_cls / news_wallstreetcn / news_sina /
news_gelonghui / news_hotlist,合计约 40 万行)用
title LIKE '%q%' OR content LIKE '%q%',无法走索引;且 data 与 count 各扫一遍。
实测 count 语句 0.20–0.23s,单次搜索约 0.4–0.5s。
尝试的优化与否决依据
库内有 ngram 全文检索插件(ngram_token_size=2,STATUS=ACTIVE)。
拟用 MATCH(title,content) AGAINST(... IN BOOLEAN MODE) 走索引预筛、再用 LIKE 复核,
期望「结果不变、速度提升」。新增 Scripts/bin/verify_fulltext_equivalence.py
对 9 个查询做四写法对照,结果不等价:
| 查询 | LIKE(基准) | 两阶段 OR | 差异 |
|---|---|---|---|
股(单字) |
54,015 | 1 | ★ −54,014(token_size=2,单字无法命中) |
A股(中英混合) |
5,882 | 0 | ★ −5,882(跨中英边界无法成 gram) |
人工智能(纯中文 4 字) |
3,923 | 3,916 | ★ −7(最优情形也不精确) |
股市/涨停/央行/美股/IPO/5G |
— | 相等 | OK |
裸 MATCH(OR 语义)同样不是 LIKE 的超集。
处置
- 不修改
api_search(保留 LIKE 的连续子串语义,正确优先) - 撤掉为验证而建的 5 个
ft_title_contentFULLTEXT 索引 (无查询使用,只会增加高频写入表的写入与重建开销);news_flash/news_flash_archive上原有的同名索引保持不动 - 速度提升确实存在(0.25s → 0.03s),但静默丢结果比慢更糟,故不采纳
- 保留
verify_fulltext_equivalence.py作为可复跑的决策证据
若确有提速需求,可选路径为刻意改变检索语义(产品决策 + 前端提示同步) 或应用侧倒排索引,均属独立工作量。
P2-20 findatahub-client:顺带查出 SQL 注入(2026-09-19)
为什么它长期没有 venv、也没有测试
pyproject.toml 依赖 findatahub-core,但没有 [tool.uv.sources] 指向本地路径,
而该包不在 PyPI 上 → uv sync 直接失败:
error: No solution found when resolving dependencies
cause: Because findatahub-core was not found in the package registry ...
已补本地源(与 tushare 一致,不加 editable)与 [dependency-groups] dev;
安装用 uv sync --no-editable(默认 editable 构建本项目自身会触发已知 EEXIST 缺陷)。
SQL 注入(实测可复现)
两处把调用方传入的名字直接拼进 SQL、未校验:
-
export(**filters)的过滤键 —— 传入"x` = 1 OR `1`=`1` -- "作为键名时生成:SELECT * FROM `stock_daily` WHERE `x` = 1 OR `1`=`1` -- ` = %sOR 1=1生效、后续条件被注释 → export 返回整张表。 -
daily/weekly/monthly/fina_indicator的fields(列名列表) —— 同类突破。
修法:新增 Client._check_identifier()(^[A-Za-z_][A-Za-z0-9_]*$),
在这两处先行校验,非法即 ValueError 且不产生任何 SQL。
测试与 CI
新增 Services/findatahub-client/tests/(38 条,全部离线不连库),
含安全回归(修复前 10 条 FAIL、修复后全绿 —— 构成证伪)。
CI 同步补全:compileall 增加 findatahub-client/src、tushare_fetcher/src、
akshare_fetcher/src;测试从「仅 Scripts/bin」扩为五个套件(本地合 1140 条全绿),
kuaixun 因可能依赖本机服务单列为非阻塞。
P2-19 超大文件拆分(首批)
按仓库既有约定(「超大文件用 extension 拆到多个文件,不拆成独立类型」)拆分
App/Sources/FinDataHub/Services/NewsFlashService.swift:1054 → 821 行,
新增三个按关注点的 extension 文件:
| 新文件 | 关注点 | 行数 |
|---|---|---|
NewsFlashService+Probe.swift |
免 spawn 进程探测(P0-3 节能) | 67 |
NewsFlashService+Exec.swift |
子进程执行(含 30 秒超时守护) | 87 |
NewsFlashService+Fetch.swift |
脚本路径解析 + 快讯拉取 | 113 |
必须同步调整可见性(Swift 的 private 是文件作用域):
搬走的方法在新文件里由 private func 放宽为 func;主文件
private lazy var cachedPython/cachedScriptBase 改为 private(set) lazy var。
首次编译得到 30 条 inaccessible due to 'private' protection level,
逐条定位修正后 swift build -c release → Build complete!(0 error)。
剩余待拆(未做):akshare_fetcher/core/fetcher.py(1035)、
Scripts/bin/newsflash_collect.py(962)、omnidata_sources_fetcher.py(955)、
PythonProcessManager.swift(842)、DatabaseSetupGuideView.swift(807) 等。
这些文件承载数据采集与进程管理的核心逻辑,拆分需逐文件评估依赖并逐一编译验证,
不适合批量机械处理。
新增 P0:stuck 误报导致采集器被反复自动重启(2026-09-19 23:20 修复)
机制(这是「一天被判定异常停止 193 次」的根因)
get_status() 用「数据库最后写入距今 > 300s」判定 stuck;
而非交易时段采集器进入节能模式,采集间隔由 30s 退避到 3600s
(日志 [eco] 非交易时段,采集间隔 30s → 3600s)、期间刻意不写数据
→ 每个非交易小时里约 55 分钟被误判为卡死。
同时 AutoRecoveryManager.swift:166:
if st.stuck == true { return .collectorStuck }
即 stuck 被当作可恢复故障 → 自动重启采集器。
实证时间线(今日 debug.log):20:27 / 20:46 / 21:05 / 21:24 / 21:49 / 22:11 呈规律性停止,间隔与健康检查周期一致。
修法
stuck 改由心跳判定(心跳线程每 60s 刷新、长休眠时也活跃;Swift 侧的免 spawn
探测本就是读心跳的,本次把 CLI 路径与之统一):
- 心跳新鲜(≤3×60s)→ 不卡死
- 心跳过期/缺失 → 回退判定,阈值取 4200s(>节能上限 3600s + 余量)
- 无写入时间可比时(空库 / DB 读取异常)保守判为不卡死 ——
stuck会触发自动重启,误判代价高于漏判 last_write_time语义不变,仍如实反映数据写入
顺带修解析脆弱性:DB 返回 datetime 对象时 str() 带微秒,
原 strptime("%Y-%m-%d %H:%M:%S") 抛 unconverted data remains: .219352;
新增 _parse_ts() 容错。
验证
新增 6 条用例(核心回归:节能空闲 1 小时但心跳鲜活 → 不得判 stuck,
旧逻辑必判 true);5 条旧契约用例同步更新。部署位实测
{"stuck": false} 而 last_write_time 仍为 26 分钟前 —— 语义正确。
附带:kuaixun E2E 与采集器撞锁
test_unified_writer_e2e 真实写 news_unified,与在跑的采集器撞锁:
(1205, 'Lock wait timeout exceeded')。它此前"通过"只因采集器当时停摆 13 小时 ——
修好采集后反而暴露。已加守卫:采集器心跳活跃时 pytest.skip 并说明原因,
而非任其随机失败。kuaixun 耗时 54s → 3.9s。
本批(第 3/4/5 项)交付汇总
| 项 | 结果 |
|---|---|
| 3a P1-11 搜索提速 | ✅ 已评估并否决(实测 ngram 与 LIKE 不等价,会静默丢结果) |
| 3b P2-19 超大文件拆分 | ✅ 首批:NewsFlashService.swift 1054→821 + 3 个 extension(编译验证) |
| 3c P2-20 client | ✅ 建 venv + 38 条测试 + 修复 SQL 注入 + 登记 CI |
| 4 三表参数接线 | ✅ 已验证增量路径正确(opt_daily 1049 万行 14s 完成增量) |
| 5a venv 软链接脆弱 | ✅ 已修:运行时内联(裁至 59M)+ 修 shebang/.pth,--check 可作门禁 |
5b 打包漏 common/ |
✅ 已修(并更正:桌面后端不导入 app/main.py,未造成当前故障) |
| 5c 凭据轮换 | ⬜ 须外部操作:Tushare token 需在 tushare.pro 后台重置;MySQL 口令轮换需同步改 env 文件 + 7 个 launchd plist,属运维窗口动作 |
测试总量:1147 passed(core 122 / client 38 / tushare 174 / akshare 183 / Scripts 220 / kuaixun 410)。