Kamixitong/app/api/version.py
2025-11-16 19:56:14 +08:00

659 lines
26 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.

from flask import request, jsonify, current_app
from datetime import datetime
import os
import hashlib
from werkzeug.utils import secure_filename
from app import db
from app.models import Version, Product, Device
from . import api_bp
from .decorators import require_login, require_admin
from sqlalchemy import desc, func, case, or_
import traceback
import sys
from app.utils.logger import log_operation
@api_bp.route('/versions', methods=['GET'])
@require_login
def get_versions():
"""获取版本列表"""
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('publish_status', type=int)
include_stats = request.args.get('include_stats', 'true').lower() == 'true'
query = Version.query
if product_id:
query = query.filter_by(product_id=product_id)
if status is not None:
query = query.filter_by(publish_status=status)
query = query.order_by(desc(Version.create_time))
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
versions = pagination.items
# 优化批量查询统计信息避免N+1查询问题
if include_stats and versions:
# 构建版本查询条件
version_conditions = []
for v in versions:
version_conditions.append(
(Device.product_id == v.product_id) &
(Device.software_version == v.version_num)
)
if version_conditions:
# 批量查询设备统计
device_stats = db.session.query(
Device.product_id,
Device.software_version,
func.count(Device.device_id).label('download_count'),
func.sum(case((Device.status == 1, 1), else_=0)).label('active_device_count')
).filter(
or_(*version_conditions)
).group_by(Device.product_id, Device.software_version).all()
# 构建统计字典
stats_dict = {}
for stat in device_stats:
key = (stat.product_id, stat.software_version)
stats_dict[key] = {
'download_count': stat.download_count or 0,
'active_device_count': stat.active_device_count or 0
}
# 为每个版本添加统计信息
for version in versions:
key = (version.product_id, version.version_num)
stats = stats_dict.get(key, {'download_count': 0, 'active_device_count': 0})
version._cached_stats = stats
versions = [version.to_dict(include_stats=include_stats) for version in versions]
return jsonify({
'success': True,
'data': {
'versions': versions,
'pagination': {
'page': page,
'per_page': per_page,
'total': pagination.total,
'pages': pagination.pages
}
}
})
except Exception as e:
current_app.logger.error(f"获取版本列表失败: {str(e)}")
return jsonify({'success': False, 'message': '服务器内部错误'}), 500
@api_bp.route('/versions', methods=['POST'])
@require_login
def create_version():
"""创建版本"""
try:
current_app.logger.info("开始创建版本")
current_app.logger.info(f"请求的Content-Type: {request.content_type}")
current_app.logger.info(f"请求方法: {request.method}")
current_app.logger.info(f"请求URL: {request.url}")
current_app.logger.info(f"请求头: {dict(request.headers)}")
# 处理文件上传和表单数据
if request.content_type and ('multipart/form-data' in request.content_type or 'application/x-www-form-urlencoded' in request.content_type):
current_app.logger.info("处理表单数据请求")
# 处理表单数据
product_id = request.form.get('product_id')
version_num = request.form.get('version_num')
update_log = request.form.get('update_notes', '') # 修改字段名映射
download_url = request.form.get('download_url', '')
file_hash = request.form.get('file_hash', '')
force_update = request.form.get('force_update', 0)
download_status = request.form.get('download_status', 1)
min_license_version = request.form.get('min_license_version')
# 添加对新增字段的处理
platform = request.form.get('platform', '')
description = request.form.get('description', '')
publish_now = request.form.get('publish_now') == 'true' or request.form.get('publish_now') == 'on'
else:
current_app.logger.info("处理JSON请求")
# 处理JSON数据
data = request.get_json()
if not data:
current_app.logger.warning("请求数据为空")
return jsonify({'success': False, 'message': '请求数据为空'}), 400
product_id = data.get('product_id')
version_num = data.get('version_num')
update_log = data.get('update_notes', '') # 修改字段名映射
download_url = data.get('download_url', '')
file_hash = data.get('file_hash', '')
force_update = data.get('force_update', 0)
download_status = data.get('download_status', 1)
min_license_version = data.get('min_license_version')
# 添加对新增字段的处理
platform = data.get('platform', '')
description = data.get('description', '')
publish_now = data.get('publish_now', False)
current_app.logger.info(f"收到的参数: product_id={product_id}, version_num={version_num}")
if not all([product_id, version_num]):
current_app.logger.warning(f"缺少必要参数: product_id={product_id}, version_num={version_num}")
return jsonify({'success': False, 'message': '缺少必要参数'}), 400
current_app.logger.info(f"验证产品是否存在: product_id={product_id}")
# 验证产品存在
current_app.logger.info(f"执行产品查询...")
# 先查询所有产品进行调试
all_products = Product.query.all()
current_app.logger.info(f"数据库中所有产品: {[(p.product_id, p.product_name) for p in all_products]}")
# 执行实际查询
product = Product.query.filter_by(product_id=product_id).first()
current_app.logger.info(f"产品查询结果: {product}")
if not product:
current_app.logger.warning(f"产品不存在: product_id={product_id}")
return jsonify({'success': False, 'message': '产品不存在'}), 404
current_app.logger.info(f"检查版本号是否重复: product_id={product_id}, version_num={version_num}")
# 检查版本号是否重复
existing = Version.query.filter_by(
product_id=product_id,
version_num=version_num
).first()
if existing:
current_app.logger.warning(f"版本号已存在: product_id={product_id}, version_num={version_num}")
return jsonify({'success': False, 'message': '版本号已存在'}), 400
# 验证文件链接格式(如果提供了链接)
if download_url:
from urllib.parse import urlparse
try:
result = urlparse(download_url)
if not all([result.scheme, result.netloc]):
current_app.logger.warning(f"无效的文件链接格式: {download_url}")
return jsonify({'success': False, 'message': '文件链接格式无效'}), 400
except Exception as e:
current_app.logger.warning(f"文件链接验证失败: {str(e)}")
return jsonify({'success': False, 'message': '文件链接格式无效'}), 400
current_app.logger.info(f"创建版本对象: product_id={product_id}, version_num={version_num}")
version = Version(
product_id=product_id,
version_num=version_num,
platform=platform, # 添加新字段
description=description, # 添加新字段
update_log=update_log,
download_url=download_url,
file_hash=file_hash,
force_update=force_update,
download_status=download_status,
min_license_version=min_license_version
)
current_app.logger.info("添加版本到数据库")
db.session.add(version)
current_app.logger.info("提交数据库事务")
db.session.commit()
current_app.logger.info(f"检查是否立即发布: publish_now={publish_now}")
# 如果选择了立即发布,则发布版本
if publish_now:
current_app.logger.info("发布版本")
version.publish()
# 记录操作日志
log_operation('CREATE_VERSION', 'VERSION', version.version_id, {
'product_id': version.product_id,
'version_num': version.version_num,
'publish_now': publish_now,
'has_download_url': bool(download_url)
})
return jsonify({
'success': True,
'message': '版本创建成功',
'data': version.to_dict()
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"创建版本失败: {str(e)}")
# 添加更详细的错误信息
import traceback
current_app.logger.error(f"创建版本失败详细信息: {traceback.format_exc()}")
return jsonify({'success': False, 'message': f'服务器内部错误: {str(e)}'}), 500
@api_bp.route('/versions/<int:version_id>/publish', methods=['POST'])
@require_login
def publish_version(version_id):
"""发布版本"""
try:
version = Version.query.get(version_id)
if not version:
return jsonify({'success': False, 'message': '版本不存在'}), 404
version.publish()
# 记录操作日志
log_operation('PUBLISH_VERSION', 'VERSION', version.version_id, {
'version_num': version.version_num
})
return jsonify({
'success': True,
'message': '版本发布成功',
'data': version.to_dict()
})
except Exception as e:
current_app.logger.error(f"发布版本失败: {str(e)}")
return jsonify({'success': False, 'message': '服务器内部错误'}), 500
@api_bp.route('/versions/<int:version_id>', methods=['PUT'])
@require_login
def update_version(version_id):
"""更新版本信息"""
try:
version = Version.query.get(version_id)
if not version:
return jsonify({'success': False, 'message': '版本不存在'}), 404
# 获取请求数据
data = request.get_json()
if not data:
return jsonify({'success': False, 'message': '请求数据为空'}), 400
# 更新版本信息(只允许更新未发布或已回滚的版本的部分信息)
# 已发布的版本只能更新描述信息
version.platform = data.get('platform', version.platform)
version.description = data.get('description', version.description)
version.update_log = data.get('update_log', version.update_log)
version.min_license_version = data.get('min_license_version', version.min_license_version)
version.force_update = data.get('force_update', version.force_update)
version.download_status = data.get('download_status', version.download_status)
# 只有未发布或已回滚的版本才能更新版本号和产品
if version.publish_status != 1: # 不是已发布状态
version.version_num = data.get('version_num', version.version_num)
version.product_id = data.get('product_id', version.product_id)
# 保存更新
db.session.commit()
# 记录操作日志
log_operation('UPDATE_VERSION', 'VERSION', version.version_id, {
'version_num': version.version_num,
'platform': version.platform,
'description': version.description
})
return jsonify({
'success': True,
'message': '版本信息更新成功',
'data': version.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('/versions/<int:version_id>/status', methods=['PUT'])
@require_login
def update_version_status(version_id):
"""更新版本状态(发布/取消发布)"""
try:
version = Version.query.get(version_id)
if not version:
return jsonify({'success': False, 'message': '版本不存在'}), 404
# 获取请求数据
data = request.get_json()
status = data.get('status')
if status is None:
return jsonify({'success': False, 'message': '缺少状态参数'}), 400
# 更新状态
if status == 1: # 发布
version.publish()
message = '版本发布成功'
elif status == 0: # 取消发布
version.unpublish()
message = '版本取消发布成功'
elif status == 2: # 回滚
version.rollback()
message = '版本回滚成功'
else:
return jsonify({'success': False, 'message': '无效的状态值'}), 400
# 记录操作日志
status_names = {0: '取消发布', 1: '发布', 2: '回滚'}
log_operation('UPDATE_VERSION_STATUS', 'VERSION', version.version_id, {
'version_num': version.version_num,
'status': status,
'status_name': status_names.get(status, '未知')
})
return jsonify({
'success': True,
'message': message,
'data': version.to_dict()
})
except Exception as e:
current_app.logger.error(f"更新版本状态失败: {str(e)}")
return jsonify({
'success': False,
'message': '服务器内部错误'
}), 500
@api_bp.route('/versions/upload', methods=['POST'])
@require_login
def upload_version_file():
"""上传版本文件"""
try:
# 检查是否有文件上传
if 'version_file' not in request.files:
return jsonify({'success': False, 'message': '没有文件被上传'}), 400
file = request.files['version_file']
if file.filename == '' or file.filename is None:
return jsonify({'success': False, 'message': '没有选择文件'}), 400
if file:
# 确保上传目录存在
upload_folder = current_app.config['UPLOAD_FOLDER']
# 确保使用绝对路径
upload_folder = os.path.abspath(upload_folder)
os.makedirs(upload_folder, exist_ok=True)
# 生成安全的文件名
filename = secure_filename(str(file.filename))
# 在文件名前添加时间戳以避免重复
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
filename = f"{timestamp}_{filename}"
# 保存文件
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
# 计算文件哈希值
file_hash = calculate_file_hash(file_path)
# 生成文件访问URL相对于static目录
# 修复URL生成逻辑
file_url = f"/static/uploads/{filename}"
return jsonify({
'success': True,
'message': '文件上传成功',
'data': {
'file_name': filename,
'file_url': file_url,
'file_hash': file_hash,
'file_size': os.path.getsize(file_path)
}
})
else:
return jsonify({'success': False, 'message': '文件上传失败'}), 400
except Exception as e:
current_app.logger.error(f"文件上传失败: {str(e)}")
import traceback
current_app.logger.error(f"文件上传失败详细信息: {traceback.format_exc()}")
return jsonify({'success': False, 'message': f'文件上传失败: {str(e)}'}), 500
def calculate_file_hash(file_path):
"""计算文件SHA256哈希值"""
try:
hash_sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
except Exception as e:
raise Exception(f"计算文件哈希值失败: {str(e)}")
def extract_filename_from_url(download_url):
"""从下载URL中提取文件名"""
if not download_url:
return None
# 从URL中提取文件名假设URL格式为 /static/uploads/filename
if '/static/uploads/' in download_url:
filename = download_url.split('/static/uploads/')[-1]
return filename
return None
@api_bp.route('/versions/<int:version_id>', methods=['DELETE'])
@require_login
def delete_version(version_id):
"""删除版本"""
try:
version = Version.query.get(version_id)
if not version:
return jsonify({'success': False, 'message': '版本不存在'}), 404
# 检查版本是否已被发布,已发布的版本不能直接删除
if version.is_published():
return jsonify({'success': False, 'message': '已发布的版本不能直接删除,请先取消发布后再删除'}), 400
# 检查是否有设备正在使用此版本
active_device_count = version.get_active_device_count()
if active_device_count > 0:
return jsonify({'success': False, 'message': f'{active_device_count}个活跃设备正在使用此版本,为保护数据安全,不能直接删除。请先将这些设备解绑或禁用后再删除版本'}), 400
# 删除版本相关的文件
if version.download_url:
filename = extract_filename_from_url(version.download_url)
if filename:
upload_folder = os.path.abspath(current_app.config['UPLOAD_FOLDER'])
file_path = os.path.join(upload_folder, filename)
if os.path.exists(file_path):
try:
os.remove(file_path)
current_app.logger.info(f"已删除版本文件: {file_path}")
except Exception as e:
current_app.logger.error(f"删除版本文件失败: {str(e)}")
# 删除版本
db.session.delete(version)
db.session.commit()
# 记录操作日志
log_operation('DELETE_VERSION', 'VERSION', version.version_id, {
'version_num': version.version_num
})
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('/versions/batch', methods=['DELETE'])
@require_login
def batch_delete_versions():
"""批量删除版本"""
try:
data = request.get_json()
if not data or 'version_ids' not in data:
return jsonify({
'success': False,
'message': '请求数据为空或缺少version_ids字段'
}), 400
version_ids = data['version_ids']
if not isinstance(version_ids, list) or len(version_ids) == 0:
return jsonify({
'success': False,
'message': 'version_ids必须是非空列表'
}), 400
# 查找所有要删除的版本
versions = Version.query.filter(Version.version_id.in_(version_ids)).all()
if len(versions) != len(version_ids):
found_ids = [v.version_id for v in versions]
missing_ids = [vid for vid in version_ids if vid not in found_ids]
return jsonify({
'success': False,
'message': f'以下版本不存在: {", ".join(map(str, missing_ids))}'
}), 404
# 检查是否有已发布的版本
published_versions = [v for v in versions if v.is_published()]
if published_versions:
published_ids = [str(v.version_id) for v in published_versions]
return jsonify({
'success': False,
'message': f'以下版本已发布,不能直接删除: {", ".join(published_ids)},请先取消发布后再删除'
}), 400
# 检查是否有设备正在使用这些版本
undeletable_versions = []
for version in versions:
active_device_count = version.get_active_device_count()
if active_device_count > 0:
undeletable_versions.append({
'version_id': version.version_id,
'version_num': version.version_num,
'active_device_count': active_device_count
})
if undeletable_versions:
undeletable_info = [f'{v["version_num"]}({v["active_device_count"]}个设备)' for v in undeletable_versions]
return jsonify({
'success': False,
'message': f'以下版本有活跃设备正在使用,不能直接删除: {", ".join(undeletable_info)},请先将这些设备解绑或禁用后再删除版本'
}), 400
# 批量删除版本
for version in versions:
# 删除版本相关的文件
if version.download_url:
filename = extract_filename_from_url(version.download_url)
if filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
# 检查文件是否存在,如果存在则删除
if os.path.exists(file_path):
try:
os.remove(file_path)
current_app.logger.info(f"已删除版本文件: {file_path}")
except Exception as e:
current_app.logger.error(f"删除版本文件失败: {str(e)}")
# 即使文件删除失败也继续删除版本记录
db.session.delete(version)
db.session.commit()
# 记录操作日志
version_ids = [version.version_id for version in versions]
log_operation('BATCH_DELETE_VERSIONS', 'VERSION', None, {
'version_ids': version_ids,
'count': len(versions)
})
return jsonify({
'success': True,
'message': f'成功删除 {len(versions)} 个版本'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"批量删除版本失败: {str(e)}")
return jsonify({
'success': False,
'message': '服务器内部错误'
}), 500
@api_bp.route('/versions/batch/status', methods=['PUT'])
@require_login
def batch_update_version_status():
"""批量更新版本状态(发布/取消发布)"""
try:
data = request.get_json()
if not data or 'version_ids' not in data or 'status' not in data:
return jsonify({
'success': False,
'message': '请求数据为空或缺少version_ids/status字段'
}), 400
version_ids = data['version_ids']
status = data['status']
if not isinstance(version_ids, list) or len(version_ids) == 0:
return jsonify({
'success': False,
'message': 'version_ids必须是非空列表'
}), 400
if status not in [0, 1]:
return jsonify({
'success': False,
'message': 'status必须是0(草稿)或1(已发布)'
}), 400
# 查找所有要更新的版本
versions = Version.query.filter(Version.version_id.in_(version_ids)).all()
if len(versions) != len(version_ids):
found_ids = [v.version_id for v in versions]
missing_ids = [vid for vid in version_ids if vid not in found_ids]
return jsonify({
'success': False,
'message': f'以下版本不存在: {", ".join(map(str, missing_ids))}'
}), 404
# 批量更新版本状态
for version in versions:
if status == 1:
version.publish()
else:
version.unpublish()
# 记录操作日志
version_ids = [version.version_id for version in versions]
log_operation('BATCH_UPDATE_VERSION_STATUS', 'VERSION', None, {
'version_ids': version_ids,
'status': status,
'count': len(versions)
})
status_name = '发布' if status == 1 else '取消发布'
return jsonify({
'success': True,
'message': f'成功{status_name} {len(versions)} 个版本'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"批量更新版本状态失败: {str(e)}")
return jsonify({
'success': False,
'message': '服务器内部错误'
}), 500