From 4bd6765ab434156f1fa396a0381dc1e4cb683c9f Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 10:20:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(borrow):=20=E8=BD=AC=E4=BA=A4=E5=8F=91?= =?UTF-8?q?=E8=B5=B7=E6=94=B6=E7=B4=A7=E4=B8=BA=E3=80=8C=E4=BB=85=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E6=8C=81=E6=9C=89=E4=BA=BA=E6=9C=AC=E4=BA=BA=E3=80=8D?= =?UTF-8?q?=EF=BC=88=E8=B4=A3=E4=BB=BB=E9=93=BE=E9=9A=94=E7=A6=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 此前【转交】只要持有 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 · 接收环节不受影响;库存零副作用、数据零残留 --- inventory-backend/app/api/v1/transactions.py | 19 ++++++++++--- .../app/services/trans_service.py | 27 ++++++++++++++++--- .../src/views/transaction/records.vue | 26 ++++++++++++------ 3 files changed, 57 insertions(+), 15 deletions(-) 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