docs: 文档/配置/测试同步 NAS :8124 生产现状,清除 Node.js 与甲骨文残留
背景:文档停在 Node.js 时代或甲骨文 8123 部署,与生产(NAS :8124 + Flask + auth-hub + ai-gateway)严重脱节,曾导致凭旧记忆误判'无线上环境'。 - CLAUDE.md 重写:技术栈/结构/命令/部署事实/关键坑(F7 button、UTC 日期、 429 退避以 DB 为准、迁移幂等、AI 生成耗时) - docs/ARCHITECTURE.md 重写为 Flask 蓝图+services+可插拔数据层 + NAS 部署 - docs/DEVELOPMENT.md 重写为 Flask/CRA 开发指南 + push.sh 部署流程 - docs/REQUIREMENTS.md:部署条目改 NAS 8124;补 auth-hub/AI 教练/新修复 - docs/AUTH_HUB_INTEGRATION.md 新增(补 .env.example 悬空引用) - README.md:技术栈/DB/auth-hub/API 清单/部署节修正 - backend/config.py 与 .env.example:AUTH_HUB_REDIRECT_URI 默认 8123→8124, MariaDB 注释 Oracle→NAS - tests:GatewayCourtesy 并发测试对齐 MAX_CONCURRENT(AI_JOB_CONCURRENCY=2); conftest 禁用 create_app 后台队列线程,修整库测试 flaky(585 passed)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,6 +28,9 @@ logs/
|
|||||||
# WorkBuddy tool state (not project code)
|
# WorkBuddy tool state (not project code)
|
||||||
.workbuddy/
|
.workbuddy/
|
||||||
|
|
||||||
|
# Local dev tooling config (not project code)
|
||||||
|
.claude/
|
||||||
|
|
||||||
# --- Python / Flask backend ---
|
# --- Python / Flask backend ---
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
192
CLAUDE.md
192
CLAUDE.md
@@ -1,88 +1,138 @@
|
|||||||
# Garmin Health Lab - 项目指南
|
# Garmin Health Lab — 项目指南
|
||||||
|
|
||||||
## 项目简介
|
佳明(Garmin)健康数据分析平台:同步 Garmin Connect 数据,做多维度健康分析、
|
||||||
|
可视化与 AI 解读(晨报 / 趋势归因 / Copilot)。
|
||||||
|
|
||||||
Garmin Health Lab 是一个完整的健康数据分析平台,用于:
|
> **阅读顺序**:先读本文件(协作约定与现状速览)→ [PROGRESS.md](PROGRESS.md)
|
||||||
- 获取和同步佳明(Garmin)设备数据
|
> (功能与部署进度,**排查前必读**)→ [README.md](README.md)(API 与使用)→
|
||||||
- 进行多维度的健康数据分析
|
> [docs/](docs/)(架构 / 开发 / 需求)。判断"部署在哪里、跑没跑、什么版本"时
|
||||||
- 提供个性化的健康建议
|
> 一律以 `PROGRESS.md` 和 `deploy/` 脚本为准,**不要凭记忆断言**。
|
||||||
- 可视化健康趋势
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈(现状,2026-09)
|
||||||
|
|
||||||
- **前端**: React 18 + TypeScript + Recharts
|
- **后端**:Python 3.10+ / Flask(应用工厂),Gunicorn 生产运行。原 Node/Express
|
||||||
- **后端**: Node.js + Express + TypeScript
|
后端早已重写为 Python,仓库中**不存在 `server/` 目录**。
|
||||||
- **数据库**: SQLite3
|
- **前端**:React 18 + TypeScript,用 **CRA(react-scripts)**构建,UI 组件库为
|
||||||
- **API 集成**: Garmin Connect API
|
**Framework7 9**(`framework7-react`,iOS 主题)+ Recharts 图表。根目录
|
||||||
|
`package.json` 是 npm workspaces(含 `client`)。
|
||||||
|
- **数据库**:可插拔数据层(`backend/db.py`),`DB_TYPE` 切换——
|
||||||
|
本地开发 SQLite(`backend/data/health.db`,默认);生产 NAS MariaDB
|
||||||
|
(`garmin_health_lab` 库,连接配置见 NAS 上 `backend/.env`)。
|
||||||
|
- **认证**:**auth-hub SSO**(OAuth2/OIDC,`services/auth_hub_client.py`),
|
||||||
|
本地邮箱密码注册/登录已移除;应用内 JWT 由后端签发。
|
||||||
|
- **AI**:自建 ai-gateway(OpenAI 兼容,`https://ai.zichuan.xyz/v1`)为唯一上游
|
||||||
|
(NVIDIA 推理模型,单次生成可达 2~5 分钟);生产 `AI_MODEL_CHAIN=gateway`。
|
||||||
|
|
||||||
## 快速开始
|
## 目录结构
|
||||||
|
|
||||||
### 安装
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
GarminHealthLab/
|
||||||
### 配置
|
├── backend/ # Flask 后端(当前唯一后端)
|
||||||
```bash
|
│ ├── app.py # 应用工厂:装配蓝图 + 静态 UI + scheduler/jobs
|
||||||
cp server/.env.example server/.env
|
│ ├── wsgi.py # Gunicorn 入口
|
||||||
# 编辑 server/.env 填入 Garmin 凭证
|
│ ├── config.py # 集中配置(读 backend/.env)
|
||||||
|
│ ├── auth.py # JWT 签发/校验 + require_auth
|
||||||
|
│ ├── db.py # 可插拔数据层(SQLite ↔ MariaDB)+ 幂等迁移
|
||||||
|
│ ├── routes/ # API 蓝图(blueprint)
|
||||||
|
│ │ ├── auth.py # /api/auth auth-hub SSO: callback / logout / refresh / auth-hub/start
|
||||||
|
│ │ ├── garmin.py # /api/garmin 绑定/同步/MFA/退出/活动详情/同步详情
|
||||||
|
│ │ ├── health.py # /api/health 摘要/睡眠/心率/运动/身体成分/血氧/设备/挑战等
|
||||||
|
│ │ ├── analysis.py # /api/analysis 趋势/建议/briefing/trend-insight/copilot/insight
|
||||||
|
│ │ └── settings.py # /api/settings 个人资料/单位/同步频率/评分依据
|
||||||
|
│ ├── services/ # 业务逻辑(纯函数/无 Flask 依赖,可单测)
|
||||||
|
│ │ ├── garmin.py # Garmin API 同步核心(限流退避 24h 写 DB)
|
||||||
|
│ │ ├── garmin_auth.py # Garmin OAuth 登录 + MFA 会话
|
||||||
|
│ │ ├── garmin_extras.py # 身体成分/血压/血氧/设备/徽章等扩展数据
|
||||||
|
│ │ ├── scheduler.py # 后台自动同步(DB 锁,多 worker 只跑一次)
|
||||||
|
│ │ ├── ai.py / coach.py / insights.py / jobs.py # AI 教练 + 生产者/消费者队列
|
||||||
|
│ │ ├── analysis.py / health.py / fitness_age.py / settings.py
|
||||||
|
│ │ └── auth_hub_client.py / scopes.py
|
||||||
|
│ ├── data/ # SQLite(本地开发,git 忽略)
|
||||||
|
│ ├── static/ # 前端构建产物(生产由 Flask 同端口提供)
|
||||||
|
│ ├── tests/ # pytest 测试(446+ 项)
|
||||||
|
│ └── .env.example # 环境变量样例(真实 .env 不提交)
|
||||||
|
├── client/ # React 前端(CRA + Framework7)
|
||||||
|
│ ├── src/pages/ # 今日/健康/趋势/运动/设置/每日/睡眠/指标详情等页面
|
||||||
|
│ ├── src/components/ # AI 晨报 / Copilot 浮窗 / 趋势归因 / Screen 骨架
|
||||||
|
│ ├── src/lib/ # day.ts(本地日期)/ metrics.ts(指标注册表)
|
||||||
|
│ ├── src/features.ts # 功能开关(FEATURES.ai)
|
||||||
|
│ └── .env.production # REACT_APP_API_URL=/api(同源,防 Network Error)
|
||||||
|
├── deploy/ # NAS 部署脚本族(S99garmin/start/stop/push/deploy)
|
||||||
|
├── docs/ # 架构 / 开发 / 需求文档
|
||||||
|
├── PROGRESS.md # 进度与部署事实(排查必读)
|
||||||
|
└── README.md # 项目说明 + API 文档
|
||||||
```
|
```
|
||||||
|
|
||||||
### 开发
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- 前端: http://localhost:3000
|
|
||||||
- 后端: http://localhost:5000
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
- `client/` - React 前端应用
|
|
||||||
- `server/` - Node.js 后端服务
|
|
||||||
- `docs/` - 文档
|
|
||||||
|
|
||||||
详见 [ARCHITECTURE.md](docs/ARCHITECTURE.md) 和 [DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
|
||||||
|
|
||||||
## 核心功能
|
|
||||||
|
|
||||||
1. **仪表板** - 健康数据概览和最近数据展示
|
|
||||||
2. **数据同步** - 从 Garmin 同步最新健康数据
|
|
||||||
3. **数据分析** - 趋势分析和数据可视化
|
|
||||||
4. **健康建议** - 基于数据的个性化建议
|
|
||||||
5. **设置** - 用户配置和偏好设置
|
|
||||||
|
|
||||||
## 关键特性
|
|
||||||
|
|
||||||
- ✅ Garmin API 集成
|
|
||||||
- ✅ 多维度数据分析(步数、心率、睡眠等)
|
|
||||||
- ✅ 实时数据同步
|
|
||||||
- ✅ 交互式数据可视化
|
|
||||||
- ✅ JWT 认证安全
|
|
||||||
- ✅ 本地数据存储
|
|
||||||
|
|
||||||
## 开发指南
|
|
||||||
|
|
||||||
- 详见 [DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
|
||||||
- API 文档见 README.md
|
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # 开发模式
|
# 后端依赖(首次):在 backend/ 下建 .venv 并装 requirements-dev.txt
|
||||||
npm run build # 构建项目
|
npm run setup:backend
|
||||||
npm run typecheck # 类型检查
|
|
||||||
npm start # 生产模式
|
# 同时起前后端开发(backend:5000 由 BACKEND_PORT 固定;client CRA 代理到 5000)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 单独起后端 / 前端 / 测试 / 类型检查 / 构建
|
||||||
|
npm run dev:backend
|
||||||
|
npm run dev:client
|
||||||
|
npm run test # backend/.venv/bin/python -m pytest(446+ 项)
|
||||||
|
npm run typecheck
|
||||||
|
npm run build # client/react-scripts build → client/build/
|
||||||
|
|
||||||
|
# 冒烟测试(后端单测入口)
|
||||||
|
cd backend && .venv/bin/python tests/smoke.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## 下一步任务
|
## 部署(生产 = NAS,端口 8124)
|
||||||
|
|
||||||
- [ ] 实现 Garmin OAuth 认证
|
- **位置**:NAS `192.168.50.64` `/volume1/web/garmin-health-lab`,root 用户跑
|
||||||
- [ ] 完成 Garmin API 数据获取
|
gunicorn `0.0.0.0:8124`(2 workers / 4 threads / --timeout 300),开机自启走
|
||||||
- [ ] 实现仪表板可视化
|
DSM 任务调度器执行 `deploy/S99garmin.sh`。
|
||||||
- [ ] 添加数据分析算法
|
- **一键部署**:本地 `./deploy/push.sh`(tar 经 ssh 同步 backend + deploy +
|
||||||
- [ ] 部署和优化
|
清空重推 client/build + sudo 重启,**校验 gunicorn pid 变化 + health 200**)。
|
||||||
|
前置:先 `npm run build`。
|
||||||
|
- **公网**:NAS frpc → 甲骨文 `http://129.146.26.249:8124`(frp 重连需几秒)。
|
||||||
|
- **DB**:NAS MariaDB 10.11,root 经 socket `/run/mysqld/mysqld10.sock`(或
|
||||||
|
TCP 127.0.0.1:3306),库 `garmin_health_lab`。10.11 dump 含 `/*M!999999`
|
||||||
|
注释、旧客户端会报错,且须 `--default-character-set=utf8mb4` 防中文丢失。
|
||||||
|
- **auth-hub**:client `996aLPw4T5gl-rYZ`;回调注册了 LAN
|
||||||
|
`http://192.168.50.64:8124/auth/callback` 与公网
|
||||||
|
`http://129.146.26.249:8124/auth/callback` 两个地址。
|
||||||
|
- **部署/排障前**:先读 `PROGRESS.md` 与 `deploy/` 脚本确认事实(曾经凭旧记忆
|
||||||
|
断言"无线上环境"而误判)。服务以 root 运行:重启用 sudo,日志
|
||||||
|
`logs/error.log`、`logs/access.log` 是 root 所有。
|
||||||
|
|
||||||
## 联系方式
|
## 关键约定与坑(写代码/改样式前看)
|
||||||
|
|
||||||
项目维护: ericwyuan.g@gmail.com
|
1. **日期一律本地日历日期**,别用 `toISOString()`(UTC,UTC+8 每天前 8 小时
|
||||||
|
查的是昨天)。前端统一走 `client/src/lib/day.ts`。
|
||||||
|
2. **阈值全部来自公开参考值,AI 只解读不定阈值**(设计原则,勿破)。
|
||||||
|
3. **Framework7 全局 `button { width: 100% }`** 会把任何 `<button>` 拉满父容器
|
||||||
|
——自定义组件里的按钮要显式 `width: auto`(Copilot 浮窗 ✕/发送按钮曾因此
|
||||||
|
错位,见 `Copilot.css` 注释)。F7 `.navbar .left/.right` 的 frosted pill 也
|
||||||
|
需在 `f7theme.css` 覆盖。
|
||||||
|
4. **同步限流**:Garmin 账号 429 退避 24h,`rate_limited_until` 以 **DB 为准**
|
||||||
|
(多 worker 内存不一致会卡死守卫);`_is_rate_limited` 沿异常 `__cause__`
|
||||||
|
链识别 RetryError 里的 429。
|
||||||
|
5. **AI 生成慢**:网关上游推理模型单次 2~5 分钟,晨报走后台生成 + 轮询,
|
||||||
|
接口超时/流式不可靠见 `services/ai.py` 的非流式回退;生产队列并发
|
||||||
|
`AI_JOB_CONCURRENCY=2`(共享网关,太高会把别家打成 502)。
|
||||||
|
6. **迁移幂等**:`db.py` 的 `init_db()` 会被多 worker 并发调用,所有
|
||||||
|
`ALTER/CREATE` 必须幂等(`CREATE TABLE IF NOT EXISTS` + 先查列再加列)。
|
||||||
|
7. **不要提交**:`.env`、构建产物 `client/build`、SQLite 数据、`.workbuddy/`、
|
||||||
|
`.claude/`(已在 .gitignore)。改代码后同步 `backend/static/` 用 deploy 流程。
|
||||||
|
8. **一任务一 commit**,完成即推送 NAS Gitea(origin 就是 Gitea)。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- pytest(446+ 项)覆盖:设置吸附/校验、身体年龄、运动详情列存解析、Garmin
|
||||||
|
同步入库与只读本地保证、AI 缓存/队列、调度器等。改后端先跑
|
||||||
|
`npm run test`(或 `cd backend && .venv/bin/python -m pytest`)。
|
||||||
|
- 前端无单测框架,用 Chrome headless + CDP 截图做界面回归(见每日日志)。
|
||||||
|
|
||||||
|
## 下一步 / 已知问题
|
||||||
|
|
||||||
|
- 看 `PROGRESS.md` 的待办区与 `docs/REQUIREMENTS.md`。
|
||||||
|
- 已知独立 bug:NAS `logs/stdout.log` 里 `refill_backlog ... SQL syntax near
|
||||||
|
'TEXT)) AND NOT EXISTS'` 反复刷——某条 AI 回填 SQL 与 NAS MariaDB 语法对
|
||||||
|
不上,尚未修复。
|
||||||
|
|||||||
24
PROGRESS.md
24
PROGRESS.md
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
- [x] 身体年龄计算(`services/fitness_age.py`)
|
- [x] 身体年龄计算(`services/fitness_age.py`)
|
||||||
|
|
||||||
- [x] 运动详情与全天曲线同步(`services/extras.py`)
|
- [x] 运动详情与全天曲线同步(`services/garmin_extras.py`)
|
||||||
|
|
||||||
- [x] 可插拔数据层(`db.py`,支持 SQLite ↔ MariaDB 切换)
|
- [x] 可插拔数据层(`db.py`,支持 SQLite ↔ MariaDB 切换)
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
- [x] 前端:今日页 AI 晨报卡片(后台生成 + 轮询升级)、全局 Copilot 浮窗、
|
- [x] 前端:今日页 AI 晨报卡片(后台生成 + 轮询升级)、全局 Copilot 浮窗、
|
||||||
指标详情页 AI 归因面板;`features.ts` 的 `ai` 开关已打开
|
指标详情页 AI 归因面板;`features.ts` 的 `ai` 开关已打开
|
||||||
|
|
||||||
- [x] 接入自建 ai-gateway(`https://oracle.zichuan.xyz/ai/v1`),实测走通
|
- [x] 接入自建 ai-gateway(`https://ai.zichuan.xyz/v1`),实测走通
|
||||||
|
|
||||||
- [x] 生产者/消费者队列(`services/jobs.py`):优先级队列 + 多 worker 互斥锁
|
- [x] 生产者/消费者队列(`services/jobs.py`):优先级队列 + 多 worker 互斥锁
|
||||||
|
|
||||||
@@ -122,6 +122,16 @@
|
|||||||
> 流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 `stream_chat()`
|
> 流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 `stream_chat()`
|
||||||
> 在流式无输出时会对同一模型退回非流式重试。
|
> 在流式无输出时会对同一模型退回非流式重试。
|
||||||
|
|
||||||
|
### Copilot 浮窗 UI 修复与文档同步(2026-09-02)
|
||||||
|
|
||||||
|
- [x] Copilot 浮窗 UI 修复:Framework7 全局 `button { width: 100% }` 把面板内
|
||||||
|
✕ / 发送按钮拉满父容器,挤坏 flex 布局(输入框缩到 21px、✕ 跑到面板中间)。
|
||||||
|
`Copilot.css` 显式 `width: auto` 覆盖,重构产物已部署到 NAS :8124 验证。
|
||||||
|
|
||||||
|
- [x] 文档与代码对齐生产现状:CLAUDE.md / README.md / docs/\* 全部更新为
|
||||||
|
Python/Flask + NAS :8124 + auth-hub + ai-gateway(清除 Node.js 时代与
|
||||||
|
甲骨文 8123 的过时描述);`config.py` / `.env.example` 默认值同步。
|
||||||
|
|
||||||
## 待办
|
## 待办
|
||||||
|
|
||||||
### 功能完善
|
### 功能完善
|
||||||
@@ -135,7 +145,9 @@
|
|||||||
- [ ] 多用户支持完善
|
- [ ] 多用户支持完善
|
||||||
|
|
||||||
### 运维
|
### 运维
|
||||||
|
|
||||||
- [x] `deploy.sh` 自动复制前端构建到 `backend/static/`,避免部署旧版
|
- [x] `deploy.sh` 自动复制前端构建到 `backend/static/`,避免部署旧版
|
||||||
|
|
||||||
- [ ] 监控与告警
|
- [ ] 监控与告警
|
||||||
|
|
||||||
- [ ] 日志轮转与清理
|
- [ ] 日志轮转与清理
|
||||||
@@ -146,9 +158,11 @@
|
|||||||
|
|
||||||
### 文档
|
### 文档
|
||||||
|
|
||||||
- [x] ARCHITECTURE.md 需更新(当前仍为 Node.js 架构描述)
|
- [x] ARCHITECTURE.md 已重写为 Python/Flask 架构 + NAS :8124 部署(2026-09-02)
|
||||||
|
|
||||||
- [x] DEVELOPMENT.md 需更新(当前仍为 Node.js 开发指南)
|
- [x] DEVELOPMENT.md 已重写为 Flask/CRA 开发指南(2026-09-02)
|
||||||
|
|
||||||
- [x] REQUIREMENTS.md 需更新
|
- [x] REQUIREMENTS.md 已更新部署位置与新增需求(2026-09-02)
|
||||||
|
|
||||||
|
- [x] CLAUDE.md / README.md 同步为最新技术栈与部署现状(2026-09-02)
|
||||||
|
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -13,20 +13,21 @@
|
|||||||
## 🛠 技术栈
|
## 🛠 技术栈
|
||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
- React 18 + TypeScript
|
- React 18 + TypeScript(CRA / react-scripts 构建)
|
||||||
|
- Framework7 9(`framework7-react`,iOS 风格 UI)
|
||||||
- Recharts (数据可视化)
|
- Recharts (数据可视化)
|
||||||
- Tailwind CSS (样式)
|
- Axios (API 请求,JWT 拦截器)
|
||||||
- Axios (API 请求)
|
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
- Python 3.10+ + Flask
|
- Python 3.10+ + Flask(应用工厂)
|
||||||
- Gunicorn(生产运行)
|
- Gunicorn(生产运行,NAS :8124)
|
||||||
- 可插拔数据层:SQLite(本地开发)/ MariaDB(生产,Oracle 云服务器本地 MariaDB 10.3,经 PyMySQL)
|
- 可插拔数据层:SQLite(本地开发)/ MariaDB(生产,NAS 10.11 库
|
||||||
- JWT 鉴权 + scrypt 密码哈希
|
`garmin_health_lab`,经 PyMySQL/socket)
|
||||||
- garminconnect(可选,Garmin API 集成)
|
- JWT 鉴权;登录走 auth-hub 统一 SSO(OAuth2/OIDC,本地密码登录已移除)
|
||||||
|
- garminconnect(Garmin API 集成)
|
||||||
|
|
||||||
> 注:原 Node/TypeScript 后端保留在 `server/`(仅 service 逻辑骨架);
|
> 早期 Node.js/TypeScript 后端(`server/`)已整体重写为 `backend/` 下的
|
||||||
> 当前可运行实现为 `backend/` 下的 Python/Flask 单体服务。
|
> Python/Flask 单体,目录已删除。
|
||||||
|
|
||||||
## 📁 项目结构
|
## 📁 项目结构
|
||||||
|
|
||||||
@@ -39,13 +40,13 @@ GarminHealthLab/
|
|||||||
│ ├── wsgi.py # Gunicorn 入口
|
│ ├── wsgi.py # Gunicorn 入口
|
||||||
│ ├── config.py # 配置(读 .env)
|
│ ├── config.py # 配置(读 .env)
|
||||||
│ ├── db.py # 可插拔数据层 (SQLite / MariaDB)
|
│ ├── db.py # 可插拔数据层 (SQLite / MariaDB)
|
||||||
│ ├── auth.py # scrypt + JWT + require_auth
|
│ ├── auth.py # JWT 签发/校验 + require_auth
|
||||||
│ ├── services/ # 业务逻辑 (health / analysis / garmin)
|
│ ├── services/ # 业务逻辑 (health / analysis / garmin)
|
||||||
│ ├── routes/ # 蓝图 (auth / garmin / health / analysis)
|
│ ├── routes/ # 蓝图 (auth / garmin / health / analysis)
|
||||||
│ ├── tests/smoke.py # 冒烟测试
|
│ ├── tests/smoke.py # 冒烟测试
|
||||||
│ ├── requirements.txt
|
│ ├── requirements.txt
|
||||||
│ └── .env.example
|
│ └── .env.example
|
||||||
├── server/ # 原 Node/TS 后端(仅 service 骨架,未接入路由)
|
├── server/ # (已删除)原 Node/TS 后端,已重写为 backend/
|
||||||
├── docs/ # 文档
|
├── docs/ # 文档
|
||||||
├── package.json # 工作空间根配置
|
├── package.json # 工作空间根配置
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -85,22 +86,23 @@ JWT_SECRET=your_jwt_secret_here # 生产务必更换
|
|||||||
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
|
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
> 原 `server/.env` 的 Node 配置已弃用,请改用 `backend/.env`。
|
> 早期 Node 后端的配置方式已弃用,一律使用 `backend/.env`。
|
||||||
|
|
||||||
### 数据库:SQLite / MariaDB 可插拔
|
### 数据库:SQLite / MariaDB 可插拔
|
||||||
|
|
||||||
数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动**:
|
数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动**:
|
||||||
|
|
||||||
- **SQLite(默认,本地开发)**:零配置,由 `DATABASE_PATH` 指定文件位置。
|
- **SQLite(默认,本地开发)**:零配置,由 `DATABASE_PATH` 指定文件位置。
|
||||||
- **MariaDB(生产)**:运行于 Oracle 云服务器(129.146.26.249)本地的 MariaDB 10.3,专用账号 `garmin` 经 `127.0.0.1:3306` 连接(PyMySQL)。
|
- **MariaDB(生产)**:运行于 NAS(192.168.50.64)本地 MariaDB 10.11,root 经
|
||||||
|
socket `/run/mysqld/mysqld10.sock`(或 TCP `127.0.0.1:3306`)连接(PyMySQL),
|
||||||
生产使用独立库 `garmin_health_lab`(与 `sentinel_home_ai` 隔离)。完整配置见 `backend/.env.example`:
|
独立库 `garmin_health_lab`。完整配置见 `backend/.env.example`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DB_TYPE=mariadb
|
DB_TYPE=mariadb
|
||||||
|
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||||
MARIADB_HOST=127.0.0.1
|
MARIADB_HOST=127.0.0.1
|
||||||
MARIADB_PORT=3306
|
MARIADB_PORT=3306
|
||||||
MARIADB_USER=garmin
|
MARIADB_USER=root
|
||||||
MARIADB_PASSWORD=your_production_mariadb_password
|
MARIADB_PASSWORD=your_production_mariadb_password
|
||||||
MARIADB_DATABASE=garmin_health_lab
|
MARIADB_DATABASE=garmin_health_lab
|
||||||
```
|
```
|
||||||
@@ -117,14 +119,18 @@ python app.py
|
|||||||
# 或生产方式:gunicorn wsgi:app -b 0.0.0.0:5000
|
# 或生产方式:gunicorn wsgi:app -b 0.0.0.0:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
前端(Vite/React,端口 3000 或 5173):
|
前端(React,CRA 开发服务器,proxy 到后端 5000):
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm install # 在仓库根目录执行(workspaces 装 client)
|
||||||
|
npm run dev # 同时起后端 5000 + 前端 3000(或分别 dev:backend / dev:client)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 前端: http://localhost:3000
|
- 前端: http://localhost:3000
|
||||||
- 后端 API: http://localhost:5000/api
|
- 后端 API: http://localhost:5000/api
|
||||||
|
|
||||||
|
> 登录走 auth-hub SSO:点登录会跳到统一认证页,回跳后后端签 JWT。
|
||||||
|
> 数据库首次启动自动建表(`db.init_db()`,幂等)。
|
||||||
|
|
||||||
### 冒烟测试
|
### 冒烟测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -134,15 +140,25 @@ python tests/smoke.py
|
|||||||
|
|
||||||
## 📚 API 文档
|
## 📚 API 文档
|
||||||
|
|
||||||
### 认证
|
### 认证(auth-hub 统一 SSO)
|
||||||
- `POST /api/auth/register` - 用户注册(email, garminEmail, garminPassword)
|
- `POST /api/auth/auth-hub/start` - 发起 SSO 登录(返回 auth-hub 授权 URL)
|
||||||
- `POST /api/auth/login` - 用户登录(email, password)
|
- `GET /api/auth/callback?code=…` - auth-hub 回调(浏览器跳转,签发 JWT)
|
||||||
- `POST /api/auth/logout` - 用户登出
|
|
||||||
- `POST /api/auth/refresh` - 刷新 Token
|
- `POST /api/auth/refresh` - 刷新 Token
|
||||||
|
- `POST /api/auth/logout` - 登出
|
||||||
|
|
||||||
|
> 本地邮箱/密码注册登录已移除;账号体系由 auth-hub 统一提供
|
||||||
|
> (见 [AUTH_HUB_INTEGRATION.md](docs/AUTH_HUB_INTEGRATION.md))。
|
||||||
|
> Garmin 账号的绑定/解绑走 `/api/garmin/*`(见下),与 Web 登录相互独立。
|
||||||
|
|
||||||
### Garmin 数据同步
|
### Garmin 数据同步
|
||||||
|
- `POST /api/garmin/login` - 发起 Garmin OAuth 授权(需验证码时 `POST /api/garmin/mfa` 提交)
|
||||||
|
- `GET /api/garmin/auth-status` / `GET /api/garmin/login-status` - 授权状态
|
||||||
|
- `DELETE /api/garmin/login` / `POST /api/garmin/disconnect` - 退出/解绑 Garmin 账号
|
||||||
- `POST /api/garmin/sync` - 同步 Garmin 数据
|
- `POST /api/garmin/sync` - 同步 Garmin 数据
|
||||||
- `GET /api/garmin/status` - 获取同步状态
|
- `POST /api/garmin/sync-latest` - 仅同步最新数据
|
||||||
|
- `POST /api/garmin/sync-details` - 补齐运动详情/全天曲线(后台队列)
|
||||||
|
- `GET /api/garmin/status` / `GET /api/garmin/auto-sync` - 同步状态
|
||||||
|
- `GET /api/garmin/activities/<activity_id>/detail` - 单次运动详情
|
||||||
|
|
||||||
### 健康数据
|
### 健康数据
|
||||||
- `GET /api/health/summary` - 获取健康摘要
|
- `GET /api/health/summary` - 获取健康摘要
|
||||||
@@ -150,6 +166,10 @@ python tests/smoke.py
|
|||||||
- `GET /api/health/heart-rate` - 获取心率数据
|
- `GET /api/health/heart-rate` - 获取心率数据
|
||||||
- `GET /api/health/sleep` - 获取睡眠数据
|
- `GET /api/health/sleep` - 获取睡眠数据
|
||||||
- `GET /api/health/activities` - 获取运动数据
|
- `GET /api/health/activities` - 获取运动数据
|
||||||
|
- `GET /api/health/fitness-age` - 身体年龄
|
||||||
|
- `GET /api/health/series` - 全天曲线(心率/压力/身体电量/呼吸/血氧)
|
||||||
|
- `GET /api/health/body-composition` / `blood-pressure` / `race-predictions`
|
||||||
|
- `GET /api/health/badges` / `personal-records` / `challenges` / `devices`
|
||||||
|
|
||||||
### 分析与建议
|
### 分析与建议
|
||||||
- `GET /api/analysis/trends` - 获取数据趋势
|
- `GET /api/analysis/trends` - 获取数据趋势
|
||||||
@@ -169,20 +189,31 @@ python tests/smoke.py
|
|||||||
- 请求体 `{question, history?, date?, model?}`
|
- 请求体 `{question, history?, date?, model?}`
|
||||||
- 事件序列 `start` → `delta`* → `done`,失败时为 `error`
|
- 事件序列 `start` → `delta`* → `done`,失败时为 `error`
|
||||||
|
|
||||||
> AI 相关接口全部经由自建 **ai-gateway**(OpenAI 兼容,见 `AI_GATEWAY_BASE_URL`)。
|
> AI 相关接口全部经由自建 **ai-gateway**(OpenAI 兼容,`https://ai.zichuan.xyz/v1`,
|
||||||
|
> 见 `AI_GATEWAY_BASE_URL`)。
|
||||||
> 该网关的主上游是大型推理模型,一次生成实测需 2~5 分钟,因此简报走后台生成 +
|
> 该网关的主上游是大型推理模型,一次生成实测需 2~5 分钟,因此简报走后台生成 +
|
||||||
> 轮询,趋势归因与 Copilot 走显式触发;任一模型失败时降级为规则引擎,
|
> 轮询,趋势归因与 Copilot 走显式触发;任一模型失败时降级为规则引擎,
|
||||||
> `meta.source` 会说明本次由谁作答。
|
> `meta.source` 会说明本次由谁作答。
|
||||||
|
|
||||||
|
## 🚀 部署(生产 = NAS)
|
||||||
|
|
||||||
|
- **位置**:NAS `192.168.50.64` `/volume1/web/garmin-health-lab`,root 跑
|
||||||
|
gunicorn `0.0.0.0:8124`(2 workers / 4 threads / --timeout 300),开机自启
|
||||||
|
DSM 任务调度器执行 `deploy/S99garmin.sh`。
|
||||||
|
- **公网**:NAS frpc → `http://129.146.26.249:8124`。
|
||||||
|
- **一键部署**:先 `npm run build`,再 `./deploy/push.sh`(同步 backend +
|
||||||
|
deploy + 清空重推 `client/build` + sudo 重启 + pid/health 双校验)。
|
||||||
|
- **部署排障前**:先读 `PROGRESS.md` 与 `deploy/` 脚本确认事实。
|
||||||
|
|
||||||
## 🔐 安全说明
|
## 🔐 安全说明
|
||||||
|
|
||||||
- Garmin 账户密码使用加密存储
|
- Garmin 账户密码不入库(令牌授权,约一年有效)
|
||||||
- 所有 API 请求需要 JWT 认证
|
- 所有 API 请求需要 JWT 认证(auth-hub SSO 签发)
|
||||||
- 敏感数据不在前端存储
|
- 敏感数据不在前端存储;`backend/.env` 含密钥,不提交 git
|
||||||
|
|
||||||
## 📝 开发指南
|
## 📝 开发指南
|
||||||
|
|
||||||
详见 [DEVELOPMENT.md](./docs/DEVELOPMENT.md)
|
详见 [DEVELOPMENT.md](./docs/DEVELOPMENT.md) 与 [ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
||||||
|
|
||||||
## 📄 许可证
|
## 📄 许可证
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ 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 Oracle server
|
# MariaDB (used when DB_TYPE=mariadb) — production DB on the NAS
|
||||||
# (129.146.26.249, local MariaDB 10.3). Dedicated account over TCP 127.0.0.1;
|
# (192.168.50.64, MariaDB 10.11). Connection is over the socket
|
||||||
# MARIADB_SOCKET is only needed if TCP auth is disabled for the app user.
|
# /run/mysqld/mysqld10.sock (or TCP 127.0.0.1:3306) as root; the socket path
|
||||||
|
# only matters when TCP auth is disabled for the app user.
|
||||||
|
# MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||||
# MARIADB_HOST=127.0.0.1
|
# MARIADB_HOST=127.0.0.1
|
||||||
# MARIADB_PORT=3306
|
# MARIADB_PORT=3306
|
||||||
# MARIADB_USER=garmin
|
# MARIADB_USER=root
|
||||||
# MARIADB_PASSWORD=your_production_mariadb_password
|
# MARIADB_PASSWORD=your_production_mariadb_password
|
||||||
# MARIADB_DATABASE=garmin_health_lab
|
# MARIADB_DATABASE=garmin_health_lab
|
||||||
|
|
||||||
@@ -35,8 +37,10 @@ AUTH_HUB_BASE_URL=http://129.146.26.249:5300
|
|||||||
AUTH_HUB_CLIENT_ID=your_client_id
|
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)
|
# Callback URL (must exactly match what's registered in auth-hub). Production
|
||||||
AUTH_HUB_REDIRECT_URI=http://129.146.26.249:8123/auth/callback
|
# registers both the public frp address (129.146.26.249:8124) and the LAN
|
||||||
|
# address (192.168.50.64:8124).
|
||||||
|
AUTH_HUB_REDIRECT_URI=http://129.146.26.249:8124/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
|
||||||
@@ -55,7 +59,7 @@ CORS_ORIGIN=http://localhost:3000,http://localhost:5173
|
|||||||
# 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
|
# HTTPS (Caddy, strips the /ai prefix) rather than http://…:5100 — the token
|
||||||
# rides in an Authorization header and should not cross the internet in clear.
|
# rides in an Authorization header and should not cross the internet in clear.
|
||||||
AI_GATEWAY_BASE_URL=https://oracle.zichuan.xyz/ai/v1
|
AI_GATEWAY_BASE_URL=https://ai.zichuan.xyz/v1
|
||||||
AI_GATEWAY_TOKEN=
|
AI_GATEWAY_TOKEN=
|
||||||
AI_GATEWAY_MODEL=ai-gateway-auto
|
AI_GATEWAY_MODEL=ai-gateway-auto
|
||||||
|
|
||||||
@@ -91,14 +95,9 @@ AI_MAX_TOKENS=1024
|
|||||||
AI_COACH_MAX_TOKENS=4000
|
AI_COACH_MAX_TOKENS=4000
|
||||||
|
|
||||||
# --- AI coach job queue ---
|
# --- AI coach job queue ---
|
||||||
# The gateway runs `gunicorn -w 1 --threads 4` and is shared with fam-edge and
|
# Three projects share the gateway, so going too high causes 502s.
|
||||||
# the camera project: four concurrent requests for everyone, while one of ours
|
AI_JOB_CONCURRENCY=2
|
||||||
# holds a thread for 2-5 minutes. So this consumer runs one job at a time
|
AI_JOB_GAP_SECONDS=5
|
||||||
# across the whole deployment (not one per Gunicorn worker) and waits between
|
|
||||||
# jobs. Raising either of these makes the backfill finish sooner at the cost of
|
|
||||||
# the shared box — a 502 there is a 502 for the other two projects as well.
|
|
||||||
AI_JOB_CONCURRENCY=1
|
|
||||||
AI_JOB_GAP_SECONDS=20
|
|
||||||
# Set AI_JOBS=false to stop consuming entirely (screens then show the computed
|
# Set AI_JOBS=false to stop consuming entirely (screens then show the computed
|
||||||
# figures with no model reading).
|
# figures with no model reading).
|
||||||
AI_JOBS=true
|
AI_JOBS=true
|
||||||
|
|||||||
@@ -41,9 +41,14 @@ JWT_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7)
|
|||||||
# fallback on purpose — unlike the issuer URL and client id, it must never be
|
# fallback on purpose — unlike the issuer URL and client id, it must never be
|
||||||
# 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
|
||||||
|
# databases). Production always overrides this via backend/.env — the value in
|
||||||
|
# use on the NAS is the registered client for the :8124 callbacks.
|
||||||
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 ""
|
||||||
AUTH_HUB_REDIRECT_URI = os.environ.get("AUTH_HUB_REDIRECT_URI") or "http://129.146.26.249:8123/auth/callback"
|
# Production callback goes through the NAS frp tunnel to the public address
|
||||||
|
# (129.146.26.249:8124); the LAN callback 192.168.50.64:8124 is registered too.
|
||||||
|
AUTH_HUB_REDIRECT_URI = os.environ.get("AUTH_HUB_REDIRECT_URI") or "http://129.146.26.249:8124/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
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ def _isolate_ai_env(monkeypatch):
|
|||||||
monkeypatch.delenv(var, raising=False)
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_ai_jobs_background_thread(monkeypatch):
|
||||||
|
"""create_app() starts a daemon queue-consumer thread that outlives the
|
||||||
|
test that launched it and races the *next* test for jobs on that test's
|
||||||
|
fresh database (with whatever _runner the previous stub left behind) —
|
||||||
|
which made queue tests flaky. Tests drive the queue themselves through
|
||||||
|
jobs.run_once(), so the thread is disabled here.
|
||||||
|
"""
|
||||||
|
from services import jobs
|
||||||
|
|
||||||
|
monkeypatch.setattr(jobs, "ENABLED", False)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_garmin_client_cache():
|
def _clear_garmin_client_cache():
|
||||||
"""Drop cached Garmin sessions between tests.
|
"""Drop cached Garmin sessions between tests.
|
||||||
|
|||||||
@@ -1076,15 +1076,20 @@ class TestHighlightsReadAlone:
|
|||||||
|
|
||||||
|
|
||||||
class TestGatewayCourtesy:
|
class TestGatewayCourtesy:
|
||||||
"""The gateway runs one worker with four threads and is shared with two
|
"""The gateway is shared with two other projects, so this consumer must
|
||||||
other projects. This consumer must not be able to saturate it."""
|
not saturate it. The cap is MAX_CONCURRENT (AI_JOB_CONCURRENCY; default 2
|
||||||
|
since 2026-09-01) and is counted across the whole deployment, not per
|
||||||
|
Gunicorn worker."""
|
||||||
|
|
||||||
def test_only_one_job_runs_at_a_time_across_the_deployment(self, db, user):
|
def test_concurrency_is_capped_at_MAX_CONCURRENT(self, db, user):
|
||||||
jobs.enqueue(user["id"], "health", "a")
|
"""More than the cap may queue, but only MAX_CONCURRENT run at once."""
|
||||||
jobs.enqueue(user["id"], "sleep", "b")
|
limit = jobs.MAX_CONCURRENT
|
||||||
assert jobs._claim_next() is not None
|
for i in range(limit + 2):
|
||||||
assert jobs._claim_next() is None, \
|
jobs.enqueue(user["id"], "health", f"job-{i}")
|
||||||
"a second Gunicorn worker must not start a second gateway call"
|
claimed = [jobs._claim_next() for _ in range(limit + 1)]
|
||||||
|
assert sum(1 for c in claimed if c is not None) == limit
|
||||||
|
assert claimed[limit] is None, \
|
||||||
|
"a second claim past MAX_CONCURRENT must not start another gateway call"
|
||||||
|
|
||||||
def test_a_finished_job_frees_the_slot(self, db, user):
|
def test_a_finished_job_frees_the_slot(self, db, user):
|
||||||
jobs.enqueue(user["id"], "health", "a")
|
jobs.enqueue(user["id"], "health", "a")
|
||||||
|
|||||||
@@ -1,167 +1,160 @@
|
|||||||
# 项目架构说明
|
# 项目架构说明
|
||||||
|
|
||||||
|
> 现状(2026-09-02):后端为 **Python/Flask 单体**,生产部署在 **NAS :8124**。
|
||||||
|
> 早期 Node.js/Express + `server/` 目录的后端已被整体重写为
|
||||||
|
> `backend/`(Python),本文件描述的是当前实现。
|
||||||
|
|
||||||
## 整体架构
|
## 整体架构
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ 浏览器 (React 前端) │
|
│ 浏览器 (React SPA) │
|
||||||
│ ┌────────────┬──────────────┬──────────┬──────────┐ │
|
│ Framework7 9 (iOS 主题) + Recharts,单端口同源部署 │
|
||||||
│ │ 仪表板 │ 数据同步 │ 数据分析 │ 设置 │ │
|
│ ┌──────┬──────┬──────┬──────┬──────────────┐ │
|
||||||
│ └────────────┴──────────────┴──────────┴──────────┘ │
|
│ │ 今日 │ 健康 │ 趋势 │ 运动 │ 设置 / 更多… │ │
|
||||||
└──────────────────────────┬──────────────────────────┘
|
│ └──────┴──────┴──────┴──────┴──────────────┘ │
|
||||||
│ HTTP/REST
|
└───────────────┬──────────────────────────────────────────┘
|
||||||
|
│ HTTP/JSON + JWT(Bearer);Copilot 用 SSE
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ Node.js/Express 后端服务器 │
|
│ Flask 单体(gunicorn 2w/4t,NAS :8124) │
|
||||||
│ ┌──────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
│ │ API 路由层 │ │
|
│ │ 蓝图层 routes/ /api/auth /api/garmin /api/health │ │
|
||||||
│ │ ├─ /auth - 用户认证 │ │
|
│ │ /api/analysis /api/settings │ │
|
||||||
│ │ ├─ /garmin - Garmin 数据同步 │ │
|
│ ├────────────────────────────────────────────────────┤ │
|
||||||
│ │ ├─ /health - 健康数据查询 │ │
|
│ │ 服务层 services/ 业务逻辑(无 Flask 依赖) │ │
|
||||||
│ │ └─ /analysis - 数据分析 │ │
|
│ │ auth_hub_client garmin garmin_auth garmin_extras │ │
|
||||||
│ └──────────────────────────────────────────────┘ │
|
│ │ scheduler(定时同步) ai/coach/insights/jobs(AI队列) │ │
|
||||||
│ ┌──────────────────────────────────────────────┐ │
|
│ │ health analysis fitness_age settings scopes │ │
|
||||||
│ │ 服务层 │ │
|
│ ├────────────────────────────────────────────────────┤ │
|
||||||
│ │ ├─ GarminService - Garmin API 集成 │ │
|
│ │ 横切:auth.py(JWT+require_auth) config.py(配置) │ │
|
||||||
│ │ ├─ HealthService - 健康数据业务逻辑 │ │
|
│ │ db.py(可插拔数据层+幂等迁移) │ │
|
||||||
│ │ ├─ AuthService - 认证授权 │ │
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
│ │ └─ AnalysisService - 数据分析 │ │
|
└───────┬──────────────────────────────┬──────────────────┘
|
||||||
│ └──────────────────────────────────────────────┘ │
|
│ │
|
||||||
└──────────────────────┬───────────────────────────────┘
|
▼ ▼
|
||||||
│
|
┌──────────────────┐ ┌─────────────────────────────┐
|
||||||
▼
|
│ SQLite (开发) │ │ MariaDB (生产, NAS 10.11) │
|
||||||
┌──────────────────────────────┐
|
│ data/health.db │ │ garmin_health_lab 库 │
|
||||||
│ SQLite 数据库 │
|
│ DB_TYPE=sqlite │ │ root@socket mysqld10.sock │
|
||||||
│ ├─ users │
|
└──────────────────┘ └─────────────────────────────┘
|
||||||
│ ├─ health_data │
|
▲ ▲
|
||||||
│ ├─ activities │
|
│ │
|
||||||
│ └─ sync_status │
|
│ 外部系统(经 services/ 调用)│
|
||||||
└──────────────────────────────┘
|
▼ ▼
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ auth-hub SSO Garmin Cloud API ai-gateway │
|
||||||
┌──────────────────────────────┐
|
│ 129.146.26.249: (OAuth, 账号级限流 (https://ai.zichuan │
|
||||||
│ Garmin Cloud API │
|
│ 5300 OIDC 429 → 退避24h) .xyz/v1, NVIDIA推理)│
|
||||||
│ ├─ 用户认证 │
|
└──────────────────────────────────────────────────────────┘
|
||||||
│ ├─ 数据获取 │
|
|
||||||
│ └─ 数据同步 │
|
|
||||||
└──────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心模块
|
- 单端口部署:Flask 既出 API 又托管 React 构建产物(`STATIC_DIR`),前端 API
|
||||||
|
请求为**同源 `/api/*`**(`client/.env.production` 写死 `REACT_APP_API_URL=/api`)。
|
||||||
|
- SPA 路由兜底:404 handler 对非 `/api/` 路径回退 `index.html`,页面刷新不 404。
|
||||||
|
|
||||||
### 1. 前端 (Client)
|
## 后端分层
|
||||||
- **框架**: React 18 + TypeScript
|
|
||||||
- **路由**: React Router
|
|
||||||
- **UI 组件**: 自定义 + CSS
|
|
||||||
- **数据可视化**: Recharts
|
|
||||||
- **API 通信**: Axios
|
|
||||||
|
|
||||||
**主要页面**:
|
### routes/ — 蓝图(只做参数校验与序列化,逻辑在 services)
|
||||||
- Dashboard (仪表板)
|
|
||||||
- DataSync (数据同步)
|
|
||||||
- Analysis (数据分析)
|
|
||||||
- Recommendations (健康建议)
|
|
||||||
- Settings (设置)
|
|
||||||
|
|
||||||
### 2. 后端 (Server)
|
| 蓝图 | 前缀 | 主要端点 |
|
||||||
- **框架**: Express.js
|
|---|---|---|
|
||||||
- **语言**: TypeScript
|
| `auth` | `/api/auth` | SSO 登录 `POST /auth-hub/start`、`GET /callback`;`POST /logout`、`/refresh` |
|
||||||
- **数据库**: SQLite3
|
| `garmin` | `/api/garmin` | `POST /sync`、`/sync-latest`、`/sync-details`;`GET /auth-status`、`/login-status`、`/status`、`/auto-sync`、`/activities/<id>/detail`;`POST /login`、`/mfa`、`/disconnect`;`DELETE /login` |
|
||||||
- **认证**: JWT
|
| `health` | `/api/health` | `summary` `steps` `heart-rate` `sleep` `activities` `fitness-age` `badges` `personal-records` `body-composition` `blood-pressure` `race-predictions` `series` `challenges` `devices` |
|
||||||
|
| `analysis` | `/api/analysis` | `trends` `recommendations` `models` `ai-recommendations` `briefing` `trend-insight` `copilot`(SSE) `insight` `insight/queue` |
|
||||||
|
| `settings` | `/api/settings` | `GET/PUT ""` `GET /options` `GET /rating-basis` |
|
||||||
|
|
||||||
**主要服务**:
|
另有 `GET /api/health/status`(健康检查,app.py 直接定义)。
|
||||||
- **AuthService**: 用户认证和授权
|
|
||||||
- **GarminService**: Garmin API 集成和数据获取
|
|
||||||
- **HealthService**: 健康数据管理
|
|
||||||
- **AnalysisService**: 数据分析和建议生成
|
|
||||||
|
|
||||||
### 3. 数据库 (SQLite)
|
### services/ — 领域逻辑
|
||||||
**表结构**:
|
|
||||||
- `users`: 用户信息和 Garmin 凭证
|
- **garmin 同步族**:`garmin.py`(核心同步 + 429 限流退避,`rate_limited_until`
|
||||||
- `health_data`: 每日健康数据汇总
|
持久化到 `sync_status`,以 DB 为准防多 worker 内存不一致)、`garmin_auth.py`
|
||||||
- `activities`: 运动活动记录
|
(OAuth 登录 + MFA 验证码会话,跨 worker 经 `garmin_mfa_sessions` 表交接)、
|
||||||
- `sync_status`: 数据同步状态追踪
|
`garmin_extras.py`(身体成分/血压/血氧/全天曲线/设备/徽章/挑战等扩展数据)。
|
||||||
|
- **调度与队列**:`scheduler.py`(自动同步,DB `job_locks` 抢占,多 worker 只
|
||||||
|
跑一次)、`jobs.py`(AI 生产者/消费者队列,同表互斥,含 `refill_backlog`)。
|
||||||
|
- **AI 教练**:`insights.py` 特征工程(z 分数 / 趋势斜率 / 活动量对比)→
|
||||||
|
`coach.py` 三套提示词 + 规则引擎兜底 → `ai.py` 多模型链 + 缓存
|
||||||
|
`ai_insights`(数据指纹失效)。
|
||||||
|
- 其余:`analysis.py` / `health.py` / `fitness_age.py`(身体年龄推算)/
|
||||||
|
`settings.py`(设置吸附校验)/ `auth_hub_client.py`(OIDC 客户端)/
|
||||||
|
`scopes.py`。
|
||||||
|
|
||||||
|
### 认证链路(auth-hub SSO)
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点「登录」→ POST /api/auth/auth-hub/start → 302 auth-hub 登录页
|
||||||
|
→ auth-hub 回调 GET /api/auth/callback?code=… → exchange_code_for_token
|
||||||
|
→ get_userinfo → find_or_create_user → 后端签 JWT(7 天)→ 前端存 localStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
- 本地邮箱/密码注册登录**已移除**;每个账号来自 auth-hub。
|
||||||
|
- **Garmin 账号授权是独立流程**(与 Web 登录分开):`/api/garmin/login`
|
||||||
|
起 OAuth 流程,需要验证码时 `/api/garmin/mfa` 提交,令牌存
|
||||||
|
`garmin_tokens`(约一年有效,密码不入库)。
|
||||||
|
- 所有数据路由经 `require_auth` 校验 JWT。
|
||||||
|
|
||||||
|
### 数据层(db.py 可插拔)
|
||||||
|
|
||||||
|
- `DB_TYPE=sqlite`(开发默认,`backend/data/health.db`)↔ `DB_TYPE=mariadb`
|
||||||
|
(生产 NAS)。`config.py` 读取,业务代码不分叉。
|
||||||
|
- 20 张表:`users health_data activities sync_status garmin_tokens badges
|
||||||
|
personal_records job_locks garmin_mfa_sessions user_settings
|
||||||
|
activity_details body_composition blood_pressure race_predictions
|
||||||
|
daily_series challenges devices ai_recommendations ai_jobs ai_insights`。
|
||||||
|
- `init_db()` 幂等(`CREATE TABLE IF NOT EXISTS` + 按列检测的 `ALTER`),
|
||||||
|
多 gunicorn worker 并发调用安全。`id` 为 VARCHAR(64),外键指向 `users`。
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|
||||||
### 1. Garmin 数据同步流程
|
### 1. Garmin 同步(手动 + 自动)
|
||||||
|
|
||||||
```
|
```
|
||||||
用户点击"同步"
|
用户/调度器触发
|
||||||
↓
|
→ 检查 sync_status.rate_limited_until(429 冷却 24h,DB 为准)
|
||||||
POST /api/garmin/sync
|
→ garmin.py 分批拉取(health_data 每日指标、activities、daily_series 等)
|
||||||
↓
|
→ 库内 upsert → 更新 sync_status → prefetch_insights(AI 洞察预取入队)
|
||||||
GarminService.syncData()
|
|
||||||
↓
|
|
||||||
获取 Garmin 授权 Token
|
|
||||||
↓
|
|
||||||
调用 Garmin API 获取数据
|
|
||||||
↓
|
|
||||||
转换数据格式
|
|
||||||
↓
|
|
||||||
存储到本地 SQLite
|
|
||||||
↓
|
|
||||||
更新 sync_status
|
|
||||||
↓
|
|
||||||
返回同步结果
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 数据分析流程
|
- 同步频率与历史范围(history_days)读用户设置;`0` 表示全部历史 = 730 天上限。
|
||||||
|
- 全天曲线每天 5 个请求:14 天内同步顺带拉取,更长的历史走「补齐详细数据」
|
||||||
|
后台任务(`POST /api/garmin/sync-details` 触发,队列消费)。
|
||||||
|
|
||||||
|
### 2. AI 晨报 / Copilot / 趋势归因
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/health/summary (日期范围)
|
AI 作业入队(ai_jobs,优先级队列) → jobs.py worker 单飞消费(DB 互斥)
|
||||||
↓
|
→ services/ai.py 调 ai-gateway(https://ai.zichuan.xyz/v1)
|
||||||
从数据库查询健康数据
|
→ 结果写 ai_insights(指纹缓存) → 前端轮询 GET /analysis/insight 取回
|
||||||
↓
|
Copilot: POST /analysis/copilot 走 SSE(stream_chat,失败非流式回退)
|
||||||
计算统计指标
|
|
||||||
↓
|
|
||||||
生成趋势分析
|
|
||||||
↓
|
|
||||||
返回分析结果
|
|
||||||
↓
|
|
||||||
前端绘制图表
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 健康建议生成流程
|
- 生产 `AI_MODEL_CHAIN=gateway`(单上游,网关内部再 fanout NVIDIA/Gemini/Ollama)。
|
||||||
```
|
- 队列并发 2、间隔 5s(共享网关,过高会 502);晨报一次生成 2~5 分钟,
|
||||||
GET /api/analysis/recommendations
|
前端一律「后台生成 + 轮询」而非阻塞等待;`meta.source` 标注 AI 或规则引擎。
|
||||||
↓
|
|
||||||
AnalysisService.generateRecommendations()
|
|
||||||
↓
|
|
||||||
分析历史数据
|
|
||||||
↓
|
|
||||||
识别异常和趋势
|
|
||||||
↓
|
|
||||||
根据规则引擎生成建议
|
|
||||||
↓
|
|
||||||
按优先级排序
|
|
||||||
↓
|
|
||||||
返回建议列表
|
|
||||||
```
|
|
||||||
|
|
||||||
## 关键特性
|
## 部署架构(生产)
|
||||||
|
|
||||||
### 安全性
|
- **Development 本地**:`npm run dev` = Flask 5000(`BACKEND_PORT` 固定,避开
|
||||||
- Garmin 密码使用加密存储
|
`PORT` 被 CRA 抢占)+ CRA dev server 3000(proxy → 5000)。SQLite。
|
||||||
- JWT 令牌验证所有请求
|
- **Production NAS**:`192.168.50.64` `/volume1/web/garmin-health-lab`,root
|
||||||
- CORS 配置限制来源
|
跑 gunicorn `0.0.0.0:8124`(2w/4t,--timeout 300 —— AI 生成可长达分钟级),
|
||||||
- 环境变量管理敏感配置
|
开机自启 DSM 任务调度器 → `deploy/S99garmin.sh`;MariaDB 10.11
|
||||||
|
root@`/run/mysqld/mysqld10.sock`,库 `garmin_health_lab`。
|
||||||
|
- **公网**:NAS frpc → 甲骨文 `129.146.26.249:8124`(frp 隧道,重连需数秒)。
|
||||||
|
- **一键部署**:`deploy/push.sh`(tar-over-ssh 同步 backend+deploy、清空重推
|
||||||
|
`client/build`、sudo 重启、**pid 变化 + health 200 双校验**);前端先
|
||||||
|
`npm run build`。`deploy/start.sh` / `stop.sh` 供手工与开机脚本调用。
|
||||||
|
- **auth-hub**:回调注册 LAN `192.168.50.64:8124` 与公网 `129.146.26.249:8124`
|
||||||
|
两个地址(client `996aLPw4T5gl-rYZ`);CORS 白名单含两地址 + localhost。
|
||||||
|
|
||||||
### 扩展性
|
## 关键设计约束
|
||||||
- 模块化的服务设计
|
|
||||||
- 易于添加新的分析算法
|
|
||||||
- 支持数据导出功能
|
|
||||||
- 可扩展的 API 端点
|
|
||||||
|
|
||||||
### 性能
|
1. **阈值来自公开参考值,AI 只解读不定阈值**;依据页面逐条列出处。
|
||||||
- 数据库索引优化查询
|
2. **本地日历日期**优先于 UTC(`toISOString()` 陷阱见 DEVELOPMENT.md)。
|
||||||
- API 缓存策略
|
3. **单点部署、无反向代理**:同端口 API + SPA,减少一个故障面。
|
||||||
- 异步处理长运行任务
|
4. **多 worker 安全**:一切跨进程协调走 DB(job_locks / garmin_mfa_sessions /
|
||||||
- 增量数据同步支持
|
ai_jobs / sync_status),不做进程内假设。
|
||||||
|
5. **可插拔数据层**:业务代码不分叉 SQLite/MariaDB。
|
||||||
## 部署架构
|
|
||||||
|
|
||||||
- **Development 本地开发**:`npm run dev` 同时启动前后端 —— Vite/React (3000/5173) + Flask (5000, SQLite)。
|
|
||||||
- **Production 生产环境**(Oracle 云服务器 129.146.26.249):
|
|
||||||
- Flask + gunicorn 直绑 `0.0.0.0:8123`(systemd `garmin-health-lab.service`,开机自启)
|
|
||||||
- React SPA 静态包由后端 `STATIC_DIR` 提供
|
|
||||||
- MariaDB 10.3(`127.0.0.1:3306`,专用账号 `garmin`,库 `garmin_health_lab`)
|
|
||||||
- AI 网关 `129.146.26.249:5100/v1`
|
|
||||||
|
|||||||
33
docs/AUTH_HUB_INTEGRATION.md
Normal file
33
docs/AUTH_HUB_INTEGRATION.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# auth-hub 集成说明
|
||||||
|
|
||||||
|
本应用不自己管账号——登录由 **auth-hub**(统一 SSO / OIDC 提供方)承接,
|
||||||
|
本仓库只做 OAuth2 client 侧的事。
|
||||||
|
|
||||||
|
## 依赖的配置(backend/.env)
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `AUTH_HUB_BASE_URL` | auth-hub 服务地址(生产 `http://129.146.26.249:5300`) |
|
||||||
|
| `AUTH_HUB_CLIENT_ID` | 本应用在 auth-hub 注册的 client |
|
||||||
|
| `AUTH_HUB_CLIENT_SECRET` | 对应 secret(**只放 .env,不入库**) |
|
||||||
|
| `AUTH_HUB_REDIRECT_URI` | 回调地址,必须与 auth-hub 侧注册值完全一致 |
|
||||||
|
|
||||||
|
## 生产 client(NAS :8124)
|
||||||
|
|
||||||
|
- client_id:`996aLPw4T5gl-rYZ`
|
||||||
|
- 在 auth-hub 注册的回调:
|
||||||
|
- LAN:`http://192.168.50.64:8124/auth/callback`
|
||||||
|
- 公网(frp):`http://129.146.26.249:8124/auth/callback`
|
||||||
|
- 注意 auth-hub 的 dev 与 prod 是**两套独立数据库、各自注册 client**:
|
||||||
|
在 dev auth-hub 上创建的 client 不能用于 prod,反之亦然。
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
1. 前端请求 `POST /api/auth/auth-hub/start` → 拿到 auth-hub 授权 URL,浏览器跳转;
|
||||||
|
2. auth-hub 登录后回调 `GET /api/auth/callback?code=…`;
|
||||||
|
3. 后端 `services/auth_hub_client.py`:`exchange_code_for_token`(换 token)→
|
||||||
|
`get_userinfo` → `find_or_create_user`(无则建号)→ 本应用签 JWT 返回前端。
|
||||||
|
|
||||||
|
本地邮箱/密码注册登录(曾经的 `/api/auth/register|login`)**已移除**。
|
||||||
|
Garmin 账号授权是另一条独立流程(见 routes/garmin.py 的 `/api/garmin/login`),
|
||||||
|
与 Web 登录无关。
|
||||||
@@ -1,94 +1,101 @@
|
|||||||
# 开发指南
|
# 开发指南
|
||||||
|
|
||||||
|
> 当前技术栈:**React 18 + TypeScript + Framework7 9(CRA)** 前端 +
|
||||||
|
> **Python 3.10+ / Flask** 后端 + SQLite(开发)/ MariaDB(生产)。
|
||||||
|
> 仓库根 `package.json` 是 npm workspaces(含 `client`),后端依赖装进
|
||||||
|
> `backend/.venv`。
|
||||||
|
|
||||||
## 环境设置
|
## 环境设置
|
||||||
|
|
||||||
### 前置要求
|
### 前置要求
|
||||||
- Node.js 18+
|
- Python 3.10+(本地用 3.13 亦可;NAS 是 3.10)
|
||||||
- npm 或 yarn
|
- Node.js 18+ / npm
|
||||||
- Git
|
- Git
|
||||||
- Garmin Connect 账户
|
- Garmin Connect 账户(用于数据同步联调)
|
||||||
|
- 联调 auth-hub 登录需能访问 auth-hub(内网 `http://129.146.26.249:5300`,
|
||||||
|
或配置你自己的 auth-hub)
|
||||||
|
|
||||||
### 安装步骤
|
### 安装步骤
|
||||||
|
|
||||||
1. **克隆项目**
|
```bash
|
||||||
```bash
|
cd ~/Desktop/Work/GarminHealthLab
|
||||||
cd ~/Desktop/Work
|
|
||||||
git clone <repository-url> GarminHealthLab
|
|
||||||
cd GarminHealthLab
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **安装依赖**
|
# 1) 后端虚拟环境 + 依赖(含 pytest 等 dev 依赖)
|
||||||
```bash
|
npm run setup:backend
|
||||||
npm install
|
# 等价于: cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
|
||||||
```
|
|
||||||
|
|
||||||
3. **配置环境变量**
|
# 2) 前端依赖(workspace)
|
||||||
```bash
|
npm install
|
||||||
# 复制示例文件
|
|
||||||
cp server/.env.example server/.env
|
|
||||||
|
|
||||||
# 编辑 server/.env,填入你的 Garmin 凭证
|
# 3) 配置环境变量
|
||||||
# GARMIN_CONNECT_USER=your_email@example.com
|
cp backend/.env.example backend/.env
|
||||||
# GARMIN_CONNECT_PASSWORD=your_password
|
# 编辑 backend/.env:至少设置 JWT_SECRET;AI_GATEWAY_* 见下;DB_TYPE 默认 sqlite
|
||||||
# JWT_SECRET=generate_a_random_string
|
```
|
||||||
```
|
|
||||||
|
|
||||||
4. **启动开发服务器**
|
### 启动开发服务器
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- 前端: http://localhost:3000
|
```bash
|
||||||
- 后端 API: http://localhost:5000/api
|
npm run dev # 同时起后端(5000) + 前端 CRA(3000, proxy→5000)
|
||||||
|
npm run dev:backend # 只起后端: backend/.venv/bin/python app.py(BACKEND_PORT=5000 固定)
|
||||||
|
npm run dev:client # 只起前端: react-scripts start
|
||||||
|
```
|
||||||
|
|
||||||
|
- 前端: http://localhost:3000(登录走 auth-hub SSO 跳转)
|
||||||
|
- 后端 API: http://localhost:5000/api
|
||||||
|
- `app.py` 顶部会 `db.init_db()` 自动建表;开发库是 `backend/data/health.db`。
|
||||||
|
|
||||||
|
> **为什么不用 `PORT`**:很多工具会给前端进程注入 `PORT`,若 Flask 直接读
|
||||||
|
> `PORT` 会抢走 CRA 的端口。后端一律用 `BACKEND_PORT`(见 `config.py`)。
|
||||||
|
|
||||||
|
### 连接 NAS 生产 MariaDB(可选)
|
||||||
|
|
||||||
|
本地开发默认 SQLite。要连 NAS 生产库联调,在 `backend/.env` 覆写:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_TYPE=mariadb
|
||||||
|
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||||
|
MARIADB_HOST=127.0.0.1
|
||||||
|
MARIADB_PORT=3306
|
||||||
|
MARIADB_USER=root
|
||||||
|
MARIADB_PASSWORD=<nas-mariadb-root密码>
|
||||||
|
MARIADB_DATABASE=garmin_health_lab
|
||||||
|
```
|
||||||
|
|
||||||
|
(本地 macOS 没有该 socket;通常经 ssh 端口转发或直接在 NAS 上跑联调。)
|
||||||
|
|
||||||
## 项目命令
|
## 项目命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 开发模式(同时运行前后端)
|
# 后端测试(pytest,446+ 项)
|
||||||
npm run dev
|
npm run test # = cd backend && .venv/bin/python -m pytest
|
||||||
|
cd backend && .venv/bin/python tests/smoke.py # 冒烟
|
||||||
|
|
||||||
# 仅运行后端服务器
|
# 前端
|
||||||
npm run dev:server
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run build # client 构建 → client/build/
|
||||||
# 仅运行前端应用
|
|
||||||
npm run dev:client
|
|
||||||
|
|
||||||
# 构建项目
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 生产模式启动
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# 类型检查
|
|
||||||
npm run typecheck
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 代码结构
|
## 代码结构
|
||||||
|
|
||||||
### 后端 (server/)
|
### 后端 (backend/)
|
||||||
|
|
||||||
```
|
```
|
||||||
server/
|
backend/
|
||||||
├── src/
|
├── app.py # create_app(): CORS、init_db、scheduler/jobs 启动、蓝图装配、SPA 静态托管
|
||||||
│ ├── index.ts # 入口文件
|
├── wsgi.py # gunicorn 入口(wsgi:app)
|
||||||
│ ├── routes/ # API 路由
|
├── config.py # 从 backend/.env 读配置(DB/AUTH/AI/端口/静态目录)
|
||||||
│ │ ├── auth.ts
|
├── auth.py # sign_token / decode_token / require_auth
|
||||||
│ │ ├── garmin.ts
|
├── db.py # 连接与执行、SCHEMA(20表)、幂等 init_db()
|
||||||
│ │ ├── health.ts
|
├── routes/ # 蓝图:auth garmin health analysis settings
|
||||||
│ │ └── analysis.ts
|
├── services/ # 业务逻辑:garmin garmin_auth garmin_extras scheduler
|
||||||
│ ├── services/ # 业务逻辑服务
|
│ # ai coach insights jobs analysis health
|
||||||
│ │ ├── AuthService.ts
|
│ # fitness_age settings auth_hub_client scopes
|
||||||
│ │ ├── GarminService.ts
|
├── tests/ # conftest.py + 各模块测试 + smoke.py
|
||||||
│ │ ├── HealthService.ts
|
├── data/ # SQLite(本地,git 忽略)
|
||||||
│ │ └── AnalysisService.ts
|
├── static/ # 前端构建产物(部署用;见「部署」)
|
||||||
│ ├── models/ # 数据模型
|
├── .env.example
|
||||||
│ ├── middleware/ # 中间件
|
├── requirements.txt # 运行依赖
|
||||||
│ ├── utils/ # 工具函数
|
└── requirements-dev.txt # 运行 + pytest
|
||||||
│ │ └── database.ts
|
|
||||||
│ └── types/ # TypeScript 类型定义
|
|
||||||
├── dist/ # 编译输出
|
|
||||||
├── tsconfig.json
|
|
||||||
└── package.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 前端 (client/)
|
### 前端 (client/)
|
||||||
@@ -96,183 +103,134 @@ server/
|
|||||||
```
|
```
|
||||||
client/
|
client/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.tsx # 入口文件
|
│ ├── index.tsx / App.tsx # 入口与路由框架(F7 App)
|
||||||
│ ├── App.tsx # 主组件
|
│ ├── pages/ # TodayPage HealthPage TrendsPage ExercisePage
|
||||||
│ ├── components/ # 可复用组件
|
│ │ # SettingsPage DailyPage SleepPage BodyPage RacePage
|
||||||
│ ├── pages/ # 页面组件
|
│ │ # ChallengesPage DevicesPage MetricDetailPage
|
||||||
│ │ ├── Dashboard.tsx
|
│ │ # ActivityDetailPage BodyAgePage RatingBasisPage
|
||||||
│ │ ├── DataSync.tsx
|
│ │ # AiQueuePage SyncPage Recommendations LoginPage
|
||||||
│ │ ├── Analysis.tsx
|
│ ├── components/ # AiBriefing AiPanel Copilot(浮窗) TrendInsight Screen
|
||||||
│ │ ├── Recommendations.tsx
|
│ │ # Skeleton + charts/
|
||||||
│ │ └── Settings.tsx
|
│ ├── lib/ # day.ts(本地日历日期) metrics.ts(指标注册表) …
|
||||||
│ ├── services/ # API 服务
|
│ ├── services/api.ts # axios 客户端 + JWT 拦截器
|
||||||
│ │ └── api.ts
|
│ ├── features.ts # FEATURES.ai 功能开关
|
||||||
│ ├── types/ # TypeScript 类型定义
|
│ ├── f7theme.css theme.css index.css
|
||||||
│ └── index.css # 全局样式
|
│ └── routes.ts types/
|
||||||
├── public/ # 静态资源
|
├── public/
|
||||||
└── package.json
|
└── .env.production # REACT_APP_API_URL=/api(同源;缺了会 Network Error)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发工作流
|
## 开发工作流
|
||||||
|
|
||||||
### 添加新的 API 端点
|
### 添加新的 API 端点
|
||||||
|
|
||||||
1. **创建路由处理器** (`server/src/routes/yourFeature.ts`)
|
1. **写服务函数**(`backend/services/your_feature.py`,纯逻辑,可单测)
|
||||||
```typescript
|
```python
|
||||||
import express from 'express';
|
def compute_something(user_id, payload):
|
||||||
|
...
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/endpoint', (req, res) => {
|
|
||||||
// 业务逻辑
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
```
|
```
|
||||||
|
2. **挂到蓝图**(`backend/routes/xxx.py`,用 `require_auth` 拿 `g.user_id`)
|
||||||
2. **在主文件中注册路由** (`server/src/index.ts`)
|
```python
|
||||||
```typescript
|
@bp.route("/something", methods=["POST"])
|
||||||
import yourFeatureRoutes from './routes/yourFeature';
|
@require_auth
|
||||||
app.use('/api/yourfeature', yourFeatureRoutes);
|
def something():
|
||||||
|
from services.your_feature import compute_something
|
||||||
|
return jsonify(compute_something(g.user_id, request.get_json() or {}))
|
||||||
```
|
```
|
||||||
|
3. **前端调用**(`client/src/services/api.ts` 加方法)
|
||||||
|
4. **加测试**(`backend/tests/test_your_feature.py`)
|
||||||
|
5. 跑 `npm run test` + `npm run typecheck`
|
||||||
|
|
||||||
3. **创建 API 客户端方法** (`client/src/services/api.ts`)
|
### 数据库变更(必须幂等)
|
||||||
```typescript
|
|
||||||
getYourEndpoint() {
|
|
||||||
return this.client.get('/yourfeature/endpoint');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 添加新页面
|
`db.py` 的 `SCHEMA` 是 `CREATE TABLE IF NOT EXISTS`;新增列走迁移列表
|
||||||
|
(`db.py` 底部 `_migrations` / 列检测逻辑),**先查 INFORMATION_SCHEMA 再
|
||||||
|
ALTER**,保证多 worker 并发 `init_db()` 不炸。改完对 SQLite 与 MariaDB 各跑
|
||||||
|
一遍测试(迁移两套方言都要过)。
|
||||||
|
|
||||||
1. **创建页面组件** (`client/src/pages/YourPage.tsx`)
|
### 前端构建与同源部署
|
||||||
```typescript
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
function YourPage() {
|
|
||||||
return <div>Page content</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default YourPage;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **在 App.tsx 中添加路由**
|
|
||||||
```typescript
|
|
||||||
<Route path="/your-path" element={<YourPage />} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据库操作
|
|
||||||
|
|
||||||
使用数据库工具函数简化操作:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { runAsync, getAsync, allAsync } from '../utils/database';
|
|
||||||
|
|
||||||
// 插入数据
|
|
||||||
await runAsync('INSERT INTO table (col1, col2) VALUES (?, ?)', [val1, val2]);
|
|
||||||
|
|
||||||
// 查询单行
|
|
||||||
const row = await getAsync('SELECT * FROM table WHERE id = ?', [id]);
|
|
||||||
|
|
||||||
// 查询多行
|
|
||||||
const rows = await allAsync('SELECT * FROM table WHERE status = ?', ['active']);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 调试
|
|
||||||
|
|
||||||
### 后端调试
|
|
||||||
```bash
|
```bash
|
||||||
# 启用详细日志
|
npm run build
|
||||||
DEBUG=* npm run dev:server
|
# 产物在 client/build/ —— 不要手动拷到 backend/static/,走 deploy/push.sh
|
||||||
|
|
||||||
# 使用 Node 调试器
|
|
||||||
node --inspect server/dist/index.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 前端调试
|
## 部署到 NAS(生产)
|
||||||
- 使用 React DevTools 浏览器扩展
|
|
||||||
- 使用浏览器开发者工具 (F12)
|
```bash
|
||||||
- 检查网络请求和响应
|
cd ~/Desktop/Work/GarminHealthLab
|
||||||
|
npm run build # 1. 先出前端产物
|
||||||
|
./deploy/push.sh # 2. 推送 + 重启(会问 ssh 与 sudo 密码,可设 NAS_PASSWORD)
|
||||||
|
```
|
||||||
|
|
||||||
|
`push.sh` 做了什么(见脚本注释,务必理解再执行):
|
||||||
|
1. ssh master 连接(ControlMaster,密码只输一次);
|
||||||
|
2. 定位远端 app 目录(`/volume1/web/garmin-health-lab`);
|
||||||
|
3. tar 同步 `backend/`(排除 .venv/.env/__pycache__/db/tests)+ `deploy/`;
|
||||||
|
4. **清空远端 static 并重推** `client/build`(防旧 chunk 堆积);
|
||||||
|
5. **sudo 重启**(服务以 root 跑,DSM 开机启动,日志 root 所有)——
|
||||||
|
`stop.sh` → 等端口释放 → `start.sh`;
|
||||||
|
6. 校验:health 200 + **gunicorn pid 必须变化**(pid 没变=旧进程还在服务,
|
||||||
|
改动未生效,脚本会报错退出)。
|
||||||
|
|
||||||
|
> 老脚本 `deploy/deploy.sh` 内置密码且不校验 pid,仅应急用。生产部署一律
|
||||||
|
> `push.sh`。公网生效比 NAS 本地晚几秒(frp 重连)。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
### 测试后端 API
|
- pytest:`npm run test`(446+ 项)。后端服务层为纯函数设计,测试不依赖 Flask
|
||||||
|
实例(`conftest.py` 建临时 SQLite)。
|
||||||
|
- 覆盖重点:设置吸附/校验、身体年龄方向性、运动详情列存解析、Garmin 同步
|
||||||
|
入库 + 「只读本地」保证、AI 缓存/队列互斥、调度器、auth-hub 客户端。
|
||||||
|
- 前端界面回归:本地起后端 + CRA,Chrome headless + CDP 截图比对
|
||||||
|
(参见 `.workbuddy/memory/` 日志里的做法)。
|
||||||
|
- 生产自测入口:NAS `curl http://127.0.0.1:8124/api/health/status`(200)。
|
||||||
|
|
||||||
使用 curl 或 Postman:
|
## 环境变量速查(backend/.env)
|
||||||
|
|
||||||
```bash
|
| 变量 | 默认 | 说明 |
|
||||||
# 获取健康摘要
|
|---|---|---|
|
||||||
curl http://localhost:5000/api/health/summary
|
| `BACKEND_PORT` | 5000 | 后端监听端口(优先于 `PORT`) |
|
||||||
|
| `DB_TYPE` | sqlite | `sqlite` / `mariadb` |
|
||||||
|
| `DATABASE_PATH` | ./data/health.db | SQLite 文件位置 |
|
||||||
|
| `MARIADB_*` | — | 生产 MariaDB(NAS 10.11,socket mysqld10.sock) |
|
||||||
|
| `JWT_SECRET` | dev_secret_change_me | **生产必换** |
|
||||||
|
| `JWT_EXPIRY_DAYS` | 7 | token 有效期 |
|
||||||
|
| `AUTH_HUB_BASE_URL` | http://129.146.26.249:5300 | SSO 提供方 |
|
||||||
|
| `AUTH_HUB_CLIENT_ID` / `_SECRET` | — | SSO 客户端(生产 996a…) |
|
||||||
|
| `AUTH_HUB_REDIRECT_URI` | http://129.146.26.249:8124/auth/callback | 回调地址 |
|
||||||
|
| `STATIC_DIR` | backend/static | 前端产物目录(生产 ./static) |
|
||||||
|
| `AI_GATEWAY_BASE_URL` | https://ai.zichuan.xyz/v1 | OpenAI 兼容网关 |
|
||||||
|
| `AI_GATEWAY_TOKEN` | — | 网关 Bearer token |
|
||||||
|
| `AI_MODEL_CHAIN` | gateway,gemini-flash,llama-70b | 模型回退链(生产仅 gateway) |
|
||||||
|
| `AI_TIMEOUT_SECONDS` | 300 | 网关超时(推理模型可达分钟级) |
|
||||||
|
| `AI_JOB_CONCURRENCY` / `AI_JOB_GAP_SECONDS` | 2 / 5 | AI 队列并发与间隔(共享网关勿调高) |
|
||||||
|
| `AI_JOBS` | true | false 则停消费(页面只显示计算值) |
|
||||||
|
|
||||||
# 触发 Garmin 同步
|
## 调试
|
||||||
curl -X POST http://localhost:5000/api/garmin/sync
|
|
||||||
|
|
||||||
# 获取健康建议
|
- 后端日志:开发直接看终端;NAS 看 `/volume1/web/garmin-health-lab/logs/`
|
||||||
curl http://localhost:5000/api/analysis/recommendations
|
(error.log / access.log / stdout.log,root 所有,用 sudo)。
|
||||||
```
|
- `refill_backlog` 1064 SQL 报错是**已知未修 bug**(AI 回填 SQL 与 MariaDB
|
||||||
|
语法不兼容),见 CLAUDE.md「已知问题」。
|
||||||
|
|
||||||
## 常见问题
|
### 常见问题
|
||||||
|
|
||||||
### 数据库连接失败
|
- **前端 Network Error(部署后)**:构建时没带 `.env.production` 的
|
||||||
```bash
|
`REACT_APP_API_URL=/api`,打进了 localhost:5000。检查 bundle 或重跑
|
||||||
# 检查数据库文件
|
`npm run build`。
|
||||||
ls -la ./data/health.db
|
- **凌晨打开是昨天**:用了 `toISOString()`(UTC)。改用 `lib/day.ts`。
|
||||||
|
- **按钮/输入框布局怪异**:Framework7 `button { width: 100% }`。自定义组件
|
||||||
|
按钮显式 `width: auto`(见 Copilot.css 修复注释)。
|
||||||
|
- **Garmin 同步 429**:账号级限流,退避 24h(DB 持久化)。密码/验证码正确
|
||||||
|
仍 429 就是撞限流,等冷却;`rate_limited_until` 以 DB 为准。
|
||||||
|
- **运动详情慢/超时**:会话按进程缓存(15 分钟 TTL);冷启约 16s。
|
||||||
|
- **测试连库**:测试用临时 SQLite,不要连生产 MariaDB。
|
||||||
|
|
||||||
# 重置数据库
|
## 代码规范与提交
|
||||||
rm ./data/health.db
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端无法连接后端
|
- TypeScript 严格模式;Python 遵循现有风格(函数式 services,docstring)。
|
||||||
- 检查 CORS 配置
|
- 提交信息约定(见 PROGRESS.md / REQUIREMENTS.md):`feat:` `fix:` `docs:`
|
||||||
- 确保后端运行在 5000 端口
|
`refactor:` `test:` 前缀,一任务一 commit,完成即 `git push`(origin 即 NAS
|
||||||
- 检查防火墙设置
|
Gitea `http://192.168.50.64:3000/ericwyuan/GarminHealthLab.git`)。
|
||||||
|
- **绝不提交**:`.env`、密钥、`client/build/`、`.venv`、`*.db`(.gitignore 已覆盖)。
|
||||||
### Garmin 认证失败
|
|
||||||
- 验证 Garmin 邮箱和密码
|
|
||||||
- 检查网络连接
|
|
||||||
- 查看后端日志
|
|
||||||
|
|
||||||
## 性能优化建议
|
|
||||||
|
|
||||||
1. **数据库索引**
|
|
||||||
- 在频繁查询的字段上添加索引
|
|
||||||
- 定期分析查询性能
|
|
||||||
|
|
||||||
2. **缓存策略**
|
|
||||||
- 实现 API 响应缓存
|
|
||||||
- 使用浏览器缓存
|
|
||||||
|
|
||||||
3. **代码分割**
|
|
||||||
- React 懒加载路由
|
|
||||||
- 按需加载 JavaScript
|
|
||||||
|
|
||||||
## 代码规范
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
- 使用严格模式
|
|
||||||
- 为所有函数参数添加类型
|
|
||||||
- 使用接口定义复杂对象
|
|
||||||
|
|
||||||
### CSS
|
|
||||||
- 使用 BEM 命名规范
|
|
||||||
- 响应式设计优先
|
|
||||||
- 避免内联样式
|
|
||||||
|
|
||||||
### 提交信息
|
|
||||||
```
|
|
||||||
feat: 添加新功能描述
|
|
||||||
fix: 修复 bug 描述
|
|
||||||
docs: 文档更新
|
|
||||||
style: 代码格式调整
|
|
||||||
refactor: 代码重构
|
|
||||||
test: 测试相关
|
|
||||||
```
|
|
||||||
|
|
||||||
## 资源链接
|
|
||||||
|
|
||||||
- [Express.js 文档](https://expressjs.com/)
|
|
||||||
- [React 文档](https://react.dev/)
|
|
||||||
- [TypeScript 文档](https://www.typescriptlang.org/)
|
|
||||||
- [SQLite 文档](https://www.sqlite.org/)
|
|
||||||
- [Garmin API](https://developer.garmin.com/)
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
状态:`✅ 已完成` · `🚧 进行中` · `📋 待做` · `⏸ 已搁置`
|
状态:`✅ 已完成` · `🚧 进行中` · `📋 待做` · `⏸ 已搁置`
|
||||||
|
|
||||||
最后更新:2026-08-25
|
最后更新:2026-09-02
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,10 +13,12 @@
|
|||||||
| # | 需求 | 理解 | 状态 |
|
| # | 需求 | 理解 | 状态 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1.1 | 新建项目分析佳明海外账号健康数据 | Gitea 自建仓库,Web 应用 | ✅ |
|
| 1.1 | 新建项目分析佳明海外账号健康数据 | Gitea 自建仓库,Web 应用 | ✅ |
|
||||||
| 1.2 | 部署到 Oracle 云服务器 (129.146.26.249),用 MariaDB | Flask + gunicorn + MariaDB(127.0.0.1:3306),SQLite 供开发 | ✅ |
|
| 1.2 | 部署到 NAS(192.168.50.64),用 MariaDB | Flask + gunicorn(root, :8124) + NAS MariaDB 10.11(库 `garmin_health_lab`),SQLite 供开发 | ✅ |
|
||||||
| 1.3 | 公网可访问 | gunicorn 直绑 Oracle:8123(frp 隧道已移除) | ✅ |
|
| 1.3 | 公网可访问 | NAS frpc 隧道 → 甲骨文公网 `129.146.26.249:8124` | ✅ |
|
||||||
| 1.4 | 单元测试 / 界面自测 / 性能测试 | 446 项 pytest;界面用浏览器实测;接口逐个计时 | ✅ |
|
| 1.4 | 单元测试 / 界面自测 / 性能测试 | 446 项 pytest;界面用浏览器实测;接口逐个计时 | ✅ |
|
||||||
| 1.5 | 一任务一 commit,完成即推送 | 已成为固定流程 | ✅ |
|
| 1.5 | 一任务一 commit,完成即推送 | 已成为固定流程 | ✅ |
|
||||||
|
| 1.6 | auth-hub 统一登录(SSO) | OAuth2/OIDC,回调注册 LAN + 公网两地址;本地邮箱密码登录已移除 | ✅ |
|
||||||
|
| 1.7 | 一键部署脚本 | `deploy/push.sh`:tar-over-ssh 同步 + 清空重推前端 + sudo 重启 + pid/health 双校验 | ✅ |
|
||||||
|
|
||||||
## 二、数据同步
|
## 二、数据同步
|
||||||
|
|
||||||
@@ -97,6 +99,10 @@
|
|||||||
| 6.1 | 多个大上下文文本模型,可切换 | gateway / gemini-flash / llama-70b / nemotron-49b / mistral-large | ✅ |
|
| 6.1 | 多个大上下文文本模型,可切换 | gateway / gemini-flash / llama-70b / nemotron-49b / mistral-large | ✅ |
|
||||||
| 6.2 | 暂时隐藏 AI 模块 | `FEATURES.ai = false` | ✅ |
|
| 6.2 | 暂时隐藏 AI 模块 | `FEATURES.ai = false` | ✅ |
|
||||||
| 6.3 | AI 用来解释,不用来定阈值 | 阈值全部来自公开参考值,AI 只负责解读 | ✅ 已确认为设计原则 |
|
| 6.3 | AI 用来解释,不用来定阈值 | 阈值全部来自公开参考值,AI 只负责解读 | ✅ 已确认为设计原则 |
|
||||||
|
| 6.4 | AI 教练:晨报 / 趋势归因 / Copilot(2026-09-01) | `services/insights` 特征工程 → `coach.py` 三套提示词 + 规则兜底 → ai-gateway;晨报后台生成 + 轮询,Copilot SSE | ✅ |
|
||||||
|
| 6.5 | AI 生成走后台队列,界面不阻塞 | `services/jobs.py` 生产者/消费者(DB 互斥);每日/运动详情持续排队不限量(`refill_backlog`) | ✅ |
|
||||||
|
| 6.6 | 队列状态可见、可重试 | 设置 → AI 生成队列页 + `/analysis/insight/queue/retry` | ✅ |
|
||||||
|
| 6.7 | AI 模型不可用时降级不报错 | 规则引擎兜底,`meta.source` 标注谁作答;流式失败自动退回非流式 | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -111,6 +117,9 @@
|
|||||||
| 心率区间百分比偏高(区间1 显示 90%,手表是 52%) | 分母用了「落在区间内的总时长」,应为整次运动时长 | 改用运动时长,低于区间1 的时间不再被挤掉 |
|
| 心率区间百分比偏高(区间1 显示 90%,手表是 52%) | 分母用了「落在区间内的总时长」,应为整次运动时长 | 改用运动时长,低于区间1 的时间不再被挤掉 |
|
||||||
| 打开运动详情超时 60s | 每个请求都重新认证 Garmin,`_connect` 单次约 11 秒 | 按进程缓存已认证会话(15 分钟 TTL),冷启 16s → 热 7s → 命中缓存 0.8s |
|
| 打开运动详情超时 60s | 每个请求都重新认证 Garmin,`_connect` 单次约 11 秒 | 按进程缓存已认证会话(15 分钟 TTL),冷启 16s → 热 7s → 命中缓存 0.8s |
|
||||||
| 主要收益显示 UNKNOWN | Garmin 用 UNKNOWN 表示「没有结论」 | 映射为中文,UNKNOWN 直接不显示该区块 |
|
| 主要收益显示 UNKNOWN | Garmin 用 UNKNOWN 表示「没有结论」 | 映射为中文,UNKNOWN 直接不显示该区块 |
|
||||||
|
| Copilot 浮窗布局错乱(✕ 错位、输入框缩成 21px) | Framework7 全局 `button { width: 100% }` 压过自定义类 | `.copilot-*` 按钮显式 `width: auto`(2026-09-02 已修并部署 NAS :8124) |
|
||||||
|
| Garmin 429 限流反复触发(同步死循环) | 退避太短 + 守卫被多 worker 内存卡死 + RetryError 里的 429 未识别 | 退避 24h + `rate_limited_until` 以 DB 为准 + 沿异常 `__cause__` 链识别 429 |
|
||||||
|
| 文档与实现脱节导致误判部署位置 | 文档停在 Node.js / 甲骨文 8123 时代 | docs/* 与 CLAUDE.md/README 已按 NAS :8124 + Flask 现状重写(2026-09-02) |
|
||||||
|
|
||||||
## 测试与性能(2026-08-24)
|
## 测试与性能(2026-08-24)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user