Files
group_fqcd_jr/tools/grant_customer_service_phase2_permissions.py
T

211 lines
8.6 KiB
Python
Raw 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` 满足。
## ⚠️ 与 `seed_test_rbac.py` 的冲突(与投顾那次同源)
`seed_test_rbac.py` 是 **DELETE 重建**语义,它的
`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099` 会**清掉本脚本建的权限**。
所以本脚本**只增不删**,用于局部补齐;若要长期固化,请把这三个权限并进
`seed_test_rbac.py` 的 `PERMISSIONS` 常量(投顾那 16 个也是同一个待办)。
权限 id 从 **9036** 起:避开种子占用的 9001-9019,也避开投顾的 9020-9035。
用法:
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, permission_code, resource, action, data_scope)
PHASE2_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9036, "memory:candidate:confirm", "memory:candidate", "confirm", "self"),
(9037, "memory:candidate:review", "memory:candidate", "review", "all"),
(9038, "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())