feat: 商家子客服 merchant_ops 模块与专用 API

This commit is contained in:
XingQue
2026-06-20 02:19:39 +08:00
parent e233ee4bea
commit 0cf5341d73
25 changed files with 2091 additions and 7 deletions

View File

View File

@@ -0,0 +1,66 @@
"""操作审计。"""
from decimal import Decimal
from merchant_ops.constants import ACTOR_MERCHANT, ACTOR_STAFF
from merchant_ops.models import MerchantStaffAuditLog
def write_audit(
merchant_id,
operator_user_id,
operator_type,
action,
*,
staff_member_id=None,
resource_type='',
resource_id='',
value_before=None,
value_after=None,
amount=None,
request_ip='',
remark='',
):
MerchantStaffAuditLog.objects.create(
merchant_id=merchant_id,
operator_user_id=operator_user_id,
operator_type=operator_type,
staff_member_id=staff_member_id,
action=action,
resource_type=resource_type,
resource_id=str(resource_id) if resource_id else '',
value_before=value_before,
value_after=value_after,
amount=Decimal(str(amount)) if amount is not None else None,
request_ip=request_ip or '',
remark=remark or '',
)
def audit_staff(member, action, request, **kwargs):
write_audit(
member.merchant_id,
member.staff_user_id,
ACTOR_STAFF,
action,
staff_member_id=member.id,
request_ip=_client_ip(request),
**kwargs,
)
def audit_owner(merchant_id, operator_id, action, request, **kwargs):
write_audit(
merchant_id,
operator_id,
ACTOR_MERCHANT,
action,
request_ip=_client_ip(request),
**kwargs,
)
def _client_ip(request):
xff = request.META.get('HTTP_X_FORWARDED_FOR')
if xff:
return xff.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '') or ''

View File

@@ -0,0 +1,126 @@
"""权限校验与成员解析。"""
from rest_framework.exceptions import PermissionDenied, NotFound
from merchant_ops.constants import (
MEMBER_STATUS_ACTIVE, MEMBER_STATUS_REMOVED, OWNER_ALL_PERMISSIONS,
)
from merchant_ops.models import MerchantStaffMember, MerchantStaffMemberPermission
from merchant_ops.services.bootstrap import ensure_merchant_roles
from users.models import UserShangjia
class StaffAuthError(Exception):
def __init__(self, code, msg):
self.code = code
self.msg = msg
super().__init__(msg)
def is_merchant_owner(user):
try:
profile = user.ShopProfile
return profile.zhuangtai == 1
except UserShangjia.DoesNotExist:
return False
def get_active_staff_member(user):
return MerchantStaffMember.query.filter(
staff_user_id=user.UserUID,
status=MEMBER_STATUS_ACTIVE,
).select_related('role').first()
def get_member_permissions(member):
"""角色权限 + 成员覆盖。"""
perms = set(
member.role.role_permissions.values_list('perm_code', flat=True)
)
for mp in member.member_permissions.all():
if mp.granted:
perms.add(mp.perm_code)
else:
perms.discard(mp.perm_code)
return perms
def member_has_perm(member, perm_code):
return perm_code in get_member_permissions(member)
def assert_staff_perm(member, perm_code):
if not member_has_perm(member, perm_code):
raise StaffAuthError(403, f'无权限: {perm_code}')
def resolve_staff_context(user):
"""子客服上下文;非客服返回 None。"""
member = get_active_staff_member(user)
if not member:
return None
wallet = member.wallets.first()
perms = list(get_member_permissions(member))
return {
'active': True,
'merchant_id': member.merchant_id,
'member_id': member.id,
'role_id': member.role_id,
'role_code': member.role.role_code,
'role_name': member.role.role_name,
'display_name': member.display_name or '',
'permissions': perms,
'wallet': {
'quota_total': str(wallet.quota_total if wallet else 0),
'quota_used': str(wallet.quota_used if wallet else 0),
'quota_frozen': str(wallet.quota_frozen if wallet else 0),
'quota_available': str(wallet.quota_available if wallet else 0),
},
}
def resolve_owner_or_staff(request, perm_code=None):
"""
返回 (mode, merchant_id, member, operator_id)
mode: 'owner' | 'staff'
"""
user = request.user
if is_merchant_owner(user):
return 'owner', user.UserUID, None, user.UserUID
member = get_active_staff_member(user)
if not member:
raise StaffAuthError(403, '非商家老板或有效子客服')
if perm_code:
assert_staff_perm(member, perm_code)
return 'staff', member.merchant_id, member, user.UserUID
def assert_not_merchant_when_bind(user):
if is_merchant_owner(user):
raise StaffAuthError(403, '您已是商家,无法成为子客服')
def assert_no_active_staff_when_merchant_register(user):
if MerchantStaffMember.query.filter(
staff_user_id=user.UserUID, status=MEMBER_STATUS_ACTIVE
).exists():
raise StaffAuthError(403, '您当前是商家客服,请先联系商家解除后再认证商家')
def assert_can_bind_staff(user):
assert_not_merchant_when_bind(user)
if MerchantStaffMember.query.filter(
staff_user_id=user.UserUID, status=MEMBER_STATUS_ACTIVE
).exists():
raise StaffAuthError(403, '您已绑定商家客服,无法重复绑定')
def get_owner_permissions():
return list(OWNER_ALL_PERMISSIONS)
def ensure_owner_merchant(merchant_id, user):
if user.UserUID != merchant_id or not is_merchant_owner(user):
raise StaffAuthError(403, '仅商家老板可操作')
ensure_merchant_roles(merchant_id)

View File

@@ -0,0 +1,56 @@
"""初始化权限码与商家默认角色。"""
from merchant_ops.constants import CLUB_ID_DEFAULT, PERMISSION_DEFINITIONS, SYSTEM_ROLE_TEMPLATES
from merchant_ops.models import (
MerchantStaffPermission, MerchantStaffRole, MerchantStaffRolePermission,
)
def seed_global_permissions():
for idx, (code, name) in enumerate(PERMISSION_DEFINITIONS):
MerchantStaffPermission.objects.get_or_create(
perm_code=code,
defaults={'perm_name': name, 'sort_order': idx, 'status': 1},
)
def ensure_system_role_templates():
seed_global_permissions()
for tpl in SYSTEM_ROLE_TEMPLATES:
role, _ = MerchantStaffRole.objects.get_or_create(
merchant_id=None,
role_code=tpl['role_code'],
defaults={
'club_id': CLUB_ID_DEFAULT,
'role_name': tpl['role_name'],
'is_system': True,
'sort_order': tpl['sort_order'],
'status': 1,
},
)
for perm in tpl['permissions']:
MerchantStaffRolePermission.objects.get_or_create(
role=role, perm_code=perm,
)
def ensure_merchant_roles(merchant_id):
"""为商家复制系统角色模板(若尚未存在)。"""
ensure_system_role_templates()
templates = MerchantStaffRole.query.filter(merchant_id=None, status=1)
for tpl in templates:
role, created = MerchantStaffRole.objects.get_or_create(
merchant_id=merchant_id,
role_code=tpl.role_code,
defaults={
'club_id': CLUB_ID_DEFAULT,
'role_name': tpl.role_name,
'is_system': True,
'sort_order': tpl.sort_order,
'status': 1,
},
)
if created:
for rp in tpl.role_permissions.all():
MerchantStaffRolePermission.objects.get_or_create(
role=role, perm_code=rp.perm_code,
)

View File

