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

View File

@ -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 = {}

View File

@ -152,9 +152,12 @@
<el-button type="success" link size="small" @click="acceptOne(c)">接收</el-button>
<el-button type="danger" link size="small" @click="rejectOne(c)">拒绝</el-button>
</template>
<!-- 只有该物品的**当前持有人本人**能发起转交can_transfer
由后端判定前端 localStorage 里没有 user_id
后端 transfer_borrow 有同样的强校验 这里只是降噪
真正的边界在服务端 -->
<el-button
v-else-if="(c.pending_quantity || 0) > 0 && !c.pending_transfer"
v-permission="'borrow_transfer'"
v-else-if="(c.pending_quantity || 0) > 0 && !c.pending_transfer && c.can_transfer"
type="primary" link size="small"
@click="openTransferDialog(props.row, c)"
>转交</el-button>
@ -292,11 +295,10 @@
@click="openScrapDialog(row)"
>申请报废</el-button>
<!-- 主行入口进入弹窗后勾选具体物品默认全选可转交项
精准的单件转交在展开后的明细行操作列里两者共用同一个弹窗 -->
<!-- 主行入口仅当该单下有**由我持有**的未还物品时才出现
can_transfer 由后端按当前持有人 == 判定 -->
<el-button
v-if="isUnreturned(row)"
v-permission="'borrow_transfer'"
v-if="canTransferAny(row)"
type="primary" link size="small"
@click="openTransferDialog(row)"
>转交</el-button>
@ -782,6 +784,11 @@ const transferTotalQty = computed(() =>
transferSelected.value.reduce((s: number, c: any) => s + (Number(c.pending_quantity) || 0), 0)
)
// 该单下是否有「由我持有、可发起转交」的未还物品 —— 主行【转交】按钮的显示条件。
// can_transfer 由后端按「当前持有人 == 我」判定(前端 localStorage 无 user_id
const canTransferAny = (row: any): boolean =>
(row.children || []).some((c: any) => (c.pending_quantity || 0) > 0 && c.can_transfer)
// 该单下所有「待我接收」的转交 —— 主行聚合用。
// (明细行只看自己那一条,见模板里的 c.pending_transfer
const myPendings = (row: any): any[] =>
@ -810,9 +817,12 @@ const loadTransferUsers = async () => {
* 故这里用勾选而非「整单」。
*/
const openTransferDialog = async (row: any, detail?: any) => {
const candidates = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0)
// ★ 只列出「由我持有」的未还物品:不是我的,后端会拒,列在弹窗里只会误导。
const candidates = (row.children || []).filter(
(c: any) => (c.pending_quantity || 0) > 0 && c.can_transfer
)
if (!candidates.length) {
ElMessage.warning('该单号下没有未归还的明细,无法转交')
ElMessage.warning('该单号下没有由您持有的未归还物品,无法转交')
return
}
transferBorrowNo.value = row.borrow_no