Files
FinDataHub/FinDataHub-未解决问题清单-2026-09-17.md
FinDataHub 83f1546074 docs: 记录 stuck 误报根因与本批(3/4/5 项)交付汇总
- 清单:新增 P0 章节(stuck 误报机制 + 6 条用例 + 撞锁守卫),
  并补本批交付汇总表(含 5c 凭据轮换仍须外部操作)
- 记忆:stuck 误判 → 自动重启的完整机制与修法;E2E 与采集器撞锁
2026-09-19 23:10:38 +08:00

60 KiB
Raw Permalink Blame History

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 (日更实际执行者)都属于前者。

修法(三层)

  1. 写库层护栏(findatahub_core/db.py):normalize_date_columns() 挂在 upsert()/replace() 上。放共享基类可一次覆盖两个 fetcher 与清洗层 —— 比逐个修 20+ 个采集脚本可靠,新脚本也不会再引入。
  2. 源头修正:sync_daily_tasks.py / backfill_daily.py / fix_stock_daily.py 补 .dt.strftime('%Y%m%d'),并把比较口径同步改掉(原先用 'YYYY-MM-DD' 与 datetime64 比较,格式一变就永远匹配不到)。
  3. 迁移工具: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 可用,备份本身可行。

下一步(顺序不可颠倒)

  1. 先做一次真实库备份(mysqldump 三库,或至少覆盖 66 张待改表);
  2. 在非交易时段(避开 15:35 / 15:40 / 18:00 三个 launchd 任务)逐库逐表执行;
  3. 每表执行前后记录 information_schema 的 collation 与行数,逐表校验;
  4. 建议统一到 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_holdertrade 51,030 → 52,937 行(最新 20260618 → 20260918) repurchase 43,584 → 45,901 行(最新 20260618 → 20260918)
  • 建审计机制:audit_schedule_coverage.py —— 这类缺陷的特征就是静默, 必须有主动对账,不能靠人发现

仍未处置(需单独决策,本次已查明)

  1. 剩余 7 张中 6 张表根本不存在(从未建表): hk_hold / shibor / stk_managers / stk_rewards / ths_daily / us_daily → 需先建表 DDL 再首次全量加载,属独立工作量。
  2. stk_factor:接口限 5 次/天,无法批量抓取(fetcher.py:306 已显式跳过)。 已把 config 改为 false 并注明原因 —— 避免长期"假启用"。
  3. **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)。


本批更正(臣先前报告中的不准确引用)

  1. Services/kuaixun-service/extend/fetchers/collector.py 并不存在 (该目录下无 collector.py,只有 app/routers/collector.py)。 先前条目中引用此路径的"句柄未关"实指 Scripts/bin/newsflash_collect.py 的后端 stderr 句柄,已按后者修复。
  2. fetcher 的 except Exception as e: print(...) 并非"静默吞错" —— 抽查 wallstreetcn.py / jin10.py 等,异常信息都有打印、e 也被使用。 先前"静默吞错"的描述不成立。
  3. 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_inst 964,214→1,008,371 行、index_weekly 542,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 停摆是同一类缺陷。

处置

  1. 新增 Scripts/bin/create_missing_tables.py(list / dry-run / create,幂等)
    • tables_registry.json 只登记表名/唯一键/日期列、不含字段定义,仓库也无通用 DDL 生成器 → 从 Tushare 接口实际返回的 DataFrame 推导列与类型, 保证 DDL 与写入时的 df.columns 一致(否则 upsert 整批失败)
    • 入参用阶梯重试:不同接口要求不同入参,且有 1 次/分钟限流,单次失败≠不可用
  2. 建成 17 张,首次加载已入库 3,893,727 行(精确 COUNT(*)) fut_holding 1,800,000 / fut_daily 960,979 / fut_settle 850,048 / fut_wsr 250,207 / fut_trade_cal 13,527 / fut_basic 10,000 / fut_mapping 5,000 / hk_basic 2,785 / rate_gz_index 1,181;余 8 张后台继续
  3. 摘除 7 张确认不可采并在 config.yaml 标注实测报文:
    • 无接口访问权限:libor、ths_index、ths_daily、stk_factor
    • 配额 1 次/小时(无法按日迭代):hibor、shibor_lpr、us_daily

复核

  • 排期涉及的 80 张表在库中全部存在 → 日更不再有空转行
  • 15 条 fetch 命令 dry-run 全部可解析;config 经 YAML 解析校验

本批修正的两处自身错误

  1. 探测时把 TABLE_MAP 元组里第 1 个引号串当 api_name(实为库表名), 使 shibor/stk_managers 等被误判「接口名错误」—— 第 2 个才是 api_name。
  2. 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_content FULLTEXT 索引 (无查询使用,只会增加高频写入表的写入与重建开销); 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、未校验:

  1. export(**filters) 的过滤键 —— 传入 "x` = 1 OR `1`=`1` -- " 作为键名时生成:

    SELECT * FROM `stock_daily` WHERE `x` = 1 OR `1`=`1` -- ` = %s
    

    OR 1=1 生效、后续条件被注释 → export 返回整张表。

  2. 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)。