2026-07-13 15:43:03 +08:00
"""
文件下载 API
"""
import os
2026-07-21 15:21:29 +08:00
import zipfile
from datetime import datetime
2026-07-13 15:43:03 +08:00
from flask import request , jsonify , send_from_directory
2026-07-21 15:21:29 +08:00
from lib . db import execute_query
2026-08-02 09:38:48 +08:00
from lib . logger import log_info , log_error , log_warning
2026-07-13 15:43:03 +08:00
from . import download_bp
2026-07-21 15:21:29 +08:00
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 ' )
2026-07-13 15:43:03 +08:00
@download_bp.route ( ' /download ' , methods = [ ' GET ' ] )
def download_file ( ) :
2026-07-21 15:21:29 +08:00
""" 下载文件
支持两种方式 :
1. path : 直接传文件路径
2. id : 传历史记录ID , 从数据库查询文件路径
"""
2026-07-13 15:43:03 +08:00
try :
file_path = request . args . get ( ' path ' )
2026-07-21 15:21:29 +08:00
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 ' )
2026-07-13 15:43:03 +08:00
if not file_path :
return jsonify ( { ' success ' : False , ' message ' : ' 文件路径不能为空 ' } ) , 400
2026-07-21 15:21:29 +08:00
# 安全检查:确保路径在 public/reports 目录下
if not file_path . startswith ( ' /reports/ ' ) and not file_path . startswith ( ' /public/reports/ ' ) :
2026-07-13 15:43:03 +08:00
return jsonify ( { ' success ' : False , ' message ' : ' 无效的文件路径 ' } ) , 400
2026-07-21 15:21:29 +08:00
# 提取文件名
if file_path . startswith ( ' /public/reports/ ' ) :
filename = file_path . replace ( ' /public/reports/ ' , ' ' )
else :
filename = file_path . replace ( ' /reports/ ' , ' ' )
2026-07-13 15:43:03 +08:00
2026-07-21 15:21:29 +08:00
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
2026-07-13 15:43:03 +08:00
2026-07-21 15:21:29 +08:00
log_info ( f ' [下载API] 下载文件: { filename } ' , ' download ' )
2026-07-13 15:43:03 +08:00
return send_from_directory ( reports_dir , filename , as_attachment = True )
except Exception as e :
2026-07-21 15:21:29 +08:00
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 ( )
2026-08-02 09:38:48 +08:00
# 查询所有成功的历史记录(附带筛选条件以增强安全性)
2026-07-21 15:21:29 +08:00
placeholders = ' , ' . join ( [ ' %s ' ] * len ( ids ) )
result = execute_query (
2026-08-02 09:38:48 +08:00
f ' SELECT id, file_path, config_name, report_date, status, split_name FROM t_daily_report_history WHERE id IN ( { placeholders } ) ' ,
2026-07-21 15:21:29 +08:00
tuple ( ids )
)
if not result :
return jsonify ( { ' success ' : False , ' message ' : ' 未找到对应的历史记录 ' } ) , 404
2026-08-02 09:38:48 +08:00
total_matched = len ( result )
success_records = [ r for r in result if r . get ( ' status ' ) == 1 ]
skipped_failed = total_matched - len ( success_records )
2026-07-21 15:21:29 +08:00
reports_dir = get_reports_dir ( )
temp_dir = get_temp_dir ( )
# 收集要打包的文件
files_to_zip = [ ]
failed_names = [ ]
2026-08-02 09:38:48 +08:00
used_names = set ( ) # 检测 zip 内文件名冲突
2026-07-21 15:21:29 +08:00
2026-08-02 09:38:48 +08:00
for item in success_records :
2026-07-21 15:21:29 +08:00
file_path = item . get ( ' file_path ' )
if not file_path :
2026-08-02 09:38:48 +08:00
failed_names . append ( f " { item . get ( ' config_name ' , ' 未知 ' ) } (无文件路径) " )
2026-07-21 15:21:29 +08:00
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 ) :
2026-08-02 09:38:48 +08:00
failed_names . append ( f " { item . get ( ' config_name ' , ' 未知 ' ) } _ { item . get ( ' report_date ' , ' ' ) } (文件不存在) " )
2026-07-21 15:21:29 +08:00
continue
2026-08-02 09:38:48 +08:00
# 使用配置名+报表日期+拆分值作为zip内的文件名, 避免重名
2026-07-21 15:21:29 +08:00
config_name = item . get ( ' config_name ' , ' 未知 ' )
report_date = item . get ( ' report_date ' , ' ' )
2026-08-02 09:38:48 +08:00
split_name = item . get ( ' split_name ' , ' ' )
ext = os . path . splitext ( filename ) [ 1 ] or ' .xlsx '
base_parts = [ config_name ]
2026-07-21 15:21:29 +08:00
if report_date :
2026-08-02 09:38:48 +08:00
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 )
2026-07-21 15:21:29 +08:00
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 ' ] )
2026-08-02 09:38:48 +08:00
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 ' )
2026-07-21 15:21:29 +08:00
2026-08-02 09:38:48 +08:00
# 返回zip文件, 通过响应头返回统计信息
2026-07-21 15:21:29 +08:00
from flask import send_file
2026-08-02 09:38:48 +08:00
response = send_file (
2026-07-21 15:21:29 +08:00
zip_path ,
as_attachment = True ,
download_name = zip_filename ,
mimetype = ' application/zip '
)
2026-08-02 09:38:48 +08:00
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
2026-07-21 15:21:29 +08:00
except Exception as e :
log_error ( f ' [下载API] 批量下载错误: { e } ' , ' download ' )
2026-07-13 15:43:03 +08:00
import traceback
traceback . print_exc ( )
return jsonify ( { ' success ' : False , ' message ' : str ( e ) } ) , 500
2026-08-02 09:14:59 +08:00
@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 )
2026-08-02 09:38:48 +08:00
# 注意: 批量导出只导出成功记录( status=1) , 忽略用户传入的 status 筛选
# 因为失败的记录没有文件可下载
2026-08-02 09:14:59 +08:00
if config_name :
where_clauses . append ( ' config_name LIKE %s ' )
params . append ( f ' % { config_name } % ' )
where_sql = ' AND ' . join ( where_clauses )
2026-08-02 09:38:48 +08:00
# 先查询总数,用于日志记录
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 ' )
# 查询所有匹配的成功记录(按报表日期正序排列,方便用户查看)
2026-08-02 09:14:59 +08:00
result = execute_query (
2026-08-02 09:38:48 +08:00
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 ' ,
2026-08-02 09:14:59 +08:00
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 = [ ]
2026-08-02 09:38:48 +08:00
used_names = set ( ) # 用于检测 zip 内文件名冲突
2026-08-02 09:14:59 +08:00
for item in result :
file_path = item . get ( ' file_path ' )
if not file_path :
2026-08-02 09:38:48 +08:00
failed_names . append ( f " { item . get ( ' config_name ' , ' 未知 ' ) } _ { item . get ( ' report_date ' , ' ' ) } (无文件路径) " )
2026-08-02 09:14:59 +08:00
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 ) :
2026-08-02 09:38:48 +08:00
failed_names . append ( f " { item . get ( ' config_name ' , ' 未知 ' ) } _ { item . get ( ' report_date ' , ' ' ) } (文件不存在) " )
2026-08-02 09:14:59 +08:00
continue
2026-08-02 09:38:48 +08:00
# 构建 zip 内文件名: 配置名_报表日期( 避免重名)
2026-08-02 09:14:59 +08:00
config_name_val = item . get ( ' config_name ' , ' 未知 ' )
report_date = item . get ( ' report_date ' , ' ' )
2026-08-02 09:38:48 +08:00
split_name = item . get ( ' split_name ' , ' ' )
ext = os . path . splitext ( filename ) [ 1 ] or ' .xlsx '
# 基础名称: 配置名_报表日期
base_parts = [ config_name_val ]
2026-08-02 09:14:59 +08:00
if report_date :
2026-08-02 09:38:48 +08:00
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 )
2026-08-02 09:14:59 +08:00
files_to_zip . append ( {
' full_path ' : full_path ,
' inner_name ' : zip_inner_name
} )
2026-08-02 09:38:48 +08:00
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 ' )
2026-08-02 09:14:59 +08:00
if len ( files_to_zip ) == 0 :
2026-08-02 09:38:48 +08:00
return jsonify ( { ' success ' : False , ' message ' : ' 没有可下载的文件(匹配的记录文件均不存在) ' } ) , 400
2026-08-02 09:14:59 +08:00
# 生成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 ' )
2026-08-02 09:38:48 +08:00
# 返回zip文件, 通过自定义头部返回统计信息
2026-08-02 09:14:59 +08:00
from flask import send_file
2026-08-02 09:38:48 +08:00
response = send_file (
2026-08-02 09:14:59 +08:00
zip_path ,
as_attachment = True ,
download_name = zip_filename ,
mimetype = ' application/zip '
)
2026-08-02 09:38:48 +08:00
# 通过响应头返回统计信息,便于前端诊断
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
2026-08-02 09:14:59 +08:00
except Exception as e :
log_error ( f ' [下载API] 按筛选批量下载错误: { e } ' , ' download ' )
import traceback
traceback . print_exc ( )
return jsonify ( { ' success ' : False , ' message ' : str ( e ) } ) , 500
2026-08-02 09:38:48 +08:00
@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