修复了商家客服接口添加客服操作专属接口,其次商家看日志接口

This commit is contained in:
XingQue
2026-06-20 03:54:14 +08:00
parent 1d4d054c75
commit 470546ae82
6 changed files with 459 additions and 24 deletions

View File

@@ -86,3 +86,40 @@ SYSTEM_ROLE_TEMPLATES = [
]
OWNER_ALL_PERMISSIONS = [code for code, _ in PERMISSION_DEFINITIONS]
# 操作审计:仅展示「重要行为」,不含查看类
IMPORTANT_AUDIT_ACTIONS = frozenset([
'DISPATCH', 'LINK_GENERATE', 'SETTLE', 'CANCEL', 'REFUND_APPLY',
'CHANGE_PLAYER', 'PENALTY_APPLY', 'PENALTY_MANAGE',
'TEMPLATE_CREATE', 'TEMPLATE_UPDATE', 'TEMPLATE_DELETE',
'WALLET_ALLOCATE', 'INVITE_CREATE', 'INVITE_REVOKE',
'MEMBER_UPDATE_ROLE', 'MEMBER_REMOVE', 'MEMBER_DISABLE', 'MEMBER_ENABLE',
'ROLE_CREATE', 'ROLE_SET_PERMS', 'MEMBER_SET_PERMS',
'STAFF_BIND',
])
AUDIT_ACTION_LABELS = {
'DISPATCH': '派单发单',
'LINK_GENERATE': '生成链接',
'SETTLE': '结单',
'CANCEL': '撤销订单',
'REFUND_APPLY': '申请退款',
'CHANGE_PLAYER': '更换打手',
'PENALTY_APPLY': '申请罚单',
'PENALTY_MANAGE': '改撤罚单',
'TEMPLATE_CREATE': '新增模板',
'TEMPLATE_UPDATE': '修改模板',
'TEMPLATE_DELETE': '删除模板',
'WALLET_ALLOCATE': '划额度',
'INVITE_CREATE': '生成邀请码',
'INVITE_REVOKE': '作废邀请码',
'MEMBER_UPDATE_ROLE': '更换客服角色',
'MEMBER_REMOVE': '移除客服',
'MEMBER_DISABLE': '禁用客服',
'MEMBER_ENABLE': '启用客服',
'ROLE_CREATE': '创建角色',
'ROLE_SET_PERMS': '设置角色权限',
'MEMBER_SET_PERMS': '调整成员权限',
'STAFF_BIND': '客服绑定入驻',
'ORDER_DETAIL': '查看订单详情',
}

View File

@@ -1,8 +1,8 @@
"""操作审计。"""
from decimal import Decimal
from merchant_ops.constants import ACTOR_MERCHANT, ACTOR_STAFF
from merchant_ops.models import MerchantStaffAuditLog
from merchant_ops.constants import ACTOR_MERCHANT, ACTOR_STAFF, AUDIT_ACTION_LABELS
from merchant_ops.models import MerchantStaffAuditLog, MerchantStaffMember
def write_audit(
@@ -64,3 +64,38 @@ def _client_ip(request):
if xff:
return xff.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '') or ''
def audit_action_label(action):
return AUDIT_ACTION_LABELS.get(action, action)
def serialize_audit_row(row, member_map=None):
"""格式化审计记录供前端展示。"""
member_map = member_map or {}
label = audit_action_label(row.action)
operator_name = '商家老板'
role_name = ''
if row.operator_type == ACTOR_STAFF and row.staff_member_id:
m = member_map.get(row.staff_member_id)
if m:
operator_name = m.display_name or m.staff_user_id
role_name = m.role.role_name if m.role else ''
else:
operator_name = row.operator_user_id
elif row.operator_type == ACTOR_MERCHANT:
operator_name = '商家老板'
return {
'id': row.id,
'operator_user_id': row.operator_user_id,
'operator_type': row.operator_type,
'operator_name': operator_name,
'role_name': role_name,
'action': row.action,
'action_label': label,
'resource_type': row.resource_type,
'resource_id': row.resource_id,
'amount': str(row.amount) if row.amount is not None else '',
'remark': row.remark,
'time': row.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if row.CreateTime else '',
}

View File

