22 Commits
Author SHA1 Message Date
FinDataHub cb11a3d445 fix(backend): API_KEY 统一从持久化文件加载,修掉受保护端点恒 401
## 现象

装机后测受保护端点,文件 key → 401、乱码 key → 401、无 key → 401。
全 401 说明不是「没鉴权」,而是**鉴权用的 key 与客户端手里的不是同一个**。

## 根因(与 T6 同族,又一处双入口分叉)

  app/main.py                      → _load_or_create_api_key()   读持久化文件
  Scripts/bin/newsflash_backend.py → secrets.token_urlsafe(32)   现生成随机值

生产走后者,每次启动换一把钥匙。客户端读 ~/Library/Application Support/
findatahub/.api_key 去请求,永远对不上;两边都不报错,只表现为「点了没反应」。

## 修复

把加载逻辑收进 app/dependencies.py::load_or_create_api_key(),两个入口共用:

- 文件已存在 → 复用,**不覆盖**(覆盖会让客户端手里的旧 key 立即失效)
- 文件存在但内容为空 → 补生成(空 key 会让 compare_digest("", "") 恒真)

路径用 platformdirs.user_config_dir("findatahub"),macOS 上即
~/Library/Application Support/findatahub,与 Swift 端读取位置一致。

## 部署位实测

  文件 key → HTTP 200   (修复前 401)
  乱码 key → HTTP 401
  无 key   → HTTP 401

## 防回退锁

test_app_factory.py::TestApiKeySource 3 条:共享加载函数存在、复用时不被覆盖/
空内容补生成、两个入口源码都必须出现 load_or_create_api_key 且都不得出现
secrets.token_urlsafe。

backend 443 → 446。

## 这一族的教训

T6 收敛了「装配」,但 lifespan 里还藏着同类分叉。凡是「两个入口都该有且必须一致」
的逻辑,都该像 key 加载这样收成单一函数,而不是指望两边同步。
2026-10-27 23:23:06 +08:00
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 3de1d78863 refactor(backend): 后端装配收敛到 create_app,根治双入口漂移
## 问题

仓库有两条 FastAPI 启动路径——app/main.py(uvicorn,手册/开发)与
Scripts/bin/newsflash_backend.py(**生产实际入口**),两者各自 new FastAPI 并各写一遍
中间件与路由注册。只改 app/main.py 的装配时,生产**完全空转且不报错**;而
app/database.py 因被 import 反而生效——一半生效一半不生效,最难察觉。

2026-10-27 装机实测:CORS 预检 405、/health 只有 {status, time} 两个字段,
而源码里明明是完整实现。

早先的止血只做到「两处都改」(给 backend 补中间件、health 载荷共用),
漂移风险仍在。本次根治。

## 改动

新增 Services/kuaixun-service/app/factory.py::create_app(),把**必须逐字一致**的
两件事收敛到一处:

1. 中间件顺序(由内到外)exception_catch → inner_middlewares → security_stack → cors
   Starlette 的 add_middleware 是 insert(0),先添加的位于更内层。CORS 必须最外,
   否则异常响应拿不到跨源头、浏览器只报 "blocked by CORS policy"。
2. 基础路由清单(7 个 router)

两个入口现在都只调 create_app(),self-build 的 FastAPI 实例全部删除。
差异只保留「本就该不同」的部分:

| 差异项 | 处理 |
|---|---|
| lifespan | 各自实现(main 等建表 / backend 走 db_pool) |
| backend 独占端点(analysis / source-test / shutdown / SPA catch-all) | 留在 backend |
| ResponseCache / RequestLog | 经 inner_middlewares 传入,**层序仍由 factory 决定** |

app/main.py 顺带清掉变为无用的导入(WebSocket / Request / JSONResponse /
HTTPException / 7 个 router 名)。

## 必须保留的实现细节

_security_module() 先试常规导入、失败再按 __file__ + importlib 加载。原因:
Scripts/bin/common.py(模块)与 kuaixun-service/common/(包)同名,bin 在 sys.path
前部且已被缓存,生产入口下 from common.security import ... 会报
'common' is not a package。实测该场景确实走 fallback,加载到正确的 security.py。

## 顺序验证(实测,非推理)

app.user_middleware 由外到内:

  app.main : CORSMiddleware → SecurityHeaders → ExceptionCatch
  backend  : CORSMiddleware → SecurityHeaders → RequestLog → ResponseCache → ExceptionCatch

与原 backend 的 exception_catch → ResponseCache → RequestLog → security → cors
逐层一致。新版 FastAPI 用 _IncludedRouter 惰性挂载(path 为 None),
故测试断言挂载数量而非 path 集合。

## 防回退锁 + 证伪

