feat: 假单后台管理接口(按俱乐部+商品类型 CRUD)

新增 fake-grab-order-list/manage/avatar 接口,权限 2200a;支持增删改价格介绍头像等;假单展示使用自定义头像。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-05 22:28:13 +08:00
parent 2a350a2ec8
commit ddfd3d3ac7
5 changed files with 441 additions and 1 deletions

View File

@@ -0,0 +1,322 @@
"""后台假单管理(按俱乐部 + 商品类型)。"""
import logging
import os
import random
import time
import uuid
from decimal import Decimal
from jituan.services.club_context import (
club_id_for_write,
resolve_club_id_from_request,
resolve_club_scope,
)
from jituan.services.club_user_access import list_response_meta
from orders.models import FakeGrabOrder, FakeGrabOrderShuffleState
from orders.services.fake_order_pool import DEFAULT_DISPATCHER_AVATAR, _scope_key
from products.models import ShangpinLeixing
logger = logging.getLogger(__name__)
CLUB_WRITE_SCOPE_MSG = '当前为集团汇总视图,请先在顶栏切换到具体俱乐部后再操作'
CLUB_READ_SCOPE_MSG = '请先在顶栏切换到具体小程序/俱乐部后再操作'
PRODUCT_PERMS = ('2200a',)
SHUFFLE_STATE_PK = 1
def club_read_blocked(request):
return resolve_club_scope(request) == DATA_SCOPE_ALL
def club_write_blocked(request):
return resolve_club_scope(request) == DATA_SCOPE_ALL
def _gen_order_id():
while True:
oid = f'JD{int(time.time() * 1000)}{random.randint(1000, 9999)}'
if not FakeGrabOrder.query.filter(OrderID=oid).exists():
return oid
def invalidate_fake_order_shuffle(club_id, leixing_id=None):
"""假单增删改后清除对应打乱缓存,下次读取自动重排。"""
try:
state = FakeGrabOrderShuffleState.objects.get(pk=SHUFFLE_STATE_PK)
except FakeGrabOrderShuffleState.DoesNotExist:
return
seq_map = dict(state.OrderSequence or {})
if leixing_id is not None:
seq_map.pop(_scope_key(club_id, leixing_id), None)
else:
prefix = f'{club_id}|'
for key in list(seq_map.keys()):
if key.startswith(prefix):
seq_map.pop(key, None)
state.OrderSequence = seq_map
state.save(update_fields=['OrderSequence', 'UpdateTime'])
def _leixing_name_map():
return {
lx.id: (lx.jieshao or f'类型{lx.id}')
for lx in ShangpinLeixing.query.exclude(shenhezhuangtai=2)
}
def build_leixing_options():
"""全部可用商品类型(非审核专用),假单可绑定任意类型。"""
options = []
for lx in ShangpinLeixing.query.exclude(shenhezhuangtai=2).order_by('-paixu', 'id'):
options.append({
'id': lx.id,
'jieshao': lx.jieshao or '',
'tupian_url': lx.tupian_url or '',
'bankuai_id': lx.bankuai_id,
})
return options
def _row_to_dict(row, leixing_names=None):
leixing_names = leixing_names or _leixing_name_map()
return {
'order_id': row.OrderID,
'dingdan_id': row.OrderID,
'amount': str(row.Amount or 0),
'jine': str(row.Amount or 0),
'leixing_id': row.ProductTypeID,
'leixing_name': leixing_names.get(row.ProductTypeID, f'类型{row.ProductTypeID}'),
'jieshao': row.Description or '',
'beizhu': row.Remark or '',
'paidan_yonghuid': row.DispatcherUserID or '',
'paidan_touxiang': row.DispatcherAvatar or DEFAULT_DISPATCHER_AVATAR,
'shangjia_nicheng': row.MerchantNickname or '',
'is_youzhi_shangjia': bool(row.IsPremiumMerchant),
'fadan_pingtai': int(row.Platform or 1),
'platform_label': '平台发单' if int(row.Platform or 1) == 1 else '商家发单',
'sort_order': int(row.SortOrder or 0),
'club_id': row.ClubID,
'CreateTime': row.CreateTime.isoformat() if row.CreateTime else None,
'UpdateTime': row.UpdateTime.isoformat() if row.UpdateTime else None,
}
def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20):
if club_read_blocked(request):
return {
'list': [],
'total': 0,
'page': page,
'page_size': page_size,
'club_id': resolve_club_id_from_request(request),
'scope': resolve_club_scope(request),
'scope_error': CLUB_READ_SCOPE_MSG,
'leixing_options': build_leixing_options(),
**list_response_meta(request),
}
club_id = resolve_club_id_from_request(request)
page = max(1, int(page or 1))
page_size = min(100, max(1, int(page_size or 20)))
qs = FakeGrabOrder.query.filter(ClubID=club_id)
if leixing_id not in (None, ''):
qs = qs.filter(ProductTypeID=int(leixing_id))
qs = qs.order_by('-SortOrder', '-CreateTime')
total = qs.count()
offset = (page - 1) * page_size
rows = list(qs[offset:offset + page_size])
names = _leixing_name_map()
return {
'list': [_row_to_dict(r, names) for r in rows],
'total': total,
'page': page,
'page_size': page_size,
'club_id': club_id,
'scope': resolve_club_scope(request),
'leixing_options': build_leixing_options(),
**list_response_meta(request),
}
def _parse_decimal(value, field_name='金额'):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError
return val
except Exception:
raise ValueError(f'{field_name}格式错误')
def _validate_leixing_id(leixing_id):
try:
lid = int(leixing_id)
except (TypeError, ValueError):
return None, '请选择商品类型'
if not ShangpinLeixing.query.filter(id=lid).exclude(shenhezhuangtai=2).exists():
return None, '商品类型不存在或为审核专用'
return lid, None
def create_fake_order(request, data):
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
leixing_id, err = _validate_leixing_id(data.get('leixing_id'))
if err:
return None, err
try:
amount = _parse_decimal(data.get('amount') or data.get('jine'), '价格')
except ValueError as e:
return None, str(e)
platform = int(data.get('fadan_pingtai') or data.get('platform') or 1)
if platform not in (1, 2):
return None, '发单平台无效'
row = FakeGrabOrder.query.create(
OrderID=_gen_order_id(),
Amount=amount,
ProductTypeID=leixing_id,
Description=(data.get('jieshao') or '').strip(),
Remark=(data.get('beizhu') or '').strip(),
DispatcherUserID=(data.get('paidan_yonghuid') or '').strip(),
DispatcherAvatar=(data.get('paidan_touxiang') or '').strip() or DEFAULT_DISPATCHER_AVATAR,
MerchantNickname=(data.get('shangjia_nicheng') or '').strip() if platform == 2 else '',
IsPremiumMerchant=bool(data.get('is_youzhi_shangjia')) if platform == 2 else False,
Platform=platform,
ClubID=club_id,
SortOrder=int(data.get('sort_order') or 0),
)
invalidate_fake_order_shuffle(club_id, leixing_id)
return _row_to_dict(row), None
def update_fake_order(request, order_id, data):
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
order_id = (order_id or '').strip()
if not order_id:
return None, '缺少订单ID'
try:
row = FakeGrabOrder.objects.get(OrderID=order_id, ClubID=club_id)
except FakeGrabOrder.DoesNotExist:
return None, '假单不存在或不属于当前俱乐部'
old_leixing = row.ProductTypeID
update_fields = ['UpdateTime']
if 'leixing_id' in data and data.get('leixing_id') not in (None, ''):
leixing_id, err = _validate_leixing_id(data.get('leixing_id'))
if err:
return None, err
row.ProductTypeID = leixing_id
update_fields.append('ProductTypeID')
if 'amount' in data or 'jine' in data:
try:
row.Amount = _parse_decimal(data.get('amount') or data.get('jine'), '价格')
except ValueError as e:
return None, str(e)
update_fields.append('Amount')
if 'jieshao' in data:
row.Description = (data.get('jieshao') or '').strip()
update_fields.append('Description')
if 'beizhu' in data:
row.Remark = (data.get('beizhu') or '').strip()
update_fields.append('Remark')
if 'paidan_yonghuid' in data:
row.DispatcherUserID = (data.get('paidan_yonghuid') or '').strip()
update_fields.append('DispatcherUserID')
if 'paidan_touxiang' in data:
row.DispatcherAvatar = (data.get('paidan_touxiang') or '').strip() or DEFAULT_DISPATCHER_AVATAR
update_fields.append('DispatcherAvatar')
if 'sort_order' in data:
row.SortOrder = int(data.get('sort_order') or 0)
update_fields.append('SortOrder')
if 'fadan_pingtai' in data or 'platform' in data:
platform = int(data.get('fadan_pingtai') or data.get('platform') or row.Platform or 1)
if platform not in (1, 2):
return None, '发单平台无效'
row.Platform = platform
update_fields.append('Platform')
if platform == 1:
row.MerchantNickname = ''
row.IsPremiumMerchant = False
update_fields.extend(['MerchantNickname', 'IsPremiumMerchant'])
if row.Platform == 2:
if 'shangjia_nicheng' in data:
row.MerchantNickname = (data.get('shangjia_nicheng') or '').strip()
update_fields.append('MerchantNickname')
if 'is_youzhi_shangjia' in data:
row.IsPremiumMerchant = bool(data.get('is_youzhi_shangjia'))
update_fields.append('IsPremiumMerchant')
row.save(update_fields=list(set(update_fields)))
invalidate_fake_order_shuffle(club_id, row.ProductTypeID)
if old_leixing != row.ProductTypeID:
invalidate_fake_order_shuffle(club_id, old_leixing)
return _row_to_dict(row), None
def delete_fake_order(request, order_id):
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
order_id = (order_id or '').strip()
if not order_id:
return None, '缺少订单ID'
try:
row = FakeGrabOrder.objects.get(OrderID=order_id, ClubID=club_id)
except FakeGrabOrder.DoesNotExist:
return None, '假单不存在或不属于当前俱乐部'
leixing_id = row.ProductTypeID
row.delete()
invalidate_fake_order_shuffle(club_id, leixing_id)
return {'order_id': order_id}, None
def upload_fake_order_avatar(request, order_id, file_obj):
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
from utils.oss_utils import upload_to_oss, validate_image
club_id = club_id_for_write(request)
order_id = (order_id or '').strip()
if not order_id or not file_obj:
return None, '参数不完整'
ok, msg = validate_image(file_obj)
if not ok:
return None, msg
try:
row = FakeGrabOrder.objects.get(OrderID=order_id, ClubID=club_id)
except FakeGrabOrder.DoesNotExist:
return None, '假单不存在或不属于当前俱乐部'
ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower()
if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'):
ext = '.png'
rel_path = f'club/{club_id}/fake_grab_order/{order_id}_{uuid.uuid4().hex}{ext}'
if not upload_to_oss(file_obj, rel_path):
return None, '上传 OSS 失败'
row.DispatcherAvatar = rel_path
row.save(update_fields=['DispatcherAvatar', 'UpdateTime'])
invalidate_fake_order_shuffle(club_id, row.ProductTypeID)
return {'order_id': order_id, 'paidan_touxiang': rel_path}, None

