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