Files
FinDataHub cb905cda01 docs: 新增架构说明与全面审查方案,补入工作记忆
## Docs/ARCHITECTURE.md(新增)

此前缺一份「四层是什么、边界在哪、数据怎么流」的单一入口——新人与跨端协作
只能翻 5000 行 DEVLOG。本文控制在 300 行内,只写当前稳定结构:

- 全景图与四层边界(采集/数据/服务/展示,含"不该做什么")
- 数据流(日更行情、快讯、读取三条链)
- 跨平台约束表(该用 paths/process_utils 的什么,禁止直接写什么)
- 已知边界:明确标注「七步 Pipeline / 四级门控不在生产路径」等
- 测试约定、代码组织约定

## Docs/FinDataHub-全面审查与改进方案-2026-10-25.md(新增)

审查结论 + 11 项修复 + 待办。核心判断:
架构无结构性问题,真正风险集中在「失败时静默」——三处最危险的缺陷同一个模式:
出错不报警、不中断、只悄悄留下错误状态。这类比崩溃难查,因为崩溃有日志。

含两处对初版判断的自我更正:
- datetime.utcnow 并非"已导致数据偏移"(实测主链路用 datetime.now,
  36 万行数据无偏移),真实缺陷是潜在的基准混存
- extend/fetchers/collector.py 已不存在(采集器已重构)

## DEVLOG.md

追加 6 轮记录,含每轮的证据、判据与被推翻的判断。

## .workbuddy/memory

按 P2-23 既定策略入库。MEMORY.md 新增 14 条硬约束,含两处记忆纠正:
collector.py 路径作废、PythonProcessManager 拆分的决策依据与执行顺序。
2026-10-26 20:56:10 +08:00

11 KiB
Raw Permalink Blame History

FinDataHub 架构说明

目的:给「四层是什么、边界在哪、数据怎么流」一个单一入口。 历史决策与逐次改动留在 DEVLOG.md,本文只写当前稳定结构。 最后更新:2026-10-25


一、全景

┌─────────────────────────────────────────────────────────────────┐
│  展示层                                                          │
│  ├─ App/            macOS 原生(SwiftUI,77 文件)               │
│  ├─ Services/kuaixun-frontend/   Vue 3 + Vite(跨端共享)         │
│  └─ App.Win/(规划) Tauri 2 壳 + 同一份 Vue 构建产物             │
└───────────────────────────┬─────────────────────────────────────┘
                            │ HTTP / WebSocket :8100
┌───────────────────────────▼─────────────────────────────────────┐
│  服务层  Services/kuaixun-service(FastAPI)                      │
│  app/ 路由与 DAL | common/ 配置与安全 | extend/ 采集管线          │
└───────────────────────────┬─────────────────────────────────────┘
                            │ SQLAlchemy / pymysql
┌───────────────────────────▼─────────────────────────────────────┐
│  数据层  MySQL                                                    │
│  tushare_data | akshare_data | fin_data_clean | news_flash     │
│  共享代码:Services/findatahub-core(BaseDatabaseManager 等)      │
└───────────────────────────▲─────────────────────────────────────┘
                            │ 写入
┌───────────────────────────┴─────────────────────────────────────┐
│  采集层                                                          │
│  Services/tushare_fetcher | Services/akshare_fetcher(CLI)      │
│  Scripts/bin/newsflash_collect.py(快讯常驻采集器)               │
│  入口:launchd(macOS)/任务计划程序(Windows,规划)              │
└─────────────────────────────────────────────────────────────────┘

二、四层的边界

2.1 采集层 —— 「取数并落库」

组件 职责 不该做的事
tushare_fetcher Tushare API → tushare_data 不做质量门控、不做跨源校验
akshare_fetcher akshare API → akshare_data 同上
newsflash_collect.py 8 信源快讯 → news_flash 不做 AI 摘要(交给 extend/)
fin_data_clean 写入 清洗后数据 当前无生产调度(见 §5)

