Files
FinDataHub 649085ab3b fix(scripts): site 目录加载失败留痕
common.py 的 site.addsitedir() 原为 except Exception: pass。

此处静默的代价是:某个 site 目录没加载成功时,「依赖明明装了却 import 不到」
完全无从判断——而排障时最需要知道的恰恰是「哪个目录失败、为什么」。

改为 stderr 留痕(沿用本文件的 ⚠️ 风格)。单个目录失败仍不阻断启动。

说明:其余回填/分析脚本中的 except: pass 保持原样。
全项目 40 处已分类审计(见审查方案 §3.4),多数属合理防御——
错误处理路径内再失败、崩溃处理器自身失败——无差别改成 logger.exception
会制造噪音甚至改变行为。
2026-10-26 21:07:58 +08:00

266 lines
9.5 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.
#!/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)
# 项目根目录:本文件位于 <root>/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