"""CRUD API for the global business → agent / chain mapping (业务链条管理). Binds a fixed business code (3Q / 6BF / 6-1…6-4 / 7BF / 8BF / AFX) to an optional 智能体 and an optional 业务链条 (a **public** ``roundtable_chains`` row). Used to auto-pick the business chain when a task page launches the multi-agent roundtable. Global / shared — there is no user scoping; all accounts see and edit the same rows. Fixed business rows are seeded on startup; this API only lists them and updates the agent / chain columns (no create/delete). Routes (prefix ``/api/business-mapping``): GET "" list all business mappings (display order) PUT "/{business_code}" update one row's agent / chain selection """ from __future__ import annotations import logging from datetime import datetime from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from app.gateway.deps import get_agent_store from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"]) _EIGHTBF_BUSINESS_CODE = "8BF" _EIGHTBF_MANAGER_USERNAME = "lqq" # ── schemas ──────────────────────────────────────────────────────────────── class BusinessMappingResponse(BaseModel): business_code: str label: str = "" agent_id: str | None = None agent_name: str | None = None chain_id: str | None = None chain_title: str | None = None sort_order: int = 0 created_at: datetime | str | None = None updated_at: datetime | str | None = None class BusinessMappingListResponse(BaseModel): mappings: list[BusinessMappingResponse] class BusinessMappingUpdateRequest(BaseModel): # All optional → callers send only what changed; explicit null clears a field. agent_id: str | None = Field(default=None, max_length=64) agent_name: str | None = Field(default=None, max_length=191) chain_id: str | None = Field(default=None, max_length=64) chain_title: str | None = Field(default=None, max_length=512) # ── helpers ────────────────────────────────────────────────────────────── def _get_store(request: Request): store = getattr(request.app.state, "business_mapping_store", None) if store is None: raise HTTPException(status_code=503, detail="Business mapping store not available") return store def _is_admin(request: Request) -> bool: """Treat no-auth deployments as the single administrative workspace.""" user = getattr(request.state, "user", None) return user is None or getattr(user, "system_role", None) == "admin" def _username_of(request: Request) -> str: """Read the trusted login account name, not an externally supplied username.""" user = getattr(request.state, "user", None) email = str(getattr(user, "email", "") or "") return email.split("@", 1)[0].strip().lower() def _can_update_mapping(request: Request, business_code: str) -> bool: """8BF's public chain is maintained exclusively by the lqq account.""" if business_code.strip().upper() == _EIGHTBF_BUSINESS_CODE: return _username_of(request) == _EIGHTBF_MANAGER_USERNAME return _is_admin(request) def _current_user_id(request: Request) -> str: user = getattr(request.state, "user", None) if user is not None: return str(user.id) return get_effective_user_id() async def _validate_public_chain_mapping( request: Request, fields: dict[str, object], ) -> None: """Require globally mapped chains to remain readable by every account.""" if "chain_id" not in fields: return raw_chain_id = fields["chain_id"] chain_id = raw_chain_id.strip() if isinstance(raw_chain_id, str) else "" if not chain_id: fields["chain_id"] = None fields["chain_title"] = None return chain_store = getattr(request.app.state, "roundtable_chain_store", None) if chain_store is None: raise HTTPException(status_code=503, detail="Business chain store not available") chain = await chain_store.get_chain( chain_id, _current_user_id(request), is_admin=True, ) if chain is None: raise HTTPException(status_code=400, detail="Business chain not found") if not chain.get("is_public"): raise HTTPException( status_code=400, detail="Global business mappings require a public business chain", ) # 公共业务映射会被任意账号启动,链条中的席位也必须对任意账号可见。 # 仅把链条本身发布、却把其中某个自定义智能体保留为未发布/私有,会导致 # 前端只能解析到不足两个席位,最终被误判为“没有可用业务链”。 seats = chain.get("seats") if isinstance(seats, list): agent_store = get_agent_store(request) unavailable_names: list[str] = [] for raw_seat in seats: if not isinstance(raw_seat, dict): continue agent_id = str(raw_seat.get("agent_id") or "").strip() if not agent_id: continue agent = await agent_store.get_any(agent_id) is_builtin = agent is not None and agent.get("user_id") is None is_public_agent = bool(agent and agent.get("published")) and not bool( agent and agent.get("is_private") ) if is_builtin or is_public_agent: continue name = str(raw_seat.get("name") or agent_id).strip() unavailable_names.append(name) if unavailable_names: raise HTTPException( status_code=400, detail=( "Global business mappings require every seat to be published and public; " f"unavailable agents: {', '.join(unavailable_names)}" ), ) fields["chain_id"] = chain_id fields["chain_title"] = str(chain.get("title") or "") or None # ── routes ─────────────────────────────────────────────────────────────── @router.get("", response_model=BusinessMappingListResponse) async def list_mappings(request: Request) -> BusinessMappingListResponse: store = _get_store(request) rows = await store.list_mappings() return BusinessMappingListResponse(mappings=[BusinessMappingResponse(**r) for r in rows]) @router.put("/{business_code}", response_model=BusinessMappingResponse) async def update_mapping(request: Request, business_code: str, body: BusinessMappingUpdateRequest) -> BusinessMappingResponse: if not _can_update_mapping(request, business_code): detail = "Only the lqq account can update the 8BF business mapping" if business_code.strip().upper() == _EIGHTBF_BUSINESS_CODE else "Only administrators can update business mappings" raise HTTPException(status_code=403, detail=detail) store = _get_store(request) # Only forward fields the caller explicitly sent (distinguish omitted vs null). fields = body.model_dump(exclude_unset=True) await _validate_public_chain_mapping(request, fields) row = await store.update_mapping(business_code, **fields) if row is None: raise HTTPException(status_code=404, detail="Business mapping not found") return BusinessMappingResponse(**row)