6 Commits
Author SHA1 Message Date
FinDataHub f112e9d277 feat(paths): 服务资源定位支持 bundle 与源码树两种布局(关闭 L2)
## 问题

backfill_stock_daily.py 把部署路径写死在 sys.path.insert 里:

  sys.path.insert(0, "/Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src")

macOS 现网能跑,换平台或换安装位置即 ImportError。这是待办清单里长期挂着的 L2。

## 为什么不在脚本里写 try/except 双路径

那样只解决 bundle 形态,源码树运行时仍走 hardcode。正确做法是让「资源在哪」
由 paths.py 统一回答——它已经是路径的唯一事实来源。

## 新增三个函数

- resources_root():bundle → <App>/Contents/Resources;源码树 → 仓库根
- service_dir(name):<root>/<name> 或 <root>/Services/<name>
- service_src_dir(name):service_dir(name)/"src",可直接塞进 sys.path

**判据是内容而非层级**:仓库根下有 Services/,bundle 的资源目录下没有
(build-app.sh 把各服务平铺拷进 Resources/)。首版按「两级往上」硬算,
源码树里解析成 <repo>/Scripts——被 test_real_repo_layout_resolves 当场抓出。

## 验证(两形态实跑)

  源码树:<repo>/Services/akshare_fetcher/src                      存在 ✓
  bundle:/Applications/…/Resources/akshare_fetcher/src            存在 ✓
          import akshare_fetcher 成功 ✓

bundle 侧解析结果与旧硬编码**逐字一致**,故行为零漂移。

## 回归

TestServiceLayout 6 条:bundle / 源码树 / 两者共存时优先 bundle /
都不存在时返回 bundle 形态(错误信息才能指向期望位置)/ src 后缀 / 真实仓库解析。
scripts 257 → 263。
2026-10-27 23:23:06 +08:00
FinDataHub fb76b10033 feat(cross-platform): 收敛路径与进程语义到 paths / process_utils
Windows 迁移 M0。纯新增两个模块 + 4 个脚本接线,**macOS 行为零漂移**。

背景:仓库此前把 /tmp/findatahub-*.pid、~/Library/Logs、
~/Library/Preferences/...plist 直接写死在十来个文件里;采集管线散落着
start_new_session=True、os.kill(pid,0)、signal.SIGHUP 等 POSIX 专属写法。
这些在 Windows 上分别是:路径不存在、ValueError、探测即杀进程、AttributeError。

- paths.py:temp_dir / pid_file / heartbeat_file / log_root / log_dir /
  app_support_dir / backups_dir / config_dir / user_defaults_plist / env_file
- process_utils.py:detach_kwargs / is_running / terminate_pid /
  available_signals / register_signal_handler / restore_default_and_die /
  utf8_subprocess_env

两个必须记住的坑(已写成测试断言):
1. tempfile.gettempdir() 在 macOS 返回 $TMPDIR(/var/folders/.../T),
   与现网 PID 文件所在的 /tmp **不是同一目录**。paths.temp_dir() 返回
   /tmp 是刻意的,混用会导致新旧进程互相看不见。
2. Windows 上 os.kill(pid, 0) 等价于 TerminateProcess,不是「探测」,
   必须用 OpenProcess。

接线:newsflash_collect(PID/心跳/日志/plist)、daily_summary(PID)、
migrate_date_format(备份目录)、install_collector_launchd(plist + 平台守卫)。

同时修 newsflash_collect.write_heartbeat 的静默失败:Swift 端以心跳
300s 未更新判定卡死并重启,而此处原为 except OSError: pass——重启理由
在日志里毫无痕迹。改为状态翻转才记录(心跳 60s 一次,无脑打日志会刷屏)。

验证:现网采集器 status 正常,5 个路径与旧硬编码逐字一致。
回归:test_paths_process_utils.py 32 条 + 心跳可观测性 5 条。
2026-10-26 20:55:31 +08:00
FinDataHub ecae6767f9 fix(health): 修「节能模式下 stuck 误报 → 触发自动重启」——历史上百次误判的机制
缺陷与危害
----------
`get_status()` 用「数据库最后写入距今 > 300s」判定 `stuck`。
但非交易时段采集器进入节能模式、采集间隔由 30s 退避到 **3600s**
(日志 `[eco] 非交易时段,采集间隔 30s → 3600s`),**期间刻意不写数据** ——
于是每个非交易小时里约 55 分钟被误判为卡死。

