Kamixitong/app/api/product.py
2025-12-12 11:35:14 +08:00

660 lines
24 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
from app import db
from app.models import Product, License, Device, Version
from . import api_bp
from .decorators import require_login, require_admin
from sqlalchemy import func, case
import traceback
import sys
import os
from app.utils.logger import log_operation
from werkzeug.utils import secure_filename
# 允许的图片文件扩展名
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}
# 允许的MIME类型
ALLOWED_IMAGE_MIMES = {
'image/png', 'image/jpeg', 'image/jpg', 'image/gif',
'image/webp', 'image/bmp', 'image/x-ms-bmp'
}
# 最大图片文件大小5MB
MAX_IMAGE_SIZE = 5 * 1024 * 1024
def allowed_image_file(filename):
"""检查文件扩展名是否允许"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
def validate_image_file(file, filename):
"""验证图片文件扩展名和MIME类型"""
# 检查扩展名
if not allowed_image_file(filename):
return False, '不支持的文件类型只允许上传图片文件PNG、JPG、JPEG、GIF、WEBP、BMP'
# 检查MIME类型
if hasattr(file, 'content_type') and file.content_type:
if file.content_type not in ALLOWED_IMAGE_MIMES:
return False, f'不支持的文件MIME类型: {file.content_type}'
return True, None
@api_bp.route('/products', methods=['GET'])
@require_login
def get_products():
"""获取产品列表"""
try:
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
keyword = request.args.get('keyword', '').strip()
include_stats = request.args.get('include_stats', 'true').lower() == 'true'
query = Product.query
if keyword:
query = query.filter(
db.or_(
Product.product_name.like(f'%{keyword}%'),
Product.product_id.like(f'%{keyword}%'),
Product.description.like(f'%{keyword}%')
)
)
query = query.order_by(Product.create_time.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
products = pagination.items
# 优化批量查询统计信息避免N+1查询问题
if include_stats and products:
product_ids = [p.product_id for p in products]
# 批量查询license统计
license_stats = db.session.query(
License.product_id,
func.count(License.license_id).label('total_licenses'),
func.sum(case((License.status == 1, 1), else_=0)).label('active_licenses')
).filter(License.product_id.in_(product_ids)).group_by(License.product_id).all()
license_dict = {pid: {'total': 0, 'active': 0} for pid in product_ids}
for stat in license_stats:
license_dict[stat.product_id] = {
'total': stat.total_licenses or 0,
'active': stat.active_licenses or 0
}
# 批量查询device统计
device_stats = db.session.query(
Device.product_id,
func.count(Device.device_id).label('total_devices')
).filter(
Device.product_id.in_(product_ids),
Device.status == 1
).group_by(Device.product_id).all()
device_dict = {pid: 0 for pid in product_ids}
for stat in device_stats:
device_dict[stat.product_id] = stat.total_devices or 0
# 批量查询最新版本
version_stats = db.session.query(
Version.product_id,
Version.version_num,
Version.update_time,
Version.create_time
).filter(
Version.product_id.in_(product_ids),
Version.publish_status == 1
).order_by(Version.update_time.desc(), Version.create_time.desc()).all()
version_dict = {}
for v in version_stats:
if v.product_id not in version_dict:
version_dict[v.product_id] = v.version_num
# 为每个产品添加统计信息
for product in products:
pid = product.product_id
stats = license_dict.get(pid, {'total': 0, 'active': 0})
product._cached_stats = {
'total_licenses': stats['total'],
'active_licenses': stats['active'],
'total_devices': device_dict.get(pid, 0),
'latest_version': version_dict.get(pid)
}
products = [product.to_dict(include_stats=include_stats) for product in products]
return jsonify({
'success': True,
'data': {
'products': products,
'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)}")
current_app.logger.error(f"错误类型: {type(e)}")
current_app.logger.error(f"错误堆栈: {traceback.format_exc()}")
return jsonify({
'success': False,
'message': '服务器内部错误,请稍后重试'
}), 500
@api_bp.route('/products', methods=['POST'])
@require_login
def create_product():
"""创建产品"""
try:
# 检查请求类型
is_multipart = request.content_type and 'multipart/form-data' in request.content_type
# 处理文件上传
image_path = None
if 'image' in request.files:
image_file = request.files['image']
if image_file and image_file.filename:
# 验证文件类型扩展名和MIME类型
is_valid, error_msg = validate_image_file(image_file, image_file.filename)
if not is_valid:
return jsonify({
'success': False,
'message': error_msg
}), 400
# 验证文件大小优先使用content_length否则读取文件
file_size = None
if request.content_length:
file_size = request.content_length
else:
# 备用方案:读取文件大小
image_file.seek(0, os.SEEK_END)
file_size = image_file.tell()
image_file.seek(0) # 重置文件指针
if file_size and file_size > MAX_IMAGE_SIZE:
return jsonify({
'success': False,
'message': f'文件大小超过限制,最大允许 {MAX_IMAGE_SIZE / (1024*1024):.1f}MB'
}), 400
# 确保上传目录存在(使用绝对路径)
static_folder = os.path.abspath(current_app.static_folder)
upload_folder = os.path.join(static_folder, 'producepic')
# 验证路径安全性确保在static目录下
if not upload_folder.startswith(static_folder):
return jsonify({
'success': False,
'message': '无效的上传路径'
}), 400
os.makedirs(upload_folder, exist_ok=True)
# 生成安全的唯一文件名
import uuid
original_filename = secure_filename(image_file.filename)
file_ext = original_filename.rsplit('.', 1)[1].lower() if '.' in original_filename else 'jpg'
filename = f"{uuid.uuid4().hex}.{file_ext}"
file_path = os.path.join(upload_folder, filename)
# 再次验证路径安全性
real_path = os.path.abspath(file_path)
if not real_path.startswith(os.path.abspath(upload_folder)):
return jsonify({
'success': False,
'message': '文件路径不安全'
}), 400
# 保存文件
image_file.save(file_path)
# 保存相对路径到数据库
image_path = f"/static/producepic/{filename}"
# 处理数据如果是multipart请求只从form获取否则尝试JSON
data = {}
if is_multipart or request.files:
# multipart/form-data 请求从form中获取数据
data_str = request.form.get('data')
if data_str:
import json
try:
data = json.loads(data_str)
except json.JSONDecodeError:
current_app.logger.warning(f"无法解析form中的data字段: {data_str[:100]}")
data = {}
# 如果仍然没有数据尝试从form中直接获取各个字段
if not data:
data = {
'product_name': request.form.get('product_name', ''),
'description': request.form.get('description', ''),
'product_id': request.form.get('product_id', ''),
'status': request.form.get('status', '1')
}
else:
# application/json 请求从JSON获取数据
data = request.get_json() or {}
if not data or not data.get('product_name', '').strip():
return jsonify({
'success': False,
'message': '请求数据为空'
}), 400
product_name = data.get('product_name', '').strip()
description = data.get('description', '').strip()
features = data.get('features', '').strip()
custom_id = data.get('product_id', '').strip()
# 处理 status 字段:确保是整数类型
status = data.get('status', 1)
try:
status = int(status) if status else 1
except (ValueError, TypeError):
status = 1
# 确保 status 只能是 0 或 1
status = 1 if status not in [0, 1] else status
if not product_name:
return jsonify({
'success': False,
'message': '产品名称不能为空'
}), 400
# 检查自定义ID是否重复
if custom_id:
existing = Product.query.filter_by(product_id=custom_id).first()
if existing:
return jsonify({
'success': False,
'message': '产品ID已存在'
}), 400
# 创建产品
product = Product(
product_id=custom_id if custom_id else None,
product_name=product_name,
description=description,
features=features,
image_path=image_path,
status=status
)
db.session.add(product)
db.session.commit()
# 记录操作日志
log_operation('CREATE_PRODUCT', 'PRODUCT', product.product_id, {
'product_name': product.product_name,
'description': product.description
})
return jsonify({
'success': True,
'message': '产品创建成功',
'data': product.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('/products/<product_id>', methods=['GET'])
@require_login
def get_product(product_id):
"""获取产品详情"""
try:
product = Product.query.filter_by(product_id=product_id).first()
if not product:
return jsonify({
'success': False,
'message': '产品不存在'
}), 404
return jsonify({
'success': True,
'data': product.to_dict(include_stats=True)
})
except Exception as e:
current_app.logger.error(f"获取产品详情失败: {str(e)}")
return jsonify({
'success': False,
'message': '服务器内部错误'
}), 500
@api_bp.route('/products/<product_id>', methods=['PUT'])
@require_login
def update_product(product_id):
"""更新产品"""
try:
product = Product.query.filter_by(product_id=product_id).first()
if not product:
return jsonify({
'success': False,
'message': '产品不存在'
}), 404
# 检查请求类型
is_multipart = request.content_type and 'multipart/form-data' in request.content_type
# 处理文件上传
if 'image' in request.files:
image_file = request.files['image']
if image_file and image_file.filename:
# 验证文件类型扩展名和MIME类型
is_valid, error_msg = validate_image_file(image_file, image_file.filename)
if not is_valid:
return jsonify({
'success': False,
'message': error_msg
}), 400
# 验证文件大小优先使用content_length
file_size = None
if request.content_length:
file_size = request.content_length
else:
image_file.seek(0, os.SEEK_END)
file_size = image_file.tell()
image_file.seek(0)
if file_size and file_size > MAX_IMAGE_SIZE:
return jsonify({
'success': False,
'message': f'文件大小超过限制,最大允许 {MAX_IMAGE_SIZE / (1024*1024):.1f}MB'
}), 400
# 确保上传目录存在(使用绝对路径)
static_folder = os.path.abspath(current_app.static_folder)
upload_folder = os.path.join(static_folder, 'producepic')
if not upload_folder.startswith(static_folder):
return jsonify({
'success': False,
'message': '无效的上传路径'
}), 400
os.makedirs(upload_folder, exist_ok=True)
# 生成安全的唯一文件名
import uuid
original_filename = secure_filename(image_file.filename)
file_ext = original_filename.rsplit('.', 1)[1].lower() if '.' in original_filename else 'jpg'
filename = f"{uuid.uuid4().hex}.{file_ext}"
file_path = os.path.join(upload_folder, filename)
# 再次验证路径安全性
real_path = os.path.abspath(file_path)
if not real_path.startswith(os.path.abspath(upload_folder)):
return jsonify({
'success': False,
'message': '文件路径不安全'
}), 400
# 保存文件
image_file.save(file_path)
# 保存相对路径到数据库
product.image_path = f"/static/producepic/{filename}"
# 处理数据如果是multipart请求只从form获取否则尝试JSON
data = {}
if is_multipart or request.files:
# multipart/form-data 请求从form中获取数据
data_str = request.form.get('data')
if data_str:
import json
try:
data = json.loads(data_str)
except json.JSONDecodeError:
current_app.logger.warning(f"无法解析form中的data字段: {data_str[:100]}")
data = {}
# 如果仍然没有数据尝试从form中直接获取各个字段
if not data:
data = {
'product_name': request.form.get('product_name', ''),
'description': request.form.get('description', ''),
'status': request.form.get('status', '')
}
else:
# application/json 请求从JSON获取数据
data = request.get_json() or {}
if not data:
return jsonify({
'success': False,
'message': '请求数据为空'
}), 400
if 'product_name' in data:
product.product_name = data['product_name'].strip()
if 'description' in data:
product.description = data['description'].strip()
if 'features' in data:
product.features = data['features'].strip()
if 'status' in data:
# 确保 status 是整数类型
try:
status = int(data['status']) if data['status'] else product.status
# 确保 status 只能是 0 或 1
product.status = 1 if status not in [0, 1] else status
except (ValueError, TypeError):
# 如果转换失败,保持原值
pass
db.session.commit()
# 记录操作日志
log_operation('UPDATE_PRODUCT', 'PRODUCT', product.product_id, {
'product_name': product.product_name,
'description': product.description
})
return jsonify({
'success': True,
'message': '产品更新成功',
'data': product.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('/products/<product_id>', methods=['DELETE'])
@require_login
def delete_product(product_id):
"""删除产品"""
try:
product = Product.query.filter_by(product_id=product_id).first()
if not product:
return jsonify({
'success': False,
'message': '产品不存在'
}), 404
# 检查是否有关联的卡密
license_count = product.licenses.count()
if license_count > 0:
return jsonify({
'success': False,
'message': f'产品下还有 {license_count} 个卡密,无法删除'
}), 400
db.session.delete(product)
db.session.commit()
# 记录操作日志
log_operation('DELETE_PRODUCT', 'PRODUCT', product.product_id, {
'product_name': product.product_name
})
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('/products/batch', methods=['DELETE'])
@require_login
def batch_delete_products():
"""批量删除产品"""
try:
data = request.get_json()
if not data or 'product_ids' not in data:
return jsonify({
'success': False,
'message': '请求数据为空或缺少product_ids字段'
}), 400
product_ids = data['product_ids']
if not isinstance(product_ids, list) or len(product_ids) == 0:
return jsonify({
'success': False,
'message': 'product_ids必须是非空列表'
}), 400
# 查找所有要删除的产品
products = Product.query.filter(Product.product_id.in_(product_ids)).all()
if len(products) != len(product_ids):
found_ids = [p.product_id for p in products]
missing_ids = [pid for pid in product_ids if pid not in found_ids]
return jsonify({
'success': False,
'message': f'以下产品不存在: {", ".join(missing_ids)}'
}), 404
# 检查是否有产品有关联的卡密
undeletable_products = []
for product in products:
license_count = product.licenses.count()
if license_count > 0:
undeletable_products.append({
'product_id': product.product_id,
'product_name': product.product_name,
'license_count': license_count
})
if undeletable_products:
return jsonify({
'success': False,
'message': '部分产品无法删除,因为有关联的卡密',
'undeletable_products': undeletable_products
}), 400
# 批量删除产品
for product in products:
db.session.delete(product)
db.session.commit()
# 记录操作日志
product_ids = [p.product_id for p in products]
log_operation('BATCH_DELETE_PRODUCTS', 'PRODUCT', None, {
'product_ids': product_ids,
'count': len(products)
})
return jsonify({
'success': True,
'message': f'成功删除 {len(products)} 个产品'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"批量删除产品失败: {str(e)}")
return jsonify({
'success': False,
'message': '服务器内部错误'
}), 500
@api_bp.route('/products/batch/status', methods=['PUT'])
@require_login
def batch_update_product_status():
"""批量更新产品状态"""
try:
data = request.get_json()
if not data or 'product_ids' not in data or 'status' not in data:
return jsonify({
'success': False,
'message': '请求数据为空或缺少product_ids/status字段'
}), 400
product_ids = data['product_ids']
status = data['status']
if not isinstance(product_ids, list) or len(product_ids) == 0:
return jsonify({
'success': False,
'message': 'product_ids必须是非空列表'
}), 400
if status not in [0, 1]:
return jsonify({
'success': False,
'message': 'status必须是0(禁用)或1(启用)'
}), 400
# 查找所有要更新的产品
products = Product.query.filter(Product.product_id.in_(product_ids)).all()
if len(products) != len(product_ids):
found_ids = [p.product_id for p in products]
missing_ids = [pid for pid in product_ids if pid not in found_ids]
return jsonify({
'success': False,
'message': f'以下产品不存在: {", ".join(missing_ids)}'
}), 404
# 批量更新产品状态
for product in products:
product.status = status
db.session.commit()
# 记录操作日志
product_ids = [p.product_id for p in products]
log_operation('BATCH_UPDATE_PRODUCT_STATUS', 'PRODUCT', None, {
'product_ids': product_ids,
'status': status,
'count': len(products)
})
status_name = '启用' if status == 1 else '禁用'
return jsonify({
'success': True,
'message': f'成功{status_name} {len(products)} 个产品'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"批量更新产品状态失败: {str(e)}")
return jsonify({
'success': False,
'message': '服务器内部错误'
}), 500