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:
ericwyuan
2026-09-02 19:34:32 +08:00
parent 68363957ec
commit 2d9a2be185
12 changed files with 609 additions and 496 deletions

View File

@@ -8,12 +8,14 @@ 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 Oracle server
# (129.146.26.249, local MariaDB 10.3). Dedicated account over TCP 127.0.0.1;
# MARIADB_SOCKET is only needed if TCP auth is disabled for the app user.
# 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=garmin
# MARIADB_USER=root
# MARIADB_PASSWORD=your_production_mariadb_password
# 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_SECRET=your_client_secret
#
# Callback URL (must exactly match what's registered in auth-hub)
AUTH_HUB_REDIRECT_URI=http://129.146.26.249:8123/auth/callback
# 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
@@ -55,7 +59,7 @@ CORS_ORIGIN=http://localhost:3000,http://localhost:5173
# 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://oracle.zichuan.xyz/ai/v1
AI_GATEWAY_BASE_URL=https://ai.zichuan.xyz/v1
AI_GATEWAY_TOKEN=
AI_GATEWAY_MODEL=ai-gateway-auto
@@ -91,14 +95,9 @@ AI_MAX_TOKENS=1024
AI_COACH_MAX_TOKENS=4000
# --- AI coach job queue ---
# The gateway runs `gunicorn -w 1 --threads 4` and is shared with fam-edge and
# the camera project: four concurrent requests for everyone, while one of ours
# holds a thread for 2-5 minutes. So this consumer runs one job at a time
# 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
# 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

View File

@@ -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
# 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"
# 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_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 --------------------------------------------------------------
# Directory holding the built React app. When set and populated, the Flask

View File

@@ -52,6 +52,19 @@ def _isolate_ai_env(monkeypatch):
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)
def _clear_garmin_client_cache():
"""Drop cached Garmin sessions between tests.

View File

@@ -1076,15 +1076,20 @@ class TestHighlightsReadAlone:
class TestGatewayCourtesy:
"""The gateway runs one worker with four threads and is shared with two
other projects. This consumer must not be able to saturate it."""
"""The gateway is shared with two other projects, so this consumer must
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):
jobs.enqueue(user["id"], "health", "a")
jobs.enqueue(user["id"], "sleep", "b")
assert jobs._claim_next() is not None
assert jobs._claim_next() is None, \
"a second Gunicorn worker must not start a second gateway call"
def test_concurrency_is_capped_at_MAX_CONCURRENT(self, db, user):
"""More than the cap may queue, but only MAX_CONCURRENT run at once."""
limit = jobs.MAX_CONCURRENT
for i in range(limit + 2):
jobs.enqueue(user["id"], "health", f"job-{i}")
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):
jobs.enqueue(user["id"], "health", "a")