Compare commits

..

4 Commits

Author SHA1 Message Date
ericwyuan
e911a7c1f0 feat(garmin): 绑定表单也能强制重试了,之前只有同步页有
用户想现在就试绑定,账号却处在昨天真实撞上的 SSO 冷却期里。发现「强制
重试」按钮只接在同步流程上——绑定表单命中同一个 429 时,除了看提示、等
到冷却过期,没有别的路。

- error/blocked 这两个状态本来就是绑定表单和同步区共用的,但重试按钮硬编码
  只会调 syncHistory(true)。加一个 retryAction 记住是哪个流程触发的失败,
  按钮据此调对应的重试
- startLogin 加 force 参数透传给后端已有的 /garmin/login force 支持;密码
  只在请求真正成功后才清空,所以失败重试不需要用户重新输入
- 强制重试仍然只在真正拿到 429 之后才出现,不是默认可点的选项——窗口内
  重试会延长冷却,这个闸门就是为了防这个

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 07:31:43 +08:00
ericwyuan
db7f182030 fix(garmin): 退出账号会把邮箱和令牌一起永久删掉,SSO 账号无处补救
用户刚才绑定失败:`Request failed with status code 400`。查下来是
`delete_token()`("退出 Garmin 账号"背后的函数)的注释说"邮箱留在 user 表,
下次只需要密码",但代码只有 `DELETE FROM garmin_tokens`——而 `garmin_email`
唯一的落脚点就是这张表。`users.garmin_email` 是本地密码登录时代的遗留列,
`get_remembered_email` 里写得很清楚:auth-hub 接管之后,没有任何绑定路径再
往那张表写过东西。也就是说现在**所有账号**(auth-hub SSO)退出一次,等于
永久忘记邮箱——注释描述的"安全网"对这些账号从来没生效过。

- `delete_token` 删除前把 `garmin_tokens.garmin_email` 复制一份到
  `users.garmin_email`,注释里承诺的行为终于是真的
- 顺带修了一条原有测试掩盖真相的问题:`test_disconnecting_drops_the_
  current_binding_but_not_the_legacy_value` 预先在 users 表塞了一条 legacy
  邮箱,退出后自然能读到——从来没测过 SSO 账号真正会遇到的情况(legacy 列
  本来就是空的)。新增两条测试覆盖这个和"连续退出两次不会把邮箱也搞丢"
- 生产账号的邮箱已经从退出前的 dump 备份里手工恢复,不用重新绑定就能补上

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 07:20:52 +08:00
ericwyuan
4331a07462 docs: docs/ 目录同步甲骨文部署事实(上一提交漏加)
上一个提交 git add 漏了 docs/,架构/开发/需求/auth-hub 集成文档里的 NAS
IP、8124 端口还是旧的,补上。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 23:53:26 +08:00
ericwyuan
9d6ebbe422 ops: 生产从 NAS 整体迁移到甲骨文云主机
NAS 局域网 IP 因重启被 DHCP 换过两次,磁盘/网络稳定性都不如已经跑着好几个
生产服务的甲骨文机器。整体搬迁:应用 + 数据库都搬走,NAS 只保留 Gitea(这个
仓库的源码托管,未动)。

## 迁移过程(已核对无损)

- MariaDB:NAS 导出(10.11 源库,处理了只有新版本才有的 `/*M!999999` 注释)
  → 导入甲骨文 MariaDB 10.3.39,**20 张表逐条精确 COUNT(*) 比对完全一致**
- 冻结 NAS(停服务)后又 dump 一次核对,确认期间零数据差异,才继续删库
- NAS `garmin_health_lab` 已 DROP DATABASE,备份在本地
  `~/Desktop/Work/backups/garmin_health_lab_nas_backup_20260912.sql.gz`
- 应用部署到 `/opt/garmin-health-lab`,systemd 单元(`ubuntu` 用户,非
  root),和这台机器上的 ai-gateway/auth-hub 同一套约定
- 公网:`https://garmin.zichuan.xyz`,DNS + Caddy 反代 + 自动 TLS,替代原来
  `NAS frpc → 甲骨文:8124` 那条隧道(已从 NAS 的 frpc.toml 精确删除对应段,
  其它转发未动,改完逐条复检过没打断)
- auth-hub 回调地址换成新域名,NAS/旧端口那几条历史回调已清掉
- AI 网关配置改本地回环(网关现在同机了),触发真实生成验证过

## 一个当场拦下来的风险

甲骨文部署完默认开着自动同步。迁移窗口期两边并行跑时,若两边的调度器同时去
刷新 Garmin 令牌,会撞上按账号计算的 SSO 限流(`GarminHealthLab` 仓库
2026-09-03 那次事故的根因,那次修复花了一整天)。确认账号级 auto_sync 设置
本来是关的、这次算侥幸没撞上——不是设计上的保险,所以迁移期间显式在甲骨文这边
加了 `AUTO_SYNC=false`,直接在运行进程里验证过生效,确认 NAS 已冻结、数据无
缺口后才打开。

## 文档 / 脚本同步