@@ -0,0 +1,211 @@
"""子客服派单(独立实现,不修改商家派单接口)。"""
import logging
import os
import random
import time
from decimal import Decimal
from django.db import transaction
from django.db.models import F
from backend.utils import update_shangjia_daily
from merchant_ops.constants import ACTOR_STAFF, STAT_ACTION_DISPATCH
from merchant_ops.models import MerchantOrderDispatch
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 orders.notice_tasks import dingdan_guangbo
from orders.utils import calc_shangjia_order_fencheng
from products.models import ShangpinLeixing
from rank.models import Chenghao, DingdanBiaoqian
from users.models import UserShangjia, UserDashou
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 staff_dispatch_order(member, staff_user, data):
"""
子客服发单。返回 (success, payload_dict)
payload_dict: code/msg/data 或 dingdan
"""
shangpin_type_id = data.get('shangpinTypeId')
huiyuan_id = data.get('huiyuanId')
dingdan_jieshao = str(data.get('dingdanJieshao', '')).strip()
dingdan_beizhu = str(data.get('dingdanBeizhu', '')).strip()
laoban_name = str(data.get('laobanName', '')).strip()
zhiding_uid = str(data.get('zhidingUid', '')).strip()
jiage_str = data.get('jiage')
label_id = data.get('labelId')
commission_enabled = data.get('commissionEnabled', False)
commission_value_raw = data.get('commissionValue')
commission_value_str = str(commission_value_raw).strip() if commission_value_raw is not None else ''
if not all([shangpin_type_id, dingdan_jieshao, laoban_name, jiage_str]):
return False, {'code': 400, 'msg': '缺少必填参数', 'data': None}
try:
jiage = float(jiage_str)
if jiage < 1 or jiage > 10000:
return False, {'code': 400, 'msg': '价格需在1-10000元之间', 'data': None}
except ValueError:
return False, {'code': 400, 'msg': '价格格式错误', 'data': None}
try:
shangpin_type = ShangpinLeixing.query.get(id=shangpin_type_id)
except ShangpinLeixing.DoesNotExist:
return False, {'code': 400, 'msg': '商品类型不存在', 'data': None}
if commission_enabled and commission_value_str:
try:
cv = float(commission_value_str)
if cv > 0:
final_yaoqiu_type = 2
final_yongjin = Decimal(str(cv))
final_huiyuan_id = ''
else:
final_yaoqiu_type = shangpin_type.yaoqiuleixing or 1
final_huiyuan_id = str(huiyuan_id) if huiyuan_id else ''
except ValueError:
final_yaoqiu_type = shangpin_type.yaoqiuleixing or 1
final_huiyuan_id = str(huiyuan_id) if huiyuan_id else ''
else:
final_yaoqiu_type = shangpin_type.yaoqiuleixing or 1
final_huiyuan_id = str(huiyuan_id) if huiyuan_id else ''
chenghao_obj = None
label_id_str = str(label_id).strip() if label_id is not None else ''
if label_id_str and label_id_str not in ('0', 'null', 'undefined'):
try:
lid = int(label_id_str)
if lid > 0:
chenghao_obj = Chenghao.query.filter(id=lid).first()
except (ValueError, TypeError):
pass
merchant_id = member.merchant_id
try:
shangjia = UserShangjia.query.get(user__UserUID=merchant_id)
except UserShangjia.DoesNotExist:
return False, {'code': 403, 'msg': '所属商家不存在', 'data': None}
if shangjia.zhuangtai != 1:
return False, {'code': 403, 'msg': '商家状态异常', 'data': None}
if jiage > float(shangjia.yue):
return False, {'code': 400, 'msg': f'商家余额不足,当前{shangjia.yue}', 'data': None}
zhiding_dashou = None
if zhiding_uid:
try:
zhiding_user = User.query.get(UserUID=zhiding_uid)
zhiding_dashou = zhiding_user.DashouProfile
if zhiding_dashou.zhanghaozhuangtai != 1:
return False, {'code': 400, 'msg': '指定打手状态异常', 'data': None}
except User.DoesNotExist:
return False, {'code': 400, 'msg': '指定用户不存在', 'data': None}
except UserDashou.DoesNotExist:
return False, {'code': 400, 'msg': '指定用户不是打手', 'data': None}
dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage)
merchant_user = User.query.get(UserUID=merchant_id)
dingdan = None
try:
with transaction.atomic():
check_wallet_available(member, jiage)
dingdan = Order.query.create(
OrderID=_generate_dingdan_id(),
Status=7 if zhiding_dashou else 1,
Platform=2,
Amount=jiage,
PlayerCommission=dashou_fencheng,
ManagerCommission=guanshi_fencheng,
AssignedID=zhiding_uid if zhiding_dashou else '',
ProductID=0,
ProductTypeID=int(shangpin_type_id),
GrabRequirement=final_yaoqiu_type,
CommissionReq=final_yongjin,
MembershipID=final_huiyuan_id,
Description=dingdan_jieshao[:2000],
Remark=dingdan_beizhu[:1000],
Nickname=laoban_name[:50],
ImageURL=merchant_user.Avatar if merchant_user else '',
User1ID=f"Sj{merchant_id}",
)
MerchantOrderExt.query.create(
Order=dingdan,
MerchantID=merchant_id,
MerchantNickname=shangjia.nicheng,
)
if chenghao_obj:
DingdanBiaoqian.query.create(dingdan=dingdan, chenghao=chenghao_obj)
UserShangjia.query.filter(id=shangjia.id).update(
fabu=F('fabu') + 1,
yue=F('yue') - Decimal(str(jiage)),
jinridingdan=F('jinridingdan') + 1,
jinriliushui=F('jinriliushui') + Decimal(str(jiage)),
jinyuedingdan=F('jinyuedingdan') + 1,
jinyueliushui=F('jinyueliushui') + Decimal(str(jiage)),
)
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,
)
MerchantOrderDispatch.objects.create(
order_id=dingdan.OrderID,
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.OrderID, member.staff_user_id)
from merchant_ops.models import MerchantStaffMember
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)),
)
except ValueError as e:
return False, {'code': 400, 'msg': str(e), 'data': None}
except Exception as e:
logger.error(f'子客服派单失败: {e}', exc_info=True)
return False, {'code': 500, 'msg': '订单创建失败', 'data': None}
try:
from orders.views import ShangjiaPaifaView
view = ShangjiaPaifaView()
game_type = view._get_game_type_name(dingdan.ProductTypeID)
except Exception:
game_type = '游戏订单'
try:
dingdan_guangbo.delay({
'OrderID': dingdan.OrderID,
'game_type': game_type,
'amount': str(dingdan.Amount),
'order_desc': (dingdan.Description or '')[:50],
})
except Exception as e:
logger.error(f'广播任务失败: {e}')
return True, {
'code': 200,
'msg': '派单成功',
'data': {'dingdan_id': dingdan.OrderID, 'jine': str(dingdan.Amount)},
}

