diff --git a/docs/30-投顾Agent迁移TODO.md b/docs/30-投顾Agent迁移TODO.md index be2e73a..53d790f 100644 --- a/docs/30-投顾Agent迁移TODO.md +++ b/docs/30-投顾Agent迁移TODO.md @@ -275,6 +275,22 @@ Redis 不可用时实测按设计降级放行;生产装配模式因本机 Milv - [x] 记录灰度期间错误、延迟和降级次数。(本次 Redis 降级 4 次请求,延迟基线已记录) - [x] 确认没有产生真实交易委托。(模拟订单、成交记录均为 0) +### 阶段二十三演示环境迁移准备(2026-09-12,工具完成,目标机待执行) + +已新增受控初始化编排脚本 `tools/bootstrap_advisor_demo.py`:默认只预览,只有同时传入 +`--apply --confirm-demo-database` 才会对配置的目标库写入。脚本只允许使用全新、可销毁的 +演示库,明确禁止复制或直接升级存在结构漂移的旧 `jr_agent`。它串联当前迁移、RBAC、 +投顾角色、演示账号、合规基线、演示测评/归属、权威产品证据、行情同步和结构审计。 + +已新增 `tools/publish_advisor_demo_config.py`,在目标环境通过配置发布状态机继承既有配置项, +并发布 advisor 六个意图的工具白名单,避免代码已部署但工具因缺少 `config_release` 而失败关闭。 +产品适当性与合同 CSV 仍必须来自真实披露文件,脚本不伪造来源 URL 或文件摘要。 + +- [x] 准备演示库初始化编排脚本和最小化写入保护。 +- [x] 准备投顾 Agent 工具白名单发布脚本。 +- [x] 准备演示环境迁移说明和专项单元测试。(`3 passed`、Ruff 通过) +- [ ] 在另一台演示电脑的新建演示库实际执行初始化、启动服务并完成 HTTP 验收。 + ## 一、迁移准备 - [ ] 确认远程仓库可访问。(当前失败:连接 `47.106.207.27:3000` 被拒绝) 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/docs/验收与审计/演示环境初始化包.md b/docs/验收与审计/演示环境初始化包.md new file mode 100644 index 0000000..f1cb9cf --- /dev/null +++ b/docs/验收与审计/演示环境初始化包.md @@ -0,0 +1,65 @@ +# 投顾演示环境初始化包 + +本包用于在另一台演示电脑上建立独立、可销毁的投顾演示环境。它不复制旧 `jr_agent` +数据库,也不导出真实客户数据。旧库存在历史表和约束漂移,只能保留为历史参考。 + +## 必备输入 + +- 新建的空 MySQL 演示库,以及指向该库的 `MYSQL_DSN`。 +- 新生成的 JWT 开发密钥:`python tools/generate_jwt_keys.py --out-dir config/jwt/dev`。 +- 已安装的项目依赖:`python -m pip install -r requirements.txt`。 +- 已审核的产品适当性披露 CSV 与基金合同 CSV。它们必须包含真实 HTTPS 来源和文件 SHA-256; + 不得伪造来源字段。 + +可选依赖:Redis 用于限流缓存,Milvus 用于知识/语义检索,Neo4j 用于图谱增强,模型端点用于 +模型路由。它们缺失时,投顾结构化分析会按既有降级策略运行;演示完整 Agent 对话前应配置模型端点。 + +## 执行顺序 + +先预览,不写库: + +```powershell +python tools/bootstrap_advisor_demo.py ` + --suitability-csv D:\demo-data\suitability.csv ` + --contracts-csv D:\demo-data\contracts.csv +``` + +确认 `.env` 中的 `MYSQL_DSN` 是新建演示库后,再执行: + +```powershell +python tools/bootstrap_advisor_demo.py ` + --apply --confirm-demo-database ` + --suitability-csv D:\demo-data\suitability.csv ` + --contracts-csv D:\demo-data\contracts.csv +``` + +该命令依次完成迁移、演示账号与角色、投顾权限、合规基线、客户测评和归属、产品目录、 +权威产品证据、资产分类、历史行情和质量快照、投顾工具白名单发布及数据库审计。 + +公共行情源不可访问时可加 `--skip-market-sync`,但推荐和动态配置会继续失败关闭,不能将该 +状态当作完整投顾演示通过。 + +## 演示账号 + +初始化脚本会创建仅用于演示的客户 `cust_t`、管理员 `admin_t` 和投顾 `advisor_t`。账号密码 +仅存在于目标演示数据库;脚本的显示输出会掩盖密码参数。演示结束后应销毁整个演示库,而不是 +将这些账号带入其他环境。 + +## 验收 + +执行完成后,启动 API 和 Worker,再运行: + +```powershell +python tools/acceptance_check.py +python tools/seed_advisor_demo.py +``` + +并在浏览器使用登录测试台或 Swagger 验证问卷、投资目标、持仓分析、配置、推荐审核发布和 +越权拦截。灰度开关使用: + +```text +ADVISOR_ROLLOUT_ENABLED=true +ADVISOR_ROLLOUT_CUSTOMER_IDS=9001 +``` + +修改灰度变量后必须重启 API 和 Worker。 diff --git a/tests/unit/tools/test_bootstrap_advisor_demo.py b/tests/unit/tools/test_bootstrap_advisor_demo.py new file mode 100644 index 0000000..93d3f06 --- /dev/null +++ b/tests/unit/tools/test_bootstrap_advisor_demo.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import argparse + +from tools.bootstrap_advisor_demo import build_steps, validate_args +from tools.publish_advisor_demo_config import INTENT_TOOLS + + +def test_demo_bootstrap_requires_explicit_database_confirmation() -> None: + args = argparse.Namespace( + apply=True, + confirm_demo_database=False, + suitability_csv=None, + contracts_csv=None, + market_days=400, + skip_market_sync=False, + ) + + assert validate_args(args) == "--apply requires --confirm-demo-database" + + +def test_demo_bootstrap_adds_evidence_and_market_steps() -> None: + args = argparse.Namespace( + apply=False, + confirm_demo_database=False, + suitability_csv=None, + contracts_csv=None, + market_days=400, + skip_market_sync=False, + ) + + labels = [step.label for step in build_steps(args)] + + assert "Refresh market history and quality snapshots" in labels + assert "Publish advisor tool allowlist" in labels + assert labels.index("Seed baseline demo RBAC") < labels.index( + "Create advisor role and permissions" + ) + + +def test_advisor_tool_release_covers_every_supported_intent() -> None: + assert set(INTENT_TOOLS) == { + "fund_quote", + "investment_goal", + "portfolio_analysis", + "asset_allocation", + "product_recommend", + "comparison", + } diff --git a/tools/bootstrap_advisor_demo.py b/tools/bootstrap_advisor_demo.py new file mode 100644 index 0000000..a77640a --- /dev/null +++ b/tools/bootstrap_advisor_demo.py @@ -0,0 +1,106 @@ +"""Prepare a fresh MySQL database for the advisor demonstration. + +This is deliberately an orchestrator, not a database dump tool. The legacy +``jr_agent`` database has schema drift and must not be copied or upgraded in +place. The command is dry-run by default and requires two explicit flags +before it writes the configured target database. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Step: + label: str + command: tuple[str, ...] + display: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Bootstrap an isolated advisor demo database") + parser.add_argument("--apply", action="store_true", help="Run commands that write the target database") + parser.add_argument( + "--confirm-demo-database", + action="store_true", + help="Confirm that MYSQL_DSN points to a disposable demo database", + ) + parser.add_argument("--suitability-csv", type=Path, help="Reviewed suitability disclosure CSV") + parser.add_argument("--contracts-csv", type=Path, help="Reviewed fund-contract disclosure CSV") + parser.add_argument("--market-days", type=int, default=400, help="Days of history to synchronize") + parser.add_argument("--skip-market-sync", action="store_true", help="Do not call public market sources") + return parser.parse_args() + + +def build_steps(args: argparse.Namespace) -> tuple[Step, ...]: + python = sys.executable + steps = [ + Step("Apply current Alembic migrations", (python, "-m", "alembic", "upgrade", "head"), "python -m alembic upgrade head"), + Step("Seed baseline demo RBAC", (python, "tools/seed_test_rbac.py"), "python tools/seed_test_rbac.py"), + Step("Set customer login", (python, "tools/create_test_user.py", "--id", "9001", "--username", "cust_t", "--role", "customer", "--password", "123456"), "python tools/create_test_user.py --id 9001 --username cust_t --role customer --password "), + Step("Set administrator login", (python, "tools/create_test_user.py", "--id", "9003", "--username", "admin_t", "--role", "admin", "--password", "88888888"), "python tools/create_test_user.py --id 9003 --username admin_t --role admin --password "), + Step("Create advisor role and permissions", (python, "tools/grant_advisor_role.py"), "python tools/grant_advisor_role.py"), + Step("Set advisor login", (python, "tools/create_test_user.py", "--id", "9020", "--username", "advisor_t", "--role", "advisor", "--password", "abc12345"), "python tools/create_test_user.py --id 9020 --username advisor_t --role advisor --password "), + Step("Seed compliance baseline", (python, "tools/seed_compliance_baseline.py"), "python tools/seed_compliance_baseline.py"), + Step("Seed customer assessment and advisor assignment", (python, "tools/seed_advisor_demo.py"), "python tools/seed_advisor_demo.py"), + ] + if args.suitability_csv is not None: + steps.append( + Step( + "Import reviewed product evidence", + (python, "tools/import_product_governance_reference.py", "--suitability", str(args.suitability_csv), "--contracts", str(args.contracts_csv)), + "python tools/import_product_governance_reference.py --suitability --contracts ", + ) + ) + steps.append( + Step("Classify product assets", (python, "tools/import_product_asset_classifications.py"), "python tools/import_product_asset_classifications.py") + ) + if not args.skip_market_sync: + steps.append( + Step("Refresh market history and quality snapshots", (python, "tools/sync_advisor_market_data.py", "--days", str(args.market_days)), f"python tools/sync_advisor_market_data.py --days {args.market_days}"), + ) + steps.extend(( + Step("Publish advisor tool allowlist", (python, "tools/publish_advisor_demo_config.py", "--apply"), "python tools/publish_advisor_demo_config.py --apply"), + Step("Audit database schema", (python, "tools/audit_schema.py"), "python tools/audit_schema.py"), + Step("Audit database constraints", (python, "tools/audit_constraints.py"), "python tools/audit_constraints.py"), + )) + return tuple(steps) + + +def validate_args(args: argparse.Namespace) -> str | None: + if (args.suitability_csv is None) != (args.contracts_csv is None): + return "--suitability-csv and --contracts-csv must be supplied together" + if args.market_days < 20: + return "--market-days must be at least 20" + if args.apply and not args.confirm_demo_database: + return "--apply requires --confirm-demo-database" + return None + + +def main() -> int: + args = parse_args() + error = validate_args(args) + if error: + print(f"error: {error}") + return 2 + steps = build_steps(args) + print("Target database must be newly created and disposable; never use legacy jr_agent.") + if args.suitability_csv is None: + print("warning: no reviewed product evidence supplied; recommendations remain fail-closed.") + for index, step in enumerate(steps, start=1): + print(f"[{index}/{len(steps)}] {step.label}\n $ {step.display}") + if args.apply and subprocess.run(step.command, check=False).returncode != 0: + print(f"failed: {step.label}") + return 1 + if not args.apply: + print("dry run complete; use --apply --confirm-demo-database after checking MYSQL_DSN and .env.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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/publish_advisor_demo_config.py b/tools/publish_advisor_demo_config.py new file mode 100644 index 0000000..f2058b0 --- /dev/null +++ b/tools/publish_advisor_demo_config.py @@ -0,0 +1,194 @@ +"""Publish the advisor Agent tool allowlist for an isolated demo environment. + +The active ``config_release`` is environment data. A new demo database has no +release by default, so AdvisorAgent tool calls would correctly fail closed even +though the Agent code is present. This script creates a new release, carries +forward every existing item, and replaces only advisor tool entries. +""" + +from __future__ import annotations + +import argparse +import asyncio +import datetime as dt +import json +import sys +import uuid +from pathlib import Path +from typing import Any + +import asyncmy +import httpx +import jwt + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.core.config import get_settings # noqa: E402 +from app.main import create_app # noqa: E402 + +ADMIN_ID = "9003" +AGENT_TYPE = "advisor" +INTENT_TOOLS: dict[str, tuple[str, ...]] = { + "fund_quote": ("query_fund_quote",), + "investment_goal": ("query_investment_goal",), + "portfolio_analysis": ("analyze_portfolio",), + "asset_allocation": ("generate_asset_allocation",), + "product_recommend": ("recommend_products",), + "comparison": ("compare_products",), +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Publish advisor demo tool allowlist") + parser.add_argument("--apply", action="store_true", help="Create, review, and activate release") + return parser.parse_args() + + +def issue_token(subject: str) -> str: + settings = get_settings() + private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, + "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), + "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +async def active_items() -> list[dict[str, Any]]: + settings = get_settings() + dsn = settings.mysql_dsn.split("://", 1)[1] + credentials, location = dsn.split("@", 1) + user, password = credentials.split(":", 1) + host_port, database = location.split("/", 1) + host, _, port = host_port.partition(":") + connection = await asyncmy.connect( + host=host, port=int(port or 3306), user=user, password=password, db=database + ) + try: + cursor = connection.cursor() + await cursor.execute( + """ + SELECT i.namespace, i.config_key, i.value_json, i.schema_version + FROM platform_config_item i + JOIN config_release r ON r.id = i.release_id + WHERE r.status = 'active' + """ + ) + rows = await cursor.fetchall() + finally: + connection.close() + return [ + { + "namespace": namespace, + "item_key": key, + "value_json": json.loads(value) if isinstance(value, str) else value, + "schema_version": schema_version, + } + for namespace, key, value, schema_version in rows + ] + + +async def request( + client: httpx.AsyncClient, + path: str, + *, + auth: dict[str, str], + payload: dict[str, object] | None = None, + if_match: str | None = None, +) -> httpx.Response: + headers = {**auth, "Idempotency-Key": uuid.uuid4().hex} + if if_match: + headers["If-Match"] = if_match + return await client.post(path, json=payload, headers=headers) + + +async def etag(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None: + return (await client.get(path, headers=auth)).headers.get("ETag") + + +def advisor_items() -> list[dict[str, object]]: + return [ + { + "namespace": "agent_tools", + "item_key": f"{AGENT_TYPE}:{intent}", + "value_json": {"allowed_tools": list(tools)}, + "schema_version": "1", + } + for intent, tools in INTENT_TOOLS.items() + ] + + +async def publish() -> int: + app = create_app() + auth = {"Authorization": f"Bearer {issue_token(ADMIN_ID)}"} + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + inherited = await active_items() + replacement_keys = {("agent_tools", str(item["item_key"])) for item in advisor_items()} + carried = [ + item + for item in inherited + if (str(item["namespace"]), str(item["item_key"])) not in replacement_keys + ] + created = await request( + client, + "/api/v1/admin/config-releases", + auth=auth, + payload={ + "release_no": f"advisor-demo-{uuid.uuid4().hex[:12]}", + "title": "Advisor demo tool allowlist", + "change_summary": "Publish advisor tool allowlists for the isolated demo environment", + }, + ) + if created.status_code != 201: + print(f"create release failed: {created.status_code} {created.text[:240]}") + return 1 + release_id = int(created.json()["data"]["id"]) + base = f"/api/v1/admin/config-releases/{release_id}" + for item in [*carried, *advisor_items()]: + response = await request( + client, f"{base}/platform-config-items", auth=auth, payload=item + ) + if response.status_code != 201: + print(f"write {item['namespace']}/{item['item_key']} failed: {response.status_code}") + return 1 + for suffix, payload in ( + ("validations", {}), + ("reviews", {"decision": "approved", "comment": "isolated demo setup"}), + ("activations", {}), + ): + response = await request( + client, + f"{base}/{suffix}", + auth=auth, + payload=payload, + if_match=await etag(client, base, auth), + ) + if response.status_code not in (200, 201): + print(f"{suffix} failed: {response.status_code} {response.text[:240]}") + return 1 + print(f"published advisor demo release id={release_id}") + return 0 + + +def main() -> int: + args = parse_args() + for intent, tools in INTENT_TOOLS.items(): + print(f"advisor:{intent} -> {', '.join(tools)}") + if not args.apply: + print("dry run only; pass --apply to publish a new config release") + return 0 + return asyncio.run(publish()) + + +if __name__ == "__main__": + sys.exit(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]