docs: 收口 L2 / T5-2 / T8,版本 1.6.0 → 1.6.1

## 待办清单

- 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):本轮为内部重构 + 缺陷修复,无新增用户可见功能。
This commit is contained in:
FinDataHub committed 2026-10-27 23:23:16 +08:00
1 parent cb11a3d445
commit b777f4e241
5 files changed
+219 -53

No files matched your search

+18
View File
@@ -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。
+36 -36
View File
@@ -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/<suite>/`),**并行两个 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**,**周/月线已脱节**;**交易时段负载高是常态不是泄漏**。
+68
View File
@@ -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 → `<App>/Contents/Resources`;源码树 → 仓库根
- `service_dir(name)`:`<root>/<name>` 或 `<root>/Services/<name>`
- `service_src_dir(name)`:`service_dir(name)/"src"`,可直接塞进 sys.path
**判据是内容而非层级**:仓库根有 `Services/`,bundle 资源目录没有。
首版按「两级往上」算,源码树里解析成 `<repo>/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** 并已装机。
+96 -16
View File
@@ -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 | `<App>/Contents/Resources` | `<…>/Resources/akshare_fetcher` |
| 源码树 | 仓库根 | `<repo>/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/`)。按层级硬算会在源码树里解析成
`<repo>/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 加载这样收成单一函数,
而不是指望两边同步。
+1 -1
View File
@@ -1 +1 @@
1.6.0
1.6.1