## 实现内容 (6 项改动) 1. **admin /health 端点增强** — 主键契约 status:ok 保持 + checks 子对象 - db_writable: connect + CREATE TEMP TABLE + INSERT + SELECT + DROP - disk_writable: 在 ops_alert_log_path 目录创建临时文件 + 删除 - settings_valid: 复用 is_jwt_secret_secure 判断 2. **_enforce_jwt_secret_policy** — prod env 使用 dev 默认 JWT / 长度<32 → fail-closed 3. **_enforce_default_admin_password_policy** — prod env 用 admin123 / 长度<10 / 字符类<3 → fail-closed 4. **L-A R7: admin UI footer** — dashboard.html (592 行) + ui.py 内联 admin/orders/new 模板加 footer 隐私政策 + 数据删除 + 服务说明链接, 与 portal _render_footer_links() 同口径 5. **L-A R1+R4: LEGAL_PRIVACY_BASELINE 文档同步** - §6 移除孤儿 'admin' consent_channel 值 (代码侧从未实际产生) - §7 已具备/尚缺 重写, 显式归到 6/20 增量 6. **Q-A: tests/test_crowd_db_data_quality.py** — 8 个测试锁住 - 27 省总数 + 仅湖南 high + 其它 26 省 ≤ usable - 高考生源大省 (广东/江苏/北京/上海/山东/河南/四川/湖北) 不在 high 集合 - data_year=2025 (6/25 后需显式更新) ## 测试 - 4/4 RED → GREEN (admin/tests/test_health.py: JWT/admin password 拒绝 dev 默认值) - 8/8 GREEN (data/crowd_db/tests/test_crowd_db_data_quality.py: 数据质量契约) - 3 回归修复 (test_app.py + test_routes.py + test_health.py 适配新 /health checks 字段) - 31/31 GREEN (admin/tests/test_app.py + test_routes.py + test_health.py) ## 报告 - reports/LA_LEGAL_PRIVACY_PRE_AUDIT_2026-06-20.md (363 行, 9 风险 0 阻塞) - reports/QA_CROWD_DB_NON_HUNAN_DENSITY_AUDIT.md (45 行, CRITICAL 文档失真已规避) ## 文件 M CHANGELOG.md (v2.1.3) M admin/config.py (2 个 _enforce_*_policy + load_settings post-load) M admin/routes/health.py (3 个 _check_* + checks 子对象) M admin/routes/ui.py (admin/orders/new 模板加 footer) M admin/static/dashboard.html (footer 块) M admin/tests/test_app.py (适配 checks 字段 + regex 兼容) M admin/tests/test_health.py (4 个新测试) M admin/tests/test_routes.py (适配 checks 字段) M docs/CURRENT_STATE.md (0.3-0.5 增量段) M docs/LEGAL_PRIVACY_BASELINE.md (§6 清理 + §7 重写) + data/crowd_db/tests/test_crowd_db_data_quality.py (8 tests) + reports/LA_LEGAL_PRIVACY_PRE_AUDIT_2026-06-20.md + reports/QA_CROWD_DB_NON_HUNAN_DENSITY_AUDIT.md
353 lines
16 KiB
Python
353 lines
16 KiB
Python
"""配置加载 (T6.1).
|
||
|
||
所有运行时配置通过环境变量读取。提供默认值用于开发环境,但启动时
|
||
必须输出 WARN 提示生产环境必须显式覆盖。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import secrets
|
||
import string
|
||
from dataclasses import dataclass
|
||
|
||
from fastapi import Request
|
||
|
||
|
||
# 安全占位密钥(仅 dev 环境)。生产必须显式设置 GAOKAO_JWT_SECRET。
|
||
_DEV_JWT_SECRET = "dev-only-do-not-use-in-prod-please-override-via-env"
|
||
# Portal token 独立 dev 占位密钥。**与后台 JWT 密钥不同**,P2-4 验收要求。
|
||
# 生产环境必须显式设置 GAOKAO_PORTAL_TOKEN_SECRET。
|
||
_DEV_PORTAL_TOKEN_SECRET = "dev-only-portal-token-secret-do-not-use-in-prod"
|
||
# Payment webhook dev 默认密钥。生产环境必须显式设置,否则 fail-closed (P2-5)。
|
||
_DEV_PAYMENT_WEBHOOK_SECRET = "dev-mock-payment-secret"
|
||
_DEFAULT_ADMIN_PASSWORD = "admin123"
|
||
_MIN_ADMIN_PASSWORD_LENGTH = 10
|
||
# Webhook secret 最低长度门槛 (生产环境)
|
||
_MIN_PROD_WEBHOOK_SECRET_LEN = 16
|
||
# Portal token secret 最低长度门槛 (生产环境)
|
||
_MIN_PORTAL_TOKEN_SECRET_LEN = 32
|
||
_SUPPORTED_PAYMENT_PROVIDERS = {"mock", "alipay_sim", "alipay"}
|
||
_PROD_ALLOWED_PAYMENT_PROVIDERS = {"alipay"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Settings:
|
||
"""运行时配置(不可变)。"""
|
||
|
||
env: str
|
||
db_path: str
|
||
orders_db_path: str # T6.2 — 订单数据 (data.orders.* 写入位置)
|
||
share_db_path: str # T7.5 — 短链接 SQLite
|
||
share_report_dir: str # T7.5 — report_id -> JSON 报告目录
|
||
portal_upload_dir: str # T12 — 用户前台资料/附件上传目录
|
||
portal_upload_max_bytes: int # T12 — 单文件上传大小上限
|
||
portal_upload_max_files: int # T12 — 单订单最大附件数
|
||
payment_provider: str # mock|alipay
|
||
payment_base_url: str
|
||
payment_webhook_secret: str
|
||
payment_notify_url: str
|
||
payment_return_url: str
|
||
payment_app_id: str
|
||
payment_merchant_id: str
|
||
payment_private_key_path: str
|
||
payment_alipay_public_key_path: str
|
||
smtp_host: str
|
||
smtp_port: int
|
||
smtp_sender: str
|
||
smtp_username: str
|
||
smtp_password: str
|
||
smtp_use_tls: bool
|
||
smtp_use_ssl: bool
|
||
alert_recipients: list[str]
|
||
alert_webhook_urls: list[str]
|
||
ops_alert_log_path: str
|
||
deletion_request_log_path: str
|
||
retention_days: int # 2026-06-19: 删除/匿名化保留期 (天)
|
||
jwt_secret: str
|
||
portal_token_secret: str # P2-4: 与后台 jwt_secret 分离
|
||
jwt_algorithm: str
|
||
jwt_expire_minutes: int
|
||
default_admin_username: str
|
||
default_admin_password: str
|
||
|
||
|
||
def _resolve_payment_webhook_secret(env: str) -> str:
|
||
"""解析支付 webhook secret,根据环境返回实际值。
|
||
|
||
dev/test 保留 dev 默认值 ``_DEV_PAYMENT_WEBHOOK_SECRET``,便于本地开发/测试;
|
||
生产环境不读取默认值,强制要求显式设置环境变量。
|
||
"""
|
||
raw = os.getenv("GAOKAO_PAYMENT_WEBHOOK_SECRET")
|
||
if raw is None or raw == "":
|
||
if env == "prod":
|
||
# 生产环境未设置环境变量 → 抛错,防止 Settings 实例化成功后再次绕过校验
|
||
raise RuntimeError(
|
||
"GAOKAO_PAYMENT_WEBHOOK_SECRET 必须在生产环境显式设置 "
|
||
"(当前缺失或为空),已拒绝启动 (P2-5 fail-closed)"
|
||
)
|
||
return _DEV_PAYMENT_WEBHOOK_SECRET
|
||
return raw
|
||
|
||
|
||
def _enforce_payment_webhook_secret_policy(settings: Settings) -> None:
|
||
"""生产环境 webhook secret fail-closed 校验 (P2-5)。
|
||
|
||
不接受:
|
||
- 缺失/空字符串 (由 ``_resolve_payment_webhook_secret`` 处理,但为防御性双重检查)
|
||
- dev 占位 secret (``_DEV_PAYMENT_WEBHOOK_SECRET``)
|
||
- 长度 < ``_MIN_PROD_WEBHOOK_SECRET_LEN`` 字符
|
||
"""
|
||
if settings.env != "prod":
|
||
return
|
||
secret = settings.payment_webhook_secret
|
||
if not secret:
|
||
raise RuntimeError(
|
||
"GAOKAO_PAYMENT_WEBHOOK_SECRET 缺失,生产环境必须显式设置 (P2-5 fail-closed)"
|
||
)
|
||
if secret == _DEV_PAYMENT_WEBHOOK_SECRET:
|
||
raise RuntimeError(
|
||
"GAOKAO_PAYMENT_WEBHOOK_SECRET 仍使用 dev 默认值,生产环境禁止 "
|
||
"(P2-5 fail-closed)"
|
||
)
|
||
if len(secret) < _MIN_PROD_WEBHOOK_SECRET_LEN:
|
||
raise RuntimeError(
|
||
"GAOKAO_PAYMENT_WEBHOOK_SECRET 长度 {} 小于生产环境最低要求 {} "
|
||
"(P2-5 fail-closed)".format(len(secret), _MIN_PROD_WEBHOOK_SECRET_LEN)
|
||
)
|
||
|
||
|
||
def _resolve_portal_token_secret(env: str) -> str:
|
||
raw = os.getenv("GAOKAO_PORTAL_TOKEN_SECRET")
|
||
if raw is None or raw == "":
|
||
if env == "prod":
|
||
raise RuntimeError(
|
||
"GAOKAO_PORTAL_TOKEN_SECRET 必须在生产环境显式设置 "
|
||
"(当前缺失或为空),已拒绝启动 (P2-4 fail-closed)"
|
||
)
|
||
return _DEV_PORTAL_TOKEN_SECRET
|
||
return raw
|
||
|
||
|
||
def is_portal_token_secret_secure(settings: Settings) -> tuple[bool, str]:
|
||
secret = settings.portal_token_secret
|
||
if settings.env == "prod":
|
||
if secret == _DEV_PORTAL_TOKEN_SECRET:
|
||
return False, "生产环境禁止使用默认 portal token secret"
|
||
if len(secret) < _MIN_PORTAL_TOKEN_SECRET_LEN:
|
||
return False, "portal token secret 长度必须 >= 32"
|
||
if secret == settings.jwt_secret:
|
||
return False, "portal token secret 必须与 JWT secret 分离"
|
||
if settings.env == "dev" and secret == _DEV_PORTAL_TOKEN_SECRET:
|
||
return False, "dev 环境使用占位 portal token secret(仅本地开发可接受)"
|
||
if len(secret) < _MIN_PORTAL_TOKEN_SECRET_LEN:
|
||
return False, "portal token secret 长度必须 >= 32"
|
||
if secret == settings.jwt_secret:
|
||
return False, "portal token secret 必须与 JWT secret 分离"
|
||
return True, "ok"
|
||
|
||
|
||
def _enforce_portal_token_secret_policy(settings: Settings) -> None:
|
||
secure, reason = is_portal_token_secret_secure(settings)
|
||
if not secure and settings.env == "prod":
|
||
raise RuntimeError(f"GAOKAO_PORTAL_TOKEN_SECRET invalid in prod: {reason}")
|
||
|
||
|
||
def _enforce_jwt_secret_policy(settings: Settings) -> None:
|
||
"""6/20 加固: 生产环境 JWT secret 必须满足强度门槛, 否则 fail-closed。"""
|
||
secure, reason = is_jwt_secret_secure(settings)
|
||
if not secure and settings.env == "prod":
|
||
raise RuntimeError(f"GAOKAO_JWT_SECRET invalid in prod: {reason}")
|
||
|
||
|
||
def _enforce_default_admin_password_policy(settings: Settings) -> None:
|
||
"""6/20 加固: 生产环境默认管理员密码必须满足强度门槛, 否则 fail-closed。"""
|
||
secure, reason = is_default_admin_password_secure(settings)
|
||
if not secure and settings.env == "prod":
|
||
raise RuntimeError(f"GAOKAO_ADMIN_PASS invalid in prod: {reason}")
|
||
|
||
|
||
def _enforce_payment_provider_policy(settings: Settings) -> None:
|
||
provider = (settings.payment_provider or "mock").strip().lower()
|
||
if settings.env != "prod":
|
||
return
|
||
if provider not in _SUPPORTED_PAYMENT_PROVIDERS:
|
||
raise RuntimeError(
|
||
"GAOKAO_PAYMENT_PROVIDER={} 在生产环境被禁止,"
|
||
"仅允许受支持且可上线的 provider {} (P0-2 fail-closed)".format(
|
||
provider, sorted(_PROD_ALLOWED_PAYMENT_PROVIDERS)
|
||
)
|
||
)
|
||
if provider not in _PROD_ALLOWED_PAYMENT_PROVIDERS:
|
||
raise RuntimeError(
|
||
"GAOKAO_PAYMENT_PROVIDER={} 在生产环境被禁止,"
|
||
"仅允许受支持且可上线的 provider {} (P0-2 fail-closed)".format(
|
||
provider, sorted(_PROD_ALLOWED_PAYMENT_PROVIDERS)
|
||
)
|
||
)
|
||
|
||
|
||
def load_settings() -> Settings:
|
||
"""从环境变量加载配置。
|
||
|
||
- GAOKAO_ENV : dev|prod,默认 dev
|
||
- GAOKAO_DB_PATH : 管理后台 SQLite 文件路径,默认 data/orders/admin.db
|
||
- GAOKAO_ORDERS_DB_PATH : 订单 DB 路径 (与 data.orders.* 共享),默认 data/orders.db
|
||
- GAOKAO_SHARE_DB_PATH : 分享短链接 DB 路径,默认 data/share/short_links.db
|
||
- GAOKAO_SHARE_REPORT_DIR : 分享报告 JSON 目录,默认 data/share/reports
|
||
- GAOKAO_PAYMENT_PROVIDER : 支付 provider,默认 mock
|
||
- GAOKAO_PAYMENT_BASE_URL : 支付相关回跳基础 URL,默认 http://testserver
|
||
- GAOKAO_PAYMENT_WEBHOOK_SECRET : 支付 webhook 独立签名密钥。生产环境 (GAOKAO_ENV=prod)
|
||
必须显式设置强密钥,否则启动 fail-closed (P2-5)。
|
||
dev 保留 ``dev-mock-payment-secret`` 默认值。
|
||
- GAOKAO_PAYMENT_NOTIFY_URL : 真实 provider 异步通知地址,默认空
|
||
- GAOKAO_PAYMENT_RETURN_URL : 真实 provider 浏览器返回地址,默认空
|
||
- GAOKAO_PAYMENT_APP_ID : 真实 provider 应用 ID,默认空
|
||
- GAOKAO_PAYMENT_MERCHANT_ID : 真实 provider 商户/卖家 ID,默认空
|
||
- GAOKAO_PAYMENT_PRIVATE_KEY_PATH : 真实 provider 私钥路径,默认空
|
||
- GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH : 支付宝公钥路径,默认空
|
||
- GAOKAO_SMTP_HOST : SMTP 主机,默认空
|
||
- GAOKAO_SMTP_PORT : SMTP 端口,默认 25
|
||
- GAOKAO_SMTP_SENDER : 发件邮箱,默认空
|
||
- GAOKAO_SMTP_USER : SMTP 用户名,默认空
|
||
- GAOKAO_SMTP_PASS : SMTP 密码,默认空
|
||
- GAOKAO_SMTP_USE_TLS : 是否启用 STARTTLS,默认 false
|
||
- GAOKAO_SMTP_USE_SSL : 是否启用 SMTPS,默认 false
|
||
- GAOKAO_JWT_SECRET : HS256 密钥,默认 dev 占位(启动日志 WARN)
|
||
- GAOKAO_PORTAL_TOKEN_SECRET : Portal token 独立签名密钥 (P2-4)。
|
||
与 ``GAOKAO_JWT_SECRET`` **分离**:默认 dev 占位,
|
||
生产环境必须显式设置且与 JWT secret 不同。
|
||
- GAOKAO_JWT_EXP_MIN : JWT 过期时间(分钟),默认 60
|
||
- GAOKAO_ADMIN_USER : 默认管理员用户名,默认 admin
|
||
- GAOKAO_ADMIN_PASS : 默认管理员密码,默认 admin123(仅本地开发占位)
|
||
|
||
Raises:
|
||
RuntimeError: 生产环境下 GAOKAO_PAYMENT_WEBHOOK_SECRET 缺失/默认值/长度不足
|
||
时 fail-closed。
|
||
"""
|
||
settings = Settings(
|
||
env=os.getenv("GAOKAO_ENV", "dev"),
|
||
db_path=os.getenv("GAOKAO_DB_PATH", "data/orders/admin.db"),
|
||
orders_db_path=os.getenv("GAOKAO_ORDERS_DB_PATH", "data/orders.db"),
|
||
share_db_path=os.getenv("GAOKAO_SHARE_DB_PATH", "data/share/short_links.db"),
|
||
share_report_dir=os.getenv("GAOKAO_SHARE_REPORT_DIR", "data/share/reports"),
|
||
portal_upload_dir=os.getenv("GAOKAO_PORTAL_UPLOAD_DIR", "data/portal_uploads"),
|
||
portal_upload_max_bytes=int(
|
||
os.getenv("GAOKAO_PORTAL_UPLOAD_MAX_BYTES", "5242880")
|
||
),
|
||
portal_upload_max_files=int(os.getenv("GAOKAO_PORTAL_UPLOAD_MAX_FILES", "5")),
|
||
payment_provider=os.getenv("GAOKAO_PAYMENT_PROVIDER", "mock").strip().lower(),
|
||
payment_base_url=os.getenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver"),
|
||
payment_webhook_secret=_resolve_payment_webhook_secret(
|
||
os.getenv("GAOKAO_ENV", "dev")
|
||
),
|
||
payment_notify_url=os.getenv("GAOKAO_PAYMENT_NOTIFY_URL", ""),
|
||
payment_return_url=os.getenv("GAOKAO_PAYMENT_RETURN_URL", ""),
|
||
payment_app_id=os.getenv("GAOKAO_PAYMENT_APP_ID", ""),
|
||
payment_merchant_id=os.getenv("GAOKAO_PAYMENT_MERCHANT_ID", ""),
|
||
payment_private_key_path=os.getenv("GAOKAO_PAYMENT_PRIVATE_KEY_PATH", ""),
|
||
payment_alipay_public_key_path=os.getenv(
|
||
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH", ""
|
||
),
|
||
smtp_host=os.getenv("GAOKAO_SMTP_HOST", ""),
|
||
smtp_port=int(os.getenv("GAOKAO_SMTP_PORT", "25")),
|
||
smtp_sender=os.getenv("GAOKAO_SMTP_SENDER", ""),
|
||
smtp_username=os.getenv("GAOKAO_SMTP_USER", ""),
|
||
smtp_password=os.getenv("GAOKAO_SMTP_PASS", ""),
|
||
smtp_use_tls=os.getenv("GAOKAO_SMTP_USE_TLS", "false").lower() == "true",
|
||
smtp_use_ssl=os.getenv("GAOKAO_SMTP_USE_SSL", "false").lower() == "true",
|
||
alert_recipients=[
|
||
s.strip()
|
||
for s in os.getenv("GAOKAO_ALERT_RECIPIENTS", "").split(",")
|
||
if s.strip()
|
||
],
|
||
alert_webhook_urls=[
|
||
s.strip()
|
||
for s in os.getenv("GAOKAO_ALERT_WEBHOOK_URLS", "").split(",")
|
||
if s.strip()
|
||
],
|
||
ops_alert_log_path=os.getenv(
|
||
"GAOKAO_OPS_ALERT_LOG", "data/alerts/ops-alerts.jsonl"
|
||
),
|
||
deletion_request_log_path=os.getenv(
|
||
"GAOKAO_DELETION_REQUEST_LOG", "data/alerts/deletion-requests.jsonl"
|
||
),
|
||
retention_days=int(os.getenv("GAOKAO_RETENTION_DAYS", "180")),
|
||
jwt_secret=os.getenv("GAOKAO_JWT_SECRET", _DEV_JWT_SECRET),
|
||
portal_token_secret=_resolve_portal_token_secret(
|
||
os.getenv("GAOKAO_ENV", "dev")
|
||
),
|
||
jwt_algorithm=os.getenv("GAOKAO_JWT_ALGORITHM", "HS256"),
|
||
jwt_expire_minutes=int(os.getenv("GAOKAO_JWT_EXP_MIN", "60")),
|
||
default_admin_username=os.getenv("GAOKAO_ADMIN_USER", "admin"),
|
||
default_admin_password=os.getenv("GAOKAO_ADMIN_PASS", _DEFAULT_ADMIN_PASSWORD),
|
||
)
|
||
# 生产环境 post-load 校验:webhook / portal token / JWT / admin password
|
||
# / payment provider 必须满足强度门槛, 任一不满足 fail-closed (P0-2/P2-4/P2-5/6/20)。
|
||
_enforce_payment_webhook_secret_policy(settings)
|
||
_enforce_portal_token_secret_policy(settings)
|
||
_enforce_jwt_secret_policy(settings)
|
||
_enforce_default_admin_password_policy(settings)
|
||
_enforce_payment_provider_policy(settings)
|
||
return settings
|
||
|
||
|
||
def is_jwt_secret_secure(settings: Settings) -> tuple:
|
||
"""判断 JWT 密钥是否满足最低安全门槛。
|
||
|
||
Returns:
|
||
(is_secure, reason) : 不满足时给出原因字符串
|
||
"""
|
||
secret = settings.jwt_secret
|
||
if settings.env == "prod":
|
||
if secret == _DEV_JWT_SECRET:
|
||
return False, "生产环境禁止使用 dev 占位密钥"
|
||
if len(secret) < 32:
|
||
return False, "生产环境 JWT 密钥长度必须 >= 32 (当前 {})".format(
|
||
len(secret)
|
||
)
|
||
if secret == _DEV_JWT_SECRET and settings.env == "dev":
|
||
return False, "dev 环境使用占位密钥(仅本地开发可接受)"
|
||
if len(secret) < 32:
|
||
return False, "JWT 密钥长度必须 >= 32 (当前 {})".format(len(secret))
|
||
return True, "ok"
|
||
|
||
|
||
def is_default_admin_password_secure(settings: Settings) -> tuple[bool, str]:
|
||
"""判断默认管理员密码是否满足最低安全门槛。"""
|
||
password = settings.default_admin_password
|
||
if len(password) < _MIN_ADMIN_PASSWORD_LENGTH:
|
||
return False, (
|
||
f"默认管理员密码长度必须 >= {_MIN_ADMIN_PASSWORD_LENGTH} (当前 {len(password)})"
|
||
)
|
||
if settings.env == "prod" and password == _DEFAULT_ADMIN_PASSWORD:
|
||
return False, "生产环境禁止使用默认管理员密码 admin123"
|
||
if settings.env == "prod":
|
||
classes = sum((
|
||
any(ch.islower() for ch in password),
|
||
any(ch.isupper() for ch in password),
|
||
any(ch.isdigit() for ch in password),
|
||
any(ch in string.punctuation for ch in password),
|
||
))
|
||
if classes < 3:
|
||
return False, "生产环境默认管理员密码至少覆盖 3 类字符(大小写/数字/符号)"
|
||
if settings.env == "dev" and password == _DEFAULT_ADMIN_PASSWORD:
|
||
return False, "dev 环境仍在使用默认管理员密码(仅本地临时开发可接受)"
|
||
return True, "ok"
|
||
|
||
|
||
def generate_secure_secret() -> str:
|
||
"""生成 32-byte 十六进制密钥(用于初始化文档示例)。"""
|
||
return secrets.token_hex(32)
|
||
|
||
|
||
def get_settings_dep(request: Request) -> Settings:
|
||
"""FastAPI 依赖:从 app.state 取已加载的 Settings。
|
||
|
||
与 admin.auth.get_settings 重复,但放在 config 里便于其他路由直接引用。
|
||
"""
|
||
settings = getattr(request.app.state, "settings", None)
|
||
if settings is None: # pragma: no cover - 兜底
|
||
settings = load_settings()
|
||
return settings
|