d601a26850
- articles.py: Path traversal sanitization - optimizer_logs.py: Admin auth guard - platform_config.py: Admin auth guard - system.py: Path traversal whitelist - topic_config.py: Admin auth guard - topics.py: Minor fix Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
412 lines
18 KiB
Python
412 lines
18 KiB
Python
import logging
|
|
import subprocess
|
|
import re
|
|
from fastapi import APIRouter, HTTPException, Depends, Body
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func
|
|
from datetime import datetime, date, timezone, timedelta
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List, Optional
|
|
import os
|
|
import json
|
|
from ..database import get_db
|
|
from ..models import Topic, Article, TaskConfig, TaskLog
|
|
from ..core.generator import run_creator, get_generator_status, _running_processes as _generator_running
|
|
from ..core.optimizer import run_optimizer, get_optimizer_status, _running_processes as _optimizer_running
|
|
from ..core.collector import run_collector, get_collector_status, _running_processes as _collector_running
|
|
import threading
|
|
from ..core.sync import sync_all_topics
|
|
from ..core.scheduler import scheduler
|
|
from .auth import get_current_user, get_current_admin, org_filter
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
|
if os.getenv('PROJECT_ROOT'):
|
|
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
|
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/system", tags=["system"])
|
|
|
|
_active_monitors: Dict[int, dict] = {}
|
|
|
|
def _parse_stdout_result(stdout: str, module_id: str) -> dict:
|
|
"""从脚本 stdout 中提取关键指标存入 result_data"""
|
|
result = {}
|
|
if module_id == "scheduled_collect":
|
|
m = re.search(r'SUCCESS:\s*Collected\s*(\d+)\s*cases?\s*and\s*(\d+)\s*topics?', stdout)
|
|
if m:
|
|
result["cases_saved"] = int(m.group(1))
|
|
result["topics_saved"] = int(m.group(2))
|
|
m = re.search(r'WARNING:\s*(.*)', stdout)
|
|
if m:
|
|
result["warning"] = m.group(1).strip()
|
|
elif module_id in ("scheduled_generate", "scheduled_optimize"):
|
|
# 尝试解析 JSON 输出
|
|
try:
|
|
data = json.loads(stdout.strip())
|
|
if isinstance(data, dict):
|
|
for k, v in data.items():
|
|
if isinstance(v, (str, int, float, bool)):
|
|
result[k] = v
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
if not result:
|
|
m = re.search(r'SUCCESS:\s*(.*)', stdout)
|
|
if m:
|
|
result["summary"] = m.group(1).strip()
|
|
return result
|
|
|
|
def _monitor_subprocess(log_id: int, proc, module_id: str, task_name: str, db_session_factory):
|
|
"""阻塞等待子进程退出(最长 1800s),完成后更新 task_logs"""
|
|
stdout, stderr = "", ""
|
|
try:
|
|
stdout, stderr = proc.communicate(timeout=1800)
|
|
returncode = proc.returncode
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
stdout, stderr = proc.communicate(timeout=5)
|
|
returncode = -1
|
|
logger.warning("Subprocess %s (pid=%s) killed after 1800s timeout", module_id, proc.pid)
|
|
except Exception as e:
|
|
logger.warning("Subprocess %s monitor error: %s", module_id, e)
|
|
returncode = -1
|
|
stdout = stderr = ""
|
|
finished_at = datetime.now(timezone.utc)
|
|
try:
|
|
db = db_session_factory()
|
|
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
|
if log:
|
|
log.status = "success" if returncode == 0 else "failed"
|
|
log.finished_at = finished_at
|
|
msg = f"{task_name} {'完成' if returncode == 0 else '失败'}"
|
|
log.message = msg
|
|
if returncode != 0:
|
|
log.error_trace = (stderr or stdout or "")[:2000]
|
|
if log.started_at:
|
|
log.duration = int((finished_at - log.started_at).total_seconds())
|
|
# 解析 stdout 提取结果指标
|
|
if returncode == 0 and stdout:
|
|
parsed = _parse_stdout_result(stdout, module_id)
|
|
if parsed:
|
|
log.result_data = parsed
|
|
db.commit()
|
|
db.close()
|
|
except Exception as e:
|
|
logger.warning("Failed to update task log %s: %s", log_id, e)
|
|
finally:
|
|
_active_monitors.pop(log_id, None)
|
|
|
|
def _aggregate_status_counts(q):
|
|
"""聚合状态计数,兼容中英文状态值"""
|
|
raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
|
|
mapping = {
|
|
'pending': ['pending', '待处理'],
|
|
'review': ['review', '待审查'],
|
|
'ready': ['ready', '待发布'],
|
|
'published': ['published', '已发布']
|
|
}
|
|
counts = {'pending': 0, 'review': 0, 'ready': 0, 'published': 0}
|
|
for status_val, cnt in raw:
|
|
for key, aliases in mapping.items():
|
|
if status_val in aliases:
|
|
counts[key] += cnt
|
|
break
|
|
return counts
|
|
|
|
@router.get("/status")
|
|
def get_status(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
total = db.query(Topic).count()
|
|
counts = _aggregate_status_counts(db.query(Topic))
|
|
today = date.today()
|
|
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
|
|
return {
|
|
"stats": {
|
|
"total": total,
|
|
"pending": counts['pending'],
|
|
"review": counts['review'],
|
|
"ready": counts['ready'],
|
|
"published": counts['published'],
|
|
"today": today_count
|
|
}
|
|
}
|
|
|
|
@router.post("/generate/run")
|
|
def trigger_generation(topic_id: Optional[str] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
logger.info(f"Generation triggered by {current_user.username}, topic_id={topic_id}")
|
|
try:
|
|
result = run_creator(topic_id)
|
|
from ..database import SessionLocal
|
|
log = TaskLog(module_id="scheduled_generate", task_name="🤖 内容创作", status="running", message="内容创作已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
|
|
db.add(log)
|
|
db.commit()
|
|
log_id = log.id
|
|
proc = result.get("_proc")
|
|
if proc:
|
|
t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_generate", "🤖 内容创作", SessionLocal), daemon=True)
|
|
t.start()
|
|
return {"message": "内容创作已后台启动", "pid": result.get("pid"), "log_id": log_id}
|
|
except RuntimeError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.get("/generate/status", dependencies=[Depends(get_current_user)])
|
|
def generation_status():
|
|
status = get_generator_status()
|
|
if status is None:
|
|
return {"status": "idle", "message": "当前无运行中的创作任务"}
|
|
return status
|
|
|
|
@router.post("/collect/run")
|
|
def trigger_collection(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
logger.info(f"Manual collection triggered by {current_user.username}")
|
|
try:
|
|
result = run_collector()
|
|
from ..database import SessionLocal
|
|
log = TaskLog(module_id="scheduled_collect", task_name="📡 内容采集", status="running", message="内容采集已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
|
|
db.add(log)
|
|
db.commit()
|
|
log_id = log.id
|
|
proc = result.get("_proc")
|
|
if proc:
|
|
t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_collect", "📡 内容采集", SessionLocal), daemon=True)
|
|
t.start()
|
|
return {"message": "内容采集已后台启动", "result": result, "log_id": log_id}
|
|
except RuntimeError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.get("/collect/status", dependencies=[Depends(get_current_user)])
|
|
def collection_status():
|
|
status = get_collector_status()
|
|
if status is None:
|
|
return {"status": "idle", "message": "当前无运行中的采集任务"}
|
|
return status
|
|
|
|
@router.post("/review/run")
|
|
def trigger_review(data: Dict = Body({}), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
topic_ids = data.get("topic_ids") if isinstance(data, dict) else None
|
|
try:
|
|
result = run_optimizer(topic_ids)
|
|
from ..database import SessionLocal
|
|
log = TaskLog(module_id="scheduled_optimize", task_name="🔍 合规审查", status="running", message="合规审查已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
|
|
db.add(log)
|
|
db.commit()
|
|
log_id = log.id
|
|
proc = result.get("_proc")
|
|
if proc:
|
|
t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_optimize", "🔍 合规审查", SessionLocal), daemon=True)
|
|
t.start()
|
|
return {"message": "合规审查已后台启动", "pid": result.get("pid"), "log_id": log_id}
|
|
except RuntimeError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.get("/review/status", dependencies=[Depends(get_current_user)])
|
|
def review_status():
|
|
status = get_optimizer_status()
|
|
if status is None:
|
|
return {"status": "idle", "message": "当前无运行中的审查任务"}
|
|
return status
|
|
|
|
_ALLOWED_LOG_TYPES = {"creator", "collector", "optimizer", "sources", "metrics", "trends", "rank_tracker"}
|
|
|
|
@router.get("/logs/{log_date}", dependencies=[Depends(get_current_user)])
|
|
def get_logs(log_date: str, log_type: str = "creator"):
|
|
if log_type not in _ALLOWED_LOG_TYPES:
|
|
raise HTTPException(status_code=400, detail=f"Invalid log_type: {log_type}")
|
|
if not re.match(r'^\d{4}-\d{2}-\d{2}$', log_date):
|
|
raise HTTPException(status_code=400, detail="Invalid log_date format (expected YYYY-MM-DD)")
|
|
log_file = LOGS_DIR / f"{log_type}_{log_date}.log"
|
|
if not log_file.exists():
|
|
raise HTTPException(status_code=404, detail=f"Log file not found: {log_file}")
|
|
content = log_file.read_text(encoding='utf-8')
|
|
lines = content.splitlines()[-100:] if log_type != "collector" else content.splitlines()[-200:]
|
|
return {"log_date": log_date, "log_type": log_type, "content": lines}
|
|
|
|
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
|
|
def get_pipeline_status(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
topic_base = db.query(Topic)
|
|
of = org_filter(current_user, Topic)
|
|
if of is not True:
|
|
topic_base = topic_base.filter(of)
|
|
total = topic_base.count()
|
|
counts = _aggregate_status_counts(topic_base)
|
|
log_files = {
|
|
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
|
|
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
|
|
"optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log",
|
|
}
|
|
pipeline_status = {}
|
|
for name, log_file in log_files.items():
|
|
if log_file.exists():
|
|
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
|
pipeline_status[name] = {"last_run": mtime.isoformat(), "exists": True}
|
|
else:
|
|
pipeline_status[name] = {"exists": False, "last_run": None}
|
|
return {"topics_count": total, "status_distribution": counts, "pipeline_modules": pipeline_status}
|
|
|
|
@router.post("/sync/run")
|
|
def run_sync(current_user=Depends(get_current_user)):
|
|
try:
|
|
sync_all_topics()
|
|
return {"message": "Sync completed (DB → JSON backup)"}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/optimize-sources/run")
|
|
def trigger_optimize_sources(current_user=Depends(get_current_user)):
|
|
try:
|
|
from ..core.scheduler import scheduler
|
|
def _bg():
|
|
try:
|
|
scheduler._run_optimize_sources(triggered_by="manual")
|
|
except Exception as e:
|
|
logger.exception("Background optimize sources failed: %s", e)
|
|
t = threading.Thread(target=_bg, daemon=True)
|
|
t.start()
|
|
return {"message": "信息源优化已后台启动"}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/metrics-sync/run")
|
|
def trigger_metrics_sync(current_user=Depends(get_current_user)):
|
|
try:
|
|
from ..core.scheduler import scheduler
|
|
def _bg():
|
|
try:
|
|
scheduler._run_metrics_sync(triggered_by="manual")
|
|
except Exception as e:
|
|
logger.exception("Background metrics sync failed: %s", e)
|
|
t = threading.Thread(target=_bg, daemon=True)
|
|
t.start()
|
|
return {"message": "指标同步已后台启动"}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.post("/trends/run")
|
|
def trigger_trends_refresh(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
try:
|
|
import sys as sys_mod
|
|
scripts_dir = PROJECT_ROOT / "scripts"
|
|
from ..database import SessionLocal as _ss
|
|
proc = subprocess.Popen(
|
|
[sys_mod.executable, str(scripts_dir / "trends.py")],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
cwd=str(PROJECT_ROOT)
|
|
)
|
|
logger.info("Trends refresh started (pid=%s)", proc.pid)
|
|
log = TaskLog(module_id="scheduled_fetch_trends", task_name="🔥 热点趋势", status="running", message="热点趋势刷新已启动", triggered_by="manual", started_at=datetime.now(timezone.utc), result_data={"pid": proc.pid})
|
|
db.add(log)
|
|
db.commit()
|
|
log_id = log.id
|
|
t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_fetch_trends", "🔥 热点趋势", _ss), daemon=True)
|
|
t.start()
|
|
return {"message": "热点趋势刷新已后台启动", "pid": proc.pid, "log_id": log_id}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.get("/automation/topics")
|
|
def list_automation_topics(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
try:
|
|
topic_base = db.query(Topic)
|
|
of = org_filter(current_user, Topic)
|
|
if of is not True:
|
|
topic_base = topic_base.filter(of)
|
|
topics = topic_base.order_by(Topic.created_at.desc()).limit(100).all()
|
|
result = []
|
|
for t in topics:
|
|
result.append({
|
|
"id": t.id,
|
|
"title": t.title,
|
|
"field": t.field,
|
|
"status": t.status,
|
|
"priority": t.priority,
|
|
"priority_score": t.priority_score,
|
|
"total_score": t.total_score,
|
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
|
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
|
"ready_at": t.ready_at.isoformat() if t.ready_at else None,
|
|
"compliance_score": t.compliance_score
|
|
})
|
|
return {"count": len(result), "topics": result[:50]}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.post("/refresh")
|
|
def refresh_all(current_user=Depends(get_current_user)):
|
|
try:
|
|
sync_all_topics()
|
|
return {"message": "Refresh completed"}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.get("/scheduler/status", dependencies=[Depends(get_current_user)])
|
|
def get_scheduler_status():
|
|
return {"running": scheduler._started, "jobs": scheduler.get_jobs()}
|
|
|
|
|
|
@router.get("/modules/status", dependencies=[Depends(get_current_user)])
|
|
def get_modules_status(db: Session = Depends(get_db)):
|
|
configs = db.query(TaskConfig).all()
|
|
config_map = {c.module_id: c for c in configs}
|
|
|
|
MODULE_META = {
|
|
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10", "params_desc": {}},
|
|
"scheduled_collect": {"name": "📡 内容采集", "cron": "01:30", "params_desc": {"max_topics": "最大选题数", "categories": "采集类别"}},
|
|
"scheduled_generate": {"name": "🤖 内容创作", "cron": "02:00", "params_desc": {"auto_review": "自动合规审查"}},
|
|
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00", "params_desc": {"auto_pass_threshold": "自动通过分数阈值"}},
|
|
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00", "params_desc": {}},
|
|
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00", "params_desc": {}},
|
|
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*", "params_desc": {}},
|
|
}
|
|
|
|
modules = []
|
|
for mod_id, meta in MODULE_META.items():
|
|
cfg = config_map.get(mod_id)
|
|
latest = db.query(TaskLog).filter(TaskLog.module_id == mod_id).order_by(TaskLog.started_at.desc()).first()
|
|
next_run = _get_next_run(mod_id)
|
|
|
|
total = db.query(TaskLog).filter(TaskLog.module_id == mod_id).count()
|
|
success = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "success").count()
|
|
failed = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "failed").count()
|
|
running = db.query(TaskLog).filter(TaskLog.module_id == mod_id, TaskLog.status == "running").count()
|
|
|
|
modules.append({
|
|
"module_id": mod_id,
|
|
"title": meta["name"],
|
|
"enabled": cfg.enabled if cfg else True,
|
|
"params": cfg.params if cfg else {},
|
|
"params_desc": meta["params_desc"],
|
|
"schedule": cfg.schedule if cfg else meta["cron"],
|
|
"cron_default": meta["cron"],
|
|
"status": "running" if running else ("stopped" if not (cfg and cfg.enabled) else "idle"),
|
|
"last_run": latest.started_at.strftime("%Y-%m-%d %H:%M") if latest and latest.started_at else None,
|
|
"last_status": latest.status if latest else None,
|
|
"last_message": latest.message if latest else None,
|
|
"last_result": latest.result_data if latest else None,
|
|
"next_run": next_run,
|
|
"total_runs": total,
|
|
"success_runs": success,
|
|
"failed_runs": failed,
|
|
"running": running,
|
|
})
|
|
|
|
jobs = scheduler.get_jobs()
|
|
return {"modules": modules, "scheduler": {"running": scheduler._started, "jobs": jobs}}
|
|
|
|
|
|
def _get_next_run(mod_id: str) -> Optional[str]:
|
|
for job in scheduler.get_jobs():
|
|
if job["id"] == mod_id and job["next_run_time"]:
|
|
try:
|
|
dt = datetime.fromisoformat(job["next_run_time"])
|
|
return dt.strftime("%Y-%m-%d %H:%M")
|
|
except Exception:
|
|
return job["next_run_time"]
|
|
return None
|