diff --git a/tools/check_permission_coverage.py b/tools/check_permission_coverage.py new file mode 100644 index 0000000..36d96dc --- /dev/null +++ b/tools/check_permission_coverage.py @@ -0,0 +1,149 @@ +"""对账:代码声明的权限码 vs 库里存在的 vs 各角色实际拥有的(只读)。 + +## 为什么需要它 + +权限缺失的表现一律是 `AGENT_PERMISSION_DENIED: 缺少操作权限`,但底下有**三种完全不同的原因**, +报错区分不出来,排查时极易往错的方向找: + +1. **权限码在库里根本不存在**(代码写了、没人建)→ 任何角色都过不去, + 看起来却像"这个角色没授权"; +2. 权限码存在,但**没授给这个角色**(例如投顾缺 `investment-goal:write:self`); +3. 角色本身没有对应的**接口范围或界面**(例如运营只有 `offsite:write`, + 而它要用的 `financial:nl2sql:read` 从没建过)。 + +这类问题**服务层测试测不出来** —— 那层直接构造 `RequestContext`,权限是测试自己塞的。 +只有把"代码声明"和"库里数据"放一起比才看得见。2026-09-12 用它在主干上查出 +6 个不存在的权限码(`promotion:*` 四个导致推广材料整条线 403、`financial:nl2sql:read`、 +`probe:read`)。 + +只读:不连业务逻辑、不改任何数据。 + +用法: + + python tools/check_permission_coverage.py +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.core.config import get_settings # noqa: E402 + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +#: 代码里声明权限码的几种写法。 +PATTERNS: tuple[str, ...] = ( + r'require\(\s*context\s*,\s*"([^"]+)"', + r'^\s*permission\s*[:=]\s*"([^"]+)"', + r'^[A-Z_]*PERMISSION[A-Z_]*\s*[:=]\s*"([^"]+)"', + r'required_permission\s*=\s*"([^"]+)"', +) + +CODE_SHAPE = re.compile(r"^[a-z][a-z0-9_-]*(?::[a-z0-9_-]+)+$") + + +def collect_required(root: Path = PROJECT_ROOT) -> dict[str, set[str]]: + found: dict[str, set[str]] = {} + for path in (root / "app").rglob("*.py"): + text = path.read_text(encoding="utf-8", errors="replace") + for pattern in PATTERNS: + for match in re.finditer(pattern, text, re.MULTILINE): + code = match.group(1) + if CODE_SHAPE.match(code): + found.setdefault(code, set()).add(path.relative_to(root).as_posix()) + return found + + +def load_from_db() -> tuple[dict[str, int], dict[str, set[str]]]: + """返回 `(权限码 → id, 角色 → 权限码集合)`。""" + 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(":") + return _query(host, int(port or 3306), user, password, database) + + +def _query(host: str, port: int, user: str, password: str, database: str): + import asyncio + + import asyncmy + + async def run(): + connection = await asyncmy.connect( + host=host, port=port, user=user, password=password, db=database + ) + try: + cursor = connection.cursor() + await cursor.execute("SELECT id, permission_code FROM sys_permission") + permissions = {str(code): int(pid) for pid, code in await cursor.fetchall()} + + await cursor.execute( + """ + SELECT r.role_code, p.permission_code + FROM sys_role r + LEFT JOIN sys_role_permission rp ON rp.role_id = r.id + LEFT JOIN sys_permission p ON p.id = rp.permission_id + """ + ) + roles: dict[str, set[str]] = {} + for role_code, permission_code in await cursor.fetchall(): + roles.setdefault(str(role_code), set()) + if permission_code: + roles[str(role_code)].add(str(permission_code)) + return permissions, roles + finally: + connection.close() + + return asyncio.run(run()) + + +def main() -> int: + required = collect_required() + permissions, roles = load_from_db() + + print(f"代码里声明的权限码:{len(required)} 个") + print(f"库里已有的权限码:{len(permissions)} 个") + + missing = sorted(code for code in required if code not in permissions) + print() + print(f"【一】代码要求、但库里不存在(任何角色都过不去):{len(missing)} 个") + for code in missing: + where = sorted(required[code])[:3] + print(f" ✗ {code:<44} 用于 {', '.join(where)}") + + unused = sorted(code for code in permissions if code not in required) + if unused: + print() + print(f"【二】库里有、代码里没直接搜到(可能是变量拼接或历史遗留):{len(unused)} 个") + for code in unused: + holders = sorted(r for r, s in roles.items() if code in s) + print(f" · {code:<44} 持有角色 {holders or '无'}") + + print() + print("【三】各角色的权限数,以及「代码要求却没拿到」的码:") + for role, codes in sorted(roles.items()): + lacks = sorted(c for c in required if c in permissions and c not in codes) + print(f" {role:<16} 共 {len(codes):>2} 项;代码要求但未授予 {len(lacks)} 个") + for code in lacks[:12]: + print(f" - {code}") + if len(lacks) > 12: + print(f" … 另有 {len(lacks) - 12} 个") + + if missing: + print() + print("处置:把这几个码并进 `tools/seed_test_rbac.py` 的 `PERMISSIONS`(那是唯一定义源),") + print(" 再按角色在对应的 `grant_*.py` 里授权。") + return 1 + print() + print("结论:代码要求的权限码在库里都存在。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/create_test_user.py b/tools/create_test_user.py index e6b385e..c5a5970 100644 --- a/tools/create_test_user.py +++ b/tools/create_test_user.py @@ -45,15 +45,14 @@ from app.service.identity_service import IdentityService # noqa: E402 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] -#: 库里现成的角色。新用户复用它们。 -#: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`)—— -#: `advisor` 就是由 `tools/grant_advisor_role.py` 建立的。 -ROLE_IDS: dict[str, int] = { - "customer": 9001, - "risk_operator": 9002, - "admin": 9003, - "advisor": 9004, -} +#: 可创建的角色。**不再硬编码角色 id** —— 角色 id 是环境数据 +#: (`operator` 就是自增出来的 `1986594485028610`,各环境不同),硬编码会让脚本换个 +#: 环境就失效。现在运行时按 `role_code` 查库。 +#: 新角色得先有权限集合(`sys_role_permission`):`advisor` 由 +#: `tools/grant_advisor_role.py` 建,`operator` 由 `tools/grant_operator_role.py` 补。 +KNOWN_ROLES: tuple[str, ...] = ( + "customer", "risk_operator", "admin", "advisor", "operator", +) #: 角色 → `sys_user.user_type`。注意这是 `user_type`,与 `employee_role` 不是一回事。 ROLE_USER_TYPE: dict[str, str] = { @@ -61,6 +60,7 @@ ROLE_USER_TYPE: dict[str, str] = { "risk_operator": "employee", "admin": "employee", "advisor": "employee", + "operator": "employee", } #: 客户的开户状态。风控扫描等链路会读它,写成 `closed` 会让部分规则不成立。 @@ -107,18 +107,21 @@ async def upsert_user( *, user_id: int, username: str, role: str, password: str ) -> int: """建/更新账号并绑定角色,最后验证权限能解析出来。""" - role_id = ROLE_IDS[role] user_type = ROLE_USER_TYPE[role] now = datetime.now(UTC).replace(tzinfo=None) assigned_at = now - timedelta(seconds=ASSIGN_BACKDATE_SECONDS) async with SessionFactory() as session, session.begin(): - role_exists = await session.scalar( - text("SELECT id FROM sys_role WHERE id = :role_id"), {"role_id": role_id} + role_id = await session.scalar( + text("SELECT id FROM sys_role WHERE role_code = :code"), {"code": role} ) - if role_exists is None: - print(f"[失败] 角色 {role}(id={role_id})不存在,先跑 tools/seed_test_rbac.py") + if role_id is None: + available = ( + await session.scalars(text("SELECT role_code FROM sys_role ORDER BY id")) + ).all() + print(f"[失败] 库里没有角色 {role}。现有角色:{list(available)}") return 1 + role_id = int(role_id) # 覆盖语义:同一个 id 重跑不会堆出第二行。 await session.execute( @@ -192,7 +195,7 @@ async def main() -> int: parser.add_argument("--list", action="store_true", help="列出所有账号与角色") parser.add_argument("--id", type=int, help="用户 id(9001-9003 已被演示账号占用)") parser.add_argument("--username", help="登录用户名") - parser.add_argument("--role", choices=sorted(ROLE_IDS), help="角色") + parser.add_argument("--role", choices=KNOWN_ROLES, help="角色(库里需已存在该角色)") parser.add_argument("--password", help="登录密码(仅限演示环境)") args = parser.parse_args() diff --git a/tools/grant_advisor_role.py b/tools/grant_advisor_role.py index 56001e8..aa0fd13 100644 --- a/tools/grant_advisor_role.py +++ b/tools/grant_advisor_role.py @@ -71,10 +71,15 @@ ADVISOR_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( (9043, "product-governance:sync", "product-governance", "sync", "all"), ) -#: 投顾拿哪些 —— 工作流那 10 个;治理类 6 个只给 admin。 +#: 投顾拿哪些。2026-09-12 补齐:此前只给了 10 项工作流权限,结果投顾**用不了** +#: 投资目标创建/确认、跑不了 Agent、查不了行情与知识、看不了所服务客户的画像 —— +#: 表现出来就是一片 `AGENT_PERMISSION_DENIED`。下面每一项都对应代码里真实用到的地方。 ADVISOR_GRANTED_CODES: tuple[str, ...] = ( + # 投顾工作流(定义在种子的 9020-9034) "asset-allocation:generate:self", "investment-goal:read:self", + "investment-goal:write:self", # 新建投资目标 + "investment-goal:confirm:self", # 与客户确认目标 "investment-goal:review", "investment-goal:publish", "portfolio-analysis:read:self", @@ -83,6 +88,22 @@ ADVISOR_GRANTED_CODES: tuple[str, ...] = ( "product-recommendation:generate:self", "product-recommendation:review", "product-recommendation:publish", + # 平台通用:投顾同样要跑 Agent、查行情、检索知识、看所服务客户的画像 + "agent:run", + "suitability:read", + "fund:quote:read", + "knowledge:query", + "knowledge:reference:read", + "memory:read:customer", + "conversation:create", + "conversation:close", + "conversation:feedback", + # 推广材料(`promotion_material_service.py:164` 专门判 `advisor`) + "promotion:read", + "promotion:write", + "promotion:deliver", + # 金融数据(`financial_nl2sql_service.py` 的角色白名单含 advisor) + "financial:nl2sql:read", ) #: admin 角色 id(`seed_test_rbac.py` 建的)。 diff --git a/tools/grant_operator_role.py b/tools/grant_operator_role.py new file mode 100644 index 0000000..ce2223e --- /dev/null +++ b/tools/grant_operator_role.py @@ -0,0 +1,155 @@ +"""补齐运营(`operator`)角色的权限,并按需创建该角色。 + +## 为什么需要它 + +`operator` 是场外/推广线建的角色,**不在 `seed_test_rbac.py` 的 9001-9003 里** —— +所以种子既不会创建它,也不会清理它的绑定。库里这个角色长期**只有 1 项权限** +(`offsite:write`),但两条线实际要求的并不一样: + +| 功能 | 门槛类型 | 位置 | +|---|---|---| +| 场外基金运营(邮件、单据、确认、通知、结算) | **角色门槛** `{"operator","risk_operator","admin","super_admin"}` | `offsite_fund_service.py:2600` | +| 金融 NL2SQL | **权限码** `financial:nl2sql:read`(角色白名单含 `operator`) | `financial_nl2sql_service.py:272` | + +也就是说:场外主体功能**本来就该能用**(靠角色),运营真正缺的是 NL2SQL 那一个码; +而"看不到运营界面"是前端没做,不是权限问题。 + +## 给哪些 —— 按代码真实要求,不多给 + +运营不做治理、不看审计、不发布配置,因此**不给** `audit:read` / `config:*` / +`product-governance:*` / `knowledge:manage`。需要排查权限缺口时跑 +`python tools/check_permission_coverage.py`。 + +本脚本**只增不删**,可重复执行。 + +用法: + + python tools/grant_operator_role.py --dry-run + python tools/grant_operator_role.py +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from datetime import UTC, datetime + +from sqlalchemy import text + +from app.infrastructure.db import SessionFactory + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +OPERATOR_ROLE_CODE = "operator" +OPERATOR_ROLE_NAME = "运营专员" + +#: 运营该有的权限码。`offsite:write` 由场外线创建;其余在本种子的 9051-9056 号段里定义。 +OPERATOR_GRANTED_CODES: tuple[str, ...] = ( + # 场外运营(角色门槛之外,这个码是场外线自己声明的) + "offsite:write", + # 金融 NL2SQL:角色白名单 {advisor, operator, admin, super_admin} 含 operator + "financial:nl2sql:read", +) + + +async def apply(*, dry_run: bool) -> int: + now = datetime.now(UTC).replace(tzinfo=None) + async with SessionFactory() as session, session.begin(): + role_id = await session.scalar( + text("SELECT id FROM sys_role WHERE role_code = :code"), {"code": OPERATOR_ROLE_CODE} + ) + print(f"角色 {OPERATOR_ROLE_CODE}:{'已存在 id=' + str(role_id) if role_id else '将新建(自动分配 id)'}") + + permission_ids = dict( + (await session.execute(text("SELECT permission_code, id FROM sys_permission"))).all() + ) + missing = [code for code in OPERATOR_GRANTED_CODES if code not in permission_ids] + print(f"权限码:库里已有 {len(permission_ids)} 个;本脚本需要的 {len(OPERATOR_GRANTED_CODES)} 个中缺 {len(missing)} 个") + for code in missing: + print(f" ✗ 缺失:{code}(应先跑 tools/seed_test_rbac.py)") + + if dry_run: + print("\n[dry-run] 未写入任何数据。") + return 0 + + if role_id is None: + await session.execute( + text( + "INSERT INTO sys_role (role_code, role_name, status, created_at, updated_at)" + " VALUES (:code, :name, 'active', :now, :now)" + ), + {"code": OPERATOR_ROLE_CODE, "name": OPERATOR_ROLE_NAME, "now": now}, + ) + role_id = await session.scalar( + text("SELECT id FROM sys_role WHERE role_code = :code"), + {"code": OPERATOR_ROLE_CODE}, + ) + role_id = int(role_id) + + have = set( + (await session.scalars( + text("SELECT permission_id FROM sys_role_permission WHERE role_id = :r"), + {"r": role_id}, + )).all() + ) + added = 0 + for code in OPERATOR_GRANTED_CODES: + permission_id = permission_ids.get(code) + if permission_id is None or int(permission_id) in have: + continue + await session.execute( + text( + "INSERT INTO sys_role_permission (role_id, permission_id, created_at)" + " VALUES (:r, :p, :now)" + ), + {"r": role_id, "p": int(permission_id), "now": now}, + ) + added += 1 + print(f"授权:{OPERATOR_ROLE_CODE} 新增 {added} 项(目标共 {len(OPERATOR_GRANTED_CODES)} 项)") + + await verify() + return 0 + + +async def verify() -> None: + """用真实链路验证:按权限码列出该角色最终拥有什么。""" + async with SessionFactory() as session: + rows = ( + await session.execute( + text( + """ + SELECT p.permission_code + FROM sys_role r + JOIN sys_role_permission rp ON rp.role_id = r.id + JOIN sys_permission p ON p.id = rp.permission_id + WHERE r.role_code = :code + ORDER BY p.permission_code + """ + ), + {"code": OPERATOR_ROLE_CODE}, + ) + ).all() + codes = [str(row[0]) for row in rows] + print(f"\n{OPERATOR_ROLE_CODE} 实测权限 {len(codes)} 项:{codes}") + lacked = [c for c in OPERATOR_GRANTED_CODES if c not in codes] + if lacked: + print(f"[失败] 仍未绑定的码:{lacked}") + raise SystemExit(1) + print( + "\n下一步:给运营账号绑这个角色 ——\n" + " python tools/create_test_user.py --id 9006 --username offsite_t " + "--role operator --password offsite123" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="补齐运营角色的权限") + parser.add_argument("--dry-run", action="store_true", help="只打印将写入什么") + args = parser.parse_args() + return asyncio.run(apply(dry_run=args.dry_run)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/portal.py b/tools/portal.py new file mode 100644 index 0000000..c15c7ae --- /dev/null +++ b/tools/portal.py @@ -0,0 +1,1120 @@ +"""统一登录门户:一个登录入口,按角色分流到不同的工作台。 + +**和 `tools/chat_console.py` 的区别**:那个控制台没有登录、身份写死在命令行里; +本门户**走真实的 `POST /api/v1/auth/tokens`**,令牌只存活在本进程,浏览器拿不到, +然后按登录者的**真实角色**决定进哪个界面 —— 也就是照着"客户 / 员工 / 管理员"三种 +使用者的实际路径走一遍,而不是逐个接口点着测。 + +| 登录者的角色 | 进入的界面 | 主要能力 | +|---|---|---| +| `customer` | **客服** | 与客服 Agent 对话(真实受理 + 进程内驱动 Worker)、转人工、查看并确认自己的画像候选 | +| `risk_operator` / `operator` | **员工工作台** | 风控总览、预警列表与详情、预警扫描、确认/升级/解决等处置、生成日报 | +| `admin` / `super_admin` | **权限界面** | 角色清单、每个角色的权限明细、按用户查角色、审计流水、客服转人工工单、知识库清单 | +| `advisor` | **投顾工作台** | 当前投资目标、已发布方案、组合分析 | + +角色从 `IdentityService.resolve()` 现查(不是从令牌里读)—— 所以改了库里角色,重登即生效。 + +跑法: + +```powershell +D:\\conda\\envs\\jr_py313\\python.exe tools\\portal.py +# 浏览器打开 http://127.0.0.1:8101 +``` + +**前置**: + +1. MySQL / Redis 可用(平台启动时会连); +2. 演示账号能登录:先 `python tools/seed_test_rbac.py`,再 `python tools/set_user_password.py`; +3. **客服对话前请停掉常驻 Worker**(`python -m app.worker`)—— 本门户自己驱动这一条 run, + 常驻 Worker 会与它抢 `agent_run` 队列,表现为页面一直转圈。 + +**权限问题的表现是设计如此**:某个按钮点了返回 403,说明这个角色的权限**本来就不够** +(例如客户点风控、员工点权限管理)。平台一律 fail closed,门户把 403 原样显示出来, +不做任何前端隐藏式"代为授权"。 +""" + +from __future__ import annotations + +import argparse +import sys +import uuid +from pathlib import Path +from typing import Any + +import httpx +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse + +sys.stdout.reconfigure(errors="replace") + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +CUSTOMER_AGENT = "customer_service" + +DEMO_ACCOUNTS: tuple[tuple[str, str, str], ...] = ( + ("cust_t", "123456", "客户"), + ("risk_t", "666666", "员工(风控专员)"), + ("admin_t", "88888888", "管理员"), + ("advisor_t", "abc12345", "投资顾问"), + ("offsite_t", "offsite123", "员工(运营专员)"), +) + +#: 角色 → 界面。按优先级从高到低匹配,多角色者进权限最高的那个界面。 +VIEW_BY_ROLE: tuple[tuple[str, str], ...] = ( + ("super_admin", "admin"), + ("admin", "admin"), + ("risk_operator", "staff"), + ("operator", "offsite"), # 运营走场外基金这条线,不是风控那个台 + ("advisor", "advisor"), + ("customer", "customer"), +) + +VIEW_LABELS = { + "customer": "客服", + "staff": "风控工作台", + "offsite": "运营工作台", + "admin": "权限管理", + "advisor": "投顾工作台", + "unknown": "未分配界面", +} + + +def view_for(roles: tuple[str, ...] | list[str]) -> str: + for role, view in VIEW_BY_ROLE: + if role in roles: + return view + return "unknown" + + +class Platform: + """共享的平台连接:一个 app、一个 client、一个 lifespan,所有会话共用。 + + 分开的理由:每个会话各自 `create_app()` 会把平台实例化好几遍(连接池、后台任务都翻倍); + 而"多个浏览器标签用不同身份"又是演示时的刚需 —— 于是把**连接**共享、把**令牌**按会话分开。 + """ + + def __init__(self, base_url: str | None) -> None: + self.base_url = base_url + self.application: FastAPI | None = None + self._client: httpx.AsyncClient | None = None + self._lifespan: Any = None + self.environment: dict[str, Any] = {} + + async def client(self) -> httpx.AsyncClient: + if self._client is not None: + return self._client + if self.base_url: + self._client = httpx.AsyncClient(base_url=self.base_url, timeout=120.0) + self.environment = {"mode": "外部服务", "target": self.base_url} + return self._client + + from app.core.config import get_settings + from app.main import create_app + + application = create_app() + self.application = application + self._lifespan = application.router.lifespan_context(application) + await self._lifespan.__aenter__() + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=application), base_url="http://platform", timeout=120.0 + ) + dsn = get_settings().mysql_dsn + self.environment = {"mode": "进程内", "mysql": dsn.split("@")[-1] if "@" in dsn else dsn} + return self._client + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + if self._lifespan is not None: + await self._lifespan.__aexit__(None, None, None) + self._lifespan = None + + async def request( + self, + method: str, + path: str, + *, + token: str | None, + query: dict[str, Any] | None = None, + body: Any = None, + ) -> dict[str, Any]: + if not token: + return {"status": 401, "body": {"error": {"code": "NOT_LOGGED_IN", "message": "请先登录"}}} + client = await self.client() + headers = {"Authorization": f"Bearer {token}"} + # 幂等头:平台的写接口要求 `Idempotency-Key`(16-128 位 ASCII), + # 漏了会被 `AGENT_INPUT_INVALID: 必须提供 16-128 位 ASCII Idempotency-Key` 拒绝。 + if method.upper() in {"POST", "PUT", "PATCH", "DELETE"}: + headers["Idempotency-Key"] = uuid.uuid4().hex + try: + response = await client.request( + method.upper(), + path, + params=query or None, + json=body, + headers=headers, + ) + except Exception as exc: + return {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}} + return {"status": response.status_code, "body": _json(response)} + + +class Session: + """一次登录会话:只持有令牌与身份。""" + + def __init__(self, username: str, user_id: str, token: str) -> None: + self.username = username + self.user_id = user_id + self.token = token + self.roles: tuple[str, ...] = () + self.permissions: tuple[str, ...] = () + + def as_dict(self, environment: dict[str, Any]) -> dict[str, Any]: + view = view_for(self.roles) + return { + "username": self.username, + "user_id": self.user_id, + "roles": list(self.roles), + "permissions": list(self.permissions), + "view": view, + "view_label": VIEW_LABELS[view], + "logged_in": True, + "environment": environment, + } + + +class Portal: + """多会话门户:`X-Session` 头区分标签页,令牌始终留在服务端。""" + + def __init__(self, base_url: str | None) -> None: + self.platform = Platform(base_url) + self.sessions: dict[str, Session] = {} + self.error: str | None = None + + async def login(self, session_id: str, username: str, password: str) -> dict[str, Any]: + client = await self.platform.client() + response = await client.post( + "/api/v1/auth/tokens", json={"username": username, "password": password} + ) + payload = _json(response) + if response.status_code != 200: + return {"ok": False, "status": response.status_code, "body": payload} + + data = (payload.get("data") or {}) if isinstance(payload, dict) else {} + token = data.get("access_token") or data.get("token") + if not token: + return {"ok": False, "status": response.status_code, "body": payload} + + session = Session(username, str(data.get("user_id") or _sub_of(token) or ""), token) + await self._resolve(session) + self.sessions[session_id] = session + return {"ok": True, "me": session.as_dict(self.platform.environment)} + + async def _resolve(self, session: Session) -> None: + """角色与权限现查库 —— 与平台每请求解析的口径一致(令牌里只有 sub)。""" + from app.core.contracts import RequestContext + from app.service.identity_service import IdentityService + + context = await IdentityService().resolve( + RequestContext(user_id=session.user_id, trace_id=str(uuid.uuid4())) + ) + session.roles = tuple(context.roles) + session.permissions = tuple(sorted(context.permissions)) + + def logout(self, session_id: str) -> None: + self.sessions.pop(session_id, None) + + def me(self, session_id: str) -> dict[str, Any]: + session = self.sessions.get(session_id) + data = ( + session.as_dict(self.platform.environment) + if session + else {"logged_in": False, "environment": self.platform.environment} + ) + data["accounts"] = [ + {"username": u, "password": p, "label": label} for u, p, label in DEMO_ACCOUNTS + ] + data["startup_error"] = self.error + return data + + async def call( + self, + session_id: str, + method: str, + path: str, + query: dict[str, Any] | None = None, + body: Any = None, + ) -> dict[str, Any]: + session = self.sessions.get(session_id) + return await self.platform.request( + method, path, token=session.token if session else None, query=query, body=body + ) + + async def chat(self, session_id: str, message: str, chat_session: str) -> dict[str, Any]: + """客服对话:真实受理 → 本进程驱动这一条 run → 取结果。""" + from app.worker.runtime import WorkerRuntime + + accepted = await self.call( + session_id, + "POST", + "/api/v1/agent-runs", + body={ + "agent_type": CUSTOMER_AGENT, + "message": message, + "session_id": chat_session, + "idempotency_key": uuid.uuid4().hex, + }, + ) + if accepted["status"] != 202: + return {"ok": False, "status": accepted["status"], "body": accepted["body"]} + + run_id = ((accepted["body"] or {}).get("data") or {}).get("run_id") + if not run_id: + return {"ok": False, "status": accepted["status"], "body": accepted["body"]} + + await WorkerRuntime().execute(run_id) + detail = await self.call(session_id, "GET", f"/api/v1/agent-runs/{run_id}") + data = (detail["body"] or {}).get("data") or {} + result = data.get("result") or {} + raw_intent = result.get("intent") + intent = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent + return { + "ok": True, + "run_id": run_id, + "status": data.get("status"), + "answer": str(result.get("content") or ""), + "intent": intent or "", + "transfer": bool(result.get("transfer_required")), + "transfer_reason": result.get("transfer_reason") or "", + } + + +def _json(response: httpx.Response) -> Any: + try: + return response.json() + except Exception: + return {"_raw": response.text[:3000]} + + +def _sub_of(token: str) -> str | None: + import jwt + + try: + return str(jwt.decode(token, options={"verify_signature": False}).get("sub")) + except Exception: + return None + + +def build_portal(state: Portal) -> FastAPI: + # 注意:`Request` 必须在**模块顶层**导入。本文件启用了 + # `from __future__ import annotations`,注解在此处是字符串,FastAPI 解析 + # `request: Request` 时只在模块全局里找类型名 —— 写成函数内 import 会被 + # 当成"必填 query 参数 request",所有请求直接 422,且报错完全看不出是这个原因。 + portal = FastAPI(title="统一登录门户") + + def sid(request: Request) -> str: + return request.headers.get("X-Session") or "default" + + @portal.get("/", response_class=HTMLResponse) + async def index() -> HTMLResponse: + return HTMLResponse(PAGE) + + @portal.get("/api/me") + async def me(request: Request) -> JSONResponse: + return JSONResponse(state.me(sid(request))) + + @portal.post("/api/login") + async def login(request: Request, payload: dict[str, Any]) -> JSONResponse: + try: + result = await state.login( + sid(request), str(payload.get("username") or ""), str(payload.get("password") or "") + ) + state.error = None + except Exception as exc: + state.error = f"{type(exc).__name__}: {exc}" + result = {"ok": False, "status": 0, "body": {"error": {"message": state.error}}} + return JSONResponse(result) + + @portal.post("/api/logout") + async def logout(request: Request) -> JSONResponse: + state.logout(sid(request)) + return JSONResponse({"ok": True}) + + @portal.post("/api/call") + async def call(request: Request, payload: dict[str, Any]) -> JSONResponse: + try: + result = await state.call( + sid(request), + str(payload.get("method") or "GET"), + str(payload.get("path") or "/"), + payload.get("query"), + payload.get("body"), + ) + except Exception as exc: + result = {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}} + return JSONResponse(result) + + @portal.post("/api/chat") + async def chat(request: Request, payload: dict[str, Any]) -> JSONResponse: + message = str(payload.get("message") or "").strip() + chat_session = str(payload.get("session_id") or f"portal-{uuid.uuid4().hex[:8]}") + if not message: + return JSONResponse({"ok": False, "body": {"error": {"message": "请输入内容"}}}) + try: + return JSONResponse(await state.chat(sid(request), message, chat_session)) + except Exception as exc: + return JSONResponse( + {"ok": False, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}} + ) + + return portal + + +PAGE = r""" + +
+ + +请使用账号登录,系统会按你的角色进入对应界面。
+ + + + + +