@@ -0,0 +1,330 @@
"""子客服专用:极速派单链接生成 / 列表(独立实现,不修改商家原接口)。"""
import hashlib
import logging
import os
import random
import secrets
import time
from decimal import Decimal
from django.conf import settings
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import F
from django.utils import timezone
from backend.utils import update_shangjia_daily
from config.models import ShangjiaLianjie, ShangjiaMoban
from merchant_ops.constants import ACTOR_STAFF, STAT_ACTION_DISPATCH
from merchant_ops.models import MerchantOrderDispatch, MerchantStaffMember
from merchant_ops.services.stats import update_staff_daily
from merchant_ops.services.wallet import check_wallet_available, confirm_dispatch_deduct
from orders.models import MerchantOrderExt, Order
from products.models import ShangpinLeixing
from rank.models import Chenghao, DingdanBiaoqian
from users.models import CommissionRate, UserShangjia
from users.business_models import User
logger = logging.getLogger(__name__)
def _generate_dingdan_id():
ts = int(time.time() * 1000)
pid = os.getpid() % 1000
rnd = random.randint(1000, 9999)
return f"SJ{ts}{pid:03d}{rnd}"
def _generate_secure_token(dd_id, uid):
timestamp = int(time.time() * 1_000_000)
salt = secrets.token_hex(8)
raw = f"{timestamp}:{dd_id}:{uid}:{salt}"
token = hashlib.sha256(raw.encode()).hexdigest()[:24]
if ShangjiaLianjie.query.filter(LinkToken=token).exists():
return _generate_secure_token(dd_id, uid)
return token
def _generate_link_url(token):
base = getattr(settings, 'H5_DOMAIN', 'https://h5.yourdomain.com').rstrip('/')
return f"{base}/order/{token}"
def _staff_operator_payload(member):
role = member.role
return {
'operatorType': ACTOR_STAFF,
'staffMemberId': member.id,
'staffUserId': member.staff_user_id,
'roleCode': role.role_code if role else '',
'roleName': role.role_name if role else '',
'displayName': member.display_name or '',
}
def staff_generate_link(member, staff_user, data):
"""
子客服生成发单链接。
扣商家余额 + 扣客服额度,并写入 merchant_order_dispatch 标记操作人。
"""
shangpin_type_id = data.get('shangpinTypeId')
moban_id = data.get('mobanId')
if not all([shangpin_type_id, moban_id]):
return False, {'code': 400, 'msg': '缺少参数', 'data': {}}
try:
shangpin_type_id = int(shangpin_type_id)
moban_id = int(moban_id)
except (TypeError, ValueError):
return False, {'code': 400, 'msg': '参数格式错误', 'data': {}}
merchant_id = member.merchant_id
try:
shangjia = UserShangjia.query.get(user__UserUID=merchant_id)
except UserShangjia.DoesNotExist:
return False, {'code': 403, 'msg': '所属商家不存在', 'data': {}}
if shangjia.zhuangtai != 1:
return False, {'code': 403, 'msg': '商家状态异常', 'data': {}}
try:
shangpin_leixing = ShangpinLeixing.query.get(id=shangpin_type_id, shenhezhuangtai=1)
except ShangpinLeixing.DoesNotExist:
return False, {'code': 400, 'msg': '商品类型不存在或已禁用', 'data': {}}
try:
moban = ShangjiaMoban.query.get(
id=moban_id, UserID=merchant_id, ProductTypeID=shangpin_type_id,
)
except ShangjiaMoban.DoesNotExist:
return False, {'code': 400, 'msg': '模板不存在', 'data': {}}
jiage = moban.Price
if jiage > shangjia.yue:
return False, {'code': 400, 'msg': f'商家余额不足,当前{shangjia.yue}', 'data': {}}
try:
lilu = float(CommissionRate.query.get(Platform='3').Rate) or 1.0
except CommissionRate.DoesNotExist:
lilu = 1.0
dashou_fencheng = Decimal(str(round(float(jiage) * lilu, 2)))
chenghao_id = moban.TitleID
if moban.CommissionGrabEnabled and moban.CommissionPrice is not None:
final_yaoqiuleixing = 2
final_yongjin = moban.CommissionPrice
final_huiyuan_id = ''
else:
final_yaoqiuleixing = shangpin_leixing.yaoqiuleixing or 1
final_yongjin = None
final_huiyuan_id = shangpin_leixing.huiyuan_id or ''
label_name = ''
if chenghao_id:
ch = Chenghao.query.filter(id=chenghao_id).first()
if ch:
label_name = ch.mingcheng
merchant_user = User.query.filter(UserUID=merchant_id).first()
operator = _staff_operator_payload(member)
try:
with transaction.atomic():
check_wallet_available(member, jiage)
dingdan_id = _generate_dingdan_id()
secure_token = _generate_secure_token(dingdan_id, merchant_id)
dingdan = Order.query.create(
OrderID=dingdan_id,
Status=13,
Platform=2,
Amount=jiage,
PlayerCommission=dashou_fencheng,
ProductTypeID=shangpin_type_id,
GrabRequirement=final_yaoqiuleixing,
MembershipID=final_huiyuan_id,
CommissionReq=final_yongjin,
Description=moban.TemplateDesc[:2000],
User1ID=f"Sj{merchant_id}",
ImageURL=merchant_user.Avatar if merchant_user else '',
)
MerchantOrderExt.query.create(
Order=dingdan,
MerchantID=merchant_id,
MerchantNickname=shangjia.nicheng or f"商家{merchant_id}",
)
if chenghao_id:
ch = Chenghao.query.filter(id=chenghao_id).first()
if ch:
DingdanBiaoqian.query.create(dingdan=dingdan, chenghao=ch)
UserShangjia.query.filter(id=shangjia.id).update(
fabu=F('fabu') + 1,
yue=F('yue') - jiage,
jinridingdan=F('jinridingdan') + 1,
jinriliushui=F('jinriliushui') + jiage,
jinyuedingdan=F('jinyuedingdan') + 1,
jinyueliushui=F('jinyueliushui') + jiage,
)
link_url = _generate_link_url(secure_token)
lianjie_obj = ShangjiaLianjie.query.create(
UserID=merchant_id,
ProductTypeID=shangpin_type_id,
TemplateID=moban_id,
LinkURL=link_url,
LinkToken=secure_token,
OrderID=dingdan_id,
is_used=False,
ExpireTime=timezone.now() + timezone.timedelta(days=1),
)
ShangjiaMoban.query.filter(id=moban_id).update(
PublishedCount=F('PublishedCount') + 1,
)
MerchantOrderDispatch.objects.create(
order_id=dingdan_id,
merchant_id=merchant_id,
dispatch_actor_type=ACTOR_STAFF,
dispatch_actor_id=member.staff_user_id,
staff_member_id=member.id,
)
confirm_dispatch_deduct(member, jiage, dingdan_id, member.staff_user_id)
update_shangjia_daily(merchant_id, Decimal(str(jiage)), STAT_ACTION_DISPATCH)
update_staff_daily(
merchant_id, member.staff_user_id, member.id, jiage, STAT_ACTION_DISPATCH,
)
MerchantStaffMember.query.filter(id=member.id).update(
today_dispatch_count=F('today_dispatch_count') + 1,
today_dispatch_amount=F('today_dispatch_amount') + Decimal(str(jiage)),
)
shangjia.refresh_from_db()
from merchant_ops.services.wallet import get_or_create_wallet
wallet = get_or_create_wallet(member)
quota_available = str(wallet.quota_available)
except ValueError as e:
return False, {'code': 400, 'msg': str(e), 'data': {}}
except Exception as e:
logger.error(f'子客服生成链接失败: {e}', exc_info=True)
return False, {'code': 500, 'msg': '系统错误,生成链接失败', 'data': {}}
logger.info(
f"子客服链接生成 - 订单:{dingdan_id} 客服:{member.staff_user_id} "
f"角色:{operator.get('roleName')}"
)
return True, {
'code': 200,
'msg': '链接生成成功',
'data': {
'linkUrl': link_url,
'newBalance': quota_available,
'quotaAvailable': quota_available,
'dingdanId': dingdan_id,
'dingdanJieshao': moban.TemplateDesc,
'jiage': str(jiage),
'labelId': chenghao_id,
'labelName': label_name,
'youxiaoShijian': lianjie_obj.ExpireTime.strftime('%Y-%m-%d %H:%M:%S'),
**operator,
},
}
def staff_list_links(member, data):
"""子客服链接列表;总管看全部,派单员只看自己生成的。"""
from merchant_ops.services.authz import member_has_perm
moban_id = data.get('mobanId')
used = int(data.get('used', 0))
page = max(1, int(data.get('page', 1)))
page_size = min(50, max(1, int(data.get('pageSize', 5))))
if not moban_id:
return False, {'code': 400, 'msg': '模板ID不能为空', 'data': {}}
merchant_id = member.merchant_id
try:
ShangjiaMoban.query.get(id=moban_id, UserID=merchant_id)
except ShangjiaMoban.DoesNotExist:
return False, {'code': 404, 'msg': '模板不存在或无权查看', 'data': {}}
links = ShangjiaLianjie.query.filter(
UserID=merchant_id,
TemplateID=moban_id,
is_used=(used == 1),
).order_by('-CreateTime')
if not member_has_perm(member, 'order_view_all'):
own_order_ids = MerchantOrderDispatch.query.filter(
merchant_id=merchant_id,
staff_member_id=member.id,
).values_list('order_id', flat=True)
links = links.filter(OrderID__in=own_order_ids)
paginator = Paginator(links, page_size)
if page > paginator.num_pages and paginator.count > 0:
return False, {'code': 400, 'msg': '页码超出范围', 'data': {}}
current_page = paginator.page(page) if paginator.count else paginator.page(1)
order_ids = [lk.OrderID for lk in current_page.object_list if lk.OrderID]
dispatch_map = {}
if order_ids:
for d in MerchantOrderDispatch.query.filter(order_id__in=order_ids):
dispatch_map[d.order_id] = d
member_ids = {d.staff_member_id for d in dispatch_map.values() if d.staff_member_id}
members = {}
if member_ids:
for m in MerchantStaffMember.query.filter(id__in=member_ids).select_related('role'):
members[m.id] = m
data_list = []
for link in current_page.object_list:
item = {
'id': link.id,
'lianjie': link.LinkURL,
'LinkToken': link.LinkToken,
'is_used': link.is_used,
'dingdan_id': link.OrderID,
'ExpireTime': link.ExpireTime.strftime('%Y-%m-%d %H:%M:%S') if link.ExpireTime else '',
'CreateTime': link.CreateTime.strftime('%Y-%m-%d %H:%M:%S'),
'operatorType': 'MERCHANT',
'staffMemberId': None,
'staffUserId': '',
'roleCode': '',
'roleName': '',
'displayName': '商家老板',
}
disp = dispatch_map.get(link.OrderID)
if disp and disp.dispatch_actor_type == ACTOR_STAFF and disp.staff_member_id:
sm = members.get(disp.staff_member_id)
if sm:
item.update({
'operatorType': ACTOR_STAFF,
'staffMemberId': sm.id,
'staffUserId': sm.staff_user_id,
'roleCode': sm.role.role_code if sm.role else '',
'roleName': sm.role.role_name if sm.role else '',
'displayName': sm.display_name or sm.role.role_name if sm.role else '',
})
data_list.append(item)
return True, {
'code': 200,
'msg': '获取成功',
'data': {
'list': data_list,
'total': paginator.count,
'hasMore': current_page.has_next(),
'currentPage': page,
'pageSize': page_size,
'viewScope': 'all' if member_has_perm(member, 'order_view_all') else 'self',
},
}

