217 lines
9.0 KiB
Python
217 lines
9.0 KiB
Python
"""补客服二期(画像候选 + 转人工工单)需要的三个权限码。
|
||
|
||
## 为什么需要它
|
||
|
||
客服二期这条线新增了 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())
|