feat: 客服改平台订单/商品抢单要求(002ad 隔离接口)

仅状态1/7可改订单;批量只改本店类型下商品三字段;各俱乐部闸门隔离无白名单特殊化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-29 07:48:11 +08:00
parent c3a0143eff
commit af513738a5
6 changed files with 331 additions and 2 deletions

View File

@@ -29,6 +29,9 @@ ADMIN_SCOPE_CLUB = 'CLUB'
# 集团最高权限账号(写死超级管理员,拥有全部功能权限与集团视图)
SUPER_ADMIN_PHONES = frozenset({'1383714110'})
# 权限码:修改平台订单/商品抢单要求(须角色显式绑定,不随本店超管 000001 自动解锁)
PERM_GRAB_REQUIREMENT_EDIT = '002ad'
# —— 支付通道 ——
PAY_CHANNEL_WECHAT_JSAPI = 'wechat_jsapi'
PAY_CHANNEL_WECHAT_V3 = 'wechat_v3'

View File

@@ -74,6 +74,12 @@ EXTRA_PERMISSIONS = [
'配置打手/管事/组长用户协议并发布版本',
'小程序配置',
),
(
'002ad',
'修改抢单要求',
'修改平台订单/本店商品的抢单要求类型(会员/押金门槛)。须显式绑定;本店超管 000001 不会自动拥有。',
'订单管理',
),
# ---------- 店铺管理999 系列:列表/详情/复制/提现等) ----------
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),

View File