View File

@@ -0,0 +1,108 @@
"""子客服日统计 + 商家日统计双写。"""
from datetime import date
from decimal import Decimal
from django.db import transaction
from django.db.models import F
from backend.utils import update_shangjia_daily
from merchant_ops.constants import STAT_ACTION_DISPATCH, STAT_ACTION_REFUND, STAT_ACTION_SETTLE
from merchant_ops.models import MerchantStaffDailyStats
def _action_fields(action, amount):
amount = Decimal(str(amount))
if action == STAT_ACTION_DISPATCH:
return {'dispatch_count': 1, 'dispatch_amount': amount}
if action == STAT_ACTION_SETTLE:
return {'settle_count': 1, 'settle_amount': amount}
if action == STAT_ACTION_REFUND:
return {'refund_count': 1, 'refund_amount': amount}
return {}
def update_staff_daily(merchant_id, staff_user_id, member_id, amount, action):
"""子客服日统计;老板操作不写此表。"""
amount = Decimal(str(amount)) if amount else Decimal('0.00')
fields = _action_fields(action, amount)
if not fields:
return
today = date.today()
with transaction.atomic():
stat, _ = MerchantStaffDailyStats.objects.select_for_update().get_or_create(
merchant_id=merchant_id,
staff_user_id=staff_user_id,
stat_date=today,
defaults={
'club_id': 'xq',
'member_id': member_id,
'year': today.year,
'month': today.month,
'day': today.day,
},
)
update_kwargs = {}
for k, v in fields.items():
if k.endswith('_count'):
update_kwargs[k] = F(k) + int(v)
else:
update_kwargs[k] = F(k) + v
MerchantStaffDailyStats.query.filter(id=stat.id).update(**update_kwargs)
def update_merchant_and_staff_stats(merchant_id, staff_member, amount, action):
"""商家日统计必写;子客服则双写。"""
update_shangjia_daily(merchant_id, amount, action)
if staff_member:
update_staff_daily(
merchant_id,
staff_member.staff_user_id,
staff_member.id,
amount,
action,
)
def bump_cancel_count(merchant_id, staff_member):
if not staff_member:
return
today = date.today()
with transaction.atomic():
stat, _ = MerchantStaffDailyStats.objects.select_for_update().get_or_create(
merchant_id=merchant_id,
staff_user_id=staff_member.staff_user_id,
stat_date=today,
defaults={
'club_id': 'xq',
'member_id': staff_member.id,
'year': today.year,
'month': today.month,
'day': today.day,
},
)
MerchantStaffDailyStats.query.filter(id=stat.id).update(
cancel_count=F('cancel_count') + 1,
)
def bump_penalty_count(merchant_id, staff_member):
if not staff_member:
return
today = date.today()
with transaction.atomic():
stat, _ = MerchantStaffDailyStats.objects.select_for_update().get_or_create(
merchant_id=merchant_id,
staff_user_id=staff_member.staff_user_id,
stat_date=today,
defaults={
'club_id': 'xq',
'member_id': staff_member.id,
'year': today.year,
'month': today.month,
'day': today.day,
},
)
MerchantStaffDailyStats.query.filter(id=stat.id).update(
penalty_count=F('penalty_count') + 1,
)

