[阶段5] 部署到 NAS + frp 公网映射,并加注册锁

部署 (NAS 192.168.50.64):
- MariaDB 建库 garmin_health_lab,5 张表由 init_db 建好
- Python 3.8.15 venv;NAS 无 gcc,依赖全部走纯 Python 轮子
- gunicorn 2 worker × 4 线程,--timeout 300(AI 生成耗时可达数分钟)
- start.sh / stop.sh,可重复执行;日志落 logs/
- 在 NAS 真机 + 真实 MariaDB 上跑通全部测试:205 passed

app.py / config.py:
- STATIC_DIR 存在时由同一个 Flask 进程托管 React 构建产物,
  部署即单端口单进程,不需要额外反代
- 404 处理区分 /api 前缀:API 仍返回 JSON,其余回退到 index.html,
  这样 /settings 这类前端路由刷新后不会 404

安全 - 注册锁 (ALLOW_REGISTRATION):
- 服务要挂到公网,而原本 /register 完全开放,任何人都能注册进来
  读取健康数据
- 默认策略 auto:仅在尚无任何账号时开放,注册完第一个即自动关闭
- 另支持 true / false 显式覆盖;按请求读取,改配置无需重启
- 新增 GET /auth/registration-status,前端据此隐藏注册标签页

frp 公网映射:
- 复用 NAS 上已有的 frpc (/etc/frp/frpc.toml),追加 garmin 隧道
  NAS:8123 -> 甲骨文:8123(改前已按既有惯例备份 .bak.<时间戳>)
- 经 S99frpc.sh restart 生效,原有 4 条隧道均正常恢复

tests/test_registration_policy.py (13 通过):
- auto 策略下第一个账号放行、第二个 403 且不落库
- true/false 显式覆盖,大小写不敏感
- 策略按请求读取而非 import 时冻结
- 关闭注册不影响登录;status 端点无需鉴权

公网实测: 页面、SPA 路由、鉴权 401、注册锁 403 均符合预期。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 18:49:05 +08:00
parent acc6a2474b
commit 5f07dad019
7 changed files with 187 additions and 13 deletions

View File

@@ -4,11 +4,13 @@ Flask application factory for Garmin Health Lab.
Run directly (`python app.py`) for development, or serve with Gunicorn:
gunicorn wsgi:app -b 0.0.0.0:5000
"""
from flask import Flask, jsonify
import os
from flask import Flask, jsonify, send_from_directory
from flask_cors import CORS
import db
from config import CORS_ORIGINS, PORT
from config import CORS_ORIGINS, PORT, STATIC_DIR
from routes import auth, garmin, health, analysis
@@ -19,8 +21,16 @@ def create_app():
# Create tables once at startup (idempotent).
db.init_db()
# In production the built React app is served by this same process, so the
# deployment is a single port with no reverse proxy to configure. In
# development STATIC_DIR does not exist and the CRA dev server serves the
# UI instead — hence the guard rather than an unconditional route.
has_ui = bool(STATIC_DIR) and os.path.isfile(os.path.join(STATIC_DIR, "index.html"))
@app.route("/")
def index():
if has_ui:
return send_from_directory(STATIC_DIR, "index.html")
return jsonify({"name": "Garmin Health Lab API", "version": "1.0.0"})
@app.route("/api/health/status")
@@ -34,6 +44,16 @@ def create_app():
@app.errorhandler(404)
def not_found(_e):
# API paths always answer in JSON. Everything else falls through to the
# SPA so client-side routes (/settings, /recommendations, ...) survive a
# page reload instead of 404-ing.
from flask import request
if has_ui and not request.path.startswith("/api/"):
asset = request.path.lstrip("/")
if asset and os.path.isfile(os.path.join(STATIC_DIR, asset)):
return send_from_directory(STATIC_DIR, asset)
return send_from_directory(STATIC_DIR, "index.html")
return jsonify({"error": "not found"}), 404
@app.errorhandler(500)