From c07f25b6468663d4e71f10b580add1c21ff6c659 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 16 Jul 2026 11:26:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Fail-Closed=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=BA=A7=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=20=E2=80=94=20?= =?UTF-8?q?=E5=A0=B5=E4=BD=8F15+=E7=AB=AF=E7=82=B9=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E6=B3=84=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## stock.py - _make_price_stripper(): 工厂函数,选单前缀自动剥离所有价格/成本字段 - _do_get_stock_list(permission_prefix): 每个item.to_dict()后剥离价格 - /all 端点: 非AI模式自动剥离价格字段 - /list 端点: permission_prefix='outbound_selection' 传递 ## outbound.py - /bom-match-stock: 返回前按stock_type剥离全部价格/成本字段 - /scan: result.pop('price', None) ## scrap.py - /scan: result.pop('price', None) - /records: 每条记录剥离 cost_at_scrap, total_loss ## transactions.py - filter_item_by_permissions: 从空字典恢复完整20字段映射 - /borrow/stock-list: permission_prefix='op_borrow_apply' 传递 ## buy.py / semi.py / product.py - submit 成功响应剥离所有价格/成本字段(不泄露给前端) ## bom.py - /base/list: 剥离 referencePrice --- inventory-backend/app/api/v1/bom.py | 9 ++- inventory-backend/app/api/v1/inbound/buy.py | 8 ++- .../app/api/v1/inbound/product.py | 6 +- inventory-backend/app/api/v1/inbound/semi.py | 6 +- inventory-backend/app/api/v1/inbound/stock.py | 59 +++++++++++++++++-- inventory-backend/app/api/v1/outbound.py | 18 ++++++ inventory-backend/app/api/v1/scrap.py | 6 ++ inventory-backend/app/api/v1/transactions.py | 42 ++++++++----- 8 files changed, 127 insertions(+), 27 deletions(-) diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index 4b27382..b867440 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -370,9 +370,14 @@ def get_material_base_list(): page=page, per_page=limit, error_out=False ) - # 构建返回数据 + # 构建返回数据 — ★ Fail-Closed: 剥离 referencePrice + items = [] + for item in pagination.items: + d = item.to_dict() + d.pop('referencePrice', None) + items.append(d) data = { - 'list': [item.to_dict() for item in pagination.items], + 'list': items, 'total': pagination.total } diff --git a/inventory-backend/app/api/v1/inbound/buy.py b/inventory-backend/app/api/v1/inbound/buy.py index 7f362ed..e7e33d9 100644 --- a/inventory-backend/app/api/v1/inbound/buy.py +++ b/inventory-backend/app/api/v1/inbound/buy.py @@ -252,10 +252,16 @@ def submit(): new_stock = BuyInboundService.handle_inbound(data) + # ★ Fail-Closed: 入库成功响应剥离价格字段 + resp = new_stock.to_dict() + for k in ('unit_price', 'post_tax_unit_price', 'total_price', + 'tax_rate', 'currency', 'exchange_rate', 'pre_tax_unit_price'): + resp.pop(k, None) + return jsonify({ "code": 200, "msg": "入库成功", - "data": new_stock.to_dict() + "data": resp }) except Exception as e: traceback.print_exc() diff --git a/inventory-backend/app/api/v1/inbound/product.py b/inventory-backend/app/api/v1/inbound/product.py index 75c80a1..8340d96 100644 --- a/inventory-backend/app/api/v1/inbound/product.py +++ b/inventory-backend/app/api/v1/inbound/product.py @@ -149,7 +149,11 @@ def submit(): perm_code = field_to_perm.get(field) if perm_code and perm_code not in user_permissions: data.pop(field, None) new_stock = ProductInboundService.handle_inbound(data) - return jsonify({"code": 200, "msg": "入库成功", "data": new_stock.to_dict()}) + # ★ Fail-Closed: 入库成功响应剥离成本/售价字段 + resp = new_stock.to_dict() + for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'sale_price'): + resp.pop(k, None) + return jsonify({"code": 200, "msg": "入库成功", "data": resp}) except Exception as e: traceback.print_exc() return jsonify({"code": 500, "msg": str(e)}), 500 diff --git a/inventory-backend/app/api/v1/inbound/semi.py b/inventory-backend/app/api/v1/inbound/semi.py index a643b9f..ea9f478 100644 --- a/inventory-backend/app/api/v1/inbound/semi.py +++ b/inventory-backend/app/api/v1/inbound/semi.py @@ -144,7 +144,11 @@ def submit(): perm_code = field_to_perm.get(field) if perm_code and perm_code not in user_permissions: data.pop(field, None) new_stock = SemiInboundService.handle_inbound(data) - return jsonify({"code": 200, "msg": "入库成功", "data": new_stock.to_dict()}) + # ★ Fail-Closed: 入库成功响应剥离成本字段 + resp = new_stock.to_dict() + for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'total_price'): + resp.pop(k, None) + return jsonify({"code": 200, "msg": "入库成功", "data": resp}) except Exception as e: traceback.print_exc() return jsonify({"code": 500, "msg": str(e)}), 500 diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index e83fd05..af78a3f 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -150,8 +150,11 @@ def get_all_stock(): 支持 AI 极简模式: ?ai_mode=true - 只返回 name / spec / availableQuantity 三个字段 - 键名压缩为 n / s / c + + ★ Fail-Closed: 非 AI 模式自动剥离所有价格成本字段 """ ai_mode = request.args.get('ai_mode', '').lower() == 'true' + _strip = _make_price_stripper('inventory_stocktake') try: all_items = [] @@ -170,7 +173,9 @@ def get_all_stock(): 'c': float(item.available_quantity or 0) }) else: - all_items.append(item.to_dict()) + d = item.to_dict() + _strip(d, 'buy') + all_items.append(d) # 2. 半成品 if StockSemi: @@ -187,7 +192,9 @@ def get_all_stock(): 'c': float(item.available_quantity or 0) }) else: - all_items.append(item.to_dict()) + d = item.to_dict() + _strip(d, 'semi') + all_items.append(d) except Exception: pass @@ -206,7 +213,9 @@ def get_all_stock(): 'c': float(item.available_quantity or 0) }) else: - all_items.append(item.to_dict()) + d = item.to_dict() + _strip(d, 'product') + all_items.append(d) except Exception: pass @@ -219,9 +228,41 @@ def get_all_stock(): # ============================================================================== # 分页库存查询接口(服务端分页,出库/盘点/借用模块共用) # ============================================================================== -def _do_get_stock_list(): +def _make_price_stripper(permission_prefix=None): + """ + Fail-Closed 价格字段剥离器工厂。 + + 选单/借用场景默认剥离所有价格字段; + 仅当 permission_prefix 不在已知选单前缀列表中时放行(例如 material_list 场景由调用方自行处理)。 + """ + # 已知的选单/借用前缀 → 无条件剥离所有价格成本 + SELECTION_PREFIXES = {'outbound_selection', 'op_borrow_apply', 'inventory_stocktake'} + + def stripper(d, stock_category): + if permission_prefix is None or permission_prefix in SELECTION_PREFIXES: + if stock_category == 'buy': + for k in ('unit_price', 'post_tax_unit_price', 'total_price', + 'tax_rate', 'currency', 'exchange_rate', + 'pre_tax_unit_price', 'qty_inbound', 'in_quantity'): + d.pop(k, None) + elif stock_category == 'semi': + for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', + 'total_price', 'unit_price'): + d.pop(k, None) + elif stock_category == 'product': + for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', + 'sale_price', 'unit_price'): + d.pop(k, None) + return stripper + + +def _do_get_stock_list(permission_prefix=None): """ 分页获取库存列表(stock_quantity > 0) — 裸逻辑,供各模块复用 + + Args: + permission_prefix: 可选权限前缀(如 'outbound_selection' / 'op_borrow_apply')。 + 传入后自动剥离对应模块无权查看的价格/成本字段。 """ try: page = request.args.get('page', 1, type=int) @@ -233,6 +274,9 @@ def _do_get_stock_list(): if pageSize < 1 or pageSize > 200: pageSize = 20 + # ★ Fail-Closed: 选单/借用场景默认剥离所有价格成本字段 + _strip_price_fields = _make_price_stripper(permission_prefix) + all_items = [] # 1. 采购件 @@ -255,6 +299,7 @@ def _do_get_stock_list(): d['name'] = d.get('material_name', d.get('name', '')) d['standard'] = d.get('spec_model', d.get('standard', '')) d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0)) + _strip_price_fields(d, 'buy') all_items.append(d) # 2. 半成品 @@ -278,6 +323,7 @@ def _do_get_stock_list(): d['name'] = d.get('material_name', d.get('name', '')) d['standard'] = d.get('spec_model', d.get('standard', '')) d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0)) + _strip_price_fields(d, 'semi') all_items.append(d) except Exception: pass @@ -304,6 +350,7 @@ def _do_get_stock_list(): d['name'] = d.get('material_name', d.get('name', '')) d['standard'] = d.get('spec_model', d.get('standard', '')) d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0)) + _strip_price_fields(d, 'product') all_items.append(d) # ── 按规格+库位聚合(出库选单合并同类项)─────────────────────── @@ -360,8 +407,8 @@ def _do_get_stock_list(): @jwt_required() @permission_required('outbound_selection') def get_stock_list(): - """出库选单专用库存列表""" - return _do_get_stock_list() + """出库选单专用库存列表 — Fail-Closed: 剥离价格字段""" + return _do_get_stock_list(permission_prefix='outbound_selection') # --- 草稿箱接口 --- diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 1290fc9..c5ad37b 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -86,6 +86,8 @@ def scan_barcode(): result = OutboundService.get_stock_by_barcode(barcode) if result: + # ★ Fail-Closed: 扫码响应剥离价格字段 + result.pop('price', None) return jsonify({ 'code': 200, 'msg': '扫描成功', @@ -279,6 +281,22 @@ def bom_match_stock(): except Exception: pass + # ★ Fail-Closed: 剥离所有价格成本字段(BOM 匹配用于出库选单,无需价格) + for d in all_items: + stype = d.get('stock_type', '') + if stype == 'material': + for k in ('unit_price', 'post_tax_unit_price', 'total_price', + 'tax_rate', 'currency', 'exchange_rate'): + d.pop(k, None) + elif stype == 'semi': + for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', + 'total_price', 'unit_price'): + d.pop(k, None) + elif stype == 'product': + for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', + 'sale_price', 'unit_price'): + d.pop(k, None) + return jsonify({'code': 200, 'msg': 'success', 'data': {'items': all_items}}) except Exception as e: diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 9dc86b5..5971239 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -49,6 +49,8 @@ def scan_barcode(): try: result = ScrapService.get_stock_by_barcode(barcode) if result: + # ★ Fail-Closed: 扫码响应剥离价格字段 + result.pop('price', None) return jsonify({'code': 200, 'msg': '扫描成功', 'data': result}) else: return jsonify({'code': 404, 'msg': '未找到对应的库存记录'}), 404 @@ -124,6 +126,10 @@ def get_scrap_records(): start_date=start_date, end_date=end_date ) + # ★ Fail-Closed: 报废记录剥离成本字段 + for item in (result.get('list') or []): + for k in ('cost_at_scrap', 'total_loss'): + item.pop(k, None) return jsonify({'code': 200, 'msg': 'success', 'data': result}) except Exception as e: traceback.print_exc() diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index 279f257..5e442d1 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -44,23 +44,33 @@ def get_current_user_info(): def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'): """ 根据用户权限过滤 item 字典,无权限的字段值置为 None + + ★ Fail-Closed: 字段映射默认为完整列表,不再为空字典。 """ - # 字段名到权限码的映射(与前端 permissionMap 保持一致) field_to_perm = { - # 'borrow_no': f'{prefix}:borrow_no', - # 'borrower_name': f'{prefix}:borrower_name', - # 'sku': f'{prefix}:sku', - # 'borrow_time': f'{prefix}:borrow_time', - # 'return_time': f'{prefix}:return_time', - # 'return_operator': f'{prefix}:return_operator', - # 'status': f'{prefix}:status', - # 'expected_return_time': f'{prefix}:expected_return_time', - # 'return_location': f'{prefix}:return_location', - # 'borrow_signature': f'{prefix}:borrow_signature', - # 'return_signature': f'{prefix}:return_signature', + 'id': f'{prefix}:id', + 'borrow_no': f'{prefix}:borrow_no', + 'borrower_name': f'{prefix}:borrower_name', + 'sku': f'{prefix}:sku', + 'source_table': f'{prefix}:source_table', + 'stock_id': f'{prefix}:stock_id', + 'barcode': f'{prefix}:barcode', + 'quantity': f'{prefix}:quantity', + 'returned_quantity': f'{prefix}:returned_quantity', + 'borrow_time': f'{prefix}:borrow_time', + 'return_time': f'{prefix}:return_time', + 'return_operator': f'{prefix}:return_operator', + 'return_location': f'{prefix}:return_location', + 'status': f'{prefix}:status', + 'expected_return_time': f'{prefix}:expected_return_time', + 'borrow_signature': f'{prefix}:borrow_signature', + 'return_signature': f'{prefix}:return_signature', + 'remark': f'{prefix}:remark', + 'material_name': f'{prefix}:material_name', + 'is_returned': f'{prefix}:is_returned', + 'current_location': f'{prefix}:current_location', } - # 如果用户是超级管理员且有 '*',则不过滤 - if '*' in user_permissions: + if '*' in user_permissions or f'{prefix}:*' in user_permissions: return item_dict for field, perm_code in field_to_perm.items(): if field in item_dict and perm_code not in user_permissions: @@ -290,9 +300,9 @@ def get_borrow_request_list(): @jwt_required() @permission_required('op_borrow_apply') def get_borrow_stock_list(): - """借库选单专用库存列表,与出库选单共享底层逻辑""" + """借库选单专用库存列表 — Fail-Closed: 剥离价格字段""" from app.api.v1.inbound.stock import _do_get_stock_list - return _do_get_stock_list() + return _do_get_stock_list(permission_prefix='op_borrow_apply') # --- 执行借库扣减(审批通过后调用)---