Files
GarminHealthLab/backend/.env.example
ericwyuan 682936b0b6 fix(garmin): 刷新出来的令牌从来没写回库,于是每次连接都重换一次
「又被限流了」的根因找到了,不是请求量,是令牌。

`_connect` 里 `refresh_oauth2()` 换来的新 OAuth2 令牌只活在进程内存里——
`save_token` 只在绑定账号时调用过一次。于是每次客户端缓存过期(15 分钟)、
每个 gunicorn worker、每次部署重启,都从库里读回**同一个已过期的令牌**,
然后再做一次真实 SSO 换令牌。而 SSO 端点是按账号限流最狠的那个,社区报告能
封 48 小时(garth #217、python-garminconnect #337)。我今天为了部署重启了
八次服务,每次都清掉缓存。

- `refresh_oauth2()` 成功后 `_persist_token()` 写回。拆出这个函数是因为它和
  `save_token` 想要的正好相反:重新绑定要作废现有会话,持久化刷新结果必须
  保住刚刚产出它的那个会话
- 写回时不带 garmin_email,否则 upsert 会把绑定邮箱刷成 NULL,数据同步页会
  忘记绑的是哪个账号
- 刷新加进程内锁,并在拿到锁后重读一次库:另一个线程刚换过就直接用它的,
  不再自己去换一次
- 五条测试盯住这个不变量,包括「冷缓存不该再换一次」(这条如果回归,就是同一
  个 bug 再来一遍)

顺带把数据端点也节流了——那是另外一半问题,不是这次的病因,但一天历史要 9 次
调用,730 天全历史 6600 个请求全速打出去,不该指望佳明一直容忍:

- services/garmin_throttle.py:代理包住 client,所有调用(含以后新加的)都经
  同一个收口,按间隔排队并计数
- 0.5s 是查过的:garmin-data-export 默认 0.15s、garmin-connect-scraper 默认
  3s、官方合作方 API 100 次/分钟(0.6s)。依据写在文件顶部
- 单次同步 1200 个请求预算,跑满就干净收尾、下次接着跑(已存的天数本来就跳过)
- 运动详情每次最多补 40 条——新账号几百条,不限量就是一次性打光预算

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:24:48 +08:00

119 lines
5.3 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# --- Server ---
# BACKEND_PORT takes precedence over PORT. Prefer it: many tools inject PORT
# for the frontend, and Flask would otherwise take the React dev server's port.
BACKEND_PORT=5000
# --- Database: sqlite (default) or mariadb ---
DB_TYPE=sqlite
# SQLite file (used when DB_TYPE=sqlite)
DATABASE_PATH=./data/health.db
# MariaDB (used when DB_TYPE=mariadb) — production DB on the NAS
# (192.168.50.64, MariaDB 10.11). Connection is over the socket
# /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_PORT=3306
# MARIADB_USER=root
# MARIADB_PASSWORD=your_production_mariadb_password
# MARIADB_DATABASE=garmin_health_lab
# --- Auth ---
# CHANGE THIS in production! Used to sign JWTs (7-day expiry by default).
JWT_SECRET=dev_secret_change_me
JWT_EXPIRY_DAYS=7
# --- auth-hub OAuth2 provider (centralized SSO) ---
# See docs/AUTH_HUB_INTEGRATION.md for setup instructions.
#
# Base URL of the auth-hub service
AUTH_HUB_BASE_URL=http://129.146.26.249:5300
#
# OAuth2 client credentials (obtain from auth-hub.manage_clients create)
# NOTE: these must be registered against the auth-hub instance AUTH_HUB_BASE_URL
# actually points to (dev vs prod are separate databases with separate clients).
# Put the REAL values in backend/.env (gitignored) — never here.
AUTH_HUB_CLIENT_ID=your_client_id
AUTH_HUB_CLIENT_SECRET=your_client_secret
#
# 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
# 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) ---
# localhost stays in the production list on purpose: CORS is not an auth
# boundary — every data route requires a valid JWT — so allowing a developer's
# dev server costs nothing and saves toggling this on every session.
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
# --- AI models (text-only, large context) ---
# Put REAL keys in backend/.env — that file is gitignored. Never commit keys.
# Any model whose credentials are absent is skipped automatically.
# Self-hosted AI gateway (model id "gateway"). OpenAI-compatible; it fans out
# over nvidia/gemini/ollama itself and rotates several Gemini keys, so it
# absorbs single-vendor quota limits. Reached directly, bypassing any local
# HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies
# can take 2-3 minutes, so set AI_TIMEOUT_SECONDS accordingly.
# 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.
AI_GATEWAY_BASE_URL=https://ai.zichuan.xyz/v1
AI_GATEWAY_TOKEN=
AI_GATEWAY_MODEL=ai-gateway-auto
# Google AI Studio -> "gemini-flash". Free-tier quota is small; 429s are common.
GEMINI_API_KEY=
# NVIDIA NIM -> "llama-70b", "nemotron-49b", "mistral-large".
# Model ids come from that account's live GET /v1/models — do not guess them.
NVIDIA_API_KEY=
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# Preference order. The first configured model answers; if it fails or times
# out, the next is tried. Read per request, so changes need no restart.
AI_MODEL_CHAIN=gateway,gemini-flash,llama-70b
# Max days of history sent (CSV-encoded). Trimmed further per model so the
# payload always fits that model's own context window.
AI_DAY_BUDGET=365
# Measured against the gateway, not guessed: a trivial prompt took 138s end to
# end, because its primary upstream emits a full chain of thought before the
# answer. Nothing user-facing blocks on this (the briefing generates in a
# background thread), but the timeout still has to clear the real latency.
AI_TIMEOUT_SECONDS=300
# Output cap. Reasoning models spend part of it thinking before they answer;
# entries that need more declare their own budget in services/ai.py.
AI_MAX_TOKENS=1024
# Output cap for the AI coach (晨报 / 趋势归因 / Copilot). Larger than
# AI_MAX_TOKENS above: the same reasoning trace is spent from this budget
# before the answer starts, and at 1024 the reply was all thinking with the
# JSON truncated away.
AI_COACH_MAX_TOKENS=4000
# --- AI coach job queue ---
# Three projects share the gateway, so going too high causes 502s.
AI_JOB_CONCURRENCY=2
AI_JOB_GAP_SECONDS=5
# Set AI_JOBS=false to stop consuming entirely (screens then show the computed
# figures with no model reading).
AI_JOBS=true
# --- Garmin 请求节流 ---
# 一天的历史要 9 次 API 调用_extract_daily 6 + daily_extras 3短同步再加
# 5 次曲线,每条没有详情的运动 1 次。730 天全历史 ≈ 6600 个请求。以前是能发多
# 快发多快。
#
# 0.5 秒的依据2026-09-03 调研,见 services/garmin_throttle.py 顶部注释):
# sirredbeard/garmin-data-export 默认 0.15s、evg656e/garmin-connect-scraper
# 默认 3s、佳明官方合作方 API 是 100 次/分钟(合 0.6s)。
GARMIN_MIN_INTERVAL_SECONDS=0.5
# 单次同步的请求预算。跑满就干净收尾,下次接着跑(已存的天数会跳过)。
# 1200 × 0.5s ≈ 10 分钟,够覆盖四个月历史。
GARMIN_REQUEST_BUDGET=1200
# 每次同步补多少条运动详情。新账号有几百条,不限量就是一次性打光预算。
GARMIN_DETAILS_PER_SYNC=40