""" 文件下载 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