From a769130658d85b0e5e636a4334b58b0ae1e8be43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=83=9C=E5=AE=87?= <17412268+zzzzz11122222@user.noreply.gitee.com> Date: Fri, 11 Sep 2026 17:47:06 +0800 Subject: [PATCH] feat: add profile candidate review workflow --- app/api/controllers/admin.py | 22 +++ app/api/controllers/public_platform.py | 30 ++- .../customer_profile_candidate_service.py | 180 ++++++++++++++++++ docs/05-接口文档.md | 25 ++- docs/客服Agent二期_客户画像候选流程_v1.md | 44 +++-- ...test_customer_profile_candidate_service.py | 144 ++++++++++++++ tools/seed_test_rbac.py | 6 +- 7 files changed, 437 insertions(+), 14 deletions(-) create mode 100644 app/service/customer_profile_candidate_service.py create mode 100644 tests/unit/service/test_customer_profile_candidate_service.py diff --git a/app/api/controllers/admin.py b/app/api/controllers/admin.py index cfaf040..41e6302 100644 --- a/app/api/controllers/admin.py +++ b/app/api/controllers/admin.py @@ -19,6 +19,7 @@ from app.api.schemas.admin import ( ) from app.core.contracts import RequestContext from app.service.admin_service import AdminService +from app.service.customer_profile_candidate_service import CustomerProfileCandidateService from app.service.customer_service_handover_admin_service import CustomerServiceHandoverAdminService router = APIRouter(prefix="/api/v1/admin", tags=["platform-admin"], @@ -152,3 +153,24 @@ async def get_customer_service_handover_ticket( ) -> dict[str, Any]: """只读查看单个工单的脱敏转接摘要。""" return await CustomerServiceHandoverAdminService().get_ticket(ticket_no, context) + + +@router.get("/customer-profile-candidates") +async def list_customer_profile_candidates( + limit: int = Query(default=20, ge=1, le=100), + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, Any]: + """管理员查看待确认或待审核的画像候选。""" + return await CustomerProfileCandidateService().list_for_admin(context, limit=limit) + + +@router.post("/customer-profile-candidates/{candidate_id}/reviews", status_code=200) +async def review_customer_profile_candidate( + candidate_id: int, + payload: ReviewPayload, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, Any]: + """管理员批准或驳回候选;批准会处理同键旧正式记忆。""" + return await CustomerProfileCandidateService().review_by_admin( + candidate_id, payload.decision, context, comment=payload.comment + ) diff --git a/app/api/controllers/public_platform.py b/app/api/controllers/public_platform.py index 782bffe..9463599 100644 --- a/app/api/controllers/public_platform.py +++ b/app/api/controllers/public_platform.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, Header from pydantic import Field @@ -22,6 +22,10 @@ class Cancellation(StrictPayload): reason: str = Field(default="user_cancelled", max_length=128) +class CandidateDecisionPayload(StrictPayload): + decision: Literal["confirmed", "rejected"] + + @router.post("/conversations", status_code=201) async def create_session( payload: SessionCreate, @@ -90,3 +94,27 @@ async def customer_memory( customer_id: int, context: RequestContext = Depends(build_request_context), # noqa: B008 ) -> dict[str, Any]: return await PublicPlatformService().memory(customer_id, context) + + +@router.get("/users/me/memory-candidates") +async def my_memory_candidates( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, Any]: + """返回当前用户可确认的画像候选,不返回证据原文。""" + from app.service.customer_profile_candidate_service import CustomerProfileCandidateService + + return await CustomerProfileCandidateService().list_for_customer(context) + + +@router.post("/users/me/memory-candidates/{candidate_id}/decisions") +async def decide_memory_candidate( + candidate_id: int, + payload: CandidateDecisionPayload, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, Any]: + """用户确认或拒绝自己的候选;确认后仍需管理员审核才能激活。""" + from app.service.customer_profile_candidate_service import CustomerProfileCandidateService + + return await CustomerProfileCandidateService().decide_by_customer( + candidate_id, payload.decision, context + ) diff --git a/app/service/customer_profile_candidate_service.py b/app/service/customer_profile_candidate_service.py new file mode 100644 index 0000000..7555245 --- /dev/null +++ b/app/service/customer_profile_candidate_service.py @@ -0,0 +1,180 @@ +"""客户画像候选的确认、审核与晋升服务。 + +候选与正式记忆共用 ``memory_unit``,但状态转换必须经过本服务;客服 Agent 不具备 +调用权限。用户确认只把候选标记为 ``verified``,管理员批准后才切换为 ``active``。 +""" + +from datetime import UTC, datetime +from typing import Any, Literal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.core.errors import GenericResourceNotFoundError, InvalidStateError +from app.infrastructure.db import SessionFactory +from app.model.audit import InteractionAudit +from app.model.memory import MemoryConflict, MemoryUnit +from app.service.agent.bootstrap import get_memory_cache_adapter +from app.service.authorization_service import AuthorizationService +from app.service.memory_service import MemoryService + +CandidateDecision = Literal["confirmed", "rejected"] +ReviewDecision = Literal["approved", "rejected"] +USER_CONFIRM_PERMISSION = "memory:candidate:confirm" +ADMIN_REVIEW_PERMISSION = "memory:candidate:review" + + +class CustomerProfileCandidateService: + """候选状态机的唯一应用服务入口。""" + + async def list_for_customer( + self, context: RequestContext, *, limit: int = 20 + ) -> dict[str, Any]: + """返回当前登录用户自己的候选,不暴露证据原文或其他客户数据。""" + await AuthorizationService.require(context, "memory:read:self") + customer_id = int(context.user_id) + async with SessionFactory() as session: + rows = await session.scalars( + select(MemoryUnit) + .where( + MemoryUnit.customer_id == customer_id, + MemoryUnit.status.in_(("candidate", "verified")), + ) + .order_by(MemoryUnit.updated_at.desc()) + .limit(max(1, min(limit, 100))) + ) + data = [self._view(item) for item in rows] + return {"data": data, "meta": {"trace_id": context.trace_id}} + + async def list_for_admin( + self, context: RequestContext, *, limit: int = 20 + ) -> dict[str, Any]: + """管理员查看所有待处理候选;列表仍不返回证据原文。""" + await AuthorizationService.require(context, ADMIN_REVIEW_PERMISSION, admin=True) + async with SessionFactory() as session: + rows = await session.scalars( + select(MemoryUnit) + .where(MemoryUnit.status.in_(("candidate", "verified"))) + .order_by(MemoryUnit.updated_at.asc()) + .limit(max(1, min(limit, 100))) + ) + data = [self._view(item) for item in rows] + return {"data": data, "meta": {"trace_id": context.trace_id}} + + async def decide_by_customer( + self, candidate_id: int, decision: CandidateDecision, context: RequestContext + ) -> dict[str, Any]: + """用户确认或拒绝自己的候选;确认不会直接激活正式记忆。""" + await AuthorizationService.require(context, USER_CONFIRM_PERMISSION) + customer_id = int(context.user_id) + target_status = "verified" if decision == "confirmed" else "rejected" + async with SessionFactory() as session, session.begin(): + candidate = await self._locked_candidate(session, candidate_id, customer_id) + if candidate.status != "candidate": + raise InvalidStateError("候选已处理,不能重复确认") + candidate.status = target_status + candidate.updated_at = self._now() + session.add(self._audit( + context, customer_id, "memory.candidate_user_decision", + {"candidate_id": candidate_id, "decision": decision}, + )) + await session.flush() + return {"data": self._view(candidate), "meta": {"trace_id": context.trace_id}} + + async def review_by_admin( + self, candidate_id: int, decision: ReviewDecision, context: RequestContext, + *, comment: str = "", + ) -> dict[str, Any]: + """管理员审核候选;批准时处理同键正式记忆冲突并激活候选。""" + await AuthorizationService.require(context, ADMIN_REVIEW_PERMISSION, admin=True) + async with SessionFactory() as session, session.begin(): + candidate = await self._locked_candidate(session, candidate_id, None) + if candidate.status not in {"candidate", "verified"}: + raise InvalidStateError("候选已处理,不能重复审核") + if decision == "rejected": + candidate.status = "rejected" + else: + await self._promote(session, candidate, int(context.user_id)) + candidate.updated_at = self._now() + session.add(self._audit( + context, candidate.customer_id, "memory.candidate_admin_review", + {"candidate_id": candidate_id, "decision": decision, "comment": comment[:1000]}, + )) + await session.flush() + return {"data": self._view(candidate), "meta": {"trace_id": context.trace_id}} + + async def _locked_candidate( + self, session: AsyncSession, candidate_id: int, customer_id: int | None + ) -> MemoryUnit: + """按身份范围加锁读取候选,找不到时统一隐藏资源存在性。""" + conditions = [MemoryUnit.id == candidate_id] + if customer_id is not None: + conditions.append(MemoryUnit.customer_id == customer_id) + candidate = await session.scalar(select(MemoryUnit).where(*conditions).with_for_update()) + if candidate is None: + raise GenericResourceNotFoundError("候选不存在") + return candidate + + async def _promote( + self, session: AsyncSession, candidate: MemoryUnit, reviewer_id: int + ) -> None: + """同一客户同一键只保留一条 active,旧值失效并留下冲突审计记录。""" + current = await session.scalar( + select(MemoryUnit) + .where( + MemoryUnit.customer_id == candidate.customer_id, + MemoryUnit.memory_key == candidate.memory_key, + MemoryUnit.status == "active", + ) + .with_for_update() + ) + now = self._now() + if current is not None and current.id != candidate.id: + current.status = "invalidated" + current.updated_at = now + session.add(MemoryConflict( + left_memory_id=current.id, right_memory_id=candidate.id, + conflict_type="candidate_promoted", severity="medium", status="resolved", + resolution="管理员审核候选后替换旧正式记忆", winner_memory_id=candidate.id, + resolved_by=reviewer_id, resolved_at=now, created_at=now, + )) + candidate.status = "active" + candidate.promoted_at = now + candidate.version += 1 + await MemoryService(session, cache=get_memory_cache_adapter()).invalidate_recall_cache( + int(candidate.customer_id) + ) + + @staticmethod + def _view(item: MemoryUnit) -> dict[str, Any]: + """只返回结构化候选值,不返回对话证据摘录。""" + return { + "candidate_id": int(item.id), + "customer_id": str(item.customer_id), + "memory_key": item.memory_key, + "value": item.content, + "memory_type": item.memory_type, + "confidence": float(item.confidence), + "status": item.status, + "version": item.version, + "created_at": item.created_at.isoformat(), + "updated_at": item.updated_at.isoformat(), + } + + @staticmethod + def _audit( + context: RequestContext, customer_id: int, action_type: str, detail: dict[str, Any] + ) -> InteractionAudit: + """统一生成候选状态变更审计,不携带证据原文。""" + return InteractionAudit( + actor_type="user", actor_id=int(context.user_id), target_customer_id=customer_id, + session_id=None, portal=context.portal, action_type=action_type, + detail={**detail, "trace_id": context.trace_id}, + created_at=CustomerProfileCandidateService._now(), + ) + + @staticmethod + def _now() -> datetime: + """使用 UTC 无时区值,与现有数据库 DATETIME 字段保持一致。""" + return datetime.now(UTC).replace(tzinfo=None) diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md index 3285e62..4c71ee9 100644 --- a/docs/05-接口文档.md +++ b/docs/05-接口文档.md @@ -615,7 +615,26 @@ Authorization: Bearer 记忆提取没有客户端写接口。`memory.extraction_requested` 由 `complete_run()` 与最终结果在同一事务写入 Outbox,再由 Worker 调用内部 `MemoryService`。更正、遗忘和监管删除属于独立隐私流程,本接口不临时复用 `memory_conflict`。 -### 8.2 解析知识引用 +### 8.2 客服画像候选(Phase 2) + +客服 Agent 不读取或直接修改正式画像。已登录用户明确陈述长期偏好、约束或目标时,系统 +异步生成 `memory_unit.status='candidate'` 候选;访客不会生成候选。候选不进入客服召回, +必须经过用户确认和管理员审核后才能晋升为 `active`。 + +```text +GET /api/v1/users/me/memory-candidates +POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions +GET /api/v1/admin/customer-profile-candidates +POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews +``` + +用户确认请求体为 `{ "decision": "confirmed" | "rejected" }`,需要 +`memory:candidate:confirm`;确认只将状态改为 `verified`。管理员审核请求体复用 +`ReviewPayload`,需要管理员角色和 `memory:candidate:review`;`approved` 会在事务内 +处理同键旧记忆冲突并将候选改为 `active`,`rejected` 将其改为 `rejected`。接口只返回 +结构化候选值,不返回对话证据摘录、密码、验证码或其他原始敏感内容。 + +### 8.3 解析知识引用 ```http GET /api/v1/knowledge-references/{reference_token} @@ -931,6 +950,10 @@ GET /internal/metrics | C007 | `POST /api/v1/conversation-messages/{message_id}/feedback` | `conversation:feedback` | 必须 | `201` | 反馈创建 | | M001 | `GET /api/v1/users/me/memory-profile` | `memory:read:self` | 否 | `200` | 敏感访问 | | M002 | `GET /api/v1/customers/{customer_id}/memory-profile` | `memory:read:customer` | 否 | `200` | 敏感访问 | +| M003 | `GET /api/v1/users/me/memory-candidates` | `memory:read:self` | 否 | `200` | 候选查询 | +| M004 | `POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions` | `memory:candidate:confirm` | 必须 | `200` | 用户确认/拒绝 | +| A034 | `GET /api/v1/admin/customer-profile-candidates` | `memory:candidate:review` | 否 | `200` | 候选审核列表 | +| A035 | `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews` | `memory:candidate:review` | 必须 | `200` | 候选审核 | | K001 | `GET /api/v1/knowledge-references/{reference_token}` | `knowledge:reference:read` | 否 | `200` | 否 | | A001 | `POST /api/v1/admin/config-releases` | `config:write` | 必须 | `201` | 配置草稿 | | A002 | `GET /api/v1/admin/config-releases` | `config:read` | 否 | `200` | 否 | diff --git a/docs/客服Agent二期_客户画像候选流程_v1.md b/docs/客服Agent二期_客户画像候选流程_v1.md index 888b24e..657eff2 100644 --- a/docs/客服Agent二期_客户画像候选流程_v1.md +++ b/docs/客服Agent二期_客户画像候选流程_v1.md @@ -2,7 +2,7 @@ 版本:v1.0 适用分支:`ZSY_develop2` -状态:候选提取链路已实现,确认/审核入口待后续迭代 +状态:候选提取、用户确认和管理员审核入口已实现,画像快照同步待后续迭代 ## 1. 业务边界 @@ -22,8 +22,10 @@ -> 二次脱敏 -> 受控模型抽取 memory_key/value/type/confidence -> 写入 memory_unit(status='candidate') + memory_evidence - -> 等待用户确认或管理员审核 - -> 后续流程再决定是否晋升为 active/profile_snapshots + -> 用户确认或拒绝 + -> 管理员审核 + -> 批准后处理同键冲突并晋升为 active + -> 后续流程再生成 profile_snapshots ``` 普通公开问答、闲聊、一次性操作问题不触发候选抽取;访客、非客服 Agent、非 self 数据范围 @@ -58,25 +60,45 @@ - 访客候选事件必须被消费者拒绝,不能仅依赖上游路由判断。 - `MemoryRecallService` 只召回 `active` 状态,因此候选不会进入任何 Agent 的长期记忆上下文。 -## 5. 当前已实现文件 +## 5. 确认与审核接口 + +用户接口: + +- `GET /api/v1/users/me/memory-candidates`:查看自己的 `candidate/verified` 候选。 +- `POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions`:提交 + `confirmed` 或 `rejected`,需要 `memory:candidate:confirm`。 + +管理员接口: + +- `GET /api/v1/admin/customer-profile-candidates`:查看所有待处理候选,需要管理员角色和 + `memory:candidate:review`。 +- `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews`:提交 + `approved` 或 `rejected`,需要管理员角色和 `memory:candidate:review`。 + +用户确认只转换为 `verified`,管理员批准才转换为 `active`。批准时同客户同记忆键的旧 +`active` 记录会失效,并写入 `memory_conflict`,全流程在一个 MySQL 事务内完成。 + +## 6. 当前已实现文件 - `app/service/memory_service.py`:支持候选状态写入,并保证候选不覆盖正式记忆。 - `app/worker/memory_extraction_worker.py`:支持事件类型、状态、来源和脱敏策略配置。 - `app/worker/customer_profile_candidate_worker.py`:已登录客服候选专用消费者。 - `app/service/agent_persistence_service.py`:完成客服运行时写入候选 Outbox 事件。 - `app/worker/runtime.py`:候选触发判定与事件处理器。 +- `app/service/customer_profile_candidate_service.py`:用户确认、管理员审核、冲突处理和晋升。 +- `app/api/controllers/public_platform.py`:用户候选查询和确认接口。 +- `app/api/controllers/admin.py`:管理员候选查询和审核接口。 -## 6. 尚未实现的后续能力 +## 7. 尚未实现的后续能力 -1. 用户确认候选的接口和页面。 -2. 管理员候选列表、审核、驳回和审计接口。 -3. 候选晋升为 `active` 的冲突检测、版本切换和 `profile_snapshots` 生成。 -4. 候选撤回、过期、删除和隐私授权管理。 -5. 候选流程的 MySQL 集成测试和管理员端到端验收。 +1. 前端用户确认页面和管理员审核页面。 +2. 候选晋升后的 `profile_snapshots` 版本生成和 `memory_sync_outbox` 同步。 +3. 候选撤回、过期、删除和隐私授权管理。 +4. 候选流程的 MySQL 集成测试和管理员端到端验收。 在上述能力完成前,禁止把 `candidate` 状态直接作为正式画像对外展示或用于业务决策。 -## 7. 验证结果 +## 8. 验证结果 - 客服画像候选专项测试:通过。 - 一期单元与契约回归:`683 passed`。 diff --git a/tests/unit/service/test_customer_profile_candidate_service.py b/tests/unit/service/test_customer_profile_candidate_service.py new file mode 100644 index 0000000..7da1b3d --- /dev/null +++ b/tests/unit/service/test_customer_profile_candidate_service.py @@ -0,0 +1,144 @@ +"""客户画像候选状态机的权限、输出和状态边界测试。""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import pytest + +from app.core.contracts import RequestContext +from app.core.errors import ForbiddenAgentError, InvalidStateError +from app.model.memory import MemoryUnit +from app.service.customer_profile_candidate_service import ( + ADMIN_REVIEW_PERMISSION, + USER_CONFIRM_PERMISSION, + CustomerProfileCandidateService, +) + +NOW = datetime.now(UTC).replace(tzinfo=None) + + +def candidate(*, status: str = "candidate") -> MemoryUnit: + """构造不含原始证据的候选实体。""" + return MemoryUnit( + id=11, memory_uuid="candidate-11", customer_id=7, + memory_key="preference:risk_level", content="稳健型", + memory_type="preference", source_type="AI对话提取", source_confidence=0.9, + confidence=0.9, evidence_count=1, conflict_count=0, recall_count=0, + status=status, valid_from=NOW, version=1, created_at=NOW, updated_at=NOW, + ) + + +@pytest.mark.asyncio +async def test_customer_confirmation_requires_dedicated_permission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """用户确认不能借用客服运行权限,必须使用候选专用权限。""" + captured: list[tuple[str, bool]] = [] + + class RecordingAuthorization: + @staticmethod + async def require(context: RequestContext, permission: str, *, admin: bool = False) -> None: + del context + captured.append((permission, admin)) + raise ForbiddenAgentError("stop at gate") + + monkeypatch.setattr( + "app.service.customer_profile_candidate_service.AuthorizationService", + RecordingAuthorization, + ) + with pytest.raises(ForbiddenAgentError): + await CustomerProfileCandidateService().decide_by_customer( + 11, "confirmed", RequestContext(user_id="7", trace_id="candidate-test") + ) + assert captured == [(USER_CONFIRM_PERMISSION, False)] + + +@pytest.mark.asyncio +async def test_admin_review_requires_admin_role_and_permission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """管理员审核必须同时具备专用权限和管理员角色。""" + captured: list[tuple[str, bool]] = [] + + class RecordingAuthorization: + @staticmethod + async def require(context: RequestContext, permission: str, *, admin: bool = False) -> None: + del context + captured.append((permission, admin)) + raise ForbiddenAgentError("stop at gate") + + monkeypatch.setattr( + "app.service.customer_profile_candidate_service.AuthorizationService", + RecordingAuthorization, + ) + with pytest.raises(ForbiddenAgentError): + await CustomerProfileCandidateService().review_by_admin( + 11, "approved", RequestContext(user_id="9003", trace_id="candidate-admin") + ) + assert captured == [(ADMIN_REVIEW_PERMISSION, True)] + + +def test_candidate_view_excludes_evidence_and_raw_customer_data() -> None: + """对客和管理列表只返回结构化候选,不暴露证据字段。""" + data = CustomerProfileCandidateService._view(candidate()) + + assert data["candidate_id"] == 11 + assert data["value"] == "稳健型" + assert "evidence_excerpt" not in data + assert "customer_id" in data + + +@pytest.mark.asyncio +async def test_candidate_transition_rejects_repeated_processing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """已处理候选不能重复确认或审核。""" + class AllowingAuthorization: + require = AsyncMock() + + monkeypatch.setattr( + "app.service.customer_profile_candidate_service.AuthorizationService", + AllowingAuthorization, + ) + + class Transaction: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *args: object) -> None: + return None + + class FakeSession: + def __init__(self) -> None: + self.target = candidate(status="verified") + + async def __aenter__(self) -> "FakeSession": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + def begin(self) -> Transaction: + return Transaction() + + async def scalar(self, statement: object) -> MemoryUnit: + del statement + return self.target + + def add(self, item: object) -> None: + del item + + async def flush(self) -> None: + return None + + class FakeFactory: + def __call__(self) -> FakeSession: + return FakeSession() + + monkeypatch.setattr( + "app.service.customer_profile_candidate_service.SessionFactory", FakeFactory() + ) + with pytest.raises(InvalidStateError): + await CustomerProfileCandidateService().decide_by_customer( + 11, "confirmed", RequestContext(user_id="7", trace_id="candidate-test") + ) diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index fbba0cc..6d41393 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -45,10 +45,14 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( (9016, "config:activate", "config", "activate", "all"), (9017, "model-endpoint:manage", "model-endpoint", "manage", "all"), (9018, "handover:read", "handover", "read", "all"), + (9019, "memory:candidate:confirm", "memory", "candidate_confirm", "self"), + (9020, "memory:candidate:review", "memory", "candidate_review", "all"), ) # 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。 -CUSTOMER_PERMISSIONS = (9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011) +CUSTOMER_PERMISSIONS = ( + 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011, 9019 +) # 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。 RISK_PERMISSIONS = (9001, 9002, 9003, 9010, 9011, 9012) # 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。