5cb64f181a
Dashboard (M3):
- 路由 /api/dashboard/{daily,history,today,datasets,stats}/
- daily_summary: 按日期聚合 ok/warning/error/blocked
- history: 同步历史详情(按 task / 日期过滤)
- today: 今日 KPI + 任务列表
- datasets: dataset_registry + 最近 7d run 合并
- stats: 各表行数 + 日期范围(17 张表)
HTML SPA: app/api/static/dashboard.html
- 5 张状态卡片 + 7 天趋势表 + 任务状态表 + 数据规模表 + 历史详情
- 自动 30s 刷新, 纯 vanilla JS (无框架依赖)
Config (M5):
- 路由 /api/config/{GET list, GET key, PUT upsert, DELETE}/
- 凭证类自动脱敏 (MAIRUI_LICENCE / XUEQIU_TOKEN / MAIRUI_RPS_LIMIT)
- POST /api/config/reload: 把 .env / 环境变量已知项回填到 config 表
- 分类过滤 + key 搜索 + 凭证提示
HTML SPA: app/api/static/config.html
- 列表 + 编辑弹窗 + 删除确认
- 凭证类编辑时强制重新输入完整值(避免显示脱敏误以为是新值)
- 一键从环境变量回填
FastAPI 主入口挂载:
- include_router(dashboard, config)
- mount('/static', StaticFiles) 提供 HTML
120 lines
4.2 KiB
Python
120 lines
4.2 KiB
Python
"""配置管理 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") |