Files
group_fqcd_jr/tools/grant_operator_role.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

174 lines
6.9 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.
"""补齐运营(`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())