fix(backend): resolve DetachedInstanceError in audit_log, add pessimistic locks for stock adjustments, and eliminate N+1 queries with eager loading
This commit is contained in:
@ -3,6 +3,7 @@ from app.extensions import db, beijing_time
|
||||
from datetime import datetime, timedelta
|
||||
from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity
|
||||
from app.utils.decorators import permission_required
|
||||
from sqlalchemy.orm import joinedload
|
||||
import uuid as uuid_module
|
||||
import io
|
||||
from openpyxl import Workbook
|
||||
@ -954,8 +955,8 @@ def export_stocktake():
|
||||
|
||||
unscanned_items = []
|
||||
|
||||
# 遍历 StockBuy
|
||||
for stock in StockBuy.query.all():
|
||||
# ★ 修复 N+1 查询:使用 joinedload 预加载 base 关系
|
||||
for stock in StockBuy.query.options(joinedload(StockBuy.base)).all():
|
||||
key = ('stock_buy', stock.id)
|
||||
if key in scanned_set:
|
||||
continue
|
||||
@ -966,7 +967,14 @@ def export_stocktake():
|
||||
borrowed_qty = get_borrowed_qty('stock_buy', stock.id)
|
||||
expected_qty = stock_qty - borrowed_qty
|
||||
if expected_qty > 0:
|
||||
mat_info = get_material_info('stock_buy', stock.id)
|
||||
# ★ 直接使用预加载的 base 关系,避免额外查询
|
||||
material = stock.base
|
||||
mat_info = {
|
||||
'name': material.name if material else '-',
|
||||
'sku': getattr(stock, 'sku', None) or '-',
|
||||
'spec': getattr(material, 'spec_model', None) if material else '-',
|
||||
'location': getattr(stock, 'warehouse_location', None) or '-'
|
||||
}
|
||||
unscanned_items.append({
|
||||
'name': mat_info['name'],
|
||||
'sku': mat_info['sku'],
|
||||
@ -980,7 +988,7 @@ def export_stocktake():
|
||||
|
||||
# 遍历 StockSemi
|
||||
if StockSemi:
|
||||
for stock in StockSemi.query.all():
|
||||
for stock in StockSemi.query.options(joinedload(StockSemi.base)).all():
|
||||
key = ('stock_semi', stock.id)
|
||||
if key in scanned_set:
|
||||
continue
|
||||
@ -990,7 +998,14 @@ def export_stocktake():
|
||||
borrowed_qty = get_borrowed_qty('stock_semi', stock.id)
|
||||
expected_qty = stock_qty - borrowed_qty
|
||||
if expected_qty > 0:
|
||||
mat_info = get_material_info('stock_semi', stock.id)
|
||||
# ★ 直接使用预加载的 base 关系,避免额外查询
|
||||
material = stock.base
|
||||
mat_info = {
|
||||
'name': material.name if material else '-',
|
||||
'sku': getattr(stock, 'sku', None) or '-',
|
||||
'spec': getattr(material, 'spec_model', None) if material else '-',
|
||||
'location': getattr(stock, 'warehouse_location', None) or '-'
|
||||
}
|
||||
unscanned_items.append({
|
||||
'name': mat_info['name'],
|
||||
'sku': mat_info['sku'],
|
||||
@ -1004,7 +1019,7 @@ def export_stocktake():
|
||||
|
||||
# 遍历 StockProduct
|
||||
if StockProduct:
|
||||
for stock in StockProduct.query.all():
|
||||
for stock in StockProduct.query.options(joinedload(StockProduct.base)).all():
|
||||
key = ('stock_product', stock.id)
|
||||
if key in scanned_set:
|
||||
continue
|
||||
@ -1014,7 +1029,14 @@ def export_stocktake():
|
||||
borrowed_qty = get_borrowed_qty('stock_product', stock.id)
|
||||
expected_qty = stock_qty - borrowed_qty
|
||||
if expected_qty > 0:
|
||||
mat_info = get_material_info('stock_product', stock.id)
|
||||
# ★ 直接使用预加载的 base 关系,避免额外查询
|
||||
material = stock.base
|
||||
mat_info = {
|
||||
'name': material.name if material else '-',
|
||||
'sku': getattr(stock, 'sku', None) or '-',
|
||||
'spec': getattr(material, 'spec_model', None) if material else '-',
|
||||
'location': getattr(stock, 'warehouse_location', None) or '-'
|
||||
}
|
||||
unscanned_items.append({
|
||||
'name': mat_info['name'],
|
||||
'sku': mat_info['sku'],
|
||||
|
||||
@ -132,7 +132,8 @@ def create():
|
||||
if not StockModel:
|
||||
return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400
|
||||
|
||||
stock = StockModel.query.get(stock_id)
|
||||
# ★ 修复并发冲突:使用悲观锁锁定行,防止多人同时修改导致的数据覆盖
|
||||
stock = StockModel.query.filter(StockModel.id == stock_id).with_for_update().first()
|
||||
if not stock:
|
||||
return jsonify({'code': 404, 'msg': '库存记录不存在'}), 404
|
||||
|
||||
|
||||
@ -205,18 +205,18 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target
|
||||
username = claims.get('username', '')
|
||||
display_name = claims.get('display_name', '')
|
||||
|
||||
# 兜底:如果 display_name 为空,查询数据库获取
|
||||
# ★ 修复 DetachedInstanceError:在 fn() 执行前预先获取用户完整信息
|
||||
# 这样可以避免在 fn() 提交 session 后再访问 User 对象导致游离
|
||||
if not display_name and user_id:
|
||||
try:
|
||||
from app.models.system import SysUser
|
||||
user = SysUser.query.get(user_id)
|
||||
if user:
|
||||
user_info = user.to_dict()
|
||||
display_name = user_info.get('display_name', username)
|
||||
display_name = user.display_name or username
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取IP
|
||||
# 预先获取 IP(避免后续访问 request 对象异常)
|
||||
ip_address = request.headers.get('X-Forwarded-For') or request.remote_addr or ''
|
||||
if ip_address and ',' in ip_address:
|
||||
ip_address = ip_address.split(',')[0].strip()
|
||||
@ -235,7 +235,7 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target
|
||||
raw_payload = _get_payload()
|
||||
filtered_payload = _filter_payload(raw_payload) if raw_payload else None
|
||||
|
||||
# 执行原函数
|
||||
# 执行原函数(此时 Session 可能被提交或回滚)
|
||||
response = fn(*args, **kwargs)
|
||||
|
||||
# 只记录成功的请求(响应状态码 200/201)
|
||||
@ -249,6 +249,9 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target
|
||||
from app.extensions import db
|
||||
from flask import current_app
|
||||
|
||||
# ★ 已在上方预先获取 display_name,此处无需再查询 User 对象
|
||||
# 使用预先获取的字符串数据,避免 DetachedInstanceError
|
||||
|
||||
# 获取 target_id
|
||||
target_id = None
|
||||
if get_target_id_fn:
|
||||
|
||||
Reference in New Issue
Block a user