"""APIs for DSH Planner workflow templates and immutable plan versions.""" from __future__ import annotations import threading from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select from . import execution_config from . import planner from . import task_queue from .access import actor_fields, can_access, request_user from .db import engine as db_engine, get_session from .models import ( AuditLog, Campaign, CampaignPlanVersion, TaskQueueItem, WorkflowTemplate, now, ) router = APIRouter() def _client_template_config(body: dict) -> dict: config = body.get("config") or {} if not isinstance(config, dict): raise HTTPException(422, "工作流模板配置必须是对象") merged = dict(planner.default_template_config()) merged.update(config) max_segments = int(merged.get("max_segments") or 80) if max_segments < 1 or max_segments > 200: raise HTTPException(422, "max_segments 必须在 1~200 之间") merged["max_segments"] = max_segments return merged def _template_payload(tpl: WorkflowTemplate) -> dict[str, Any]: return { "id": tpl.id, "key": tpl.key, "name": tpl.name, "description": tpl.description, "config": tpl.config or {}, "enabled": tpl.enabled, "builtin": tpl.builtin, "organization_id": tpl.organization_id, "owner_user_id": tpl.owner_user_id, "visibility": tpl.visibility, "created_at": tpl.created_at, "updated_at": tpl.updated_at, } def _plan_payload(v: CampaignPlanVersion, *, include_plan: bool = True) -> dict[str, Any]: data = { "id": v.id, "campaign_id": v.campaign_id, "version": v.version, "source": v.source, "template_id": v.template_id, "template_key": v.template_key, "status": v.status, "summary": v.summary, "risks": v.risks or [], "advice": v.advice, "validation": v.validation or {}, "created_by": v.created_by, "reviewed_by": v.reviewed_by, "review_note": v.review_note, "created_at": v.created_at, "reviewed_at": v.reviewed_at, } if include_plan: data["plan"] = v.plan or {} return data def _require_campaign(s: Session, cid: int, request: Request, operation: str = "manage") -> Campaign: campaign = s.get(Campaign, cid) perm = {"review": "tasks:review", "read": "tasks:read"}.get(operation, "agents:write") if not campaign or not can_access(campaign, request_user(request), operation, perm, s): raise HTTPException(404, "任务不存在") return campaign def _audit(s: Session, request: Request, action: str, resource: str, result: str = "success", detail: str = "") -> None: user = request_user(request) s.add(AuditLog( actor_user_id=int(getattr(user, "id", 0) or 0), actor_display_name=getattr(user, "display_name", "") or getattr(user, "username", "") or "", action=action, resource=resource, result=result, )) s.commit() @router.get("/api/campaigns/workflow-templates") def list_workflow_templates(s: Session = Depends(get_session), request: Request = None): planner.ensure_default_workflow_template(s) user = request_user(request) rows = s.exec(select(WorkflowTemplate).order_by(WorkflowTemplate.builtin.desc(), WorkflowTemplate.id)).all() out = [] for tpl in rows: if user is None or user.role == "admin" or tpl.owner_user_id == int(user.id or 0) or tpl.visibility == "global": out.append(_template_payload(tpl)) return out @router.post("/api/campaigns/workflow-templates") def create_workflow_template(body: dict, s: Session = Depends(get_session), request: Request = None): user = request_user(request) if user and user.role != "admin": raise HTTPException(403, "只有管理员可以创建工作流模板") key = str(body.get("key") or "").strip() name = str(body.get("name") or "").strip() if not key or not name: raise HTTPException(422, "模板 key 和名称不能为空") if s.exec(select(WorkflowTemplate).where(WorkflowTemplate.key == key)).first(): raise HTTPException(409, "模板 key 已存在") organization_id, owner_user_id = actor_fields(request) tpl = WorkflowTemplate( key=key[:80], name=name[:100], description=str(body.get("description") or "")[:1000], config=_client_template_config(body), enabled=bool(body.get("enabled", True)), builtin=False, organization_id=organization_id, owner_user_id=owner_user_id, visibility=str(body.get("visibility") or "private"), ) s.add(tpl) s.commit() s.refresh(tpl) _audit(s, request, "workflow_template.create", f"/api/campaigns/workflow-templates/{tpl.id}") return _template_payload(tpl) @router.patch("/api/campaigns/workflow-templates/{tid}") def update_workflow_template(tid: int, body: dict, s: Session = Depends(get_session), request: Request = None): user = request_user(request) tpl = s.get(WorkflowTemplate, tid) if not tpl: raise HTTPException(404, "工作流模板不存在") if user and user.role != "admin" and tpl.owner_user_id != int(user.id or 0): raise HTTPException(404, "工作流模板不存在") if "name" in body: tpl.name = str(body.get("name") or tpl.name)[:100] if "description" in body: tpl.description = str(body.get("description") or "")[:1000] if "enabled" in body: tpl.enabled = bool(body.get("enabled")) if "visibility" in body and str(body.get("visibility")) in {"private", "shared", "global"}: tpl.visibility = str(body.get("visibility")) if body.get("config") is not None: if tpl.builtin and user and user.role != "admin": raise HTTPException(403, "内置模板只能由管理员调整") tpl.config = _client_template_config(body) tpl.updated_at = now() s.add(tpl) s.commit() s.refresh(tpl) _audit(s, request, "workflow_template.update", f"/api/campaigns/workflow-templates/{tpl.id}") return _template_payload(tpl) @router.post("/api/campaigns/{cid}/plans") def create_campaign_plan(cid: int, body: dict = None, s: Session = Depends(get_session), request: Request = None): campaign = _require_campaign(s, cid, request, "manage") body = body or {} if campaign.status == "done": raise HTTPException(409, "任务已交付,不能重新规划") if campaign.status not in {"draft", "planning", "plan_review", "plan_failed", "parsed"}: raise HTTPException(409, f"任务当前状态 {campaign.status} 不能重新规划;请先停止运行或使用新版本任务") if not (campaign.mindmap_text or "").strip() and not advice: raise HTTPException(422, "导图内容是空的——请先补充导图/框架文本,再提交 DSH Planner 规划") active = s.exec(select(TaskQueueItem).where( TaskQueueItem.task_key == f"campaign-plan:{cid}", TaskQueueItem.status.in_(["pending", "running"]))).first() if active: return {"status": "queued", "queued": True, "planning": True, "backend": "dsh", "message": "DSH Planner 已在规划队列中"} try: template_id = int(body.get("template_id") or 0) if template_id: planner.get_template(s, template_id) except (TypeError, ValueError): raise HTTPException(422, "工作流模板无效") except ValueError as exc: raise HTTPException(404, str(exc)) from exc advice = str(body.get("advice") or "")[:1000] _, user_id = actor_fields(request) campaign.status = "planning" s.add(campaign) s.commit() payload = {"campaign_id": cid, "template_id": template_id, "advice": advice, "created_by": int(user_id or campaign.owner_user_id or 0)} if execution_config.is_dsh_backend(): task_queue.enqueue(s, TaskQueueItem, task_key=f"campaign-plan:{cid}", payload=payload, update_existing=True) return {"status": "queued", "queued": True, "planning": True, "backend": "dsh"} def _run_inline(): with Session(db_engine) as worker_session: planner.run_plan_job(worker_session, cid, template_id=template_id, advice=advice, created_by=payload["created_by"]) threading.Thread(target=_run_inline, daemon=True).start() return {"status": "accepted", "planning": True, "backend": "inline"} @router.get("/api/campaigns/{cid}/plans") def list_campaign_plans(cid: int, s: Session = Depends(get_session), request: Request = None): campaign = _require_campaign(s, cid, request, "read") rows = s.exec(select(CampaignPlanVersion).where( CampaignPlanVersion.campaign_id == cid ).order_by(CampaignPlanVersion.version.desc())).all() return [_plan_payload(v, include_plan=False) for v in rows] @router.get("/api/campaigns/{cid}/plans/{vid}") def get_campaign_plan(cid: int, vid: int, s: Session = Depends(get_session), request: Request = None): _require_campaign(s, cid, request, "read") v = s.get(CampaignPlanVersion, vid) if not v or v.campaign_id != cid: raise HTTPException(404, "计划版本不存在") return _plan_payload(v) @router.post("/api/campaigns/{cid}/plans/{vid}/approve") def approve_campaign_plan(cid: int, vid: int, body: dict = None, s: Session = Depends(get_session), request: Request = None): campaign = _require_campaign(s, cid, request, "review") v = s.get(CampaignPlanVersion, vid) if not v or v.campaign_id != cid: raise HTTPException(404, "计划版本不存在") note = str((body or {}).get("review_note") or "")[:1000] acknowledge_risks = bool((body or {}).get("acknowledge_risks")) if any(str(risk.get("severity") or "") == "high" for risk in (v.risks or [])) and not acknowledge_risks: raise HTTPException(409, "该计划存在高风险项,请在工作台逐项核对并显式确认后再批准") user = request_user(request) try: v = planner.approve_plan(s, vid, reviewer_user_id=int(getattr(user, "id", 0) or 0), review_note=note) except ValueError as exc: raise HTTPException(409, str(exc)) from exc _audit(s, request, "campaign_plan.approve", f"/api/campaigns/{cid}/plans/{vid}") return {"status": "approved", "plan": _plan_payload(v, include_plan=False)} @router.post("/api/campaigns/{cid}/plans/{vid}/reject") def reject_campaign_plan(cid: int, vid: int, body: dict = None, s: Session = Depends(get_session), request: Request = None): campaign = _require_campaign(s, cid, request, "review") v = s.get(CampaignPlanVersion, vid) if not v or v.campaign_id != cid: raise HTTPException(404, "计划版本不存在") note = str((body or {}).get("review_note") or "")[:1000] user = request_user(request) try: v = planner.reject_plan(s, vid, reviewer_user_id=int(getattr(user, "id", 0) or 0), review_note=note) except ValueError as exc: raise HTTPException(409, str(exc)) from exc _audit(s, request, "campaign_plan.reject", f"/api/campaigns/{cid}/plans/{vid}") return {"status": "rejected", "plan": _plan_payload(v, include_plan=False)}