10 Commits

10 changed files with 74 additions and 311 deletions

View File

@ -35,34 +35,19 @@ def get_current_user_permissions():
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
与前端 permissionMap 对齐:仅 total_amount/unit_price/subtotal 需权限
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'outbound_no': 'outbound_list:outbound_no',
'outbound_time': 'outbound_list:outbound_time',
'outbound_type': 'outbound_list:outbound_type',
'total_amount': 'outbound_list:total_amount',
'consumer_name': 'outbound_list:consumer_name',
'operator_name': 'outbound_list:operator_name',
'remark': 'outbound_list:remark',
'signature_path': 'outbound_list:signature_path',
# 明细字段
'sku': 'outbound_list:sku',
'name': 'outbound_list:name',
'material_type': 'outbound_list:material_type',
'category': 'outbound_list:category',
'spec_model': 'outbound_list:spec_model',
'quantity': 'outbound_list:quantity',
'unit_price': 'outbound_list:unit_price',
'subtotal': 'outbound_list:subtotal',
}
# 如果用户是超级管理员且有 'outbound_list:*',则不过滤
if 'outbound_list:*' 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
# 如果 item_dict 中包含 items 列表,递归处理每个子项
# 递归处理明细子项
if 'items' in item_dict and isinstance(item_dict['items'], list):
for sub_item in item_dict['items']:
filter_item_by_permissions(sub_item, user_permissions)

View File

