from flask import request, jsonify, current_app, send_file from datetime import datetime, timedelta from app import db from app.models import Product, License from . import api_bp from .decorators import require_login, require_admin import io import csv import xlsxwriter from functools import wraps from sqlalchemy import or_, func from app.utils.logger import log_operation @api_bp.route('/licenses', methods=['GET']) @require_login def get_licenses(): """获取卡密列表""" try: # 获取查询参数 page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 20, type=int), 100) product_id = request.args.get('product_id') status = request.args.get('status', type=int) license_type = request.args.get('type', type=int) keyword = request.args.get('keyword', '').strip() sort = request.args.get('sort', 'create_time') # 排序字段 order = request.args.get('order', 'desc') # 排序方向 # 构建查询 - 使用join预加载product关系,避免N+1查询 query = db.session.query(License).join(Product, License.product_id == Product.product_id) # 产品筛选 if product_id: query = query.filter(License.product_id == product_id) # 状态筛选 if status is not None: query = query.filter(License.status == status) # 类型筛选 if license_type is not None: query = query.filter(License.type == license_type) # 关键词搜索(卡密或产品名称) if keyword: query = query.filter( db.or_( func.lower(License.license_key).like(f'%{keyword.lower()}%'), func.lower(Product.product_name).like(f'%{keyword.lower()}%') ) ) # 排序处理 sort_field = getattr(License, sort, License.create_time) # 默认按创建时间 if order.lower() == 'asc': query = query.order_by(sort_field.asc()) else: query = query.order_by(sort_field.desc()) # 分页 pagination = query.paginate( page=page, per_page=per_page, error_out=False ) # 格式化结果 - product关系已经通过join加载 licenses = [license.to_dict() for license in pagination.items] return jsonify({ 'success': True, 'data': { 'licenses': licenses, 'pagination': { 'page': page, 'per_page': per_page, 'total': pagination.total, 'pages': pagination.pages, 'has_prev': pagination.has_prev, 'has_next': pagination.has_next } } }) except Exception as e: current_app.logger.error(f"获取卡密列表失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses', methods=['POST']) @require_login def generate_licenses(): """批量生成卡密""" try: data = request.get_json() if not data: return jsonify({ 'success': False, 'message': '请求数据为空' }), 400 # 获取参数 product_id = data.get('product_id') count = data.get('count', 1) license_type = data.get('type', 1) # 0=试用, 1=正式 valid_days = data.get('valid_days') # 不设置默认值,保持None prefix = data.get('prefix', '') length = data.get('length', 32) # 验证参数 if not product_id: return jsonify({ 'success': False, 'message': '产品ID不能为空' }), 400 if count < 1 or count > 10000: return jsonify({ 'success': False, 'message': '生成数量必须在1-10000之间' }), 400 if length < 16 or length > 35: return jsonify({ 'success': False, 'message': '卡密长度必须在16-35之间' }), 400 # 验证产品存在 product = Product.query.filter_by(product_id=product_id).first() if not product: return jsonify({ 'success': False, 'message': '产品不存在' }), 404 # 设置默认有效期 if valid_days is None: valid_days = 1 # 默认为1天(天卡) # 试用卡密最大有效期限制 if license_type == 0 and valid_days > 90: return jsonify({ 'success': False, 'message': '试用卡密有效期不能超过90天' }), 400 # 验证有效期范围 if valid_days != -1 and (valid_days < 1 or valid_days > 3650): return jsonify({ 'success': False, 'message': '卡密有效期必须在1-3650天之间,或-1表示永久' }), 400 # 验证永久卡不能是试用卡 if valid_days == -1 and license_type == 0: return jsonify({ 'success': False, 'message': '试用卡密不能设置为永久' }), 400 # 生成卡密 try: licenses = License.generate_batch( product_id=product_id, count=count, license_type=license_type, valid_days=valid_days, prefix=prefix, length=length ) # 批量保存 db.session.add_all(licenses) db.session.commit() # 格式化结果 license_data = [license.to_dict() for license in licenses] # 记录操作日志 license_keys = [license.license_key for license in licenses] log_operation('GENERATE_LICENSES', 'LICENSE', None, { 'product_id': product_id, 'count': count, 'license_type': license_type, 'license_keys': license_keys }) return jsonify({ 'success': True, 'message': f'成功生成 {count} 个卡密', 'data': { 'licenses': license_data, 'count': len(licenses) } }) except Exception as e: db.session.rollback() current_app.logger.error(f"生成卡密失败: {str(e)}") is_production = current_app.config.get('FLASK_ENV') == 'production' or not current_app.config.get('DEBUG', False) error_message = '生成卡密失败,请稍后重试' if is_production else f'生成卡密失败: {str(e)}' return jsonify({ 'success': False, 'message': error_message }), 500 except Exception as e: current_app.logger.error(f"生成卡密失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses/', methods=['GET']) @require_login def get_license(license_id): """获取单个卡密详情""" try: license_obj = License.query.get(license_id) if not license_obj: return jsonify({ 'success': False, 'message': '卡密不存在' }), 404 return jsonify({ 'success': True, 'data': license_obj.to_dict() }) except Exception as e: current_app.logger.error(f"获取卡密详情失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses/', methods=['PUT']) @require_login def update_license(license_id): """更新卡密信息""" try: license_obj = License.query.get(license_id) if not license_obj: return jsonify({ 'success': False, 'message': '卡密不存在' }), 404 data = request.get_json() if not data: return jsonify({ 'success': False, 'message': '请求数据为空' }), 400 # 更新状态 if 'status' in data: new_status = data['status'] # 已激活的卡密不能改为未激活 if license_obj.status == 1 and new_status == 0: return jsonify({ 'success': False, 'message': '已激活的卡密不能改为未激活状态' }), 400 license_obj.status = new_status # 更新备注 if 'remark' in data: license_obj.remark = data['remark'] db.session.commit() # 记录操作日志 log_operation('UPDATE_LICENSE', 'LICENSE', license_obj.license_id, { 'status': license_obj.status, 'remark': license_obj.remark }) return jsonify({ 'success': True, 'message': '更新成功', 'data': license_obj.to_dict() }) except Exception as e: db.session.rollback() current_app.logger.error(f"更新卡密失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses//extend', methods=['POST']) @require_login def extend_license(license_id): """延长卡密有效期""" try: license_obj = License.query.get(license_id) if not license_obj: return jsonify({ 'success': False, 'message': '卡密不存在' }), 404 data = request.get_json() if not data or 'days' not in data: return jsonify({ 'success': False, 'message': '缺少延期天数参数' }), 400 days = data['days'] if days <= 0: return jsonify({ 'success': False, 'message': '延期天数必须大于0' }), 400 success, message = license_obj.extend_validity(days) if not success: return jsonify({ 'success': False, 'message': message }), 400 # 记录操作日志 log_operation('EXTEND_LICENSE', 'LICENSE', license_obj.license_id, { 'days': days, 'new_expire_time': license_obj.expire_time.strftime('%Y-%m-%d %H:%M:%S') if license_obj.expire_time else None }) return jsonify({ 'success': True, 'message': message, 'data': license_obj.to_dict() }) except Exception as e: db.session.rollback() current_app.logger.error(f"延长卡密有效期失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses//convert', methods=['POST']) @require_login def convert_license(license_id): """试用卡密转正式""" try: license_obj = License.query.get(license_id) if not license_obj: return jsonify({ 'success': False, 'message': '卡密不存在' }), 404 data = request.get_json() if not data or 'valid_days' not in data: return jsonify({ 'success': False, 'message': '缺少有效期参数' }), 400 valid_days = data['valid_days'] if valid_days <= 0: return jsonify({ 'success': False, 'message': '有效期必须大于0天' }), 400 success, message = license_obj.convert_to_formal(valid_days) if not success: return jsonify({ 'success': False, 'message': message }), 400 # 记录操作日志 log_operation('CONVERT_LICENSE', 'LICENSE', license_obj.license_id, { 'valid_days': valid_days }) return jsonify({ 'success': True, 'message': message, 'data': license_obj.to_dict() }) except Exception as e: db.session.rollback() current_app.logger.error(f"转换卡密失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses//unbind', methods=['POST']) @require_login def unbind_license(license_id): """解绑设备""" try: license_obj = License.query.get(license_id) if not license_obj: return jsonify({ 'success': False, 'message': '卡密不存在' }), 404 # 获取最大解绑次数配置 max_unbind_times = current_app.config.get('MAX_UNBIND_TIMES', 3) success, message = license_obj.unbind() if not success: return jsonify({ 'success': False, 'message': message }), 400 # 记录操作日志 log_operation('UNBIND_LICENSE', 'LICENSE', license_obj.license_id, { 'machine_code': license_obj.bind_machine_code }) return jsonify({ 'success': True, 'message': message, 'data': license_obj.to_dict() }) except Exception as e: db.session.rollback() current_app.logger.error(f"解绑设备失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses/export', methods=['POST']) @require_login def export_licenses(): """导出卡密""" try: data = request.get_json() if not data: return jsonify({ 'success': False, 'message': '请求数据为空' }), 400 product_id = data.get('product_id') status = data.get('status') license_type = data.get('type') export_format = data.get('format', 'excel') # excel 或 csv # 构建查询 query = License.query if product_id: query = query.filter_by(product_id=product_id) if status is not None: query = query.filter_by(status=status) if license_type is not None: query = query.filter_by(type=license_type) # 获取数据 licenses = query.order_by(License.create_time.desc()).all() if not licenses: return jsonify({ 'success': False, 'message': '没有找到符合条件的卡密' }), 404 if export_format == 'csv': # 导出CSV output = io.StringIO() writer = csv.writer(output) # 写入表头 writer.writerow(['卡密', '产品ID', '产品名称', '类型', '状态', '有效期', '绑定机器码', '激活时间', '过期时间', '创建时间']) # 写入数据 for license_obj in licenses: writer.writerow([ license_obj.license_key, license_obj.product_id, license_obj.product.product_name if license_obj.product else '', '试用' if license_obj.is_trial() else '正式', license_obj.get_status_name(), '永久' if license_obj.valid_days == -1 else f'{license_obj.valid_days}天', license_obj.bind_machine_code or '', license_obj.activate_time.strftime('%Y-%m-%d %H:%M:%S') if license_obj.activate_time else '', license_obj.expire_time.strftime('%Y-%m-%d %H:%M:%S') if license_obj.expire_time else '', license_obj.create_time.strftime('%Y-%m-%d %H:%M:%S') ]) output.seek(0) return send_file( io.BytesIO(output.getvalue().encode('utf-8-sig')), mimetype='text/csv', as_attachment=True, download_name=f'licenses_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' ) else: # 导出Excel output = io.BytesIO() workbook = xlsxwriter.Workbook(output) worksheet = workbook.add_worksheet('卡密列表') # 设置表头格式 header_format = workbook.add_format({ 'bold': True, 'text_wrap': True, 'valign': 'top', 'fg_color': '#D7E4BC', 'border': 1 }) # 写入表头 headers = ['卡密', '产品ID', '产品名称', '类型', '状态', '有效期', '绑定机器码', '激活时间', '过期时间', '创建时间'] for col, header in enumerate(headers): worksheet.write(0, col, header, header_format) # 写入数据 for row, license_obj in enumerate(licenses, 1): worksheet.write(row, 0, license_obj.license_key) worksheet.write(row, 1, license_obj.product_id) worksheet.write(row, 2, license_obj.product.product_name if license_obj.product else '') worksheet.write(row, 3, '试用' if license_obj.is_trial() else '正式') worksheet.write(row, 4, license_obj.get_status_name()) worksheet.write(row, 5, '永久' if license_obj.valid_days == -1 else f'{license_obj.valid_days}天') worksheet.write(row, 6, license_obj.bind_machine_code or '') worksheet.write(row, 7, license_obj.activate_time.strftime('%Y-%m-%d %H:%M:%S') if license_obj.activate_time else '') worksheet.write(row, 8, license_obj.expire_time.strftime('%Y-%m-%d %H:%M:%S') if license_obj.expire_time else '') worksheet.write(row, 9, license_obj.create_time.strftime('%Y-%m-%d %H:%M:%S')) # 设置列宽 worksheet.set_column(0, 0, 35) # 卡密 worksheet.set_column(1, 1, 20) # 产品ID worksheet.set_column(2, 2, 25) # 产品名称 worksheet.set_column(3, 5, 12) # 类型、状态、有效期 worksheet.set_column(6, 6, 35) # 机器码 worksheet.set_column(7, 9, 20) # 时间字段 workbook.close() output.seek(0) return send_file( output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=f'licenses_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx' ) except Exception as e: current_app.logger.error(f"导出卡密失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses/import', methods=['POST']) @require_login def import_licenses(): """导入卡密""" try: if 'file' not in request.files: return jsonify({ 'success': False, 'message': '没有上传文件' }), 400 file = request.files['file'] if file.filename == '': return jsonify({ 'success': False, 'message': '没有选择文件' }), 400 # 验证文件格式 if not file.filename or not (file.filename.endswith('.xlsx') or file.filename.endswith('.csv')): return jsonify({ 'success': False, 'message': '只支持Excel或CSV文件格式' }), 400 # 读取文件内容 import pandas as pd if file.filename.endswith('.xlsx'): # 读取Excel文件 file.stream.seek(0) # 重置文件指针 df = pd.read_excel(file.stream) else: # 读取CSV文件 file.stream.seek(0) # 重置文件指针 df = pd.read_csv(file.stream) # 验证必要列 required_columns = ['卡密', '产品ID', '有效期(天)', '类型'] missing_columns = [col for col in required_columns if col not in df.columns] if missing_columns: return jsonify({ 'success': False, 'message': f'缺少必要列: {", ".join(missing_columns)}' }), 400 success_count = 0 error_count = 0 error_messages = [] # 逐行处理 for index, row in df.iterrows(): try: license_key = str(row['卡密']).strip() product_id = str(row['产品ID']).strip() valid_days = int(row['有效期(天)']) license_type = 1 if str(row['类型']).strip() == '正式' else 0 # 验证产品存在 product = Product.query.filter_by(product_id=product_id).first() if not product: error_messages.append(f'第{int(str(index))+1}行: 产品ID {product_id} 不存在') error_count += 1 continue # 检查卡密是否已存在 existing = License.query.filter_by(license_key=license_key).first() if existing: error_messages.append(f'第{int(str(index))+1}行: 卡密 {license_key} 已存在') error_count += 1 continue # 创建新卡密 license_obj = License( license_key=license_key, product_id=product_id, type=license_type, valid_days=valid_days, status=0 # 未激活 ) db.session.add(license_obj) success_count += 1 except Exception as e: error_messages.append(f'第{int(str(index))+1}行: 处理失败 - {str(e)}') error_count += 1 # 提交成功的记录 try: db.session.commit() except Exception as e: db.session.rollback() current_app.logger.error(f"保存卡密失败: {str(e)}") is_production = current_app.config.get('FLASK_ENV') == 'production' or not current_app.config.get('DEBUG', False) error_message = '保存失败,请稍后重试' if is_production else f'保存失败: {str(e)}' return jsonify({ 'success': False, 'message': error_message }), 500 return jsonify({ 'success': True, 'message': f'导入完成,成功 {success_count} 条,失败 {error_count} 条', 'data': { 'success_count': success_count, 'error_count': error_count, 'error_messages': error_messages[:10] # 只返回前10条错误信息 } }) except Exception as e: current_app.logger.error(f"导入卡密失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses/', methods=['DELETE']) @require_login def delete_license(license_key): """删除卡密""" try: # 获取查询参数,检查是否允许强制删除 force = request.args.get('force', 'false').lower() == 'true' # 根据卡密key查找卡密 license_obj = License.query.filter_by(license_key=license_key).first() if not license_obj: return jsonify({ 'success': False, 'message': '卡密不存在' }), 404 # 检查卡密状态,已激活的卡密不能直接删除,除非强制删除 if license_obj.status == 1 and not force: return jsonify({ 'success': False, 'message': '已激活的卡密不能删除,请先禁用或解绑设备' }), 400 # 执行删除操作 db.session.delete(license_obj) db.session.commit() # 记录操作日志 log_operation('DELETE_LICENSE', 'LICENSE', None, { 'license_key': license_key }) return jsonify({ 'success': True, 'message': '卡密删除成功' }) except Exception as e: db.session.rollback() current_app.logger.error(f"删除卡密失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses/batch', methods=['DELETE']) @require_login def batch_delete_licenses(): """批量删除卡密""" try: data = request.get_json() if not data or 'license_keys' not in data: return jsonify({ 'success': False, 'message': '请求数据为空或缺少license_keys字段' }), 400 license_keys = data['license_keys'] if not isinstance(license_keys, list) or len(license_keys) == 0: return jsonify({ 'success': False, 'message': 'license_keys必须是非空列表' }), 400 # 查找所有要删除的卡密 licenses = License.query.filter(License.license_key.in_(license_keys)).all() if len(licenses) != len(license_keys): found_keys = [l.license_key for l in licenses] missing_keys = [key for key in license_keys if key not in found_keys] return jsonify({ 'success': False, 'message': f'以下卡密不存在: {", ".join(missing_keys)}' }), 404 # 检查是否有已激活的卡密 active_licenses = [l for l in licenses if l.status == 1] if active_licenses: active_keys = [l.license_key for l in active_licenses] return jsonify({ 'success': False, 'message': f'以下卡密已激活,不能直接删除: {", ".join(active_keys)},请先禁用或解绑设备' }), 400 # 批量删除卡密 for license_obj in licenses: db.session.delete(license_obj) db.session.commit() # 记录操作日志 license_keys = [license_obj.license_key for license_obj in licenses] log_operation('BATCH_DELETE_LICENSES', 'LICENSE', None, { 'license_keys': license_keys, 'count': len(licenses) }) return jsonify({ 'success': True, 'message': f'成功删除 {len(licenses)} 个卡密' }) except Exception as e: db.session.rollback() current_app.logger.error(f"批量删除卡密失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500 @api_bp.route('/licenses/batch/status', methods=['PUT']) @require_login def batch_update_license_status(): """批量更新卡密状态""" try: data = request.get_json() if not data or 'license_keys' not in data or 'status' not in data: return jsonify({ 'success': False, 'message': '请求数据为空或缺少license_keys/status字段' }), 400 license_keys = data['license_keys'] status = data['status'] if not isinstance(license_keys, list) or len(license_keys) == 0: return jsonify({ 'success': False, 'message': 'license_keys必须是非空列表' }), 400 if status not in [0, 1, 2, 3]: return jsonify({ 'success': False, 'message': 'status必须是0(未激活)、1(已激活)、2(已过期)或3(已禁用)' }), 400 # 查找所有要更新的卡密 licenses = License.query.filter(License.license_key.in_(license_keys)).all() if len(licenses) != len(license_keys): found_keys = [l.license_key for l in licenses] missing_keys = [key for key in license_keys if key not in found_keys] return jsonify({ 'success': False, 'message': f'以下卡密不存在: {", ".join(missing_keys)}' }), 404 # 检查是否有已激活的卡密要改为未激活 if status == 0: active_licenses = [l for l in licenses if l.status == 1] if active_licenses: active_keys = [l.license_key for l in active_licenses] return jsonify({ 'success': False, 'message': f'以下卡密已激活,不能改为未激活状态: {", ".join(active_keys)}' }), 400 # 批量更新卡密状态 for license_obj in licenses: license_obj.status = status db.session.commit() # 记录操作日志 license_keys = [license_obj.license_key for license_obj in licenses] log_operation('BATCH_UPDATE_LICENSE_STATUS', 'LICENSE', None, { 'license_keys': license_keys, 'status': status, 'count': len(licenses) }) status_names = {0: '未激活', 1: '已激活', 2: '已过期', 3: '已禁用'} status_name = status_names.get(status, '未知') return jsonify({ 'success': True, 'message': f'成功将 {len(licenses)} 个卡密状态更新为{status_name}' }) except Exception as e: db.session.rollback() current_app.logger.error(f"批量更新卡密状态失败: {str(e)}") return jsonify({ 'success': False, 'message': '服务器内部错误' }), 500