tests/test_app_factory.py 8 条,核心是「两个入口源码里不得出现 FastAPI( 与
include_router(」(注释行豁免)。

证伪:把 backend 改回 app = FastAPI(...) 后,
test_backend_entry_does_not_build_app_itself 立即失败:
  E  AssertionError: 生产入口必须经 create_app 装配
恢复后 443 passed,grep FastAPI( 无残留。

## 部署位实测(只改 Python 资源,免全量重建)

  /health        → {"status":"ok","db":{"ready":false,"connected":true,
                    "timebase_drift_seconds":0.537},...}
  OPTIONS 预检   → 200, access-control-allow-origin: http://tauri.localhost
  安全头         → x-content-type-options / x-frame-options / referrer-policy 齐全
  analysis/*     → overview / trend / hourly 全 200(后端专属端点未被破坏)
  news/live,stats→ 200
  RequestLog     → stderr 有 "POST /api/v1/news/notify-internal → 200 (14ms)"

回归:后端 435 → 443 passed。全量 8 套件 PASS(含真实库集成),40.6s。
2026-10-27 13:30:37 +08:00
FinDataHub 9a5fa23228 build: 脚本通配复制 + 产物完整性自检;版本 1.5.2 → 1.6.0
## build-app.sh 第 3 步改为通配复制

原先是 ~18 行 `cp -f xxx.py` 的显式白名单。代价是每新增一个运行时依赖文件
都必须记得回来加一行——漏了只在**部署位**暴露为 ImportError(本地跑源码完全正常)。

本轮就撞上了:paths.py / process_utils.py 未入产物,导致部署位的
newsflash_collect.py 静默走 ImportError 降级到硬编码路径,跨平台抽象层等于没装。

改为 for 通配复制 *.py / *.sh:tests/ 与 __pycache__ 都是子目录不会被匹配;
带入的开发脚本每个仅数十 KB,且部署位可用来做诊断。

## 新增构建期完整性自检

在打包完成后断言最小运行时必需集(paths.py / process_utils.py / common.py /
db_pool.py / sync_bridge.py / newsflash_collect.py)都在产物里,缺任一即 exit 1。

原则:**打包漏文件必须在构建阶段失败,不能等上线后才发现**。

## VERSION 1.5.2 → 1.6.0

minor 升级依据:本轮新增跨平台抽象层(paths/process_utils)、前端 runtime.js
端点解析、CORS 白名单(对外接口行为变化)、SyncOutputParser。
不是纯修补,故走 minor。同版本号无法区分新旧,部署验证会失去锚点。
2026-10-27 00:32:23 +08:00
FinDataHub e48b2e0ce6 fix(backend): 生产入口补齐 CORS 与 health,两路入口收敛到共享实现
## 部署验证发现的严重问题

本轮装机后实测发现:加在 app/main.py 的修复**在生产完全不生效**——
部署位 /health 只返回 {status, time}、CORS 预检返回 405,而源码明明是新版。

根因:仓库有**两条后端启动路径**——

  Services/kuaixun-service/app/main.py   uvicorn app.main:app(开发/手册)
  Scripts/bin/newsflash_backend.py       **生产实际入口**,自建 FastAPI(...),
                                         从不 import app.main

后者自己 app = FastAPI(...)、只 import 各 router,并自己注册了 /health。
因此 apply_cors / apply_exception_catch / apply_security_stack / 增强 health /
init_db 重试全部空转。而 app/database.py 的改动(now() 时间基准、双检锁、
连接超时)因为被 import 而生效——一半生效一半不生效,最难察觉。

## 修复

1. newsflash_backend.py 装三层中间件,顺序(由内到外):
   exception_catch → ResponseCache → RequestLog → security_stack → cors
   (后者必须最外,否则 500 响应拿不到跨源响应头)
2. health 载荷抽到 app/health.py::build_health_payload(),两个入口共用,
   避免再次分叉
3. app/main.py 的 health 只做路由注册,转调共享实现

## 途中踩到并绕开的两个坑

- **命名冲突**:Scripts/bin/common.py(模块)与 kuaixun-service/common/(包)同名,
  且 common 已被 `from common import ...` 缓存,故
  `from common.security import ...` 报 "ModuleNotFoundError: 'common' is not a package"。
  改为按 __file__ 推导路径 + importlib 显式加载。
- **路径推导**:不能用 common.KXS_HOME,它在 bundle 下解析为 Contents/Services/...
  (缺 Resources 层级),实测 FileNotFoundError。改用
  os.path.dirname(os.path.dirname(abspath(__file__))) 得到 Contents/Resources。

## 顺带修正 health 的 ready 语义

ready 表示「SQLAlchemy 建表完成」。生产入口走 db_pool(pymysql 池),不负责建表,
ready 恒为 False → 健康检查对完全可用的服务持续报 degraded,反向误导运维。
改为 ok = connected and drift_ok and not(init_failed),
即连通性与时区基准是硬判据,ready 只作诊断信息。

## 部署位实测(修复后)

  GET /health → {"status":"ok",
                 "db":{"ready":false,"connected":true,"error":null,
                       "timebase_drift_seconds":0.274},
                 "ws_connections":0,"time":"2026-10-27T00:31:13"}
  OPTIONS /api/v1/news/live (Origin: http://tauri.localhost) → 200
    access-control-allow-origin: http://tauri.localhost
    access-control-allow-headers: ... X-API-Key ...
    access-control-allow-credentials: true
  业务接口 GET /api/v1/news/live?limit=1 → 返回真实快讯数据

测试:test_timebase.py 的 health 用例改为 patch app.database 模块属性
(health 内部按模块属性取值),435 passed。
2026-10-27 00:32:16 +08:00
FinDataHub 649085ab3b fix(scripts): site 目录加载失败留痕
common.py 的 site.addsitedir() 原为 except Exception: pass。

此处静默的代价是:某个 site 目录没加载成功时,「依赖明明装了却 import 不到」
完全无从判断——而排障时最需要知道的恰恰是「哪个目录失败、为什么」。

改为 stderr 留痕(沿用本文件的 ⚠️ 风格)。单个目录失败仍不阻断启动。

说明:其余回填/分析脚本中的 except: pass 保持原样。
全项目 40 处已分类审计(见审查方案 §3.4),多数属合理防御——
错误处理路径内再失败、崩溃处理器自身失败——无差别改成 logger.exception
会制造噪音甚至改变行为。
2026-10-26 21:07:58 +08:00
FinDataHub 5901055e16 chore(repo): 补入两份历史遗留产物
此前长期处于未跟踪状态的资产,属 P0-7「版本控制失效」的同类隐患——
一次 git clean -fd 即不可恢复。

1. Scripts/bin/backfill_stock_daily.py(88 行)
   个股日线历史回填,按 OVERRIDE_DATE 逐日补 akshare_data.stock_daily。
   文档说明了两个关键判据:只跑 stock_daily 不做板块快照(后者的接口只返回
   实时数据,用于历史回填等于用今天快照伪造历史行情,会污染题材分析);
   以及运行前需清除注入的 http_proxy(否则国内源 ProxyError)。

   ⚠️ 已知问题(未在本轮修改):第 15 行硬编码
   /Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src
   与跨平台方向冲突,应改为经 paths.py 解析。因其为部署环境专用脚本,
   本轮不做无验证的改动,留作待办。

2. FinDataHub-应用功能设计与模块结构方案-2026-10-17.html(37K)
   10-17 的功能与模块结构设计文档。
2026-10-26 20:56:28 +08:00
FinDataHub cd076018ba test: 统一测试入口 + 真实 MySQL 集成测试
## 统一入口 run_all_tests.py

跑一次全量测试要记住四件事,任何一条忘了结论就是错的:
1. 每套件用哪个 venv(findatahub_core 只装在部分 venv 里)
2. 必须注入 PYTHONPATH,否则验的是已安装的旧版本
3. Scripts/bin/tests 需要 httpx,选错 venv 在收集阶段直接报错
4. **不能并行**——两个 pytest 会互踩系统临时目录,报 pytest-of-root
   EEXIST 假失败(表现为几十个 errors,看着像大回归)

摩擦本身就是回归风险的来源,固化成脚本。四个设计点:
- 解释器按能力选:每套件声明 requires,扫描 Services/*/.venv 挑第一个
  满足的;挑不到 exit 2 并列出缺什么,绝不用错的 venv 硬跑出假结果
- 强制注入 PYTHONPATH,源码目录排最前
- **每套件独立 --basetemp**(.pytest-cache/<suite>/):从根上消除 tmpdir
  争用,不依赖"记得串行",也不去删系统临时目录
- 摘要提取要求「数字+状态词」相邻且 stdout 优先,避免把
  (Background on this error at: sqlalche.me/...) 当成统计行

实测 6 套件 14.9s;加 --include-swift --include-frontend 共 8 套件 37s。
**重要发现**:scripts 从 205 → 252——多的 47 条是 test_rss_fetcher.py,
此前因 akshare venv 缺 httpx 一直被 --ignore 跳过。这批用例一直存在且可用,
只是从没在本机跑过。

## 真实 MySQL 集成测试

findatahub-core/tests/integration/,8 条用例。

为什么 fake 不够:test_db_replace_guard.py 用 Fake 游标验了代码路径,但
Fake 验不了三件事——事务是否真的回滚(Fake 的 rollback 只是置标志位)、
跨 chunk 失败是否留下半成品(Fake 不会真提交,这个场景永远复现不出来)、
熔断时表是否真的没动(需真实读行数)。

隔离:每次 fixture 新建 fdh_it_replace_<随机8位> 空库,用完 DROP;
库名带前缀且 DROP 前再断言。不读现有配置、不碰现有库。
无 MYSQL_PASSWORD 时整组 skip,CI 无 MySQL 不会变红。

**证伪验证**:把 replace() 临时改回「每 chunk 提交」旧行为,该套件报
「回滚不完整:表内行数既不是旧值 1000,说明留下了半成品数据」→ 测试有牙。
恢复后复跑 8 passed,生产库行数前后一致(news_flash 368346 /
stock_daily 8158172),无 fdh_it_% 残留。

--with-integration 开关默认关闭(要真实库且会建临时库)。
2026-10-26 20:55:52 +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 9138904cad docs: Windows 迁移计划修订——SwiftUI 留作 Mac 分支,Windows 侧用 Tauri 2 做 GUI
- 结论先行与依赖清单:GUI 行改为「SwiftUI(App/)=Mac 分支保留;新建 App.Win/(Tauri 2 + 内嵌 Vue3)」
- 阶段 4 改为「Windows GUI 分支」:三方案选型(推荐 Tauri 2)、Mac/Win 代码组织、
  Windows 特性与 macOS(SwiftUI) 的等价物对照表
- 速查表与落地形态同步更新
2026-10-07 00:49:14 +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 03d7db9cd4 perf(P1-11): 评估「ngram 全文检索提速搜索」——**实测不等价,故不采纳**(附证据工具)
背景
----
`api_search` 对 5 张 ACTIVE_TABLES 用 `title LIKE '%q%' OR content LIKE '%q%'`,
无法走索引;且 data 与 count 各扫一遍。数据库已有 `ngram` 插件
(`ngram_token_size=2`,STATUS=ACTIVE),故尝试用
`MATCH(title,content) AGAINST(... IN BOOLEAN MODE)` 走索引预筛、再用 LIKE 精确复核,
期望在不改变结果集的前提下提速。

实测结论:**不等价,不能采纳**
------------------------------
新增 `Scripts/bin/verify_fulltext_equivalence.py` 对 9 个查询做四写法对照
(like 基准 / 两阶段OR / 裸OR / 引号短语),结果:

| 查询 | LIKE 基准 | 两阶段 OR | 差异 |
|---|---|---|---|
| `股`(单字) | 54,015 | **1** | ★ −54,014(ngram token_size=2,单字无法命中) |
| `A股`(中英混合) | 5,882 | **0** | ★ −5,882(跨中英边界无法成 gram) |
| `人工智能`(纯中文 4 字) | 3,923 | **3,916** | ★ **−7**(即便最优情形也不精确) |
| `股市`/`涨停`/`央行`/`美股`/`IPO`/`5G` | — | 相等 | OK |

裸 MATCH(OR 语义)也**不是** LIKE 的超集(同上三项为负)→ 无法作为安全预筛。
速度确有大改善(0.25s → 0.03s),但**静默丢结果比慢更糟**,故不采纳。

处置
----
- **不修改** `api_search`(保持 `LIKE` 语义正确)
- **撤掉**本次为验证而建的 5 个 `ft_title_content` FULLTEXT 索引
  (无查询会用到,只会增加高频写入表的重建与写入开销);
  `news_flash` / `news_flash_archive` 上**原有的**同名索引保持不动
- 保留 `verify_fulltext_equivalence.py` 作为可复跑的决策证据工具

当前基准(实测,5 表合计)
--------------------------
- count 语句:**0.20–0.23s**;叠加 data 查询后单次搜索约 **0.4–0.5s**
- 结论:以 5 张表(合计约 40 万行)的规模,0.25s 量级的全扫描**可接受**。
  若确有提速需求,可选路径是**刻意改变检索语义**(改产品决策并同步前端提示),
  或改用应用侧倒排索引 —— 二者都属独立工作量,不在"透明提速"范畴。
2026-09-19 22:42:00 +08:00
FinDataHub da9642c2bc fix(packaging): 裁剪内联运行时(72M → 59M),解决 codesign --deep 失败
问题
----
上一步把 Python 运行时内联进 `Contents/Frameworks/python312/` 后,
`codesign --force --deep` 失败:
    bundle format unrecognized, invalid, or unsuitable
    In subcomponent: .../Frameworks/python312/lib/thread3.0.4
把完整运行时(含 Tcl/Tk)放进 bundle 超出了 `--deep` 的处理能力。

改动
----
`relocate_bundle_venvs.py` 增加 `prune_runtime()`,裁掉运行期不需要的内容:
- Tcl/Tk 家族:`tcl9.0` `tk9.0` `tcl9` `itcl4.3.5` `thread3.0.4`
  `libtcl9.0.dylib` `libtcl9tk9.0.dylib`
- `include/`(供编译 C 扩展,运行期不需要)、`share/`
- stdlib 无关部分:`idlelib` `turtledemo` `ensurepip` `lib2to3` `tkinter` `test`
- `bin/` 中的开发工具:`idle3*` `2to3*`(保留 `pip` / `pydoc`)

裁剪依据(非拍脑袋):三个 venv 只用 pandas / numpy / pymysql / sqlalchemy /
fastapi / akshare 等纯计算与网络库,**不用 tkinter** ——
全仓已安装包中仅 `tqdm/tk.py` 引用它,那是可选的 GUI 进度条、未启用。

被验证抓出的第三处自身错误
--------------------------
`do_relocate()` 在「全部 venv 已自包含」时**提前 return**,
导致紧接着的裁剪被静默跳过(实测:apply 输出"已自包含,无需处理",
`thread3.0.4` 仍在,签名照旧失败)。
→ 已改为:venv 无需重写时**仍执行裁剪**。教训:早退分支里别忘掉并行的收尾动作。

结果与验证
----------
- 运行时 72M → **59M**(裁剪 15 项,释放 6.8 MB)
- 三个 venv 功能回归通过:python 3.12.13 / pymysql 2.2.8 均可导入
- `tushare-fetcher --help`、`akshare-fetcher --help` 均正常
- `tkinter` 确认已不可导入(符合裁剪预期,未被使用)

签名现状(如实说明)
--------------------
`codesign --deep` 仍无法覆盖内联的完整 stdlib(报
`In subcomponent: .../lib/python3.12`)。该 bundle 现为 **ad-hoc 签名 + 主可执行文件已签**。
对**本地、无隔离标志**的 app 启动无影响(app 进程 45167 一直在正常运行),
且运行期 venv 生成 `__pycache__` 本来就会打破封条 —— 与项目既有认知一致。
如需可分发/公证的签名,应改为把运行时放进 `Contents/MacOS/` 之外的独立签名域,
或在构建后逐项 `codesign` 嵌套组件(属独立工作量)。
2026-09-19 22:37:19 +08:00
FinDataHub 39a28becc7 fix(packaging): 让 .app 的 venv 自包含(内联 Python 运行时 + 修 editable .pth)+ 补打包 common/
缺陷一:bundle 内 venv 依赖用户级 Python
----------------------------------------
三个 bundle venv 的 `python` 都是软链接,指向
`~/.local/share/uv/python/cpython-3.12-macos-aarch64-none/...`,
`pyvenv.cfg` 的 `home` 与 `.venv/bin/*` 的 **shebang 同为绝对外部路径**
(`build-app.sh` 用 `rsync -a`,会原样保留软链接)。
→ 用户执行 `uv python uninstall 3.12`、升级,或清理 `~/.local/share/uv`,
**整个 app 的 Python 侧全部失效**,而这是日常会做的操作。

缺陷二:akshare venv 的 editable .pth 指向源码仓库
---------------------------------------------------
`_editable_impl_akshare_fetcher.pth` → `<repo>/Services/akshare_fetcher/src`。
即**部署的 akshare 采集器依赖源码仓库存在于原路径**(tushare 侧则已指向 bundle 内 src)。
换机器 / 移动仓库即 `ModuleNotFoundError`。

改动
----
1. 新增 `Scripts/bin/relocate_bundle_venvs.py`(`--check` / `--dry-run` / `--apply`)
   - 把 Python 运行时整个拷入 `Contents/Frameworks/python312/`(103 MB)
   - 重写 `.venv/bin/python*` 软链接、`pyvenv.cfg` 的 home/executable、
     `.venv/bin/*` 的 shebang、指向 bundle 外的 editable `.pth`
   - `--check` 可作启动自检 / CI 门禁
2. `build-app.sh` 第 5 段补 `common/`:该段此前只 rsync 了 `extend` 与 `app`,
   **漏了 `common/` 包**(服务自身入口 `app/main.py` 需要 `from common.config import ...`)。

落实与验证(部署位已执行 --apply)
----------------------------------
- 三个 venv 全部 `--check` 通过「已自包含」;解释器均解析到
  `<app>/Contents/Frameworks/python312/bin/python3.12`
- 三个 venv 的关键依赖可导入:pandas 3.0.3 / akshare 1.18.64 / fastapi 0.141.1
- `tushare-fetcher --help`、`akshare-fetcher --help` 均正常
  (akshare 侧修复前报 `ModuleNotFoundError: No module named 'akshare_fetcher'`)
- 真实取数:`tushare-fetcher fetch --type daily --dry-run` 正常解析 6 张表

实现过程中被验证抓出的两处自身错误
----------------------------------
1. **shebang 误指向基础解释器**:首版把 `.venv/bin/*` 的 shebang 改成
   `<app>/Contents/Frameworks/python312/bin/python3.12`。该路径虽在 bundle 内,
   但**执行时不带 venv 上下文** → venv 的 site-packages 不在 sys.path、
   editable `.pth` 不生效 → console script 直接 ModuleNotFoundError。
   正解:shebang 必须指向 **venv 自己的** `<venv>/bin/python`(它本身是指向
   bundle 内解释器的软链接,能正确带出 venv 上下文)。
   `--check` 的判据也据此收紧为「shebang 必须恰为本 venv python」。
2. **只看首个 venv 就提前返回**:`--apply` 原先用第一个 venv 的自包含状态决定
   是否跳过,导致「tushare 已自包含、akshare 未自包含」时整批跳过、漏修。

关于"缺 common/"的更正(不夸大)
--------------------------------
实测确认:**桌面后端并不导入 `app/main.py`**(它导入 `app.dependencies` /
`app.routers.*`),因此缺 `common/` **并未导致任何当前故障**,后端一直正常工作。
本次补齐属"bundle 相对服务自身入口不完整"的正确性修复与前瞻加固。
另注:`Scripts/bin/common.py`(单文件模块)与 `kuaixun-service/common/`(包)**同名**,
两者同时进入 sys.path 时前者会遮蔽后者 —— 这是既有架构约束,已在 build-app.sh 注明。

对应地,本批另一处真实故障是**采集器被注入了失效的会话代理**(臣上一轮重启时
误带 `HTTP_PROXY=127.0.0.1:50782`),导致所有新闻源 `All connection attempts failed`、
13 小时零写入(`stuck: true`)。已用**不含代理**的环境重启:
`stuck: false`,近 10 分钟写入 346 条 / 去重入库 43 条,采集恢复正常。
境内新闻源应直连,不应走会话代理。
2026-09-19 22:34:08 +08:00
FinDataHub e7186e5ce6 fix(prod): 调度脚本改用 bundle venv 直调,修复「uv run 运行期联网」导致日更整体中止
故障(2026-09-19 15:40 实测)
------------------------------
`tushare_sync.sh daily` **一张表都没抓**,日志只有一行:
    ── 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
      cause: failed to lookup address information: nodename nor servname provided

根因
----
两个调度脚本都用 `uv run <entrypoint>` 调用。**`uv run` 会在运行时同步/重建依赖
环境**,需要联网下载缺失包 —— 当天该请求 DNS 解析失败;又因脚本带
`set -eo pipefail`,这条 error 直接**中止整条任务**。

手动复现同一条命令还看到额外危害:
    Building tushare-fetcher @ file:///...
    Downloading mypy (13.0MiB)
    Installed 14 packages in 31ms
即**生产运行期会联网改写自身 venv 的依赖**(把 mypy 装进生产环境)。

改动
----
`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 路径(18:00 任务)冒烟通过,无 uv 报错

说明:今天为周六(非交易日),故本次故障的**数据**影响有限(无新行情可采),
但该缺陷在每个工作日都会造成**整日零采集**,属最高优先级修复。

附带提交(同日稍早完成、一并入库)
----------------------------------
- `tables_registry.json`:`us_basic` 唯一键由 ["ts_code"] 修正为
  ["ts_code","list_date","name"]。同一 ticker 会被不同公司复用(AEM/ABX/AGI 等),
  旧键导致静态表 replace 写入报 (1062, Duplicate entry),整批失败。
  已按 MySQL NULL 语义实测验证为 0 冲突,DB 侧唯一索引同步重建。
- `fut_wsr` 追加警告:接口返回行在 (trade_date,symbol,warehouse) 下可重复且
  **无自然唯一键**(同仓库同日多条仓单,vol 不同),按唯一键 upsert 会静默覆盖丢行。
- 新增 `Scripts/bin/verify_unique_keys.py`(校验 registry 声明的唯一键在真实数据上
  是否唯一,含 MySQL NULL 语义与组合搜索建议)与 `Scripts/bin/inspect_dup_rows.py`
  (打印重复行以确定真实唯一键)。
2026-09-19 17:49:33 +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 a4b2cf11d7 fix(schedule): 建齐 17 张缺表让排期生效,并摘除 7 张不可采表
问题
----
排期前飞行检查(dry-run 只校验表名解析,不校验表是否存在)发现:
`tushare_sync.sh` 引用了 **21 张库中不存在的表**(fut_* / fx_* / hk_* / us_* /
opt_daily / sge_daily / ths_index / rate_* …)。因缺表只记 `写入失败` 日志、
**退出码仍为 0**,带 pipefail 的脚本也不会 abort —— 表现为这些行**长期静默空转**,
与用户报的 stk_holdertrade 停摆是同一类缺陷。仓库此前没有任何机制发现它。

改动
----
1. 新增 `Scripts/bin/create_missing_tables.py`
   - `--list` 列出「排期引用但库中不存在」的表
   - `--dry-run` 打印将执行的 DDL
   - `--create` 幂等建表(已存在则跳过,不 DROP、不改既有结构)
   - 从 **Tushare 接口实际返回的 DataFrame 推导列与类型**:仓库里
     `tables_registry.json` 只登记表名/唯一键/日期列、**不含字段定义**,
     而 DDL 必须与写入时的 `df.columns` 一致,否则 upsert 整批失败。
   - 入参采用**阶梯重试**(ts_code / trade_date / start+end / 无参):
     实测不同接口要求不同入参,且部分接口有 1 次/分钟限流,单次失败不代表不可用。
2. 建表结果:**成功 17 张**,首次加载已入库 **2,708,881 行**
   (fut_daily 960,979 / fut_holding 1,800,000 / fut_trade_cal 13,527 /
     fut_basic 10,000 / hk_basic 2,785 / rate_gz_index 1,181 …,其余后台继续)
3. 摘除 7 张**确认不可采**的表,并在 config.yaml 标注确凿原因(附实测报文):
   - `libor` / `ths_index` / `ths_daily` / `stk_factor` —— **无接口访问权限**
   - `hibor` / `shibor_lpr` / `us_daily` —— 配额 **1 次/小时**,无法按日迭代采集

复核(两条独立检查)
--------------------
- 排期涉及的 **80 张表在库中全部存在** → 下午 15:40 的日更不再有空转行
- 15 条 fetch 命令 dry-run 全部可解析;配置经 YAML 解析校验

过程中修正的两处自身错误
------------------------
1. 首次探测把 `TABLE_MAP` 元组里**第 1 个**引号串当作 api_name(实为库表名),
   导致 `shibor`/`stk_managers` 等被误判为「接口名错误」;取第 2 个才是 api_name。
2. DDL 推导首版对 `str(dtype)` 做字符串匹配 —— pandas 3.x 字符串列 dtype 显示为
   `str`,导致 `ts_code`/`name` 被误生成为 `double`。
   改用 `pd.api.types.is_numeric_dtype()` 等类型谓词后,可疑列归零。

回归
----
tushare 174、core 121、akshare 183、Scripts 213、kuaixun 410 = 1101 passed
2026-09-19 08:38:27 +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 c6d3556b4b fix(data): 统一三库 collation(81 张表),跨库 UNION/JOIN 恢复可用
缺陷(P0-6 的后半部分)
----------------------
日期格式统一后,跨库查询仍报:
  ERROR 1271 (HY000): Illegal mix of collations for operation 'UNION'
原因是列的 collation 不同:
  tushare_data      63 张表  utf8mb4_unicode_ci
  akshare_data      18 张表  utf8mb4_unicode_ci(库内自身也不一致)
  fin_data_clean    全部     utf8mb4_0900_ai_ci
跨源对账/双源比对因此无法用原生 SQL 完成。

前置条件(本次已满足)
----------------------
collation 变更 MySQL **只能走 COPY 算法(整表重建)**,期间该表禁止写入,
且不可回退 —— 因此先做了完整备份:
  ~/Library/Application Support/FinDataHub/backups/db-20260918-235703/
    tushare_data.sql.gz     1.9G   CRC OK, CREATE TABLE 65 条(与本库表数一致)
    akshare_data.sql.gz     496M   CRC OK, 43 条
    fin_data_clean.sql.gz   416M   CRC OK, 14 条
备份可用性经 gzip -t 与建表语句计数双重校验。

改动
----
新增 Scripts/bin/migrate_collation.py:
  --report 输出待转换清单(按体积升序)
  --execute 逐表 ALTER ... CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
            每表记录前后 collation、行数、残留异类列数;任一异常即 fail-fast
  --verify 独立复核 + 端到端跑跨库 UNION/JOIN
选 utf8mb4_0900_ai_ci 为目标:MySQL 8 默认,且 3 个库中 2 个已经是它。

迁移结果(实测)
----------------
  转换 81 张表,总耗时 336.9 秒,**0 失败**
  每张表行数前后完全一致(如 stock_daily 8,046,947 → 8,046,947)
  残留异类列全部为 0
  复验:三库 65+43+14 = 122 张表全部符合目标

效果(实测,无需任何显式 COLLATE)
----------------------------------
  跨库 UNION          : ✓ 返回 2 行
  跨库 JOIN 同标的对账 : ✓ 命中 5,547 行(akshare/tushare 同日同标的)
  → 跨源校验通路恢复;此前需在每条 JOIN 上写 COLLATE 才能绕过

回归
----
core 121 / tushare 174 / akshare 183 / Scripts 207 / kuaixun 407 = 1092 passed
银层迁移冒烟(表重建后下游):stock_weekly 11,262 行写入成功,blocked=False,质量分 0.85
2026-09-19 00:21:44 +08:00
FinDataHub 6456fcd299 fix(schedule): 补排期两张静默停摆 65 个交易日的事件表 + 新增排期覆盖审计
问题(用户报告)
----------------
`stk_holdertrade`(股东增减持)与 `repurchase`(股票回购)双双停在
`20260618`,而行情已到 `20260917` —— **停摆约 65 个交易日且无任何告警**。

根因
----
两张表在 `cli.py` 的 `_TYPE_TABLES` 任何分组里都不存在,也不在任何
`--tables` 列表里 —— **从未被任何调度任务调用过**。
`config.yaml` 里的 `tables:` 启用开关不救场:该开关只在
「未显式指定要抓哪些表」时才生效(`fetcher.py:239-243`),
而两个 launchd 调度脚本总是显式传 `--type` / `--tables`,开关被完全绕过。
于是「配置里写着 true」与「实际在不在跑」是两件事,且没有任何对账机制。

改动
----
1. `scripts/tushare_sync.sh` 补排期
   - daily:新增 `stk_holdertrade,repurchase`
   - monthly:新增 `fund_div,fund_manager,fund_share,fund_nav`
2. 新增 `Scripts/bin/audit_schedule_coverage.py`
   把三份来源交叉对账:TABLE_MAP 定义 / 调度实际覆盖 / config 声明启用。
   输出「启用但无任何排期」的真缺陷清单与「孤儿且未声明」的参考清单。
   —— 这类缺陷的特征就是**静默**,必须有机制主动对账。

补数结果(实测)
----------------
  stk_holdertrade  51,030 → 52,937 行,最新 20260618 → 20260918
  repurchase       43,584 → 45,901 行,最新 20260618 → 20260918
最近交易日分布正常(20260918 新增 11 条、20260917 新增 29 条)

审计复核
--------
  调度覆盖 55 → 61 张;「启用但无排期」真缺陷 12 → 7 张

仍未处理(本次已查明,需单独决策)
----------------------------------
剩余 7 张「启用但无排期」中,6 张**表根本不存在**,从未建过:
  hk_hold / shibor / stk_managers / stk_rewards / ths_daily / us_daily
(需先建表 DDL 再首次全量加载,属独立工作量)
另有 `stk_factor`:接口限 5 次/天,**无法批量抓取**,
`fetcher.py` 已显式跳过并打 WARNING —— 应从 config 里改为停用,避免长期假"启用"。
另有 44 张「孤儿且 config 未声明」的历史表,需人工确认是否应纳入采集。
2026-09-19 00:12:54 +08:00
FinDataHub d6c55633e1 fix(data): 统一日期列格式为 YYYYMMDD(消除范围过滤静默丢 60% 行的缺陷)
缺陷(P0-6 的格式部分)
----------------------
同一列混存 `YYYY-MM-DD` 与 `YYYYMMDD` 两种格式,按标准 YYYYMMDD 写的
等值/范围过滤会**静默丢弃**另一种格式的行。实测:
  tushare_data.stock_daily(804 万行)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')。混存由此产生。

改动
----
1. 写库层护栏(findatahub_core/db.py)
   - 新增 DATE_LIKE_COLUMNS 白名单 + normalize_date_columns():
     datetime64 → 格式化为 YYYYMMDD;字符串列仅替换匹配 YYYY-MM-DD 的值,
     其余(含 YYYYMMDD、NULL、空串)原样透传;浅拷贝输出,不改写入参。
   - upsert() / replace() 写前调用。放在共享基类可一次覆盖两个 fetcher
     与清洗层,比逐个修 20+ 个采集脚本可靠(新脚本也不会再引入)。
2. 源头修正(避免只依赖护栏)
   - akshare_fetcher/scripts/sync_daily_tasks.py(日更实际执行者)
   - akshare_fetcher/scripts/backfill_daily.py、fix_stock_daily.py
   三处 rename 后补 .dt.strftime('%Y%m%d'),并把比较口径同步改为 YYYYMMDD
   (原先用 'YYYY-MM-DD' 与 datetime64 比较,格式改变后会永远匹配不到)。
3. 迁移工具(Scripts/bin/)
   - audit_date_format.py:扫描三库全部日期样列,判定混存/仅带横线/已统一
   - migrate_date_format.py:--report / --backup / --execute / --verify / --only
     分批键选 ts_code(这些表都没有自增主键,ts_code 才是相关索引最左列;
     按 trade_date 分批会在扫描中改到分片列本身,可能重复访问或漏行)

数据迁移结果(44,477,754 行,按 ts_code 分批,逐批提交)
--------------------------------------------------------
  tushare_data.stock_daily           8,046,947  ✓
  tushare_data.stock_daily_basic     7,971,103  ✓
  tushare_data.stock_moneyflow       7,739,856  ✓
  tushare_data.margin_detail         5,174,771  ✓
  akshare_data.stock_daily          16,899,091  ✓
  akshare_data.index_daily              18,427  ✓
  akshare_data.stock_fundamental         5,501 ×2 列  ✓
  akshare_data._table_meta                  26  ✓
不变量:dashed=0 且 plain+empty=total(无行丢失)—— --verify 9/9 通过。
迁移前已留基线样本与逐年直方图至
  ~/Library/Application Support/FinDataHub/backups/datefmt-*

效果(实测)
------------
  tushare_data.stock_daily 2026 年:标准范围过滤命中 951,774 / 实际 951,774(100%)
  修复前:命中 376,056 / 实际 946,221(丢 60%)
  三库等值过滤均正常(tushare 20260916=5550、akshare 20260902=5547、clean=5550)
  迁移后重跑审计:同列混存 6→0,仅带横线 3→0,仅无横线 21→30

回归
----
core 121 passed(新增 13 条,含"upsert/replace 确实挂了归一化"的集成用例与
"不得就地改写入参"的契约用例,均做过证伪)、tushare 174、akshare 183、
Scripts 207、kuaixun 407 —— 合计 1092 passed。
gate.py 与 db.py 及三个采集脚本已同步至部署位。

仍未处理(同属 P0-6 的另一半)
------------------------------
**collation 不一致**:tushare_data 63 张表 316 个列为 utf8mb4_unicode_ci,
另两库为 utf8mb4_0900_ai_ci(akshare_data 自身还有 5 个列不一致)→ 跨库 UNION/JOIN
仍报 ERROR 1271。统一需重建 66 张表,且当前**没有数据库备份**,
故未在本次执行;临时可用显式 COLLATE 规避:
  ... ON a.trade_date = b.trade_date COLLATE utf8mb4_0900_ai_ci
2026-09-18 23:34:51 +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