feat: 实现异步导出骨架(Threading + Redis 状态流转),支持 POST 提交/轮询状态/下载文件
This commit is contained in:
@ -234,6 +234,17 @@ def create_app():
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: Scan 模块注册失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.x 注册异步导出模块 (Export)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.export import export_bp
|
||||
app.register_blueprint(export_bp, url_prefix='/api/v1/export')
|
||||
app.register_blueprint(export_bp, url_prefix='/api/export', name='export_legacy')
|
||||
print("✅ Export 模块注册成功")
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: Export 模块注册失败: {e}")
|
||||
|
||||
# =========================================================
|
||||
# 3. 预加载数据模型
|
||||
# =========================================================
|
||||
|
||||
10
inventory-backend/app/api/v1/export/__init__.py
Normal file
10
inventory-backend/app/api/v1/export/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""
|
||||
app/api/v1/export/__init__.py
|
||||
导出模块 Blueprint 注册文件
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
export_bp = Blueprint('export', __name__, url_prefix='/api/v1/export')
|
||||
|
||||
from app.api.v1.export import inventory_export
|
||||
144
inventory-backend/app/api/v1/export/inventory_export.py
Normal file
144
inventory-backend/app/api/v1/export/inventory_export.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""
|
||||
app/api/v1/export/inventory_export.py
|
||||
异步导出核心接口
|
||||
|
||||
提供三个端点:
|
||||
POST /api/v1/export/inventory → 提交导出任务,返回 task_id
|
||||
GET /api/v1/export/status/<task_id> → 查询任务状态(轮询)
|
||||
GET /api/v1/export/download/<task_id> → 下载已生成的 Excel 文件
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Blueprint, request, jsonify, send_file, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt
|
||||
from app.services.export_service.excel_task import (
|
||||
submit_export_task,
|
||||
get_task_status,
|
||||
get_export_filepath,
|
||||
)
|
||||
from app.utils.decorators import permission_required
|
||||
|
||||
export_bp = Blueprint('export', __name__, url_prefix='/api/v1/export')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 任务提交接口
|
||||
# =============================================================================
|
||||
|
||||
@export_bp.route('/inventory', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('inventory_manage')
|
||||
def submit_export():
|
||||
"""
|
||||
接收前端导出请求,生成 task_id,立即返回。
|
||||
|
||||
请求体(JSON):
|
||||
{
|
||||
"keyword": "螺丝",
|
||||
"category": "原材料",
|
||||
"status": "在库"
|
||||
}
|
||||
|
||||
响应:
|
||||
{ "code": 200, "msg": "success", "data": { "task_id": "xxx" } }
|
||||
"""
|
||||
try:
|
||||
filters = request.get_json() or {}
|
||||
|
||||
# 生成 task_id 并启动后台任务(同步返回,不阻塞)
|
||||
task_id = submit_export_task(filters)
|
||||
|
||||
current_app.logger.info(
|
||||
f"[Export] 用户 {get_jwt().get('username')} 提交导出任务 task_id={task_id}"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '导出任务已创建',
|
||||
'data': {
|
||||
'task_id': task_id, # 前端用此 ID 轮询 /export/status/<task_id>
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[Export] 提交导出任务失败: {e}")
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 进度查询接口(前端轮询)
|
||||
# =============================================================================
|
||||
|
||||
@export_bp.route('/status/<task_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_export_status(task_id: str):
|
||||
"""
|
||||
从 Redis 读取任务状态,供前端轮询。
|
||||
|
||||
响应示例(处理中):
|
||||
{
|
||||
"code": 200,
|
||||
"data": { "status": "processing", "progress": 45, "url": "", "error": "" }
|
||||
}
|
||||
|
||||
响应示例(已完成):
|
||||
{
|
||||
"code": 200,
|
||||
"data": { "status": "completed", "progress": 100, "url": "/api/v1/export/download/xxx", "error": "" }
|
||||
}
|
||||
"""
|
||||
try:
|
||||
status = get_task_status(task_id)
|
||||
|
||||
if status.get('status') == 'not_found':
|
||||
return jsonify({'code': 404, 'msg': '任务不存在或已过期'}), 404
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': status
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[Export] 查询任务状态失败: task_id={task_id}, err={e}")
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 文件下载接口
|
||||
# =============================================================================
|
||||
|
||||
@export_bp.route('/download/<task_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def download_export_file(task_id: str):
|
||||
"""
|
||||
下载已生成的 Excel 文件。
|
||||
|
||||
前端轮询发现 status=completed 后,
|
||||
取 data.url 拼接完整下载地址,发起下载请求。
|
||||
|
||||
安全:只允许下载已完成且未过期的文件(TTL=1h)。
|
||||
"""
|
||||
try:
|
||||
# 再次确认任务状态,防止下载不存在的文件
|
||||
status = get_task_status(task_id)
|
||||
if status.get('status') != 'completed':
|
||||
return jsonify({'code': 400, 'msg': '文件未就绪,请稍后'}), 400
|
||||
|
||||
filepath = get_export_filepath(task_id)
|
||||
if not filepath:
|
||||
return jsonify({'code': 404, 'msg': '文件不存在或已过期'}), 404
|
||||
|
||||
current_app.logger.info(f"[Export] 用户 {get_jwt().get('username')} 下载 task_id={task_id}")
|
||||
|
||||
# send_file 自动设置 Content-Disposition: attachment(触发浏览器下载)
|
||||
return send_file(
|
||||
filepath,
|
||||
as_attachment=True,
|
||||
download_name=f"库存导出_{task_id[:8]}.xlsx",
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[Export] 下载失败: task_id={task_id}, err={e}")
|
||||
return jsonify({'code': 500, 'msg': '下载失败,请重试'}), 500
|
||||
358
inventory-backend/app/services/export_service/excel_task.py
Normal file
358
inventory-backend/app/services/export_service/excel_task.py
Normal file
@ -0,0 +1,358 @@
|
||||
"""
|
||||
app/services/export_service/excel_task.py
|
||||
异步导出核心任务逻辑
|
||||
|
||||
Redis 中的任务状态键格式:export:task:{task_id}
|
||||
TTL = 1 小时(3600 秒),超时自动清理
|
||||
|
||||
状态流转:
|
||||
提交任务 → status=processing, progress=0
|
||||
写入中 → status=processing, progress=N (10~90)
|
||||
完成 → status=completed, progress=100, url=下载路径
|
||||
失败 → status=failed, error=具体原因
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from threading import Thread
|
||||
from datetime import datetime
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 导出文件存放根目录(相对于项目根目录)
|
||||
EXPORT_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'uploads', 'exports'
|
||||
)
|
||||
|
||||
# Redis 键前缀 + TTL
|
||||
TASK_KEY_PREFIX = 'export:task:'
|
||||
TASK_TTL = 3600 # 1小时
|
||||
|
||||
|
||||
def _redis():
|
||||
"""获取 Redis 客户端,带容错保护。"""
|
||||
try:
|
||||
from app.extensions import redis_client
|
||||
return redis_client
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _update_task(task_id: str, **kwargs):
|
||||
"""
|
||||
原子更新 Redis 中的任务状态。
|
||||
|
||||
使用 setex 分两步:
|
||||
1. 保存最新状态 JSON
|
||||
2. 重置 TTL 为 1 小时
|
||||
|
||||
即使 Redis 写入失败也不阻断业务流程。
|
||||
"""
|
||||
client = _redis()
|
||||
if not client:
|
||||
return
|
||||
key = f"{TASK_KEY_PREFIX}{task_id}"
|
||||
try:
|
||||
client.setex(key, TASK_TTL, json.dumps(kwargs, ensure_ascii=False))
|
||||
logger.debug(f"[Export] 更新任务状态 task_id={task_id} → {kwargs}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Export] Redis 更新任务状态失败: task_id={task_id}, err={e}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 对外入口:提交导出任务(启动后台线程)
|
||||
# =============================================================================
|
||||
|
||||
def submit_export_task(filters: dict) -> str:
|
||||
"""
|
||||
接收前端过滤参数,生成 task_id,写入 Redis 初始状态,
|
||||
然后启动后台线程执行 Excel 生成。
|
||||
|
||||
参数:
|
||||
filters: dict,任意查询参数(category, keyword, status 等)
|
||||
|
||||
返回:
|
||||
str: task_id(UUID),前端用此 ID 轮询进度
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 写入 Redis:初始状态
|
||||
_update_task(task_id, status='processing', progress=0, url='', error='')
|
||||
|
||||
# 立即启动后台线程执行(daemon=True 使主进程退出时自动终止)
|
||||
t = Thread(
|
||||
target=generate_excel_task,
|
||||
args=(task_id, filters),
|
||||
daemon=True
|
||||
)
|
||||
t.start()
|
||||
logger.info(f"[Export] 任务已提交 task_id={task_id}, filters={filters}")
|
||||
|
||||
return task_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 后台任务:生成 Excel 文件
|
||||
# =============================================================================
|
||||
|
||||
def generate_excel_task(task_id: str, filters: dict):
|
||||
"""
|
||||
在后台线程中执行 Excel 生成。
|
||||
|
||||
流程:
|
||||
1. 更新进度 10% → 开始查询
|
||||
2. 根据 filters 查询数据库(可复用现有 Service)
|
||||
3. 用 openpyxl 构建 Workbook,写入数据
|
||||
4. 保存到 uploads/exports/{task_id}.xlsx
|
||||
5. 更新进度 100% + status=completed + url
|
||||
|
||||
任何异常被捕获,不会导致主进程崩溃。
|
||||
"""
|
||||
logger.info(f"[Export] 任务开始 task_id={task_id}")
|
||||
try:
|
||||
# ===== 阶段1:查询数据(模拟 + 实际) =====
|
||||
_update_task(task_id, status='processing', progress=10)
|
||||
|
||||
# 延迟导入:在子线程中加载 App Context,避免主线程时序问题
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
records = _query_inventory(filters)
|
||||
|
||||
# ===== 阶段2:写入 Excel =====
|
||||
_update_task(task_id, status='processing', progress=40)
|
||||
|
||||
filename = f"{task_id}.xlsx"
|
||||
filepath = os.path.join(EXPORT_DIR, filename)
|
||||
_write_excel(filepath, records, task_id)
|
||||
|
||||
# ===== 阶段3:完成 =====
|
||||
_update_task(
|
||||
task_id,
|
||||
status='completed',
|
||||
progress=100,
|
||||
url=f"/api/v1/export/download/{task_id}",
|
||||
error=''
|
||||
)
|
||||
logger.info(f"[Export] 任务完成 task_id={task_id}, file={filename}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Export] 任务失败 task_id={task_id}, err={e}")
|
||||
_update_task(task_id, status='failed', error=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 查询层:根据 filters 聚合库存数据
|
||||
# =============================================================================
|
||||
|
||||
def _query_inventory(filters: dict) -> list:
|
||||
"""
|
||||
根据过滤条件查询三张库存表,返回标准化记录列表。
|
||||
进度更新策略:在主线程(后台线程)内,每处理 1000 条回调一次 Redis。
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
results = []
|
||||
|
||||
# ---------- 采购件 ----------
|
||||
query = db.session.query(
|
||||
MaterialBase.name.label('material_name'),
|
||||
MaterialBase.spec_model.label('spec_model'),
|
||||
StockBuy.barcode,
|
||||
StockBuy.sku,
|
||||
StockBuy.status,
|
||||
StockBuy.warehouse_location,
|
||||
StockBuy.available_quantity,
|
||||
StockBuy.supplier_name,
|
||||
StockBuy.in_date,
|
||||
).join(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
||||
|
||||
if filters.get('keyword'):
|
||||
kw = f"%{filters['keyword']}%"
|
||||
query = query.filter(
|
||||
(MaterialBase.name.ilike(kw)) |
|
||||
(MaterialBase.spec_model.ilike(kw)) |
|
||||
(StockBuy.barcode.ilike(kw)) |
|
||||
(StockBuy.sku.ilike(kw))
|
||||
)
|
||||
if filters.get('status'):
|
||||
query = query.filter(StockBuy.status == filters['status'])
|
||||
|
||||
all_rows = query.order_by(StockBuy.id.desc()).limit(10000).all()
|
||||
total = len(all_rows)
|
||||
|
||||
for idx, row in enumerate(all_rows):
|
||||
results.append({
|
||||
'type': '采购件',
|
||||
'material_name': row.material_name or '',
|
||||
'spec_model': row.spec_model or '',
|
||||
'barcode': row.barcode or '',
|
||||
'sku': row.sku or '',
|
||||
'status': row.status or '',
|
||||
'warehouse_location': row.warehouse_location or '',
|
||||
'available_quantity': float(row.available_quantity or 0),
|
||||
'supplier_name': row.supplier_name or '',
|
||||
'in_date': row.in_date.strftime('%Y-%m-%d') if row.in_date else '',
|
||||
})
|
||||
|
||||
# 每 1000 条更新一次 Redis 进度(40%~80% 之间)
|
||||
if idx > 0 and idx % 1000 == 0:
|
||||
pct = 40 + int(40 * idx / total) if total else 80
|
||||
# 注意:这里的 task_id 由外层 generate_excel_task 持有,
|
||||
# 进度更新在 _write_excel 中进行,此处仅做占位说明
|
||||
logger.debug(f"[Export] 采购件已处理 {idx}/{total} 条, 估算进度 {pct}%")
|
||||
|
||||
# ---------- 半成品 + 成品(可同理扩展) ----------
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Excel 写入层:使用 openpyxl 构建 .xlsx 文件
|
||||
# =============================================================================
|
||||
|
||||
def _write_excel(filepath: str, records: list, task_id: str):
|
||||
"""
|
||||
使用 openpyxl 将记录列表写入 Excel 文件。
|
||||
包含表头样式(加粗、背景色)、自适应列宽、边框。
|
||||
|
||||
参数:
|
||||
filepath: 完整保存路径(含 .xlsx 后缀)
|
||||
records: 标准化后的数据列表(dict)
|
||||
task_id: 用于增量进度更新
|
||||
"""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "库存导出"
|
||||
|
||||
if not records:
|
||||
ws.append(['暂无数据'])
|
||||
wb.save(filepath)
|
||||
return
|
||||
|
||||
# ---------- 表头 ----------
|
||||
headers = [
|
||||
'类型', '物料名称', '规格型号', '条码', 'SKU',
|
||||
'状态', '库位', '可用数量', '供应商', '入库日期'
|
||||
]
|
||||
ws.append(headers)
|
||||
|
||||
# 表头样式:深蓝色背景 + 白色加粗字体
|
||||
header_fill = PatternFill("solid", fgColor="1F4E79")
|
||||
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
header_align = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
thin = Side(style='thin', color='BFBFBF')
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
for col_idx, _ in enumerate(headers, start=1):
|
||||
cell = ws.cell(row=1, column=col_idx)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_align
|
||||
cell.border = border
|
||||
|
||||
# ---------- 数据行 ----------
|
||||
even_fill = PatternFill("solid", fgColor="DEEAF1") # 浅蓝隔行底色
|
||||
data_align = Alignment(horizontal='left', vertical='center')
|
||||
data_font = Font(size=10)
|
||||
|
||||
total = len(records)
|
||||
for idx, rec in enumerate(records):
|
||||
ws.append([
|
||||
rec.get('type', ''),
|
||||
rec.get('material_name', ''),
|
||||
rec.get('spec_model', ''),
|
||||
rec.get('barcode', ''),
|
||||
rec.get('sku', ''),
|
||||
rec.get('status', ''),
|
||||
rec.get('warehouse_location', ''),
|
||||
rec.get('available_quantity', 0),
|
||||
rec.get('supplier_name', ''),
|
||||
rec.get('in_date', ''),
|
||||
])
|
||||
|
||||
# 每 1000 行更新一次 Redis 进度(80%~95%)
|
||||
row_num = idx + 2
|
||||
for col_idx in range(1, len(headers) + 1):
|
||||
cell = ws.cell(row=row_num, column=col_idx)
|
||||
cell.alignment = data_align
|
||||
cell.font = data_font
|
||||
cell.border = border
|
||||
if idx % 2 == 1:
|
||||
cell.fill = even_fill
|
||||
|
||||
if idx > 0 and idx % 1000 == 0:
|
||||
pct = 80 + int(15 * idx / total)
|
||||
_update_task(task_id, status='processing', progress=min(pct, 95))
|
||||
|
||||
# ---------- 自适应列宽 ----------
|
||||
for col in ws.columns:
|
||||
max_len = 0
|
||||
col_letter = col[0].column_letter
|
||||
for cell in col:
|
||||
if cell.value:
|
||||
max_len = max(max_len, len(str(cell.value)))
|
||||
ws.column_dimensions[col_letter].width = min(max_len + 4, 40)
|
||||
|
||||
# ---------- 冻结首行 ----------
|
||||
ws.freeze_panes = 'A2'
|
||||
|
||||
# ---------- 保存 ----------
|
||||
wb.save(filepath)
|
||||
logger.info(f"[Export] Excel 已保存: {filepath}, 共 {total} 行")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 查询任务状态(供 API 层调用)
|
||||
# =============================================================================
|
||||
|
||||
def get_task_status(task_id: str) -> dict:
|
||||
"""
|
||||
从 Redis 读取任务状态字典。
|
||||
若任务不存在或 Redis 不可用,返回默认 pending 状态。
|
||||
"""
|
||||
client = _redis()
|
||||
if not client:
|
||||
return {'status': 'unknown', 'progress': 0, 'url': '', 'error': ''}
|
||||
|
||||
key = f"{TASK_KEY_PREFIX}{task_id}"
|
||||
try:
|
||||
raw = client.get(key)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
return {'status': 'not_found', 'progress': 0, 'url': '', 'error': ''}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Export] 读取任务状态失败: task_id={task_id}, err={e}")
|
||||
return {'status': 'unknown', 'progress': 0, 'url': '', 'error': ''}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 获取导出文件路径(供下载接口调用)
|
||||
# =============================================================================
|
||||
|
||||
def get_export_filepath(task_id: str) -> str | None:
|
||||
"""
|
||||
根据 task_id 返回已生成文件的完整路径。
|
||||
未完成或不存在返回 None。
|
||||
"""
|
||||
filename = f"{task_id}.xlsx"
|
||||
filepath = os.path.join(EXPORT_DIR, filename)
|
||||
if os.path.exists(filepath):
|
||||
return filepath
|
||||
return None
|
||||
Reference in New Issue
Block a user