diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index b3593c8..b75a8b8 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -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//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({ diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 05af917..649af5f 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -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 = {} diff --git a/inventory-web/src/views/transaction/records.vue b/inventory-web/src/views/transaction/records.vue index 29301f3..5283736 100644 --- a/inventory-web/src/views/transaction/records.vue +++ b/inventory-web/src/views/transaction/records.vue @@ -152,9 +152,12 @@ 接收 拒绝 + 转交 @@ -292,11 +295,10 @@ @click="openScrapDialog(row)" >申请报废 - + 转交 @@ -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