From 890e465e4061e15ca8d5cf22708ce79d08affd35 Mon Sep 17 00:00:00 2001 From: XingQue Date: Mon, 27 Jul 2026 00:50:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=95=E8=AF=89=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E6=AD=A3=E8=A7=84=E5=8C=96=EF=BC=88=E4=BB=8B=E5=85=A5/?= =?UTF-8?q?=E7=89=B5=E8=BF=9E=E6=8F=90=E7=8E=B0=E6=8B=A6=E6=88=AA=EF=BC=8C?= =?UTF-8?q?=E4=BB=85=E4=BF=B1=E4=B9=90=E9=83=A8=E5=BC=80=E5=85=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持被投诉对象、平台介入、协商中状态与组长投诉;提现硬拦含管事向上牵连组长,默认仅 lxs 开启。 Co-authored-by: Cursor --- gongdan/constants.py | 30 +- gongdan/management/__init__.py | 0 gongdan/management/commands/__init__.py | 0 .../commands/seed_gongdan_withdraw_block.py | 24 + .../0002_gongdan_target_intervene.py | 81 +++ gongdan/models.py | 33 +- gongdan/services.py | 480 ++++++++++++++++-- gongdan/urls.py | 10 + gongdan/views.py | 184 +++++-- users/tixian_shenhe_services.py | 10 + users/views/tixian.py | 11 +- 11 files changed, 785 insertions(+), 78 deletions(-) create mode 100644 gongdan/management/__init__.py create mode 100644 gongdan/management/commands/__init__.py create mode 100644 gongdan/management/commands/seed_gongdan_withdraw_block.py create mode 100644 gongdan/migrations/0002_gongdan_target_intervene.py diff --git a/gongdan/constants.py b/gongdan/constants.py index 2598f6d..d669110 100644 --- a/gongdan/constants.py +++ b/gongdan/constants.py @@ -1,11 +1,12 @@ """投诉工单常量。""" # 投诉类型 -TYPE_ORDER = 1 # 订单投诉(需订单 OrderID) -TYPE_RECHARGE = 2 # 充值投诉(需 Czjilu.dingdan_id) -TYPE_PENALTY = 3 # 罚款投诉(需罚单 Penalty.id) +TYPE_ORDER = 1 # 订单投诉 +TYPE_RECHARGE = 2 # 充值投诉 +TYPE_PENALTY = 3 # 罚款投诉 TYPE_GUANSHI = 4 # 管事投诉 TYPE_FRAUD = 5 # 被骗投诉 +TYPE_ZUZHANG = 6 # 组长投诉 TYPE_CHOICES = ( (TYPE_ORDER, '订单投诉'), @@ -13,30 +14,47 @@ TYPE_CHOICES = ( (TYPE_PENALTY, '罚款投诉'), (TYPE_GUANSHI, '管事投诉'), (TYPE_FRAUD, '被骗投诉'), + (TYPE_ZUZHANG, '组长投诉'), ) TYPE_LABELS = dict(TYPE_CHOICES) -# 状态:1处理中 2已处理 3不满意(可升级)4已关闭 +# 状态:1处理中 2已处理 3不满意 4已关闭 5协商中 STATUS_PROCESSING = 1 STATUS_DONE = 2 STATUS_DISSATISFIED = 3 STATUS_CLOSED = 4 +STATUS_NEGOTIATING = 5 STATUS_CHOICES = ( (STATUS_PROCESSING, '处理中'), (STATUS_DONE, '已处理'), (STATUS_DISSATISFIED, '不满意'), (STATUS_CLOSED, '已关闭'), + (STATUS_NEGOTIATING, '协商中'), ) STATUS_LABELS = dict(STATUS_CHOICES) +# 未结清(拦提现):处理中 / 不满意 / 协商中 +OPEN_STATUSES = (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING) + +TARGET_ROLE_DASHOU = 'dashou' +TARGET_ROLE_GUANSHI = 'guanshi' +TARGET_ROLE_ZUZHANG = 'zuzhang' +TARGET_ROLE_PLATFORM = 'platform' +TARGET_ROLE_LABELS = { + TARGET_ROLE_DASHOU: '接单员', + TARGET_ROLE_GUANSHI: '管事', + TARGET_ROLE_ZUZHANG: '组长', + TARGET_ROLE_PLATFORM: '平台', +} + MAX_IMAGES = 9 MAX_SHUOMING_LEN = 500 MAX_SHENGJI_TIMES = 5 OSS_PREFIX = 'gongdan' # 权限码 -PERM_VIEW = 'gdck666' # 查看工单 -PERM_HANDLE = 'gdcl666' # 处理工单 +PERM_VIEW = 'gdck666' +PERM_HANDLE = 'gdcl666' diff --git a/gongdan/management/__init__.py b/gongdan/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gongdan/management/commands/__init__.py b/gongdan/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gongdan/management/commands/seed_gongdan_withdraw_block.py b/gongdan/management/commands/seed_gongdan_withdraw_block.py new file mode 100644 index 0000000..76c5af2 --- /dev/null +++ b/gongdan/management/commands/seed_gongdan_withdraw_block.py @@ -0,0 +1,24 @@ +"""种子:俱乐部工单提现拦截开关。默认仅 lxs 打开。""" +from django.core.management.base import BaseCommand + +from gongdan.services import ensure_club_gongdan_config + + +class Command(BaseCommand): + help = '初始化俱乐部工单配置(complaint_withdraw_block)' + + def add_arguments(self, parser): + parser.add_argument('--club-id', default='lxs', help='俱乐部 ID,默认 lxs') + parser.add_argument('--enable', action='store_true', default=True, help='打开提现拦截') + parser.add_argument('--disable', action='store_true', help='关闭提现拦截') + + def handle(self, *args, **options): + club_id = (options.get('club_id') or 'lxs').strip() + enable = not options.get('disable') + row = ensure_club_gongdan_config(club_id, withdraw_block=enable) + if row.complaint_withdraw_block != enable: + row.complaint_withdraw_block = enable + row.save(update_fields=['complaint_withdraw_block', 'UpdateTime']) + self.stdout.write(self.style.SUCCESS( + f'club={club_id} complaint_withdraw_block={row.complaint_withdraw_block}' + )) diff --git a/gongdan/migrations/0002_gongdan_target_intervene.py b/gongdan/migrations/0002_gongdan_target_intervene.py new file mode 100644 index 0000000..ccd17af --- /dev/null +++ b/gongdan/migrations/0002_gongdan_target_intervene.py @@ -0,0 +1,81 @@ +# 工单:被投诉对象 / 平台介入 / 协商中状态 / 俱乐部提现拦截开关 +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gongdan', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='gongdan', + name='target_role', + field=models.CharField(blank=True, db_index=True, default='', max_length=16, verbose_name='被投诉角色'), + ), + migrations.AddField( + model_name='gongdan', + name='target_user_id', + field=models.CharField(blank=True, db_index=True, default='', max_length=32, verbose_name='被投诉人UID'), + ), + migrations.AddField( + model_name='gongdan', + name='platform_intervened', + field=models.BooleanField(db_index=True, default=False, verbose_name='平台已介入'), + ), + migrations.AddField( + model_name='gongdan', + name='intervene_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='介入时间'), + ), + migrations.AddField( + model_name='gongdan', + name='intervene_by', + field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入操作人UID(仅存库)'), + ), + migrations.AddField( + model_name='gongdan', + name='intervene_reason', + field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入原因码'), + ), + migrations.AlterField( + model_name='gongdan', + name='leixing', + field=models.IntegerField( + choices=[ + (1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'), + (4, '管事投诉'), (5, '被骗投诉'), (6, '组长投诉'), + ], + db_index=True, + verbose_name='投诉类型', + ), + ), + migrations.AlterField( + model_name='gongdan', + name='zhuangtai', + field=models.IntegerField( + choices=[ + (1, '处理中'), (2, '已处理'), (3, '不满意'), + (4, '已关闭'), (5, '协商中'), + ], + db_index=True, + default=1, + verbose_name='状态', + ), + ), + migrations.CreateModel( + name='ClubGongdanConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='俱乐部')), + ('complaint_withdraw_block', models.BooleanField(default=False, verbose_name='投诉拦截提现')), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '俱乐部工单配置', + 'db_table': 'club_gongdan_config', + }, + ), + ] diff --git a/gongdan/models.py b/gongdan/models.py index 2766b86..a28e217 100644 --- a/gongdan/models.py +++ b/gongdan/models.py @@ -1,4 +1,4 @@ -"""投诉工单模型:主单 + 证据图 + 升级补充说明。""" +"""投诉工单模型:主单 + 证据图 + 升级补充说明 + 俱乐部开关。""" from django.db import models from gvsdsdk.model_base import QModel @@ -9,11 +9,14 @@ class Gongdan(QModel): """投诉工单主表。""" gongdan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='工单号') leixing = models.IntegerField(db_index=True, choices=TYPE_CHOICES, verbose_name='投诉类型') - # 按类型选填关联业务 ID(字符串统一存,便于展示与复制) order_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='订单ID') chongzhi_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='充值单ID') fadan_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='罚单ID') - guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID') + guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID(兼容)') + + # 通用被投诉对象(存库;接口对普通端脱敏不展示 UID) + target_role = models.CharField(max_length=16, blank=True, default='', db_index=True, verbose_name='被投诉角色') + target_user_id = models.CharField(max_length=32, blank=True, default='', db_index=True, verbose_name='被投诉人UID') shuoming = models.CharField(max_length=500, verbose_name='投诉说明') shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人UserUID') @@ -27,6 +30,12 @@ class Gongdan(QModel): chuliren_name = models.CharField(max_length=64, null=True, blank=True, verbose_name='处理人展示名') chuli_time = models.DateTimeField(null=True, blank=True, verbose_name='处理时间') + # 平台介入(对外只展示「平台已介入」,不露客服 UID) + platform_intervened = models.BooleanField(default=False, db_index=True, verbose_name='平台已介入') + intervene_at = models.DateTimeField(null=True, blank=True, verbose_name='介入时间') + intervene_by = models.CharField(max_length=32, blank=True, default='', verbose_name='介入操作人UID(仅存库)') + intervene_reason = models.CharField(max_length=32, blank=True, default='', verbose_name='介入原因码') + shengji_cishu = models.IntegerField(default=0, verbose_name='升级次数') CreateTime = models.DateTimeField(auto_now_add=True, db_index=True) UpdateTime = models.DateTimeField(auto_now=True) @@ -38,11 +47,10 @@ class Gongdan(QModel): class GongdanImage(QModel): - """工单证据图(最多9张,以主单当前版本为准;升级时可整体替换)。""" + """工单证据图(最多9张)。""" gongdan = models.ForeignKey( Gongdan, on_delete=models.CASCADE, related_name='images', db_column='gongdan_pk', ) - # 冗余业务工单号(勿命名 gongdan_id,会与 FK 访问器冲突) gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余') url = models.CharField(max_length=512, verbose_name='图片URL') paixu = models.IntegerField(default=0, verbose_name='排序') @@ -55,7 +63,7 @@ class GongdanImage(QModel): class GongdanShengji(QModel): - """升级/继续投诉补充说明(只记文字与次数;图片改主表 images)。""" + """升级/继续投诉补充说明。""" gongdan = models.ForeignKey( Gongdan, on_delete=models.CASCADE, related_name='shengji_list', db_column='gongdan_pk', ) @@ -70,3 +78,16 @@ class GongdanShengji(QModel): verbose_name = '工单升级记录' ordering = ['cishu', 'id'] unique_together = [['gongdan', 'cishu']] + + +class ClubGongdanConfig(QModel): + """俱乐部工单相关开关(默认全关,仅种子打开的俱乐部生效)。""" + club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部') + # 未结清被投诉/牵连时禁止提现 + complaint_withdraw_block = models.BooleanField(default=False, verbose_name='投诉拦截提现') + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_gongdan_config' + verbose_name = '俱乐部工单配置' diff --git a/gongdan/services.py b/gongdan/services.py index c15ef0c..ac7dd8f 100644 --- a/gongdan/services.py +++ b/gongdan/services.py @@ -1,20 +1,22 @@ -"""工单业务:创建 / 升级 / 客服处理(幂等与校验)。""" +"""工单业务:创建 / 升级 / 客服处理 / 提现拦截 / 列表范围。""" from __future__ import annotations import logging import random import time -from decimal import Decimal from django.db import transaction +from django.db.models import Q from django.utils import timezone from gongdan.constants import ( - TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, + TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, TYPE_ZUZHANG, TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED, - STATUS_LABELS, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, OSS_PREFIX, + STATUS_NEGOTIATING, STATUS_LABELS, OPEN_STATUSES, MAX_IMAGES, MAX_SHUOMING_LEN, + MAX_SHENGJI_TIMES, TARGET_ROLE_GUANSHI, TARGET_ROLE_ZUZHANG, TARGET_ROLE_LABELS, + TARGET_ROLE_DASHOU, TARGET_ROLE_PLATFORM, ) -from gongdan.models import Gongdan, GongdanImage, GongdanShengji +from gongdan.models import Gongdan, GongdanImage, GongdanShengji, ClubGongdanConfig logger = logging.getLogger(__name__) @@ -36,8 +38,84 @@ def _trim(text, max_len=MAX_SHUOMING_LEN): return (text or '').strip()[:max_len] +def is_complaint_withdraw_block_enabled(club_id: str) -> bool: + cid = (club_id or '').strip() + if not cid: + return False + row = ClubGongdanConfig.query.filter(club_id=cid).first() + return bool(row and row.complaint_withdraw_block) + + +def ensure_club_gongdan_config(club_id: str, *, withdraw_block: bool = False) -> ClubGongdanConfig: + cid = (club_id or '').strip() + row = ClubGongdanConfig.query.filter(club_id=cid).first() + if not row: + row = ClubGongdanConfig(club_id=cid, complaint_withdraw_block=bool(withdraw_block)) + row.save() + return row + + +def _user_nicheng(uid: str) -> str: + """展示昵称:打手 nicheng → 老板 nickname → 用户{UID}。不把 UID 当昵称展示。""" + uid = (uid or '').strip() + if not uid: + return '' + try: + from users.models import UserDashou, UserBoss + from users.business_models import User + + ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first() + if ds and (ds.nicheng or '').strip(): + return (ds.nicheng or '').strip()[:32] + boss = UserBoss.query.filter(user__UserUID=uid).select_related('user').first() + if boss and (boss.nickname or '').strip(): + return (boss.nickname or '').strip()[:32] + u = User.query.filter(UserUID=uid).first() or User.objects.filter(UserUID=uid).first() + if u and (getattr(u, 'Phone', None) or '').strip(): + phone = str(u.Phone).strip() + if len(phone) >= 4: + return f'用户*{phone[-4:]}' + return '用户' + except Exception: + return '用户' + + +def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None): + """归一化被投诉对象;兼容旧 guanshi_id。管事/组长投诉必须选人。""" + role = (target_role or '').strip().lower() + tid = (target_user_id or '').strip() + gid = (guanshi_id or '').strip() or None + + if leixing == TYPE_GUANSHI: + role = TARGET_ROLE_GUANSHI + tid = tid or (gid or '') + if not tid: + raise GongdanError('请选择被投诉的管事', 'need_guanshi') + from users.models import UserGuanshi + if not UserGuanshi.query.filter(user__UserUID=tid).exists(): + if not UserGuanshi.objects.filter(user__UserUID=tid).exists(): + raise GongdanError('管事不存在,请重新选择', 'guanshi_not_found') + return role, tid, tid + + if leixing == TYPE_ZUZHANG: + role = TARGET_ROLE_ZUZHANG + if not tid: + raise GongdanError('请选择被投诉的组长', 'need_zuzhang') + from users.models import UserZuzhang + if not UserZuzhang.query.filter(user__UserUID=tid).exists(): + if not UserZuzhang.objects.filter(user__UserUID=tid).exists(): + raise GongdanError('组长不存在,请重新选择', 'zuzhang_not_found') + return role, tid, None + + if role and tid: + if role not in TARGET_ROLE_LABELS and role not in ('order', 'recharge', 'penalty'): + raise GongdanError('无效的被投诉角色', 'bad_target_role') + return role, tid, gid if role == TARGET_ROLE_GUANSHI else None + + return role or '', tid or '', gid + + def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None): - """按类型校验必填关联 ID,并尽量核对库内是否存在。""" order_id = (order_id or '').strip() or None chongzhi_id = (chongzhi_id or '').strip() or None fadan_id = (fadan_id or '').strip() or None @@ -72,20 +150,46 @@ def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, return None, None, str(fid), None if leixing == TYPE_GUANSHI: - # 管事UID 选填,有则校验存在 - if guanshi_id: - from users.models import UserGuanshi - if not UserGuanshi.objects.filter(user__UserUID=guanshi_id).exists(): - raise GongdanError('管事不存在,请核对管事ID', 'guanshi_not_found') return None, None, None, guanshi_id - if leixing == TYPE_FRAUD: + if leixing in (TYPE_FRAUD, TYPE_ZUZHANG): return None, None, None, None raise GongdanError('不支持的投诉类型', 'bad_type') -def serialize_gongdan(gd, include_images=True, include_shengji=False): +def _effective_target_uid(gd) -> str: + return (gd.target_user_id or gd.guanshi_id or '').strip() + + +def _effective_target_role(gd) -> str: + role = (gd.target_role or '').strip() + if role: + return role + if gd.leixing == TYPE_GUANSHI or gd.guanshi_id: + return TARGET_ROLE_GUANSHI + if gd.leixing == TYPE_ZUZHANG: + return TARGET_ROLE_ZUZHANG + return '' + + +def serialize_gongdan(gd, include_images=True, include_shengji=False, *, viewer_uid=None, for_admin=False): + """ + 序列化。默认脱敏:不把申请人/被投诉人/客服 UID 作为展示字段下发。 + for_admin=True 时仍不主动展示 UID 列,只多给内部审计需要的标记。 + contact_peer_uid 仅当 viewer 是当事方时返回,供 IM 打开(前端勿渲染)。 + """ + t_role = _effective_target_role(gd) + t_uid = _effective_target_uid(gd) + relation = '' + if viewer_uid: + if gd.shenqingren_id == viewer_uid: + relation = 'created' + elif t_uid and t_uid == viewer_uid: + relation = 'received' + else: + relation = 'linked' if _viewer_is_linked_zuzhang(viewer_uid, gd) else '' + data = { 'gongdan_id': gd.gongdan_id, 'leixing': gd.leixing, @@ -93,20 +197,58 @@ def serialize_gongdan(gd, include_images=True, include_shengji=False): 'order_id': gd.order_id or '', 'chongzhi_id': gd.chongzhi_id or '', 'fadan_id': gd.fadan_id or '', - 'guanshi_id': gd.guanshi_id or '', 'shuoming': gd.shuoming or '', - 'shenqingren_id': gd.shenqingren_id, 'club_id': gd.club_id or '', 'zhuangtai': gd.zhuangtai, 'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)), 'chuli_jieguo': gd.chuli_jieguo or '', - 'chuliren_id': gd.chuliren_id or '', - 'chuliren_name': gd.chuliren_name or '', + # 对外:只显示「平台已介入」,不返回客服 UID + 'platform_intervened': bool(gd.platform_intervened), + 'intervene_at': gd.intervene_at.isoformat() if gd.intervene_at else '', + 'intervene_reason': gd.intervene_reason or '', 'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '', 'shengji_cishu': gd.shengji_cishu or 0, 'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '', 'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '', + 'target_role': t_role, + 'target_role_label': TARGET_ROLE_LABELS.get(t_role, t_role or ''), + 'target_nicheng': _user_nicheng(t_uid), + 'complainant_nicheng': _user_nicheng(gd.shenqingren_id), + 'relation': relation, + 'is_open': gd.zhuangtai in OPEN_STATUSES, + 'can_contact': False, + 'contact_peer_uid': '', + 'contact_peer_nicheng': '', + 'can_contact_complainant': False, + 'contact_complainant_uid': '', + 'contact_complainant_nicheng': '', } + if viewer_uid and relation in ('created', 'received'): + peer = t_uid if relation == 'created' else gd.shenqingren_id + if peer and peer != viewer_uid: + data['can_contact'] = True + data['contact_peer_uid'] = peer + data['contact_peer_nicheng'] = _user_nicheng(peer) + elif viewer_uid and relation == 'linked': + # 牵连组长:默认可联系主被投诉管事;另可联系投诉人 + if t_uid and t_uid != viewer_uid: + data['can_contact'] = True + data['contact_peer_uid'] = t_uid + data['contact_peer_nicheng'] = _user_nicheng(t_uid) + if gd.shenqingren_id and gd.shenqingren_id != viewer_uid: + data['can_contact_complainant'] = True + data['contact_complainant_uid'] = gd.shenqingren_id + data['contact_complainant_nicheng'] = _user_nicheng(gd.shenqingren_id) + if for_admin: + # 客服需要 UID 做内部处理;用户端绝不下发 + data['shenqingren_id'] = gd.shenqingren_id or '' + data['target_user_id'] = t_uid or '' + data['guanshi_id'] = gd.guanshi_id or '' + data['chuliren_id'] = gd.chuliren_id or '' + data['chuliren_name'] = gd.chuliren_name or ('平台客服' if gd.platform_intervened or gd.chuliren_id else '') + else: + data['chuliren_name'] = '平台客服' if (gd.platform_intervened or gd.chuliren_name) else '' + if include_images: data['images'] = [ {'url': img.url, 'paixu': img.paixu} @@ -124,6 +266,209 @@ def serialize_gongdan(gd, include_images=True, include_shengji=False): return data +def _viewer_is_linked_zuzhang(viewer_uid: str, gd) -> bool: + """下级管事被投诉时,其上级组长视为牵连方。""" + t_uid = _effective_target_uid(gd) + t_role = _effective_target_role(gd) + if not t_uid or t_role != TARGET_ROLE_GUANSHI: + return False + try: + from users.models import UserGuanshi + gs = UserGuanshi.objects.filter(user__UserUID=t_uid).first() + if not gs: + return False + return (gs.yaoqingren or '').strip() == str(viewer_uid) + except Exception: + return False + + +def _sub_guanshi_uids(zuzhang_uid: str): + from users.models import UserGuanshi + rows = UserGuanshi.objects.filter(yaoqingren=str(zuzhang_uid)).select_related('user') + out = [] + for r in rows: + uid = getattr(getattr(r, 'user', None), 'UserUID', None) + if uid: + out.append(str(uid)) + return out + + +def open_complaint_q_for_target_uids(uids): + """未结清且指向这些 UID 的工单条件。""" + uids = [str(u) for u in (uids or []) if u] + if not uids: + return Q(pk__in=[]) + return ( + Q(zhuangtai__in=OPEN_STATUSES) + & (Q(target_user_id__in=uids) | Q(guanshi_id__in=uids)) + ) + + +def check_complaint_blocks_withdraw(user_main, club_id=None): + """ + 提现拦截。 + 返回 (ok, msg, extra) + extra: reason=received|linked, tab, count + """ + try: + from jituan.services.club_user import get_user_club_id + cid = (club_id or get_user_club_id(user_main) or '').strip() + except Exception: + cid = (club_id or '').strip() + + if not is_complaint_withdraw_block_enabled(cid): + return True, '', {} + + uid = str(user_main.UserUID) + base = Gongdan.query.filter(club_id=cid) if cid else Gongdan.query.all() + + # 1) 自己是主被投诉人 + qs_recv = base.filter(open_complaint_q_for_target_uids([uid])) + cnt = qs_recv.count() + if cnt: + return False, '你有未处理完的投诉,请处理后再提现', { + 'reason': 'received', + 'tab': 'received', + 'count': cnt, + 'code': 'COMPLAINT_BLOCK_WITHDRAW', + } + + # 2) 自己是组长,下级管事被投诉 → 牵连 + try: + from users.models import UserZuzhang + is_zz = UserZuzhang.query.filter(user=user_main).exists() + except Exception: + is_zz = False + if is_zz: + subs = _sub_guanshi_uids(uid) + if subs: + qs_link = base.filter(open_complaint_q_for_target_uids(subs)).filter( + Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI) + ) + cnt2 = qs_link.count() + if cnt2: + return False, '下级管事有未处理投诉,暂不可提现', { + 'reason': 'linked', + 'tab': 'linked', + 'count': cnt2, + 'code': 'COMPLAINT_BLOCK_WITHDRAW', + } + + return True, '', {} + + +def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20): + """按昵称/UID 搜管事或组长,供投诉选人(返回 uid 仅供提交,前端展示昵称)。""" + role = (role or '').strip().lower() + keyword = (keyword or '').strip() + page = max(int(page or 1), 1) + page_size = min(max(int(page_size or 20), 1), 50) + cid = (club_id or '').strip() + + from users.models import UserGuanshi, UserZuzhang, UserBoss + + rows = [] + if role == TARGET_ROLE_GUANSHI: + qs = UserGuanshi.query.filter(zhuangtai=1).select_related('user') + if cid: + qs = qs.filter(user__ClubID=cid) + if keyword: + boss_uids = list( + UserBoss.query.filter(nickname__icontains=keyword).values_list('user__UserUID', flat=True)[:80] + ) + qs = qs.filter(Q(user__UserUID__icontains=keyword) | Q(user__UserUID__in=boss_uids)) + qs = qs.order_by('-id') + total = qs.count() + start = (page - 1) * page_size + for g in qs[start:start + page_size]: + uid = getattr(getattr(g, 'user', None), 'UserUID', None) + if not uid: + continue + rows.append({ + 'user_id': str(uid), + 'nicheng': _user_nicheng(str(uid)), + 'role': TARGET_ROLE_GUANSHI, + 'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_GUANSHI], + }) + elif role == TARGET_ROLE_ZUZHANG: + qs = UserZuzhang.query.filter(zhuangtai=1).select_related('user') + if cid: + qs = qs.filter(user__ClubID=cid) + if keyword: + boss_uids = list( + UserBoss.query.filter(nickname__icontains=keyword).values_list('user__UserUID', flat=True)[:80] + ) + qs = qs.filter(Q(user__UserUID__icontains=keyword) | Q(user__UserUID__in=boss_uids)) + qs = qs.order_by('-id') + total = qs.count() + start = (page - 1) * page_size + for z in qs[start:start + page_size]: + uid = getattr(getattr(z, 'user', None), 'UserUID', None) + if not uid: + continue + rows.append({ + 'user_id': str(uid), + 'nicheng': _user_nicheng(str(uid)), + 'role': TARGET_ROLE_ZUZHANG, + 'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_ZUZHANG], + }) + else: + raise GongdanError('role 须为 guanshi 或 zuzhang', 'bad_role') + + return {'list': rows, 'total': total, 'page': page, 'page_size': page_size, 'role': role} + + +def list_gongdan_for_user(yonghuid, *, scope='created', club_id=None, page=1, page_size=20): + """ + scope: + created — 我发起的 + received — 我被投诉的 + linked — 下级牵连(组长) + """ + scope = (scope or 'created').strip().lower() + page = max(int(page or 1), 1) + page_size = min(max(int(page_size or 20), 1), 50) + uid = str(yonghuid) + qs = Gongdan.query.all() + if club_id: + qs = qs.filter(club_id=club_id) + + if scope == 'received': + qs = qs.filter(Q(target_user_id=uid) | Q(guanshi_id=uid)).order_by('-CreateTime') + elif scope == 'linked': + subs = _sub_guanshi_uids(uid) + if not subs: + qs = qs.none() + else: + qs = qs.filter(Q(target_user_id__in=subs) | Q(guanshi_id__in=subs)).filter( + Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI) + ).order_by('-CreateTime') + else: + qs = qs.filter(shenqingren_id=uid).order_by('-CreateTime') + + total = qs.count() + start = (page - 1) * page_size + rows = list(qs[start:start + page_size]) + return { + 'list': [serialize_gongdan(g, include_images=False, viewer_uid=uid) for g in rows], + 'total': total, + 'page': page, + 'page_size': page_size, + 'scope': scope, + } + + +def user_can_view_gongdan(gd, yonghuid) -> bool: + uid = str(yonghuid) + if gd.shenqingren_id == uid: + return True + if _effective_target_uid(gd) == uid: + return True + if _viewer_is_linked_zuzhang(uid, gd): + return True + return False + + def _replace_images(gd, image_urls): urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()] if len(urls) > MAX_IMAGES: @@ -149,6 +494,8 @@ def create_gongdan( chongzhi_id=None, fadan_id=None, guanshi_id=None, + target_role=None, + target_user_id=None, image_urls=None, ): try: @@ -163,7 +510,10 @@ def create_gongdan( raise GongdanError('请填写投诉说明', 'need_shuoming') order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs( - leixing, order_id, chongzhi_id, fadan_id, guanshi_id, + leixing, order_id, chongzhi_id, fadan_id, guanshi_id or target_user_id, + ) + t_role, t_uid, guanshi_id = _resolve_target( + leixing, guanshi_id=guanshi_id, target_role=target_role, target_user_id=target_user_id or guanshi_id, ) gid = _gen_gongdan_id() @@ -179,6 +529,8 @@ def create_gongdan( chongzhi_id=chongzhi_id, fadan_id=fadan_id, guanshi_id=guanshi_id, + target_role=t_role or '', + target_user_id=t_uid or '', shuoming=text, shenqingren_id=yonghuid, club_id=club_id or None, @@ -186,13 +538,12 @@ def create_gongdan( shengji_cishu=0, ) _replace_images(gd, image_urls) - logger.info('创建工单 %s type=%s user=%s', gid, leixing, yonghuid) + logger.info('创建工单 %s type=%s user=%s target=%s/%s', gid, leixing, yonghuid, t_role, t_uid) return gd @transaction.atomic def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None): - """不满意后继续投诉:写升级记录;可选整体替换证据图。""" gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() if not gd: raise GongdanError('工单不存在', 'not_found') @@ -219,13 +570,11 @@ def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None): gd.zhuangtai = STATUS_PROCESSING gd.chuli_jieguo = '' gd.chuli_time = None - # 保留原处理人记录便于审计,但状态回到处理中 gd.save(update_fields=[ 'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime', ]) if image_urls is not None: _replace_images(gd, image_urls) - logger.info('工单升级 %s cishu=%s', gongdan_id, next_cishu) return gd @@ -237,13 +586,16 @@ def handle_gongdan( handler_name, action, chuli_jieguo='', + intervene_reason='', ): """ - 客服处理: - action=done → 已处理 - action=dissatisfied → 标记不满意(一般由用户侧触发,客服 sparingly) - action=close → 关闭 - action=processing → 重新标处理中 + action: + done/finish/resolve → 已处理 + close → 关闭 + processing/reopen → 处理中 + dissatisfied → 不满意 + intervene → 标记平台介入(不改结案状态) + negotiate → 协商中(客服 sparingly) """ gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() if not gd: @@ -252,22 +604,40 @@ def handle_gongdan( action = (action or '').strip().lower() jieguo = _trim(chuli_jieguo) + if action in ('intervene', 'platform_intervene'): + gd.platform_intervened = True + gd.intervene_at = timezone.now() + gd.intervene_by = (handler_uid or '')[:32] + gd.intervene_reason = (intervene_reason or 'kefu')[:32] + if gd.zhuangtai not in OPEN_STATUSES: + gd.zhuangtai = STATUS_PROCESSING + gd.save() + logger.info('工单平台介入 %s by=%s', gongdan_id, handler_uid) + return gd + if action in ('done', 'finish', 'resolve'): if not jieguo: raise GongdanError('请填写处理结果', 'need_jieguo') gd.zhuangtai = STATUS_DONE gd.chuli_jieguo = jieguo gd.chuliren_id = handler_uid - gd.chuliren_name = (handler_name or '')[:64] + gd.chuliren_name = (handler_name or '平台客服')[:64] gd.chuli_time = timezone.now() + if not gd.platform_intervened: + gd.platform_intervened = True + gd.intervene_at = gd.intervene_at or timezone.now() + gd.intervene_by = (handler_uid or '')[:32] + gd.intervene_reason = gd.intervene_reason or 'kefu_done' elif action in ('close',): gd.zhuangtai = STATUS_CLOSED gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭' gd.chuliren_id = handler_uid - gd.chuliren_name = (handler_name or '')[:64] + gd.chuliren_name = (handler_name or '平台客服')[:64] gd.chuli_time = timezone.now() elif action in ('processing', 'reopen'): gd.zhuangtai = STATUS_PROCESSING + elif action in ('negotiate',): + gd.zhuangtai = STATUS_NEGOTIATING elif action in ('dissatisfied',): gd.zhuangtai = STATUS_DISSATISFIED else: @@ -290,3 +660,55 @@ def user_mark_dissatisfied(*, gongdan_id, yonghuid): gd.zhuangtai = STATUS_DISSATISFIED gd.save(update_fields=['zhuangtai', 'UpdateTime']) return gd + + +@transaction.atomic +def user_resolve_gongdan(*, gongdan_id, yonghuid): + """投诉人确认已解决(私了结清)。""" + gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() + if not gd: + raise GongdanError('工单不存在', 'not_found') + if gd.shenqingren_id != yonghuid: + raise GongdanError('仅投诉人可确认解决', 'forbidden') + if gd.zhuangtai not in OPEN_STATUSES: + raise GongdanError('当前状态无需确认解决', 'bad_status') + gd.zhuangtai = STATUS_DONE + gd.chuli_jieguo = gd.chuli_jieguo or '双方协商已解决' + gd.chuli_time = timezone.now() + gd.chuliren_name = gd.chuliren_name or '双方协商' + gd.save() + return gd + + +@transaction.atomic +def user_start_negotiate(*, gongdan_id, yonghuid): + gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() + if not gd: + raise GongdanError('工单不存在', 'not_found') + if not user_can_view_gongdan(gd, yonghuid): + raise GongdanError('无权操作此工单', 'forbidden') + if gd.zhuangtai not in (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING): + raise GongdanError('当前状态不可转协商', 'bad_status') + gd.zhuangtai = STATUS_NEGOTIATING + gd.save(update_fields=['zhuangtai', 'UpdateTime']) + return gd + + +@transaction.atomic +def user_apply_intervene(*, gongdan_id, yonghuid): + """用户申请平台介入:打标,仍保持未结清。""" + gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() + if not gd: + raise GongdanError('工单不存在', 'not_found') + if gd.shenqingren_id != yonghuid and _effective_target_uid(gd) != yonghuid: + raise GongdanError('无权申请介入', 'forbidden') + if gd.zhuangtai not in OPEN_STATUSES: + raise GongdanError('当前状态不可申请介入', 'bad_status') + gd.platform_intervened = True + if not gd.intervene_at: + gd.intervene_at = timezone.now() + gd.intervene_reason = gd.intervene_reason or 'user_apply' + if gd.zhuangtai == STATUS_NEGOTIATING: + gd.zhuangtai = STATUS_PROCESSING + gd.save() + return gd diff --git a/gongdan/urls.py b/gongdan/urls.py index a7cd4ed..e3720da 100644 --- a/gongdan/urls.py +++ b/gongdan/urls.py @@ -7,6 +7,11 @@ from gongdan.views import ( GongdanDetailView, GongdanEscalateView, GongdanDissatisfiedView, + GongdanResolveView, + GongdanNegotiateView, + GongdanApplyInterveneView, + GongdanWithdrawCheckView, + GongdanSearchTargetView, GongdanMetaView, ChongzhiRecordListView, GongdanAdminListView, @@ -20,6 +25,11 @@ urlpatterns = [ path('detail/', GongdanDetailView.as_view(), name='工单详情'), path('escalate/', GongdanEscalateView.as_view(), name='继续投诉升级'), path('dissatisfied/', GongdanDissatisfiedView.as_view(), name='标记不满意'), + path('resolve/', GongdanResolveView.as_view(), name='投诉人确认解决'), + path('negotiate/', GongdanNegotiateView.as_view(), name='转协商中'), + path('apply-intervene/', GongdanApplyInterveneView.as_view(), name='申请平台介入'), + path('withdraw-block-check/', GongdanWithdrawCheckView.as_view(), name='提现投诉拦截检查'), + path('search-target/', GongdanSearchTargetView.as_view(), name='搜索被投诉对象'), path('meta/', GongdanMetaView.as_view(), name='工单枚举'), path('chongzhi-list/', ChongzhiRecordListView.as_view(), name='我的充值记录'), path('admin/list/', GongdanAdminListView.as_view(), name='客服工单列表'), diff --git a/gongdan/views.py b/gongdan/views.py index f1720c2..af976d7 100644 --- a/gongdan/views.py +++ b/gongdan/views.py @@ -20,6 +20,13 @@ from gongdan.services import ( handle_gongdan, serialize_gongdan, user_mark_dissatisfied, + list_gongdan_for_user, + user_can_view_gongdan, + user_resolve_gongdan, + user_start_negotiate, + user_apply_intervene, + check_complaint_blocks_withdraw, + search_complaint_targets, ) from utils.oss_utils import upload_to_oss, validate_image @@ -93,9 +100,14 @@ class GongdanCreateView(APIView): chongzhi_id=d.get('chongzhi_id') or d.get('dingdanid') or d.get('dingdan_id'), fadan_id=d.get('fadan_id') or d.get('penalty_id'), guanshi_id=d.get('guanshi_id'), + target_role=d.get('target_role'), + target_user_id=d.get('target_user_id') or d.get('zuzhang_id') or d.get('guanshi_id'), image_urls=images, ) - return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '提交成功') + return _ok( + serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID), + '提交成功', + ) except GongdanError as e: return _err(str(e)) except Exception as e: @@ -114,18 +126,24 @@ class GongdanMyListView(APIView): return self._list(request) def _list(self, request): + scope = ( + request.data.get('scope') + or request.data.get('tab') + or request.query_params.get('scope') + or request.query_params.get('tab') + or 'created' + ) page = int(request.data.get('page') or request.query_params.get('page') or 1) page_size = min(int(request.data.get('page_size') or request.query_params.get('page_size') or 20), 50) - qs = Gongdan.query.filter(shenqingren_id=request.user.UserUID).order_by('-CreateTime') - total = qs.count() - start = max(page - 1, 0) * page_size - rows = qs[start:start + page_size] - return _ok({ - 'list': [serialize_gongdan(g, include_images=False) for g in rows], - 'total': total, - 'page': page, - 'page_size': page_size, - }) + club_id = _resolve_club(request) + data = list_gongdan_for_user( + request.user.UserUID, + scope=scope, + club_id=club_id, + page=page, + page_size=page_size, + ) + return _ok(data) class GongdanDetailView(APIView): @@ -139,22 +157,26 @@ class GongdanDetailView(APIView): gd = Gongdan.query.filter(gongdan_id=gid).first() if not gd: return _err('工单不存在', http=404) - # 申请人或有客服查看权限 - if gd.shenqingren_id != request.user.UserUID: - from backend.utils import verify_kefu_permission - from gongdan.constants import PERM_VIEW, PERM_HANDLE - kefu, perms_or_resp = verify_kefu_permission( - request, request.data.get('username') or getattr(request.user, 'Phone', None), - ) - if not kefu: - return perms_or_resp - perms = perms_or_resp if isinstance(perms_or_resp, list) else [] - if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms: - # 超管占位:is admin via kefu object - is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False) - if not is_admin: - return _err('无权查看', http=403) - return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True)) + # 申请人 / 被投诉人 / 牵连组长 / 客服 + if user_can_view_gongdan(gd, request.user.UserUID): + return _ok(serialize_gongdan( + gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID, + )) + from backend.utils import verify_kefu_permission + from gongdan.constants import PERM_VIEW, PERM_HANDLE + kefu, perms_or_resp = verify_kefu_permission( + request, request.data.get('username') or getattr(request.user, 'Phone', None), + ) + if not kefu: + return perms_or_resp + perms = perms_or_resp if isinstance(perms_or_resp, list) else [] + if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms: + is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False) + if not is_admin: + return _err('无权查看', http=403) + return _ok(serialize_gongdan( + gd, include_images=True, include_shengji=True, for_admin=True, + )) class GongdanEscalateView(APIView): @@ -173,7 +195,10 @@ class GongdanEscalateView(APIView): shuoming=d.get('shuoming') or '', image_urls=images, ) - return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '已提交补充投诉') + return _ok( + serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID), + '已提交补充投诉', + ) except GongdanError as e: return _err(str(e)) except Exception as e: @@ -191,7 +216,86 @@ class GongdanDissatisfiedView(APIView): gongdan_id=(request.data.get('gongdan_id') or '').strip(), yonghuid=request.user.UserUID, ) - return _ok(serialize_gongdan(gd), '已标记不满意,可继续投诉') + return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已标记不满意,可继续投诉') + except GongdanError as e: + return _err(str(e)) + + +class GongdanResolveView(APIView): + """投诉人确认已解决。POST /gongdan/resolve/""" + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + gd = user_resolve_gongdan( + gongdan_id=(request.data.get('gongdan_id') or '').strip(), + yonghuid=request.user.UserUID, + ) + return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已确认解决') + except GongdanError as e: + return _err(str(e)) + + +class GongdanNegotiateView(APIView): + """转协商中。POST /gongdan/negotiate/""" + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + gd = user_start_negotiate( + gongdan_id=(request.data.get('gongdan_id') or '').strip(), + yonghuid=request.user.UserUID, + ) + return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已进入协商') + except GongdanError as e: + return _err(str(e)) + + +class GongdanApplyInterveneView(APIView): + """申请平台介入。POST /gongdan/apply-intervene/""" + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + gd = user_apply_intervene( + gongdan_id=(request.data.get('gongdan_id') or '').strip(), + yonghuid=request.user.UserUID, + ) + return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已申请平台介入') + except GongdanError as e: + return _err(str(e)) + + +class GongdanWithdrawCheckView(APIView): + """提现前投诉拦截检查。POST /gongdan/withdraw-block-check/""" + permission_classes = [IsAuthenticated] + + def post(self, request): + ok, msg, extra = check_complaint_blocks_withdraw(request.user, club_id=_resolve_club(request)) + return _ok({ + 'blocked': not ok, + 'msg': msg, + 'reason': extra.get('reason') or '', + 'tab': extra.get('tab') or '', + 'count': extra.get('count') or 0, + }) + + +class GongdanSearchTargetView(APIView): + """搜索被投诉管事/组长。POST /gongdan/search-target/""" + permission_classes = [IsAuthenticated] + + def post(self, request): + d = request.data + try: + data = search_complaint_targets( + role=d.get('role') or d.get('target_role') or '', + keyword=d.get('keyword') or d.get('q') or '', + club_id=_resolve_club(request), + page=d.get('page') or 1, + page_size=d.get('page_size') or 20, + ) + return _ok(data) except GongdanError as e: return _err(str(e)) @@ -294,24 +398,29 @@ class GongdanAdminListView(APIView): only_escalated = str(request.data.get('only_escalated') or '').lower() in ('1', 'true', 'yes') need_handle = str(request.data.get('need_handle') or '').lower() in ('1', 'true', 'yes') if need_handle: - qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3)) + qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)) elif zhuangtai not in (None, ''): qs = qs.filter(zhuangtai=int(zhuangtai)) if only_escalated: - qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3)) + qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)) if leixing not in (None, ''): qs = qs.filter(leixing=int(leixing)) if keyword: qs = qs.filter( Q(gongdan_id__icontains=keyword) | Q(shenqingren_id__icontains=keyword) + | Q(target_user_id__icontains=keyword) + | Q(guanshi_id__icontains=keyword) | Q(order_id__icontains=keyword) | Q(chongzhi_id__icontains=keyword) | Q(fadan_id__icontains=keyword) ) total = qs.count() start = max(page - 1, 0) * page_size - rows = [serialize_gongdan(g, include_images=False) for g in qs[start:start + page_size]] + rows = [ + serialize_gongdan(g, include_images=False, for_admin=True) + for g in qs[start:start + page_size] + ] # 统计基于俱乐部范围全量(不受当前筛选影响),方便客服一眼看到待办量 stats_qs = Gongdan.query.all() @@ -326,10 +435,11 @@ class GongdanAdminListView(APIView): 'done': stats_qs.filter(zhuangtai=2).count(), 'dissatisfied': stats_qs.filter(zhuangtai=3).count(), 'closed': stats_qs.filter(zhuangtai=4).count(), + 'negotiating': stats_qs.filter(zhuangtai=5).count(), 'escalated': stats_qs.filter(shengji_cishu__gt=0).filter( - Q(zhuangtai=1) | Q(zhuangtai=3) + Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5) ).count(), - 'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3)).count(), + 'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)).count(), } return _ok({ 'list': rows, @@ -358,8 +468,12 @@ class GongdanAdminHandleView(APIView): handler_name=handler_name, action=d.get('action') or 'done', chuli_jieguo=d.get('chuli_jieguo') or d.get('jieguo') or '', + intervene_reason=d.get('intervene_reason') or '', + ) + return _ok( + serialize_gongdan(gd, include_images=True, include_shengji=True, for_admin=True), + '处理成功', ) - return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '处理成功') except GongdanError as e: return _err(str(e)) except Exception as e: diff --git a/users/tixian_shenhe_services.py b/users/tixian_shenhe_services.py index 929412e..d44f450 100644 --- a/users/tixian_shenhe_services.py +++ b/users/tixian_shenhe_services.py @@ -337,6 +337,16 @@ def validate_withdraw_eligibility(user_main, leixing, jine): if not fadan_ok: return False, fadan_msg, {} + # 投诉未结清 / 管事被诉牵连上级组长(仅俱乐部开关打开时) + try: + from gongdan.services import check_complaint_blocks_withdraw + gd_ok, gd_msg, gd_extra = check_complaint_blocks_withdraw(user_main) + if not gd_ok: + return False, gd_msg, gd_extra or {} + except Exception as e: + import logging + logging.getLogger(__name__).error('投诉提现拦截检查异常: %s', e, exc_info=True) + if leixing == 1: try: dashou = UserDashou.query.get(user=user_main) diff --git a/users/views/tixian.py b/users/views/tixian.py index f771914..fed5c9e 100644 --- a/users/views/tixian.py +++ b/users/views/tixian.py @@ -402,8 +402,15 @@ class TixianShenqingView(APIView): ok, elig_msg, extra = validate_withdraw_eligibility(user_main, leixing, jine) if not ok: - return Response({'code': 8, 'msg': elig_msg}, - status=status.HTTP_400_BAD_REQUEST) + body = {'code': 8, 'msg': elig_msg} + if (extra or {}).get('code') == 'COMPLAINT_BLOCK_WITHDRAW': + body['data'] = { + 'tab': extra.get('tab') or 'received', + 'reason': extra.get('reason') or '', + 'count': extra.get('count') or 0, + 'code': 'COMPLAINT_BLOCK_WITHDRAW', + } + return Response(body, status=status.HTTP_400_BAD_REQUEST) # 3. 根据提现类型扣款(资格已在 validate_withdraw_eligibility 校验) nicheng = extra.get('nicheng', '')