@@ -11,7 +11,8 @@ from .views import WechatMiniProgramLoginView, UserInfoUpdateView, DashouXinxiAP
WeixinOfficialCallbackView,KefuLoginView, KefuStatsView, KefuGetOrderTypesView, KefuGetOrderListView, \
KefuGetShangjiaOrderListView, KefuGetOrderDetailView, KefuChangeDashouView, KefuRecoverOrderView, \
KefuPlatformRefundView, KefuRejectRefundView, KefuMerchantRefundView, KefuRejectSettlementView, \
KefuTransferHallView, KefuCancelDesignationView, KefuGetDashouListView, KefuGetDashouDetailView, \
KefuTransferHallView, KefuCancelDesignationView, KefuUpdateOrderGrabRequirementView, \
KefuBatchProductGrabRequirementView, KefuGetDashouListView, KefuGetDashouDetailView, \
KefuUpdateDashouView, KefuPunishmentListView, KefuPunishmentActionView, KefuPunishmentDetailView, \
KefuWithdrawListView, KefuWithdrawDetailView, KefuWithdrawActionView, KefuPunishView, AdKfglView, AdKftjView, \
AdKfxgView,GuanshiRegisterView, WechatLoginAndGuanshiRegisterView,ZuzhangZhuceView, ZuzhangXinxiView,KefuForceCompleteView,HuoQuYaoQingRenView, DashouJianquanView, LaobanJianquanView,\
@@ -107,6 +108,8 @@ urlpatterns = [
path('kefujjjs', KefuRejectSettlementView.as_view(), name='客服拒绝结算'),
path('kefuzydt', KefuTransferHallView.as_view(), name='客服订单返回大厅'),
path('kefuqxzd', KefuCancelDesignationView.as_view(), name='客服取消指定'),
path('kefu_gai_qiangdan_yaoqiu', KefuUpdateOrderGrabRequirementView.as_view(), name='客服改订单抢单要求'),
path('kefu_batch_qiangdan_yaoqiu', KefuBatchProductGrabRequirementView.as_view(), name='客服批量改商品抢单要求'),
path('kefuhqdslb', KefuGetDashouListView.as_view(), name='客服获取打手数据'),
path('kefuhqdsxq', KefuGetDashouDetailView.as_view(), name='客服获取打手具体信息'),
path('kefuxgds', KefuUpdateDashouView.as_view(), name='客服修改打手数据'),

View File

@@ -123,6 +123,11 @@ from .kefu_orders import (
KefuTransferHallView,
)
from .kefu_grab_requirement import (
KefuBatchProductGrabRequirementView,
KefuUpdateOrderGrabRequirementView,
)
from .kefu_dashou import (
KefuGetDashouDetailView,
KefuGetDashouListView,

View File

@@ -0,0 +1,306 @@
"""客服改抢单要求(会员/押金门槛)— 隔离新接口,不影响旧抢单/支付主路径。"""
import json
import logging
from decimal import Decimal, InvalidOperation
from django.db import transaction
from rest_framework.permissions import IsAuthenticated
from rest_framework.parsers import JSONParser
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import verify_kefu_permission, has_perm_code
from jituan.constants import (
CLUB_ID_DEFAULT,
PERM_GRAB_REQUIREMENT_EDIT,
)
from jituan.models import AdminAuditLog
from jituan.services.club_context import resolve_club_id_from_request
from orders.models import Order
from products.models import Huiyuan, Shangpin
logger = logging.getLogger(__name__)
ALLOWED_ORDER_STATUSES = frozenset({1, 7})
GRAB_TYPE_MEMBER = 1
GRAB_TYPE_DEPOSIT = 2
def _client_ip(request):
xff = (request.META.get('HTTP_X_FORWARDED_FOR') or '').split(',')[0].strip()
return xff or (request.META.get('REMOTE_ADDR') or '')[:64]
def _normalize_club_id(club_id):
cid = (club_id or '').strip()
return cid or CLUB_ID_DEFAULT
def _require_002ad(request):
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
kefu, permissions = verify_kefu_permission(request, phone or None)
if kefu is None:
return None, None, permissions
if not has_perm_code(permissions, PERM_GRAB_REQUIREMENT_EDIT):
return None, None, Response({
'code': 403,
'msg': f'您无权修改抢单要求(需要 {PERM_GRAB_REQUIREMENT_EDIT}',
})
return kefu, permissions, None
def _parse_grab_payload(data):
"""返回 (yaoqiuleixing, huiyuan_id, yongjin, error_response)。"""
try:
yaoqiu = int(data.get('yaoqiuleixing'))
except (TypeError, ValueError):
return None, None, None, Response({'code': 400, 'msg': '抢单要求类型无效须为1会员或2押金'})
if yaoqiu not in (GRAB_TYPE_MEMBER, GRAB_TYPE_DEPOSIT):
return None, None, None, Response({'code': 400, 'msg': '抢单要求类型无效须为1会员或2押金'})
if yaoqiu == GRAB_TYPE_MEMBER:
huiyuan_id = (data.get('huiyuan_id') or '').strip()
if not huiyuan_id:
return None, None, None, Response({'code': 400, 'msg': '会员要求须填写会员ID'})
if len(huiyuan_id) > 6:
return None, None, None, Response({'code': 400, 'msg': '会员ID过长'})
if not Huiyuan.query.filter(huiyuan_id=huiyuan_id).exists():
return None, None, None, Response({'code': 400, 'msg': f'会员ID不存在{huiyuan_id}'})
return yaoqiu, huiyuan_id, None, None
raw = data.get('yongjin', data.get('yajin'))
if raw is None or raw == '':
return None, None, None, Response({'code': 400, 'msg': '押金要求须填写押金金额'})
try:
yongjin = Decimal(str(raw)).quantize(Decimal('0.01'))
except (InvalidOperation, ValueError, TypeError):
return None, None, None, Response({'code': 400, 'msg': '押金金额格式错误'})
if yongjin < 0:
return None, None, None, Response({'code': 400, 'msg': '押金金额不能为负'})
return yaoqiu, None, yongjin, None
def _snapshot_order(order):
return {
'yaoqiuleixing': order.GrabRequirement,
'huiyuan_id': order.MembershipID or '',
'yongjin': str(order.CommissionReq) if order.CommissionReq is not None else None,
}
def _snapshot_product_fields(yaoqiu, huiyuan_id, yongjin):
return {
'yaoqiuleixing': yaoqiu,
'huiyuan_id': huiyuan_id or '',
'yongjin': str(yongjin) if yongjin is not None else None,
}
def _write_audit(*, request, club_id, action, resource_type, resource_id, before, after, remark=''):
try:
op = getattr(request.user, 'UserUID', None) or getattr(request.user, 'Phone', '') or ''
AdminAuditLog.query.create(
club_id=_normalize_club_id(club_id),
operator_yonghuid=str(op)[:16],
action=action,
resource_type=resource_type,
resource_id=str(resource_id)[:64],
field_name='yaoqiuleixing,huiyuan_id,yongjin',
value_before=json.dumps(before, ensure_ascii=False)[:2000],
value_after=json.dumps(after, ensure_ascii=False)[:2000],
request_ip=_client_ip(request),
remark=(remark or '')[:500],
)
except Exception as e:
logger.warning('抢单要求审计写入失败: %s', e)
class KefuUpdateOrderGrabRequirementView(APIView):
"""
POST /yonghu/kefu_gai_qiangdan_yaoqiu
改单笔平台订单抢单要求。权限 002ad按俱乐部闸门各管各的仅状态 1/7。
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, _permissions, err = _require_002ad(request)
if err:
return err
dingdan_id = (request.data.get('dingdan_id') or '').strip()
if not dingdan_id:
return Response({'code': 400, 'msg': '缺少订单ID'})
yaoqiu, huiyuan_id, yongjin, perr = _parse_grab_payload(request.data)
if perr:
return perr
try:
with transaction.atomic():
order = (
Order.query.select_for_update()
.filter(OrderID=dingdan_id)
.first()
)
if not order:
return Response({'code': 404, 'msg': '订单不存在'})
from jituan.services.club_user_access import forbid_if_order_mutate_out_of_scope
scope_deny = forbid_if_order_mutate_out_of_scope(request, order)
if scope_deny:
return scope_deny
order_club = _normalize_club_id(getattr(order, 'ClubID', None))
if int(order.Platform or 0) != 1:
return Response({'code': 400, 'msg': '仅平台订单可修改抢单要求'})
if int(order.Status or 0) not in ALLOWED_ORDER_STATUSES:
return Response({
'code': 400,
'msg': '仅下单中(1)/指定中(7)的订单可修改抢单要求',
})
before = _snapshot_order(order)
order.GrabRequirement = yaoqiu
if yaoqiu == GRAB_TYPE_MEMBER:
order.MembershipID = huiyuan_id
order.CommissionReq = None
else:
order.MembershipID = None
order.CommissionReq = yongjin
order.save(update_fields=[
'GrabRequirement', 'MembershipID', 'CommissionReq', 'UpdateTime',
])
# 并发接单后状态可能已变:再读一次,若已不在允许状态则回滚
order.refresh_from_db()
if int(order.Status or 0) not in ALLOWED_ORDER_STATUSES:
raise RuntimeError('ORDER_STATUS_CHANGED')
after = _snapshot_order(order)
phone = getattr(request.user, 'Phone', '') or ''
_write_audit(
request=request,
club_id=order_club,
action='grab_requirement_order',
resource_type='order',
resource_id=dingdan_id,
before=before,
after=after,
remark=f'客服{phone}改平台订单抢单要求',
)
except RuntimeError as e:
if str(e) == 'ORDER_STATUS_CHANGED':
return Response({
'code': 409,
'msg': '订单状态已变更,无法修改抢单要求,请刷新后重试',
})
raise
except Exception:
logger.exception('改订单抢单要求失败 dingdan_id=%s', dingdan_id)
return Response({'code': 500, 'msg': '修改失败'})
return Response({
'code': 0,
'msg': '修改成功',
'data': {
'dingdan_id': dingdan_id,
'yaoqiuleixing': yaoqiu,
'huiyuan_id': huiyuan_id or '',
'yajin': float(yongjin) if yongjin is not None else None,
},
})
class KefuBatchProductGrabRequirementView(APIView):
"""
POST /yonghu/kefu_batch_qiangdan_yaoqiu
预览/确认:批量改本店某商品类型下 Shangpin 的抢单要求字段。
confirm 缺省/false 只预览true 才写库。不碰全局 ShangpinLeixing。
须切换到具体俱乐部(不跨店)。
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, _permissions, err = _require_002ad(request)
if err:
return err
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
if resolve_club_scope(request) == DATA_SCOPE_ALL:
return Response({
'code': 403,
'msg': '请切换到具体俱乐部后再批量修改商品抢单要求',
})
club_id = _normalize_club_id(resolve_club_id_from_request(request))
try:
leixing_id = int(request.data.get('leixing_id'))
except (TypeError, ValueError):
return Response({'code': 400, 'msg': '缺少或无效的商品类型ID'})
yaoqiu, huiyuan_id, yongjin, perr = _parse_grab_payload(request.data)
if perr:
return perr
confirm = request.data.get('confirm') in (True, 'true', '1', 1, 'on')
qs = Shangpin.query.filter(club_id=club_id, leixing_id=leixing_id)
total = qs.count()
samples = list(qs.order_by('id').values(
'id', 'biaoqian', 'yaoqiuleixing', 'huiyuan_id', 'yongjin',
)[:5])
for row in samples:
if row.get('yongjin') is not None:
row['yongjin'] = float(row['yongjin'])
preview = {
'club_id': club_id,
'leixing_id': leixing_id,
'affected_count': total,
'samples': samples,
'will_set': _snapshot_product_fields(yaoqiu, huiyuan_id, yongjin),
}
if not confirm:
return Response({'code': 0, 'msg': '预览成功(未写入)', 'data': preview})
if total == 0:
return Response({'code': 400, 'msg': '该俱乐部下该类型暂无商品可改'})
updates = {
'yaoqiuleixing': yaoqiu,
'huiyuan_id': huiyuan_id if yaoqiu == GRAB_TYPE_MEMBER else None,
'yongjin': yongjin if yaoqiu == GRAB_TYPE_DEPOSIT else None,
}
try:
with transaction.atomic():
updated = Shangpin.query.filter(
club_id=club_id, leixing_id=leixing_id,
).update(**updates)
phone = getattr(request.user, 'Phone', '') or ''
_write_audit(
request=request,
club_id=club_id,
action='grab_requirement_product_batch',
resource_type='shangpin_leixing',
resource_id=f'{club_id}:{leixing_id}',
before={'affected_count': total, 'samples': samples},
after={**updates, 'updated_count': updated},
remark=f'客服{phone}批量改商品抢单要求 count={updated}',
)
except Exception:
logger.exception(
'批量改商品抢单要求失败 club=%s leixing=%s', club_id, leixing_id,
)
return Response({'code': 500, 'msg': '批量修改失败'})
preview['updated_count'] = updated
return Response({'code': 0, 'msg': f'已更新 {updated} 个商品', 'data': preview})

View File

@@ -275,7 +275,10 @@ class KefuGetOrderListView(APIView):
'jiage': float(order.Amount) if order.Amount else 0.00,
'tupian_url': order.ImageURL or '',
'fadanshijian': order.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if order.CreateTime else '',
'youxi_nicheng': youxi_nicheng
'youxi_nicheng': youxi_nicheng,
'yaoqiuleixing': order.GrabRequirement,
'huiyuan_id': order.MembershipID or '',
'yajin': float(order.CommissionReq) if order.CommissionReq is not None else None,
}
_attach_order_club_fields(item, order, club_names)
item.update(order_auto_settle_payload(order))
@@ -1622,6 +1625,9 @@ class KefuGetOrderDetailView(APIView):
'clkf': dingdan_obj.AssignedCS or '',
'chuangjianshijian': dingdan_obj.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if dingdan_obj.CreateTime else '',
'genggaishijian': dingdan_obj.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if dingdan_obj.UpdateTime else '',
'yaoqiuleixing': dingdan_obj.GrabRequirement,
'huiyuan_id': dingdan_obj.MembershipID or '',
'yajin': float(dingdan_obj.CommissionReq) if dingdan_obj.CommissionReq is not None else None,
}
except Exception as e:
logger.exception(f"[kefuhqddxq] 构建基础数据异常 dingdan_id={dingdan_id}")