perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板

本次提交包含本会话所有修改的最终统一提交

## 权限系统重构
- permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions
- field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase)
- decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit
- deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity)

## 采购模块
- purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤
- purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1
- purchase/index.vue: canApprove严格操作权限 + upload重复修复

## 导出/入
- base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化
- import_service.py + import_api.py: Excel批量导入(模板+预览+执行)
- ImportDialog.vue: 三步骤导入弹窗

## 异步邮件
- email_service.py: send_email_async (守护线程)
- inventory_task.py: send_email→send_email_async

## 前端对齐
- product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2
- buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载
- BomManage.vue: 懒加载分组 + 导入按钮
- list.vue: 导入按钮
- Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮
- outbound/create.vue: 出库类型必选
- AppMain.vue: 移除transition白屏修复
- material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
This commit is contained in:
yueli
2026-07-17 13:07:12 +08:00
parent 2e903cff2c
commit 3c0954598c
12 changed files with 259 additions and 265 deletions

View File

@ -14,33 +14,15 @@ def get_current_user_permissions():
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role: return []
if user_role.upper() == 'SUPER_ADMIN': return ['inbound_semi:*']
from app.utils.constants import UserRole
if str(user_role).strip().upper() == UserRole.SUPER_ADMIN: return ['inbound_semi:*']
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
return perm_dict.get('menus', []) + perm_dict.get('elements', [])
def filter_item_by_permissions(item_dict, user_permissions):
"""根据用户权限过滤字段,无权限的字段值置为 None"""
field_to_perm = {
'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name',
'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category',
'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model',
'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date',
'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number',
'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status',
'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:qty_inbound',
'stock_quantity': 'inbound_semi:qty_stock', 'available_quantity': 'inbound_semi:qty_available',
'warehouse_location': 'inbound_semi:warehouse_loc', 'bom_code': 'inbound_semi:bom_code',
'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code',
'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost',
'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager',
'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time',
'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link',
'detail_link': 'inbound_semi:detail_link',
}
if 'inbound_semi:*' 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: item_dict[field] = None
return item_dict
"""严格 Default Deny 字段过滤 (see app/utils/field_permissions.py)"""
from app.utils.field_permissions import apply_strict_rbac
return apply_strict_rbac(item_dict, 'StockSemi', user_permissions)
@inbound_semi_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_semi')
@ -139,10 +121,10 @@ def submit():
user_permissions = get_current_user_permissions()
if 'inbound_semi:*' not in user_permissions:
field_to_perm = {'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name', 'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category', 'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model', 'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date', 'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number', 'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status', 'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:in_quantity', 'stock_quantity': 'inbound_semi:stock_quantity', 'available_quantity': 'inbound_semi:available_quantity', 'warehouse_location': 'inbound_semi:warehouse_location', 'bom_code': 'inbound_semi:bom_code', 'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code', 'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost', 'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager', 'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time', 'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link', 'detail_link': 'inbound_semi:detail_link'}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
for field in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'total_price'):
perm_code = f'inbound_semi:{field}'
if field in data and perm_code not in user_permissions:
data.pop(field, None)
new_stock = SemiInboundService.handle_inbound(data)
# ★ Fail-Closed: 入库成功响应剥离成本字段
resp = new_stock.to_dict()
@ -166,10 +148,10 @@ def update_semi(id):
data = request.get_json()
user_permissions = get_current_user_permissions()
if 'inbound_semi:*' not in user_permissions:
field_to_perm = {'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name', 'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category', 'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model', 'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date', 'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number', 'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status', 'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:in_quantity', 'stock_quantity': 'inbound_semi:stock_quantity', 'available_quantity': 'inbound_semi:available_quantity', 'warehouse_location': 'inbound_semi:warehouse_location', 'bom_code': 'inbound_semi:bom_code', 'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code', 'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost', 'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager', 'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time', 'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link', 'detail_link': 'inbound_semi:detail_link'}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
for field in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'total_price'):
perm_code = f'inbound_semi:{field}'
if field in data and perm_code not in user_permissions:
data.pop(field, None)
SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e: