From b777f4e241b062c67bb5d3d1a1f3f9525fc05531 Mon Sep 17 00:00:00 2001 From: FinDataHub Date: Tue, 27 Oct 2026 23:23:16 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=94=B6=E5=8F=A3=20L2=20/=20T5-2=20/?= =?UTF-8?q?=20T8=EF=BC=8C=E7=89=88=E6=9C=AC=201.6.0=20=E2=86=92=201.6.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 待办清单 - L2 标记完成,并记录「为什么不在脚本里写 try/except 双路径,而是给 paths.py 补函数」 - T5 标题更新为「842 → 800 行,第 1–2 步已完成」,补第 2 步完整记录 (含 SyncSession 的取舍、证伪结果、以及第 3 步为何仍不做) - 新增 T8:生产入口生成随机 API_KEY 导致受保护端点恒 401, 含根因对照表、修复、部署位实测、防回退锁 ## DEVLOG 追加本轮三段记录。其中两个坑值得单独留档: 1. `pgrep -f "FinDataHub.app/Contents/MacOS"` 会匹配到 Bash 工具自身命令行 → kill 自己(exit 143),链式命令中断。部署片段已全部改为 `pgrep -x FinDataHub`。 这是本项目第二次踩同类问题(第一次是 pkill -f)。 2. Swift 编译报 `failed to produce diagnostic for expression` 时,错误位置会退化到 文件**最后一行**,真正原因藏在中间的 #Preview 固件里。 ## MEMORY.md 同步三处约束:API_KEY 必须走共享加载函数(第二处双入口分叉)、 paths.service_dir 用于服务资源定位、SyncSession 已是状态唯一来源 + `replaceSession` 的用途与其编译陷阱。 ## VERSION 1.6.0 → 1.6.1(patch):本轮为内部重构 + 缺陷修复,无新增用户可见功能。 --- .workbuddy/memory/2026-10-27.md | 18 +++++ .workbuddy/memory/MEMORY.md | 72 ++++++++++---------- DEVLOG.md | 68 +++++++++++++++++++ Docs/FinDataHub-遗留待办清单.md | 112 +++++++++++++++++++++++++++----- VERSION | 2 +- 5 files changed, 219 insertions(+), 53 deletions(-) diff --git a/.workbuddy/memory/2026-10-27.md b/.workbuddy/memory/2026-10-27.md index 2d06c47..098ac12 100644 --- a/.workbuddy/memory/2026-10-27.md +++ b/.workbuddy/memory/2026-10-27.md @@ -14,3 +14,21 @@ - **MEMORY.md 重构**:26,422 → 15,045 字节(压 43%),12 节分层结构,20 项硬约束逐条 grep 验证零丢失;性能基线 / MySQL 升级链 / 体积口径拆到 `Docs/FinDataHub-运维基线.md`。 - 新增 `Docs/FinDataHub-运维基线.md`(性能基线、launchd 生产链路、MySQL 版本链、体积口径)。 + +## 23:25 本轮收口:L2 + T5 第 2 步 + T8 + +- **L2 完成**:`paths.py` 新增 `resources_root/service_dir/service_src_dir`, + 自动认 bundle 与源码树两种布局;`backfill_stock_daily.py` 去掉硬编码路径。 + 两形态实跑验证,bundle 侧与旧硬编码逐字一致。 +- **T5 第 2 步完成**:20 个 `@Published` 收进 `Models/SyncSession.swift` 值类型, + 对外仍是同名转发属性 → View 层零改动。顺带修掉 `reset()` 漏重置 6 个统计字段的真缺陷。 + 证伪通过(回退后 6 条断言精确失败)。Swift 42 → 47。 +- **T8 新发现并修复**:生产入口 lifespan 里 `secrets.token_urlsafe(32)` 现生成随机 + API_KEY,而客户端读持久化文件 → 受保护端点恒 401(实测 `/api/v1/admin/keys`)。 + 已收进 `app/dependencies.py::load_or_create_api_key()` 两入口共用。部署位实测 + 文件 key → 200、乱码 → 401。backend 443 → 446。 +- **又踩一次 pgrep 自杀**:`pgrep -f "FinDataHub.app/Contents/MacOS"` 匹配到 Bash 工具 + 自身命令行 → exit 143。部署片段已在记忆中改为 `pgrep -x FinDataHub`。 +- **Swift 编译报错位置会骗人**:`failed to produce diagnostic for expression` 落在 + 文件末行,真正原因在 `#Preview` 里直接写 `private(set)` 属性。 +- 版本 1.6.0 → 1.6.1,全量 8 套件 PASS。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 01559c4..55483f2 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -18,9 +18,9 @@ ## 1. 过时记忆纠正 -- `extend/fetchers/collector.py` **已不存在**:重构为 `manager.py` + 各源独立文件。 -- 「`datetime.utcnow` 已致数据偏移 8 小时」**不成立**(见 §5.3);「akshare 是 Windows - 硬阻塞」**已推翻**(见 §7);「部署位未更新」**已完成**(2026-10-27 装 v1.6.0)。 +`extend/fetchers/collector.py` **已不存在**(重构为 `manager.py` + 各源文件); +「`datetime.utcnow` 已致 8 小时偏移」**不成立**(见 §5.3);「akshare 是 Windows 硬阻塞」 +**已推翻**(见 §7);「部署位未更新」**已完成**(2026-10-27 装 v1.6.1)。 ## 2. 版本 3.12 @@ -29,8 +29,7 @@ - **setuptools 的 `build_editable` 本机必失败**(`EEXIST`,与中文路径无关)→ tushare/akshare 的 `[tool.uv.sources]` 里 `findatahub-core` **不能加 `editable = true`**(hatchling 正常)。 - 运行时不依赖:build-app.sh 拷 `findatahub-core/src` 并用 `.pth` 引入。 -- `~/Library/Application Support/FinDataHub/venvs/<项目>/.venv` **优先级高于 bundle 内 venv**, + 运行时不依赖:build-app.sh 拷 `findatahub-core/src` 并用 `.pth` 引入。- `~/Library/Application Support/FinDataHub/venvs/<项目>/.venv` **优先级高于 bundle 内 venv**, 旧副本会造成「bundle 已换新、运行还是旧」。已修:比对 `pyvenv.cfg` 次版本号,不一致回退 bundle 并记 WARN(uv 生成 `version_info`、标准 venv 是 `version`,两种都认)。 @@ -43,11 +42,12 @@ - 沙箱禁大批量删除(阈值 199 项):`rm -rf` 放 `( ... & )` 子 shell 或 mv 到 /tmp; rsync 排除 `.venv.bak`(曾致 515M→708M)。产物约 438–442 MB。 - **kuaixun-service 包结构**:可安装包为 `app`(`uvicorn app.main:app`)+ `common`; - `extend/` 是采集管线、从 cwd 运行、不打包。**不要**写 `packages.find where=["src"]`—— - `src/kuaixun_service` 是残包(已删),pyproject 用显式 `packages = ["app", "common"]`。 + `extend/` 是采集管线、从 cwd 运行、不打包。**不要**写 `packages.find where=["src"]` + (`src/kuaixun_service` 是残包已删),用显式 `packages = ["app", "common"]`。 ```bash # 部署(漏一步就静默用旧代码) -kill -TERM $(pgrep -f "FinDataHub.app/Contents/MacOS" | head -1) +# ⚠️ 应用用 pgrep -x:`pgrep -f "FinDataHub.app/…"` 会匹配 Bash 工具自身 → 自杀。 +kill -TERM $(pgrep -x FinDataHub | head -1) for p in $(pgrep -f "[n]ewsflash_backend.py") $(pgrep -f "[n]ewsflash_collect.py"); do kill -TERM $p; done lsof -nP -iTCP:8100 -sTCP:LISTEN # 必须已释放;未停后端则新 app 见端口被占会跳过启动 mv /Applications/FinDataHub.app /tmp/backup-... && mv .build/FinDataHub.app /Applications/FinDataHub.app @@ -74,8 +74,9 @@ open /Applications/FinDataHub.app # 自动拉起 backend + collector (analysis/source-test/shutdown/SPA catch-all)、`ResponseCache`/`RequestLog`(经 `inner_middlewares` 传入,层序仍由 factory 定)。health 共用 `app/health.py`。 -历史事故:曾两处各写一遍装配,只改 `app/main.py` 时生产**完全空转且不报错**, -`app/database.py` 却因被 import 而生效(2026-10-27 实测 CORS 405、health 只有两字段)。 +**同类分叉已踩过第二处**:API_KEY 必须走 `app/dependencies.py::load_or_create_api_key()` +(读持久化文件)。backend 曾在 lifespan 里现生成随机值 → 客户端拿文件 key 请求受保护端点 +恒 401、两边都不报错。**凡「两入口都该有且必须一致」的逻辑,收成单一函数,别指望人工同步。** ### 4.2 中间件顺序不可调(由内到外) @@ -88,16 +89,16 @@ Starlette 的 `ServerErrorMiddleware` 恒在最外层,未捕获异常绕过 CO 本地时间(MySQL `session.time_zone = SYSTEM` = UTC+8),非 UTC;改 UTC 需迁 36 万行 + 改 session tz,不值。前提「Python 与 MySQL 同时区」由 `check_timebase_drift()` 观测, -`/health` 的 `db.timebase_drift_seconds` >60s 判 `degraded`;库若迁 UTC 容器应**整体迁 -UTC**。坑:零参 callable 被 SQLAlchemy 包成接受 `ExecutionContext` 的版本, -`col.default.arg()` 报 `missing ... 'ctx'`(原函数在 `arg.__wrapped__`),测试用**行为断言**。 +`/health` 的 `db.timebase_drift_seconds` >60s 判 `degraded`;库若迁 UTC 容器应**整体迁 UTC**。 +坑:零参 callable 被 SQLAlchemy 包成接受 `ExecutionContext` 的版本,`col.default.arg()` 报 +`missing ... 'ctx'`(原函数在 `arg.__wrapped__`),测试用**行为断言**。 ### 4.4 `common` 命名冲突 `Scripts/bin/common.py`(模块)与 `kuaixun-service/common/`(包)同名且前者已被缓存 → `from common.security import ...` 报 `'common' is not a package`。绕法:按 `__file__` 推导 + -`importlib` 显式加载(`app/factory.py::_security_module` 已封装)。**不要用 -`common.KXS_HOME`**(bundle 下解析为 `Contents/Services/...`,缺 `Resources`)。 +`importlib` 显式加载(`app/factory.py::_security_module` 已封装)。**不要用 `common.KXS_HOME`** +(bundle 下解析为 `Contents/Services/...`,缺 `Resources`)。 ### 4.5 health 的 `ready` 语义 @@ -131,13 +132,10 @@ collations`。`news_flash.published_at` 是 bigint 毫秒。 有 `df["vol"] = df["amount"]`,而 `cleaner.py:139-146` 的 `|amount-vol|/max < 0.01` 恒成立 → `amount=0.0`。实测命中 94%。 - **SSRF 重定向绕过**:`extend/content_enricher.py:181` 只校验初始 URL,`:190` 仍 - `follow_redirects=True`。 + `follow_redirects=True`。**凭据明文在 launchd plist**(`com.{tushare,akshare,ths}.*.plist`)。 - **`common.py:191` 用 `split(":")` 解析 PID**(`:182` 写 `f"{pid}:{isoformat()}"`) → `started_at` 恒截断到整点 → `get_status().stuck` 在每小时第 10 分钟后恒真。 - 改 `split(":", 1)`。 -- **凭据明文在 launchd plist**(`com.{tushare,akshare,ths}.*.plist` 内联 TOKEN 与密码,P0)。 -- **`published_at` 类型不统一**(BigInteger vs TIMESTAMP)**未造成可观测问题**(跨表 UNION - 都是同构分表)→ **不做迁移**。 +- **`published_at` 类型不统一**(BigInteger vs TIMESTAMP)**未造成可观测问题** → **不迁移**。 ## 6. 测试 @@ -153,9 +151,8 @@ set -a; . ~/.config/findatahub/env; set +a; python3 Scripts/bin/run_all_tests.py 挑不到满足依赖的解释器时它 exit 2 并列出缺什么,**不要绕过它用别的 venv 硬跑**。 - 每套件独立 `--basetemp`(`.pytest-cache//`),**并行两个 pytest 互踩 tmpdir 已根治** (历史报 `FileExistsError: pytest-of-root`,表现为几十个 errors、看着像大回归)。 -- `scripts` 全量 257 条(含需 httpx 的 `test_rss_fetcher.py`);那 47 条曾因 venv 选错被 - `--ignore`,**从来没在本机跑过**。CI 有 `--list` 自检,改目录要同步 `SUITES`。 -- `swift build`/`swift test` 本机需 `--disable-sandbox`。 +- `scripts` 全量 263 条(含需 httpx 的 `test_rss_fetcher.py`);CI 有 `--list` 自检, + 改目录要同步 `SUITES`。`swift build`/`swift test` 本机需 `--disable-sandbox`。 - **真实 MySQL 集成测试**(默认不跑):fixture 新建 `fdh_it_replace_<随机8位>` 空库、用完 DROP(带前缀且 DROP 前再断言),不碰现有库;无 `MYSQL_PASSWORD` 整组 **skip**。 最关键用例「跨 chunk 失败整体回滚」——Fake 游标**永远复现不出来**(不会真提交)。 @@ -165,7 +162,8 @@ set -a; . ~/.config/findatahub/env; set +a; python3 Scripts/bin/run_all_tests.py ## 7. 跨平台 `paths.py` / `process_utils.py` 是**唯一事实来源**:不直接写 `/tmp/`、`Library/Logs`、 -`start_new_session`、`os.kill(pid,0)`。 +`start_new_session`、`os.kill(pid,0)`、`/Applications/FinDataHub.app/...`。 +服务资源用 `service_dir(name)` / `service_src_dir(name)`(自动认 bundle 与源码树两种布局)。 - `tempfile.gettempdir()` 在 macOS 返回 `$TMPDIR`(`/var/folders/.../T`),与现网 PID 文件 所在 `/tmp` **不是同一目录**;`paths.temp_dir()` 返回 `/tmp` 是刻意的。 @@ -190,12 +188,15 @@ set -a; . ~/.config/findatahub/env; set +a; python3 Scripts/bin/run_all_tests.py 返回**动作序列**而非直接改状态:原语义允许"一行命中多个模式"(靠 `matched` 标记而非提前 return),改 guard-return 会静默改行为。 坑:`NSRange` 必须用 `(s as NSString).length`,**不能用 `s.count`**(emoji 占 2 个 UTF-16 - 单元、count=1,用 count 会让行尾正则静默漏匹配)。 - 既有行为(勿"修正"):tqdm 的 `daily sync: 45%` 表名取到 `"sync"`(冒号前最后一个非数字 - 片段),测试已锁定。 -- **`PythonProcessManager` 暂不整体拆分**(842→729 行,第一步已完成)。`private` 是**文件级** - 作用域,拆文件需放宽约 37 个成员,且 View 层对 `@Published` 直接访问约 **90+ 处**。 - 顺序:先 `SyncSession` 值类型(对外仍以计算属性暴露、View 零改动)→ 分批迁 View → 拆文件。 + 单元、count=1,用 count 会让行尾正则静默漏匹配)。既有行为勿"修正":tqdm 的 + `daily sync: 45%` 表名取到 `"sync"`(冒号前最后一个非数字片段),测试已锁定。 +- **`PythonProcessManager` 状态已收进 `SyncSession` 值类型**(842→800 行,第 1–2 步完成)。 + 20 个散落的 `@Published` 改为 `@Published private(set) var session` + 同名转发计算属性, + **View 层零改动**;重置是 `session = SyncSession()`(旧实现逐字段赋值漏了 6 个统计字段)。 + 预览/测试要摆状态用 `replaceSession(_:)`——各字段 setter 已 `private(set)`, + 直接 `pm.progress = …` 会让 Swift 报 `failed to produce diagnostic for expression` + 且位置退化到**文件末行**,看不到真正错误点。 + 第 3 步(拆文件)仍未做:扩展只需读 `session`,但拆文件本身不产生行为价值。 ## 9. 操作禁忌 @@ -211,15 +212,14 @@ set -a; . ~/.config/findatahub/env; set +a; python3 Scripts/bin/run_all_tests.py - `brew services stop` 后本会话内 `start` 常遇 `launchctl bootstrap ... I/O error`—— **会话级**问题,plist 没坏则重启电脑即恢复。 -## 10. 运维与排查(详见 `Docs/FinDataHub-运维基线.md`) +## 10. 运维与排查 -性能基线表、launchd 生产链路、MySQL 升级链与体积口径、日志路径与排查技巧,全在该文档。 -改代码前必须知道的三条: +性能基线、launchd 链路、MySQL 升级链与体积口径、日志路径与排查技巧,全在 +`Docs/FinDataHub-运维基线.md`。改代码前必须知道的三条: - **跨模式 grep 一律用 Grep 工具(ripgrep)**:BSD grep 的 `grep -rn "a\|b" --include=...` 会**静默漏匹配**,曾据此误判 `pipeline_runner` 无调用方。 - **采集器"反复重启"最快判据**:`newsflash/collector-stderr.log` 里 - `Building prefix dict ...`(jieba 初始化)的行数 ≈ 重启次数,比查 PID 快。 + `Building prefix dict ...`(jieba 初始化)行数 ≈ 重启次数,比查 PID 快。 - **日更数据不经过 `fin_data_clean` 也无质量门控**:`flows/pipeline_runner.py` **未装 - launchd**,清洁层靠一次性脚本维持、**周/月线已脱节**。讨论"门控/血缘生效"前先确认它 - 有没有在跑。**交易时段负载高是常态不是泄漏**,节能类优化须两种时段分验。 + launchd**,**周/月线已脱节**;**交易时段负载高是常态不是泄漏**。 diff --git a/DEVLOG.md b/DEVLOG.md index b70cf10..487332f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -5407,3 +5407,71 @@ MEMORY.md 已超注入上限被截断(原 400 行 / 26,422 字节,实测截 ### 回归 后端 435 → **443 passed**。全量 8 套件 PASS(含真实库集成),40.6s。 + +## 2026-10-27(续)— L2 + T5 第 2 步 + T8(收口本轮) + +### L2 `backfill_stock_daily.py` 硬编码路径 → 已修 + +原为 `sys.path.insert(0, "/Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src")`。 +不在脚本里加 try/except 双路径(那样只解决 bundle 形态),而是给 `paths.py` 补三个函数: + +- `resources_root()`:bundle → `/Contents/Resources`;源码树 → 仓库根 +- `service_dir(name)`:`/` 或 `/Services/` +- `service_src_dir(name)`:`service_dir(name)/"src"`,可直接塞进 sys.path + +**判据是内容而非层级**:仓库根有 `Services/`,bundle 资源目录没有。 +首版按「两级往上」算,源码树里解析成 `/Scripts`——被 +`test_real_repo_layout_resolves` 当场抓出。 + +两形态实跑均通过,bundle 侧与旧硬编码**逐字一致**。 + +### T5 第 2 步:20 个 `@Published` 收敛为 `SyncSession` 值类型 + +新增 `Models/SyncSession.swift`,manager 上变成 +`@Published private(set) var session = SyncSession()` + 20 个同名转发计算属性。 +View 层零改动(90+ 处读取照旧)。 + +**顺手修掉一个真实缺陷**:`reset()` 旧实现逐个赋值,漏了 6 个字段 +(`syncRate` / `successCount` / `failCount` / `currentTableProgress` / +`currentTableStocksDone` / `currentTableStocksTotal`)。症状是「第二轮同步一开始就 +显示上一轮的速率与成功率」,不报错、只能肉眼发现。改成 `session = SyncSession()` 后 +新增字段自动被重置。 + +**踩到的坑**:编译报 `failed to produce diagnostic for expression`,位置在 +DashboardView / SyncProgressView 的**最后一行**——那是 `#Preview` 固件在直接 +`pm.progress = 0.45`,而 setter 已收为 `private(set)`。Swift 对大 body 的类型检查 +报错会退化到文件末行,真正的错误点看不出来。 +解法是给预览与测试一个显式缝 `replaceSession(_:)`,注释写明「业务代码不要用」。 +另外把 `reset()` 从 private 放宽到 internal,只为让回归测试能调用。 + +**证伪**:把 `reset()` 改回逐字段赋值后,`testResetClearsAllSessionFields` +精确报出 6 条失败(每条形如 `("3999") is not equal to ("0") - successCount 未被重置`), +恢复后 47 tests 全绿。 + +### T8 生产入口生成随机 API_KEY(部署验证时新发现,已修) + +装机后测受保护端点:文件 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) 现生成随机值 +``` + +生产走后者,每次启动换一把钥匙,客户端永远对不上,且两边都不报错。 + +修:收进 `app/dependencies.py::load_or_create_api_key()` 两处共用 +(已存在则复用不覆盖;存在但为空则补生成——空 key 会让 `compare_digest("","")` 恒真)。 +部署位实测:文件 key → **200**、乱码 → 401、无 → 401。 + +### 本轮回归 + +| 套件 | 之前 | 现在 | +|---|---|---| +| scripts | 257 | **263**(+6:服务布局解析) | +| swift | 42 | **47**(+5:SyncSession) | +| backend | 443 | **446**(+3:API_KEY 来源) | + +全量 8 套件 PASS(含真实库集成),47.5s。版本 1.6.0 → **1.6.1** 并已装机。 diff --git a/Docs/FinDataHub-遗留待办清单.md b/Docs/FinDataHub-遗留待办清单.md index 8b25e17..bc16bfc 100644 --- a/Docs/FinDataHub-遗留待办清单.md +++ b/Docs/FinDataHub-遗留待办清单.md @@ -24,32 +24,41 @@ --- -### L2 `backfill_stock_daily.py` 硬编码部署路径 +### L2 `backfill_stock_daily.py` 硬编码部署路径 ✅ 已完成(2026-10-27) -**位置**:`Scripts/bin/backfill_stock_daily.py:15` +**原状**:`Scripts/bin/backfill_stock_daily.py:15` 写死 ```python sys.path.insert(0, "/Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src") ``` -**为什么搁置**:该脚本是**部署环境专用**(依赖 `/Applications` 里的 akshare 源码), -臣无法在本地验证「改用 `paths.py` 解析」后的行为是否等价。原样入库优于无验证的改动。 +**为什么当初搁置**:脚本是部署环境专用,无法在本地验证「改用 `paths.py` 解析」是否等价。 -**触发条件**:实际需要再次跑历史回填,且已验证新路径解析可用。 +**实际做法**:不是加一个 `app_bundle_resources()`(那只能解决 bundle 形态), +而是在 `paths.py` 补 **`resources_root()` / `service_dir()` / `service_src_dir()`**, +让同一个函数同时认两种布局: -**建议改法**: +| 形态 | `resources_root()` | `service_dir("akshare_fetcher")` | +|---|---|---| +| bundle | `/Contents/Resources` | `<…>/Resources/akshare_fetcher` | +| 源码树 | 仓库根 | `/Services/akshare_fetcher` | -```python -try: - from paths import app_bundle_resources # 需要在 paths.py 补充该函数 - _AKSHARE_SRC = str(app_bundle_resources() / "akshare_fetcher/src") -except ImportError: - _AKSHARE_SRC = "/Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src" -sys.path.insert(0, _AKSHARE_SRC) +**判据是内容而非层级**:仓库根下有 `Services/`,bundle 的资源目录下没有 +(build-app.sh 把各服务平铺拷进 `Resources/`)。按层级硬算会在源码树里解析成 +`/Scripts`——首版就踩了这个,被 `test_real_repo_layout_resolves` 抓出来。 + +**验证**(两种形态实跑): + +``` +源码树:/Users/…/FinDataHub/Services/akshare_fetcher/src 存在 ✓ +bundle:/Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src 存在 ✓ + akshare_fetcher 可导入 ✓ ``` -注意 `paths.py` 目前**没有** bundle 资源路径函数——需先补,且要处理 -「源码树运行」与「bundle 内运行」两种形态。 +bundle 侧解析结果与旧硬编码**逐字一致**,故行为零漂移。 + +**回归**:`TestServiceLayout` 6 条(bundle / 源码树 / 两者共存优先 bundle / +都不存在时返回 bundle 形态 / src 后缀 / 真实仓库解析)。scripts 257 → 263。 --- @@ -183,7 +192,7 @@ tushare 一并纳入。 --- -### T5 `PythonProcessManager` 拆分(842 → 729 行,分步推进中) +### T5 `PythonProcessManager` 拆分(842 → 800 行,第 1–2 步已完成) **第一步已完成(2026-10-27):抽取无状态解析器。** @@ -223,6 +232,42 @@ tushare 一并纳入。 --- +#### 第 2 步:状态收敛为 `SyncSession` 值类型 ✅(2026-10-27) + +把 20 个散落的 `@Published`(progress / currentTable / ... / currentTableStocksTotal) +收进 `Models/SyncSession.swift`,manager 上改为: + +```swift +@Published private(set) var session = SyncSession() +private(set) var progress: Double { get { session.progress } set { session.progress = newValue } } +// … 共 20 个同名转发属性 +``` + +要点与收益: + +- **View 层零改动**:对外仍是同名属性,90+ 处 `pm.progress` 读取照旧。 +- **重置不再靠逐个赋值**:`reset()` 改为 `session = SyncSession()`。旧实现漏掉了 + `syncRate` / `successCount` / `failCount` / `currentTableProgress` / + `currentTableStocksDone` / `currentTableStocksTotal` **六个字段**—— + 症状是「第二轮同步一开始就显示上一轮的速率与成功率」,不报错、只能肉眼发现。 +- **setter 收为 `private(set)`**:外部只能读,写入口全部收在类内。 + 这是将来拆成多文件的必要前提(`private` 是文件级作用域)。 +- **预览固件的写入走显式缝**:`#Preview` 里原本直接 `pm.progress = 0.45`, + 现改为 `var s = SyncSession(); …; pm.replaceSession(s)`。 + `replaceSession` 的注释明确写了「仅供预览与测试,业务代码不要用」。 +- **未纳入 `session` 的字段**:`state`(带 didSet 副作用,语义是状态机而非会话数据)、 + `consecutiveFailureWarning`(UI 横幅状态)。 + +**证伪**:把 `reset()` 改回逐字段赋值(漏 6 个字段)后, +`testResetClearsAllSessionFields` 精确报出 6 条失败,恢复后 47 tests 全绿。 + +**回归**:`SyncSessionTests` 5 条(默认值中性 / 值语义 / replaceSession 往返 / +重置覆盖全字段 / 保留 queuedTasks)。Swift 42 → 47。 + +**剩余第 3 步**(拆文件):此时才可安全拆——扩展只需读 `session`, +不必放出 20 个内部状态。仍未做,因为拆文件本身不产生行为价值,等下一个改动真正 +需要触及时再做。 + ## 三、已知但不在本轮范围(记录备查) | 项 | 说明 | @@ -315,3 +360,38 @@ tushare 一并纳入。 只在部署位表现为 ImportError 降级。已改为通配复制 + 构建期完整性自检。 3. 只改 Python 资源文件时**不必全量重建**:`cp` 到部署位 + 重启应用即可 (本轮 `app/health.py` 即如此,省掉一次 4 分钟构建)。 + +### T8 生产入口生成随机 API_KEY,受保护端点恒 401 ✅ 已修(2026-10-27) + +**现象**:客户端拿 `~/Library/Application Support/findatahub/.api_key` 请求 +`/api/v1/admin/keys` 返回 **401**,而乱码 key 与无 key 同样 401—— +说明不是「没鉴权」,而是**鉴权用的 key 与客户端手里的不是同一个**。 + +**根因**(与 T6 同族,又一处双入口分叉): + +| 入口 | lifespan 里的做法 | +|---|---| +| `app/main.py` | `deps.API_KEY = _load_or_create_api_key()` → **读持久化文件** | +| `Scripts/bin/newsflash_backend.py` | `deps.API_KEY = secrets.token_urlsafe(32)` → **现生成随机值** | + +生产走后者,于是每次启动都换一把新钥匙,客户端永远对不上。 +两边都不报错,只表现为「点了没反应」。 + +**修复**:把加载逻辑收进 `app/dependencies.py::load_or_create_api_key()`, +两个入口共用。文件已存在则复用、不覆盖(覆盖会让客户端手里的旧 key 立即失效); +文件存在但为空时补生成(空 key 会让 `compare_digest("", "")` 恒真)。 + +**部署位实测**: + +``` +文件 key → HTTP 200 (修复前 401) +乱码 key → HTTP 401 +无 key → HTTP 401 +``` + +**防回退锁**:`tests/test_app_factory.py::TestApiKeySource` 3 条——断言两个入口源码里 +都必须出现 `load_or_create_api_key`、且都不得出现 `secrets.token_urlsafe`。 + +**这一族的教训**:T6 收敛了「装配」,但 lifespan 里还藏着同类分叉。 +凡是「两个入口都该有、且必须一致」的逻辑,都该像 key 加载这样收成单一函数, +而不是指望两边同步。 diff --git a/VERSION b/VERSION index dc1e644..9c6d629 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0 +1.6.1