feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离)
背景
----
此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 ——
任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为
**只有该物品的当前持有人本人可以发起**。
改动(前后端同改,缺一不可)
----
· service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细
current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。
· get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端
localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。
· 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有
的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来
(后端会拒,列出来只会误导)。
★ 连带调整:移除 route 上的 permission_required('borrow_transfer')
责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限,
再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人
反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。
真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。
⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与
4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口,
可在此基础上加豁免;若确定不需要,该权限码可择期下线。
验证(13 项断言全通过)
----
· 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒
· 持有人本人发起成功,from_user_id 正确记为持有人
· can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True
· 接收环节不受影响;库存零副作用、数据零残留
This commit is contained in:
@ -588,7 +588,8 @@ def get_borrow_user_options():
|
||||
|
||||
★ 为什么只做 @jwt_required() 而不加 permission_required:
|
||||
同一份名单被三个页面共用 —— 借出(op_borrow:operation)、
|
||||
归还(op_return:operation)、转交(borrow_transfer)。绑定其中任一权限码,
|
||||
归还(op_return:operation)、转交(发起人是**持有人本人**,可能不具备任何
|
||||
库管权限)。绑定其中任一权限码,
|
||||
另外两个页面都会 403。此处沿用 /auth/users/approvers 的既有处理,
|
||||
且**只返回 id 与姓名**,不含邮箱/角色/部门等字段,最小披露。
|
||||
|
||||
@ -616,11 +617,14 @@ def get_borrow_user_options():
|
||||
# --- 发起借库转交(双向握手第一步)---
|
||||
@trans_bp.route('/borrow/<int:borrow_id>/transfer', methods=['POST'])
|
||||
@jwt_required()
|
||||
# ★ 幂等锁置于 permission_required 内层:prevent_double_submit 依赖
|
||||
# get_jwt_identity(),放外层会因 JWT 未验证而抛错,被其自身 except 捕获后降级放行
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
@permission_required('borrow_transfer')
|
||||
def transfer_borrow(borrow_id):
|
||||
# ★ 为什么不加 permission_required('borrow_transfer'):
|
||||
# 转交的责任链隔离规则是「**只有当前持有人本人**可以发起」,而持有人是
|
||||
# 普通员工,通常并不持有库管权限。若再挂一道库管权限,实际能发起的人
|
||||
# 变成「持有人 ∩ 库管」,绝大多数持有人反而发不了 —— 功能形同虚设。
|
||||
# 这与 accept / reject 同级:员工处置自己名下资产,不是库管职权。
|
||||
# 真正的边界在 service 层的 caller_user_id 强校验。
|
||||
"""
|
||||
发起借库转交:把一张借出单的持有权**整单**转给另一人,等待对方确认。
|
||||
|
||||
@ -640,6 +644,10 @@ def transfer_borrow(borrow_id):
|
||||
不受影响,故「借 2 件只转 1 件」得到天然支持;同单不同明细归属不同持有人
|
||||
是正常业务形态。
|
||||
|
||||
★ 责任链隔离:**只有该物品的当前持有人本人**可以发起转交 —— 物品在谁手上,
|
||||
就只能由谁把它交出去。这不是库管代办的场景(那是借出环节的职责),
|
||||
否则任何人都能把别人保管的资产「转」给第三方。
|
||||
|
||||
★ 严禁触碰库存:转交是纯持有权变更,实物不出入库,
|
||||
stock_buy / stock_semi / stock_product 的任何字段都不会被修改。
|
||||
"""
|
||||
@ -652,6 +660,9 @@ def transfer_borrow(borrow_id):
|
||||
transfer_qty=data.get('transfer_qty'),
|
||||
operator_name=_current_username(),
|
||||
remark=data.get('remark'),
|
||||
# ★ 责任链隔离:service 层强校验调用者就是该物品的当前持有人本人。
|
||||
# 前端隐藏按钮只是降噪,这里才是真正的边界。
|
||||
caller_user_id=get_jwt_identity(),
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
|
||||
@ -527,7 +527,8 @@ class TransService:
|
||||
# 故同单的其他明细可以同时各自挂着待接收,互不阻塞。)
|
||||
# ==========================================================================
|
||||
@staticmethod
|
||||
def transfer_borrow(borrow_id, to_user_id, transfer_qty=None, operator_name='System', remark=None):
|
||||
def transfer_borrow(borrow_id, to_user_id, transfer_qty=None, operator_name='System', remark=None,
|
||||
caller_user_id=None):
|
||||
"""
|
||||
发起转交(双向握手第一步):只落一条 PENDING 流水,**不动物权**。
|
||||
|
||||
@ -538,9 +539,11 @@ class TransService:
|
||||
----
|
||||
borrow_id : 该单**任一明细行**的 ID,仅用于解析单据身份(borrow_no)
|
||||
to_user_id : 接收人ID
|
||||
transfer_qty : 可选。传入时须等于整单待还量,仅作一致性校验
|
||||
operator_name: 发起操作的库管
|
||||
transfer_qty : 可选。传入时须等于该明细待还量,仅作一致性校验
|
||||
operator_name: 操作人展示名(写入流水备查)
|
||||
remark : 转交备注
|
||||
caller_user_id: **调用者本人ID**,强校验其必须是该明细的当前持有人。
|
||||
传 None 一律拒绝,不做「系统内部调用」的隐式放行。
|
||||
|
||||
返回已 commit 的 TransBorrowTransfer
|
||||
异常 ValueError
|
||||
@ -594,6 +597,15 @@ class TransService:
|
||||
from_id = int(record.current_holder_id)
|
||||
from_name = record.current_holder_name or user_display_name(SysUser.query.get(from_id))
|
||||
|
||||
# --- 3.5 责任链隔离:只有当前持有人本人可以发起转交 ---
|
||||
# 物品在谁手上,就只能由谁把它交出去 —— 否则任何人都能把别人保管的
|
||||
# 资产「转」给第三方,责任链形同虚设。
|
||||
# ★ 前端隐藏按钮只是降噪,**这里才是真正的边界**:接口可被直接调用。
|
||||
if caller_user_id is None or int(caller_user_id) != from_id:
|
||||
raise ValueError(
|
||||
f"只有该物品的当前持有人【{from_name}】本人可以发起转交"
|
||||
)
|
||||
|
||||
# --- 4. 接收人校验 ---
|
||||
to_user = SysUser.query.get(to_user_id)
|
||||
if not to_user:
|
||||
@ -1443,6 +1455,15 @@ class TransService:
|
||||
and int(_pt['to_user_id']) == int(current_user_id)
|
||||
)
|
||||
d['pending_transfer'] = _pt
|
||||
# ★ 谁能发起转交:**只有该明细当前的持有人本人**。
|
||||
# 前端据此显示【转交】,后端 transfer_borrow 做同样的强校验 ——
|
||||
# 界面遮挡不是安全边界,两处必须同口径。
|
||||
# 同样由后端判定:前端 localStorage 里没有 user_id。
|
||||
d['can_transfer'] = (
|
||||
current_user_id is not None
|
||||
and d.get('current_holder_id') is not None
|
||||
and int(d['current_holder_id']) == int(current_user_id)
|
||||
)
|
||||
else:
|
||||
_pending_map = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user