CLAUDE.md 明确写过"部署位置会变,排障前先查、不要凭记忆"——这次是第二次踩中
同一类问题(上次是"NAS 有没有生产环境"判断错),所以把 CLAUDE.md / PROGRESS.md
/ README.md / docs/* 里的部署事实全部更新,NAS 时代的 `deploy/` 脚本加废弃
说明保留参考、不删除,新增 `deploy/push_oracle.sh`(当场跑通一次真实部署)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 23:53:15 +08:00
19 changed files with 294 additions and 82 deletions

View File

@@ -16,8 +16,8 @@
**Framework7 9**`framework7-react`iOS 主题)+ Recharts 图表。根目录 **Framework7 9**`framework7-react`iOS 主题)+ Recharts 图表。根目录
`package.json` 是 npm workspaces`client`)。 `package.json` 是 npm workspaces`client`)。
- **数据库**:可插拔数据层(`backend/db.py``DB_TYPE` 切换—— - **数据库**:可插拔数据层(`backend/db.py``DB_TYPE` 切换——
本地开发 SQLite`backend/data/health.db`,默认);生产 NAS MariaDB 本地开发 SQLite`backend/data/health.db`,默认);生产 MariaDB(甲骨文云主机
`garmin_health_lab` 库,连接配置见 NAS 上 `backend/.env`)。 本机,`garmin_health_lab` 库,连接配置见该机 `/opt/garmin-health-lab/backend/.env`)。
- **认证****auth-hub SSO**OAuth2/OIDC`services/auth_hub_client.py` - **认证****auth-hub SSO**OAuth2/OIDC`services/auth_hub_client.py`
本地邮箱密码注册/登录已移除;应用内 JWT 由后端签发。 本地邮箱密码注册/登录已移除;应用内 JWT 由后端签发。
- **AI**:自建 ai-gatewayOpenAI 兼容,`https://ai.zichuan.xyz/v1`)为唯一上游 - **AI**:自建 ai-gatewayOpenAI 兼容,`https://ai.zichuan.xyz/v1`)为唯一上游
@@ -57,7 +57,8 @@ GarminHealthLab/
│ ├── src/lib/ # day.ts本地日期/ metrics.ts指标注册表 │ ├── src/lib/ # day.ts本地日期/ metrics.ts指标注册表
│ ├── src/features.ts # 功能开关FEATURES.ai │ ├── src/features.ts # 功能开关FEATURES.ai
│ └── .env.production # REACT_APP_API_URL=/api同源防 Network Error │ └── .env.production # REACT_APP_API_URL=/api同源防 Network Error
├── deploy/ # NAS 部署脚本族S99garmin/start/stop/push/deploy ├── deploy/ # push_oracle.sh 现役;其余S99garmin/start/stop/
│ # push/deploy是 NAS 时代脚本,已废弃保留参考
├── docs/ # 架构 / 开发 / 需求文档 ├── docs/ # 架构 / 开发 / 需求文档
├── PROGRESS.md # 进度与部署事实(排查必读) ├── PROGRESS.md # 进度与部署事实(排查必读)
└── README.md # 项目说明 + API 文档 └── README.md # 项目说明 + API 文档
@@ -83,24 +84,42 @@ npm run build # client/react-scripts build → client/build/
cd backend && .venv/bin/python tests/smoke.py cd backend && .venv/bin/python tests/smoke.py
``` ```
## 部署(生产 = NAS端口 8124 ## 部署(生产 = 甲骨文云主机2026-09-12 起;此前是 NAS已下线
- **位置**NAS `192.168.50.64` `/volume1/web/garmin-health-lab`root 用户跑 - **位置**`129.146.26.249` `/opt/garmin-health-lab`**`ubuntu` 用户**
gunicorn `0.0.0.0:8124`2 workers / 4 threads / --timeout 300开机自启走 gunicorn `127.0.0.1:5500`2 workers / 4 threads / --timeout 300
DSM 任务调度器执行 `deploy/S99garmin.sh` systemd 单元 `garmin-health-lab.service``enabled`,随机器开机自启,
- **一键部署**:本地 `./deploy/push.sh`tar 经 ssh 同步 backend + deploy + `Restart=always`。和这台机器上其它服务ai-gateway / auth-hub / fam-edge
清空重推 client/build + sudo 重启,**校验 gunicorn pid 变化 + health 200** 同一套约定:`/opt/<项目>` 下独立部署 + 独立 venv + Caddy 按子域名反代
前置:先 `npm run build` - **公网**`https://garmin.zichuan.xyz` → Caddy → `127.0.0.1:5500`
- **公网**NAS frpc → 甲骨文 `http://129.146.26.249:8124`frp 重连需几秒) DNS腾讯云 DNSPodA 记录)与 TLSCaddy 自动签发)都已配好
- **DB**NAS MariaDB 10.11root 经 socket `/run/mysqld/mysqld10.sock`(或 ~~旧的 `http://129.146.26.249:8124`~~ 已下线(走 NAS frpc 转发,隧道已拆)。
TCP 127.0.0.1:3306`garmin_health_lab`。10.11 dump 含 `/*M!999999` - **一键部署**:本地 `./deploy/push_oracle.sh`tar 经 ssh key 认证同步
注释、旧客户端会报错,且须 `--default-character-set=utf8mb4` 防中文丢失。 backend + 清空重推 client/build + pip install + systemctl restart
- **auth-hub**client `996aLPw4T5gl-rYZ`;回调注册了 LAN **校验 main PID 变化 + health 200**)。前置:先 `npm run build`
`http://192.168.50.64:8124/auth/callback` 与公网 `deploy/push.sh` / `start.sh` / `stop.sh` / `S99garmin.sh` / `deploy.sh`
`http://129.146.26.249:8124/auth/callback` 两个地址 是 NAS 时代的脚本,已废弃保留仅供参考
- **DB**:甲骨文本机 MariaDB 10.3.39`127.0.0.1:3306`),独立账号
`garmin`**注意**`garmin@localhost``garmin@127.0.0.1` 是两个不同账号,
必须同密码建两份,否则 TCP 连接用的是另一个密码),库 `garmin_health_lab`
和其它项目(`chat_relay``zhongyuan`)共用同一个 MariaDB 实例,各自独立库
独立账号。**这台机器磁盘 96% 已满**,改动前留意剩余空间。
- **AI 网关**`AI_GATEWAY_BASE_URL` 现在是**本地回环** `http://127.0.0.1:5100/v1`
(不再经 Caddy/公网 —— 网关和这个服务同机了)。
- **auth-hub**client `996aLPw4T5gl-rYZ`;回调只保留
`https://garmin.zichuan.xyz/auth/callback` 一条NAS/旧公网端口那几条已用
`manage_clients remove-redirect-uri` 清掉)。改注册用
`/opt/auth-hub``PYTHONPATH=/opt/auth-hub/src venv/bin/python -m
auth_hub.manage_clients`
- **NAS 现状**`garmin_health_lab` 库已 `DROP DATABASE`(备份在本地
`~/Desktop/Work/backups/garmin_health_lab_nas_backup_20260912.sql.gz`
20 张表逐条精确计数核对过一致后才删的),`S99garmin.sh` 开机项已移除,
frpc 配置里 `garmin-health` 转发段已删(`gitea`/`wordpress`/`fam-core`/
`nexusai` 那几条没动——**Gitea 还在 NAS 上,这个仓库的 git remote 仍然指
向它**,这次迁移只搬了应用和数据,没搬源码托管)。
- **部署/排障前**:先读 `PROGRESS.md``deploy/` 脚本确认事实(曾经凭旧记忆 - **部署/排障前**:先读 `PROGRESS.md``deploy/` 脚本确认事实(曾经凭旧记忆
断言"无线上环境"而误判)。服务以 root 运行:重启用 sudo日志 断言"无线上环境"而误判,后来又把"生产在 NAS"当成默认事实——**两次都错在
`logs/error.log``logs/access.log` 是 root 所有 没有先查,部署位置是会变的**
## 关键约定与坑(写代码/改样式前看) ## 关键约定与坑(写代码/改样式前看)

View File

@@ -10,13 +10,55 @@
- [x] Gunicorn 生产服务器配置2 workers / 4 threads - [x] Gunicorn 生产服务器配置2 workers / 4 threads
- [x] 部署到本地 NAS`/volume1/web/garmin-health-lab`),端口 8124 - [x] ~~部署到本地 NAS`/volume1/web/garmin-health-lab`),端口 8124~~ 已下线,见下
- [x] frp 隧道配置,通过甲骨文公网 IP 外网访问(`http://129.146.26.249:8124` - [x] ~~frp 隧道配置,通过甲骨文公网 IP 外网访问(`http://129.146.26.249:8124`~~ 已拆除
- [x] 开机自启脚本(`deploy/S99garmin.sh` - [x] ~~开机自启脚本(`deploy/S99garmin.sh`~~ 已移除,脚本保留仅供参考
- [x] 甲骨文 iptables 放行 8124 端口 - [x] ~~甲骨文 iptables 放行 8124 端口~~ 该端口不再使用
### 迁移到甲骨文云主机2026-09-12
NAS 的局域网 IP 因 DHCP 重启换过两次(`.64``.65` → 又变回 `.64`),且磁盘/
网络都不如已经稳定跑着好几个服务的甲骨文机器可靠。整体搬迁NAS 只保留 Gitea
(这个仓库的源码托管,未动)。
- [x] 甲骨文本机新建 MariaDB 库 `garmin_health_lab` + 独立账号 `garmin`(同时建
`garmin@localhost``garmin@127.0.0.1` 两个 host 变体、同密码——MariaDB 视
为两个不同账号,只建一个会导致 TCP 连接失败)
- [x] NAS `mysqldump`10.11 源库,处理了 `/*M!999999` 注释兼容 10.3 目标)→
导入甲骨文 MariaDB 10.3.39**20 张表逐条精确 `COUNT(*)` 比对完全一致**才继续
- [x] 应用部署到 `/opt/garmin-health-lab`systemd 单元 `garmin-health-lab.service`
`ubuntu` 用户,非 root和 ai-gateway/auth-hub 同一套约定)
- [x] DNS腾讯云 DNSPod API 新增 `garmin.zichuan.xyz` A 记录Caddy 反代 +
自动签发 TLS实测已生效
- [x] auth-hub 回调地址改为 `https://garmin.zichuan.xyz/auth/callback`,清掉
NAS/旧公网端口那几条历史回调
- [x] AI 网关配置从公网域名改本地回环 `http://127.0.0.1:5100/v1`(网关和本服务
现在同机),**触发真实生成验证通过**`upstream: gemini`
- [x] 迁移窗口期加了安全阀:甲骨文这边先 `AUTO_SYNC=false`,避免和 NAS 的调度
器同时刷新 Garmin 令牌撞上按账号计算的 SSO 限流(见下方"同步逻辑修复"一节
09-03 那次事故)。确认 NAS 已冻结、数据无缺口后才在甲骨文打开
- [x] 冻结 NAS停 gunicorn→ 补一次终态 dump 核对无数据差异 → 移除开机自启
`DROP DATABASE garmin_health_lab`(备份在本地
`~/Desktop/Work/backups/garmin_health_lab_nas_backup_20260912.sql.gz`
- [x] NAS frpc 配置精确删除 `garmin-health` 转发段,`gitea`/`wordpress`/
`fam-core`/`nexusai` 几条未动,改后逐条复检确认其它站点未受影响
- [x] 新部署脚本 `deploy/push_oracle.sh`key 认证 + systemd校验 PID 变化 +
health 200落地当场跑通一次真实部署
- [x] `CLAUDE.md` 部署章节整体重写NAS 时代的 `deploy/` 脚本加了废弃说明保留
参考,未删除
### 认证 ### 认证

View File

@@ -20,9 +20,9 @@
### 后端 ### 后端
- Python 3.10+ + Flask应用工厂 - Python 3.10+ + Flask应用工厂
- Gunicorn生产运行NAS :8124 - Gunicorn生产运行甲骨文云主机 :5500Caddy 反代出 `garmin.zichuan.xyz`
- 可插拔数据层SQLite本地开发/ MariaDB生产NAS 10.11 - 可插拔数据层SQLite本地开发/ MariaDB生产与应用同机的 10.3.39
`garmin_health_lab`,经 PyMySQL/socket `garmin_health_lab`,经 PyMySQL/TCP
- JWT 鉴权;登录走 auth-hub 统一 SSOOAuth2/OIDC本地密码登录已移除 - JWT 鉴权;登录走 auth-hub 统一 SSOOAuth2/OIDC本地密码登录已移除
- garminconnectGarmin API 集成) - garminconnectGarmin API 集成)
@@ -93,8 +93,8 @@ CORS_ORIGIN=http://localhost:3000,http://localhost:5173
数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动** 数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动**
- **SQLite默认本地开发**:零配置,由 `DATABASE_PATH` 指定文件位置。 - **SQLite默认本地开发**:零配置,由 `DATABASE_PATH` 指定文件位置。
- **MariaDB生产**:运行于 NAS192.168.50.64)本地 MariaDB 10.11root 经 - **MariaDB生产**:运行于甲骨文云主机(`129.146.26.249`)本地 MariaDB
socket `/run/mysqld/mysqld10.sock`(或 TCP `127.0.0.1:3306`连接PyMySQL 10.3.39,独立账号 `garmin` TCP `127.0.0.1:3306` 连接PyMySQL
独立库 `garmin_health_lab`。完整配置见 `backend/.env.example` 独立库 `garmin_health_lab`。完整配置见 `backend/.env.example`
```env ```env
@@ -195,15 +195,17 @@ python tests/smoke.py
> 轮询,趋势归因与 Copilot 走显式触发;任一模型失败时降级为规则引擎, > 轮询,趋势归因与 Copilot 走显式触发;任一模型失败时降级为规则引擎,
> `meta.source` 会说明本次由谁作答。 > `meta.source` 会说明本次由谁作答。
## 🚀 部署(生产 = NAS ## 🚀 部署(生产 = 甲骨文云主机2026-09-12 起
- **位置**NAS `192.168.50.64` `/volume1/web/garmin-health-lab`root - **位置**`129.146.26.249` `/opt/garmin-health-lab``ubuntu` 用户
gunicorn `0.0.0.0:8124`2 workers / 4 threads / --timeout 300开机自启 gunicorn `127.0.0.1:5500`2 workers / 4 threads / --timeout 300
DSM 任务调度器执行 `deploy/S99garmin.sh` systemd 单元 `garmin-health-lab.service`
- **公网**NAS frpc → `http://129.146.26.249:8124` - **公网**`https://garmin.zichuan.xyz` → Caddy → `127.0.0.1:5500`
- **一键部署**:先 `npm run build`,再 `./deploy/push.sh`(同步 backend + - **一键部署**:先 `npm run build`,再 `./deploy/push_oracle.sh`ssh key 同步
deploy + 清空重推 `client/build` + sudo 重启 + pid/health 双校验)。 backend + 清空重推 `client/build` + pip install + systemctl restart +
- **部署排障前**:先读 `PROGRESS.md``deploy/` 脚本确认事实 PID/health 双校验)
- **部署排障前**:先读 `PROGRESS.md``deploy/` 脚本确认事实——部署位置换过
一次NAS → 甲骨文),凭记忆断言曾经出过错。
## 🔐 安全说明 ## 🔐 安全说明

View File

@@ -8,14 +8,15 @@ DB_TYPE=sqlite
# SQLite file (used when DB_TYPE=sqlite) # SQLite file (used when DB_TYPE=sqlite)
DATABASE_PATH=./data/health.db DATABASE_PATH=./data/health.db
# MariaDB (used when DB_TYPE=mariadb) — production DB on the NAS # MariaDB (used when DB_TYPE=mariadb) — production DB is co-located with the
# (192.168.50.64, MariaDB 10.11). Connection is over the socket # app on the Oracle box (129.146.26.249, MariaDB 10.3.39), TCP 127.0.0.1:3306,
# /run/mysqld/mysqld10.sock (or TCP 127.0.0.1:3306) as root; the socket path # dedicated account (not root). MariaDB treats `user@localhost` and
# only matters when TCP auth is disabled for the app user. # `user@127.0.0.1` as two different accounts — if you create this user by
# MARIADB_SOCKET=/run/mysqld/mysqld10.sock # hand, create both host variants with the same password, or TCP connections
# fail with a password that looks right but is not the one that account has.
# MARIADB_HOST=127.0.0.1 # MARIADB_HOST=127.0.0.1
# MARIADB_PORT=3306 # MARIADB_PORT=3306
# MARIADB_USER=root # MARIADB_USER=garmin
# MARIADB_PASSWORD=your_production_mariadb_password # MARIADB_PASSWORD=your_production_mariadb_password
# MARIADB_DATABASE=garmin_health_lab # MARIADB_DATABASE=garmin_health_lab
@@ -38,9 +39,10 @@ AUTH_HUB_CLIENT_ID=your_client_id
AUTH_HUB_CLIENT_SECRET=your_client_secret AUTH_HUB_CLIENT_SECRET=your_client_secret
# #
# Callback URL (must exactly match what's registered in auth-hub). Production # Callback URL (must exactly match what's registered in auth-hub). Production
# registers both the public frp address (129.146.26.249:8124) and the LAN # registers https://garmin.zichuan.xyz/auth/callback — add/remove redirect
# address (192.168.50.64:8124). # URIs on that client with /opt/auth-hub's manage_clients CLI, not by editing
AUTH_HUB_REDIRECT_URI=http://129.146.26.249:8124/auth/callback # auth-hub's own database directly.
AUTH_HUB_REDIRECT_URI=http://127.0.0.1:5500/auth/callback
# --- CORS (comma-separated allowed front-end origins) --- # --- CORS (comma-separated allowed front-end origins) ---
# localhost stays in the production list on purpose: CORS is not an auth # localhost stays in the production list on purpose: CORS is not an auth
@@ -57,9 +59,12 @@ CORS_ORIGIN=http://localhost:3000,http://localhost:5173
# absorbs single-vendor quota limits. Reached directly, bypassing any local # absorbs single-vendor quota limits. Reached directly, bypassing any local
# HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies # HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies
# can take 2-3 minutes, so set AI_TIMEOUT_SECONDS accordingly. # can take 2-3 minutes, so set AI_TIMEOUT_SECONDS accordingly.
# HTTPS (Caddy, strips the /ai prefix) rather than http://…:5100 — the token # Production (co-located with ai-gateway on the same Oracle box) uses the
# rides in an Authorization header and should not cross the internet in clear. # loopback address — skips Caddy and the public hop entirely, and the token
AI_GATEWAY_BASE_URL=https://ai.zichuan.xyz/v1 # never leaves localhost either way. Use the HTTPS domain instead only when
# this app runs somewhere else: the token rides in an Authorization header
# and must not cross the public internet in the clear.
AI_GATEWAY_BASE_URL=http://127.0.0.1:5100/v1
AI_GATEWAY_TOKEN= AI_GATEWAY_TOKEN=
AI_GATEWAY_MODEL=ai-gateway-auto AI_GATEWAY_MODEL=ai-gateway-auto

View File

@@ -42,13 +42,13 @@ JWT_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7)
# hardcoded in source; put it in backend/.env (gitignored) instead. # hardcoded in source; put it in backend/.env (gitignored) instead.
AUTH_HUB_BASE_URL = os.environ.get("AUTH_HUB_BASE_URL") or "http://129.146.26.249:5300" AUTH_HUB_BASE_URL = os.environ.get("AUTH_HUB_BASE_URL") or "http://129.146.26.249:5300"
# Dev fallback client id (auth-hub keeps dev and prod clients in separate # Dev fallback client id (auth-hub keeps dev and prod clients in separate
# databases). Production always overrides this via backend/.env — the value in # databases). Production always overrides this via backend/.env — the value
# use on the NAS is the registered client for the :8124 callbacks. # in use is the client registered for https://garmin.zichuan.xyz/auth/callback.
AUTH_HUB_CLIENT_ID = os.environ.get("AUTH_HUB_CLIENT_ID") or "0asGO0FdX_XYOk6O" AUTH_HUB_CLIENT_ID = os.environ.get("AUTH_HUB_CLIENT_ID") or "0asGO0FdX_XYOk6O"
AUTH_HUB_CLIENT_SECRET = os.environ.get("AUTH_HUB_CLIENT_SECRET") or "" AUTH_HUB_CLIENT_SECRET = os.environ.get("AUTH_HUB_CLIENT_SECRET") or ""
# Production callback goes through the NAS frp tunnel to the public address # Production sets this via backend/.env to https://garmin.zichuan.xyz/auth/callback
# (129.146.26.249:8124); the LAN callback 192.168.50.64:8124 is registered too. # — the fallback below is a dev-only placeholder, not a live production route.
AUTH_HUB_REDIRECT_URI = os.environ.get("AUTH_HUB_REDIRECT_URI") or "http://129.146.26.249:8124/auth/callback" AUTH_HUB_REDIRECT_URI = os.environ.get("AUTH_HUB_REDIRECT_URI") or "http://127.0.0.1:5500/auth/callback"
# --- Static UI -------------------------------------------------------------- # --- Static UI --------------------------------------------------------------
# Directory holding the built React app. When set and populated, the Flask # Directory holding the built React app. When set and populated, the Flask

View File

@@ -496,10 +496,22 @@ def delete_token(user_id):
"""Forget the stored Garmin OAuth token. """Forget the stored Garmin OAuth token.
The next sync or login will have to re-authenticate and mint a fresh token. The next sync or login will have to re-authenticate and mint a fresh token.
garmin_email on the user record is left in place so re-login only needs the garmin_email is meant to survive this — the UI's own "只需一次" promise is
password. The cached client — built from the old token's session — is dropped that a disconnect still leaves the address remembered — but for every
in the same step so a stale session can't keep being reused. auth-hub-era account (i.e. all of them now) that address lives *only* on
the row this deletes: `users.garmin_email` is a legacy column nothing
since auth-hub has ever written to (see `get_remembered_email`). Without
copying it forward first, "退出 Garmin 账号" quietly erases the one thing
it promised to keep, and the next bind attempt 400s on a blank email field
with no visible connection to the disconnect that caused it.
The cached client — built from the old token's session — is dropped in
the same step so a stale session can't keep being reused.
""" """
row = query_one("SELECT garmin_email FROM garmin_tokens WHERE user_id = ?", [user_id])
email = (row or {}).get("garmin_email")
if email:
execute("UPDATE users SET garmin_email = ? WHERE id = ?", [email, user_id])
execute("DELETE FROM garmin_tokens WHERE user_id = ?", [user_id]) execute("DELETE FROM garmin_tokens WHERE user_id = ?", [user_id])
forget_client(user_id) forget_client(user_id)

View File

@@ -338,16 +338,41 @@ class TestRememberedEmail:
) )
assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com" assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com"
def test_disconnecting_drops_the_current_binding_but_not_the_legacy_value( def test_disconnecting_remembers_the_most_recent_email_not_a_stale_legacy_one(
self, db, user self, db, user
): ):
"""The current binding's email is copied forward on disconnect (see
`delete_token`), so it wins over whatever older address happened to be
sitting in the legacy column — the account last used `current@…`, and
that is what the next bind attempt should be offered."""
db.execute( db.execute(
"UPDATE users SET garmin_email = ? WHERE id = ?", "UPDATE users SET garmin_email = ? WHERE id = ?",
["legacy@example.com", user["id"]], ["legacy@example.com", user["id"]],
) )
garmin_svc.save_token(user["id"], "tok", "current@example.com") garmin_svc.save_token(user["id"], "tok", "current@example.com")
garmin_svc.delete_token(user["id"]) garmin_svc.delete_token(user["id"])
assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com" assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com"
def test_disconnecting_an_auth_hub_account_still_remembers_the_email(
self, db, user
):
"""The case the test above does not cover, and the one that actually
broke: an auth-hub account has no pre-existing legacy row — the only
copy of the email is the one `delete_token` is about to remove. Without
copying it forward first, 退出 Garmin 账号 silently breaks its own
"只需一次" promise, and the next bind attempt 400s on a blank email
with no visible link back to the disconnect that caused it.
"""
assert garmin_svc.get_remembered_email(user["id"]) == ""
garmin_svc.save_token(user["id"], "tok", "current@example.com")
garmin_svc.delete_token(user["id"])
assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com"
def test_disconnecting_twice_does_not_forget_the_email(self, db, user):
garmin_svc.save_token(user["id"], "tok", "current@example.com")
garmin_svc.delete_token(user["id"])
garmin_svc.delete_token(user["id"]) # no token row left to read from
assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com"
class TestMfaHandling: class TestMfaHandling:

View File

@@ -54,6 +54,11 @@ function SyncPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
/* Which action 强制重试 should retry — the two flows share one error banner
but call different endpoints, and retrying the wrong one either does
nothing (sync with no token bound yet) or silently drops the email/
password the user just typed. */
const [retryAction, setRetryAction] = useState<'sync' | 'login' | null>(null);
/* Whether the last attempt was refused by the recorded cooldown. Gates the /* Whether the last attempt was refused by the recorded cooldown. Gates the
强制重试 button: offering it unconditionally would invite the very thing 强制重试 button: offering it unconditionally would invite the very thing
the cooldown prevents. */ the cooldown prevents. */
@@ -99,10 +104,14 @@ function SyncPage() {
}, []); }, []);
// --- Garmin login ------------------------------------------------------- // --- Garmin login -------------------------------------------------------
const startLogin = async (e: React.FormEvent) => { /* `force` is only ever true when the user clicks 强制重试 after a refusal —
e.preventDefault(); never the default path, because retrying inside Garmin's real cooldown
window is what extends it (see services/garmin.py's sso_cooldown). */
const startLogin = async (e?: React.FormEvent, force?: boolean) => {
e?.preventDefault();
setError(''); setError('');
setMessage(''); setMessage('');
setBlocked(false);
if (!email) { if (!email) {
setError('请输入 Garmin 邮箱'); setError('请输入 Garmin 邮箱');
return; return;
@@ -114,7 +123,7 @@ function SyncPage() {
setLoading(true); setLoading(true);
try { try {
const sid = await apiClient.startGarminLogin(password, email); const sid = await apiClient.startGarminLogin(password, email, force);
// The password is only ever needed for this one request. // The password is only ever needed for this one request.
setPassword(''); setPassword('');
setSession(sid); setSession(sid);
@@ -123,6 +132,10 @@ function SyncPage() {
beginPolling(sid); beginPolling(sid);
} catch (err: any) { } catch (err: any) {
setError(errorMessage(err, '登录失败')); setError(errorMessage(err, '登录失败'));
if (err?.response?.status === 429 && err?.response?.data?.status === 'rate_limited') {
setBlocked(true);
setRetryAction('login');
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -246,6 +259,7 @@ function SyncPage() {
setError(''); setError('');
setMessage(''); setMessage('');
setBlocked(false); setBlocked(false);
setRetryAction('sync');
setLoading(true); setLoading(true);
try { try {
// 0 means "everything" and -1 "since the last sync"; the backend caps // 0 means "everything" and -1 "since the last sync"; the backend caps
@@ -327,7 +341,9 @@ function SyncPage() {
{blocked && ( {blocked && (
<button <button
className="sync-force" className="sync-force"
onClick={() => syncHistory(true)} onClick={() => (
retryAction === 'login' ? startLogin(undefined, true) : syncHistory(true)
)}
disabled={loading} disabled={loading}
> >

View File

@@ -658,10 +658,13 @@ class ApiClient {
* Start an interactive Garmin login. Returns a session id; the login runs * Start an interactive Garmin login. Returns a session id; the login runs
* in the background and parks if Garmin asks for a two-factor code. * in the background and parks if Garmin asks for a two-factor code.
*/ */
async startGarminLogin(garminPassword: string, garminEmail?: string) { async startGarminLogin(
garminPassword: string, garminEmail?: string, force?: boolean
) {
const { data } = await this.client.post<{ session: string }>('/garmin/login', { const { data } = await this.client.post<{ session: string }>('/garmin/login', {
garminPassword, garminPassword,
...(garminEmail ? { garminEmail } : {}), ...(garminEmail ? { garminEmail } : {}),
...(force ? { force: true } : {}),
}); });
return data.session; return data.session;
} }

View File

@@ -1,4 +1,6 @@
#!/bin/sh #!/bin/sh
# RETIRED 2026-09-12: production moved off the NAS to the Oracle box
# (systemd unit garmin-health-lab.service there). Kept for reference only.
# Garmin Health Lab — DSM boot script. # Garmin Health Lab — DSM boot script.
# Mirrors the convention already used by S99frpc.sh on this NAS. # Mirrors the convention already used by S99frpc.sh on this NAS.
APP=/volume1/web/garmin-health-lab APP=/volume1/web/garmin-health-lab

View File

@@ -1,4 +1,6 @@
#!/bin/sh #!/bin/sh
# RETIRED 2026-09-12: production moved off the NAS to the Oracle box
# (systemd unit garmin-health-lab.service there). Kept for reference only.
# Deploy the latest local code to NAS. # Deploy the latest local code to NAS.
# Usage: sh deploy/deploy.sh # Usage: sh deploy/deploy.sh
# Prerequisites: client has been built (`npm run build` in client/) # Prerequisites: client has been built (`npm run build` in client/)

View File

@@ -1,4 +1,13 @@
#!/bin/sh #!/bin/sh
# RETIRED 2026-09-12: production moved off the NAS to the Oracle box — see
# deploy/push_oracle.sh. The NAS's garmin_health_lab database has been
# DROPPED and its S99garmin.sh boot entry removed; this script now has
# nothing live to deploy to. Kept for reference (the tar-over-ssh dance for
# a password-only host may be useful again) — do not run it expecting it to
# reach a running service.
#
# --- original header below ---
#
# Push this working tree to the NAS and restart it. # Push this working tree to the NAS and restart it.
# #
# The NAS only accepts password auth, so one ssh master connection is opened # The NAS only accepts password auth, so one ssh master connection is opened

65
deploy/push_oracle.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/sh
# Push this working tree to the Oracle box and restart it.
#
# Unlike deploy/push.sh (the retired NAS deployment), this target uses key
# auth and systemd — matching the convention every other service on that box
# already follows (ai-gateway, auth-hub, fam-edge, ...): code lives under
# /opt/<name>, gunicorn is a systemd unit, Caddy reverse-proxies a subdomain
# to a local port. Nothing here needs a password or sudo for the deploy
# itself; only the one-time systemd/Caddy setup did, and that is already done.
#
# ./deploy/push_oracle.sh [user@host] [ssh-key]
#
# Never touches backend/.env, .venv or the database on the far side.
set -e
HOST="${1:-ubuntu@129.146.26.249}"
KEY="${2:-$HOME/.ssh/oracle_new}"
APP=/opt/garmin-health-lab
REPO="$(cd "$(dirname "$0")/.." && pwd)"
sh_() { ssh -i "$KEY" -o BatchMode=yes "$HOST" "$@"; }
if [ ! -f "$REPO/client/build/index.html" ]; then
echo "client/build is missing — run 'npm run build' first" >&2
exit 1
fi
# macOS bsdtar writes com.apple.provenance xattrs and ._ resource forks that
# the Linux side cannot read; --no-xattrs plus COPYFILE_DISABLE strip them.
TAR="tar czf - --no-xattrs"
export COPYFILE_DISABLE=1
echo "==> backend"
$TAR --exclude .venv --exclude .env --exclude __pycache__ \
--exclude '*.db' --exclude tests --exclude .pytest_cache --exclude static \
-C "$REPO/backend" . | sh_ "tar xzf - -C '$APP/backend'"
echo "==> static (cleared first, so stale JS chunks do not pile up)"
sh_ "rm -rf '$APP/backend/static' && mkdir -p '$APP/backend/static'"
$TAR -C "$REPO/client/build" . | sh_ "tar xzf - -C '$APP/backend/static'"
echo "==> pip install (in case requirements.txt changed)"
sh_ "$APP/backend/.venv/bin/pip install -q -r $APP/backend/requirements.txt"
echo "==> restart"
BEFORE=$(sh_ "systemctl show garmin-health-lab -p MainPID --value" || true)
sh_ "sudo systemctl restart garmin-health-lab"
sleep 2
AFTER=$(sh_ "systemctl show garmin-health-lab -p MainPID --value" || true)
if [ -z "$AFTER" ] || [ "$AFTER" = "0" ]; then
echo "service failed to come back up — check journalctl -u garmin-health-lab" >&2
exit 1
fi
if [ "$BEFORE" = "$AFTER" ]; then
echo "the main PID did not change ($AFTER) — the old process may still be" >&2
echo "serving and your changes are NOT live." >&2
exit 1
fi
echo "==> restarted: $BEFORE -> $AFTER"
echo "==> health"
sh_ "curl -sf -m 5 -o /dev/null -w 'local: %{http_code}\n' http://127.0.0.1:5500/api/health/status" \
|| echo "local: unreachable"
curl -sf -m 8 -o /dev/null -w "public (https://garmin.zichuan.xyz): %{http_code}\n" \
https://garmin.zichuan.xyz/api/health/status || echo "public: unreachable"

View File

@@ -1,4 +1,6 @@
#!/bin/sh #!/bin/sh
# RETIRED 2026-09-12: production moved off the NAS to the Oracle box
# (systemd unit garmin-health-lab.service there). Kept for reference only.
# Start Garmin Health Lab. Safe to run repeatedly: an already-running # Start Garmin Health Lab. Safe to run repeatedly: an already-running
# instance is stopped first. Intended for DSM Task Scheduler (boot-up). # instance is stopped first. Intended for DSM Task Scheduler (boot-up).
APP="$(cd "$(dirname "$0")/.." && pwd)" APP="$(cd "$(dirname "$0")/.." && pwd)"

View File

@@ -1,4 +1,6 @@
#!/bin/sh #!/bin/sh
# RETIRED 2026-09-12: production moved off the NAS to the Oracle box
# (systemd unit garmin-health-lab.service there). Kept for reference only.
APP="$(cd "$(dirname "$0")/.." && pwd)" APP="$(cd "$(dirname "$0")/.." && pwd)"
GUNICORN="$APP/backend/.venv/bin/gunicorn" GUNICORN="$APP/backend/.venv/bin/gunicorn"

View File

@@ -1,6 +1,7 @@
# 项目架构说明 # 项目架构说明
> 现状2026-09-02后端为 **Python/Flask 单体**,生产部署在 **NAS :8124** > 现状2026-09-12后端为 **Python/Flask 单体**,生产部署在**甲骨文云主机**
> `garmin.zichuan.xyz`,此前是 NAS :8124已整体迁移
> 早期 Node.js/Express + `server/` 目录的后端已被整体重写为 > 早期 Node.js/Express + `server/` 目录的后端已被整体重写为
> `backend/`Python本文件描述的是当前实现。 > `backend/`Python本文件描述的是当前实现。
@@ -17,7 +18,7 @@
│ HTTP/JSON + JWTBearerCopilot 用 SSE │ HTTP/JSON + JWTBearerCopilot 用 SSE
┌──────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────┐
Flask 单体gunicorn 2w/4tNAS :8124 │ Flask 单体gunicorn 2w/4t甲骨文云主机 :5500
│ ┌────────────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────────────┐ │
│ │ 蓝图层 routes/ /api/auth /api/garmin /api/health │ │ │ │ 蓝图层 routes/ /api/auth /api/garmin /api/health │ │
│ │ /api/analysis /api/settings │ │ │ │ /api/analysis /api/settings │ │
@@ -142,16 +143,20 @@ Copilot: POST /analysis/copilot 走 SSE(stream_chat失败非流式回退)
- **Development 本地**`npm run dev` = Flask 5000`BACKEND_PORT` 固定,避开 - **Development 本地**`npm run dev` = Flask 5000`BACKEND_PORT` 固定,避开
`PORT` 被 CRA 抢占)+ CRA dev server 3000proxy → 5000。SQLite。 `PORT` 被 CRA 抢占)+ CRA dev server 3000proxy → 5000。SQLite。
- **Production NAS**`192.168.50.64` `/volume1/web/garmin-health-lab`root - **Production**(甲骨文云主机,`129.146.26.249``/opt/garmin-health-lab`
跑 gunicorn `0.0.0.0:8124`2w/4t--timeout 300 —— AI 生成可长达分钟级), `ubuntu` 用户跑 gunicorn `127.0.0.1:5500`2w/4t--timeout 300 —— AI 生成
开机自启 DSM 任务调度器 → `deploy/S99garmin.sh`MariaDB 10.11 可长达分钟级systemd 单元 `garmin-health-lab.service``enabled`
root@`/run/mysqld/mysqld10.sock`,库 `garmin_health_lab`。 `Restart=always`MariaDB 10.3.39 本机 TCP、独立账号 `garmin`,库
- **公网**NAS frpc → 甲骨文 `129.146.26.249:8124`frp 隧道,重连需数秒)。 `garmin_health_lab`。和这台机器上的 ai-gateway/auth-hub/fam-edge 同一套
- **一键部署**`deploy/push.sh`tar-over-ssh 同步 backend+deploy、清空重推 部署约定。此前2026-09-01~12部署在 NAS已整体迁移并下线。
`client/build`、sudo 重启、**pid 变化 + health 200 双校验**);前端先 - **公网**Caddy 反代 `https://garmin.zichuan.xyz` → `127.0.0.1:5500`
`npm run build`。`deploy/start.sh` / `stop.sh` 供手工与开机脚本调用 TLS 自动签发
- **auth-hub**:回调注册 LAN `192.168.50.64:8124` 与公网 `129.146.26.249:8124` - **一键部署**`deploy/push_oracle.sh`ssh key 认证同步 backend、清空重推
两个地址(client `996aLPw4T5gl-rYZ`CORS 白名单含两地址 + localhost。 `client/build`、pip install、systemctl restart、**PID 变化 + health 200
双校验**);前端先 `npm run build`。NAS 时代的 `deploy/push.sh` /
`start.sh` / `stop.sh` / `S99garmin.sh` 已废弃,保留仅供参考。
- **auth-hub**:回调只注册 `https://garmin.zichuan.xyz/auth/callback`
一条client `996aLPw4T5gl-rYZ`CORS 白名单含该地址 + localhost。
## 关键设计约束 ## 关键设计约束

View File

@@ -12,12 +12,12 @@
| `AUTH_HUB_CLIENT_SECRET` | 对应 secret**只放 .env不入库** | | `AUTH_HUB_CLIENT_SECRET` | 对应 secret**只放 .env不入库** |
| `AUTH_HUB_REDIRECT_URI` | 回调地址,必须与 auth-hub 侧注册值完全一致 | | `AUTH_HUB_REDIRECT_URI` | 回调地址,必须与 auth-hub 侧注册值完全一致 |
## 生产 clientNAS :8124 ## 生产 client甲骨文云主机,`garmin.zichuan.xyz`2026-09-12 前是 NAS :8124
- client_id`996aLPw4T5gl-rYZ` - client_id`996aLPw4T5gl-rYZ`
- 在 auth-hub 注册的回调: - 在 auth-hub 注册的回调:
- LAN`http://192.168.50.64:8124/auth/callback` - `https://garmin.zichuan.xyz/auth/callback`唯一注册的回调地址NAS 时代
- 公网frp`http://129.146.26.249:8124/auth/callback` 的 LAN / frp 地址已用 `manage_clients remove-redirect-uri` 清掉)
- 注意 auth-hub 的 dev 与 prod 是**两套独立数据库、各自注册 client** - 注意 auth-hub 的 dev 与 prod 是**两套独立数据库、各自注册 client**
在 dev auth-hub 上创建的 client 不能用于 prod反之亦然。 在 dev auth-hub 上创建的 client 不能用于 prod反之亦然。

View File

@@ -184,7 +184,8 @@ npm run build # 1. 先出前端产物
入库 + 「只读本地」保证、AI 缓存/队列互斥、调度器、auth-hub 客户端。 入库 + 「只读本地」保证、AI 缓存/队列互斥、调度器、auth-hub 客户端。
- 前端界面回归:本地起后端 + CRAChrome headless + CDP 截图比对 - 前端界面回归:本地起后端 + CRAChrome headless + CDP 截图比对
(参见 `.workbuddy/memory/` 日志里的做法)。 (参见 `.workbuddy/memory/` 日志里的做法)。
- 生产自测入口:NAS `curl http://127.0.0.1:8124/api/health/status`200 - 生产自测入口:甲骨文云主机 `curl http://127.0.0.1:5500/api/health/status`
200或公网 `curl https://garmin.zichuan.xyz/api/health/status`。
## 环境变量速查backend/.env ## 环境变量速查backend/.env
@@ -198,7 +199,7 @@ npm run build # 1. 先出前端产物
| `JWT_EXPIRY_DAYS` | 7 | token 有效期 | | `JWT_EXPIRY_DAYS` | 7 | token 有效期 |
| `AUTH_HUB_BASE_URL` | http://129.146.26.249:5300 | SSO 提供方 | | `AUTH_HUB_BASE_URL` | http://129.146.26.249:5300 | SSO 提供方 |
| `AUTH_HUB_CLIENT_ID` / `_SECRET` | — | SSO 客户端(生产 996a… | | `AUTH_HUB_CLIENT_ID` / `_SECRET` | — | SSO 客户端(生产 996a… |
| `AUTH_HUB_REDIRECT_URI` | http://129.146.26.249:8124/auth/callback | 回调地址 | | `AUTH_HUB_REDIRECT_URI` | https://garmin.zichuan.xyz/auth/callback | 回调地址 |
| `STATIC_DIR` | backend/static | 前端产物目录(生产 ./static | | `STATIC_DIR` | backend/static | 前端产物目录(生产 ./static |
| `AI_GATEWAY_BASE_URL` | https://ai.zichuan.xyz/v1 | OpenAI 兼容网关 | | `AI_GATEWAY_BASE_URL` | https://ai.zichuan.xyz/v1 | OpenAI 兼容网关 |
| `AI_GATEWAY_TOKEN` | — | 网关 Bearer token | | `AI_GATEWAY_TOKEN` | — | 网关 Bearer token |

View File

@@ -13,8 +13,8 @@
| # | 需求 | 理解 | 状态 | | # | 需求 | 理解 | 状态 |
|---|---|---|---| |---|---|---|---|
| 1.1 | 新建项目分析佳明海外账号健康数据 | Gitea 自建仓库Web 应用 | ✅ | | 1.1 | 新建项目分析佳明海外账号健康数据 | Gitea 自建仓库Web 应用 | ✅ |
| 1.2 | 部署到 NAS192.168.50.64,用 MariaDB | Flask + gunicorn(root, :8124) + NAS MariaDB 10.11(库 `garmin_health_lab`SQLite 供开发 | ✅ | | 1.2 | 部署到甲骨文云主机,用 MariaDB | Flask + gunicorn(ubuntu, systemd, :5500) + 本机 MariaDB 10.3.39(库 `garmin_health_lab`SQLite 供开发2026-09-12 从 NAS 迁移 | ✅ |
| 1.3 | 公网可访问 | NAS frpc 隧道 → 甲骨文公网 `129.146.26.249:8124` | ✅ | | 1.3 | 公网可访问 | Caddy 反代 `https://garmin.zichuan.xyz`(自动 TLS与本机 ai-gateway/auth-hub 同一套约定 | ✅ |
| 1.4 | 单元测试 / 界面自测 / 性能测试 | 446 项 pytest界面用浏览器实测接口逐个计时 | ✅ | | 1.4 | 单元测试 / 界面自测 / 性能测试 | 446 项 pytest界面用浏览器实测接口逐个计时 | ✅ |
| 1.5 | 一任务一 commit完成即推送 | 已成为固定流程 | ✅ | | 1.5 | 一任务一 commit完成即推送 | 已成为固定流程 | ✅ |
| 1.6 | auth-hub 统一登录SSO | OAuth2/OIDC回调注册 LAN + 公网两地址;本地邮箱密码登录已移除 | ✅ | | 1.6 | auth-hub 统一登录SSO | OAuth2/OIDC回调注册 LAN + 公网两地址;本地邮箱密码登录已移除 | ✅ |