View File

@@ -0,0 +1,77 @@
"""资金池:划额度、派单扣减。"""
from decimal import Decimal
from django.db import transaction
from django.db.models import F
from merchant_ops.constants import WALLET_ALLOCATE, WALLET_DISPATCH_CONFIRM
from merchant_ops.models import MerchantStaffWallet, MerchantStaffWalletLedger
def get_or_create_wallet(member):
wallet, _ = MerchantStaffWallet.objects.get_or_create(
merchant_id=member.merchant_id,
staff_user_id=member.staff_user_id,
defaults={'member': member},
)
return wallet
def allocate_quota(member, amount, operator_id, remark=''):
amount = Decimal(str(amount))
if amount <= 0:
raise ValueError('划额度必须大于0')
with transaction.atomic():
wallet = MerchantStaffWallet.objects.select_for_update().get(
merchant_id=member.merchant_id,
staff_user_id=member.staff_user_id,
)
wallet.quota_total = F('quota_total') + amount
wallet.save(update_fields=['quota_total'])
wallet.refresh_from_db()
MerchantStaffWalletLedger.objects.create(
wallet=wallet,
merchant_id=member.merchant_id,
staff_user_id=member.staff_user_id,
change_type=WALLET_ALLOCATE,
amount=amount,
balance_after=wallet.quota_available,
operator_id=operator_id,
remark=remark,
)
return wallet
def check_wallet_available(member, amount):
amount = Decimal(str(amount))
wallet = get_or_create_wallet(member)
if amount > wallet.quota_available:
raise ValueError('客服可用额度不足')
def confirm_dispatch_deduct(member, amount, order_id, operator_id):
"""派单成功:扣客服额度。"""
amount = Decimal(str(amount))
with transaction.atomic():
wallet = MerchantStaffWallet.objects.select_for_update().get(
merchant_id=member.merchant_id,
staff_user_id=member.staff_user_id,
)
available = wallet.quota_total - wallet.quota_used - wallet.quota_frozen
if amount > available:
raise ValueError('客服可用额度不足')
wallet.quota_used = F('quota_used') + amount
wallet.save(update_fields=['quota_used'])
wallet.refresh_from_db()
MerchantStaffWalletLedger.objects.create(
wallet=wallet,
merchant_id=member.merchant_id,
staff_user_id=member.staff_user_id,
change_type=WALLET_DISPATCH_CONFIRM,
amount=-amount,
balance_after=wallet.quota_available,
related_order_id=order_id,
operator_id=operator_id,
remark='派单扣额度',
)