Files
group_fqcd_jr/app/service/admin_service.py
T

454 lines
22 KiB
Python
Raw Normal View History

2026-09-09 21:55:37 +08:00
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.cursor import parse_cursor
from app.core.errors import (
GenericResourceNotFoundError,
InvalidStateError,
ResourceAlreadyExistsError,
ResourceVersionConflictError,
ValidationAgentError,
)
2026-09-09 21:55:37 +08:00
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",
}
# 行情 namespace 的允许字段。必须与 `FundQuoteRuntimeConfig.from_mapping` 的
# 接受范围保持一致,否则会出现"配置能发布、运行期却被静默忽略"的假成功。
# 该 namespace 由 `RuntimeConfigService.fund_quote` 以
# namespace=fund_market / config_key=default 读取。
FUND_MARKET_FIELDS = frozenset({"allowed_codes", "intraday_cache_ttl", "closing_cache_ttl"})
def validate_fund_market(values: dict[str, Any]) -> None:
"""校验行情配置取值。
行情配置不是 1-100 的整数表:`allowed_codes` 是六位代码字符串列表,
TTL 上限 86400 与运行期接受范围一致(`FundQuoteRuntimeConfig`)。
"""
codes = values.get("allowed_codes", [])
if not isinstance(codes, list) or not all(
isinstance(code, str) and len(code) == 6 and code.isdigit() for code in codes
):
raise ValidationAgentError("行情基金代码必须为六位数字字符串列表")
for name in ("intraday_cache_ttl", "closing_cache_ttl"):
if name not in values:
continue
ttl = values[name]
if type(ttl) is not int or not 1 <= ttl <= 86400:
raise ValidationAgentError(f"{name} 必须为 1-86400 之间的整数")
2026-09-09 21:55:37 +08:00
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,
cursor: str | None = None,
2026-09-09 21:55:37 +08:00
) -> dict[str, Any]:
permission = "audit:read" if resource == "audit-records" else "config:read"
await AuthorizationService.require(context, permission, admin=True)
# 游标校验必须在权限闸门**之后**:未授权调用一律 403,不能因为参数格式先漏出
# 一个 400——响应差异本身就是一条越权探测信号(文档 §4.2)。校验同时早于
# 任何数据库访问,非法游标不会变成一次静默的全量查询。
before = parse_cursor(cursor)
2026-09-09 21:55:37 +08:00
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 GenericResourceNotFoundError("资源不存在")
2026-09-09 21:55:37 +08:00
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 GenericResourceNotFoundError("资源不存在")
2026-09-09 21:55:37 +08:00
if if_match is None or if_match.strip('"') != digest(existing):
raise ResourceVersionConflictError("If-Match 版本不一致")
2026-09-09 21:55:37 +08:00
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 ResourceAlreadyExistsError("资源唯一性、引用或状态约束冲突") from exc
2026-09-09 21:55:37 +08:00
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 InvalidStateError("只能编辑草稿或停用版本")
2026-09-09 21:55:37 +08:00
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 InvalidStateError("只能修改草稿发布批次")
2026-09-09 21:55:37 +08:00
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"},
"fund_market": set(FUND_MARKET_FIELDS),
2026-09-09 21:55:37 +08:00
}
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":
# 允许创建人自审(不再要求 reviewer 与 created_by 不同);仍然强制审核节点。
2026-09-11 16:57:47 +08:00
# 自审同样如实写入 reviewer_id:该列含义就是"审核人",迁移
2026-09-11 17:26:18 +08:00
# 20260910_drop_review_separation / 20260911_drop_review_separation
# 已撤下"审核人≠创建人"的检查约束(两条并行分支各补一次,均为幂等实现)。
if row["status"] != "pending_review":
raise InvalidStateError("只能驳回待审核版本")
2026-09-09 21:55:37 +08:00
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 resource == "agent-intent-configs":
return await self._transition_intent_config(repo, row, action, values, actor, now)
2026-09-09 21:55:37 +08:00
if action == "reviews":
# 审核节点不可跳过(必须先是 draft);但允许创建人自审。
if row["status"] != "draft":
raise InvalidStateError("只能审核草稿版本")
2026-09-09 21:55:37 +08:00
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 InvalidStateError("版本未审核")
2026-09-09 21:55:37 +08:00
update_values = {"status": "active"}
elif action == "disablements":
if row["status"] != "active":
raise InvalidStateError("只能停用激活版本")
2026-09-09 21:55:37 +08:00
update_values = {"status": "disabled"}
else:
raise ValidationAgentError("未知状态操作")
update_values["updated_at"] = now
return await repo.update(RESOURCES[resource], row["id"], update_values)
async def _transition_intent_config(
self,
repo: PlatformRepository,
row: dict[str, Any],
action: str,
values: dict[str, Any],
actor: int,
now: datetime,
) -> dict[str, Any]:
"""意图配置自己的状态机:`draft -> approved -> active -> archived`。
`agent_intent_config.status` 有 CHECK 约束,只允许
`draft/approved/active/archived`(**没有 `disabled`**),所以这里不能套用
`model-endpoints` 的 `disablements` 语义,归档落到 `archived`。运行期仍以
`status='active'` 判定生效(`RuntimeConfigService.active_intents`),口径一致。
同一 `agent_type:intent_code` 只能有一个 active 版本(生成列唯一键
`uk_intent_config_active_one`),激活前先把旧版本归档,语义与
`ConfigReleaseService.activate` 挂起上一个生效版本一致。
"""
if action == "reviews":
if row["status"] != "draft":
raise InvalidStateError("只能审核草稿意图配置")
update_values: dict[str, Any] = {
"status": "approved" if values["decision"] == "approved" else "draft",
"reviewer_id": actor,
"reviewed_at": now,
}
elif action == "activations":
if row["status"] != "approved" or row["reviewer_id"] is None:
raise InvalidStateError("意图配置未审核")
expire_at = row.get("expire_at")
if isinstance(expire_at, datetime) and expire_at <= now:
raise InvalidStateError("意图配置已过期,不能激活")
# ⚠️ 过滤条件**必须带 `intent_code`**:唯一键是生成列
# `active_key = concat(agent_type, ':', intent_code)`,即同一 `agent_type` 下
# **不同意图码可以同时 active**(风控的 4 个意图本就并存)。
# 只按 `agent_type` 过滤会把同一 Agent 的**其他意图一起归档** ——
# 曾因此让风控只剩 `general` 一条 active,另外三条被静默归档,
# "查看风险概览 / 查询预警证据"这类问法再也分不到意图,且没有任何报错。
for previous in await repo.rows(
"agent_intent_config",
{
"agent_type": row["agent_type"],
"intent_code": row["intent_code"],
"status": "active",
},
limit=10,
):
if previous["id"] != row["id"]:
await repo.update(
"agent_intent_config",
previous["id"],
{"status": "archived", "updated_at": now},
)
update_values = {"status": "active"}
if row.get("effective_at") is None:
update_values["effective_at"] = now
elif action == "archivals":
if row["status"] != "active":
raise InvalidStateError("只能归档生效版本")
update_values = {"status": "archived"}
else:
raise ValidationAgentError("未知状态操作")
update_values["updated_at"] = now
return await repo.update("agent_intent_config", row["id"], update_values)
2026-09-09 21:55:37 +08:00
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,
)
)