Files
Neo-ZQYY/apps/backend/app/config.py

186 lines
7.4 KiB
Python
Raw Permalink 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.
"""
后端配置加载
优先级(低 → 高):根 .env → 应用 .env.local → 环境变量
敏感值DSN、Token禁止提交仅放在 .env / .env.local 中。
"""
import os
from pathlib import Path
from dotenv import load_dotenv
# CHANGE 2026-03-07 | 项目根目录定位:防止 junction/symlink 穿透到 D 盘
# 背景C:\NeoZQYY 是 junction → D:\NeoZQYY\...\repo
# Path(__file__).resolve() 和 absolute() 都可能解析到 D 盘,
# 导致加载 D 盘的 .env路径全指向 D 盘ETL 命令因此携带错误路径。
# 策略:环境变量 > 已知固定路径 > __file__ 推算(最后手段)
import logging as _logging
_cfg_logger = _logging.getLogger("app.config")
def _find_project_root() -> Path:
"""定位项目根目录,返回包含 .env 的路径。
优先级:
1. 环境变量 NEOZQYY_ROOT最可靠显式指定
2. __file__ 向上推算,但用 junction 安全的方式
"""
# 1. 环境变量显式指定(部署时设置,最可靠)
env_root = os.environ.get("NEOZQYY_ROOT")
if env_root:
p = Path(env_root)
if (p / ".env").exists():
_cfg_logger.info("[ROOT] 策略1命中: NEOZQYY_ROOT=%s", p)
return p
_cfg_logger.warning("[ROOT] NEOZQYY_ROOT=%s 但 .env 不存在,跳过", env_root)
# 2. 从 __file__ 推算apps/backend/app/config.py → 上 3 级)
raw_file = Path(__file__)
abs_file = raw_file.absolute()
candidate = abs_file.parents[3]
_cfg_logger.info(
"[ROOT] 策略2: __file__=%s | absolute=%s | candidate=%s",
raw_file, abs_file, candidate,
)
# CHANGE 2026-03-07 | 防护:如果推算路径包含 test/repo 或 prod/repo 等
# 多环境子目录,说明发生了 junction/symlink 穿透到 D 盘部署结构,
# 此时向上搜索找到真正的项目根(包含 .env 的最浅目录)
candidate_str = str(candidate)
if any(seg in candidate_str for seg in ("\\test\\", "\\prod\\", "/test/", "/prod/")):
_cfg_logger.warning(
"[ROOT] 检测到多环境子目录穿透: %s,启动向上搜索", candidate_str
)
elif (candidate / ".env").exists():
_cfg_logger.info("[ROOT] 策略2命中: %s", candidate)
return candidate
# 3. 向上搜索:应对 junction 穿透导致层级偏移的情况
cur = abs_file.parent
for i in range(10):
if (cur / ".env").exists():
_cfg_logger.info("[ROOT] 策略3命中(第%d级): %s", i, cur)
return cur
parent = cur.parent
if parent == cur:
break
cur = parent
_cfg_logger.warning("[ROOT] 所有策略均未命中,回退到: %s", candidate)
return candidate
_project_root = _find_project_root()
_cfg_logger.info("项目根目录: %s", _project_root)
# 根 .env公共配置
_root_env = _project_root / ".env"
_cfg_logger.info("加载根 .env: %s (存在: %s)", _root_env, _root_env.exists())
load_dotenv(_root_env, override=False)
# 应用级 .env.local私有覆盖优先级更高
_local_env = _project_root / "apps" / "backend" / ".env.local"
load_dotenv(_local_env, override=True)
def get(key: str, default: str | None = None) -> str | None:
"""从环境变量读取配置值。"""
return os.getenv(key, default)
def _require_env(key: str) -> str:
"""必需的环境变量,缺失时立即报错。"""
raise RuntimeError(
f"必需的环境变量 {key} 未设置。"
f"请在 .env 中显式配置(当前 .env 路径: {_root_env}"
)
# ---- 数据库连接参数 ----
DB_HOST: str = get("DB_HOST", "localhost")
DB_PORT: str = get("DB_PORT", "5432")
DB_USER: str = get("DB_USER", "")
DB_PASSWORD: str = get("DB_PASSWORD", "")
# CHANGE 2026-02-15 | 默认指向测试库,生产环境通过 .env 覆盖
APP_DB_NAME: str = get("APP_DB_NAME", "test_zqyy_app")
# ---- JWT 认证 ----
JWT_SECRET_KEY: str = get("JWT_SECRET_KEY", "") # 生产环境必须设置
JWT_ALGORITHM: str = get("JWT_ALGORITHM", "HS256")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(get("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = int(get("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# ---- ETL 数据库连接参数(可独立配置,缺省时复用 zqyy_app 的连接参数) ----
ETL_DB_HOST: str = get("ETL_DB_HOST") or DB_HOST
ETL_DB_PORT: str = get("ETL_DB_PORT") or DB_PORT
ETL_DB_USER: str = get("ETL_DB_USER") or DB_USER
ETL_DB_PASSWORD: str = get("ETL_DB_PASSWORD") or DB_PASSWORD
# CHANGE 2026-02-15 | 默认指向测试库,生产环境通过 .env 覆盖
ETL_DB_NAME: str = get("ETL_DB_NAME", "test_etl_feiqiu")
# ---- CORS ----
# 逗号分隔的允许来源列表;缺省允许 Vite 开发服务器
CORS_ORIGINS: list[str] = [
o.strip()
for o in get("CORS_ORIGINS", "http://localhost:5173").split(",")
if o.strip()
]
# ---- ETL 项目路径 ----
# CHANGE 2026-03-06 | 必须在 .env 显式设置,禁止依赖 __file__ 推算
# 原因__file__ 推算依赖 uvicorn 启动位置,不同部署环境会指向错误代码副本
ETL_PROJECT_PATH: str = get("ETL_PROJECT_PATH") or _require_env("ETL_PROJECT_PATH")
# ETL 子进程 Python 可执行路径
# CHANGE 2026-03-06 | 必须在 .env 显式设置,避免 PATH 歧义
ETL_PYTHON_EXECUTABLE: str = get("ETL_PYTHON_EXECUTABLE") or _require_env("ETL_PYTHON_EXECUTABLE")
# ---- 运维面板 ----
# CHANGE 2026-03-06 | 必须在 .env 显式设置,消除 __file__ 推算风险
OPS_SERVER_BASE: str = get("OPS_SERVER_BASE") or _require_env("OPS_SERVER_BASE")
# CHANGE 2026-03-07 | 启动时验证关键路径:
# 1. 路径必须实际存在于文件系统
# 2. 路径不得包含多环境子目录test/repo、prod/repo这是 junction 穿透的标志
_cfg_logger.info("ETL_PROJECT_PATH = %s", ETL_PROJECT_PATH)
_cfg_logger.info("ETL_PYTHON_EXECUTABLE = %s", ETL_PYTHON_EXECUTABLE)
_cfg_logger.info("OPS_SERVER_BASE = %s", OPS_SERVER_BASE)
for _var_name, _var_val in [
("ETL_PROJECT_PATH", ETL_PROJECT_PATH),
("ETL_PYTHON_EXECUTABLE", ETL_PYTHON_EXECUTABLE),
("OPS_SERVER_BASE", OPS_SERVER_BASE),
]:
# 检测 junction 穿透特征:路径中包含 \test\repo 或 \prod\repo
_normalized = _var_val.replace("/", "\\")
if "\\test\\repo" in _normalized or "\\prod\\repo" in _normalized:
_cfg_logger.error(
"路径穿透检测: %s=%s 包含多环境子目录,"
"说明 .env 来自 junction 穿透后的 D 盘副本。"
"当前 .env 路径: %s | NEOZQYY_ROOT: %s",
_var_name, _var_val, _root_env,
os.environ.get("NEOZQYY_ROOT", "<未设置>"),
)
raise RuntimeError(
f"配置路径异常: {_var_name}={_var_val} 包含多环境子目录"
f"test/repo 或 prod/repo疑似加载了错误的 .env。"
f" 当前 .env: {_root_env}"
f" 请确保 NEOZQYY_ROOT 环境变量指向正确的项目根目录。"
)
# ---- 微信小程序 ----
WX_APPID: str = get("WX_APPID", "")
WX_SECRET: str = get("WX_SECRET", "")
# 开发模式WX_DEV_MODE=true 时启用 mock 登录端点,跳过微信 code2Session
WX_DEV_MODE: bool = get("WX_DEV_MODE", "false").lower() in ("true", "1", "yes")
# ---- 营业日分割点 ----
BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))
# ---- 通用 ----
TIMEZONE: str = get("TIMEZONE", "Asia/Shanghai")
LOG_LEVEL: str = get("LOG_LEVEL", "INFO")