- 未解决问题清单:P1-8(门控 reroute)、P1-11(搜索 total)标记为已修; 修正编号混用;追加「本批更正」三章 - 复查报告:补充第二批 6 项闭环 + 两项新修的状态表,并说明两套 P1 编号的对应关系 - 记忆:记录根因、修法、证伪结果,以及三处自我更正与生产实测证据
33 KiB
FinDataHub 代码与架构复查报告
复查日期:2026-09-17 | 版本:v1.5.1 | 范围:App(Swift 19,616 行)/ Services(6 个 Python 服务,22,118 行)/ Scripts(35 个脚本) 方式:静态代码取证 + 运行时取证(进程、日志、MySQL 实库、launchd 实况) 基线对照:
FinDataHub-代码分析与优化建议.md(v1.4.7,08-09)、security-audit-report.md(08-30)
〇、修复进展(2026-09-17 当晚,已落地并回归)
第一批 5 项已改完,每项都补了能证伪的回归用例(先确认旧实现下用例会失败,再确认新实现通过)。
| 项 | 改动文件 | 验证结果 |
|---|---|---|
| P1-4 时间戳截断 | Scripts/bin/common.py |
split(":", 1);旧实现下新用例失败于 '2026-09-17T18' == '2026-09-17T18:24:52.273919' |
| P0-2 增量写入清表 | Services/tushare_fetcher/.../core/fetcher.py |
增量走 upsert、全量才 replace;旧实现下新用例捕获到 mode=replace table=dividend |
| SSRF 重定向绕过 | Services/kuaixun-service/extend/content_enricher.py |
follow_redirects=False + 逐跳校验 + 5 跳上限;旧实现下新用例实测到「内网正文被返回」 |
| P0-1 熔断缺口 | App/.../AutoRecoveryManager.swift |
新增长窗口崩溃计数 + 稳定期门控;仅语法解析通过,未编译验证(沙箱拦截 SwiftPM) |
| P0-1 崩溃现场 | Scripts/bin/newsflash_collect.py |
faulthandler + sys/threading.excepthook 落 traceback,且不污染 stdout |
| P1-3 连接池泄漏 | Scripts/bin/db_pool.py、Scripts/bin/newsflash_backend.py |
取连接加超时、归还需 rollback、失败归还池位;实测无超时版本被 5s 强杀仍未返回 |
| 既有偶发用例 | Services/tushare_fetcher/tests/unit/test_api.py |
TestThrottle 两处 < 边界断言改 approx + <=;实测 time.time() 相邻调用 97.3% 返回同一值(ULP 2.4e-7s)导致 elapsed==0、等待量恰等于 interval。修前 4 次跑 3 次红,修后连跑 10 次全绿,且去掉节流等待后仍能报红 |
回归(最终,连续多轮稳定):
| 套件 | 结果 |
|---|---|
Scripts/bin |
207 passed |
Services/findatahub-core |
105 passed, 2 skipped |
Services/tushare_fetcher |
174 passed(连跑 5 次均全绿) |
Services/kuaixun-service |
405 passed, 1 skipped, 1 xfailed |
Services/akshare_fetcher |
183 passed, 4 skipped |
| 合计 | 1074 passed |
另修好一处环境缺口:kuaixun-service 的 pytest-asyncio 声明了但未安装,导致 9 条 async 用例长期为红。
验证边界(须如实知悉):Swift 改动只做了语法解析校验(xcrun swiftc -parse 通过),
本会话内 swift build 被沙箱拦截(SwiftPM 自身调用 sandbox-exec 报
Operation not permitted,加授权开关亦无效),因此 AutoRecoveryManager.swift 尚未通过类型检查与编译,
需在正常终端跑一次 swift build 才算验证完成。
未完成项:P0-4(Pipeline 是否接入日更,需产品决策)、P0-5(凭据轮换需在 Tushare 后台操作)、P0-6(字段类型统一,涉及数据迁移)、P0-7 的正式入库、P1-1/P1-2(门控与管线,因 P0-4 未决而暂缓)、P1-5~P1-8、P2 全部。
第二批(09-18):6 项闭环 + 2 项新修
| 项 | 结果 |
|---|---|
| P0-1 部署 | ✅ 已部署 v1.5.2(逐项核对新包修复标记 + 二进制符号;旧版留 597M 回滚点) |
P0-3 dividend 历史 |
✅ 19 行 → 216,364 行。根因更深:ann_date 是单日点查询而非区间,该表结构上就装不下全量;改为按报告期 end_date 逐季分页幂等 upsert |
| P0-5 凭据明文 | ✅ 移出 7 个 plist(原报 5 个,已更正)→ ~/.config/findatahub/env(0600),全部 LaunchAgent 明文归零 |
| P0-7 版本控制 | ✅ 3 个提交;未跟踪 159→0、core 索引 0→45、App 74/74、CI 前三步实跑全通 |
| P1-10 周/月线脱节 | ✅ 数据补齐(周线→20260911、月线→20260831)+ 银层迁移与分红刷新固化进 tushare_sync.sh |
| P1-14 Swift 未编译 | ✅ swift build --disable-sandbox 通过并部署 |
| P1-1 门控 reroute 不收敛 | ✅ 已修:Gate3 不再为 OHLC 违规 reroute(回传恒等上游不可能收敛),改 warn;新增 OHLC_SYSTEMIC_RATIO=0.05 仅在系统性违规时 block。3 条证伪用例 |
| P1-6 搜索 total 契约 | ✅ 已修:total 取真实命中数(实测 63321 与 SQL 直查一致),新增 returned 字段。2 条证伪用例 |
编号说明:本节沿用本复查报告的 P1 编号(P1-1=门控 reroute、P1-6=搜索接口);
FinDataHub-未解决问题清单用的是另一套序号(其 P1-8=门控 reroute、P1-11=搜索接口)。 两处指同一组问题,以文件名区分。
本批三处自我更正(详见未解决问题清单末章):
① 原称"reroute 分支漏 pop 致残留步骤重复执行"——实测不符,该处非缺陷,代码未改;
② 原用 news_flash 统计做对比,而该表不在 ACTIVE_TABLES 内,口径错误(正确为 63,321 行;差 3,166 倍仍成立);
③ 原称 external.py 与搜索接口"同构"——实测其走 dal.get_live_news(),本就返回真实 total,无缺陷。
验证方式升级:本批发现 swift build 的失败源于 SwiftPM 自身调用 sandbox-exec,
--disable-sandbox 即可绕过——原先"只能做语法解析"的降级方案作废。
一、整体评估结论
一句话:低层代码缺陷已基本清完,但"运行期故障无人告警"与"文档声称的能力未在生产链路生效"这两类问题已实际造成数据与时间损失。
三个月前那份报告点出的问题,多数已真正修掉——强制解包从遍布全仓收敛到 5 处、环境变量注入已统一到 dbEnv()、SettingsView 的 2467 行已拆分、client.py 的 stats() 缺陷已修、SimHash 已弃用内置 hash()、except: pass 静默吞错已改为带日志的精确捕获。CI 也真的建起来了(Python 3.12 一致性 + 语法 + 单测)。这说明迭代纪律是有效的。
但本次复查发现一个结构性问题:问题类型已经从"写错代码"转向"写对了代码,但它没在跑"。
三条主结论:
-
运行期存在持续性故障,且当前无有效告警捕获它。 快讯采集器在近一天内被判"异常停止" 193 次、后端"状态检查连续失败" 174 次,采集器 stderr 里积累了 206 次 jieba 初始化却零条堆栈。进程真在反复重启(实测 PID 5 分钟内由 5472 → 19658),但熔断机制因为"每次恢复都算成功"而永不触发。这不是误报,是崩溃循环 + 熔断设计缺口。
-
数据治理链路(七步 Pipeline / 四级门控 / 血缘 / 交叉验证)不在生产路径上。 生产日更实际由 launchd 的
com.tushare.dailyupdate(15:40) 与com.akshare.dailyupdate(18:00) 驱动,命令是bash scripts/daily_update.sh,直写tushare_data/akshare_data,不过任何门控;App 侧同步走sync_bridge.py→ fetcher CLI,同样绕过门控。号称门控入口的flows/pipeline_runner.py引用方只有文档和一份安装脚本,~/Library/LaunchAgents里没有com.findatahub.pipeline.daily.plist。实证后果:fin_data_clean.stock_weekly停在 20260727,而源库akshare_data.stock_weekly已到 20260824——清洗层周线落后近一个月,且无 SLA 保障。 -
有确凿的数据损坏已发生。
tushare_data.dividend(全市场分红表)只剩 19 行,且全部集中在 20260604 一天。根因是"先按last_date + 1拉增量、再调db.replace()",而replace()的实现是DELETE FROM tbl+ INSERT——增量数据把历史全表覆盖掉了。
同时有两处**潜伏缺陷(尚未触发)**已定位,若不处理,一旦链路切换即造成不可逆损坏:
- akshare 侧
df["vol"] = df["amount"]使 vol 恒等于 amount,正好命中清洗器"伪造成交额"判据(实测最近交易日 5208/5547 = 94% 命中),会把成交额全部清零。目前fin_data_clean未被污染(amount_zero = 0),因为该清洗分支根本没跑;一旦跑,94% 的成交额即刻报废。 - Gate3 因 OHLC 违规 reroute 回 Gate2,而 Gate2 对同一违规只置
warn / passed=True不改数据 → 复查结果恒等 → 重试计数累加超限 → 整批数据被阻断丢弃。含 NaN 的行必然触发(NaN 比较恒 False)。
资源面:主进程 RSS 179 MB、UNIX 调用 237/s;MySQL RSS 2,860 MB。对照项目记忆里的非交易时段基线(RSS 78–86 MB、UNIX 0.2–33/s、MySQL 470 MB),主进程 UNIX 调用高约一个数量级、MySQL 内存约 6 倍。此数据在非交易时段采集,需在交易时段复测才能定性为回归,暂列观察项而非结论。
优先级总览
| 级别 | 数量 | 性质 | 建议窗口 |
|---|---|---|---|
| P0 | 7 | 数据损坏 / 崩溃循环 / 凭据泄露 / 已失败的契约 | 立即(本周) |
| P1 | 8 | 确定性缺陷、性能瓶颈、结构性脱节 | 2–3 周 |
| P2 | 7 | 工程卫生、覆盖缺口、清理项 | 随迭代 |
二、P0 — 立即处理
P0-1 快讯采集器崩溃-重启循环,熔断器永不触发
位置
- 判定:
App/Sources/FinDataHub/Services/NewsFlashService.swift:313-320 - 熔断:
App/Sources/FinDataHub/Services/AutoRecoveryManager.swift:37-51, 218, 246-249
证据
~/Library/Logs/FinDataHub/debug.log
[ERROR] [NewsFlash] 采集器异常停止 → 193 次
[ERROR] [NewsFlash] 后端状态检查连续失败,服务可能不可达 → 174 次
[INFO] [AutoRecovery] 自动恢复 快讯采集器:进程异常退出(第 1 次尝试)
~/Library/Logs/FinDataHub/newsflash/collector-stderr.log (824 行)
"Building prefix dict from the default dictionary ..." → 206 次
(全文除 jieba 加载日志外,无任何 Traceback / Error)
实测:pgrep 采集器 PID 5472 → 19658 (5 分钟内变更)
机制(双层缺陷)
- 崩溃自身无诊断输出:stderr 里只有 jieba 反复初始化,没有任何异常堆栈。说明进程要么被信号杀死、要么输出在管道/缓冲层丢失——现有手段无法定位崩溃原因,这本身就是最严重的问题。
- 熔断只保护"恢复失败",不保护"恢复后再次崩溃":
verifyRecovery()在恢复成功时执行state.attempts.removeAll()(AutoRecoveryManager.swift:246-249),于是"恢复成功 → 计数清零 → 十分钟后再崩 → 再成功"形成无限循环,maxAttemptsPerWindow = 3与circuitCooldown = 1800永无机会生效。
影响:每 10–20 分钟一次采集空窗;每次重启重新加载 jieba(约 0.25 s + 常驻内存)、重建 MySQL 连接、重建通知轮询;日均 193 次 ERROR 淹没日志,真实故障信号被噪声掩盖。
建议
- 采集器入口加
faulthandler.enable(),并在顶层包try/except BaseException落盘完整堆栈 + 退出码;用start_new_session=True启动后,确认不是被父进程的进程组信号误杀。 - 熔断改为按**"同因崩溃次数"**计数:
verifyRecovery成功后不清零attempts,而是记录"最近 1 小时崩溃次数",超阈值(如 5 次)即熔断并推告警。 - 给采集器加"上次退出码 + 退出原因"落盘,供 App 侧展示。
P0-2 replace() 增量写入清空全表 —— 数据已实际损坏
位置
- 调用:
Services/tushare_fetcher/src/tushare_fetcher/core/fetcher.py:330-356(_fetch_static) - 实现:
Services/findatahub-core/src/findatahub_core/db.py:287-300
证据
# fetcher.py:337-344 —— 先按增量日期拉取
if date_col and default_params:
last_date = self.db.get_last_date(table_name, date_col)
if last_date:
default_params[param_key] = (strptime(last_date) + timedelta(days=1)).strftime("%Y%m%d")
...
# fetcher.py:352 —— 再全表覆盖
if use_generic:
self.db.replace(df, table_name)
# core/db.py:288,300 —— replace 的语义
"""DELETE+INSERT 覆盖写入."""
cursor.execute(f"DELETE FROM `{safe_tbl}`")
实库后果(决定性证据)
tushare_data.dividend
行数 19 | 最早公告日 20260604 | 最晚公告日 20260604 | 涉及股票 16 只
全市场分红表只剩一天的 19 行。
dividend正位于DATE_COLUMN_MAP中(ann_date),走的就是use_generic → replace()分支。
对照:stk_holdernumber 17,379 行、repurchase 43,584 行尚存数据,说明损坏范围取决于增量拉取是否返回空——dividend 只是第一个被发现的,不是唯一有风险的。
影响:全表历史被增量覆盖,且不可逆(本地无备份则无法恢复);下游任何依赖分红历史的分析全部失真。
建议
_fetch_static的增量路径必须改 upsert,replace()只保留给真正的全量快照表。- 立即为
DATE_COLUMN_MAP中全部 17 张表做一次行数/日期跨度体检,确认损坏范围。 - 在
replace()上加"写入行数不得低于原表行数 X%"的保护断言,防同类误用。
P0-3 akshare 成交额修正逻辑会 100% 清零(潜伏未引爆)
位置
- 制造条件:
Services/akshare_fetcher/src/akshare_fetcher/core/fetcher.py:187, 221, 903(df["vol"] = df["amount"]) - 触发判据:
Services/findatahub-core/src/findatahub_core/cleaner.py:129-148(_fix_akshare_broken_amount)
证据
# fetcher.py:187(日线,注释自陈"腾讯没有 vol,用 amount 估算")
df["vol"] = df["amount"]
# cleaner.py:139-146
mask = ((df["amount"] > 0) & (df["vol"] > 0) &
(df["amount"] - df["vol"]).abs() / df[["amount","vol"]].max(axis=1) < 0.01)
...
df.loc[mask, "amount"] = 0.0 # 判为"伪造成交额",清零
vol == amount 时,|amount-vol| / max = 0 < 0.01 → 判据对全部行成立。
实库佐证
akshare_data.stock_daily(最近交易日)
总行数 5547 | amount=0 共 0 行 | vol == amount 共 5208 行(94%)
fin_data_clean.stock_daily(同一批)
总行数 5550 | amount=0 共 0 行 | vol == amount 共 0 行
判据命中率 94%,但清洗层目前干净——因为该分支依赖 Pipeline,而 Pipeline 不在生产调度上(见 P0-4)。这是"因为没跑所以没坏",不是"修好了"。
影响:一旦 Pipeline 接入日更或有人手工跑一次,akshare 源 94% 以上成交额即刻归零;且后续 cross_validator 会因 amount == 0 而静默跳过成交额比对,污染会一路无声传递。
建议
- 腾讯
hist_tx无 vol 字段时置 NULL,不要复制 amount(宁缺勿伪)。 - 清洗判据加二次确认:
amount/vol得到的隐含单价须落在合理区间(如 [0.1, 10000] 元/手)才判伪,单靠"两值相等"过于脆弱。 - 从源头补一行回归测试:构造 vol==amount 的 DataFrame,断言
amount不被清零。
P0-4 数据治理链路不在生产路径上,文档与实现长期背离
证据
生产调度实况(~/Library/LaunchAgents):
com.tushare.dailyupdate 15:40 → cd .../tushare_fetcher && bash scripts/daily_update.sh
com.akshare.dailyupdate 18:00 → cd .../akshare_fetcher && bash scripts/daily_update.sh
com.ths.dailyarchive 15:35 → bash scripts/ths_archive_daily.sh
com.findatahub.cloudsync 每 600s → flows/cloud_sync.sh
门控入口 pipeline_runner.py 的引用方(ripgrep 全仓):
Docs/pipeline/EXECUTION_PLAN.md、Docs/pipeline/rate_limit_pull_plan.md
Services/findatahub-core/config/launchd/install.sh ← 仅安装脚本
(自身定义除外,无任何生产调用)
~/Library/LaunchAgents 中不存在 com.findatahub.pipeline.daily.plist
App 同步链路:PythonProcessManager → sync_bridge.py → fetcher CLI(同样不过门控)
新鲜度实证(清洗层 vs 源层)
| 表 | fin_data_clean 最新 |
源层最新 | 差距 |
|---|---|---|---|
| stock_daily | 20260916 | tushare_data 20260916 | 同步 |
| stock_weekly | 20260727 | akshare_data 20260824 | 落后约 1 个月 |
| stock_monthly | 20260731 | — | 落后 |
| akshare_data.stock_daily | — | 2026-09-02(tushare 到 20260916) | 源侧落后 14 天 |
影响:门控、质量分、血缘、双源交叉验证这四项被反复宣传的核心能力,在日更链路上均不生效;fin_data_clean 的更新依赖一次性迁移脚本,周/月线已实质脱节;akshare 日更失败 14 天无人告警。
建议:走 A(接入),不建议 B(降级)。
- A 接入(推荐):把
pipeline_runner.py装入 launchd,让它成为fin_data_clean的唯一写入方;旧 fetcher 的直写降级为 raw 层(本就该是 raw 层,fin_data_clean的存在意义就是被清洗后的数据)。理由是前置条件已基本具备:门控/血缘/交叉验证/清洗器的代码都在且多数已有测试(core 侧 6 个测试文件),db.py下沉模式已验证可用,缺的只是"挂上调度 + 修 P0-2/P0-3/P1-1/P1-2 四个阻断性缺陷"。B 方案要放弃的是这个项目相对"脚本集合"的核心差异化能力,代价高于修四个缺陷。 - B 降级:仅当短期内无人维护清洗层时才考虑,且必须同步改写文档,停止声称门控在线上生效。
两方案共同的前置动作(不依赖决策,立即做):为 launchd 日更加失败告警——akshare 日更已静默失败 14 天,这是本次复查中性价比较高的一个动作,且不依赖 P0-4 的方案选择。
P0-5 生产凭据明文写在 launchd plist
位置:~/Library/LaunchAgents/com.{tushare,akshare,ths}.*.plist 的 ProgramArguments 数组内
证据(已脱敏)
"ProgramArguments" => [ "/bin/bash", "-c",
"cd /Applications/FinDataHub.app/... && export PATH=... \
TUSHARE_TOKEN=<REDACTED-40位> MYSQL_PASSWORD=<REDACTED> && bash scripts/daily_update.sh" ]
影响:同机任意用户态进程均可读取;会随备份、截图、终端回滚、日志归档外泄。本次复查即因此直接读到明文。tushare token 与 MySQL root 口令同时暴露。
建议
- 立即轮换 Tushare token 与 MySQL 口令。
- 凭据移出 plist:新建
~/.config/findatahub/env(chmod 600),plist 内改为set -a; . ~/.config/findatahub/env; set +a;更稳妥是用 Keychain +security find-generic-password -w。 - 复查
install_collector_launchd.py是否有同类明文写入(本次未逐一展开该脚本的模板)。
P0-6 日期列同列混存两种格式 + 跨库排序规则冲突(严重性已上调)
首版将此条描述为"跨表格式不一致",低估了严重性。核实后:同一列内即混存两种格式, 且范围过滤会静默丢掉 60% 的当年数据。完整复现见
FinDataHub-未解决问题清单-2026-09-17.mdP0-5。
证据
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 行中,标准范围过滤 BETWEEN '20260101' AND '20260930'
只命中 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
跨库 UNION 实测:ERROR 1271 (HY000) Illegal mix of collations for operation 'UNION'
影响:所有按 trade_date 的等值/范围过滤(含增量同步与回测日期切片)在 2026-06-12 之前的 tushare 数据上全部失效且不报错;MIN()/MAX() 因字符串比较而不可信(- 编码 0x2D < 0 编码 0x30);跨源对账不可用;fin_data_clean 全为无横线且最早止于 20210104,2021 年前历史缺失。
建议:统一为 CHAR(8) 无分隔符 + 统一三库 collation + 写入侧格式断言 + 迁移前后行数校验。与 P1-5(clean 层缺 2021 年前历史)应同批处理。
P0-7 版本控制严重失效:核心库与 20 个 App 源码从未入库,CI 无法通过
证据(按 git 索引统计,非工作区)
$ git status --short --untracked-files=all | grep -c '^??' → 159(约 8.5 MB)
$ git ls-files Services/findatahub-core/ → 0 条 ← 整个核心数据库未入库
$ git ls-files Services/findatahub-client/ → 0 条
$ git ls-files Scripts/bin/ → 0 条(大写拼写;小写 scripts/bin/ 有 31 条)
$ git ls-files App/Sources → 54 条 / 磁盘 74 个 .swift(20 个未入库)
HEAD = 4b62693 "chore: 完成2026-08-03安全审计报告并修复相关漏洞"
从未入库的关键资产
Services/findatahub-core/src/findatahub_core/全部 18 个模块(pipeline / gate / cleaner / db / cross_validator / health / retry …)——一次全新 clone 拿不到核心数据层Services/findatahub-client/全部- 20 个 App 源码:
AutoRecoveryManager、全套Settings*Section(9 个)、PowerSaver、MySQLOnDemandManager、DependencyScanner、BootstrapViewModel等 Scripts/bin/check_python_version.py、perf_snapshot.py、install_collector_launchd.py、mysql_toggle.sh
连带后果:CI 形同虚设(已静态验证)
.github/workflows/ci.yml 的步骤引用了并不存在于索引中的路径:
31: run: python3 Scripts/bin/check_python_version.py --strict ← 文件不在索引中
36: Scripts/bin \ ← 索引中只有小写 scripts/
37: Services/findatahub-core/src \ ← findatahub-core 索引 0 条
47: run: python3 -m pytest Scripts/bin/tests -q
即:在任何全新检出(尤其 Linux runner)上,CI 第 1 步就会因文件不存在而失败。此前把"CI 已建立"记为工程改进是不成立的——配置写的是工作区现状,而不是已提交的内容。
另一个隐蔽陷阱:索引路径大小写不一致
索引中该目录记为 scripts/(小写),工作区是 Scripts/(大写 S),而 core.ignorecase = true(macOS 默认)让本地 git 视其为同一文件。后果:
git diff -- Scripts/bin/common.py静默返回空,看起来"没有改动",实则改动都在(用小写 pathspec 才能取到)——本次复查中我因此一度误判。- 在大小写敏感的文件系统(Linux CI、Docker 挂载)上,检出结果目录名与所有脚本/文档中的
Scripts/...引用不一致 → 路径解析失败。
处置
- ✅ 已建立完整还原点:
~/Library/Application Support/FinDataHub/backups/findatahub-worktree-20260917-184039.tar.gz(7.0 MB / 765 项,已校验收录核心库与关键未跟踪文件) - ⬜ 正式入库(本项属仓库策略决定,非纯技术动作):建议 ① 修正索引大小写(
git mv或core.ignorecase=false后重加,须在大小写敏感环境验证)② 分 3 批提交:布局迁移与删除 → 补齐 core/client/App 源码 → 性能与熔断改动 ③ 把.workbuddy/memory、.agent-teams/明确决定"入库"或"加入 .gitignore",不留悬空状态
三、P1 — 2~3 周内
P1-1 Gate3 → Gate2 reroute 不收敛,必然整批阻断
位置:gate.py:99-112(Gate2 对 OHLC 违规仅 severity="warn"、passed 不变)、gate.py:190-200(Gate3 据此触发 reroute_target="gate2_cleaning")、pipeline.py:219-224(超限置 is_blocked=True)
机制:Gate3 要求 Gate2 阻断,但 Gate2 复查结果恒等,数据不变 → reroute 计数累加至超过 max_reroute=3 → 整条链跑 3 轮后丢弃全部数据、不落库。且 NaN 比较恒为 False,OHLC 任一列存在 NaN(ffill 填不满首行或整列)即触发。
附加缺陷:reroute 分支 continue 前未执行 remaining.pop(just_completed)(pipeline.py:231-234),本轮已完成步骤以 degree=0 残留在队列,下一轮重复执行。
建议:把 Gate2 的 OHLC 严重度提为 block(与 Gate3 的期望对齐),或让 Gate3 只在"Gate2 应阻断而误判"时降级为告警;reroute 分支补 pop。
P1-2 并行步骤共享同一 context.df → 双源串源
位置:pipeline.py:169-180(ThreadPoolExecutor 向所有 ready 步骤传入同一个 context 对象)、pipeline.py:347-349(context.df = df)、context.py 仅有一个 df 字段
机制:双源模式下两个 fetch 步骤 depends_on=[] 同批并行,后完成者覆盖前者;两条分支的 gate/store 都可能读到对方的数据,且 _execute_step 并发写共享 context.current_gate,阻断日志会报错门控名。
建议:产物按步骤名存放(context.outputs[name])而非共享单槽;或在 DAG 层禁止 fetch 并行。
P1-3 连接泄漏可致后端永久挂死
位置:Scripts/bin/newsflash_backend.py:168-182、Scripts/bin/db_pool.py:34, 86-89
证据
# newsflash_backend.py:172-181
try:
conn = get_conn(); cur = conn.cursor(...); cur.execute(...)
rows = cur.fetchall(); cur.close(); put_conn(conn)
except Exception as e:
logger.error(f"[sql error] {e}"); return [] # ← conn / cur 既未归还也未关闭
# db_pool.py:34
conn = _pool.get() # ← Queue,无 timeout,池空即永久阻塞
池容量 8(_init_pool(max_conn=8))。任何 SQL 异常(表缺失、超时)丢一个连接;漏满 8 个后所有请求在 _pool.get() 永久阻塞,进程只剩线程堆积。另 DBContext.__exit__ 不 rollback,写路径半途失败会把未提交事务/行锁带回池。
建议:try/finally 归还;_pool.get(timeout=…);__exit__ 中 if exc_type: conn.rollback()。
P1-4 PidManager 时间戳被 split(":") 截断 → stuck 判定几乎恒真
位置:Scripts/bin/common.py:181-183(写)、:191(读)、消费方 Scripts/bin/newsflash_collect.py:584-592
实测复现
写入原文 : 12345:2026-09-17T18:12:13.463481
split(':') → ['12345', '2026-09-17T18', '12', '13.463481']
started_at = '2026-09-17T18'
fromisoformat → 2026-09-17 18:00:00 ← 分秒全丢
(now - started) = 733s > 600 → True
started_at 恒被截断到整点,于是 (now - started) > 600 在每小时第 10 分钟之后恒为真,stuck 判定退化为"仅由 300 s 写入检查决定"。这与 P0-1 的恢复循环叠加,构成误判放大器。
建议:split(":", 1)(一行修复)。
P1-5 周/月线清洗层与源层脱节且无告警
见 P0-4 的新鲜度实证。clean.stock_weekly 20260727 vs 源 20260824;akshare_data.stock_daily 落后 tushare 14 天。
建议:把 launchd 日更的退出码/行数写入监控表并在 App 展示;给"连续 N 天未更新"加告警。
P1-6 搜索接口 N 表 LIKE 全表扫描 + 分页契约失真
位置:Services/kuaixun-service/app/routers/public.py:41, 59-79
like_q = f"%{q}%" # 前导通配符 → 索引失效
... UNION ALL 6 张 news_* 表,每表 title LIKE :q OR content LIKE :q
sql = " UNION ALL ".join(unions) + " ORDER BY published_at DESC LIMIT :lim"
return {"data": all_results, "total": len(all_results)} # ← total 恒 ≤ limit
total 返回的是本页行数(≤ limit,上限 50),分页契约失真;每次搜索触发 6 次全表扫描。
建议:上 MySQL FULLTEXT 或 Meilisearch;total 单独 COUNT 查询;external.py:42 同构,一并处理。
P1-7 去重写入每条新建 4~5 个 MySQL 连接
位置:Services/kuaixun-service/extend/unified_writer.py:241, 296, 329, 390, 459, 512, 565(7 处 pymysql.connect),由 _process_one() 串行调用 L1→L4
影响:500 条批次约 2000 次 TCP + 握手;且 db_config 未设 connect_timeout / read_timeout,MySQL 卡住即无限阻塞。
建议:整批复用一个连接(或注入连接池),统一加超时。
P1-8 日志句柄与子进程流未关闭
位置:Scripts/bin/newsflash_collect.py:653(stderr=open(...))、:557-559(except Exception as e: pass,e 未用、urlopen 响应未关)、Scripts/bin/backfill.py:63-80(get_conn() 无 finally)
影响:反复调用累积文件描述符;静默吞掉的异常使 _notify_backend 失败不可见。
建议:用 with 管理句柄;except 至少落日志。
四、P2 — 随迭代清理
| # | 问题 | 位置 / 证据 | 建议 |
|---|---|---|---|
| 1 | Python 版本契约冲突:uv venv --python 3.14 与 .python-version=3.12、各 pyproject >=3.12,<3.13 全部矛盾 |
Scripts/bundle-python.sh:28;全仓无调用方(陈旧脚本),且只有 set -e 无 pipefail |
删除或改 3.12;如需保留补 pipefail |
| 2 | 陈旧可写 venv 残留:快讯服务的可写副本仍是 Python 3.14,每次启动触发 WARN | ~/Library/Application Support/FinDataHub/venvs/kuaixun-service;日志 [WARN] [Venv] 快讯服务 可写副本 Python 3.14.5 与 App 内置 3.12.13 不一致 |
代码层回退逻辑已正确,属清理项:删除该残留副本 |
| 3 | core 侧重复文件未收敛:cache.py/reporter.py/validator.py 与 tushare 同名文件逐字节相同(合计 574 行),core 版仅被自身测试引用 |
Services/findatahub-core/src/findatahub_core/ |
参照 db.py 已成型的下沉模式(akshare 31 行 / tushare 42 行)收口,删本地副本 |
| 4 | 超大文件:NewsFlashService 1054 行、PythonProcessManager 842 行、DatabaseSetupGuideView 807 行 |
App/Sources/FinDataHub/ |
按职责拆分(进程管理 / 通知 / 健康监控);SettingsView 2467 行已拆分,延续该模式 |
| 5 | 测试覆盖不均:findatahub-client 0 个测试文件;Swift 侧仅 Tests/FinDataHubTests/NewsFlashServiceTests.swift 1 个 |
各 tests/ 目录计数:core 6 / client 0 / tushare 10 / akshare 8 / kuaixun 20 / Scripts 10 |
优先补 client 的 SDK 方法测试与 Swift 的 stdout 解析测试 |
| 6 | 构建残留占位:Services/findatahub-core/build/lib/(21 个 .py)与两处 *.egg-info |
均未被 git 跟踪(.gitignore 已覆盖),但占磁盘且易误读为源码 |
清理并在 build-app.sh/文档中注明 |
| 7 | 资源观察项:主进程 RSS 179 MB / UNIX 237/s;MySQL RSS 2,860 MB | 基线(非交易时段):78–86 MB / 0.2–33/s;MySQL 470 MB | 交易时段复测后再定性;若成立,按 FinDataHub-性能与资源分析.html 的既有方法论排查 |
五、历史问题复核结论
| 历史问题(08-09 / 08-30 报告) | 当前状态 | 取证 |
|---|---|---|
client.py stats() 引用未定义 row_count |
✅ 已修 | client.py:195 已赋值 |
pipeline.py 血缘写入 except: pass 静默吞错 |
✅ 已修 | 改为 except (pymysql.MySQLError, OSError) + logger.warning |
去重用内置 hash()(跨进程不可复现) |
✅ 已修 | dedup/__init__.py:54、unified_writer.py:262 已改 MD5 |
Swift 强制解包遍地(try! / .first! / group.next()!) |
✅ 大幅收敛 | 全仓仅剩 5 处 try!,均为预编译正则常量,可接受 |
| MySQL 环境变量注入三处重复 | ✅ 已修 | 统一至 PythonPathFinder.dbEnv(),5 处调用点 |
SettingsView.swift 2467 行 |
✅ 已拆分 | 现为 400 行 + +Actions/+Persistence/+Database 三个 extension |
archive_data.py --table SQL 拼接 |
✅ 不可达 | 已由 information_schema 存在性预检拦截(:98-104) |
| kuaixun CORS 过宽 | ⚠️ 结论修正 | 全服务实际未挂 CORSMiddleware,CORS_ORIGINS 是死配置——问题不是过宽而是未生效 |
| SSRF 重定向绕过(08-30 唯一中危) | ❌ 仍未修复 | content_enricher.py:181 仅校验初始 URL,:190 仍 follow_redirects=True,302 可绕过防护读内网/云元数据。修复方案见 08-30 报告"方案 A",建议本次一并落地 |
六、建议落地节奏
第 1 批(本周,阻断性缺陷)
- P0-5 轮换凭据 + 凭据移出 plist
- P0-1 采集器加
faulthandler+ 顶层堆栈落盘;修AutoRecoveryManager熔断计数 - P0-2
_fetch_static增量路径改 upsert;全量体检DATE_COLUMN_MAP的 17 张表 - P1-4
split(":", 1)一行修复(与 P0-1 同批,立竿见影) - P0-7 分批提交,建立回滚点
第 2 批(2–3 周,缺陷修复与链路对齐)
6. P0-3 akshare vol 置 NULL + 清洗判据加隐含单价校验 + 回归测试
7. SSRF 按"方案 A"修复(禁自动重定向、逐跳校验)
8. P1-3 连接池 try/finally + timeout + rollback
9. P0-4 决策:Pipeline 接入日更,或降级为实验代码;无论哪条,先给 launchd 日更加失败告警(akshare 已静默失败 14 天)
10. P1-1 Gate 严重度对齐 + reroute 补 pop;P1-2 context 按步骤名存产物
第 3 批(1 个月起,工程提质)
11. P0-6 统一 trade_date 类型与 collation
12. P1-6 搜索上 FULLTEXT / Meilisearch + 修 total
13. P1-7 去重写入连接复用
14. P2-3 core 重复文件收敛;P2-5 补 client 与 Swift 测试;P2-7 交易时段复测资源
本报告所有结论均基于代码行级取证与运行时实测(进程/日志/MySQL 实库/launchd 实况),已标注"确认"与"需复测"之分。 凭据类证据在文中已脱敏处理。
仅供参考,不构成投资建议。