Kamixitong/app/api/version.py

662 lines
26 KiB
Python
Raw Normal View History

2025-11-11 21:39:12 +08:00
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
2025-11-15 23:57:05 +08:00
from app.models import Version, Product, Device
2025-11-11 21:39:12 +08:00
from . import api_bp
2025-11-15 23:57:05 +08:00
from .decorators import require_login, require_admin
from sqlalchemy import desc, func, case, or_
2025-11-11 21:39:12 +08:00
import traceback
import sys
2025-11-15 23:57:05 +08:00
from app.utils.logger import log_operation
2025-11-11 21:39:12 +08:00
@api_bp.route('/versions', methods=['GET'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-11 21:39:12 +08:00
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)
2025-11-17 12:55:58 +08:00
keyword = request.args.get('keyword', '').strip()
2025-11-15 23:57:05 +08:00
include_stats = request.args.get('include_stats', 'true').lower() == 'true'
2025-11-11 21:39:12 +08:00
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)
2025-11-17 12:55:58 +08:00
if keyword:
query = query.filter(Version.version_num.like(f'%{keyword}%'))
2025-11-11 21:39:12 +08:00
query = query.order_by(desc(Version.create_time))
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
2025-11-15 23:57:05 +08:00
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]
2025-11-11 21:39:12 +08:00
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'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-11 21:39:12 +08:00
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
2025-11-16 19:56:14 +08:00
# 验证文件链接格式(如果提供了链接)
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
2025-11-11 21:39:12 +08:00
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()
2025-11-15 23:57:05 +08:00
# 记录操作日志
log_operation('CREATE_VERSION', 'VERSION', version.version_id, {
'product_id': version.product_id,
'version_num': version.version_num,
2025-11-16 19:56:14 +08:00
'publish_now': publish_now,
'has_download_url': bool(download_url)
2025-11-15 23:57:05 +08:00
})
2025-11-11 21:39:12 +08:00
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'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-11 21:39:12 +08:00
def publish_version(version_id):
"""发布版本"""
try:
version = Version.query.get(version_id)
if not version:
return jsonify({'success': False, 'message': '版本不存在'}), 404
version.publish()
2025-11-15 23:57:05 +08:00
# 记录操作日志
log_operation('PUBLISH_VERSION', 'VERSION', version.version_id, {
'version_num': version.version_num
})
2025-11-11 21:39:12 +08:00
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'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-11 21:39:12 +08:00
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()
2025-11-15 23:57:05 +08:00
# 记录操作日志
log_operation('UPDATE_VERSION', 'VERSION', version.version_id, {
'version_num': version.version_num,
'platform': version.platform,
'description': version.description
})
2025-11-11 21:39:12 +08:00
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'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-11 21:39:12 +08:00
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
2025-11-15 23:57:05 +08:00
# 记录操作日志
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, '未知')
})
2025-11-11 21:39:12 +08:00
return jsonify({
'success': True,
'message': message,
'data': version.to_dict()
})
except Exception as e:
current_app.logger.error(f"更新版本状态失败: {str(e)}")
2025-11-16 19:56:14 +08:00
return jsonify({
'success': False,
'message': '服务器内部错误'
}), 500
2025-11-11 21:39:12 +08:00
@api_bp.route('/versions/upload', methods=['POST'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-11 21:39:12 +08:00
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'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-11 21:39:12 +08:00
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:
2025-11-13 16:51:51 +08:00
upload_folder = os.path.abspath(current_app.config['UPLOAD_FOLDER'])
file_path = os.path.join(upload_folder, filename)
2025-11-11 21:39:12 +08:00
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()
2025-11-15 23:57:05 +08:00
# 记录操作日志
log_operation('DELETE_VERSION', 'VERSION', version.version_id, {
'version_num': version.version_num
})
2025-11-11 21:39:12 +08:00
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
2025-11-12 15:11:05 +08:00
@api_bp.route('/versions/batch', methods=['DELETE'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-12 15:11:05 +08:00
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()
2025-11-15 23:57:05 +08:00
# 记录操作日志
version_ids = [version.version_id for version in versions]
log_operation('BATCH_DELETE_VERSIONS', 'VERSION', None, {
'version_ids': version_ids,
'count': len(versions)
})
2025-11-12 15:11:05 +08:00
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'])
2025-11-15 23:57:05 +08:00
@require_login
2025-11-12 15:11:05 +08:00
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()
2025-11-15 23:57:05 +08:00
# 记录操作日志
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)
})
2025-11-12 15:11:05 +08:00
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
2025-11-13 16:51:51 +08:00
2025-11-16 19:56:14 +08:00