Files
group_fqcd_jr/app/service/admin_service.py
T

343 lines
16 KiB
Python

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"},
"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,
)
)