## 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 拆分的决策依据与执行顺序。
11 KiB
11 KiB
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, ...) |
两个必须记住的坑:
tempfile.gettempdir()在 macOS 返回$TMPDIR(/var/folders/.../T), 与现网 PID 文件所在的/tmp不是同一个目录。paths.temp_dir()返回/tmp是刻意的。- 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
它替人记住四件容易忘的事:
- 每个套件该用哪个解释器(
findatahub_core只装在部分 venv 里); - 必须注入
PYTHONPATH,否则验的是已安装的旧版本; Scripts/bin/tests需要httpx,选错 venv 会在收集阶段报错;- 不能并行——两个 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 |
两个实际踩过的坑:
findatahub_core以非 editable 装进各 venv,跑 core 测试必须PYTHONPATH=src, 否则验的是已安装的旧版本,源码改动不生效。- 并行跑两个 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 已接入)。