View File

@@ -69,6 +69,8 @@ KEFU_MENU_ROW_DEFS = [
'perm_codes': ['7007a']}, 'perm_codes': ['7007a']},
{'page_id': 'product.data', 'name': '商品数据分析', 'path': '/product/data', 'parent_id': 'product', 'sort_order': 93, {'page_id': 'product.data', 'name': '商品数据分析', 'path': '/product/data', 'parent_id': 'product', 'sort_order': 93,
'perm_codes': ['2200a']}, 'perm_codes': ['2200a']},
{'page_id': 'product.fake-orders', 'name': '假单管理', 'path': '/product/fake-orders', 'parent_id': 'product', 'sort_order': 94,
'perm_codes': ['2200a']},
{'page_id': 'member', 'name': '会员管理', 'path': '', 'parent_id': '', 'sort_order': 100}, {'page_id': 'member', 'name': '会员管理', 'path': '', 'parent_id': '', 'sort_order': 100},
{'page_id': 'member.list', 'name': '会员管理', 'path': '/member/list', 'parent_id': 'member', 'sort_order': 101, {'page_id': 'member.list', 'name': '会员管理', 'path': '/member/list', 'parent_id': 'member', 'sort_order': 101,
'perm_codes': ['3300a']}, 'perm_codes': ['3300a']},

