Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague

This commit is contained in:
2026-09-12 14:48:45 +08:00
12 changed files with 549 additions and 34 deletions
+106
View File
@@ -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 <demo-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 <demo-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 <demo-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 <reviewed.csv> --contracts <reviewed.csv>",
)
)
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())
+23
View File
@@ -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
+9 -4
View File
@@ -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]
+20 -17
View File
@@ -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()))
+194
View File
@@ -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())
+13 -2
View File
@@ -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,
+39 -4
View File
@@ -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 引用它,库里有数据引用
+7 -2
View File
@@ -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]