from datetime import UTC, date, datetime from decimal import Decimal from typing import Any from uuid import uuid4 from sqlalchemy import delete from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.core.contracts import RequestContext from app.core.errors import ConflictAgentError, ResourceNotFoundError, ValidationAgentError from app.infrastructure.db import SessionFactory from app.model.audit import InteractionAudit from app.model.platform import DomainEventOutbox from app.repository.platform_repository import PlatformRepository from app.service.agent.bootstrap import get_agent_factory from app.service.api_transaction_service import ApiTransactionService, digest from app.service.authorization_service import AuthorizationService from app.service.config_release_service import ConfigReleaseService RESOURCES = { "config-releases": "config_release", "platform-config-items": "platform_config_item", "model-endpoints": "model_endpoint_config", "model-routing-rules": "model_routing_rule", "prompt-templates": "prompt_template_version", "agent-intent-configs": "agent_intent_config", "reply-templates": "agent_reply_template", "negative-word-rules": "agent_negative_word", "audit-records": "interaction_audit", } def public(value: Any, key: str = "") -> Any: if isinstance(value, dict): return { ("item_key" if k == "config_key" else k): public(v, k) for k, v in value.items() if k not in {"secret_ref", "fallback_endpoint_ids", "active_slot", "active_key"} } if isinstance(value, list): return [public(item) for item in value] if isinstance(value, datetime): return value.isoformat() + ("Z" if value.tzinfo is None else "") if isinstance(value, (Decimal, date)): return str(value) if isinstance(value, int) and (key == "id" or key.endswith("_id") or key == "created_by"): return str(value) return value class AdminService: async def query( self, resource: str, context: RequestContext, row_id: int | None = None, release_id: int | None = None, limit: int = 20, before: int | None = None, ) -> dict[str, Any]: permission = "audit:read" if resource == "audit-records" else "config:read" await AuthorizationService.require(context, permission, admin=True) async with SessionFactory() as session: repo = PlatformRepository(session) filters = {"release_id": release_id} if release_id is not None else {} if row_id is not None: row = await repo.get(RESOURCES[resource], row_id) if row is None or any(row[k] != v for k, v in filters.items()): raise ResourceNotFoundError("资源不存在") return { "data": public(row), "meta": {"trace_id": context.trace_id, "etag": digest(row)}, } rows = await repo.rows(RESOURCES[resource], filters, before=before, limit=limit) if resource == "audit-records" and "audit:read-sensitive" not in context.permissions: rows = [{**row, "detail": {"redacted": True}} for row in rows] return {"data": public(rows), "meta": {"trace_id": context.trace_id}} async def mutate( self, resource: str, context: RequestContext, values: dict[str, Any], key: str | None, if_match: str | None, *, row_id: int | None = None, release_id: int | None = None, action: str = "write", ) -> dict[str, Any]: permission = "model-endpoint:manage" if resource == "model-endpoints" else "config:write" if action == "reviews": permission = "config:review" elif action in {"activations", "rollbacks"} and resource == "config-releases": permission = "config:activate" await AuthorizationService.require(context, permission, admin=True) async def operation(session: AsyncSession) -> dict[str, Any]: repo = PlatformRepository(session) table = RESOURCES[resource] existing = await repo.get(table, row_id, lock=True) if row_id is not None else None if row_id is not None: if existing is None or ( release_id is not None and existing.get("release_id") != release_id ): raise ResourceNotFoundError("资源不存在") if if_match is None or if_match.strip('"') != digest(existing): raise ConflictAgentError("RESOURCE_VERSION_CONFLICT") if action != "write": assert existing is not None row = await self._transition(repo, resource, existing, action, values, context) else: row = await self._write(repo, resource, existing, values, release_id, context) session.add( InteractionAudit( actor_type="user", actor_id=int(context.user_id), portal="admin", action_type=f"platform.{resource}.{action}", detail={ "resource_id": row["id"], "trace_id": context.trace_id, "before_hash": digest(existing) if existing else None, "after_hash": digest(row), }, created_at=datetime.now(UTC).replace(tzinfo=None), ) ) return { "data": public(row), "meta": {"trace_id": context.trace_id, "etag": digest(row)}, } try: return await ApiTransactionService().execute( context, f"admin:{resource}:{row_id}:{release_id}:{action}", key, {"body": values, "if_match": if_match}, operation, ) except IntegrityError as exc: raise ConflictAgentError("资源唯一性、引用或状态约束冲突") from exc async def _write( self, repo: PlatformRepository, resource: str, existing: dict[str, Any] | None, values: dict[str, Any], release_id: int | None, context: RequestContext, ) -> dict[str, Any]: data = dict(values) if existing is not None and existing.get("status", "draft") not in {"draft", "disabled"}: raise ConflictAgentError("只能编辑草稿或停用版本") if release_id is not None: data["release_id"] = release_id if "release_id" in data: parent = await repo.get("config_release", int(data["release_id"]), lock=True) if parent is None or parent["status"] != "draft": raise ConflictAgentError("只能修改草稿发布批次") if resource == "platform-config-items": data["config_key"] = data.pop("item_key") allowed = { "memory": {"recall_limit", "decay_days"}, "relationship": {"max_hops", "limit"}, "runtime": {"timeout_seconds"}, "portfolio_analysis": { "single_product_concentration_pct", "single_industry_concentration_pct", "min_industry_coverage_pct", }, "agent_tools": {"allowed_tools"}, } if set(data["value_json"]) - allowed[data["namespace"]]: raise ValidationAgentError("配置包含未声明字段") if data["namespace"] == "agent_tools": raw = data["value_json"].get("allowed_tools", []) if not isinstance(raw, list) or not all(isinstance(v, str) for v in raw): raise ValidationAgentError("工具白名单必须为字符串列表") agent_type, separator, intent = data["config_key"].partition(":") definition = get_agent_factory().definition(agent_type) if not separator or intent not in definition.supported_intents: raise ValidationAgentError("意图不在 Agent 声明内") if not set(raw) <= set(definition.allowed_tools): raise ValidationAgentError("配置超出 Agent 工具上限") else: for value in data["value_json"].values(): if type(value) is not int or not 1 <= value <= 100: raise ValidationAgentError("配置值须为 1-100 整数") data["checksum"] = digest(data["value_json"]) if resource == "model-endpoints": if "@" in data["base_url"] or "?" in data["base_url"]: raise ValidationAgentError("端点地址不得包含凭证或查询参数") if resource == "agent-intent-configs": definition = get_agent_factory().definition(data["agent_type"]) if data["intent_code"] not in definition.supported_intents: raise ValidationAgentError("意图未注册") if not set(data["allowed_tools"]) <= set(definition.allowed_tools): raise ValidationAgentError("工具超出代码上限") fallbacks = data.pop("fallbacks", []) if resource == "model-routing-rules": await self._validate_routing(repo, data, fallbacks) if resource == "prompt-templates": data["checksum"] = digest(data) table = await repo.table(RESOURCES[resource]) if "created_by" in table.c and existing is None: data["created_by"] = int(context.user_id) if "status" in table.c: data["status"] = "draft" if "reviewer_id" in table.c: data.update(reviewer_id=None, reviewed_at=None) if "updated_at" in table.c: data["updated_at"] = datetime.now(UTC).replace(tzinfo=None) row = ( await repo.update(RESOURCES[resource], existing["id"], data) if existing else await repo.create(RESOURCES[resource], data) ) if resource == "model-routing-rules": fallback_table = await repo.table("model_routing_fallback") # Draft child relationships may be replaced; no historical column/schema mutation. await repo.session.execute( delete(fallback_table).where(fallback_table.c.routing_rule_id == row["id"]) ) for fallback in fallbacks: await repo.create( "model_routing_fallback", {**fallback, "routing_rule_id": row["id"]} ) row["fallbacks"] = fallbacks return row async def _validate_routing( self, repo: PlatformRepository, data: dict[str, Any], fallbacks: list[dict[str, Any]] ) -> None: get_agent_factory().definition(data["agent_type"]) ids = [data["primary_endpoint_id"], *(item["endpoint_id"] for item in fallbacks)] orders = [item["fallback_order"] for item in fallbacks] if len(ids) != len(set(ids)) or sorted(orders) != list(range(1, len(orders) + 1)): raise ValidationAgentError("备用端点重复或顺序不连续") if data["max_attempts"] > len(ids): raise ValidationAgentError("重试次数超过端点数量") for endpoint_id in ids: endpoint = await repo.get("model_endpoint_config", endpoint_id, lock=True) if endpoint is None or endpoint["status"] != "active": raise ValidationAgentError("模型端点不存在或未激活") async def _transition( self, repo: PlatformRepository, resource: str, row: dict[str, Any], action: str, values: dict[str, Any], context: RequestContext, ) -> dict[str, Any]: actor = int(context.user_id) now = datetime.now(UTC).replace(tzinfo=None) if resource == "config-releases": service = ConfigReleaseService(repo.session) if action == "validations": await self._validate_release(repo, row["id"]) result = await service.submit_for_review(row["id"], actor) elif action == "reviews": if values["decision"] == "rejected": if row["status"] != "pending_review" or row["created_by"] == actor: raise ConflictAgentError("不满足双人审核条件") return await repo.update( "config_release", row["id"], {"status": "rejected", "reviewer_id": actor, "reviewed_at": now}, ) result = await service.approve(row["id"], actor) elif action == "activations": await self._validate_release(repo, row["id"]) result = await service.activate(row["id"], actor) elif action == "rollbacks": result = await service.rollback(row["id"], actor) await self._copy_release(repo, row["id"], result.id) else: raise ValidationAgentError("未知发布操作") refreshed = await repo.get("config_release", result.id) assert refreshed is not None return refreshed if action == "reviews": if row["status"] != "draft" or row["created_by"] == actor: raise ConflictAgentError("不满足双人审核条件") status = "approved" if values["decision"] == "approved" else "draft" update_values = {"status": status, "reviewer_id": actor, "reviewed_at": now} elif action == "activations": if row["status"] != "approved" or row["reviewer_id"] is None: raise ConflictAgentError("版本未审核") update_values = {"status": "active"} elif action == "disablements": if row["status"] != "active": raise ConflictAgentError("只能停用激活版本") update_values = {"status": "disabled"} else: raise ValidationAgentError("未知状态操作") update_values["updated_at"] = now return await repo.update(RESOURCES[resource], row["id"], update_values) async def _validate_release(self, repo: PlatformRepository, release_id: int) -> None: rules = await repo.rows("model_routing_rule", {"release_id": release_id}, limit=10000) for rule in rules: fallbacks = await repo.rows( "model_routing_fallback", {"routing_rule_id": rule["id"]}, limit=3 ) await self._validate_routing(repo, rule, fallbacks) # Checksums catch out-of-band draft edits before approval/activation. for item in await repo.rows( "platform_config_item", {"release_id": release_id}, limit=10000 ): if item["checksum"] != digest(item["value_json"]): raise ValidationAgentError("配置校验和不一致") async def _copy_release(self, repo: PlatformRepository, source: int, target: int) -> None: for name in ("platform_config_item", "prompt_template_version", "model_routing_rule"): for row in await repo.rows(name, {"release_id": source}, limit=10000): old_id = row.pop("id") row.pop("created_at", None) row["release_id"] = target copied = await repo.create(name, row) if name == "model_routing_rule": for fallback in await repo.rows( "model_routing_fallback", {"routing_rule_id": old_id}, limit=3 ): fallback.pop("id") fallback.pop("created_at", None) fallback["routing_rule_id"] = copied["id"] await repo.create("model_routing_fallback", fallback) now = datetime.now(UTC).replace(tzinfo=None) repo.session.add( DomainEventOutbox( event_id=str(uuid4()), event_type="config.cache_invalidate_requested", aggregate_type="config_release", aggregate_id=str(target), trace_id=str(uuid4()), payload={"release_id": target}, occurred_at=now, created_at=now, updated_at=now, ) )