"""配置管理 API:读写 config 表(MAIRUI_LICENCE / XUEQIU_TOKEN 等可视化配置)。 凭证类配置(MAIRUI_LICENCE / XUEQIU_TOKEN)写到 config 表后,可在下次 任务运行时通过 settings 重新读取(注: 当前代码是 os.environ.get 一启动就 固化,改 config 表不会立即生效 — 见 README 说明)。 """ from __future__ import annotations from typing import Optional from fastapi import APIRouter, Body, HTTPException, Query from app.core.db import ops as db_ops from app.core.db.models import Config router = APIRouter(prefix="/api/config", tags=["config"]) # 已知凭证类配置: 写入时建议显示为占位符,读出时也建议脱敏 SENSITIVE_KEYS = {"MAIRUI_LICENCE", "XUEQIU_TOKEN", "MAIRUI_RPS_LIMIT"} def _mask(key: str, value: str) -> str: """凭证类配置显示前 8 位 + ***。""" if key in SENSITIVE_KEYS and value: if len(value) <= 8: return "*" * len(value) return value[:8] + "***" return value @router.get("/") def list_configs( category: Optional[str] = Query(None), search: Optional[str] = Query(None, description="按 key 模糊搜索"), ): """列出所有 config 项。凭证类自动脱敏。""" rows = db_ops.fetch_all_config() if category: rows = [r for r in rows if r.get("category") == category] if search: s = search.lower() rows = [r for r in rows if s in r["key"].lower()] # 脱敏 for r in rows: r["value"] = _mask(r["key"], r.get("value", "")) return {"count": len(rows), "configs": rows} @router.get("/{key}") def get_config(key: str): r = db_ops.fetch_config_by_key(key) if not r: raise HTTPException(status_code=404, detail=f"config {key!r} not found") r["value"] = _mask(key, r.get("value", "")) return r @router.put("/{key}") def upsert_config( key: str, body: dict = Body(...), ): """新建或更新一条 config。""" value = body.get("value", "") category = body.get("category", "general") description = body.get("description", "") if not isinstance(value, str): raise HTTPException(status_code=400, detail="value must be a string") db_ops.upsert_config(key=key, value=value, category=category, description=description) return {"ok": True, "key": key} @router.delete("/{key}") def delete_config(key: str): db_ops.delete_config(key) return {"ok": True, "key": key} @router.get("/categories/list") def list_categories(): """返回所有 category(用于前端下拉)。""" from sqlalchemy import distinct, select with db_ops.get_session() as s: cats = s.execute(select(distinct(Config.category))).scalars().all() return {"categories": sorted([c for c in cats if c])} @router.post("/reload") def reload_from_env(): """把 .env / 环境变量里的已知配置回填到 config 表(覆盖式)。""" import os from app.core.config import settings # 已知 env-driven 配置项 env_keys = [ ("MAIRUI_LICENCE", os.environ.get("MAIRUI_LICENCE", ""), "general", "麦蕊智数 licence"), ("XUEQIU_TOKEN", os.environ.get("XUEQIU_TOKEN", ""), "credential", "雪球 token (含 xq_a_token=...;u=...)"), ("MAIRUI_RPS_LIMIT", os.environ.get("MAIRUI_RPS_LIMIT", ""), "tuning", "麦蕊智数 RPS 限速"), ("DS_BAOSTOCK_ENABLED", str(settings.ds_baostock_enabled).lower(), "datasource", "baostock 开关"), ("DS_SINA_ENABLED", str(settings.ds_sina_enabled).lower(), "datasource", "新浪 开关"), ("TRADING_HOLIDAYS", settings.trading_holidays, "trading_calendar", "A 股休市日 (YYYY-MM-DD,逗号分隔)"), ("API_PORT", str(settings.api_port), "api", "API 监听端口"), ("SCHEDULER_TICK_SECONDS", str(settings.scheduler_tick_seconds), "scheduler", "调度器扫描间隔"), ] n = 0 for key, value, cat, desc in env_keys: if not value: continue db_ops.upsert_config(key=key, value=value, category=cat, description=desc) n += 1 return {"ok": True, "synced": n} @router.get("/page") def config_page(): """配置页 HTML 入口(前端单文件 SPA)。""" from fastapi.responses import RedirectResponse return RedirectResponse(url="/static/config.html")