Compare commits

2 Commits

Author SHA1 Message Date
dxc
3f83e8742b fix: remove duplicate error messages in BOM manage page
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:20:51 +08:00
dxc
348e4dd024 feat: add RBAC read-write separation and field masking for bom_manage
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:13:02 +08:00
2 changed files with 117 additions and 16 deletions

View File

@ -3,15 +3,61 @@ from app.services.bom_service import BomService
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.bom import BomTable from app.models.bom import BomTable
from app.extensions import db from app.extensions import db
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required, get_jwt
from app.utils.decorators import permission_required
from app.services.auth_service import AuthService
bom_bp = Blueprint('bom', __name__) bom_bp = Blueprint('bom', __name__)
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限
if user_role == 'super_admin':
return ['bom_manage:*']
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'bom_no': 'bom_manage:bom_no',
'parent_name': 'bom_manage:parent_name',
'parent_spec': 'bom_manage:parent_spec',
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'child_count': 'bom_manage:child_count',
}
# 如果用户是超级管理员且有 'bom_manage:*',则不过滤
if 'bom_manage:*' 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
# ==================== 新版 BOM 接口(基于 bom_no ==================== # ==================== 新版 BOM 接口(基于 bom_no ====================
@bom_bp.route('/list', methods=['GET']) @bom_bp.route('/list', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_bom_list(): def get_bom_list():
"""获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤""" """获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤"""
try: try:
@ -20,6 +66,10 @@ def get_bom_list():
active_only = request.args.get('active_only', 'false').lower() == 'true' active_only = request.args.get('active_only', 'false').lower() == 'true'
data = BomService.get_bom_list(keyword=keyword, active_only=active_only) data = BomService.get_bom_list(keyword=keyword, active_only=active_only)
# 字段级脱敏
user_permissions = get_current_user_permissions()
if isinstance(data, list):
data = [filter_item_by_permissions(item, user_permissions) for item in data]
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -32,6 +82,7 @@ def get_bom_list():
@bom_bp.route('/detail/<bom_no>', methods=['GET']) @bom_bp.route('/detail/<bom_no>', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_bom_detail(bom_no): def get_bom_detail(bom_no):
""" """
根据 BOM 编号获取配方详情 根据 BOM 编号获取配方详情
@ -42,6 +93,9 @@ def get_bom_detail(bom_no):
data = BomService.get_bom_detail(bom_no, version=version) data = BomService.get_bom_detail(bom_no, version=version)
if not data: if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -54,6 +108,7 @@ def get_bom_detail(bom_no):
@bom_bp.route('/save', methods=['POST']) @bom_bp.route('/save', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation')
def save_bom(): def save_bom():
"""保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)""" """保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)"""
try: try:
@ -81,12 +136,16 @@ def save_bom():
@bom_bp.route('/stock/<bom_no>', methods=['GET']) @bom_bp.route('/stock/<bom_no>', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_bom_with_stock_by_no(bom_no): def get_bom_with_stock_by_no(bom_no):
"""根据 BOM 编号获取配方详情及库存信息""" """根据 BOM 编号获取配方详情及库存信息"""
try: try:
data = BomService.get_bom_with_stock_by_bom_no(bom_no) data = BomService.get_bom_with_stock_by_bom_no(bom_no)
if not data: if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -101,6 +160,7 @@ def get_bom_with_stock_by_no(bom_no):
@bom_bp.route('/<bom_no>', methods=['DELETE']) @bom_bp.route('/<bom_no>', methods=['DELETE'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation')
def delete_bom(bom_no): def delete_bom(bom_no):
""" """
根据 BOM 编号删除 根据 BOM 编号删除
@ -133,9 +193,13 @@ def delete_bom(bom_no):
@bom_bp.route('/<int:parent_id>', methods=['GET']) @bom_bp.route('/<int:parent_id>', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_bom(parent_id): def get_bom(parent_id):
try: try:
data = BomService.get_bom_with_stock(parent_id) data = BomService.get_bom_with_stock(parent_id)
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -148,6 +212,7 @@ def get_bom(parent_id):
@bom_bp.route('', methods=['POST']) @bom_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation')
def save_bom_legacy(): def save_bom_legacy():
try: try:
req_data = request.get_json() req_data = request.get_json()
@ -169,11 +234,14 @@ def save_bom_legacy():
@bom_bp.route('/base/list', methods=['GET']) @bom_bp.route('/base/list', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_material_base_list(): def get_material_base_list():
"""获取所有基础物料列表,用于前端下拉框""" """获取所有基础物料列表,用于前端下拉框"""
try: try:
materials = MaterialBase.query.filter_by(is_enabled=True).order_by(MaterialBase.id.desc()).all() materials = MaterialBase.query.filter_by(is_enabled=True).order_by(MaterialBase.id.desc()).all()
data = [item.to_dict() for item in materials] data = [item.to_dict() for item in materials]
# 字段级脱敏 (如果需要,但此接口通常用于下拉选择,可能不需要脱敏)
# 保持原样
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -186,12 +254,16 @@ def get_material_base_list():
@bom_bp.route('/parents', methods=['GET']) @bom_bp.route('/parents', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_bom_parents(): def get_bom_parents():
"""获取所有已定义BOM的父件物料列表兼容旧版""" """获取所有已定义BOM的父件物料列表兼容旧版"""
try: try:
subq = db.session.query(BomTable.parent_id).distinct().subquery() subq = db.session.query(BomTable.parent_id).distinct().subquery()
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all() parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()
data = [item.to_dict() for item in parents] data = [item.to_dict() for item in parents]
# 字段级脱敏 (如果需要)
user_permissions = get_current_user_permissions()
data = [filter_item_by_permissions(item, user_permissions) for item in data]
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',

View File

@ -17,29 +17,29 @@
<el-button :icon="Search" @click="fetchBomList" /> <el-button :icon="Search" @click="fetchBomList" />
</template> </template>
</el-input> </el-input>
<el-button type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button> <el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
</div> </div>
</div> </div>
</template> </template>
<el-table v-loading="loading" :data="bomList" border style="width: 100%"> <el-table v-loading="loading" :data="bomList" border style="width: 100%">
<el-table-column prop="bom_no" label="BOM编号" min-width="180" sortable /> <el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable />
<el-table-column prop="parent_name" label="父件名称" min-width="150" /> <el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" />
<el-table-column prop="parent_spec" label="父件规格" min-width="150" /> <el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" />
<el-table-column prop="version" label="版本" width="100" align="center"> <el-table-column v-if="hasColumnPermission('version')" prop="version" label="版本" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag>{{ row.version }}</el-tag> <el-tag>{{ row.version }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100" align="center"> <el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'"> <el-tag :type="row.is_enabled ? 'success' : 'danger'">
{{ row.is_enabled ? '启用' : '禁用' }} {{ row.is_enabled ? '启用' : '禁用' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="child_count" label="子件数" width="80" align="center" /> <el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column label="操作" width="250" align="center" fixed="right"> <el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button> <el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
@ -80,7 +80,7 @@
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled"> <el-form-item label="是否启用" prop="is_enabled">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" /> <el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -169,6 +169,7 @@ import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue' import { Plus, Search } from '@element-plus/icons-vue'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom' import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock' import { getMaterialBaseList } from '@/api/inbound/stock'
import { useUserStore } from '@/stores/user'
// 类型定义 // 类型定义
interface BomItem { interface BomItem {
@ -190,6 +191,7 @@ interface ChildRow {
remark: string remark: string
} }
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
@ -199,6 +201,25 @@ const bomList = ref<BomItem[]>([])
const materialOptions = ref<MaterialBase[]>([]) const materialOptions = ref<MaterialBase[]>([])
const searchKeyword = ref('') const searchKeyword = ref('')
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
bom_no: 'bom_manage:bom_no',
parent_name: 'bom_manage:parent_name',
parent_spec: 'bom_manage:parent_spec',
version: 'bom_manage:version',
status: 'bom_manage:status',
child_count: 'bom_manage:child_count',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const form = reactive({ const form = reactive({
bom_prefix: '', // 自动生成的父件规格前缀 bom_prefix: '', // 自动生成的父件规格前缀
@ -229,7 +250,9 @@ const fetchBomList = async () => {
try { try {
const res = await getBomList({ keyword: searchKeyword.value }) const res = await getBomList({ keyword: searchKeyword.value })
if (res.code === 200) bomList.value = res.data if (res.code === 200) bomList.value = res.data
} catch (error) { ElMessage.error('网络错误') } } catch (error) {
// 错误已由全局拦截器统一处理
}
finally { loading.value = false } finally { loading.value = false }
} }
@ -237,7 +260,9 @@ const fetchMaterialOptions = async () => {
try { try {
const res = await getMaterialBaseList() const res = await getMaterialBaseList()
if (res.code === 200) materialOptions.value = res.data if (res.code === 200) materialOptions.value = res.data
} catch (error) {} } catch (error) {
// 错误已由全局拦截器统一处理
}
} }
// 监听父件变化,自动设置前缀 // 监听父件变化,自动设置前缀
@ -300,7 +325,9 @@ const loadDetail = async (bomNo: string, version: string) => {
form.bom_suffix = bomNo form.bom_suffix = bomNo
} }
} }
} catch (e) { ElMessage.error('获取详情失败') } } catch (e) {
// 错误已由全局拦截器统一处理
}
} }
const handleDelete = (row: BomItem) => { const handleDelete = (row: BomItem) => {
@ -353,7 +380,9 @@ const submitForm = async () => {
dialogVisible.value = false dialogVisible.value = false
fetchBomList() fetchBomList()
} else { ElMessage.error(res.msg || '保存失败') } } else { ElMessage.error(res.msg || '保存失败') }
} catch (e) { ElMessage.error('网络错误') } } catch (e) {
// 错误已由全局拦截器统一处理
}
finally { saving.value = false } finally { saving.value = false }
}) })
} }