Files
FinDataHub/FinDataHub-代码与架构复查-2026-09-17.md
FinDataHub b2da02e40a docs: 记录第二批/第三批修复,并更正三处不准确表述
- 未解决问题清单:P1-8(门控 reroute)、P1-11(搜索 total)标记为已修;
  修正编号混用;追加「本批更正」三章
- 复查报告:补充第二批 6 项闭环 + 两项新修的状态表,并说明两套 P1 编号的对应关系
- 记忆:记录根因、修法、证伪结果,以及三处自我更正与生产实测证据
2026-09-18 22:53:55 +08:00

33 KiB
Raw Permalink Blame History

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 一致性 + 语法 + 单测)。这说明迭代纪律是有效的。

但本次复查发现一个结构性问题:问题类型已经从"写错代码"转向"写对了代码,但它没在跑"。

三条主结论:

  1. 运行期存在持续性故障,且当前无有效告警捕获它。 快讯采集器在近一天内被判"异常停止" 193 次、后端"状态检查连续失败" 174 次,采集器 stderr 里积累了 206 次 jieba 初始化却零条堆栈。进程真在反复重启(实测 PID 5 分钟内由 5472 → 19658),但熔断机制因为"每次恢复都算成功"而永不触发。这不是误报,是崩溃循环 + 熔断设计缺口。

  2. 数据治理链路(七步 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 保障。

  3. 有确凿的数据损坏已发生。 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 分钟内变更)

机制(双层缺陷)

  1. 崩溃自身无诊断输出:stderr 里只有 jieba 反复初始化,没有任何异常堆栈。说明进程要么被信号杀死、要么输出在管道/缓冲层丢失——现有手段无法定位崩溃原因,这本身就是最严重的问题。
  2. 熔断只保护"恢复失败",不保护"恢复后再次崩溃":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 只是第一个被发现的,不是唯一有风险的。

影响:全表历史被增量覆盖,且不可逆(本地无备份则无法恢复);下游任何依赖分红历史的分析全部失真。

建议

  1. _fetch_static 的增量路径必须改 upsert,replace() 只保留给真正的全量快照表。
  2. 立即为 DATE_COLUMN_MAP 中全部 17 张表做一次行数/日期跨度体检,确认损坏范围。
  3. 在 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 口令同时暴露。

建议

  1. 立即轮换 Tushare token 与 MySQL 口令。
  2. 凭据移出 plist:新建 ~/.config/findatahub/env(chmod 600),plist 内改为 set -a; . ~/.config/findatahub/env; set +a;更稳妥是用 Keychain + security find-generic-password -w。
  3. 复查 install_collector_launchd.py 是否有同类明文写入(本次未逐一展开该脚本的模板)。

P0-6 日期列同列混存两种格式 + 跨库排序规则冲突(严重性已上调)

首版将此条描述为"跨表格式不一致",低估了严重性。核实后:同一列内即混存两种格式, 且范围过滤会静默丢掉 60% 的当年数据。完整复现见 FinDataHub-未解决问题清单-2026-09-17.md P0-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 批(本周,阻断性缺陷)

  1. P0-5 轮换凭据 + 凭据移出 plist
  2. P0-1 采集器加 faulthandler + 顶层堆栈落盘;修 AutoRecoveryManager 熔断计数
  3. P0-2 _fetch_static 增量路径改 upsert;全量体检 DATE_COLUMN_MAP 的 17 张表
  4. P1-4 split(":", 1) 一行修复(与 P0-1 同批,立竿见影)
  5. 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 实况),已标注"确认"与"需复测"之分。 凭据类证据在文中已脱敏处理。

仅供参考,不构成投资建议。