diff --git a/docs/风控业务演示文档/21-主项目合并后后端必改清单.md b/docs/风控业务演示文档/21-主项目合并后后端必改清单.md index 8e75e98..2b21b50 100644 --- a/docs/风控业务演示文档/21-主项目合并后后端必改清单.md +++ b/docs/风控业务演示文档/21-主项目合并后后端必改清单.md @@ -57,15 +57,18 @@ Agent 不能把截断结果表述成覆盖全部数据,也不能据此给出 以下内容属于环境准备或数据初始化,不是代码缺陷: -1. 执行 `python tools/grant_risk_permissions.py`,确认风控角色具备: +1. 先执行 `python tools/seed_test_rbac.py`,再执行 + `python tools/grant_risk_permissions.py`,确认风控角色具备: - `risk:alert:read` - `risk:alert:write` - `risk:alert:scan` - `risk:report:mail` -2. 执行 `python tools/publish_risk_agent_config.py`,确认奶龙风控智能助手的工具白名单和意图配置已发布。 -3. 按主项目发布的迁移流程执行 Alembic 升级,确认 `trigger_rule_codes` 多值索引已生效。 -4. 演示前准备足够的客户、交易、资金、持仓、登录和预警数据。 -5. 使用前确认日报邮件开关、SMTP 配置和收件人范围符合演示要求。 +2. 执行 `python tools/set_user_password.py --user 9002 --password 666666`, + 为风控演示账号设置密码;登录使用 `risk_t / 666666`。 +3. 执行 `python tools/publish_risk_agent_config.py`,确认奶龙风控智能助手的工具白名单和意图配置已发布。 +4. 按主项目发布的迁移流程执行 Alembic 升级,确认 `trigger_rule_codes` 多值索引已生效。 +5. 演示前准备足够的客户、交易、资金、持仓、登录和预警数据。 +6. 使用前确认日报邮件开关、SMTP 配置和收件人范围符合演示要求。 ## 当前保留限制 diff --git a/tools/check_rbac_seed_consistency.py b/tools/check_rbac_seed_consistency.py index df63052..d3092c0 100644 --- a/tools/check_rbac_seed_consistency.py +++ b/tools/check_rbac_seed_consistency.py @@ -45,6 +45,7 @@ if str(PROJECT_ROOT) not in sys.path: GRANT_SCRIPTS: tuple[tuple[str, str, str], ...] = ( ("tools/grant_advisor_role.py", "ADVISOR_PERMISSIONS", "投顾"), ("tools/grant_customer_service_phase2_permissions.py", "PHASE2_PERMISSIONS", "客服二期"), + ("tools/grant_risk_permissions.py", "PERMISSIONS", "风控"), ) @@ -94,6 +95,28 @@ def collect_findings() -> list[str]: if int(permission_id) not in seed_map: problems.append(f"CUSTOMER_PERMISSIONS 引用了不存在的 id {permission_id}") + for permission_id in seed.RISK_PERMISSIONS: + if int(permission_id) not in seed_map: + problems.append(f"RISK_PERMISSIONS 引用了不存在的 id {permission_id}") + + required_risk_codes = { + "risk:alert:read", + "risk:alert:write", + "risk:alert:scan", + "risk:report:mail", + } + risk_codes = { + seed_map[int(permission_id)] + for permission_id in seed.RISK_PERMISSIONS + if int(permission_id) in seed_map + } + missing_risk_codes = required_risk_codes - risk_codes + if missing_risk_codes: + problems.append( + "RISK_PERMISSIONS 缺少风控权限码:" + + ", ".join(sorted(missing_risk_codes)) + ) + return problems diff --git a/tools/create_test_user.py b/tools/create_test_user.py index 6d20ae7..e6b385e 100644 --- a/tools/create_test_user.py +++ b/tools/create_test_user.py @@ -29,13 +29,18 @@ import argparse import asyncio import sys from datetime import UTC, datetime, timedelta +from pathlib import Path from sqlalchemy import text -from app.core.contracts import RequestContext -from app.infrastructure.db import SessionFactory -from app.service.auth_service import hash_password -from app.service.identity_service import IdentityService +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.core.contracts import RequestContext # noqa: E402 +from app.infrastructure.db import SessionFactory # noqa: E402 +from app.service.auth_service import hash_password # noqa: E402 +from app.service.identity_service import IdentityService # noqa: E402 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] diff --git a/tools/grant_risk_permissions.py b/tools/grant_risk_permissions.py index 043d21a..2105d34 100644 --- a/tools/grant_risk_permissions.py +++ b/tools/grant_risk_permissions.py @@ -46,12 +46,12 @@ if str(PROJECT_ROOT) not in sys.path: from app.core.config import get_settings # noqa: E402 -# (permission_code, resource, action, 授予的角色) -PERMISSIONS: tuple[tuple[str, str, str, tuple[str, ...]], ...] = ( - ("risk:alert:read", "risk", "alert", ("risk_operator", "admin")), - ("risk:alert:write", "risk", "alert", ("risk_operator", "admin")), - ("risk:alert:scan", "risk", "alert", ("risk_operator", "admin")), - ("risk:report:mail", "risk", "report", ("risk_operator", "admin")), +# (permission_id, permission_code, resource, action, 授予的角色) +PERMISSIONS: tuple[tuple[int, str, str, str, tuple[str, ...]], ...] = ( + (9047, "risk:alert:read", "risk", "alert", ("risk_operator", "admin")), + (9048, "risk:alert:write", "risk", "alert", ("risk_operator", "admin")), + (9049, "risk:alert:scan", "risk", "alert", ("risk_operator", "admin")), + (9050, "risk:report:mail", "risk", "report", ("risk_operator", "admin")), ) DATA_SCOPE = "all" @@ -75,7 +75,7 @@ async def main() -> int: try: cursor = connection.cursor() now = dt.datetime.now(dt.UTC).replace(tzinfo=None) - for code, resource, action, roles in PERMISSIONS: + for permission_id, code, resource, action, roles in PERMISSIONS: await cursor.execute( "SELECT id FROM sys_permission WHERE permission_code = %s", (code,) ) @@ -83,18 +83,21 @@ async def main() -> int: if row is None: await cursor.execute( "INSERT INTO sys_permission" - " (permission_code, resource, `action`, data_scope, created_at, updated_at)" - " VALUES (%s, %s, %s, %s, %s, %s)", - (code, resource, action, DATA_SCOPE, now, now), + " (id, permission_code, resource, `action`, data_scope," + " created_at, updated_at)" + " VALUES (%s, %s, %s, %s, %s, %s, %s)", + (permission_id, code, resource, action, DATA_SCOPE, now, now), ) - await cursor.execute( - "SELECT id FROM sys_permission WHERE permission_code = %s", (code,) - ) - row = await cursor.fetchone() print(f"[权限] 已创建 {code}(scope={DATA_SCOPE})") else: + existing_id = int(row[0]) + if existing_id != permission_id: + raise RuntimeError( + f"权限码 {code} 已存在但主键为 {existing_id}," + f"与约定主键 {permission_id} 不一致;请先执行 " + "python tools/seed_test_rbac.py" + ) print(f"[权限] {code} 已存在") - permission_id = int(row[0]) for role_code in roles: await cursor.execute("SELECT id FROM sys_role WHERE role_code = %s", (role_code,)) @@ -122,5 +125,5 @@ async def main() -> int: finally: connection.close() - -sys.exit(asyncio.run(main())) +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tools/seed_risk_agent_config.py b/tools/seed_risk_agent_config.py index deb9451..99d25b5 100644 --- a/tools/seed_risk_agent_config.py +++ b/tools/seed_risk_agent_config.py @@ -5,11 +5,17 @@ from __future__ import annotations import asyncio import hashlib import json +import sys from datetime import UTC, datetime +from pathlib import Path from sqlalchemy import text -from app.infrastructure.db import SessionFactory +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.infrastructure.db import SessionFactory # noqa: E402 ADMIN_USER_ID = 9003 ENDPOINT_CODE = "deepseek-flash" @@ -53,7 +59,12 @@ async def seed() -> None: status='active', reviewer_id=:admin_id, reviewed_at=:now, updated_at=:now """), { "endpoint_code": ENDPOINT_CODE, - "capabilities": json.dumps(["chat", "intent_classification", "risk_answer"]), + "capabilities": json.dumps([ + "chat", + "intent_classification", + "risk_answer", + "text_generation", + ]), "data_levels": json.dumps(["internal"]), "admin_id": ADMIN_USER_ID, "now": now, diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index f308e62..1c9c656 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -26,13 +26,19 @@ id BETWEEN 9001 AND 9099` 会删掉该号段内**所有**权限(包括别的 from __future__ import annotations import asyncio +import sys from datetime import UTC, datetime, timedelta +from pathlib import Path from sqlalchemy import text -from app.core.contracts import RequestContext -from app.infrastructure.db import SessionFactory -from app.service.identity_service import IdentityService +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.core.contracts import RequestContext # noqa: E402 +from app.infrastructure.db import SessionFactory # noqa: E402 +from app.service.identity_service import IdentityService # noqa: E402 CUSTOMER_USER = "9001" RISK_USER = "9002" @@ -94,6 +100,14 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( (9044, "memory:candidate:confirm", "memory:candidate", "confirm", "self"), (9045, "memory:candidate:review", "memory:candidate", "review", "all"), (9046, "handover:read", "handover", "read", "all"), + # ---- 9047-9050:风控模块四个权限码 ---- + # 风控 Service 和 Agent 工具都按这四个权限码失败关闭。此前依赖 + # `grant_risk_permissions.py` 临时补种,重跑本脚本时会删除 9001-9099 号段内的 + # 风控权限,导致登录成功后风控接口全部 403。这里纳入唯一权限定义源。 + (9047, "risk:alert:read", "risk", "alert", "all"), + (9048, "risk:alert:write", "risk", "alert", "all"), + (9049, "risk:alert:scan", "risk", "alert", "all"), + (9050, "risk:report:mail", "risk", "report", "all"), ) # 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。 @@ -104,7 +118,10 @@ CUSTOMER_PERMISSIONS = ( 9044, ) # 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。 -RISK_PERMISSIONS = (9001, 9002, 9003, 9010, 9011, 9012) +RISK_PERMISSIONS = ( + 9001, 9002, 9003, 9010, 9011, 9012, + 9047, 9048, 9049, 9050, +) # 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。 ADMIN_PERMISSIONS = tuple(row[0] for row in PERMISSIONS) @@ -159,6 +176,24 @@ async def seed() -> None: text("DELETE FROM sys_role_permission WHERE role_id IN (9001,9002,9003)") ) await session.execute(text("DELETE FROM sys_user_role WHERE user_id IN (9001,9002,9003)")) + # 风控权限在旧环境中可能由 `grant_risk_permissions.py` 自动分配了任意主键。 + # 先按 permission_code 清掉角色绑定和旧主键,保证本次能用固定号段重建; + # 否则 `risk:report:mail` 已存在于 9001-9099 之外时会触发 permission_code 唯一键冲突。 + await session.execute(text(""" + DELETE rp FROM sys_role_permission rp + JOIN sys_permission p ON p.id = rp.permission_id + WHERE p.permission_code IN ( + 'risk:alert:read', 'risk:alert:write', + 'risk:alert:scan', 'risk:report:mail' + ) + """)) + await session.execute(text(""" + DELETE FROM sys_permission + WHERE permission_code IN ( + 'risk:alert:read', 'risk:alert:write', + 'risk:alert:scan', 'risk:report:mail' + ) + """)) await session.execute(text("DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099")) await session.execute(text("DELETE FROM sys_role WHERE id IN (9001,9002,9003)")) # 不再 DELETE sys_user:投顾域的 advisor_profile_tag 等表用 FK 引用它,库里有数据引用 diff --git a/tools/set_user_password.py b/tools/set_user_password.py index cf4d768..b30d1af 100644 --- a/tools/set_user_password.py +++ b/tools/set_user_password.py @@ -28,11 +28,16 @@ import argparse import asyncio import sys from datetime import UTC, datetime +from pathlib import Path from sqlalchemy import text -from app.infrastructure.db import SessionFactory -from app.service.auth_service import hash_password +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.infrastructure.db import SessionFactory # noqa: E402 +from app.service.auth_service import hash_password # noqa: E402 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]