Files
Django/backend/views/products.py

1254 lines
50 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.
import hmac
import threading
import traceback
import uuid
import hashlib
import xmltodict
import time
import logging
import requests
import os
import secrets
import random
import string
from calendar import monthrange
from decimal import Decimal
from datetime import date, datetime, timedelta
from django.utils import timezone
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from django.db import models, transaction, IntegrityError
from django.db.models import Q, Count, Sum, F, Exists, OuterRef
from gvsdsdk.fluent import db, func, FQ
from django.db.models.functions import TruncDate, TruncMonth, TruncYear
from django.contrib.auth.hashers import make_password
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
# 工具类导入
from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss
from backend.utils import (
update_dashou_daily_by_action,
update_shangjia_daily
)
from jituan.services.club_context import filter_club_char_field, filter_queryset_by_club, filter_user_related_by_club, filter_user_qs_by_club, orders_for_request
from jituan.services.catalog_scope import block_non_group_catalog_scope
from jituan.services.club_penalty import (
filter_penalty_qs, resolve_penalty_club_id, filter_gsfenhong_qs,
forbid_penalty_out_of_scope, resolve_penalty_record_club_id,
)
from jituan.services.club_user_access import forbid_if_user_out_of_scope, list_response_meta
from shop.utils import update_dianpu_daily_stat
from products.utils import update_shangpin_daily_stat
from orders.notice_tasks import dingdan_guangbo
from ..utils import (
verify_kefu_permission,
PERMISSION_TO_SHENFEN,
SHENFEN_PROFILE_MAP,
has_fadan_view_permission,
has_merchant_order_permission,
check_fadan_permission,
pick_order_id,
pick_penalty_create_params,
write_xiugai_log,
XIUGAI_LEIXING_DASHOU,
XIUGAI_LEIXING_GUANSHI,
XIUGAI_LEIXING_SHANGJIA,
XIUGAI_LEIXING_ZUZHANG,
XIUGAI_LEIXING_LABEL,
)
# models 集中导入
## gvsdsdk RBAC 模型(使用 PascalCase 字段名,匹配实际数据库表结构)
from gvsdsdk.models import Role, Permission, RolePermission, UserRole
## backend
from backend.models import WithdrawalDailyStats
## users
from users.models import (
UserGuanshi, UserBoss, UserZuzhang,
UserKefu, UserDashou, Xiugaijilu, UserShangjia, UserShenheguan
)
from users.business_models import User
## orders
from orders.models import (
CommissionRate, PlayerDeliveryImage, PenaltyRecord,
OrderPlayerHistory, Order, RefundRecord,
MerchantOrderExt, Penalty, PenaltyAppealImage, PenaltyBonus
)
## shop
from shop.models import Dianpu, DianpuMorenPeizhi, YonghuPingzheng, ShangpinLeixingDianpu, DianpuShangpinShenheShezhi
## products
from products.models import (
Shangpin, ShangpinLeixing, ShangpinZhuanqu, Huiyuan,
DuociFenhong, Czjilu, Huiyuangoumai, Gsfenhong
)
## config
from config.models import (
WithdrawConfig, ShangjiaLianjie, AccountPermission,
TixianQuotaDefault,
DailyIncomeStat, DailyPayoutStat, PopupPage, PopupConfig, PopupImage
)
## rank
from rank.models import (
KaoheguanBankuai, Bankuai, Chenghao, KaoheCishuFeiyong,
YonghuChenghao, ShenheJilu, KaoheJiluFeiyong, KaoheJujueJilu
)
# 序列化器
from ..serializers import PopupPageSerializer
# 全局常量、日志对象
logger = logging.getLogger('houtai')
SHOP_PRODUCT_PERMS = ['1199ab', '1199abc', '1199abd']
class GetProductBaseDataView(APIView):
"""
获取商品基础数据(类型、专区、会员)
权限2200a
POST /houtai/htsphqjcsx
参数username
返回:
code:0, data: {
type_list: [ {id, jieshao, tupian_url, paixu, shenhezhuangtai, product_count}, ...],
zone_list: [ {id, mingzi, leixing_id, paixu, shenhezhuangtai, product_count}, ...],
member_list: [ {huiyuan_id, jieshao, jiage}, ...]
}
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
# 权限校验
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
# 获取所有商品类型(按 paixu 升序);商品数按当前俱乐部统计
from jituan.services.club_context import filter_club_char_field, resolve_club_scope
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_write import resolve_club_id_for_write
try:
type_qs = ShangpinLeixing.query.all().order_by('paixu')
# 批量聚合商品数量,避免循环内 N+1 count按俱乐部
_public_sp = filter_club_char_field(
Shangpin.query.filter(
dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
),
request,
field='club_id',
)
# 用 Django QS 聚合,避免 Fluent values 链在部分环境下异常
_type_counts = {
item['leixing_id']: item['cnt']
for item in _public_sp.values('leixing_id').annotate(cnt=Count('id'))
}
type_list = []
for t in type_qs:
product_count = _type_counts.get(t.id, 0)
type_list.append({
'id': t.id,
'jieshao': t.jieshao or '',
'tupian_url': t.tupian_url or '',
'paixu': t.paixu,
'shenhezhuangtai': t.shenhezhuangtai,
'product_count': product_count,
})
# 获取专区(按俱乐部隔离;集团汇总看全部)
zone_qs = filter_club_char_field(
ShangpinZhuanqu.query.all().order_by('paixu'), request, field='club_id'
)
_zone_counts = {
item['zhuanqu_id']: item['cnt']
for item in filter_club_char_field(
Shangpin.query.filter(
dianpu__isnull=True, dianpu_leixing__isnull=True, zhuanqu_id__isnull=False
),
request,
field='club_id',
).values('zhuanqu_id').annotate(cnt=Count('id'))
}
zone_list = []
for z in zone_qs:
product_count = _zone_counts.get(z.id, 0)
zone_list.append({
'id': z.id,
'mingzi': z.mingzi or '',
'leixing_id': z.leixing_id,
'paixu': z.paixu,
'shenhezhuangtai': z.shenhezhuangtai,
'product_count': product_count,
'club_id': getattr(z, 'club_id', '') or '',
'tupian_url': getattr(z, 'tupian_url', '') or '',
})
# 获取所有会员(只需 ID、介绍、价格
member_list = []
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'):
member_list.append({
'huiyuan_id': m['huiyuan_id'],
'jieshao': m['jieshao'] or '',
'jiage': float(m['jiage'] or 0),
})
# 本俱乐部「只看审核」开关(集团汇总不下发可写状态)
club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
zhi_kan_shenhe = True
club_id = ''
if club_scoped:
club_id = resolve_club_id_for_write(request) or ''
try:
from shop.services.shenhe_config import (
get_or_create_shenhe_config,
is_zhi_kan_shenhe_enabled,
)
get_or_create_shenhe_config(club_id)
zhi_kan_shenhe = is_zhi_kan_shenhe_enabled(club_id)
except Exception:
logging.getLogger(__name__).exception(
'读取只看审核配置失败 club_id=%s(请确认已 migrate shop 0004',
club_id,
)
zhi_kan_shenhe = True
return Response({
'code': 0,
'data': {
'type_list': type_list,
'zone_list': zone_list,
'member_list': member_list,
'zhi_kan_shenhe': zhi_kan_shenhe,
'club_scoped': club_scoped,
'club_id': club_id,
}
})
except Exception:
logging.getLogger(__name__).exception('GetProductBaseDataView 失败')
return Response({'code': 500, 'msg': '获取商品基础数据失败,请稍后重试'})
class GetProductListView(APIView):
"""
获取商品列表(支持筛选、排序)
权限2200a
POST /houtai/hthqsplb
参数:
username: 必填
leixing_id: 类型ID可选
zhuanqu_id: 专区ID可选
keyword: 搜索关键词(商品标题/介绍)
sales_op: 销量比较符 gte/lte
sales_value: 销量数值
stock_op: 库存比较符 gte/lte
stock_value: 库存数值
sort_field: 排序字段 zhenshi_xiaoliang / jiage
sort_order: asc/desc
page: 页码默认1
page_size: 每页数量默认1000前端说不需要分页但为性能仍保留
返回:
code:0, data: { list: [...], total }
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
# 权限校验
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
# 获取参数
leixing_id = request.data.get('leixing_id')
zhuanqu_id = request.data.get('zhuanqu_id')
keyword = request.data.get('keyword', '').strip()
sales_op = request.data.get('sales_op', '').strip().lower()
sales_value = request.data.get('sales_value')
stock_op = request.data.get('stock_op', '').strip().lower()
stock_value = request.data.get('stock_value')
sort_field = request.data.get('sort_field', '') # zhenshi_xiaoliang / jiage
sort_order = request.data.get('sort_order', 'desc').lower()
try:
page = int(request.data.get('page', 1))
page_size = int(request.data.get('page_size', 1000))
if page < 1 or page_size < 1:
raise ValueError
except:
return Response({'code': 400, 'msg': '分页参数错误'})
# 🔥 公共商品 + 按俱乐部隔离(集团汇总看全部)
from jituan.services.club_context import filter_club_char_field
qs = Shangpin.query.filter(
dianpu__isnull=True, # 所属店铺为空
dianpu_leixing__isnull=True # 店铺商品类型为空
)
qs = filter_club_char_field(qs, request, field='club_id')
if leixing_id:
try:
leixing_id = int(leixing_id)
qs = qs.filter(leixing_id=leixing_id)
except:
pass
if zhuanqu_id:
try:
zhuanqu_id = int(zhuanqu_id)
qs = qs.filter(zhuanqu_id=zhuanqu_id)
except:
pass
if keyword:
qs = qs.filter(Q(biaoqian__icontains=keyword) | Q(jieshao__icontains=keyword))
# 销量筛选
if sales_op and sales_value is not None:
try:
sales_val = int(sales_value)
if sales_op == 'gte':
qs = qs.filter(zhenshi_xiaoliang__gte=sales_val)
elif sales_op == 'lte':
qs = qs.filter(zhenshi_xiaoliang__lte=sales_val)
except:
pass
# 库存筛选
if stock_op and stock_value is not None:
try:
stock_val = int(stock_value)
if stock_op == 'gte':
qs = qs.filter(kucun__gte=stock_val)
elif stock_op == 'lte':
qs = qs.filter(kucun__lte=stock_val)
except:
pass
# 排序
if sort_field in ('zhenshi_xiaoliang', 'jiage'):
if sort_order == 'asc':
qs = qs.order_by(sort_field)
else:
qs = qs.order_by(f'-{sort_field}')
else:
qs = qs.order_by('-CreateTime') # 默认按创建时间倒序
# 分页
total = qs.count()
offset = (page - 1) * page_size
products = qs[offset:offset + page_size]
# 构建返回数据
product_list = []
for p in products:
product_list.append({
'id': p.id,
'biaoqian': p.biaoqian or '',
'jiage': str(p.jiage),
'kucun': p.kucun,
'zhenshi_xiaoliang': p.zhenshi_xiaoliang,
'duiwai_xiaoliang': p.duiwai_xiaoliang,
'tupian_url': p.tupian_url or '',
'jieshao': p.jieshao or '',
'club_id': getattr(p, 'club_id', '') or '',
})
return Response({
'code': 0,
'data': {
'list': product_list,
'total': total,
}
})
'''class GetProductBaseDataView(APIView):
"""
获取商品基础数据(类型、专区、会员)
权限2200a
POST /houtai/htsphqjcsx
参数username
返回:
code:0, data: {
type_list: [ {id, jieshao, tupian_url, paixu, shenhezhuangtai, product_count}, ...],
zone_list: [ {id, mingzi, leixing_id, paixu, shenhezhuangtai, product_count}, ...],
member_list: [ {huiyuan_id, jieshao, jiage}, ...]
}
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
# 权限校验
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
# 获取所有商品类型(按 paixu 升序)
type_qs = ShangpinLeixing.query.all().order_by('paixu')
# 批量聚合商品数量,避免循环内 N+1 count
_type_counts = {
item['leixing_id']: item['cnt']
for item in Shangpin.query.filter(leixing_id__isnull=False).values('leixing_id').annotate(cnt=Count('id'))
}
type_list = []
for t in type_qs:
product_count = _type_counts.get(t.id, 0)
type_list.append({
'id': t.id,
'jieshao': t.jieshao or '',
'tupian_url': t.tupian_url or '',
'paixu': t.paixu,
'shenhezhuangtai': t.shenhezhuangtai,
'product_count': product_count,
})
# 获取所有专区(按 paixu 升序)
zone_qs = ShangpinZhuanqu.query.all().order_by('paixu')
_zone_counts = {
item['zhuanqu_id']: item['cnt']
for item in Shangpin.query.filter(zhuanqu_id__isnull=False).values('zhuanqu_id').annotate(cnt=Count('id'))
}
zone_list = []
for z in zone_qs:
product_count = _zone_counts.get(z.id, 0)
zone_list.append({
'id': z.id,
'mingzi': z.mingzi or '',
'leixing_id': z.leixing_id,
'paixu': z.paixu,
'shenhezhuangtai': z.shenhezhuangtai,
'product_count': product_count,
'tupian_url': getattr(z, 'tupian_url', '') or '',
})
# 获取所有会员(只需 ID、介绍、价格
member_list = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'))
return Response({
'code': 0,
'data': {
'type_list': type_list,
'zone_list': zone_list,
'member_list': member_list,
}
})
class GetProductListView(APIView):
"""
获取商品列表(支持筛选、排序)
权限2200a
POST /houtai/hthqsplb
参数:
username: 必填
leixing_id: 类型ID可选
zhuanqu_id: 专区ID可选
keyword: 搜索关键词(商品标题/介绍)
sales_op: 销量比较符 gte/lte
sales_value: 销量数值
stock_op: 库存比较符 gte/lte
stock_value: 库存数值
sort_field: 排序字段 zhenshi_xiaoliang / jiage
sort_order: asc/desc
page: 页码默认1
page_size: 每页数量默认1000前端说不需要分页但为性能仍保留
返回:
code:0, data: { list: [...], total }
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
# 权限校验
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
# 获取参数
leixing_id = request.data.get('leixing_id')
zhuanqu_id = request.data.get('zhuanqu_id')
keyword = request.data.get('keyword', '').strip()
sales_op = request.data.get('sales_op', '').strip().lower()
sales_value = request.data.get('sales_value')
stock_op = request.data.get('stock_op', '').strip().lower()
stock_value = request.data.get('stock_value')
sort_field = request.data.get('sort_field', '') # zhenshi_xiaoliang / jiage
sort_order = request.data.get('sort_order', 'desc').lower()
try:
page = int(request.data.get('page', 1))
page_size = int(request.data.get('page_size', 1000))
if page < 1 or page_size < 1:
raise ValueError
except:
return Response({'code': 400, 'msg': '分页参数错误'})
# 构建查询集
qs = Shangpin.query.all()
if leixing_id:
try:
leixing_id = int(leixing_id)
qs = qs.filter(leixing_id=leixing_id)
except:
pass
if zhuanqu_id:
try:
zhuanqu_id = int(zhuanqu_id)
qs = qs.filter(zhuanqu_id=zhuanqu_id)
except:
pass
if keyword:
qs = qs.filter(Q(biaoqian__icontains=keyword) | Q(jieshao__icontains=keyword))
# 销量筛选
if sales_op and sales_value is not None:
try:
sales_val = int(sales_value)
if sales_op == 'gte':
qs = qs.filter(zhenshi_xiaoliang__gte=sales_val)
elif sales_op == 'lte':
qs = qs.filter(zhenshi_xiaoliang__lte=sales_val)
except:
pass
# 库存筛选
if stock_op and stock_value is not None:
try:
stock_val = int(stock_value)
if stock_op == 'gte':
qs = qs.filter(kucun__gte=stock_val)
elif stock_op == 'lte':
qs = qs.filter(kucun__lte=stock_val)
except:
pass
# 排序
if sort_field in ('zhenshi_xiaoliang', 'jiage'):
if sort_order == 'asc':
qs = qs.order_by(sort_field)
else:
qs = qs.order_by(f'-{sort_field}')
else:
qs = qs.order_by('-CreateTime') # 默认按创建时间倒序
# 分页
total = qs.count()
offset = (page - 1) * page_size
products = qs[offset:offset + page_size]
# 构建返回数据
product_list = []
for p in products:
product_list.append({
'id': p.id,
'biaoqian': p.biaoqian or '',
'jiage': str(p.jiage),
'kucun': p.kucun,
'zhenshi_xiaoliang': p.zhenshi_xiaoliang,
'tupian_url': p.tupian_url or '',
'jieshao': p.jieshao or '',
})
return Response({
'code': 0,
'data': {
'list': product_list,
'total': total,
}
})'''
class SaveProductOrderView(APIView):
"""
保存商品类型或专区的排序
权限2200a
POST /houtai/htsppaixu
参数:
username: 必填
type: 'leixing''zhuanqu'
leixing_id: 当 type='zhuanqu' 时必填(用于限定该类型下的专区)
items: [ {id, paixu}, ... ] # 每个项目的新排序值从1开始
返回:
code:0 成功
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
sort_type = request.data.get('type', '').strip().lower()
if sort_type not in ('leixing', 'zhuanqu'):
return Response({'code': 400, 'msg': 'type参数必须为leixing或zhuanqu'})
items = request.data.get('items')
if not isinstance(items, list) or len(items) == 0:
return Response({'code': 400, 'msg': 'items参数无效'})
# 校验每个 item 的格式
for item in items:
if not isinstance(item, dict) or 'id' not in item or 'paixu' not in item:
return Response({'code': 400, 'msg': 'items内每项需包含id和paixu'})
try:
item['id'] = int(item['id'])
item['paixu'] = int(item['paixu'])
if item['paixu'] < 1:
return Response({'code': 400, 'msg': 'paixu必须为正整数'})
except:
return Response({'code': 400, 'msg': 'id或paixu格式错误'})
with transaction.atomic():
if sort_type == 'leixing':
# 更新商品类型的排序
for item in items:
ShangpinLeixing.query.filter(id=item['id']).update(paixu=item['paixu'])
else: # zhuanqu
leixing_id = request.data.get('leixing_id')
if not leixing_id:
return Response({'code': 400, 'msg': '更新专区排序时必须提供leixing_id'})
try:
leixing_id = int(leixing_id)
except:
return Response({'code': 400, 'msg': 'leixing_id格式错误'})
# 可选:检查该专区是否属于该类型,但不强制,由前端保证
for item in items:
ShangpinZhuanqu.query.filter(id=item['id']).update(paixu=item['paixu'])
return Response({'code': 0, 'msg': '排序保存成功'})
class AddProductView(APIView):
"""
添加商品(含图片上传)
权限2200a
POST /houtai/htfbsp
参数multipart/form-data包含所有商品字段及图片文件
字段说明见代码
"""
permission_classes = []
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
# 获取表单字段
biaoqian = request.data.get('biaoqian', '').strip()
jiage_str = request.data.get('jiage')
kucun_str = request.data.get('kucun')
duiwai_xiaoliang_str = request.data.get('duiwai_xiaoliang', '0')
leixing_id_str = request.data.get('leixing_id')
zhuanqu_id_str = request.data.get('zhuanqu_id')
yaoqiuleixing_str = request.data.get('yaoqiuleixing', '1')
huiyuan_id = request.data.get('huiyuan_id', '').strip()
yongjin_str = request.data.get('yongjin', '0')
fencheng_type = request.data.get('fencheng_type', 'default')
# 兼容列表页 ewai_dashou_fencheng 与旧字段 ewai_PlayerCommission
ewai_PlayerCommission_str = request.data.get('ewai_dashou_fencheng')
if ewai_PlayerCommission_str is None or ewai_PlayerCommission_str == '':
ewai_PlayerCommission_str = request.data.get('ewai_PlayerCommission', '0')
jieshao = request.data.get('jieshao', '').strip()
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
# 图片文件
image_file = request.FILES.get('file') # 前端上传时字段名为 'file'
# 必填字段校验
if not biaoqian:
return Response({'code': 400, 'msg': '商品标题不能为空'})
try:
jiage = Decimal(jiage_str)
if jiage <= 0:
return Response({'code': 400, 'msg': '价格必须大于0'})
except:
return Response({'code': 400, 'msg': '价格格式错误'})
try:
kucun = int(kucun_str)
if kucun < 0:
return Response({'code': 400, 'msg': '库存不能为负数'})
except:
return Response({'code': 400, 'msg': '库存格式错误'})
try:
duiwai_xiaoliang = int(duiwai_xiaoliang_str)
except:
duiwai_xiaoliang = 0
try:
leixing_id = int(leixing_id_str)
except:
return Response({'code': 400, 'msg': '商品类型ID无效'})
try:
zhuanqu_id = int(zhuanqu_id_str)
except:
return Response({'code': 400, 'msg': '商品专区ID无效'})
try:
yaoqiuleixing = int(yaoqiuleixing_str)
if yaoqiuleixing not in (1, 2):
raise ValueError
except:
return Response({'code': 400, 'msg': '抢单要求类型必须为1或2'})
# 校验类型和专区的存在性及关联性
try:
leixing_obj = ShangpinLeixing.query.get(id=leixing_id)
except ShangpinLeixing.DoesNotExist:
return Response({'code': 400, 'msg': '商品类型不存在'})
try:
zhuanqu_obj = ShangpinZhuanqu.query.get(id=zhuanqu_id)
if zhuanqu_obj.leixing_id != leixing_id:
return Response({'code': 400, 'msg': '专区不属于该商品类型'})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_write import resolve_club_id_for_write
if resolve_club_scope(request) != DATA_SCOPE_ALL:
write_club = resolve_club_id_for_write(request)
zone_club = getattr(zhuanqu_obj, 'club_id', None) or 'xq'
if zone_club != write_club:
return Response({'code': 400, 'msg': '专区不属于当前俱乐部,请先切换俱乐部或选择本俱乐部专区'})
except ShangpinZhuanqu.DoesNotExist:
return Response({'code': 400, 'msg': '商品专区不存在'})
# 处理抢单要求相关字段
huiyuan_id_val = None
yongjin_val = None
if yaoqiuleixing == 1:
# 会员要求
if not huiyuan_id:
# 未传递时尝试从商品类型中获取默认会员ID
huiyuan_id_val = leixing_obj.huiyuan_id
if not huiyuan_id_val:
return Response({'code': 400, 'msg': '未指定会员且商品类型无默认会员'})
else:
huiyuan_id_val = huiyuan_id
# 验证会员是否存在
if not Huiyuan.query.filter(huiyuan_id=huiyuan_id_val).exists():
return Response({'code': 400, 'msg': '指定的会员不存在'})
else:
# 佣金要求
try:
yongjin_val = Decimal(yongjin_str)
if yongjin_val < 0:
return Response({'code': 400, 'msg': '佣金不能为负数'})
except:
# 若未传递或无效,尝试从商品类型中获取默认佣金
if leixing_obj.yongjin is not None:
yongjin_val = leixing_obj.yongjin
else:
return Response({'code': 400, 'msg': '佣金格式错误且类型无默认佣金'})
# 处理打手分成方式
kaioi_ewai = False
ewai_fencheng = Decimal('0')
if fencheng_type == 'custom':
try:
ewai_fencheng = Decimal(ewai_PlayerCommission_str)
if ewai_fencheng < 0:
return Response({'code': 400, 'msg': '自定义分成金额不能为负数'})
kaioi_ewai = True
except:
return Response({'code': 400, 'msg': '自定义分成金额格式错误'})
# 图片上传处理
tupian_url = ''
if image_file:
# 验证图片
valid, msg = validate_image(image_file)
if not valid:
return Response({'code': 400, 'msg': f'图片无效:{msg}'})
# 生成唯一文件名
ext = image_file.name.split('.')[-1].lower()
if ext not in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']:
return Response({'code': 400, 'msg': '不支持的图片格式'})
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_str = uuid.uuid4().hex[:8]
filename = f"shangpin/shangpintupian/{timestamp}_{random_str}.{ext}"
# 上传到 OSS
uploaded_url = upload_to_oss(image_file, filename)
if not uploaded_url:
return Response({'code': 500, 'msg': '图片上传失败,请稍后重试'})
tupian_url = filename # 存储相对路径,前端拼接域名
else:
return Response({'code': 400, 'msg': '商品图片不能为空'})
# 创建商品
try:
from jituan.services.club_write import resolve_club_id_for_write
club_id = resolve_club_id_for_write(request)
with transaction.atomic():
product = Shangpin.query.create(
biaoqian=biaoqian,
jiage=jiage,
kucun=kucun,
leixing_id=leixing_id,
zhuanqu_id=zhuanqu_id,
zhenshi_xiaoliang=0, # 初始真实销量为0
duiwai_xiaoliang=duiwai_xiaoliang,
jieshao=jieshao,
xiadan_xuzhi=xiadan_xuzhi,
tupian_url=tupian_url,
yaoqiuleixing=yaoqiuleixing,
huiyuan_id=huiyuan_id_val,
yongjin=yongjin_val,
kaioi_ewai_dashou_fencheng=kaioi_ewai,
ewai_dashou_fencheng=ewai_fencheng,
paixu=0, # 新商品默认排序最后
shenhezhuangtai=1, # 正常状态
club_id=club_id,
)
except Exception as e:
logger.error(f"创建商品失败: {str(e)}", exc_info=True)
return Response({'code': 500, 'msg': '创建商品失败,请稍后重试'})
return Response({
'code': 0,
'msg': '商品发布成功',
'data': {
'id': product.id,
'tupian_url': tupian_url,
}
})
# houtai/views_product.py 中添加
class DeleteProductView(APIView):
"""
删除商品物理删除同时删除OSS图片
权限2200a
POST /houtai/htscsp
参数:
username: 客服/管理员账号
id: 商品ID
返回:
code:0 成功
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
product_id = request.data.get('id')
if not username:
return Response({'code': 401, 'msg': '缺少username'})
if not product_id:
return Response({'code': 400, 'msg': '缺少商品ID'})
try:
product_id = int(product_id)
except:
return Response({'code': 400, 'msg': '商品ID格式错误'})
# 权限校验
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
# 查询商品是否存在
try:
product = Shangpin.query.get(id=product_id)
except Shangpin.DoesNotExist:
return Response({'code': 404, 'msg': '商品不存在'})
# 先删除OSS上的图片
tupian_url = product.tupian_url
if tupian_url:
# 调用已有的 delete_from_oss 函数
delete_success = delete_from_oss(tupian_url)
if not delete_success:
logger.warning(f"删除商品图片失败: {tupian_url}, 商品ID: {product_id}")
# 注意:即使图片删除失败,也应继续删除数据库记录,避免数据不一致
# 但可以记录日志供后续人工处理
# 删除数据库记录
product.delete()
logger.info(f"商品删除成功: id={product_id}, 图片路径={tupian_url}, 操作人={username}")
return Response({'code': 0, 'msg': '商品删除成功'})
class GetProductDetailView(APIView):
"""
获取商品详情(包含商品类型、专区、会员列表)
权限2200a
POST /houtai/htsphqxq
参数:
username: 客服/管理员账号
id: 商品ID
返回:
code:0, data: { product, type_list, zone_list, member_list }
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
product_id = request.data.get('id')
if not username:
return Response({'code': 401, 'msg': '缺少username'})
if not product_id:
return Response({'code': 400, 'msg': '缺少商品ID'})
try:
product_id = int(product_id)
except ValueError:
return Response({'code': 400, 'msg': '商品ID格式错误'})
# 权限校验
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
# 查询商品
try:
product = Shangpin.query.get(id=product_id)
except Shangpin.DoesNotExist:
return Response({'code': 404, 'msg': '商品不存在'})
# 查询所有商品类型、专区、会员(用于前端下拉选择)
type_list = list(ShangpinLeixing.query.all().values(
'id', 'jieshao', 'tupian_url', 'shenhezhuangtai'
))
zone_list = list(ShangpinZhuanqu.query.all().values(
'id', 'mingzi', 'leixing_id', 'shenhezhuangtai'
))
member_list = list(Huiyuan.query.all().values(
'huiyuan_id', 'jieshao', 'jiage'
))
# 构建商品返回数据
product_data = {
'id': product.id,
'biaoqian': product.biaoqian,
'jiage': str(product.jiage),
'kucun': product.kucun,
'leixing_id': product.leixing_id,
'zhuanqu_id': product.zhuanqu_id,
'zhenshi_xiaoliang': product.zhenshi_xiaoliang,
'duiwai_xiaoliang': product.duiwai_xiaoliang,
'jieshao': product.jieshao,
'xiadan_xuzhi': product.xiadan_xuzhi,
'guize_tupian': product.guize_tupian,
'yaoqiuleixing': product.yaoqiuleixing,
'huiyuan_id': product.huiyuan_id,
'yongjin': str(product.yongjin) if product.yongjin is not None else None,
'tupian_url': product.tupian_url,
# 兼容两套字段名(客服详情页用 kaioi_ewai_dashou_fencheng
'kaioi_ewai_dashou_fencheng': bool(product.kaioi_ewai_dashou_fencheng),
'ewai_dashou_fencheng': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
'kaioi_ewai_PlayerCommission': bool(product.kaioi_ewai_dashou_fencheng),
'ewai_PlayerCommission': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
'CreateTime': product.CreateTime.isoformat() if product.CreateTime else None,
'shenhezhuangtai': product.shenhezhuangtai,
}
return Response({
'code': 0,
'data': {
'product': product_data,
'type_list': type_list,
'zone_list': zone_list,
'member_list': member_list,
}
})
class UpdateProductView(APIView):
"""
修改商品(支持商品图片和规则图片的更新)
权限2200a
POST /houtai/bcspxg
"""
permission_classes = []
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
username = request.data.get('username', '').strip()
product_id = request.data.get('id')
if not username:
return Response({'code': 401, 'msg': '缺少username'})
if not product_id:
return Response({'code': 400, 'msg': '缺少商品ID'})
try:
product_id = int(product_id)
except ValueError:
return Response({'code': 400, 'msg': '商品ID格式错误'})
# 权限校验
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
# 使用事务,将 select_for_update 放在事务内
with transaction.atomic():
try:
product = Shangpin.objects.select_for_update().get(id=product_id)
except Shangpin.DoesNotExist:
return Response({'code': 404, 'msg': '商品不存在'})
# ---------- 1. 基础字段 ----------
biaoqian = request.data.get('biaoqian', '').strip()
if not biaoqian:
return Response({'code': 400, 'msg': '商品标题不能为空'})
try:
jiage = Decimal(str(request.data.get('jiage', 0)))
if jiage <= 0:
return Response({'code': 400, 'msg': '价格必须大于0'})
except:
return Response({'code': 400, 'msg': '价格格式错误'})
try:
kucun = int(request.data.get('kucun', 0))
if kucun < 0:
return Response({'code': 400, 'msg': '库存不能为负数'})
except:
return Response({'code': 400, 'msg': '库存格式错误'})
try:
duiwai_xiaoliang = int(request.data.get('duiwai_xiaoliang', 0))
except:
duiwai_xiaoliang = 0
try:
leixing_id = int(request.data.get('leixing_id'))
except:
return Response({'code': 400, 'msg': '商品类型ID无效'})
try:
zhuanqu_id = int(request.data.get('zhuanqu_id'))
except:
return Response({'code': 400, 'msg': '商品专区ID无效'})
try:
yaoqiuleixing = int(request.data.get('yaoqiuleixing', 1))
if yaoqiuleixing not in (1, 2):
raise ValueError
except:
return Response({'code': 400, 'msg': '抢单要求类型必须为1或2'})
try:
shenhezhuangtai = int(request.data.get('shenhezhuangtai', 1))
except:
shenhezhuangtai = 1
# 校验类型和专区存在性
if not ShangpinLeixing.query.filter(id=leixing_id).exists():
return Response({'code': 400, 'msg': '商品类型不存在'})
if not ShangpinZhuanqu.query.filter(id=zhuanqu_id, leixing_id=leixing_id).exists():
return Response({'code': 400, 'msg': '专区不存在或不属于该类型'})
# ---------- 2. 会员/佣金 ----------
huiyuan_id = None
yongjin = None
if yaoqiuleixing == 1:
huiyuan_id = request.data.get('huiyuan_id', '').strip() or None
if huiyuan_id and not Huiyuan.query.filter(huiyuan_id=huiyuan_id).exists():
return Response({'code': 400, 'msg': '指定的会员不存在'})
else:
try:
yongjin = Decimal(str(request.data.get('yongjin', 0)))
if yongjin < 0:
return Response({'code': 400, 'msg': '佣金不能为负数'})
except:
yongjin = Decimal('0')
# ---------- 3. 打手分成(兼容详情页字段名与旧 PlayerCommission 别名) ----------
raw_kaioi = request.data.get('kaioi_ewai_dashou_fencheng')
if raw_kaioi is None:
raw_kaioi = request.data.get('kaioi_ewai_PlayerCommission')
kaioi_ewai = str(raw_kaioi).strip().lower() in ('1', 'true', 'yes', 'on')
ewai_fencheng = Decimal('0')
if kaioi_ewai:
raw_amt = request.data.get('ewai_dashou_fencheng')
if raw_amt is None or raw_amt == '':
raw_amt = request.data.get('ewai_PlayerCommission', 0)
try:
ewai_fencheng = Decimal(str(raw_amt))
if ewai_fencheng < 0:
return Response({'code': 400, 'msg': '分成金额不能为负数'})
except Exception:
return Response({'code': 400, 'msg': '分成金额格式错误'})
else:
# 默认分成:清空固定金额,下单按俱乐部利率算
ewai_fencheng = None
jieshao = request.data.get('jieshao', '').strip()
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
# ---------- 4. 商品图片更新 ----------
tupian_url = product.tupian_url
tupian_file = request.FILES.get('tupian_file')
if tupian_file:
valid, msg = validate_image(tupian_file)
if not valid:
return Response({'code': 400, 'msg': f'商品图片无效:{msg}'})
ext = tupian_file.name.split('.')[-1].lower()
if ext not in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']:
return Response({'code': 400, 'msg': '不支持的图片格式'})
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_str = uuid.uuid4().hex[:8]
new_filename = f"shangpin/shangpintupian/{timestamp}_{random_str}.{ext}"
uploaded_url = upload_to_oss(tupian_file, new_filename)
if not uploaded_url:
return Response({'code': 500, 'msg': '商品图片上传失败'})
if product.tupian_url:
delete_from_oss(product.tupian_url)
tupian_url = new_filename
# ---------- 5. 规则图片更新 ----------
guize_tupian = product.guize_tupian
guize_file = request.FILES.get('guize_file')
if guize_file:
valid, msg = validate_image(guize_file)
if not valid:
return Response({'code': 400, 'msg': f'规则图片无效:{msg}'})
ext = guize_file.name.split('.')[-1].lower()
if ext not in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']:
return Response({'code': 400, 'msg': '不支持的图片格式'})
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
random_str = uuid.uuid4().hex[:8]
new_filename = f"shangpin/guizetupian/{timestamp}_{random_str}.{ext}"
uploaded_url = upload_to_oss(guize_file, new_filename)
if not uploaded_url:
return Response({'code': 500, 'msg': '规则图片上传失败'})
if product.guize_tupian:
delete_from_oss(product.guize_tupian)
guize_tupian = new_filename
# ---------- 6. 更新数据库 ----------
product.biaoqian = biaoqian
product.jiage = jiage
product.kucun = kucun
product.duiwai_xiaoliang = duiwai_xiaoliang
product.leixing_id = leixing_id
product.zhuanqu_id = zhuanqu_id
product.yaoqiuleixing = yaoqiuleixing
product.huiyuan_id = huiyuan_id
product.yongjin = yongjin
product.kaioi_ewai_dashou_fencheng = kaioi_ewai
product.ewai_dashou_fencheng = ewai_fencheng
product.jieshao = jieshao
product.xiadan_xuzhi = xiadan_xuzhi
product.tupian_url = tupian_url
product.guize_tupian = guize_tupian
product.shenhezhuangtai = shenhezhuangtai
product.save()
return Response({'code': 0, 'msg': '商品修改成功', 'data': {'id': product.id}})
class UpdateZhiKanShenheView(APIView):
"""
商品管理页:按俱乐部更新「只看审核商品」开关。
权限2200a
POST /houtai/htxgzhikan
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '2200a' not in permissions:
return Response({'code': 403, 'msg': '无权限管理商品'})
val = request.data.get('zhi_kan_shenhe')
if val is None:
return Response({'code': 400, 'msg': '缺少参数 zhi_kan_shenhe'})
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
from jituan.services.club_write import resolve_club_id_for_write
from shop.services.shenhe_config import get_or_create_shenhe_config
if resolve_club_scope(request) == DATA_SCOPE_ALL:
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改只看审核开关'})
try:
with transaction.atomic():
club_id = resolve_club_id_for_write(request)
config = get_or_create_shenhe_config(club_id)
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
config.zhi_kan_shenhe = bool(val)
config.save(update_fields=['zhi_kan_shenhe'])
return Response({
'code': 0,
'msg': '只看审核开关已更新',
'data': {'club_id': club_id, 'zhi_kan_shenhe': bool(val)},
})
except Exception:
logging.getLogger(__name__).exception('更新只看审核开关失败')
return Response({'code': 500, 'msg': '服务器错误'})