唯一约定:采集层只负责「取到什么写什么」,并保证写入幂等(upsert)。 写全量快照只能用 replace(),且它带收缩熔断——这是用事故换来的约束,详见 db.py 的 docstring。

2.2 数据层 —— 「共享数据访问」

findatahub-core 提供 BaseDatabaseManager,两个 fetcher 各自只覆写配置路径:

findatahub-core/src/findatahub_core/db.py   ← 唯一实现(upsert/replace/元数据/白名单)
tushare_fetcher/core/db.py                  ← ~50 行,只覆写路径
akshare_fetcher/core/db.py                  ← ~60 行,只覆写路径

不要在 fetcher 侧再写第二套 upsert 逻辑。历史上三方重复过 574 行,改一处漏两处。

2.3 服务层 —— 「对外 API」

app/main.py          应用装配:中间件顺序 → 路由注册 → /health
app/routers/         public / external / internal / admin / collector / unified / websocket
app/dal.py           SQL 封装(只读为主)
app/dal_unified.py   统一视图查询
app/database.py      SQLAlchemy 模型 + 引擎(懒加载 + 双检锁)
common/security.py   安全头 / CORS / 异常捕获 / 请求 ID
common/config.py     .env 加载
extend/              采集管线、去重、摘要、打标、通知(不打包为服务端代码)

中间件顺序不可随意调整(由内到外):

apply_exception_catch(app)   # 最内:异常就地转 JSON,外层才加得上头
apply_security_stack(app)    # 中间:安全响应头 + X-Request-ID
apply_cors(app)              # 最外:跨源白名单

原因写在 apply_exception_catch 的 docstring 里:Starlette 的 ServerErrorMiddleware 恒在最外层, 未捕获异常会绕过 CORS 中间件,浏览器只能看到 "blocked by CORS policy"。

2.4 展示层 —— 「界面」

端 端点解析方式
浏览器 同源相对路径 /api/v1
移动端浏览器 同上,或用 VITE_API_BASE 指向局域网后端
Tauri(Windows) runtime.js 自动切 http://127.0.0.1:8100
SwiftUI(macOS) 不经后端,直接 spawn Python CLI

Web 侧所有端点必须走 src/utils/runtime.js,不要在组件里硬编码 location.host 或相对路径—— 桌面壳里两者都会静默失败:界面能开、数据全空。


三、数据流

3.1 日更行情

launchd com.tushare.dailyupdate (15:40)
  └─ bash scripts/daily_update.sh
       └─ tushare_fetcher CLI
            └─ BaseDatabaseManager.upsert()   ← 幂等
                 └─ tushare_data.*

App 手动同步
  └─ PythonProcessManager (Swift) → sync_bridge.py → fetcher CLI → 同上

3.2 快讯

