Files
group_fqcd_jr/tools/grant_customer_service_phase2_permissions.py

217 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""补客服二期(画像候选 + 转人工工单)需要的三个权限码。
## 为什么需要它
客服二期这条线新增了 6 个接口,其中 5 个要三个**平台此前不存在的权限码**:
| 接口 | 权限码 |
|---|---|
| `POST /api/v1/users/me/memory-candidates/{id}/decisions` | `memory:candidate:confirm` |
| `GET /api/v1/admin/customer-profile-candidates` | `memory:candidate:review` |
| `POST /api/v1/admin/customer-profile-candidates/{id}/reviews` | `memory:candidate:review` |
| `GET /api/v1/admin/customer-service/handover-tickets` | `handover:read` |
| `GET /api/v1/admin/customer-service/handover-tickets/{ticket_no}` | `handover:read` |
这三个码在 **没有种子脚本、也没有迁移** 的情况下被业务代码直接引用,
所以它们属于**环境数据**:代码合过来以后,如果库里没有这三行,上面 5 个接口一律
`403 缺少操作权限` —— 报错看起来像"权限配错了",实际是**权限码根本不存在**。
(`GET /api/v1/users/me/memory-candidates` 用的是既有的 `memory:read:self`,不受影响。)
这与 `AGENTS.md` §E 那条环境口径是同一类问题:
`config_release`、`sys_role`/`sys_permission`/`sys_user`、Milvus 集合 schema、
`advisor_product_*` **都不随代码合并**,换环境必须重放。
## 授给谁 —— 依据是代码里的真实校验
`app/service/authorization_service.py:11-17` 的语义是:
```python
allowed = permission in context.permissions
if admin:
allowed = allowed and bool({"admin", "super_admin"}.intersection(context.roles))
```
| 权限码 | 校验方式 | 授给 | 理由 |
|---|---|---|---|
| `memory:candidate:confirm` | `require(..., USER_CONFIRM_PERMISSION)`,**非 admin** | `customer` | 客户确认**自己**的候选;查询按 `customer_id` 过滤,不会越权 |
| `memory:candidate:review` | `require(..., ADMIN_REVIEW_PERMISSION, admin=True)` | `admin` | 需同时满足权限与管理员角色 |
| `handover:read` | `require(..., "handover:read", admin=True)` | `admin` | 同上 |
我方库里没有 `super_admin` 角色,`admin=True` 那一支由 `admin` 满足。
## ✅ 已并入种子(2026-09-12)
这三个权限码**已经并进 `seed_test_rbac.py` 的 `PERMISSIONS`(id 9044-9046)**,
所以"换环境 / 重跑种子之后接口又 403"这个根问题已经解决。本脚本保留下来作为
**幂等补齐**:库里临时缺这几行、或只想跑这一个脚本时用它。
权限 id 的号段现状:种子占 9001-9034,投顾治理类占 9041-9043,本脚本用 **9044-9046**。
⚠️ 注意 `seed_test_rbac.py` 是 **DELETE 重建**语义,它的
`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099` 会删掉该号段内**所有**权限
再按常量重建 —— 所以**权限码的定义以种子为准**。以后新增权限码请先并进种子的
`PERMISSIONS`,不要只写一个脚本。
用法:
python tools/grant_customer_service_phase2_permissions.py --dry-run # 只打印将写入什么
python tools/grant_customer_service_phase2_permissions.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]
#: 权限 id 从 9044 起:种子 `seed_test_rbac.py` 已占 9001-9034,投顾治理类用 9041-9043,
#: 本脚本再接 9044-9046。这三个权限码**已并进种子**,所以本脚本只做幂等补齐与授权。
#: (id, permission_code, resource, action, data_scope)
PHASE2_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"),
)
#: 角色 id(`seed_test_rbac.py` 建的)。
CUSTOMER_ROLE_ID = 9001
ADMIN_ROLE_ID = 9003
#: 每个角色该拿到哪些码。
GRANTS: tuple[tuple[int, str, tuple[str, ...]], ...] = (
(CUSTOMER_ROLE_ID, "customer", ("memory:candidate:confirm",)),
(ADMIN_ROLE_ID, "admin", ("memory:candidate:confirm", "memory:candidate:review",
"handover:read")),
)
async def apply(*, dry_run: bool) -> int:
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
existing = dict(
(await session.execute(
text("SELECT permission_code, id FROM sys_permission")
)).all()
)
to_create = [p for p in PHASE2_PERMISSIONS if p[1] not in existing]
print(f"权限:库里已有 {len(existing)} 个,本次新增 {len(to_create)} 个")
for _, code, resource, action, scope in to_create:
print(f" + {code:<32} {resource}:{action} scope={scope}")
if not to_create:
print(" (三个权限都已在库中)")
role_ids: dict[str, int] = {}
for role_id, role_code, _codes in GRANTS:
found = await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = :code"),
{"code": role_code},
)
print(f"角色 {role_code:<10} {'已存在' if found else '缺失(请先跑 seed_test_rbac.py)'}")
if found is not None:
role_ids[role_code] = int(found)
if dry_run:
print("\n[dry-run] 未写入任何数据。")
return 0
for perm_id, code, resource, action, scope in to_create:
await session.execute(
text(
"""
INSERT INTO sys_permission
(id, permission_code, resource, action, data_scope, created_at, updated_at)
VALUES
(:id, :code, :resource, :action, :scope, :now, :now)
"""
),
{"id": perm_id, "code": code, "resource": resource,
"action": action, "scope": scope, "now": now},
)
# 重新取一遍完整映射,避免依赖本次插入的 id。
permission_ids = dict(
(await session.execute(
text("SELECT permission_code, id FROM sys_permission")
)).all()
)
for role_id, role_code, codes in GRANTS:
if role_code not in role_ids:
print(f"跳过 {role_code}:角色不存在")
continue
have = set(
(await session.scalars(
text("SELECT permission_id FROM sys_role_permission WHERE role_id = :r"),
{"r": role_ids[role_code]},
)).all()
)
added = 0
for code in codes:
perm_id = permission_ids.get(code)
if perm_id is None or int(perm_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_ids[role_code], "p": int(perm_id), "now": now},
)
added += 1
print(f"授权:{role_code:<10} 新增 {added} 项(共 {len(codes)} 项)")
await verify()
return 0
async def verify() -> None:
"""按权限码实测一遍,而不是只看插了几行。"""
async with SessionFactory() as session:
rows = (
await session.execute(
text(
"""
SELECT p.permission_code, r.role_code
FROM sys_permission p
JOIN sys_role_permission rp ON rp.permission_id = p.id
JOIN sys_role r ON r.id = rp.role_id
WHERE p.permission_code IN
('memory:candidate:confirm', 'memory:candidate:review', 'handover:read')
ORDER BY p.permission_code, r.role_code
"""
)
)
).all()
print("\n实测绑定关系:")
for code, role in rows:
print(f" {str(code):<32} → {role}")
missing = {
"memory:candidate:confirm", "memory:candidate:review", "handover:read",
} - {str(code) for code, _ in rows}
if missing:
print(f"[失败] 仍未绑定:{sorted(missing)}")
raise SystemExit(1)
print(
"\n客服二期接口现在应当可用了。若之后跑过 `seed_test_rbac.py`(DELETE 重建 9001-9099),"
"必须重跑本脚本 —— 或先把这三个权限并进那个种子的 PERMISSIONS 常量。"
)
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())