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:
yueli
2026-09-17 10:20:34 +08:00
parent 5f0cdc3adb
commit 4bd6765ab4
3 changed files with 57 additions and 15 deletions

View File

@ -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({