View File

@@ -369,10 +369,15 @@ class StaffWalletLedgerView(APIView):
class StaffAuditListView(APIView):
"""POST /yonghu/merchant-staff/audit/list — 商家老板或有 audit_view 权限的客服可查看重要操作日志。"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
from merchant_ops.constants import IMPORTANT_AUDIT_ACTIONS
from merchant_ops.services.audit import serialize_audit_row
mid = request.user.UserUID
member = None
if is_merchant_owner(request.user):
@@ -384,23 +389,21 @@ class StaffAuditListView(APIView):
mid = member.merchant_id
page = max(1, int(request.data.get('page', 1)))
page_size = min(50, int(request.data.get('page_size', 20)))
qs = MerchantStaffAuditLog.query.filter(merchant_id=mid)
qs = MerchantStaffAuditLog.query.filter(
merchant_id=mid,
action__in=IMPORTANT_AUDIT_ACTIONS,
)
staff_filter = request.data.get('staff_user_id')
if staff_filter:
qs = qs.filter(operator_user_id=staff_filter)
total = qs.count()
rows = qs.order_by('-CreateTime')[(page - 1) * page_size: page * page_size]
items = [{
'id': r.id,
'operator_user_id': r.operator_user_id,
'operator_type': r.operator_type,
'action': r.action,
'resource_type': r.resource_type,
'resource_id': r.resource_id,
'amount': str(r.amount) if r.amount is not None else '',
'remark': r.remark,
'time': r.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if r.CreateTime else '',
} for r in rows]
rows = list(qs.order_by('-CreateTime')[(page - 1) * page_size: page * page_size])
member_ids = {r.staff_member_id for r in rows if r.staff_member_id}
member_map = {}
if member_ids:
for m in MerchantStaffMember.query.filter(id__in=member_ids).select_related('role'):
member_map[m.id] = m
items = [serialize_audit_row(r, member_map) for r in rows]
return Response({'code': 0, 'msg': '成功', 'data': {
'list': items, 'total': total, 'has_more': page * page_size < total,
}})

View File

@@ -105,8 +105,6 @@ class StaffOrderDetailView(APIView):
resp = proxy_merchant_order_view(
request, member, ShangjiaDingdanXiangqingView, 'order_detail',
)
if resp.status_code == 200 and resp.data.get('code') in (0, 200):
audit_staff(member, 'ORDER_DETAIL', request, resource_id=pick_order_id(request.data))
return resp
except Exception as e:
return _err(e)

View File

@@ -1,4 +1,5 @@
"""子客服专用:派单模板 / 链接(独立路径;读模板用 merchant_id写模板需 template_manage"""
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -7,12 +8,12 @@ from config.views import (
ShangjiaTianjiaMobanView,
ShangjiaGengxinMobanView,
ShangjiaShanchuMobanView,
ShangjiaGenerateLinkView,
ShangjiaLianjieListView,
)
from merchant_ops.helpers import proxy_as_merchant_user
from merchant_ops.services.authz import StaffAuthError, get_active_staff_member, member_has_perm
from merchant_ops.services.template_query import list_merchant_templates
from merchant_ops.services.link import staff_generate_link, staff_list_links
from merchant_ops.services.audit import audit_staff
from merchant_ops.views_manage import _err
@@ -52,7 +53,11 @@ class StaffTemplateCreateView(APIView):
member = _require_staff(request)
if not member_has_perm(member, 'template_manage'):
raise StaffAuthError(403, '无权限添加模板')
return proxy_as_merchant_user(request, member, ShangjiaTianjiaMobanView, 'template_manage')
resp = proxy_as_merchant_user(request, member, ShangjiaTianjiaMobanView, 'template_manage')
if resp.status_code == 200 and resp.data.get('code') in (0, 200):
audit_staff(member, 'TEMPLATE_CREATE', request,
resource_id=(resp.data.get('data') or {}).get('id', ''))
return resp
except Exception as e:
return _err(e)
@@ -65,7 +70,11 @@ class StaffTemplateUpdateView(APIView):
member = _require_staff(request)
if not member_has_perm(member, 'template_manage'):
raise StaffAuthError(403, '无权限修改模板')
return proxy_as_merchant_user(request, member, ShangjiaGengxinMobanView, 'template_manage')
moban_id = request.data.get('mobanId') or request.data.get('id') or ''
resp = proxy_as_merchant_user(request, member, ShangjiaGengxinMobanView, 'template_manage')
if resp.status_code == 200 and resp.data.get('code') in (0, 200):
audit_staff(member, 'TEMPLATE_UPDATE', request, resource_id=str(moban_id))
return resp
except Exception as e:
return _err(e)
@@ -78,28 +87,51 @@ class StaffTemplateDeleteView(APIView):
member = _require_staff(request)
if not member_has_perm(member, 'template_manage'):
raise StaffAuthError(403, '无权限删除模板')
return proxy_as_merchant_user(request, member, ShangjiaShanchuMobanView, 'template_manage')
moban_id = request.data.get('mobanId') or request.data.get('id') or ''
resp = proxy_as_merchant_user(request, member, ShangjiaShanchuMobanView, 'template_manage')
if resp.status_code == 200 and resp.data.get('code') in (0, 200):
audit_staff(member, 'TEMPLATE_DELETE', request, resource_id=str(moban_id))
return resp
except Exception as e:
return _err(e)
class StaffLinkGenerateView(APIView):
"""POST /yonghu/merchant-staff/link/generate — 子客服独立实现,标记操作人身份。"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
return proxy_as_merchant_user(request, member, ShangjiaGenerateLinkView, 'order_dispatch')
if not member_has_perm(member, 'order_dispatch'):
raise StaffAuthError(403, '无派单/生成链接权限')
ok, payload = staff_generate_link(member, request.user, request.data)
if ok:
audit_staff(
member, 'LINK_GENERATE', request,
resource_id=payload.get('data', {}).get('dingdanId'),
amount=payload.get('data', {}).get('jiage'),
remark=f"{member.role.role_name}:{member.display_name or member.staff_user_id}",
)
code = payload.get('code', 500)
return Response(payload, status=status.HTTP_200_OK if code in (0, 200) else status.HTTP_400_BAD_REQUEST)
except Exception as e:
return _err(e)
class StaffLinkListView(APIView):
"""POST /yonghu/merchant-staff/link/list — 子客服链接列表,含操作人身份。"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
return proxy_as_merchant_user(request, member, ShangjiaLianjieListView, 'order_dispatch')
if not member_has_perm(member, 'order_dispatch'):
raise StaffAuthError(403, '无权限')
ok, payload = staff_list_links(member, request.data)
code = payload.get('code', 500)
return Response(payload, status=status.HTTP_200_OK if code in (0, 200) else status.HTTP_400_BAD_REQUEST)
except Exception as e:
return _err(e)