newsflash_collect.py(常驻)
  └─ extend/fetchers/* → 去重(L1-L5)→ unified_writer
       └─ news_flash.*
       └─ WebSocket 广播 → 前端增量刷新

3.3 读取

前端 → GET /api/v1/news/live → dal.get_live_news() → 6 张 news_* 表 UNION
                              └─ total 用独立 COUNT 子查询(不是当页行数)

四、跨平台约束

所有平台相关逻辑收敛在两个模块,不要在业务代码里直接写:

需求 用这个 不要写
临时文件/PID/心跳 paths.temp_dir() / pid_file() "/tmp/..." 或 tempfile.gettempdir()
日志目录 paths.log_dir() Path.home()/"Library/Logs"
应用数据 paths.app_support_dir() ~/Library/Application Support
用户偏好 plist paths.user_defaults_plist()(非 macOS 返回 None) 直接拼 plist 路径
子进程脱离 process_utils.detach_kwargs() start_new_session=True
进程存活 process_utils.is_running() os.kill(pid, 0)(Windows 会杀进程)
终止进程 process_utils.terminate_pid() os.kill(pid, SIGTERM)
信号注册 process_utils.register_signal_handler() 直接 signal.signal(SIGHUP, ...)

两个必须记住的坑:

  1. tempfile.gettempdir() 在 macOS 返回 $TMPDIR(/var/folders/.../T), 与现网 PID 文件所在的 /tmp 不是同一个目录。paths.temp_dir() 返回 /tmp 是刻意的。
  2. Windows 上 os.kill(pid, 0) 等价于 TerminateProcess,不是"探测"。

五、已知边界(不要假设这些在生效)

能力 实际状态
七步 Pipeline / 四级门控 不在生产路径。flows/pipeline_runner.py 未装 launchd,生产日更直写 raw 库
fin_data_clean 自动更新 靠一次性迁移维持,周/月线曾脱节
数据质量门控 / 血缘 / 双源交叉验证 代码存在,线上均不生效
SSRF 防护 已修(follow_redirects=False + 逐跳校验)
凭据管理 ~/.config/findatahub/env(0600);轮换待办

讨论「门控是否生效」之前,先确认 pipeline_runner 到底有没有在跑。


六、测试约定

6.1 首选:统一入口

python3 Scripts/bin/run_all_tests.py                 # 6 个 Python 套件
python3 Scripts/bin/run_all_tests.py --list          # 只看套件与选中的解释器
python3 Scripts/bin/run_all_tests.py -k replace      # 参数透传给 pytest
python3 Scripts/bin/run_all_tests.py --only core,backend --skip client
python3 Scripts/bin/run_all_tests.py --include-swift --include-frontend

它替人记住四件容易忘的事:

  1. 每个套件该用哪个解释器(findatahub_core 只装在部分 venv 里);
  2. 必须注入 PYTHONPATH,否则验的是已安装的旧版本;
  3. Scripts/bin/tests 需要 httpx,选错 venv 会在收集阶段报错;
  4. 不能并行——两个 pytest 会互踩系统临时目录。

第 4 条由脚本从根上解决:每个套件独立 --basetemp(项目内 .pytest-cache/<suite>/), 不再依赖"记得串行",也不去删系统临时目录。

6.2 单套件命令(调试用)

套件 命令 环境
Swift swift test --disable-sandbox 本机需 --disable-sandbox(SwiftPM 要调 sandbox-exec)
前端 cd Services/kuaixun-frontend && npx vitest run Node 22+
后端 cd Services/kuaixun-service && .venv/bin/python -m pytest tests/ -q 自带 venv
脚本层 python -m pytest Scripts/bin/tests -q 需 httpx + pymysql(kuaixun venv 全有)
core PYTHONPATH=src python -m pytest tests/ -q 必须指向源码
tushare PYTHONPATH=../findatahub-core/src python -m pytest tests/unit -q 仅 unit,integration 需真实额度
client PYTHONPATH=../findatahub-client/src python -m pytest tests -q 被测模块是 findatahub.client

两个实际踩过的坑:

  1. findatahub_core 以非 editable 装进各 venv,跑 core 测试必须 PYTHONPATH=src, 否则验的是已安装的旧版本,源码改动不生效。
  2. 并行跑两个 pytest 进程会报 FileExistsError: pytest-of-root 的 EEXIST 假失败 (表现为几十个 errors,看着像大回归)。统一入口已用独立 basetemp 规避。

七、代码组织约定

  • 超大视图/服务用 extension 拆分到多个文件,不拆成独立 struct (对齐 SettingsView+Actions.swift / +Persistence.swift / +Database.swift)。
  • 定时器一律 Timer.publish(...).autoconnect() + .onReceive;不用 Timer.scheduledTimer (不持有引用就永不 invalidate,每开一次视图泄漏一个)。
  • 单例若需启动即生效,必须在 FinDataHubApp.init() 里显式触碰一次 (@Published 在 init 中赋值不触发 didSet)。
  • Python 版本全项目锁 3.12,唯一事实来源是仓库根 .python-version, 校验脚本 Scripts/bin/check_python_version.py --strict(CI 已接入)。