Files
Neo-ZQYY/apps/backend/app/config.py
Neo 2a7a5d68aa feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
  - 新增 GET /xcx/coaches/{id}/banner 轻量接口
  - performance/records 加 coach_id 参数 + view_board_coach 权限分流
  - coach/customer/performance/board/task 服务层重构
  - fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
  - task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
  - recall_detector settle_type=3 双重限制 + 门店级 resolved

主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
  - perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
  - isScattered 散客标记端到端
  - foodDetail/phoneFull/creator* 字段透传

主线 3: P19 指数回测框架 Phase 1+2
  - 3 个指数表 stat_date 日快照模式
  - 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
  - task_engine 升级 HTTP 实时 + 推演回测双模式

主线 4: Core 维度层启用
  - 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
  - 修复 app 视图空查询问题

主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口

主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
  - schema 基线与 DDL 快照同步

主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)

附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
      backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具

合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:32:07 +08:00

191 lines
7.6 KiB
Python
Raw 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:\Project\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"))
# CHANGE 2026-03-27 | 权限改造 W1refresh_token 有效期 7天→30天配合滑动窗口续期
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = int(get("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "30"))
# ---- 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")
# ---- 用户头像存储 ----
# chooseAvatar 上传后保存到此目录,文件名 {user_id}.jpg
AVATAR_EXPORT_PATH: str = get("AVATAR_EXPORT_PATH", "")
# ---- 营业日分割点 ----
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")