#!/usr/bin/env python3 """common.py — 公共配置与工具模块(统一 DB/路径/PID/端口管理)""" import os import signal import sys from pathlib import Path # ── 数据库配置 ────────────────────────────────────────────── MYSQL_CFG = dict( host=os.environ.get("MYSQL_HOST", "localhost"), port=int(os.environ.get("MYSQL_PORT", "3306")), user=os.environ.get("MYSQL_USER", "root"), password=os.environ.get("MYSQL_PASSWORD", ""), database=os.environ.get("MYSQL_DATABASE", "news_flash"), charset="utf8mb4", ) MYSQL_CFG_AKSHARE = dict( host=os.environ.get("MYSQL_HOST", "localhost"), port=int(os.environ.get("MYSQL_PORT", "3306")), user=os.environ.get("MYSQL_USER", "root"), password=os.environ.get("MYSQL_PASSWORD", ""), database=os.environ.get("MYSQL_DATABASE_AKSHARE", "akshare_data"), charset="utf8mb4", ) MYSQL_CMD = [ "mysql", "-h", os.environ.get("MYSQL_HOST", "localhost"), "-P", os.environ.get("MYSQL_PORT", "3306"), "-u", os.environ.get("MYSQL_USER", "root"), ] MYSQL_PWD = os.environ.get("MYSQL_PASSWORD", "") DB_NEWS = os.environ.get("MYSQL_DATABASE", "news_flash") DB_AKSHARE = os.environ.get("MYSQL_DATABASE_AKSHARE", "akshare_data") def database_url(cfg=None): """构造 SQLAlchemy DATABASE_URL""" from urllib.parse import quote_plus c = cfg or MYSQL_CFG pwd = quote_plus(c["password"]) return f"mysql+pymysql://{c['user']}:{pwd}@{c['host']}:{c['port']}/{c['database']}?charset=utf8mb4" # ── 端口/服务地址 ──────────────────────────────────────────── BACKEND_PORT = 8100 OLLAMA_URL = "http://localhost:11434/api/chat" RSS_HUB_URL = os.environ.get("RSS_HUB_URL", "http://localhost:1200") BIN_DIR = str(Path(__file__).resolve().parent) # 项目根目录:本文件位于 /Scripts/bin/common.py _PROJECT_ROOT = str(Path(BIN_DIR).parent.parent) # 开发环境路径(仅未打包时使用;打包后由 _resolve_bundled_paths 提供) # 由本文件位置动态推导,不要写死绝对路径——项目此前从 finance-terminal 改名为 # FinDataHub 后,旧常量仍指向 finance-terminal/backend/kuaixun-service, # 导致开发环境直接跑脚本时 ModuleNotFoundError: No module named 'app.xxx' _KX_HOME_DEV = str(Path(_PROJECT_ROOT) / "Services" / "kuaixun-service") _KXS_HOME_DEV = _KX_HOME_DEV KX_HOME = _KX_HOME_DEV KXS_HOME = _KXS_HOME_DEV #: 项目统一 Python 版本(见仓库根目录 .python-version)。 #: 各服务 pyproject.toml 的 requires-python 均锁定 >=3.12,<3.13。 PYTHON_TARGET_VERSION = "3.12" def _detect_venv_site(home: str) -> str: """ 动态探测 venv 的 site-packages 路径。 此前这里写死了某个具体小版本号,而实际 venv 用的是另一个版本, 属于悬空引用;更糟的是 Python 版本一变就要改代码。改为遍历 `.venv/lib/python3.X/` 并取版本号最大的那个(与 _resolve_bundled_paths 一致)。 """ lib = Path(home) / ".venv" / "lib" fallback = str(lib / f"python{PYTHON_TARGET_VERSION}" / "site-packages") if not lib.is_dir(): return fallback def _vkey(p: Path) -> tuple: # "python3.12" -> (3, 12) parts = p.parent.name.replace("python", "").split(".") try: return tuple(int(x) for x in parts) except ValueError: return (0, 0) cands = [d / "site-packages" for d in lib.iterdir() if d.name.startswith("python") and (d / "site-packages").exists()] return str(max(cands, key=_vkey)) if cands else fallback KX_VENV = _detect_venv_site(KX_HOME) def _resolve_bundled_paths(): """检测 app 内打包的资源路径(优先使用),找不到则回退到外部路径""" resources_dir = Path(BIN_DIR).parent bundled_kxs = resources_dir / "kuaixun-service" bundled_venv = bundled_kxs / ".venv" / "lib" # 找到 site-packages 路径 bundled_site_packages = None if bundled_venv.exists(): for d in bundled_venv.iterdir(): if d.name.startswith("python") and d.is_dir(): sp = d / "site-packages" if sp.exists(): bundled_site_packages = str(sp) break return str(bundled_kxs) if bundled_kxs.exists() else None, bundled_site_packages BUNDLED_KXS_HOME, BUNDLED_VENV_SITE = _resolve_bundled_paths() def setup_kuaixun_paths(): """统一设置 kuaixun-service / venv / bin 的 sys.path,优先使用 app 内打包资源""" import site paths = [] site_dirs = [] if BUNDLED_KXS_HOME: paths.append(BUNDLED_KXS_HOME) if BUNDLED_VENV_SITE: paths.append(BUNDLED_VENV_SITE) site_dirs.append(BUNDLED_VENV_SITE) paths.append(BIN_DIR) if not BUNDLED_KXS_HOME: paths.append(KX_HOME) paths.append(KXS_HOME) if not BUNDLED_VENV_SITE: paths.append(KX_VENV) site_dirs.append(KX_VENV) for p in paths: if p not in sys.path: sys.path.insert(0, p) for sd in site_dirs: try: site.addsitedir(sd) except Exception as e: # noqa: BLE001 — 单个 site 目录失败不应阻断启动 # 必须留痕:此处静默会导致「某个依赖明明装了却 import 不到」无从判断。 # 排障时最需要知道的恰恰是「哪个目录没加载成功」。 print(f" ⚠️ 加载 site 目录失败: {sd} ({e})", file=sys.stderr, flush=True) # ── .env 加载 ─────────────────────────────────────────────── def load_env(path): """加载 .env 文件到 os.environ(不覆盖已有变量)""" if not os.path.exists(path): return with open(path) as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k = k.strip() v = v.strip().strip('"').strip("'") if k and k not in os.environ: os.environ[k] = v # ── PID 文件管理 ──────────────────────────────────────────── class PidManager: """PID 文件管理:write / status / stop""" def __init__(self, pid_file: str): self.pid_file = pid_file def write(self): from datetime import datetime with open(self.pid_file, "w") as f: f.write(f"{os.getpid()}:{datetime.now().isoformat()}") def status(self) -> dict: """返回 {running, pid, started_at}""" r = {"running": False, "pid": None, "started_at": None} if not os.path.exists(self.pid_file): return r with open(self.pid_file) as f: raw = f.read().strip() # 必须 split(":", 1):ISO 时间戳本身含冒号,用 split(":") 会把 # "2026-09-17T18:12:13.463481" 截成 "2026-09-17T18", # 导致 started_at 恒被截断到整点、(now-started)>600s 在每小时第 10 分钟后恒真。 parts = raw.split(":", 1) pid = int(parts[0]) if parts and parts[0].isdigit() else None started = parts[1] if len(parts) > 1 else None if pid is not None: try: os.kill(pid, 0) r["running"] = True r["pid"] = pid r["started_at"] = started except (OSError, ProcessLookupError): if os.path.exists(self.pid_file): os.remove(self.pid_file) return r def stop(self) -> bool: if not os.path.exists(self.pid_file): return False with open(self.pid_file) as f: pid_str = f.read().strip().split(":")[0] if pid_str.isdigit(): try: os.kill(int(pid_str), signal.SIGTERM) except (OSError, ProcessLookupError): pass if os.path.exists(self.pid_file): os.remove(self.pid_file) return True return False # ── Debug 日志工具 ────────────────────────────────────────── # 通过 FDH_DEBUG 环境变量控制,值为 debug/info/warn/error/off _DEBUG_LEVEL = os.environ.get("FDH_DEBUG", "info").lower() _DEBUG_LEVELS = {"debug": 10, "info": 20, "warn": 30, "error": 40, "off": 99} def _debug_level_val() -> int: return _DEBUG_LEVELS.get(_DEBUG_LEVEL, 20) def _debug_print(level: str, category: str, message: str) -> None: """统一格式的 debug 日志输出到 stderr""" from datetime import datetime ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] print(f"[{ts}] [{level}] [{category}] {message}", flush=True, file=sys.stderr) def log_debug(category: str, message: str) -> None: if _debug_level_val() <= 10: _debug_print("DEBUG", category, message) def log_info(category: str, message: str) -> None: if _debug_level_val() <= 20: _debug_print("INFO", category, message) def log_warn(category: str, message: str) -> None: if _debug_level_val() <= 30: _debug_print("WARN", category, message) def log_error(category: str, message: str) -> None: if _debug_level_val() <= 40: _debug_print("ERROR", category, message) def is_debug_enabled() -> bool: return _debug_level_val() <= 10