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

@@ -40,6 +40,7 @@ INSTALLED_APPS = [
'utils',
'shop',
'rank',
'merchant_ops',
]
MIDDLEWARE = [

0
merchant_ops/__init__.py Normal file
View File

7
merchant_ops/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class MerchantOpsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'merchant_ops'
verbose_name = '商家子客服'

88
merchant_ops/constants.py Normal file
View File

@@ -0,0 +1,88 @@
"""商家子客服权限码与常量(码表冻结,只增不改)。"""
CLUB_ID_DEFAULT = 'xq'
MEMBER_STATUS_ACTIVE = 1
MEMBER_STATUS_DISABLED = 0
MEMBER_STATUS_REMOVED = 2
INVITE_STATUS_UNUSED = 0
INVITE_STATUS_USED = 1
INVITE_STATUS_REVOKED = 2
INVITE_STATUS_EXPIRED = 3
ACTOR_MERCHANT = 'MERCHANT'
ACTOR_STAFF = 'STAFF'
WALLET_ALLOCATE = 'ALLOCATE'
WALLET_DISPATCH_FREEZE = 'DISPATCH_FREEZE'
WALLET_DISPATCH_CONFIRM = 'DISPATCH_CONFIRM'
WALLET_REFUND_RELEASE = 'REFUND_RELEASE'
WALLET_REVOKE = 'REVOKE'
WALLET_ADJUST = 'ADJUST'
STAT_ACTION_DISPATCH = 1
STAT_ACTION_SETTLE = 2
STAT_ACTION_REFUND = 3
# perm_code -> 中文名
PERMISSION_DEFINITIONS = [
('staff_manage', '子客服管理'),
('role_manage', '角色管理'),
('wallet_allocate', '划额度'),
('wallet_view', '查看额度'),
('finance_view', '查看商家余额'),
('order_dispatch', '派单发单'),
('order_view_all', '查看全部订单'),
('order_view_self', '只看自己派的单'),
('order_detail', '订单详情'),
('order_settle', '结单'),
('order_refund_apply', '申请退款'),
('order_cancel', '撤销订单'),
('order_change_player', '换打手'),
('penalty_apply', '申请罚单'),
('penalty_manage', '改撤罚单'),
('penalty_view', '查看罚单'),
('dashou_ban', '封禁解封打手'),
('template_manage', '派单模板'),
('audit_view', '操作日志'),
('rank_view', '客服排行榜'),
('im_chat', '订单IM'),
('stats_view', '统计数据'),
]
# 系统预置角色 -> 默认权限
SYSTEM_ROLE_TEMPLATES = [
{
'role_code': 'CHIEF',
'role_name': '总管',
'sort_order': 100,
'permissions': [
'staff_manage', 'wallet_allocate', 'wallet_view', 'finance_view',
'order_dispatch', 'order_view_all', 'order_detail', 'order_settle',
'order_refund_apply', 'order_cancel', 'order_change_player',
'penalty_apply', 'penalty_manage', 'penalty_view', 'dashou_ban',
'template_manage', 'audit_view', 'rank_view', 'im_chat', 'stats_view',
],
},
{
'role_code': 'FINANCE',
'role_name': '财务',
'sort_order': 80,
'permissions': [
'wallet_allocate', 'wallet_view', 'finance_view', 'order_view_all',
'order_detail', 'penalty_view', 'audit_view', 'rank_view', 'stats_view',
],
},
{
'role_code': 'DISPATCHER',
'role_name': '派单员',
'sort_order': 60,
'permissions': [
'wallet_view', 'order_dispatch', 'order_view_self', 'order_detail',
'rank_view', 'im_chat', 'stats_view',
],
},
]
OWNER_ALL_PERMISSIONS = [code for code, _ in PERMISSION_DEFINITIONS]

52
merchant_ops/helpers.py Normal file
View File

@@ -0,0 +1,52 @@
"""视图辅助:以商家身份代理调用现有订单 View不修改原接口"""
import logging
from backend.utils import pick_order_id
from merchant_ops.services.authz import StaffAuthError, member_has_perm
from orders.models import MerchantOrderExt, MerchantOrderDispatch
from users.business_models import User
logger = logging.getLogger(__name__)
def get_merchant_user(merchant_id):
return User.query.get(UserUID=merchant_id)
def staff_can_access_order(member, order_id, perm_all='order_view_all', perm_self='order_view_self'):
ext = MerchantOrderExt.query.filter(
MerchantID=member.merchant_id,
Order__OrderID=order_id,
).first()
if not ext:
return False, None
if member_has_perm(member, perm_all):
return True, ext
if member_has_perm(member, perm_self):
disp = MerchantOrderDispatch.query.filter(order_id=order_id).first()
if disp and disp.staff_member_id == member.id:
return True, ext
return False, ext
def proxy_merchant_order_view(request, member, view_class, perm_code):
"""子客服通过商家 User 代理调用现有 View.post。"""
if not member_has_perm(member, perm_code):
raise StaffAuthError(403, f'无权限: {perm_code}')
order_id = pick_order_id(request.data)
if order_id:
ok, _ = staff_can_access_order(member, order_id)
if not ok:
raise StaffAuthError(403, '无权操作该订单')
merchant_user = get_merchant_user(member.merchant_id)
old_user = request.user
request.user = merchant_user
request._staff_member = member
try:
return view_class().post(request)
finally:
request.user = old_user
if hasattr(request, '_staff_member'):
delattr(request, '_staff_member')

View File

View File

@@ -0,0 +1,11 @@
from django.core.management.base import BaseCommand
from merchant_ops.services.bootstrap import ensure_system_role_templates
class Command(BaseCommand):
help = '初始化商家子客服权限码与系统角色模板(可重复执行)'
def handle(self, *args, **options):
ensure_system_role_templates()
self.stdout.write(self.style.SUCCESS('merchant_staff 权限与角色模板已就绪'))

View File

@@ -0,0 +1,207 @@
# Generated manually for merchant_ops
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='MerchantStaffPermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('perm_code', models.CharField(max_length=64, unique=True, verbose_name='权限码')),
('perm_name', models.CharField(max_length=64, verbose_name='权限名称')),
('sort_order', models.IntegerField(default=0, verbose_name='排序')),
('status', models.IntegerField(default=1, verbose_name='1启用0停用')),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={'db_table': 'merchant_staff_permission', 'verbose_name': '商家客服权限字典'},
),
migrations.CreateModel(
name='MerchantStaffRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(default='xq', max_length=16, verbose_name='Club')),
('merchant_id', models.CharField(blank=True, db_index=True, max_length=7, null=True, verbose_name='商家ID')),
('role_code', models.CharField(max_length=32, verbose_name='角色码')),
('role_name', models.CharField(max_length=64, verbose_name='角色名')),
('is_system', models.BooleanField(default=False, verbose_name='系统预置')),
('sort_order', models.IntegerField(default=0)),
('status', models.IntegerField(default=1, verbose_name='1启用0停用')),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={'db_table': 'merchant_staff_role', 'verbose_name': '商家客服角色', 'unique_together': {('merchant_id', 'role_code')}},
),
migrations.CreateModel(
name='MerchantStaffInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(default='xq', max_length=16)),
('merchant_id', models.CharField(db_index=True, max_length=7, verbose_name='商家ID')),
('invite_code', models.CharField(max_length=32, unique=True, verbose_name='邀请码')),
('status', models.IntegerField(default=0, verbose_name='0未用1已用2作废3过期')),
('created_by', models.CharField(max_length=7, verbose_name='创建人')),
('used_by', models.CharField(blank=True, max_length=7, null=True)),
('used_at', models.DateTimeField(blank=True, null=True)),
('expire_at', models.DateTimeField(blank=True, null=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='merchant_ops.merchantstaffrole', verbose_name='预绑角色')),
],
options={'db_table': 'merchant_staff_invite'},
),
migrations.CreateModel(
name='MerchantStaffMember',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(default='xq', max_length=16)),
('merchant_id', models.CharField(db_index=True, max_length=7)),
('staff_user_id', models.CharField(db_index=True, max_length=7)),
('status', models.IntegerField(default=1, verbose_name='1正常0停用2已移除')),
('display_name', models.CharField(blank=True, default='', max_length=64)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('today_dispatch_count', models.IntegerField(default=0)),
('today_dispatch_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
('invited_by_code', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='merchant_ops.merchantstaffinvite')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='merchant_ops.merchantstaffrole')),
],
options={'db_table': 'merchant_staff_member', 'unique_together': {('merchant_id', 'staff_user_id')}},
),
migrations.CreateModel(
name='MerchantStaffRolePermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('perm_code', models.CharField(db_index=True, max_length=64)),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_permissions', to='merchant_ops.merchantstaffrole')),
],
options={'db_table': 'merchant_staff_role_permission', 'unique_together': {('role', 'perm_code')}},
),
migrations.CreateModel(
name='MerchantStaffMemberPermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('perm_code', models.CharField(max_length=64)),
('granted', models.BooleanField(default=True, verbose_name='True授予False禁止')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_permissions', to='merchant_ops.merchantstaffmember')),
],
options={'db_table': 'merchant_staff_member_permission', 'unique_together': {('member', 'perm_code')}},
),
migrations.CreateModel(
name='MerchantStaffWallet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('merchant_id', models.CharField(db_index=True, max_length=7)),
('staff_user_id', models.CharField(db_index=True, max_length=7)),
('quota_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('quota_used', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('quota_frozen', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('UpdateTime', models.DateTimeField(auto_now=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wallets', to='merchant_ops.merchantstaffmember')),
],
options={'db_table': 'merchant_staff_wallet', 'unique_together': {('merchant_id', 'staff_user_id')}},
),
migrations.CreateModel(
name='MerchantStaffWalletLedger',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('merchant_id', models.CharField(db_index=True, max_length=7)),
('staff_user_id', models.CharField(db_index=True, max_length=7)),
('change_type', models.CharField(max_length=32)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('balance_after', models.DecimalField(decimal_places=2, max_digits=12)),
('related_order_id', models.CharField(blank=True, max_length=32, null=True)),
('operator_id', models.CharField(max_length=7)),
('remark', models.CharField(blank=True, default='', max_length=255)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('wallet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ledgers', to='merchant_ops.merchantstaffwallet')),
],
options={'db_table': 'merchant_staff_wallet_ledger'},
),
migrations.CreateModel(
name='MerchantStaffDailyStats',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(default='xq', max_length=16)),
('merchant_id', models.CharField(db_index=True, max_length=7)),
('staff_user_id', models.CharField(db_index=True, max_length=7)),
('member_id', models.BigIntegerField(db_index=True)),
('stat_date', models.DateField(db_index=True)),
('year', models.PositiveSmallIntegerField()),
('month', models.PositiveSmallIntegerField()),
('day', models.PositiveSmallIntegerField()),
('dispatch_count', models.IntegerField(default=0)),
('dispatch_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('settle_count', models.IntegerField(default=0)),
('settle_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('refund_count', models.IntegerField(default=0)),
('refund_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('cancel_count', models.IntegerField(default=0)),
('penalty_count', models.IntegerField(default=0)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={'db_table': 'merchant_staff_ri_tongji', 'unique_together': {('merchant_id', 'staff_user_id', 'stat_date')}},
),
migrations.CreateModel(
name='MerchantOrderDispatch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order_id', models.CharField(db_index=True, max_length=32, unique=True)),
('merchant_id', models.CharField(db_index=True, max_length=7)),
('dispatch_actor_type', models.CharField(max_length=16, verbose_name='MERCHANT/STAFF')),
('dispatch_actor_id', models.CharField(max_length=7)),
('staff_member_id', models.BigIntegerField(blank=True, db_index=True, null=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
],
options={'db_table': 'merchant_order_dispatch'},
),
migrations.CreateModel(
name='MerchantStaffAuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('merchant_id', models.CharField(db_index=True, max_length=7)),
('operator_user_id', models.CharField(db_index=True, max_length=7)),
('operator_type', models.CharField(max_length=16, verbose_name='MERCHANT_OWNER/STAFF')),
('staff_member_id', models.BigIntegerField(blank=True, null=True)),
('action', models.CharField(db_index=True, max_length=64)),
('resource_type', models.CharField(blank=True, default='', max_length=32)),
('resource_id', models.CharField(blank=True, default='', max_length=64)),
('value_before', models.JSONField(blank=True, null=True)),
('value_after', models.JSONField(blank=True, null=True)),
('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('request_ip', models.CharField(blank=True, default='', max_length=64)),
('remark', models.CharField(blank=True, default='', max_length=500)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
],
options={'db_table': 'merchant_staff_audit_log'},
),
migrations.CreateModel(
name='MerchantDispatchTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('merchant_id', models.CharField(db_index=True, max_length=7)),
('template_name', models.CharField(max_length=128)),
('template_type', models.CharField(default='EXPRESS', max_length=16)),
('payload_json', models.JSONField(default=dict)),
('created_by', models.CharField(max_length=7)),
('created_by_type', models.CharField(default='MERCHANT', max_length=16)),
('status', models.IntegerField(default=1)),
('use_count', models.IntegerField(default=0)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={'db_table': 'merchant_dispatch_template'},
),
]

View File

224
merchant_ops/models.py Normal file
View File

@@ -0,0 +1,224 @@
from decimal import Decimal
from django.db import models
from gvsdsdk.model_base import QModel
class MerchantStaffPermission(QModel):
"""权限字典(全局码表)"""
perm_code = models.CharField(max_length=64, unique=True, verbose_name='权限码')
perm_name = models.CharField(max_length=64, verbose_name='权限名称')
sort_order = models.IntegerField(default=0, verbose_name='排序')
status = models.IntegerField(default=1, verbose_name='1启用0停用')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'merchant_staff_permission'
verbose_name = '商家客服权限字典'
class MerchantStaffRole(QModel):
"""商家内角色merchant_id 为空表示系统模板)"""
club_id = models.CharField(max_length=16, default='xq', verbose_name='Club')
merchant_id = models.CharField(
max_length=7, null=True, blank=True, db_index=True, verbose_name='商家ID'
)
role_code = models.CharField(max_length=32, verbose_name='角色码')
role_name = models.CharField(max_length=64, verbose_name='角色名')
is_system = models.BooleanField(default=False, verbose_name='系统预置')
sort_order = models.IntegerField(default=0)
status = models.IntegerField(default=1, verbose_name='1启用0停用')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'merchant_staff_role'
verbose_name = '商家客服角色'
unique_together = [['merchant_id', 'role_code']]
class MerchantStaffRolePermission(QModel):
role = models.ForeignKey(
MerchantStaffRole, on_delete=models.CASCADE, related_name='role_permissions'
)
perm_code = models.CharField(max_length=64, db_index=True)
class Meta:
db_table = 'merchant_staff_role_permission'
unique_together = [['role', 'perm_code']]
class MerchantStaffInvite(QModel):
club_id = models.CharField(max_length=16, default='xq')
merchant_id = models.CharField(max_length=7, db_index=True, verbose_name='商家ID')
invite_code = models.CharField(max_length=32, unique=True, verbose_name='邀请码')
role = models.ForeignKey(
MerchantStaffRole, null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='预绑角色'
)
status = models.IntegerField(default=0, verbose_name='0未用1已用2作废3过期')
created_by = models.CharField(max_length=7, verbose_name='创建人')
used_by = models.CharField(max_length=7, null=True, blank=True)
used_at = models.DateTimeField(null=True, blank=True)
expire_at = models.DateTimeField(null=True, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'merchant_staff_invite'
indexes = [
models.Index(fields=['merchant_id', 'status', '-CreateTime']),
]
class MerchantStaffMember(QModel):
club_id = models.CharField(max_length=16, default='xq')
merchant_id = models.CharField(max_length=7, db_index=True)
staff_user_id = models.CharField(max_length=7, db_index=True)
role = models.ForeignKey(MerchantStaffRole, on_delete=models.PROTECT)
status = models.IntegerField(default=1, verbose_name='1正常0停用2已移除')
display_name = models.CharField(max_length=64, blank=True, default='')
invited_by_code = models.ForeignKey(
MerchantStaffInvite, null=True, blank=True, on_delete=models.SET_NULL
)
joined_at = models.DateTimeField(auto_now_add=True)
revoked_at = models.DateTimeField(null=True, blank=True)
today_dispatch_count = models.IntegerField(default=0)
today_dispatch_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'merchant_staff_member'
unique_together = [['merchant_id', 'staff_user_id']]
indexes = [
models.Index(fields=['staff_user_id', 'status']),
]
class MerchantStaffMemberPermission(QModel):
member = models.ForeignKey(
MerchantStaffMember, on_delete=models.CASCADE, related_name='member_permissions'
)
perm_code = models.CharField(max_length=64)
granted = models.BooleanField(default=True, verbose_name='True授予False禁止')
class Meta:
db_table = 'merchant_staff_member_permission'
unique_together = [['member', 'perm_code']]
class MerchantStaffWallet(QModel):
merchant_id = models.CharField(max_length=7, db_index=True)
staff_user_id = models.CharField(max_length=7, db_index=True)
member = models.ForeignKey(
MerchantStaffMember, on_delete=models.CASCADE, related_name='wallets'
)
quota_total = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
quota_used = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
quota_frozen = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
UpdateTime = models.DateTimeField(auto_now=True)
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'merchant_staff_wallet'
unique_together = [['merchant_id', 'staff_user_id']]
@property
def quota_available(self):
return self.quota_total - self.quota_used - self.quota_frozen
class MerchantStaffWalletLedger(QModel):
wallet = models.ForeignKey(MerchantStaffWallet, on_delete=models.CASCADE, related_name='ledgers')
merchant_id = models.CharField(max_length=7, db_index=True)
staff_user_id = models.CharField(max_length=7, db_index=True)
change_type = models.CharField(max_length=32)
amount = models.DecimalField(max_digits=12, decimal_places=2)
balance_after = models.DecimalField(max_digits=12, decimal_places=2)
related_order_id = models.CharField(max_length=32, null=True, blank=True)
operator_id = models.CharField(max_length=7)
remark = models.CharField(max_length=255, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'merchant_staff_wallet_ledger'
indexes = [models.Index(fields=['merchant_id', '-CreateTime'])]
class MerchantStaffDailyStats(QModel):
club_id = models.CharField(max_length=16, default='xq')
merchant_id = models.CharField(max_length=7, db_index=True)
staff_user_id = models.CharField(max_length=7, db_index=True)
member_id = models.BigIntegerField(db_index=True)
stat_date = models.DateField(db_index=True)
year = models.PositiveSmallIntegerField()
month = models.PositiveSmallIntegerField()
day = models.PositiveSmallIntegerField()
dispatch_count = models.IntegerField(default=0)
dispatch_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
settle_count = models.IntegerField(default=0)
settle_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
refund_count = models.IntegerField(default=0)
refund_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
cancel_count = models.IntegerField(default=0)
penalty_count = models.IntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'merchant_staff_ri_tongji'
unique_together = [['merchant_id', 'staff_user_id', 'stat_date']]
indexes = [
models.Index(fields=['merchant_id', 'stat_date', '-dispatch_count']),
]
class MerchantOrderDispatch(QModel):
"""订单派单归属(不修改 orders 表)"""
order_id = models.CharField(max_length=32, unique=True, db_index=True)
merchant_id = models.CharField(max_length=7, db_index=True)
dispatch_actor_type = models.CharField(max_length=16, verbose_name='MERCHANT/STAFF')
dispatch_actor_id = models.CharField(max_length=7)
staff_member_id = models.BigIntegerField(null=True, blank=True, db_index=True)
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'merchant_order_dispatch'
class MerchantStaffAuditLog(QModel):
merchant_id = models.CharField(max_length=7, db_index=True)
operator_user_id = models.CharField(max_length=7, db_index=True)
operator_type = models.CharField(max_length=16, verbose_name='MERCHANT_OWNER/STAFF')
staff_member_id = models.BigIntegerField(null=True, blank=True)
action = models.CharField(max_length=64, db_index=True)
resource_type = models.CharField(max_length=32, blank=True, default='')
resource_id = models.CharField(max_length=64, blank=True, default='')
value_before = models.JSONField(null=True, blank=True)
value_after = models.JSONField(null=True, blank=True)
amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
request_ip = models.CharField(max_length=64, blank=True, default='')
remark = models.CharField(max_length=500, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'merchant_staff_audit_log'
indexes = [models.Index(fields=['merchant_id', '-CreateTime'])]
class MerchantDispatchTemplate(QModel):
merchant_id = models.CharField(max_length=7, db_index=True)
template_name = models.CharField(max_length=128)
template_type = models.CharField(max_length=16, default='EXPRESS')
payload_json = models.JSONField(default=dict)
created_by = models.CharField(max_length=7)
created_by_type = models.CharField(max_length=16, default='MERCHANT')
status = models.IntegerField(default=1)
use_count = models.IntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'merchant_dispatch_template'

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='派单扣额度',
)

View File

@@ -0,0 +1,31 @@
-- 商家子客服:权限码初始化(部署后执行一次,或使用 python manage.py seed_merchant_staff
-- 表 merchant_staff_permission
INSERT INTO merchant_staff_permission (perm_code, perm_name, sort_order, status, CreateTime, UpdateTime) VALUES
('staff_manage', '子客服管理', 0, 1, NOW(), NOW()),
('role_manage', '角色管理', 1, 1, NOW(), NOW()),
('wallet_allocate', '划额度', 2, 1, NOW(), NOW()),
('wallet_view', '查看额度', 3, 1, NOW(), NOW()),
('finance_view', '查看商家余额', 4, 1, NOW(), NOW()),
('order_dispatch', '派单发单', 5, 1, NOW(), NOW()),
('order_view_all', '查看全部订单', 6, 1, NOW(), NOW()),
('order_view_self', '只看自己派的单', 7, 1, NOW(), NOW()),
('order_detail', '订单详情', 8, 1, NOW(), NOW()),
('order_settle', '结单', 9, 1, NOW(), NOW()),
('order_refund_apply', '申请退款', 10, 1, NOW(), NOW()),
('order_cancel', '撤销订单', 11, 1, NOW(), NOW()),
('order_change_player', '换打手', 12, 1, NOW(), NOW()),
('penalty_apply', '申请罚单', 13, 1, NOW(), NOW()),
('penalty_manage', '改撤罚单', 14, 1, NOW(), NOW()),
('penalty_view', '查看罚单', 15, 1, NOW(), NOW()),
('dashou_ban', '封禁解封打手', 16, 1, NOW(), NOW()),
('template_manage', '派单模板', 17, 1, NOW(), NOW()),
('audit_view', '操作日志', 18, 1, NOW(), NOW()),
('rank_view', '客服排行榜', 19, 1, NOW(), NOW()),
('im_chat', '订单IM', 20, 1, NOW(), NOW()),
('stats_view', '统计数据', 21, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE perm_name=VALUES(perm_name), sort_order=VALUES(sort_order);
-- 推荐:执行完 SQL 后运行
-- python manage.py seed_merchant_staff
-- 会自动创建系统角色模板及角色-权限关联

46
merchant_ops/urls.py Normal file
View File

@@ -0,0 +1,46 @@
from django.urls import path
from merchant_ops.views_manage import (
StaffInviteCreateView, StaffInviteListView, StaffInviteRevokeView,
StaffMemberListView, StaffMemberUpdateRoleView, StaffMemberRemoveView,
StaffMemberDisableView, StaffRoleListView, StaffRoleSetPermsView,
StaffWalletAllocateView, StaffWalletLedgerView, StaffAuditListView,
StaffRankView,
)
from merchant_ops.views_staff import StaffBindView, StaffMeView
from merchant_ops.views_orders import (
StaffOrderListView, StaffOrderDetailView, StaffDispatchView,
StaffProductTypesView, StaffSettleView, StaffCancelView, StaffRefundView,
StaffPenaltyApplyView, StaffPenaltyManageView, StaffChangePlayerView,
)
urlpatterns = [
# 子客服
path('bind', StaffBindView.as_view(), name='staff_bind'),
path('me', StaffMeView.as_view(), name='staff_me'),
# 老板管理
path('invite/create', StaffInviteCreateView.as_view()),
path('invite/list', StaffInviteListView.as_view()),
path('invite/revoke', StaffInviteRevokeView.as_view()),
path('member/list', StaffMemberListView.as_view()),
path('member/update-role', StaffMemberUpdateRoleView.as_view()),
path('member/remove', StaffMemberRemoveView.as_view()),
path('member/disable', StaffMemberDisableView.as_view()),
path('role/list', StaffRoleListView.as_view()),
path('role/set-perms', StaffRoleSetPermsView.as_view()),
path('wallet/allocate', StaffWalletAllocateView.as_view()),
path('wallet/ledger', StaffWalletLedgerView.as_view()),
path('audit/list', StaffAuditListView.as_view()),
path('rank', StaffRankView.as_view()),
# 子客服订单(全新接口)
path('order/list', StaffOrderListView.as_view()),
path('order/detail', StaffOrderDetailView.as_view()),
path('order/dispatch', StaffDispatchView.as_view()),
path('order/product-types', StaffProductTypesView.as_view()),
path('order/settle', StaffSettleView.as_view()),
path('order/cancel', StaffCancelView.as_view()),
path('order/refund', StaffRefundView.as_view()),
path('order/penalty-apply', StaffPenaltyApplyView.as_view()),
path('order/penalty-manage', StaffPenaltyManageView.as_view()),
path('order/change-player', StaffChangePlayerView.as_view()),
]

View File

@@ -0,0 +1,405 @@
"""商家老板:邀请码、成员、角色、额度、日志、排行。"""
import logging
import secrets
import string
from datetime import timedelta
from decimal import Decimal
from django.db.models import Sum, Q
from django.utils import timezone
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from merchant_ops.constants import (
INVITE_STATUS_REVOKED, INVITE_STATUS_UNUSED, INVITE_STATUS_USED,
MEMBER_STATUS_ACTIVE, MEMBER_STATUS_DISABLED, MEMBER_STATUS_REMOVED,
)
from merchant_ops.models import (
MerchantStaffInvite, MerchantStaffMember, MerchantStaffRole,
MerchantStaffRolePermission, MerchantStaffMemberPermission,
MerchantStaffWallet, MerchantStaffWalletLedger, MerchantStaffAuditLog,
MerchantStaffDailyStats, MerchantStaffPermission,
)
from merchant_ops.services.authz import (
StaffAuthError, ensure_owner_merchant, get_member_permissions,
is_merchant_owner, resolve_staff_context,
get_active_staff_member, member_has_perm as _member_has_perm,
)
from merchant_ops.services.audit import audit_owner
from merchant_ops.services.bootstrap import ensure_merchant_roles
from merchant_ops.services.wallet import allocate_quota, get_or_create_wallet
logger = logging.getLogger(__name__)
def _err(e):
if isinstance(e, StaffAuthError):
return Response({'code': e.code, 'msg': e.msg, 'data': None})
logger.exception(e)
return Response({'code': 500, 'msg': '服务器错误', 'data': None})
def _role_prefix(role_code):
m = {'CHIEF': 'ZG', 'FINANCE': 'CW', 'DISPATCHER': 'PD'}
return m.get(role_code, 'KF')
class StaffInviteCreateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
role_id = request.data.get('role_id')
if not role_id:
return Response({'code': 400, 'msg': '请选择角色', 'data': None})
role = MerchantStaffRole.query.get(id=role_id, merchant_id=request.user.UserUID)
prefix = _role_prefix(role.role_code)
code = f"{prefix}-{secrets.token_hex(3).upper()}"
invite = MerchantStaffInvite.objects.create(
merchant_id=request.user.UserUID,
invite_code=code,
role=role,
status=INVITE_STATUS_UNUSED,
created_by=request.user.UserUID,
expire_at=timezone.now() + timedelta(days=30),
)
audit_owner(request.user.UserUID, request.user.UserUID, 'INVITE_CREATE', request,
resource_type='invite', resource_id=code)
return Response({'code': 0, 'msg': '成功', 'data': {'invite_code': code, 'id': invite.id}})
except Exception as e:
return _err(e)
class StaffInviteListView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
status_filter = request.data.get('status') # None=all, 0,1,2
qs = MerchantStaffInvite.query.filter(merchant_id=request.user.UserUID).select_related('role')
if status_filter is not None:
qs = qs.filter(status=int(status_filter))
items = []
for inv in qs.order_by('-CreateTime')[:200]:
item = {
'id': inv.id,
'code': inv.invite_code,
'typeName': inv.role.role_name if inv.role else '',
'status': inv.status,
'adddate': inv.CreateTime.strftime('%Y-%m-%d') if inv.CreateTime else '',
'usedate': inv.used_at.strftime('%Y-%m-%d') if inv.used_at else '',
'user': None,
}
if inv.used_by:
from users.business_models import User
u = User.query.filter(UserUID=inv.used_by).first()
item['user'] = {'nickname': getattr(u, 'Nickname', '') or inv.used_by, 'uid': inv.used_by}
items.append(item)
return Response({'code': 0, 'msg': '成功', 'data': {'list': items, 'gudingCode': 'XQKF2026'}})
except Exception as e:
return _err(e)
class StaffInviteRevokeView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
invite_id = request.data.get('id')
inv = MerchantStaffInvite.query.get(id=invite_id, merchant_id=request.user.UserUID, status=INVITE_STATUS_UNUSED)
inv.status = INVITE_STATUS_REVOKED
inv.save(update_fields=['status'])
audit_owner(request.user.UserUID, request.user.UserUID, 'INVITE_REVOKE', request, resource_id=inv.invite_code)
return Response({'code': 0, 'msg': '已作废', 'data': None})
except MerchantStaffInvite.DoesNotExist:
return Response({'code': 404, 'msg': '邀请码不存在或已使用', 'data': None})
except Exception as e:
return _err(e)
class StaffMemberListView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
mid = request.user.UserUID
ensure_owner_merchant(mid, request.user)
members = MerchantStaffMember.query.filter(
merchant_id=mid, status=MEMBER_STATUS_ACTIVE,
).select_related('role')
result = []
for m in members:
stats = MerchantStaffDailyStats.query.filter(member_id=m.id).aggregate(
dispatch_count=Sum('dispatch_count'),
settle_count=Sum('settle_count'),
settle_amount=Sum('settle_amount'),
)
wallet = m.wallets.first()
result.append({
'id': m.id,
'uid': m.staff_user_id,
'nickname': m.display_name or m.staff_user_id,
'isChief': m.role.role_code == 'CHIEF',
'isFinance': m.role.role_code == 'FINANCE',
'role_name': m.role.role_name,
'orderCount': stats['dispatch_count'] or 0,
'settleCount': stats['settle_count'] or 0,
'settleAmount': str(stats['settle_amount'] or '0.00'),
'quota_available': str(wallet.quota_available if wallet else 0),
})
return Response({'code': 0, 'msg': '成功', 'data': {'list': result}})
except Exception as e:
return _err(e)
class StaffMemberUpdateRoleView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
member_id = request.data.get('member_id')
role_id = request.data.get('role_id')
m = MerchantStaffMember.query.get(id=member_id, merchant_id=request.user.UserUID, status=MEMBER_STATUS_ACTIVE)
role = MerchantStaffRole.query.get(id=role_id, merchant_id=request.user.UserUID)
old = m.role_id
m.role = role
m.save(update_fields=['role'])
audit_owner(request.user.UserUID, request.user.UserUID, 'MEMBER_UPDATE_ROLE', request,
resource_type='member', resource_id=member_id,
value_before={'role_id': old}, value_after={'role_id': role_id})
return Response({'code': 0, 'msg': '成功', 'data': None})
except Exception as e:
return _err(e)
class StaffMemberRemoveView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
member_id = request.data.get('member_id')
m = MerchantStaffMember.query.get(id=member_id, merchant_id=request.user.UserUID)
m.status = MEMBER_STATUS_REMOVED
m.revoked_at = timezone.now()
m.save(update_fields=['status', 'revoked_at'])
audit_owner(request.user.UserUID, request.user.UserUID, 'MEMBER_REMOVE', request,
resource_type='member', resource_id=member_id)
return Response({'code': 0, 'msg': '已移除', 'data': None})
except Exception as e:
return _err(e)
class StaffMemberDisableView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
member_id = request.data.get('member_id')
disable = request.data.get('disable', True)
m = MerchantStaffMember.query.get(id=member_id, merchant_id=request.user.UserUID)
m.status = MEMBER_STATUS_DISABLED if disable else MEMBER_STATUS_ACTIVE
m.save(update_fields=['status'])
action = 'MEMBER_DISABLE' if disable else 'MEMBER_ENABLE'
audit_owner(request.user.UserUID, request.user.UserUID, action, request, resource_id=member_id)
return Response({'code': 0, 'msg': '成功', 'data': None})
except Exception as e:
return _err(e)
class StaffRoleListView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
roles = MerchantStaffRole.query.filter(merchant_id=request.user.UserUID, status=1)
data = []
for r in roles:
perms = list(r.role_permissions.values_list('perm_code', flat=True))
data.append({
'id': r.id, 'role_code': r.role_code, 'role_name': r.role_name,
'is_system': r.is_system, 'permissions': perms,
})
all_perms = list(MerchantStaffPermission.query.filter(status=1).order_by('sort_order').values(
'perm_code', 'perm_name'))
return Response({'code': 0, 'msg': '成功', 'data': {'roles': data, 'all_permissions': all_perms}})
except Exception as e:
return _err(e)
class StaffRoleSetPermsView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
role_id = request.data.get('role_id')
permissions = request.data.get('permissions', [])
role = MerchantStaffRole.query.get(id=role_id, merchant_id=request.user.UserUID)
MerchantStaffRolePermission.query.filter(role=role).delete()
for p in permissions:
MerchantStaffRolePermission.objects.create(role=role, perm_code=p)
audit_owner(request.user.UserUID, request.user.UserUID, 'ROLE_SET_PERMS', request, resource_id=role_id)
return Response({'code': 0, 'msg': '成功', 'data': None})
except Exception as e:
return _err(e)
class StaffWalletAllocateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
member_id = request.data.get('member_id')
amount = request.data.get('amount')
m = MerchantStaffMember.query.get(id=member_id, merchant_id=request.user.UserUID, status=MEMBER_STATUS_ACTIVE)
get_or_create_wallet(m)
allocate_quota(m, amount, request.user.UserUID, remark='老板划额度')
audit_owner(request.user.UserUID, request.user.UserUID, 'WALLET_ALLOCATE', request,
resource_type='wallet', resource_id=member_id, amount=amount)
return Response({'code': 0, 'msg': '划额度成功', 'data': None})
except ValueError as e:
return Response({'code': 400, 'msg': str(e), 'data': None})
except Exception as e:
return _err(e)
class StaffWalletLedgerView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
mid = request.user.UserUID
if is_merchant_owner(request.user):
member_id = request.data.get('member_id')
qs = MerchantStaffWalletLedger.query.filter(merchant_id=mid)
if member_id:
m = MerchantStaffMember.query.get(id=member_id, merchant_id=mid)
qs = qs.filter(staff_user_id=m.staff_user_id)
else:
ctx = resolve_staff_context(request.user)
if not ctx:
return Response({'code': 403, 'msg': '无权限', 'data': None})
qs = MerchantStaffWalletLedger.query.filter(staff_user_id=request.user.UserUID)
items = [{
'change_type': x.change_type,
'amount': str(x.amount),
'balance_after': str(x.balance_after),
'related_order_id': x.related_order_id or '',
'remark': x.remark,
'time': x.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if x.CreateTime else '',
} for x in qs.order_by('-CreateTime')[:100]]
return Response({'code': 0, 'msg': '成功', 'data': {'list': items}})
except Exception as e:
return _err(e)
class StaffAuditListView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
mid = request.user.UserUID
member = None
if is_merchant_owner(request.user):
ensure_owner_merchant(mid, request.user)
else:
member = get_active_staff_member(request.user)
if not member or not _member_has_perm(member, 'audit_view'):
return Response({'code': 403, 'msg': '无权限', 'data': None})
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)
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]
return Response({'code': 0, 'msg': '成功', 'data': {
'list': items, 'total': total, 'has_more': page * page_size < total,
}})
except Exception as e:
return _err(e)
class StaffRankView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
from datetime import date, timedelta
from django.db.models import Sum
if is_merchant_owner(request.user):
mid = request.user.UserUID
else:
member = get_active_staff_member(request.user)
if not member:
return Response({'code': 403, 'msg': '无权限', 'data': None})
mid = member.merchant_id
period = request.data.get('period', 'today')
sort_by = request.data.get('sort_by', 'dispatch_count')
today = date.today()
if period == 'today':
start = today
elif period == 'yesterday':
start = today - timedelta(days=1)
elif period == 'week':
start = today - timedelta(days=7)
elif period == 'month':
start = today - timedelta(days=30)
else:
start = None
qs = MerchantStaffDailyStats.query.filter(merchant_id=mid)
if start:
if period == 'yesterday':
qs = qs.filter(stat_date=start)
else:
qs = qs.filter(stat_date__gte=start)
agg = qs.values('staff_user_id', 'member_id').annotate(
dispatch_count=Sum('dispatch_count'),
dispatch_amount=Sum('dispatch_amount'),
settle_count=Sum('settle_count'),
settle_amount=Sum('settle_amount'),
)
sort_field = sort_by if sort_by in ('dispatch_count', 'dispatch_amount', 'settle_count', 'settle_amount') else 'dispatch_count'
agg = sorted(agg, key=lambda x: x[sort_field] or 0, reverse=True)[:50]
result = []
for idx, row in enumerate(agg, 1):
m = MerchantStaffMember.query.filter(id=row['member_id']).first()
result.append({
'rank': idx,
'member_id': row['member_id'],
'staff_user_id': row['staff_user_id'],
'display_name': (m.display_name if m else '') or row['staff_user_id'],
'dispatch_count': row['dispatch_count'] or 0,
'dispatch_amount': str(row['dispatch_amount'] or 0),
'settle_count': row['settle_count'] or 0,
'settle_amount': str(row['settle_amount'] or 0),
})
return Response({'code': 0, 'msg': '成功', 'data': {'list': result, 'period': period}})
except Exception as e:
return _err(e)

View File

@@ -0,0 +1,256 @@
"""子客服专用订单接口(不修改商家原接口)。"""
import logging
from django.db.models import Q
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import pick_leixing_id, pick_order_id
from merchant_ops.helpers import proxy_merchant_order_view, staff_can_access_order
from merchant_ops.models import MerchantOrderDispatch
from merchant_ops.services.authz import StaffAuthError, get_active_staff_member, member_has_perm
from merchant_ops.services.audit import audit_staff
from merchant_ops.services.dispatch import staff_dispatch_order
from merchant_ops.services.stats import bump_cancel_count, bump_penalty_count, update_merchant_and_staff_stats
from merchant_ops.constants import STAT_ACTION_REFUND, STAT_ACTION_SETTLE
from orders.models import MerchantOrderExt
from orders.views import (
ShangjiaDingdanXiangqingView, ShangjiaJiesuanView, ShangjiaChexiaoView,
ShangjiaTuikuanShenqingView, ShangjiaFakuanApplyView, ShangjiaFakuaiXiugaiView,
ZxsjghdsView, ShangpinLeixingView,
)
logger = logging.getLogger(__name__)
def _require_staff(request):
member = get_active_staff_member(request.user)
if not member:
raise StaffAuthError(403, '非有效子客服')
return member
def _err(e):
if isinstance(e, StaffAuthError):
return Response({'code': e.code, 'msg': e.msg, 'data': None})
logger.exception(e)
return Response({'code': 500, 'msg': '服务器错误', 'data': None})
class StaffOrderListView(APIView):
"""POST /yonghu/merchant-staff/order/list — 对齐 sjdingdanhq"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
if not member_has_perm(member, 'order_view_all') and not member_has_perm(member, 'order_view_self'):
raise StaffAuthError(403, '无订单查看权限')
data = request.data
zhuangtai_list = data.get('zhuangtai_list', [])
page = max(1, int(data.get('page', 1)))
page_size = min(100, max(1, int(data.get('page_size', 10))))
leixing_id = pick_leixing_id(data)
keyword = str(data.get('keyword', '')).strip()
if not isinstance(zhuangtai_list, list) or not zhuangtai_list:
return Response({'code': 3, 'msg': '状态列表不能为空', 'data': None})
base_qs = MerchantOrderExt.query.filter(
MerchantID=member.merchant_id,
).select_related('Order').filter(Order__Status__in=zhuangtai_list)
if not member_has_perm(member, 'order_view_all'):
own_ids = MerchantOrderDispatch.query.filter(
staff_member_id=member.id,
).values_list('order_id', flat=True)
base_qs = base_qs.filter(Order__OrderID__in=list(own_ids))
if leixing_id is not None:
base_qs = base_qs.filter(Order__ProductTypeID=leixing_id)
if keyword:
base_qs = base_qs.filter(
Q(Order__OrderID__exact=keyword)
| Q(Order__Description__icontains=keyword)
| Q(Order__Nickname__icontains=keyword)
| Q(Order__PlayerID__exact=keyword)
)
pending_count = MerchantOrderExt.query.filter(
MerchantID=member.merchant_id, Order__Status=8,
).count()
total_count = base_qs.count()
start = (page - 1) * page_size
page_qs = base_qs.order_by('-Order__CreateTime')[start:start + page_size]
order_list = []
for ext in page_qs:
o = ext.Order
order_list.append({
'dingdan_id': o.OrderID,
'zhuangtai': o.Status,
'jine': float(o.Amount) if o.Amount else 0.0,
'jieshao': o.Description or '',
'beizhu': o.Remark or '',
'nicheng': o.Nickname or '',
'leixing_id': o.ProductTypeID,
'tupian': o.ImageURL or '',
'jiedan_dashou_id': o.PlayerID or '',
'CreateTime': o.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if o.CreateTime else '',
})
return Response({
'code': 0, 'msg': '成功',
'data': {
'list': order_list,
'has_more': len(page_qs) == page_size and start + page_size < total_count,
'pending_count': pending_count,
},
})
except Exception as e:
return _err(e)
class StaffOrderDetailView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
if not member_has_perm(member, 'order_detail'):
raise StaffAuthError(403, '无权限')
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)
class StaffDispatchView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
if not member_has_perm(member, 'order_dispatch'):
raise StaffAuthError(403, '无派单权限')
ok, payload = staff_dispatch_order(member, request.user, request.data)
if ok:
audit_staff(member, 'DISPATCH', request,
resource_id=payload.get('data', {}).get('dingdan_id'),
amount=request.data.get('jiage'))
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 StaffProductTypesView(APIView):
"""商品类型 — 代理 sjspleixing"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
if not member_has_perm(member, 'order_dispatch'):
raise StaffAuthError(403, '无权限')
return proxy_merchant_order_view(request, member, ShangpinLeixingView, 'order_dispatch')
except Exception as e:
return _err(e)
class StaffSettleView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
resp = proxy_merchant_order_view(request, member, ShangjiaJiesuanView, 'order_settle')
if resp.data.get('code') in (0, 200):
audit_staff(member, 'SETTLE', request, resource_id=pick_order_id(request.data))
from orders.models import Order
oid = pick_order_id(request.data)
o = Order.query.filter(OrderID=oid).first()
if o:
update_merchant_and_staff_stats(member.merchant_id, member, o.Amount, STAT_ACTION_SETTLE)
return resp
except Exception as e:
return _err(e)
class StaffCancelView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
resp = proxy_merchant_order_view(request, member, ShangjiaChexiaoView, 'order_cancel')
if resp.data.get('code') in (0, 200):
audit_staff(member, 'CANCEL', request, resource_id=pick_order_id(request.data))
bump_cancel_count(member.merchant_id, member)
return resp
except Exception as e:
return _err(e)
class StaffRefundView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
resp = proxy_merchant_order_view(request, member, ShangjiaTuikuanShenqingView, 'order_refund_apply')
if resp.data.get('code') in (0, 200):
audit_staff(member, 'REFUND_APPLY', request, resource_id=pick_order_id(request.data))
return resp
except Exception as e:
return _err(e)
class StaffPenaltyApplyView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
resp = proxy_merchant_order_view(request, member, ShangjiaFakuanApplyView, 'penalty_apply')
if resp.data.get('code') in (0, 200):
audit_staff(member, 'PENALTY_APPLY', request, resource_id=pick_order_id(request.data))
bump_penalty_count(member.merchant_id, member)
return resp
except Exception as e:
return _err(e)
class StaffPenaltyManageView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
resp = proxy_merchant_order_view(request, member, ShangjiaFakuaiXiugaiView, 'penalty_manage')
if resp.data.get('code') in (0, 200):
audit_staff(member, 'PENALTY_MANAGE', request, resource_id=pick_order_id(request.data))
return resp
except Exception as e:
return _err(e)
class StaffChangePlayerView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
member = _require_staff(request)
resp = proxy_merchant_order_view(request, member, ZxsjghdsView, 'order_change_player')
if resp.data.get('code') in (0, 200):
audit_staff(member, 'CHANGE_PLAYER', request, resource_id=pick_order_id(request.data))
return resp
except Exception as e:
return _err(e)

107
merchant_ops/views_staff.py Normal file
View File

@@ -0,0 +1,107 @@
"""子客服:绑定、个人信息。"""
import logging
from django.db import transaction
from django.utils import timezone
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from merchant_ops.constants import INVITE_STATUS_UNUSED, INVITE_STATUS_USED, MEMBER_STATUS_ACTIVE
from merchant_ops.models import MerchantStaffInvite, MerchantStaffMember
from merchant_ops.services.authz import (
StaffAuthError, assert_can_bind_staff, get_active_staff_member, resolve_staff_context,
)
from merchant_ops.services.audit import audit_staff
from merchant_ops.services.wallet import get_or_create_wallet
from users.business_models import User
from users.models import UserShangjia
logger = logging.getLogger(__name__)
def _err(e):
if isinstance(e, StaffAuthError):
return Response({'code': e.code, 'msg': e.msg, 'data': None})
logger.exception(e)
return Response({'code': 500, 'msg': '服务器错误', 'data': None})
class StaffBindView(APIView):
"""POST /yonghu/merchant-staff/bind"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
assert_can_bind_staff(request.user)
invite_code = str(request.data.get('inviteCode') or request.data.get('invite_code') or '').strip()
if not invite_code or invite_code == 'XQKF2026':
return Response({'code': 400, 'msg': '请使用商家提供的一次性邀请码', 'data': None})
with transaction.atomic():
inv = MerchantStaffInvite.objects.select_for_update().get(
invite_code=invite_code, status=INVITE_STATUS_UNUSED,
)
if inv.expire_at and inv.expire_at < timezone.now():
inv.status = 3
inv.save(update_fields=['status'])
return Response({'code': 400, 'msg': '邀请码已过期', 'data': None})
if not inv.role_id:
return Response({'code': 400, 'msg': '邀请码未绑定角色', 'data': None})
display = getattr(request.user, 'Nickname', '') or request.user.UserUID
member, created = MerchantStaffMember.objects.get_or_create(
merchant_id=inv.merchant_id,
staff_user_id=request.user.UserUID,
defaults={
'role_id': inv.role_id,
'status': MEMBER_STATUS_ACTIVE,
'display_name': display[:64],
'invited_by_code': inv,
},
)
if not created:
if member.status == MEMBER_STATUS_ACTIVE:
return Response({'code': 400, 'msg': '您已是该商家客服', 'data': None})
member.status = MEMBER_STATUS_ACTIVE
member.role_id = inv.role_id
member.display_name = display[:64]
member.invited_by_code = inv
member.revoked_at = None
member.save()
inv.status = INVITE_STATUS_USED
inv.used_by = request.user.UserUID
inv.used_at = timezone.now()
inv.save(update_fields=['status', 'used_by', 'used_at'])
get_or_create_wallet(member)
ctx = resolve_staff_context(request.user)
shangjia = UserShangjia.query.filter(user__UserUID=inv.merchant_id).first()
if ctx and shangjia:
ctx['merchant_display'] = {'nicheng': shangjia.nicheng, 'merchant_id': inv.merchant_id}
audit_staff(member, 'STAFF_BIND', request, resource_id=invite_code)
return Response({'code': 0, 'msg': '绑定成功', 'data': {'staff_context': ctx}})
except MerchantStaffInvite.DoesNotExist:
return Response({'code': 404, 'msg': '邀请码无效或已使用', 'data': None})
except Exception as e:
return _err(e)
class StaffMeView(APIView):
"""GET/POST /yonghu/merchant-staff/me"""
permission_classes = [IsAuthenticated]
def post(self, request):
ctx = resolve_staff_context(request.user)
if not ctx:
return Response({'code': 404, 'msg': '非子客服身份', 'data': None})
member = get_active_staff_member(request.user)
shangjia = UserShangjia.query.filter(user__UserUID=member.merchant_id).first()
ctx['merchant_display'] = {
'nicheng': shangjia.nicheng if shangjia else '',
'sjyue': str(shangjia.yue) if shangjia else '0',
}
return Response({'code': 0, 'msg': '成功', 'data': ctx})

View File

@@ -1,4 +1,4 @@
from django.urls import path
from django.urls import path, include
from django.views.decorators.csrf import csrf_exempt
from .views import WechatMiniProgramLoginView, UserInfoUpdateView, DashouXinxiAPIView, \
ZaixianZhuangtaiAPIView, GuanshiXinxiView, ShangJiaXinXiView, BossBiaoshiView, YaoqingmaView, \
@@ -157,10 +157,5 @@ urlpatterns = [
path('tixianqr', TixianQueRenAutoView.as_view(), name='自动提现确认'),
path('tixiancallback', csrf_exempt(TixianCallbackV3View.as_view()), name='提现回调'),
path('phbhqsj', PhbHqsjView.as_view(), name='排行榜数据'),
path('merchant-staff/', include('merchant_ops.urls')),
]

View File

@@ -4899,6 +4899,16 @@ class ShangjiaZhuceView(APIView):
# 用户不是商家,继续注册流程
pass
# 2.5 子客服身份互斥:有效客服不可认证商家
from merchant_ops.services.authz import assert_no_active_staff_when_merchant_register
try:
assert_no_active_staff_when_merchant_register(current_user)
except Exception as e:
from merchant_ops.services.authz import StaffAuthError
if isinstance(e, StaffAuthError):
return Response({'code': 403, 'message': e.msg, 'data': None}, status=status.HTTP_403_FORBIDDEN)
raise
# 3. 根据邀请码查找对应的管理员
try:
# 从管理员表查找邀请码