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:
@ -22,7 +22,8 @@ def get_current_user_permissions():
|
||||
if not user_role:
|
||||
return []
|
||||
# 超级管理员返回所有字段权限 (忽略大小写)
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
from app.utils.constants import UserRole
|
||||
if str(user_role).strip().upper() == UserRole.SUPER_ADMIN:
|
||||
# 返回所有以 inbound_buy: 开头的权限码(这里我们返回一个特殊标记,表示全部)
|
||||
# 为了简单,我们返回 ['inbound_buy:*'],在过滤函数中特殊处理
|
||||
return ['inbound_buy:*']
|
||||
@ -33,80 +34,9 @@ def get_current_user_permissions():
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
"""
|
||||
根据用户权限过滤字段,无权限的字段值置为 None。
|
||||
所有字段均可通过权限码独立控制。
|
||||
"""
|
||||
# 字段名 → 权限码(与前端 permissionMap、数据库 sys_element/sys_menu 保持一致)
|
||||
field_to_perm = {
|
||||
# 基础身份
|
||||
'id': 'inbound_buy:id',
|
||||
'base_id': 'inbound_buy:base_id',
|
||||
'global_print_id': 'inbound_buy:global_print_id',
|
||||
'global_print_id_str': 'inbound_buy:global_print_id',
|
||||
'company_name': 'inbound_buy:company_name',
|
||||
'material_name': 'inbound_buy:material_name',
|
||||
'spec_model': 'inbound_buy:spec_model',
|
||||
'category': 'inbound_buy:category',
|
||||
'unit': 'inbound_buy:unit',
|
||||
'material_type': 'inbound_buy:material_type',
|
||||
'isInspectionRequired': 'inbound_buy:isInspectionRequired',
|
||||
# 入库身份
|
||||
'sku': 'inbound_buy:sku',
|
||||
'inbound_date': 'inbound_buy:inbound_date',
|
||||
'barcode': 'inbound_buy:barcode',
|
||||
'serial_number': 'inbound_buy:sn_bn',
|
||||
'batch_number': 'inbound_buy:sn_bn',
|
||||
'warehouse_loc': 'inbound_buy:warehouse_loc',
|
||||
'warehouse_location': 'inbound_buy:warehouse_loc',
|
||||
# 状态
|
||||
'status': 'inbound_buy:status',
|
||||
'inspection_status': 'inbound_buy:inspection_status',
|
||||
# 数量(对齐 sys_element 中实际存在的权限码)
|
||||
'in_quantity': 'inbound_buy:qty_inbound',
|
||||
'qty_inbound': 'inbound_buy:qty_inbound',
|
||||
'stock_quantity': 'inbound_buy:qty_stock',
|
||||
'qty_stock': 'inbound_buy:qty_stock',
|
||||
'available_quantity': 'inbound_buy:qty_available',
|
||||
'qty_available': 'inbound_buy:qty_available',
|
||||
# 价格
|
||||
'unit_price': 'inbound_buy:unit_price',
|
||||
'post_tax_unit_price': 'inbound_buy:unit_price',
|
||||
'total_price': 'inbound_buy:total_price',
|
||||
'tax_rate': 'inbound_buy:tax_rate',
|
||||
'currency': 'inbound_buy:currency',
|
||||
'exchange_rate': 'inbound_buy:exchange_rate',
|
||||
# 商务
|
||||
'supplier_name': 'inbound_buy:supplier_name',
|
||||
'purchaser': 'inbound_buy:purchaser',
|
||||
'purchaser_email': 'inbound_buy:purchaser_email',
|
||||
'source_link': 'inbound_buy:source_link',
|
||||
'detail_link': 'inbound_buy:detail_link',
|
||||
# 图片/附件
|
||||
'arrival_photo': 'inbound_buy:arrival_photo',
|
||||
'inspection_report': 'inbound_buy:inspection_report',
|
||||
# 采购单关联
|
||||
'request_id': 'inbound_buy:request_id',
|
||||
'request_no': 'inbound_buy:request_no',
|
||||
}
|
||||
# 通配符(SUPER_ADMIN)不过滤
|
||||
if 'inbound_buy:*' in user_permissions:
|
||||
return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
|
||||
if field in item_dict and perm_code not in user_permissions and base_perm_code not in user_permissions:
|
||||
item_dict[field] = None
|
||||
return item_dict
|
||||
# 如果用户是超级管理员且有 'inbound_buy:*',则不过滤
|
||||
if 'inbound_buy:*' in user_permissions:
|
||||
return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
# 提取不带前缀的基础权限码(如 'serial_number')
|
||||
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
|
||||
# 如果用户的权限列表中,既没有长格式,也没有短格式,才将字段设为 None
|
||||
if field in item_dict and perm_code not in user_permissions and base_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, 'StockBuy', user_permissions)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -196,53 +126,13 @@ def submit():
|
||||
if not data:
|
||||
return jsonify({"code": 400, "msg": "No data"}), 400
|
||||
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
# ★ 白名单模式:仅过滤价格敏感字段,其余全部放行
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'inbound_buy:*' not in user_permissions:
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'id': 'inbound_buy:id',
|
||||
'base_id': 'inbound_buy:base_id',
|
||||
'global_print_id': 'inbound_buy:global_print_id',
|
||||
'sku': 'inbound_buy:sku',
|
||||
'barcode': 'inbound_buy:barcode',
|
||||
'in_date': 'inbound_buy:in_date',
|
||||
'serial_number': 'inbound_buy:serial_number',
|
||||
'batch_number': 'inbound_buy:batch_number',
|
||||
'status': 'inbound_buy:status',
|
||||
'in_quantity': 'inbound_buy:in_quantity',
|
||||
'stock_quantity': 'inbound_buy:stock_quantity',
|
||||
'available_quantity': 'inbound_buy:available_quantity',
|
||||
'inspection_status': 'inbound_buy:inspection_status',
|
||||
'warehouse_location': 'inbound_buy:warehouse_location',
|
||||
'unit_price': 'inbound_buy:unit_price',
|
||||
'post_tax_unit_price': 'inbound_buy:post_tax_unit_price',
|
||||
'tax_rate': 'inbound_buy:tax_rate',
|
||||
'total_price': 'inbound_buy:total_price',
|
||||
'currency': 'inbound_buy:currency',
|
||||
'exchange_rate': 'inbound_buy:exchange_rate',
|
||||
'supplier_name': 'inbound_buy:supplier_name',
|
||||
'buyer_name': 'inbound_buy:buyer_name',
|
||||
'buyer_email': 'inbound_buy:buyer_email',
|
||||
'original_link': 'inbound_buy:original_link',
|
||||
'detail_link': 'inbound_buy:detail_link',
|
||||
'arrival_photo': 'inbound_buy:arrival_photo',
|
||||
'inspection_report': 'inbound_buy:inspection_report',
|
||||
'material_name': 'inbound_buy:material_name',
|
||||
'spec_model': 'inbound_buy:spec_model',
|
||||
'category': 'inbound_buy:category',
|
||||
'unit': 'inbound_buy:unit',
|
||||
'material_type': 'inbound_buy:material_type',
|
||||
'company_name': 'inbound_buy:company_name',
|
||||
}
|
||||
# 复制一份,避免遍历时修改字典
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
# 提取不带前缀的基础权限码(如 'serial_number')
|
||||
base_perm_code = perm_code.split(':')[-1] if ':' in perm_code else perm_code
|
||||
# 如果用户的权限列表中,既没有长格式,也没有短格式,才移除该字段
|
||||
if perm_code and perm_code not in user_permissions and base_perm_code not in user_permissions:
|
||||
for field in ('unit_price', 'post_tax_unit_price', 'tax_rate', 'total_price',
|
||||
'currency', 'exchange_rate'):
|
||||
perm_code = f'inbound_buy:{field}'
|
||||
if field in data and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
|
||||
# 库位必填校验(安全兜底)
|
||||
|
||||
Reference in New Issue
Block a user