View File

@@ -66,6 +66,11 @@ from jituan.views_dashou_exam import (
DashouExamQuestionManageView, DashouExamQuestionManageView,
DashouExamStatusView, DashouExamStatusView,
) )
from jituan.views_fake_grab_order import (
FakeGrabOrderAvatarUploadView,
FakeGrabOrderListView,
FakeGrabOrderManageView,
)
from jituan.views_club_catalog import ( from jituan.views_club_catalog import (
ClubKaoheChenghaoCatalogView, ClubKaoheChenghaoCatalogView,
ClubKaoheChenghaoDisableView, ClubKaoheChenghaoDisableView,
@@ -144,6 +149,9 @@ urlpatterns = [
path('houtai/hy-card-upload', MemberCardImageUploadView.as_view(), name='jituan_member_card_upload'), path('houtai/hy-card-upload', MemberCardImageUploadView.as_view(), name='jituan_member_card_upload'),
path('houtai/hy-recharge-records', MemberRechargeRecordsView.as_view(), name='jituan_member_recharge_records'), path('houtai/hy-recharge-records', MemberRechargeRecordsView.as_view(), name='jituan_member_recharge_records'),
path('houtai/hy-bundle-create', MemberBundleCreateView.as_view(), name='jituan_member_bundle_create'), path('houtai/hy-bundle-create', MemberBundleCreateView.as_view(), name='jituan_member_bundle_create'),
path('houtai/fake-grab-order-list', FakeGrabOrderListView.as_view(), name='jituan_fake_grab_order_list'),
path('houtai/fake-grab-order-manage', FakeGrabOrderManageView.as_view(), name='jituan_fake_grab_order_manage'),
path('houtai/fake-grab-order-avatar', FakeGrabOrderAvatarUploadView.as_view(), name='jituan_fake_grab_order_avatar'),
path('houtai/club-leixing-list', ClubShangpinLeixingListView.as_view(), name='jituan_club_leixing_list'), path('houtai/club-leixing-list', ClubShangpinLeixingListView.as_view(), name='jituan_club_leixing_list'),
path('houtai/club-leixing-catalog', ClubShangpinLeixingCatalogView.as_view(), name='jituan_club_leixing_catalog'), path('houtai/club-leixing-catalog', ClubShangpinLeixingCatalogView.as_view(), name='jituan_club_leixing_catalog'),
path('houtai/club-leixing-enable', ClubShangpinLeixingEnableView.as_view(), name='jituan_club_leixing_enable'), path('houtai/club-leixing-enable', ClubShangpinLeixingEnableView.as_view(), name='jituan_club_leixing_enable'),

View File

@@ -0,0 +1,108 @@
"""假单后台管理 API。"""
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from backend.view import verify_kefu_permission
PRODUCT_PERMS = ('2200a',)
def _check_product_perm(request, phone):
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return None, permissions
if not any(p in permissions for p in PRODUCT_PERMS):
return None, Response({'code': 403, 'msg': '无商品管理权限'})
return kefu, None
class FakeGrabOrderListView(APIView):
"""POST /jituan/houtai/fake-grab-order-list"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
if not phone:
return Response({'code': 401, 'msg': '缺少客服账号'})
_, err = _check_product_perm(request, phone)
if err:
return err
from jituan.services.fake_grab_order_admin import build_fake_order_list_payload
payload = build_fake_order_list_payload(
request,
leixing_id=request.data.get('leixing_id'),
page=request.data.get('page', 1),
page_size=request.data.get('page_size', 20),
)
return Response({'code': 0, 'data': payload})
class FakeGrabOrderManageView(APIView):
"""POST /jituan/houtai/fake-grab-order-manage action=create|update|delete"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
action = (request.data.get('action') or '').strip().lower()
if not phone:
return Response({'code': 401, 'msg': '缺少客服账号'})
_, err = _check_product_perm(request, phone)
if err:
return err
from jituan.services.fake_grab_order_admin import (
create_fake_order,
delete_fake_order,
update_fake_order,
)
if action == 'create':
data, err_msg = create_fake_order(request, request.data)
if err_msg:
return Response({'code': 400, 'msg': err_msg})
return Response({'code': 0, 'msg': '创建成功', 'data': data})
if action == 'update':
order_id = request.data.get('order_id') or request.data.get('dingdan_id')
data, err_msg = update_fake_order(request, order_id, request.data)
if err_msg:
return Response({'code': 400, 'msg': err_msg})
return Response({'code': 0, 'msg': '更新成功', 'data': data})
if action == 'delete':
order_id = request.data.get('order_id') or request.data.get('dingdan_id')
data, err_msg = delete_fake_order(request, order_id)
if err_msg:
return Response({'code': 400, 'msg': err_msg})
return Response({'code': 0, 'msg': '已删除', 'data': data})
return Response({'code': 400, 'msg': '无效的 action'})
class FakeGrabOrderAvatarUploadView(APIView):
"""POST /jituan/houtai/fake-grab-order-avatar"""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser, JSONParser]
def post(self, request):
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
order_id = (request.data.get('order_id') or request.data.get('dingdan_id') or '').strip()
file_obj = request.FILES.get('file')
if not phone:
return Response({'code': 401, 'msg': '缺少客服账号'})
_, err = _check_product_perm(request, phone)
if err:
return err
from jituan.services.fake_grab_order_admin import upload_fake_order_avatar
data, err_msg = upload_fake_order_avatar(request, order_id, file_obj)
if err_msg:
return Response({'code': 400, 'msg': err_msg})
return Response({'code': 0, 'msg': '上传成功', 'data': data})

View File

@@ -170,7 +170,7 @@ def _load_fake_rows_in_saved_order(club_id, leixing_id):
def _format_fake_row(row, anchor_dt=None): def _format_fake_row(row, anchor_dt=None):
"""字段与 DashouDingdanHuoquView 真实单列表保持一致。""" """字段与 DashouDingdanHuoquView 真实单列表保持一致。"""
avatar = DEFAULT_DISPATCHER_AVATAR avatar = (getattr(row, 'DispatcherAvatar', None) or '').strip() or DEFAULT_DISPATCHER_AVATAR
platform = int(row.Platform or 1) platform = int(row.Platform or 1)
oid = row.OrderID oid = row.OrderID
price = float(row.Amount or 0) price = float(row.Amount or 0)