feat: 实现异步导出骨架(Threading + Redis 状态流转),支持 POST 提交/轮询状态/下载文件
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user