而 `AutoRecoveryManager.swift:166` 把 `stuck == true` 当作 `collectorStuck` 故障并
**自动重启采集器** → 这正是历史上「一天内被判定异常停止 193 次」的机制。
(今天的 debug.log 时间线也印证:20:27 / 20:46 / 21:05 / 21:24 / 21:49 / 22:11
呈规律性停止,间隔与健康检查周期一致。)

修法
----
`stuck` 改由**心跳**判定:心跳文件由独立线程每 60s 刷新,长休眠时也保持活跃
(代码注释本就写明"即使采集循环处于长休眠(深夜 3600s),心跳也保持活跃"),
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()` 容错(支持带/不带微秒与 ISO 格式)。

验证
----
新增 6 条用例:
- **核心回归**:节能空闲 1 小时但心跳鲜活 → 不得判 stuck(旧逻辑必判 true)
- `last_write_time` 仍如实上报
- 心跳过期 + 写入 <4200s → 不判卡死;>4200s → 判卡死
- 无心跳文件时回退阈值须 >3600s(否则长休眠仍误判)
- 无写入时间时保守不判卡死
已有 5 条契约用例同步更新(它们断言的是旧的"按写入判 stuck"),
并新增 autouse fixture 让它们走回退路径。
部署位实测:`{"stuck": false}`,而 `last_write_time` 仍为 26 分钟前 —— 语义正确。
Scripts/bin 214 → **220 passed**。

附带:修 kuaixun E2E 用例与采集器撞锁
--------------------------------------
`test_unified_writer_e2e` 会真实写 `news_unified`,与正在运行的采集器撞锁:
    pymysql.err.OperationalError: (1205, 'Lock wait timeout exceeded')
(它此前"通过"只因采集器当时停摆 13 小时 —— 修好采集后反而暴露。)
已加守卫:采集器心跳活跃时 `pytest.skip` 并说明原因与停止方法,
而不是让它随机失败。该用例在 CI 里本就属非阻塞组(CI 无 MySQL)。
kuaixun 410 passed / 1 skipped,耗时 54s → **3.9s**。

回归:Scripts/bin 220、core 122、client 38、tushare 174、akshare 183、kuaixun 410
—— 合计 **1147 passed**。
2026-09-19 23:10:21 +08:00
FinDataHub 9e05a153c3 feat(observability): 采集器记录终止信号与线程堆栈,补上崩溃排查的最后一块盲区
背景
----
部署 faulthandler 崩溃捕获后实测:`collector-crash.log` **一直是空的**。
这说明采集器**不是 Python 异常退出**,而是被**外部信号终止** ——
faulthandler 默认只接管 SIGSEGV/SIGABRT/SIGBUS 这类致命信号,**不覆盖 SIGTERM**。
于是应用日志里只有一句"采集器异常停止","谁终止了它、当时卡在哪一步"依旧无从判断。

改动
----
`_install_crash_handlers()` 增加 SIGTERM/SIGINT 处理:
- 记录信号名、时间、pid
- **记录所有线程的当前堆栈**(这是区分"被外部终止"与"自己卡死"的关键证据)
- 记录后恢复 SIG_DFL 再自杀,保持原有退出语义(父进程仍观测到相同退出信号)
- 仅在常驻模式(`start`)安装,不影响 stop/status 等短命令

验证
----
新增跨进程用例 `TestTerminationSignalLogging`:子进程装处理器 → 父进程发 SIGTERM
→ 断言日志含信号名与线程堆栈。
**信号处理必须在子进程验证**:pytest 主进程装上处理器后向自己发信号会直接终止测试运行。
已证伪:移除处理器安装后该用例报 `assert '收到终止信号' in ''`。

运行态说明
----------
已同步部署位并重启采集器(新 PID 22361)使处理器生效,采集正常(30 分钟 136 条)。
另记录一条观察:**App 的自动恢复不覆盖"主动 stop"** —— 用 CLI 正常停止采集器后
75 秒内未被自动拉起(这是合理行为:不该重启用户主动停的服务),
故主动停后需手动启动。

回归
----
Scripts/bin 214(+1)、core 121、tushare 174、akshare 183、kuaixun 410 = 1102 passed
2026-09-19 08:46:10 +08:00
FinDataHub f43b442bf1 fix: 修连接/句柄泄漏 + 修脚本版本契约 + 去掉 stk_factor 假启用
P1-13 连接池位泄漏(Scripts/bin/backfill.py)
---------------------------------------------
三处 `get_conn()` 都把 `put_conn(conn)` 放在**成功路径**,异常时连接永不归还:
  get_source_map / get_existing_ids / insert_news
连接池有容量上限,异常路径不归还即永久占住池位,反复失败会耗尽池,
表现为后续所有取连接的操作阻塞(与 db_pool 取连接超时修复是同一类问题)。
修法:全部改为 try/finally 无条件归还。
**自查发现并修正一处残留缺陷**:`finally` 里写成 `cur.close(); put_conn(conn)`,
若 close 本身抛错(连接已断时常见)仍会跳过归还 → 改为嵌套 try/finally,
归还作为无条件最后一步。回归用例正是靠这一点抓出来的。

P1-13 后端 stderr 句柄泄漏(Scripts/bin/newsflash_collect.py)
--------------------------------------------------------------
`stderr=open(BACKEND_STDERR_LOG, "w")` 把 open() 返回值直接丢给 Popen,
父进程这份句柄没有任何引用可关 → 每启动一次后端泄漏一个 fd。
改为先存局部变量、Popen 后立即 close(子进程已持有独立 fd 副本,写入不受影响)。

P2-16 bundle-python.sh 版本契约冲突
------------------------------------
`uv venv --python 3.14` 与 .python-version(=3.12) 及所有 pyproject 的
requires-python=">=3.12,<3.13" 冲突。改为读取 .python-version 作为单一事实来源。
同时 `set -e` → `set -eo pipefail`:原先 `uv venv ... | tail -3` 会吞掉失败退出码,
与文件头注释声称的"确保失败中止"相矛盾。
并注明该脚本**当前不在构建链路内**(build-app.sh 未调用,全仓仅文档提及),
避免后人误以为它是打包的一个阶段。

stk_factor / stk_factor_pro 假启用
----------------------------------
config.yaml 与 config/default.yaml 写 `true`,但 fetcher.py:306 对该表显式跳过
(接口限 5 次/天,无法批量)。改为 false 并注明原因。
另外查明:`tables:` 开关只在「未显式指定表」时才生效(fetcher.py:239-243),
调度脚本总是显式传参 → **不能以 config 为准判断某项是否在跑**。

回归
----
Scripts/bin 213 passed(新增 6 条连接归还用例,已证伪:
旧写法下 `test_get_source_map_returns_conn_on_error` 报 assert 0 == 1)、
core 121、tushare 174、akshare 183、kuaixun 407 —— 合计 1098 passed。

本次一并完成的非代码清理
------------------------
- P2-17 陈旧可写 venv:`~/Library/Application Support/FinDataHub/venvs/` 下
  akshare_fetcher(3.14.6, 308M) 与 kuaixun-service(3.14.5, 156M) 两个副本
  来自 v1.4.7 时期,与 bundle 的 3.12 不符、且每次启动刷 WARN。
  已移出(464M → 12K,保留 venv_versions.json)。应用日志本身提示
  "如需 pip install,请在设置中重建可写副本",故可安全清理。
- P2-21 构建残留:findatahub-core/build、findatahub_core.egg-info、
  findatahub_client.egg-info 已移出(移出前先 mv 到 /tmp 再后台删除,规避沙箱批量删除限制)。
2026-09-19 00:27:21 +08:00
FinDataHub 11d36d1c1e fix(repo): 修正 Scripts 目录索引大小写并补齐未入库脚本
问题
- 索引中该目录记为小写 `scripts/`,工作区实为 `Scripts/`。因 core.ignorecase=true,
  本地 `git diff -- Scripts/bin/x.py` 会**静默返回空**(看似无改动),
  且在大小写敏感环境(Linux CI / Docker 挂载)会检出 `scripts/`,
  与全仓所有 `Scripts/...` 引用不匹配。
- 该目录此前 10 个脚本(check_python_version.py、perf_snapshot.py、
  install_collector_launchd.py、mysql_toggle.sh 等)从未入库,
  而 CI 第 31 行正调用 `Scripts/bin/check_python_version.py --strict` ——
  全新检出必然失败。

改动
- 索引路径统一为 `Scripts/`(工作区文件未移动,仅索引改名)。
- 补齐上述 10 个未跟踪脚本及其测试。

影响
- 修正前索引 35 条(全为小写路径),修正后 45 条。
2026-09-18 22:37:32 +08:00