"""Unified project API. Every resource read/write derives scope from its project.""" import base64 from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File from fastapi.responses import Response from pydantic import BaseModel, Field, ConfigDict from api.risk_agent_access import authorize, check_project from services.project_assistant import ProjectAssistantService from services.assistant_store import Conflict, listing, get, put, event, digest, uid, now from services import component_registry as registry from services.assistant_settings import flags def enabled(): if not flags()["assistant"]: raise HTTPException(503, "新项目助手已暂停;旧工作台和历史入口保持可用") router = APIRouter(prefix="/api/xingbang/risk-agent/project-assistant", dependencies=[Depends(authorize), Depends(enabled)], tags=["兴邦项目助手"]) def service(): return ProjectAssistantService() def guard(request, project_id): check_project(request, project_id) if project_id in ("DEMO-A", "DEMO-B", "DEMO-C") and not flags()["demo"]: raise HTTPException(403, "演示数据域已禁用") def role(request, allowed): if not request.state.identity.get("preview") and request.state.identity["role"] not in allowed: raise HTTPException(403, "当前角色无权执行此操作") def translate(call): try: return call() except KeyError as exc: raise HTTPException(404, str(exc.args[0])) from exc except Conflict as exc: raise HTTPException(409, str(exc)) from exc except PermissionError as exc: raise HTTPException(403, str(exc)) from exc except ValueError as exc: raise HTTPException(422, str(exc)) from exc class Input(BaseModel): model_config = ConfigDict(extra="forbid") class RunInput(Input): requirement: str = Field(default="", max_length=2000) plan_id: str = "corporate.default" revision: int | None = None feedback_id: str | None = None class SupplementInput(Input): changes: dict = Field(default_factory=dict) finding_ids: list[str] = Field(default_factory=list, max_length=100) candidate_ids: list[str] = Field(default_factory=list, max_length=100) revision: int reason: str = Field(min_length=1, max_length=2000) class ScenarioInput(Input): facility_amount: str = Field(max_length=50) class ReportInput(Input): run_id: str template_id: str = "template.due-diligence" class ActionInput(Input): action: str = Field(max_length=50) revision: int note: str = Field(default="", max_length=2000) payload: dict = Field(default_factory=dict) class FeedbackInput(Input): category: str note: str = Field(min_length=1, max_length=3000) target_kind: str target_id: str class ExperienceInput(Input): title: str = Field(min_length=1, max_length=200) trigger: str = Field(min_length=1, max_length=2000) action: str = Field(min_length=1, max_length=2000) exceptions: str = Field(min_length=1, max_length=2000) feedback_id: str conditions: dict required_field: str exception_field: str scope: str = "project" team: str = "" industry: str = "" expires_at: str @router.get("/projects") def projects(request: Request): allowed = request.state.identity.get("projects", []) items = [p for p in service().list_projects() if ("*" in allowed or p["id"] in allowed) and (flags()["demo"] or p["data_domain"] != "demo")] return {"projects": items, "identity": request.state.identity} @router.get("/projects/{project_id}") def project(project_id: str, request: Request): guard(request, project_id) return translate(lambda: service().get_project(project_id)) @router.post("/projects/{project_id}/runs", status_code=202) def create_run(project_id: str, payload: RunInput, request: Request): guard(request, project_id) key = request.headers.get("idempotency-key", "") if len(key) > 160: raise HTTPException(422, "幂等键过长") return translate(lambda: service().enqueue(project_id, request.state.identity, payload.requirement, payload.plan_id, key, payload.revision, payload.feedback_id)) @router.get("/projects/{project_id}/runs/{run_id}") def run(project_id: str, run_id: str, request: Request): guard(request, project_id) return translate(lambda: service().public_run(service().resource("run", run_id, project_id))) @router.post("/projects/{project_id}/runs/{run_id}/cancel") def cancel(project_id: str, run_id: str, request: Request): guard(request, project_id) return translate(lambda: service().cancel(project_id, run_id, request.state.identity["id"])) @router.get("/projects/{project_id}/snapshots/{snapshot_id}") def snapshot(project_id: str, snapshot_id: str, request: Request): guard(request, project_id) return translate(lambda: service().resource("snapshot", snapshot_id, project_id)) @router.get("/projects/{project_id}/legacy/{kind}/{legacy_id}") def legacy(project_id: str, kind: str, legacy_id: str, request: Request): guard(request, project_id) if kind not in ("runs", "snapshots", "findings", "scenarios", "reports", "experiences"): raise HTTPException(404, "历史类型不存在") return translate(lambda: service().resource("legacy_archive", kind + ":" + legacy_id, project_id)) @router.post("/projects/{project_id}/documents", status_code=201) async def upload(project_id: str, request: Request, file: UploadFile = File(...)): guard(request, project_id) from services.assistant_materials import LIMIT try: content = await file.read(LIMIT + 1) finally: await file.close() # Parsing is synchronous CPU work; do not block the ASGI event loop. from starlette.concurrency import run_in_threadpool return await run_in_threadpool(lambda: translate(lambda: service().upload(project_id, file.filename or "material", content, request.state.identity["id"]))) @router.get("/projects/{project_id}/documents/{document_id}") def document(project_id: str, document_id: str, request: Request): guard(request, project_id) return translate(lambda: {k: v for k, v in service().resource("document", document_id, project_id).items() if k != "raw_base64"}) @router.get("/projects/{project_id}/documents/{document_id}/download") def download(project_id: str, document_id: str, request: Request): guard(request, project_id) body = translate(lambda: service().resource("document", document_id, project_id)) return Response(base64.b64decode(body["raw_base64"]), media_type="application/octet-stream", headers={"Content-Disposition": "attachment; filename*=UTF-8''" + quote(body["name"]), "X-Content-Type-Options": "nosniff"}) @router.post("/projects/{project_id}/supplements", status_code=201) def supplement(project_id: str, payload: SupplementInput, request: Request): guard(request, project_id) return translate(lambda: service().supplement(project_id, request.state.identity["id"], payload.changes, payload.finding_ids, payload.revision, payload.reason, payload.candidate_ids)) @router.post("/projects/{project_id}/findings/{finding_id}/actions") def finding_action(project_id: str, finding_id: str, payload: ActionInput, request: Request): guard(request, project_id) return translate(lambda: service().finding_action(project_id, finding_id, request.state.identity["id"], payload.action, payload.note, payload.revision)) @router.post("/projects/{project_id}/scenarios", status_code=201) def scenario(project_id: str, payload: ScenarioInput, request: Request): guard(request, project_id) return translate(lambda: service().compare_scenario(project_id, payload.facility_amount, request.state.identity["id"])) @router.post("/projects/{project_id}/scenarios/{scenario_id}/adopt") def adopt(project_id: str, scenario_id: str, payload: ActionInput, request: Request): guard(request, project_id) return translate(lambda: service().adopt_scenario(project_id, scenario_id, request.state.identity["id"], payload.revision, payload.note)) @router.post("/projects/{project_id}/reports", status_code=201) def report(project_id: str, payload: ReportInput, request: Request): guard(request, project_id) key = request.headers.get("idempotency-key", "") if len(key) > 160: raise HTTPException(422, "幂等键过长") return translate(lambda: service().create_report(project_id, payload.run_id, request.state.identity["id"], payload.template_id, key)) @router.post("/projects/{project_id}/reports/{report_id}/actions") def report_action(project_id: str, report_id: str, payload: ActionInput, request: Request): guard(request, project_id) if payload.action == "finalize": role(request, ("reviewer",)) return translate(lambda: service().report_action(project_id, report_id, request.state.identity["id"], payload.action, payload.payload, payload.revision)) @router.get("/projects/{project_id}/reports/{report_id}/export") def report_export(project_id: str, report_id: str, request: Request): guard(request, project_id) content = translate(lambda: service().export_report(project_id, report_id)) return Response(content, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f'attachment; filename="{report_id}.docx"', "X-Content-Type-Options": "nosniff"}) @router.post("/projects/{project_id}/feedback", status_code=201) def feedback(project_id: str, payload: FeedbackInput, request: Request): guard(request, project_id) return translate(lambda: service().feedback(project_id, request.state.identity["id"], payload.model_dump())) @router.post("/projects/{project_id}/experiences", status_code=201) def experience(project_id: str, payload: ExperienceInput, request: Request): guard(request, project_id) if payload.scope == "team" and not request.state.identity.get("preview") and payload.team not in request.state.identity.get("teams", []): raise HTTPException(403, "无权向该团队提交经验") return translate(lambda: service().save_experience(project_id, request.state.identity["id"], payload.model_dump())) @router.post("/projects/{project_id}/experiences/{experience_id}/actions") def experience_action(project_id: str, experience_id: str, payload: ActionInput, request: Request): guard(request, project_id) return translate(lambda: service().experience_action(project_id, experience_id, request.state.identity, payload.action, payload.revision, payload.note)) @router.get("/components") def components(request: Request): role(request, ("admin", "reviewer")) with service().connect() as db: return {"published": [registry.resolve(db, r["id"]) for r in listing(db, "release")], "drafts": listing(db, "component_draft"), "versions": listing(db, "component_version")} @router.post("/components") def component_draft(payload: dict, request: Request): role(request, ("admin",)) def save(): with service().connect() as db: db.execute("BEGIN IMMEDIATE") return registry.draft(db, payload, request.state.identity["id"]) return translate(save) @router.post("/components/{draft_id}/actions") def component_action(draft_id: str, payload: ActionInput, request: Request): role(request, ("admin", "reviewer")) def action(): if not flags()["evaluation"]: raise HTTPException(503, "候选评测与发布已暂停") from services.component_evaluation import evaluate with service().connect() as db: db.execute("BEGIN IMMEDIATE") if payload.action in ("evaluate", "publish") and get(db, "component_draft", draft_id)["revision"] != payload.revision: raise Conflict("组件草稿已更新,请刷新") if payload.action == "evaluate": return evaluate(db, draft_id, request.state.identity["id"]) role(request, ("reviewer",)) if payload.action == "publish": return registry.release(db, draft_id, request.state.identity["id"], request.state.identity.get("preview")) if payload.action == "rollback": old = get(db, "component_version", draft_id) result = put(db, "release", {"id": old["component_id"], "version_id": old["id"], "scope": old["scope"]}) event(db, "", "component.rollback", request.state.identity["id"], {"version_id": draft_id, "note": payload.note}) return result raise ValueError("不支持的组件动作") return translate(action) class ResetInput(Input): confirmation: str @router.post("/demo/reset") def reset_demo(payload: ResetInput, request: Request): role(request, ("admin",)) for project_id in ("DEMO-A", "DEMO-B", "DEMO-C"): guard(request, project_id) return translate(lambda: service().reset_demo(request.state.identity["id"], payload.confirmation))