## 一、根因:核对查不到不是"缺数据",是**账户口径没桥接**
实测(客户 10002 的单据):
```
生成的 SQL: JOIN fin_holding h ... WHERE h.trade_account = '10002' ← 单据上的账户标识
结果: 0 行 → 「申请前持有份额」= null → 规则判「无法判断」→ 单据状态 query_failed
```
而库里 `fin_holding.trade_account` 存的是**平台交易账号** `FSA{客户号:06d}`
(与 `fin_sim_account.account_no` 同值,见 `tools/seed_sim_account_demo.py:226`),
客户 10002 **一直持有 15911 共 1000 份**。所以页面上"单据核对与运营动作"是空的,
看起来像缺数据,实为**单据印的账户标识与平台账号是两个口径,中间少了一步换算**。
修法:新增 `OffsiteFundService._platform_account()`,在**单据字段落库的两个入口**
(`_create_document` 新建、`_apply_recognition_fields` 保存/重试)做换算,规则:
1. 已是库里的 `account_no` → 原样;
2. 纯数字且命中 `customer_id` → 用该客户的 `account_no`;
3. 其余原样返回并留 warning —— **失败关闭,不猜不造**(不凭空生成 `FSA099999`)。
一处修好,后端查询、NL2SQL 页面的自然语言、页面展示三处口径一致。
附件上的 OCR **原始值不受影响**(`extracted_fields` 仍是单据上印的 `10002`)。
端到端证据(同一张单据,只走服务层):
| 阶段 | document.account_identifier | 规则数据 | 单据状态 |
|---|---|---|---|
| 修复前 | `10002` | `{}` → 无法判断 | `query_failed` |
| 保存识别字段后 | **`FSA010002`** | — | `query_failed` |
| 触发核对后 | `FSA010002` | **申请前持有份额 1000.0000** → **正常** | **planned** |
## 二、前端:保存识别字段后自动重跑核对
`employee-operations/offsite/offsite.js`:原先 `save-ocr` 只保存 + 重载页面,
**不触发核对**,于是运营改完字段点保存,那两个区块要么停留在上一次核对的状态、
要么整块是空的,必须再手动点一次"重新核对并判定规则"——看起来像"保存没生效"。
现在:保存 == 运营已人工确认该单据内容,因此保存成功后**自动对该附件关联的单据**
执行「触发 NL2SQL → 拉取返回字段 → 重新判定规则」,并在提示里区分"已重新核对"
与"核对未全部成功"。
顺带把这段逻辑抽成 `recalculateDocument(taskId)`,与面板上的"重新核对并判定规则"
按钮**走同一条路径** —— 两处各写一份正是"保存后不刷新"这类不一致的来源。
## 三、运营(operator)角色权限
`tools/grant_operator_role.py` 的授权清单补齐:`promotion:write/read/review/deliver`
(`promotion_material_service.py` 的八处 `_require` 恰好只用这四个码,缺任一都会 403,
例如只给 write 会在查看详情 read 那一步被拒)+ `agent:run`。
已实际执行并**从身份侧验证**(`IdentityRepository.load_context`):
9005 / 9006 现在各 7 项权限,四个推介材料码齐全。
- NL2SQL 全库与它相关的权限码**只有 `financial:nl2sql:read`**(`offsite:nl2sql` 在
`sys_permission` 里并不存在,是 `offsite_fund_service` 里 any-of 校验的死值),
该码运营早已有,本次无需新增。
- 可持续性已核实:`sys_role_permission` / `sys_user_role` **没有外键**,
重跑 `seed_test_rbac.py`(DELETE 重建 9001-9099 号段权限)**不会**删掉运营的绑定;
且本工具按**权限码查 id**、不写死 id,天然抗号段变动。
## 四、10001 / 10002 的 15911 持仓
复核结论:**各 1000 份,且三处口径一致**(`fin_holding.market_value` = 数量 × 最新净值、
净值历史 120 条、`fin_product.current_nav` 与净值最新一条一致、账户可用资金正常)。
`fin_holding` 的唯一键是 `(customer_id, product_id)`,所以**不能**再插一行
`trade_account='10002'` 的"同一个持仓"—— 那会把持仓重复计数,是错的。
需要改数量就用 `python tools/seed_custom_holdings.py --quantity N`(默认就是这两个客户 + 15911)。
## 五、验证与回归
- 新增 `tests/unit/service/test_offsite_account_bridge.py`(5 条:账号原样 / 客户号换算 /
认不出原样返回 / 不凭空造账号 / 空值不查库);
- `pytest tests/unit/service -k offsite` → 34 passed;
- 场外集成测试 4 个文件 → 27 passed;
- `ruff` 干净;`mypy app` 仍只有组员新代码里那 3 个既有错(与本次无关);
- 前端 `node --check offsite.js` 通过。
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())
|