Files
ylt_diy/lib/api/download_api.py

428 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
文件下载 API
"""
import os
import zipfile
from datetime import datetime
from flask import request, jsonify, send_from_directory
from lib.db import execute_query
from lib.logger import log_info, log_error, log_warning
from . import download_bp
def get_reports_dir():
"""获取报表文件目录"""
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'public', 'reports')
def get_temp_dir():
"""获取临时zip文件目录"""
temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'public', 'temp_zip')
os.makedirs(temp_dir, exist_ok=True)
return temp_dir
def cleanup_old_temp_zips():
"""清理1小时前的临时zip文件"""
try:
temp_dir = get_temp_dir()
now = datetime.now().timestamp()
count = 0
for filename in os.listdir(temp_dir):
if filename.endswith('.zip'):
filepath = os.path.join(temp_dir, filename)
if now - os.path.getmtime(filepath) > 3600: # 1小时前的
os.remove(filepath)
count += 1
if count > 0:
log_info(f'[下载API] 清理了 {count} 个过期临时zip文件', 'download')
except Exception as e:
log_error(f'[下载API] 清理临时文件失败: {e}', 'download')
@download_bp.route('/download', methods=['GET'])
def download_file():
"""下载文件
支持两种方式:
1. path: 直接传文件路径
2. id: 传历史记录ID从数据库查询文件路径
"""
try:
file_path = request.args.get('path')
history_id = request.args.get('id')
log_info(f'[下载API] 请求path={file_path}, id={history_id}', 'download')
# 如果传了id从历史记录表查询文件路径
if history_id:
result = execute_query(
'SELECT file_path, status FROM t_daily_report_history WHERE id = %s LIMIT 1',
(history_id,)
)
if not result:
log_error(f'[下载API] 历史记录不存在id={history_id}', 'download')
return jsonify({'success': False, 'message': '历史记录不存在'}), 404
history = result[0]
if history.get('status') != 1:
log_error(f'[下载API] 生成失败的记录无法下载id={history_id}', 'download')
return jsonify({'success': False, 'message': '该记录生成失败,无法下载'}), 400
file_path = history.get('file_path')
if not file_path:
log_error(f'[下载API] 文件路径为空id={history_id}', 'download')
return jsonify({'success': False, 'message': '文件路径不能为空'}), 400
log_info(f'[下载API] 从历史记录查询到文件路径: {file_path}', 'download')
if not file_path:
return jsonify({'success': False, 'message': '文件路径不能为空'}), 400
# 安全检查:确保路径在 public/reports 目录下
if not file_path.startswith('/reports/') and not file_path.startswith('/public/reports/'):
return jsonify({'success': False, 'message': '无效的文件路径'}), 400
# 提取文件名
if file_path.startswith('/public/reports/'):
filename = file_path.replace('/public/reports/', '')
else:
filename = file_path.replace('/reports/', '')
reports_dir = get_reports_dir()
full_path = os.path.join(reports_dir, filename)
if not os.path.exists(full_path):
log_error(f'[下载API] 文件不存在: {full_path}', 'download')
return jsonify({'success': False, 'message': '文件不存在'}), 404
log_info(f'[下载API] 下载文件: {filename}', 'download')
return send_from_directory(reports_dir, filename, as_attachment=True)
except Exception as e:
log_error(f'[下载API] 错误: {e}', 'download')
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': str(e)}), 500
@download_bp.route('/download/batch', methods=['POST'])
def batch_download():
"""批量打包下载
接收 ids 数组将对应的报表文件打包成zip后下载
"""
try:
data = request.get_json(silent=True) or {}
ids = data.get('ids', [])
log_info(f'[下载API] 批量下载,数量: {len(ids)}', 'download')
if not ids or len(ids) == 0:
return jsonify({'success': False, 'message': '请选择要下载的记录'}), 400
# 清理旧的临时文件
cleanup_old_temp_zips()
# 查询所有成功的历史记录(附带筛选条件以增强安全性)
placeholders = ', '.join(['%s'] * len(ids))
result = execute_query(
f'SELECT id, file_path, config_name, report_date, status, split_name FROM t_daily_report_history WHERE id IN ({placeholders})',
tuple(ids)
)
if not result:
return jsonify({'success': False, 'message': '未找到对应的历史记录'}), 404
total_matched = len(result)
success_records = [r for r in result if r.get('status') == 1]
skipped_failed = total_matched - len(success_records)
reports_dir = get_reports_dir()
temp_dir = get_temp_dir()
# 收集要打包的文件
files_to_zip = []
failed_names = []
used_names = set() # 检测 zip 内文件名冲突
for item in success_records:
file_path = item.get('file_path')
if not file_path:
failed_names.append(f"{item.get('config_name', '未知')} (无文件路径)")
continue
# 提取文件名
if file_path.startswith('/public/reports/'):
filename = file_path.replace('/public/reports/', '')
elif file_path.startswith('/reports/'):
filename = file_path.replace('/reports/', '')
else:
filename = os.path.basename(file_path)
full_path = os.path.join(reports_dir, filename)
if not os.path.exists(full_path):
failed_names.append(f"{item.get('config_name', '未知')}_{item.get('report_date', '')} (文件不存在)")
continue
# 使用配置名+报表日期+拆分值作为zip内的文件名避免重名
config_name = item.get('config_name', '未知')
report_date = item.get('report_date', '')
split_name = item.get('split_name', '')
ext = os.path.splitext(filename)[1] or '.xlsx'
base_parts = [config_name]
if report_date:
base_parts.append(str(report_date))
if split_name:
base_parts.append(str(split_name))
base_name = '_'.join(base_parts)
zip_inner_name = f"{base_name}{ext}"
# 处理文件名冲突如果同名追加记录ID
if zip_inner_name in used_names:
zip_inner_name = f"{base_name}_{item.get('id', '')}{ext}"
used_names.add(zip_inner_name)
files_to_zip.append({
'full_path': full_path,
'inner_name': zip_inner_name
})
if len(files_to_zip) == 0:
return jsonify({'success': False, 'message': '没有可下载的文件'}), 400
# 生成zip文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
zip_filename = f'批量下载_{timestamp}.zip'
zip_path = os.path.join(temp_dir, zip_filename)
# 打包
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for file_info in files_to_zip:
zf.write(file_info['full_path'], file_info['inner_name'])
log_info(f'[下载API] 批量打包完成,共 {len(files_to_zip)} 个文件,失败 {len(failed_names)} 个,跳过 {skipped_failed} 个失败记录', 'download')
if failed_names:
log_warning(f'[下载API] 缺失/无效文件列表: {failed_names[:20]}', 'download')
# 返回zip文件通过响应头返回统计信息
from flask import send_file
response = send_file(
zip_path,
as_attachment=True,
download_name=zip_filename,
mimetype='application/zip'
)
response.headers['X-Total-Matched'] = str(total_matched)
response.headers['X-Files-Zipped'] = str(len(files_to_zip))
response.headers['X-Files-Missing'] = str(len(failed_names))
response.headers['X-Skipped-Failed'] = str(skipped_failed)
return response
except Exception as e:
log_error(f'[下载API] 批量下载错误: {e}', 'download')
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': str(e)}), 500
@download_bp.route('/download/batch-filter', methods=['POST'])
def batch_download_by_filter():
"""按筛选条件批量打包下载
接收筛选条件config_name, start_date, end_date, status
将所有匹配的成功记录的报表文件打包成zip后下载。
与历史记录列表的搜索条件保持同步。
"""
try:
data = request.get_json(silent=True) or {}
config_name = data.get('config_name', '').strip()
start_date = data.get('start_date', '').strip()
end_date = data.get('end_date', '').strip()
status = data.get('status', '')
log_info(f'[下载API] 按筛选批量下载,配置名称: {config_name or "全部"},日期范围: {start_date or "开始"} ~ {end_date or "结束"},状态: {status or "全部"}', 'download')
# 清理旧的临时文件
cleanup_old_temp_zips()
# 构建查询条件(与历史记录列表查询条件完全一致)
where_clauses = ['status = 1'] # 只下载成功的记录
params = []
if start_date:
where_clauses.append('DATE(start_time) >= %s')
params.append(start_date)
if end_date:
where_clauses.append('DATE(end_time) <= %s')
params.append(end_date)
# 注意批量导出只导出成功记录status=1忽略用户传入的 status 筛选
# 因为失败的记录没有文件可下载
if config_name:
where_clauses.append('config_name LIKE %s')
params.append(f'%{config_name}%')
where_sql = ' AND '.join(where_clauses)
# 先查询总数,用于日志记录
count_result = execute_query(
f'SELECT COUNT(*) as total FROM t_daily_report_history WHERE {where_sql}',
tuple(params)
)
total_matched = count_result[0]['total'] if count_result else 0
log_info(f'[下载API] 按筛选匹配到 {total_matched} 条成功记录', 'download')
# 查询所有匹配的成功记录(按报表日期正序排列,方便用户查看)
result = execute_query(
f'SELECT id, file_path, config_name, report_date, status, split_name FROM t_daily_report_history WHERE {where_sql} ORDER BY report_date ASC, config_name ASC, id ASC',
tuple(params)
)
if not result:
return jsonify({'success': False, 'message': '未找到符合条件的历史记录'}), 404
reports_dir = get_reports_dir()
temp_dir = get_temp_dir()
# 收集要打包的文件
files_to_zip = []
failed_names = []
used_names = set() # 用于检测 zip 内文件名冲突
for item in result:
file_path = item.get('file_path')
if not file_path:
failed_names.append(f"{item.get('config_name', '未知')}_{item.get('report_date', '')} (无文件路径)")
continue
# 提取文件名
if file_path.startswith('/public/reports/'):
filename = file_path.replace('/public/reports/', '')
elif file_path.startswith('/reports/'):
filename = file_path.replace('/reports/', '')
else:
filename = os.path.basename(file_path)
full_path = os.path.join(reports_dir, filename)
if not os.path.exists(full_path):
failed_names.append(f"{item.get('config_name', '未知')}_{item.get('report_date', '')} (文件不存在)")
continue
# 构建 zip 内文件名配置名_报表日期避免重名
config_name_val = item.get('config_name', '未知')
report_date = item.get('report_date', '')
split_name = item.get('split_name', '')
ext = os.path.splitext(filename)[1] or '.xlsx'
# 基础名称配置名_报表日期
base_parts = [config_name_val]
if report_date:
base_parts.append(str(report_date))
if split_name:
base_parts.append(str(split_name))
base_name = '_'.join(base_parts)
zip_inner_name = f"{base_name}{ext}"
# 处理文件名冲突如果同名追加记录ID
if zip_inner_name in used_names:
zip_inner_name = f"{base_name}_{item.get('id', '')}{ext}"
used_names.add(zip_inner_name)
files_to_zip.append({
'full_path': full_path,
'inner_name': zip_inner_name
})
log_info(f'[下载API] 匹配 {total_matched} 条记录,成功打包 {len(files_to_zip)} 个文件,缺失 {len(failed_names)} 个文件', 'download')
if failed_names:
log_warning(f'[下载API] 缺失的文件列表: {failed_names[:20]}', 'download')
if len(files_to_zip) == 0:
return jsonify({'success': False, 'message': '没有可下载的文件(匹配的记录文件均不存在)'}), 400
# 生成zip文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
zip_filename = f'批量导出_{timestamp}.zip'
zip_path = os.path.join(temp_dir, zip_filename)
# 打包
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for file_info in files_to_zip:
zf.write(file_info['full_path'], file_info['inner_name'])
log_info(f'[下载API] 按筛选批量打包完成,共 {len(files_to_zip)} 个文件,失败 {len(failed_names)}', 'download')
# 返回zip文件通过自定义头部返回统计信息
from flask import send_file
response = send_file(
zip_path,
as_attachment=True,
download_name=zip_filename,
mimetype='application/zip'
)
# 通过响应头返回统计信息,便于前端诊断
response.headers['X-Total-Matched'] = str(total_matched)
response.headers['X-Files-Zipped'] = str(len(files_to_zip))
response.headers['X-Files-Missing'] = str(len(failed_names))
return response
except Exception as e:
log_error(f'[下载API] 按筛选批量下载错误: {e}', 'download')
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': str(e)}), 500
@download_bp.route('/download/batch-filter-count', methods=['POST'])
def batch_download_filter_count():
"""按筛选条件统计可导出的记录数
在批量导出前调用,让用户确认导出范围。
"""
try:
data = request.get_json(silent=True) or {}
config_name = data.get('config_name', '').strip()
start_date = data.get('start_date', '').strip()
end_date = data.get('end_date', '').strip()
# 构建查询条件(与批量导出完全一致,只统计成功记录)
where_clauses = ['status = 1']
params = []
if start_date:
where_clauses.append('DATE(start_time) >= %s')
params.append(start_date)
if end_date:
where_clauses.append('DATE(end_time) <= %s')
params.append(end_date)
if config_name:
where_clauses.append('config_name LIKE %s')
params.append(f'%{config_name}%')
where_sql = ' AND '.join(where_clauses)
# 统计总记录数
count_result = execute_query(
f'SELECT COUNT(*) as total FROM t_daily_report_history WHERE {where_sql}',
tuple(params)
)
total = count_result[0]['total'] if count_result else 0
# 统计日期范围
date_range = execute_query(
f'SELECT MIN(report_date) as min_date, MAX(report_date) as max_date FROM t_daily_report_history WHERE {where_sql}',
tuple(params)
)
min_date = date_range[0]['min_date'] if date_range else None
max_date = date_range[0]['max_date'] if date_range else None
return jsonify({
'success': True,
'data': {
'total': total,
'min_date': str(min_date) if min_date else '',
'max_date': str(max_date) if max_date else ''
}
})
except Exception as e:
log_error(f'[下载API] 统计可导出记录数错误: {e}', 'download')
return jsonify({'success': False, 'message': str(e)}), 500