186 lines
7.4 KiB
Python
186 lines
7.4 KiB
Python
"""
|
||
后端配置加载
|
||
|
||
优先级(低 → 高):根 .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")
|