feat: 实现异步导出骨架(Threading + Redis 状态流转),支持 POST 提交/轮询状态/下载文件

This commit is contained in:
DXC
2026-05-19 10:35:33 +08:00
parent 6e1e1aa998
commit 4d81056075
4 changed files with 523 additions and 0 deletions

View 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

View 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