From 0cf5341d73537f945cdd004af09aba0de98c9768 Mon Sep 17 00:00:00 2001 From: XingQue Date: Sat, 20 Jun 2026 02:19:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=95=86=E5=AE=B6=E5=AD=90=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=20merchant=5Fops=20=E6=A8=A1=E5=9D=97=E4=B8=8E?= =?UTF-8?q?=E4=B8=93=E7=94=A8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- a_long_dianjing/settings.py | 1 + merchant_ops/__init__.py | 0 merchant_ops/apps.py | 7 + merchant_ops/constants.py | 88 ++++ merchant_ops/helpers.py | 52 +++ merchant_ops/management/__init__.py | 0 merchant_ops/management/commands/__init__.py | 0 .../commands/seed_merchant_staff.py | 11 + merchant_ops/migrations/0001_initial.py | 207 +++++++++ merchant_ops/migrations/__init__.py | 0 merchant_ops/models.py | 224 ++++++++++ merchant_ops/services/__init__.py | 0 merchant_ops/services/audit.py | 66 +++ merchant_ops/services/authz.py | 126 ++++++ merchant_ops/services/bootstrap.py | 56 +++ merchant_ops/services/dispatch.py | 211 +++++++++ merchant_ops/services/stats.py | 108 +++++ merchant_ops/services/wallet.py | 77 ++++ merchant_ops/sql/seed_permissions.sql | 31 ++ merchant_ops/urls.py | 46 ++ merchant_ops/views_manage.py | 405 ++++++++++++++++++ merchant_ops/views_orders.py | 256 +++++++++++ merchant_ops/views_staff.py | 107 +++++ users/urls.py | 9 +- users/views.py | 10 + 25 files changed, 2091 insertions(+), 7 deletions(-) create mode 100644 merchant_ops/__init__.py create mode 100644 merchant_ops/apps.py create mode 100644 merchant_ops/constants.py create mode 100644 merchant_ops/helpers.py create mode 100644 merchant_ops/management/__init__.py create mode 100644 merchant_ops/management/commands/__init__.py create mode 100644 merchant_ops/management/commands/seed_merchant_staff.py create mode 100644 merchant_ops/migrations/0001_initial.py create mode 100644 merchant_ops/migrations/__init__.py create mode 100644 merchant_ops/models.py create mode 100644 merchant_ops/services/__init__.py create mode 100644 merchant_ops/services/audit.py create mode 100644 merchant_ops/services/authz.py create mode 100644 merchant_ops/services/bootstrap.py create mode 100644 merchant_ops/services/dispatch.py create mode 100644 merchant_ops/services/stats.py create mode 100644 merchant_ops/services/wallet.py create mode 100644 merchant_ops/sql/seed_permissions.sql create mode 100644 merchant_ops/urls.py create mode 100644 merchant_ops/views_manage.py create mode 100644 merchant_ops/views_orders.py create mode 100644 merchant_ops/views_staff.py diff --git a/a_long_dianjing/settings.py b/a_long_dianjing/settings.py index 8b2501d..275571d 100644 --- a/a_long_dianjing/settings.py +++ b/a_long_dianjing/settings.py @@ -40,6 +40,7 @@ INSTALLED_APPS = [ 'utils', 'shop', 'rank', + 'merchant_ops', ] MIDDLEWARE = [ diff --git a/merchant_ops/__init__.py b/merchant_ops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/merchant_ops/apps.py b/merchant_ops/apps.py new file mode 100644 index 0000000..639203f --- /dev/null +++ b/merchant_ops/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MerchantOpsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'merchant_ops' + verbose_name = '商家子客服' diff --git a/merchant_ops/constants.py b/merchant_ops/constants.py new file mode 100644 index 0000000..3a7ba7d --- /dev/null +++ b/merchant_ops/constants.py @@ -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] diff --git a/merchant_ops/helpers.py b/merchant_ops/helpers.py new file mode 100644 index 0000000..3a52645 --- /dev/null +++ b/merchant_ops/helpers.py @@ -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') diff --git a/merchant_ops/management/__init__.py b/merchant_ops/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/merchant_ops/management/commands/__init__.py b/merchant_ops/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/merchant_ops/management/commands/seed_merchant_staff.py b/merchant_ops/management/commands/seed_merchant_staff.py new file mode 100644 index 0000000..6bcafe4 --- /dev/null +++ b/merchant_ops/management/commands/seed_merchant_staff.py @@ -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 权限与角色模板已就绪')) diff --git a/merchant_ops/migrations/0001_initial.py b/merchant_ops/migrations/0001_initial.py new file mode 100644 index 0000000..fb6963d --- /dev/null +++ b/merchant_ops/migrations/0001_initial.py @@ -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'}, + ), + ] diff --git a/merchant_ops/migrations/__init__.py b/merchant_ops/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/merchant_ops/models.py b/merchant_ops/models.py new file mode 100644 index 0000000..f618c3c --- /dev/null +++ b/merchant_ops/models.py @@ -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' diff --git a/merchant_ops/services/__init__.py b/merchant_ops/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/merchant_ops/services/audit.py b/merchant_ops/services/audit.py new file mode 100644 index 0000000..1b14eac --- /dev/null +++ b/merchant_ops/services/audit.py @@ -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 '' diff --git a/merchant_ops/services/authz.py b/merchant_ops/services/authz.py new file mode 100644 index 0000000..f3b5dfe --- /dev/null +++ b/merchant_ops/services/authz.py @@ -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) diff --git a/merchant_ops/services/bootstrap.py b/merchant_ops/services/bootstrap.py new file mode 100644 index 0000000..c35ed40 --- /dev/null +++ b/merchant_ops/services/bootstrap.py @@ -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, + ) diff --git a/merchant_ops/services/dispatch.py b/merchant_ops/services/dispatch.py new file mode 100644 index 0000000..b0a0923 --- /dev/null +++ b/merchant_ops/services/dispatch.py @@ -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)}, + } diff --git a/merchant_ops/services/stats.py b/merchant_ops/services/stats.py new file mode 100644 index 0000000..c4112bc --- /dev/null +++ b/merchant_ops/services/stats.py @@ -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, + ) diff --git a/merchant_ops/services/wallet.py b/merchant_ops/services/wallet.py new file mode 100644 index 0000000..3617474 --- /dev/null +++ b/merchant_ops/services/wallet.py @@ -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='派单扣额度', + ) diff --git a/merchant_ops/sql/seed_permissions.sql b/merchant_ops/sql/seed_permissions.sql new file mode 100644 index 0000000..4026be4 --- /dev/null +++ b/merchant_ops/sql/seed_permissions.sql @@ -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 +-- 会自动创建系统角色模板及角色-权限关联 diff --git a/merchant_ops/urls.py b/merchant_ops/urls.py new file mode 100644 index 0000000..2063896 --- /dev/null +++ b/merchant_ops/urls.py @@ -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()), +] diff --git a/merchant_ops/views_manage.py b/merchant_ops/views_manage.py new file mode 100644 index 0000000..c1ff49b --- /dev/null +++ b/merchant_ops/views_manage.py @@ -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) diff --git a/merchant_ops/views_orders.py b/merchant_ops/views_orders.py new file mode 100644 index 0000000..0488486 --- /dev/null +++ b/merchant_ops/views_orders.py @@ -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) diff --git a/merchant_ops/views_staff.py b/merchant_ops/views_staff.py new file mode 100644 index 0000000..e446c57 --- /dev/null +++ b/merchant_ops/views_staff.py @@ -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}) diff --git a/users/urls.py b/users/urls.py index cdfc591..e4ede45 100644 --- a/users/urls.py +++ b/users/urls.py @@ -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')), ] \ No newline at end of file diff --git a/users/views.py b/users/views.py index 2dde352..aae931e 100644 --- a/users/views.py +++ b/users/views.py @@ -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: # 从管理员表查找邀请码