feat: 投诉工单正规化(介入/牵连提现拦截,仅俱乐部开关)

支持被投诉对象、平台介入、协商中状态与组长投诉;提现硬拦含管事向上牵连组长,默认仅 lxs 开启。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-27 00:50:31 +08:00
parent fa6e679cbe
commit 890e465e40
11 changed files with 785 additions and 78 deletions

View File

@@ -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'

View File

View File

View File

@@ -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}'
))

View File

@@ -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',
},
),
]

View File

@@ -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 = '俱乐部工单配置'

View File

@@ -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

View File

@@ -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='客服工单列表'),

View File

@@ -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:

View File

@@ -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)

View File

@@ -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', '')