@ -47,29 +47,8 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
★ Fail-Closed: 字段映射默认为完整列表,不再为空字典。
"""
field_to_perm = {
'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',
}
# sys_element 补齐前不做字段级过滤
field_to_perm = {}
if '*' in user_permissions or f'{prefix}:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.63
当前版本:V3.64
</span>
</footer>

View File

@ -165,22 +165,26 @@ const handleClearSearch = () => {
}
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
outbound_no: 'outbound_list:outbound_no',
outbound_time: 'outbound_list:outbound_time',
outbound_type: 'outbound_list:outbound_type',
const permissionMap: Record<string, string | null> = {
// 主表基础列 — 始终可见
outbound_no: null,
outbound_time: null,
outbound_type: null,
consumer_name: null,
operator_name: null,
remark: null,
signature_path: null,
// 金额列 — 需权限
total_amount: 'outbound_list:total_amount',
consumer_name: 'outbound_list:consumer_name',
operator_name: 'outbound_list:operator_name',
remark: 'outbound_list:remark',
signature_path: 'outbound_list:signature_path',
// 明细列
sku: 'outbound_list:sku',
name: 'outbound_list:name',
material_type: 'outbound_list:material_type',
category: 'outbound_list:category',
spec_model: 'outbound_list:spec_model',
quantity: 'outbound_list:quantity',
// 明细列 — 始终可见
sku: null,
name: null,
material_type: null,
category: null,
spec_model: null,
quantity: null,
batch_sn: null,
// 明细金额 — 需权限
unit_price: 'outbound_list:unit_price',
subtotal: 'outbound_list:subtotal',
}
@ -190,8 +194,14 @@ const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
if (!(prop in permissionMap)) {
return false // Default Deny
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
if (code === null || code === undefined) {
return true // 基础字段,无需权限
}
return userStore.hasPermission(code)
}
const list = ref([])

View File

@ -1,5 +1,5 @@
<template>
<div v-if="userStore.hasPermission('inbound_summary:view')" class="app-container">
<div v-if="userStore.hasPermission('inbound_summary')" class="app-container">
<div class="filter-container">
<CompanySelector v-model="listQuery.company" @change="handleFilter" style="margin-right: 10px;" />

View File

@ -391,6 +391,10 @@ const handleClonePermissions = async (sourceRole: string) => {
// 递归回显状态
const setRowStatus = (rows: PermissionNode[], perms: Set<any>) => {
rows.forEach(row => {
// ★ 诊断
if (row.code === 'outbound_approval' || row.code === 'op_borrow_approval') {
console.log('[诊断]', row.code, 'opCode=', row.operationCode, 'children=', row.children)
}
// 1. 设置可读 (页面Code是否存在)
row.hasRead = perms.has(row.code)

View File

@ -275,17 +275,22 @@ import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系
const permissionMap: Record<string, string> = {
borrower_name: 'op_borrow:borrower_name',
sku: 'op_borrow:sku',
available_quantity: 'op_borrow:available_quantity',
out_quantity: 'op_borrow:out_quantity',
const permissionMap: Record<string, string | null> = {
// 基础显示列 — 始终可见
name: null,
spec_model: null,
sku: null,
borrower_name: null,
available_quantity: null,
out_quantity: null,
}
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
if (!(prop in permissionMap)) return false
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
if (code === null || code === undefined) return true
return userStore.hasPermission(code)
}
// --- 状态定义 ---

View File

@ -172,31 +172,29 @@ const handleClearSearch = () => {
}
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrow_no: 'op_records:borrow_no',
borrower_name: 'op_records:borrower_name',
sku: 'op_records:sku',
borrow_time: 'op_records:borrow_time',
return_time: 'op_records:return_time',
status: 'op_records:status',
expected_return_time: 'op_records:expected_return_time',
return_location: 'op_records:return_location',
borrow_signature: 'op_records:borrow_signature',
return_signature: 'op_records:return_signature',
const permissionMap: Record<string, string | null> = {
// 基础显示列 — 始终可见
borrow_no: null,
borrower_name: null,
sku: null,
borrow_time: null,
return_time: null,
return_operator: null,
status: null,
expected_return_time: null,
return_location: null,
// 签名列 — 始终可见sys_element 补齐后再改为显式权限码)
borrow_signature: null,
return_signature: null,
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// SKU / return_time / return_operator 不再参与权限过滤,始终可见
const alwaysVisible = ['sku', 'return_time', 'return_operator', 'expected_return_time', 'status', 'return_location', 'borrow_no', 'borrower_name', 'borrow_time']
if (alwaysVisible.includes(prop)) {
return true
}
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
if (!(prop in permissionMap)) return false
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
if (code === null || code === undefined) return true
return userStore.hasPermission(code)
}
const list = ref<any[]>([])

View File

@ -205,19 +205,20 @@ import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrower_name: 'op_return:borrower_name',
sku: 'op_return:sku',
return_location: 'op_return:return_location',
const permissionMap: Record<string, string | null> = {
// 基础显示列 — 始终可见
borrower_name: null,
sku: null,
return_location: null,
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
if (!(prop in permissionMap)) return false
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
if (code === null || code === undefined) return true
return userStore.hasPermission(code)
}
// --- 状态 ---

View File

@ -1,219 +0,0 @@
# IRIS 库存管理系统 - 全局系统体检报告
> 审查日期2026-04-02
> 审查范围inventory-web (Vue3 + Element Plus) + inventory-backend (Flask + SQLAlchemy)
> 审查模式:静态代码分析
---
## 一、前端状态与渲染漏洞 (Vue3 + Element Plus)
### [🚨 高危漏洞]
#### 1. el-table 缺少 reserve-selection 导致分页勾选丢失
- **模块/文件**: `inventory-web/src/views/materiel/list.vue` (及其他多个页面)
- **问题描述**: 大多数表格使用了 `type="selection"` 但未设置 `:reserve-selection="true"`
- **代码行数**: 该问题影响 30+ 个 el-table 组件
- **根因分析**:
- 分页后切换页面时,之前选中的行会丢失
- 只有 Selection.vue 的手动选择弹窗表格(第118行)添加了 `:reserve-selection="true"`
- **验证方法**: 进入物料列表勾选第1页的某几项切换到第2页再返回第1页确认勾选状态
#### 2. el-table 缺少 row-key 导致全选/渲染异常
- **模块/文件**: `inventory-web/src/views/stock/stocktake/index.vue`
- **代码行数**: 第240行
- **根因分析**: 该表格使用的 row-key="id"但如果存在跨表数据如物料、半成品、成品混排id 可能会冲突
#### 3. 盘点列表一次性加载 10000 条数据
- **模块/文件**: `inventory-web/src/views/stock/stocktake/index.vue`
- **代码行数**: 第662行、第985行
- **问题代码**:
```javascript
params: { page: 1, limit: 10000 } // 获取足够多的数据
limit: 10000, // 获取全部已盘点记录
```
- **风险**: 大量盘点记录时会导致前端内存溢出、页面卡死
---
### [⚠️ 交互/逻辑隐患]
#### 4. el-dialog 缺少 destroy-on-close 导致表单残留
- **模块/文件**: `inventory-web/src/views/outbound/create.vue`
- **代码行数**: 第174行
- **问题代码**: `<el-dialog v-model="showDialog" title="新建出库单" width="75%" :close-on-click-modal="false">`
- **根因分析**:
- 弹窗关闭后,数据未重置
- 再次打开弹窗会看到上一次填写的数据
- **建议**: 添加 `destroy-on-close` 属性或手动在关闭回调中重置表单
#### 5. formRef.resetFields() 调用不足
- **模块/文件**: 多个视图文件
- **根因分析**: 大多数弹窗表单没有调用 resetFields() 进行彻底重置
- **对比**:
- material/list.vue: 第1228行 ✅ 有 resetFields
- bom/BomManage.vue: 第656行 ✅ 有 resetFields
- system/UserCreate.vue: 第374行 ✅ 有 resetFields
- 其他大多数 ❌ 无重置逻辑
#### 6. 响应式解构潜在风险
- **模块/文件**: `inventory-web/src/App.vue`
- **代码行数**: 第80行
- **问题代码**: `const { new_password, confirm_password } = passwordForm.value`
- **说明**: 这种写法会失去响应性绑定,属于潜在风险(当前代码未直接修改解构后的变量,风险较低)
---
## 二、后端 ORM 与生命周期陷阱 (Flask + SQLAlchemy)
### [🚨 高危漏洞]
#### 7. @audit_log 装饰器中的 DetachedInstanceError 风险
- **模块/文件**: `inventory-backend/app/utils/decorators.py`
- **代码行数**: 第211-260行、第318行
- **问题代码**:
```python
# 第211-217行查询用户
user = SysUser.query.get(user_id)
if user:
user_info = user.to_dict() # 可能返回 DetachedInstanceError
display_name = user_info.get('display_name', username)
# 第248行添加审计日志
log_entry = AuditLog(...)
db.session.add(log_entry)
# 第318行在请求结束后 commit
db.session.commit()
```
- **根因分析**:
- 装饰器在 db.session.commit() 后访问已游离的对象属性
- 特别是在请求返回后session 可能已关闭,再次访问 user 对象会触发 DetachedInstanceError
#### 8. N+1 查询问题 - 库位树/库存列表
- **模块/文件**: `inventory-backend/app/api/v1/warehouse.py`, `inventory-backend/app/api/v1/inbound/stock.py`
- **代码行数**:
- warehouse.py 第118-132行循环查询每条库存记录
- stock.py 第1113-1138行全量查询后循环处理
- **问题代码**:
```python
# stock.py 第1113行
for item in StockBuy.query.filter(StockBuy.stock_quantity > 0).all():
# 每次循环访问 item.base 时会触发新的 SQL 查询 (Lazy Load)
'material_name': item.base.name
```
- **根因分析**: 没有使用 joinedload 或 eager loading 预加载关联关系
#### 9. 批量操作无最大数量限制
- **模块/文件**: `inventory-backend/app/api/v1/inbound/buy.py`
- **根因分析**:
- 批量创建物料/成品/半成品时没有限制最大条目数
- 前端子啊 stocktake/index.vue 设置了 limit=10000
- 后端如果接收大量数据会导致内存溢出
---
### [⚠️ 交互/逻辑隐患]
#### 10. 出库扣减逻辑中的可用库存竞态
- **模块/文件**: `inventory-backend/app/services/outbound_service.py`
- **代码行数**: 第169-173行
- **问题代码**:
```python
stock_record = ModelClass.query.with_for_update().get(stock_id) # 使用了悲观锁 ✅
if float(stock_record.available_quantity) < quantity:
raise ValueError(...)
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
```
- **分析**: 已使用 `with_for_update()` 悲观锁,但需要确认数据库连接是否支持行级锁
- **建议**: 建议增加版本号字段实现乐观锁,作为双重保险
#### 11. 文件上传无大小限制
- **模块/文件**: `inventory-backend/app/api/v1/common/upload.py`
- **根因分析**:
- 没有检查文件大小
- 没有限制同时上传的文件数量
- **建议**: 添加 MAX_FILE_SIZE 和并发限制
---
## 三、业务并发与数据一致性
### [🚨 高危漏洞]
#### 12. 库存扣减无乐观锁
- **模块/文件**: 所有库存相关表 (StockBuy, StockSemi, StockProduct)
- **根因分析**:
- 库存表中没有 version 字段
- 仅依赖数据库行锁with_for_update()
- 高并发场景下可能出现库存扣为负数
- **影响范围**: 出库、报废、借用、盘点调整等所有减少库存的操作
#### 13. 盘点实盘数更新竞态
- **模块/文件**: `inventory-backend/app/api/v1/stock/adjustment.py`
- **代码行数**: 第226-250行
- **问题代码**:
```python
for stock in pagination.items:
new_qty = float(stock.stock_quantity)
# 读取和写入之间存在时间窗口,可能被其他请求修改
stock.stock_quantity = new_qty
db.session.add(stock)
db.session.commit()
```
- **根因分析**:
- 循环中的每条记录没有悲观锁
- 并发盘点可能导致数据覆盖
---
### [⚠️ 交互/逻辑隐患]
#### 14. 唯一键判断不严谨 - 购物车追加
- **模块/文件**: `inventory-web/src/views/outbound/Selection.vue`
- **根因分析**:
- 前端购物车基于 `type_id` 判断是否重复
- 如果 type 相同但 id 不同,仍会误判
- **当前状态**: ✅ 代码已修复,使用 `${item.type}_${item.id}` 格式
#### 15. BOM 批量导入无校验
- **模块/文件**: `inventory-backend/app/api/v1/bom.py`
- **代码行数**: 第278-340行
- **根因分析**:
- children 数据直接写入,不检查是否有重复子件
- 不验证子件是否存在
---
## 四、报告总结
### 漏洞统计
| 严重程度 | 数量 |
|---------|------|
| 🚨 高危漏洞 | 7 |
| ⚠️ 交互/逻辑隐患 | 8 |
| ✅ 状态良好 | 15+ |
### 优先修复建议(按优先级排序)
1. **[P0] 前端**:为所有分页表格添加 `:reserve-selection="true"` 和唯一 `row-key`
2. **[P0] 前端**:修复 stocktake <20><> 10000 条限制,改用分页或虚拟滚动
3. **[P0] 后端**:修复 @audit_log 的 DetachedInstanceError分两次 commit 或刷新对象)
4. **[P1] 后端**:为库存表添加 version 字段实现乐观锁
5. **[P1] 后端**:为盘点实盘更新添加 with_for_update()
6. **[P1] 后端**:添加批量操作最大限制(如 500 条/请求)
7. **[P2] 前端**:为所有表单弹窗添加 destroy-on-close 或手动重置
8. **[P2] 后端**:优化 N+1 查询,添加 joinedload
### ✅ 状态良好的核心链路
- ✅ 出库单创建使用了悲观锁 (with_for_update)
- ✅ 物料基础信息管理使用 visibilityLevel 控制
- ✅ 登录 Token 验证与 Redis 单设备登录互踢
- ✅ 文件上传使用 UUID 生成唯一文件名
- ✅ 出库选单唯一键已修复为 `${type}_${id}` 格式
- ✅ 库位路径 natural sorting 已实现
---
*本报告由 Qwen Code 全局静态扫描生成,仅供参考。实际修复请结合业务场景进行测试验证。*