1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
174 lines
6.9 KiB
Python
174 lines
6.9 KiB
Python
"""补齐运营(`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 pathlib import Path
|
||
|
||
from sqlalchemy import text
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from app.infrastructure.db import SessionFactory # noqa: E402
|
||
|
||
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, ...] = (
|
||
# Agent 公共入口:没有该权限,请求会在 Agent 类型和工具权限校验之前被拒绝。
|
||
"agent:run",
|
||
# 场外运营(角色门槛之外,这个码是场外线自己声明的)
|
||
"offsite:write",
|
||
# 金融 NL2SQL:角色白名单 {advisor, operator, admin, super_admin} 含 operator。
|
||
# ⚠️ 全库与 NL2SQL 相关的权限码**只有这一个**(`offsite:nl2sql` 在
|
||
# `sys_permission` 里并不存在,是 `offsite_fund_service` 里 any-of 校验的死值),
|
||
# 所以"NL2SQL 的全部权限"就是它。
|
||
"financial:nl2sql:read",
|
||
# 产品推介材料(生成/查看/审核/发送):`promotion_material_service.py` 的八处
|
||
# `_require` 恰好只用到这四个码(72/91/112/136 要 write,150/161 要 read,
|
||
# 211 要 review,229 要 deliver)。运营要"生成推介材料"就必须四个齐全 ——
|
||
# 只给 write 会在查看详情(read)那一步 403。
|
||
"promotion:write",
|
||
"promotion:read",
|
||
"promotion:review",
|
||
"promotion:deliver",
|
||
)
|
||
|
||
|
||
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())
|