2025-11-11 21:39:12 +08:00
|
|
|
|
from flask import request, jsonify, current_app
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from app import db
|
2025-11-15 23:57:05 +08:00
|
|
|
|
from app.models import Product, License, Device, Version
|
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 func, case
|
2025-11-11 21:39:12 +08:00
|
|
|
|
import traceback
|
|
|
|
|
|
import sys
|
2025-11-19 22:49:24 +08:00
|
|
|
|
import os
|
2025-11-15 23:57:05 +08:00
|
|
|
|
from app.utils.logger import log_operation
|
2025-11-22 22:59:31 +08:00
|
|
|
|
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
|
2025-11-11 21:39:12 +08:00
|
|
|
|
|
|
|
|
|
|
@api_bp.route('/products', methods=['GET'])
|
2025-11-15 23:57:05 +08:00
|
|
|
|
@require_login
|
2025-11-11 21:39:12 +08:00
|
|
|
|
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()
|
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 = Product.query
|
|
|
|
|
|
|
|
|
|
|
|
if keyword:
|
|
|
|
|
|
query = query.filter(
|
2025-11-17 12:55:58 +08:00
|
|
|
|
db.or_(
|
|
|
|
|
|
Product.product_name.like(f'%{keyword}%'),
|
|
|
|
|
|
Product.product_id.like(f'%{keyword}%'),
|
|
|
|
|
|
Product.description.like(f'%{keyword}%')
|
|
|
|
|
|
)
|
2025-11-11 21:39:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
query = query.order_by(Product.create_time.desc())
|
|
|
|
|
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
|
|
|
|
|
|
2025-11-15 23:57:05 +08:00
|
|
|
|
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,
|
2025-11-28 15:56:33 +08:00
|
|
|
|
Version.update_time,
|
2025-11-15 23:57:05 +08:00
|
|
|
|
Version.create_time
|
|
|
|
|
|
).filter(
|
|
|
|
|
|
Version.product_id.in_(product_ids),
|
|
|
|
|
|
Version.publish_status == 1
|
2025-11-28 15:56:33 +08:00
|
|
|
|
).order_by(Version.update_time.desc(), Version.create_time.desc()).all()
|
2025-11-15 23:57:05 +08:00
|
|
|
|
|
|
|
|
|
|
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]
|
2025-11-11 21:39:12 +08:00
|
|
|
|
|
|
|
|
|
|
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()}")
|
2025-11-13 16:51:51 +08:00
|
|
|
|
|
2025-11-11 21:39:12 +08:00
|
|
|
|
return jsonify({
|
|
|
|
|
|
'success': False,
|
2025-11-13 16:51:51 +08:00
|
|
|
|
'message': '服务器内部错误,请稍后重试'
|
2025-11-11 21:39:12 +08:00
|
|
|
|
}), 500
|
|
|
|
|
|
|
|
|
|
|
|
@api_bp.route('/products', methods=['POST'])
|
2025-11-15 23:57:05 +08:00
|
|
|
|
@require_login
|
2025-11-11 21:39:12 +08:00
|
|
|
|
def create_product():
|
|
|
|
|
|
"""创建产品"""
|
|
|
|
|
|
try:
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 检查请求类型
|
|
|
|
|
|
is_multipart = request.content_type and 'multipart/form-data' in request.content_type
|
|
|
|
|
|
|
2025-11-19 22:49:24 +08:00
|
|
|
|
# 处理文件上传
|
|
|
|
|
|
image_path = None
|
|
|
|
|
|
if 'image' in request.files:
|
|
|
|
|
|
image_file = request.files['image']
|
|
|
|
|
|
if image_file and image_file.filename:
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 验证文件类型(扩展名和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
|
|
|
|
|
|
|
2025-11-19 22:49:24 +08:00
|
|
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
|
|
|
|
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 生成安全的唯一文件名
|
2025-11-19 22:49:24 +08:00
|
|
|
|
import uuid
|
2025-11-22 22:59:31 +08:00
|
|
|
|
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}"
|
2025-11-19 22:49:24 +08:00
|
|
|
|
file_path = os.path.join(upload_folder, filename)
|
|
|
|
|
|
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 再次验证路径安全性
|
|
|
|
|
|
real_path = os.path.abspath(file_path)
|
|
|
|
|
|
if not real_path.startswith(os.path.abspath(upload_folder)):
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
'success': False,
|
|
|
|
|
|
'message': '文件路径不安全'
|
|
|
|
|
|
}), 400
|
|
|
|
|
|
|
2025-11-19 22:49:24 +08:00
|
|
|
|
# 保存文件
|
|
|
|
|
|
image_file.save(file_path)
|
|
|
|
|
|
|
|
|
|
|
|
# 保存相对路径到数据库
|
|
|
|
|
|
image_path = f"/static/producepic/{filename}"
|
|
|
|
|
|
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 处理数据:如果是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')
|
|
|
|
|
|
}
|
2025-11-19 22:49:24 +08:00
|
|
|
|
else:
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# application/json 请求:从JSON获取数据
|
2025-11-19 22:49:24 +08:00
|
|
|
|
data = request.get_json() or {}
|
|
|
|
|
|
|
|
|
|
|
|
if not data or not data.get('product_name', '').strip():
|
2025-11-11 21:39:12 +08:00
|
|
|
|
return jsonify({
|
|
|
|
|
|
'success': False,
|
|
|
|
|
|
'message': '请求数据为空'
|
|
|
|
|
|
}), 400
|
|
|
|
|
|
|
|
|
|
|
|
product_name = data.get('product_name', '').strip()
|
|
|
|
|
|
description = data.get('description', '').strip()
|
2025-12-12 11:35:14 +08:00
|
|
|
|
features = data.get('features', '').strip()
|
2025-11-11 21:39:12 +08:00
|
|
|
|
custom_id = data.get('product_id', '').strip()
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 处理 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
|
2025-11-11 21:39:12 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2025-12-12 11:35:14 +08:00
|
|
|
|
features=features,
|
2025-11-19 22:49:24 +08:00
|
|
|
|
image_path=image_path,
|
2025-11-22 22:59:31 +08:00
|
|
|
|
status=status
|
2025-11-11 21:39:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.session.add(product)
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
2025-11-15 23:57:05 +08:00
|
|
|
|
# 记录操作日志
|
|
|
|
|
|
log_operation('CREATE_PRODUCT', 'PRODUCT', product.product_id, {
|
|
|
|
|
|
'product_name': product.product_name,
|
|
|
|
|
|
'description': product.description
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-11 21:39:12 +08:00
|
|
|
|
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'])
|
2025-11-15 23:57:05 +08:00
|
|
|
|
@require_login
|
2025-11-11 21:39:12 +08:00
|
|
|
|
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'])
|
2025-11-15 23:57:05 +08:00
|
|
|
|
@require_login
|
2025-11-11 21:39:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 检查请求类型
|
|
|
|
|
|
is_multipart = request.content_type and 'multipart/form-data' in request.content_type
|
|
|
|
|
|
|
2025-11-19 22:49:24 +08:00
|
|
|
|
# 处理文件上传
|
|
|
|
|
|
if 'image' in request.files:
|
|
|
|
|
|
image_file = request.files['image']
|
|
|
|
|
|
if image_file and image_file.filename:
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 验证文件类型(扩展名和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
|
|
|
|
|
|
|
2025-11-19 22:49:24 +08:00
|
|
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
|
|
|
|
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 生成安全的唯一文件名
|
2025-11-19 22:49:24 +08:00
|
|
|
|
import uuid
|
2025-11-22 22:59:31 +08:00
|
|
|
|
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}"
|
2025-11-19 22:49:24 +08:00
|
|
|
|
file_path = os.path.join(upload_folder, filename)
|
|
|
|
|
|
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 再次验证路径安全性
|
|
|
|
|
|
real_path = os.path.abspath(file_path)
|
|
|
|
|
|
if not real_path.startswith(os.path.abspath(upload_folder)):
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
'success': False,
|
|
|
|
|
|
'message': '文件路径不安全'
|
|
|
|
|
|
}), 400
|
|
|
|
|
|
|
2025-11-19 22:49:24 +08:00
|
|
|
|
# 保存文件
|
|
|
|
|
|
image_file.save(file_path)
|
|
|
|
|
|
|
|
|
|
|
|
# 保存相对路径到数据库
|
|
|
|
|
|
product.image_path = f"/static/producepic/{filename}"
|
|
|
|
|
|
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 处理数据:如果是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', '')
|
|
|
|
|
|
}
|
2025-11-19 22:49:24 +08:00
|
|
|
|
else:
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# application/json 请求:从JSON获取数据
|
2025-11-19 22:49:24 +08:00
|
|
|
|
data = request.get_json() or {}
|
|
|
|
|
|
|
2025-11-11 21:39:12 +08:00
|
|
|
|
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()
|
2025-12-12 11:35:14 +08:00
|
|
|
|
if 'features' in data:
|
|
|
|
|
|
product.features = data['features'].strip()
|
2025-11-11 21:39:12 +08:00
|
|
|
|
if 'status' in data:
|
2025-11-22 22:59:31 +08:00
|
|
|
|
# 确保 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
|
2025-11-11 21:39:12 +08:00
|
|
|
|
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
2025-11-15 23:57:05 +08:00
|
|
|
|
# 记录操作日志
|
|
|
|
|
|
log_operation('UPDATE_PRODUCT', 'PRODUCT', product.product_id, {
|
|
|
|
|
|
'product_name': product.product_name,
|
|
|
|
|
|
'description': product.description
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-11 21:39:12 +08:00
|
|
|
|
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'])
|
2025-11-15 23:57:05 +08:00
|
|
|
|
@require_login
|
2025-11-11 21:39:12 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2025-11-15 23:57:05 +08:00
|
|
|
|
# 记录操作日志
|
|
|
|
|
|
log_operation('DELETE_PRODUCT', 'PRODUCT', product.product_id, {
|
|
|
|
|
|
'product_name': product.product_name
|
|
|
|
|
|
})
|
|
|
|
|
|
|
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)}")
|
2025-11-12 15:11:05 +08:00
|
|
|
|
return jsonify({
|
|
|
|
|
|
'success': False,
|
|
|
|
|
|
'message': '服务器内部错误'
|
|
|
|
|
|
}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_bp.route('/products/batch', methods=['DELETE'])
|
2025-11-15 23:57:05 +08:00
|
|
|
|
@require_login
|
2025-11-12 15:11:05 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2025-11-15 23:57:05 +08:00
|
|
|
|
# 记录操作日志
|
|
|
|
|
|
product_ids = [p.product_id for p in products]
|
|
|
|
|
|
log_operation('BATCH_DELETE_PRODUCTS', 'PRODUCT', None, {
|
|
|
|
|
|
'product_ids': product_ids,
|
|
|
|
|
|
'count': len(products)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-12 15:11:05 +08:00
|
|
|
|
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'])
|
2025-11-15 23:57:05 +08:00
|
|
|
|
@require_login
|
2025-11-12 15:11:05 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2025-11-15 23:57:05 +08:00
|
|
|
|
# 记录操作日志
|
|
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-12 15:11:05 +08:00
|
|
|
|
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)}")
|
2025-11-11 21:39:12 +08:00
|
|
|
|
return jsonify({
|
|
|
|
|
|
'success': False,
|
|
|
|
|
|
'message': '服务器内部错误'
|
|
|
|
|
|
}), 500
|