feat: 投诉仅限本人邀请人+人工客服配置+抢单池商家成交指标

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-27 01:41:29 +08:00
parent 0e102fbcba
commit 9ac0a3a5ad
7 changed files with 333 additions and 90 deletions

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gongdan', '0002_gongdan_target_intervene'),
]
operations = [
migrations.AddField(
model_name='clubgongdanconfig',
name='human_kefu_wechat',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='人工客服微信号'),
),
migrations.AddField(
model_name='clubgongdanconfig',
name='human_kefu_link',
field=models.CharField(blank=True, default='', max_length=512, verbose_name='人工客服链接'),
),
migrations.AddField(
model_name='clubgongdanconfig',
name='human_kefu_tip',
field=models.CharField(blank=True, default='', max_length=120, verbose_name='人工客服提示文案'),
),
]

View File

@@ -85,6 +85,10 @@ 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='投诉拦截提现')
# 投诉页人工客服(分俱乐部配置;有则前端展示)
human_kefu_wechat = models.CharField(max_length=64, blank=True, default='', verbose_name='人工客服微信号')
human_kefu_link = models.CharField(max_length=512, blank=True, default='', verbose_name='人工客服链接')
human_kefu_tip = models.CharField(max_length=120, blank=True, default='', verbose_name='人工客服提示文案')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)

View File

@@ -58,11 +58,25 @@ def ensure_club_gongdan_config(club_id: str, *, withdraw_block: bool = False) ->
def get_club_gongdan_config(club_id: str) -> dict:
cid = (club_id or '').strip()
if not cid:
return {'club_id': '', 'complaint_withdraw_block': False}
return {
'club_id': '',
'complaint_withdraw_block': False,
'human_kefu_wechat': '',
'human_kefu_link': '',
'human_kefu_tip': '',
'human_kefu_available': False,
}
row = ClubGongdanConfig.query.filter(club_id=cid).first()
wechat = ((row.human_kefu_wechat if row else '') or '').strip()
link = ((row.human_kefu_link if row else '') or '').strip()
tip = ((row.human_kefu_tip if row else '') or '').strip()
return {
'club_id': cid,
'complaint_withdraw_block': bool(row and row.complaint_withdraw_block),
'human_kefu_wechat': wechat,
'human_kefu_link': link,
'human_kefu_tip': tip or ('添加人工客服微信咨询' if wechat else ('点击联系人工客服' if link else '')),
'human_kefu_available': bool(wechat or link),
}
@@ -78,6 +92,107 @@ def set_complaint_withdraw_block(club_id: str, enabled: bool) -> dict:
return get_club_gongdan_config(cid)
def update_club_gongdan_config(club_id: str, **fields) -> dict:
"""更新俱乐部工单配置(提现拦截 / 人工客服联系方式)。"""
cid = (club_id or '').strip()
if not cid:
raise GongdanError('俱乐部不能为空', 'need_club')
row = ensure_club_gongdan_config(cid)
dirty = []
if 'complaint_withdraw_block' in fields and fields['complaint_withdraw_block'] is not None:
val = bool(fields['complaint_withdraw_block'])
if row.complaint_withdraw_block != val:
row.complaint_withdraw_block = val
dirty.append('complaint_withdraw_block')
if 'human_kefu_wechat' in fields and fields['human_kefu_wechat'] is not None:
val = str(fields['human_kefu_wechat'] or '').strip()[:64]
if (row.human_kefu_wechat or '') != val:
row.human_kefu_wechat = val
dirty.append('human_kefu_wechat')
if 'human_kefu_link' in fields and fields['human_kefu_link'] is not None:
val = str(fields['human_kefu_link'] or '').strip()[:512]
if (row.human_kefu_link or '') != val:
row.human_kefu_link = val
dirty.append('human_kefu_link')
if 'human_kefu_tip' in fields and fields['human_kefu_tip'] is not None:
val = str(fields['human_kefu_tip'] or '').strip()[:120]
if (row.human_kefu_tip or '') != val:
row.human_kefu_tip = val
dirty.append('human_kefu_tip')
if dirty:
dirty.append('UpdateTime')
row.save(update_fields=dirty)
return get_club_gongdan_config(cid)
def resolve_inviter_complaint_target(yonghuid: str, leixing: int) -> dict:
"""
管事/组长投诉只能针对本人邀请人,禁止搜索乱选:
- 管事投诉:打手的 yaoqingren且对方须是管事
- 组长投诉:管事的 yaoqingren且对方须是组长
无邀请人则不可投诉。
"""
uid = (yonghuid or '').strip()
if not uid:
raise GongdanError('请先登录', 'need_login')
from users.models import UserDashou, UserGuanshi, UserZuzhang
if leixing == TYPE_GUANSHI:
ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first()
if not ds:
raise GongdanError('仅打手可投诉自己的管事', 'need_dashou')
inviter = (ds.yaoqingren or '').strip()
if not inviter:
raise GongdanError('你没有邀请人,无法投诉管事', 'no_inviter')
gs = UserGuanshi.query.filter(user__UserUID=inviter, zhuangtai=1).select_related('user').first()
if not gs:
raise GongdanError('你的邀请人不是有效管事,无法投诉', 'inviter_not_guanshi')
return {
'role': TARGET_ROLE_GUANSHI,
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_GUANSHI],
'user_id': inviter,
'nicheng': _user_nicheng(inviter),
'can_complain': True,
}
if leixing == TYPE_ZUZHANG:
gs = UserGuanshi.query.filter(user__UserUID=uid).select_related('user').first()
if not gs:
raise GongdanError('仅管事可投诉自己的组长', 'need_guanshi')
inviter = (gs.yaoqingren or '').strip()
if not inviter:
raise GongdanError('你没有邀请人,无法投诉组长', 'no_inviter')
zz = UserZuzhang.query.filter(user__UserUID=inviter, zhuangtai=1).select_related('user').first()
if not zz:
raise GongdanError('你的邀请人不是有效组长,无法投诉', 'inviter_not_zuzhang')
return {
'role': TARGET_ROLE_ZUZHANG,
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_ZUZHANG],
'user_id': inviter,
'nicheng': _user_nicheng(inviter),
'can_complain': True,
}
raise GongdanError('仅管事/组长投诉需要邀请人', 'bad_type')
def peek_inviter_complaint_target(yonghuid: str, leixing: int) -> dict:
"""前端展示用:失败时返回 can_complain=False + 原因,不抛错。"""
try:
return resolve_inviter_complaint_target(yonghuid, leixing)
except GongdanError as e:
role = TARGET_ROLE_GUANSHI if leixing == TYPE_GUANSHI else TARGET_ROLE_ZUZHANG
return {
'role': role,
'role_label': TARGET_ROLE_LABELS.get(role, ''),
'user_id': '',
'nicheng': '',
'can_complain': False,
'reason': str(e),
}
def _user_nicheng(uid: str) -> str:
"""展示昵称:打手 nicheng → 老板 nickname → 用户{UID}。不把 UID 当昵称展示。"""
uid = (uid or '').strip()
@@ -103,32 +218,21 @@ def _user_nicheng(uid: str) -> str:
return '用户'
def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None):
"""归一化被投诉对象;兼容旧 guanshi_id。管事/组长投诉必须选人。"""
def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None, *, yonghuid=None):
"""归一化被投诉对象。管事/组长投诉强制本人邀请人,忽略前端传入的选人。"""
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
info = resolve_inviter_complaint_target(yonghuid, TYPE_GUANSHI)
tid = info['user_id']
return TARGET_ROLE_GUANSHI, 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
info = resolve_inviter_complaint_target(yonghuid, TYPE_ZUZHANG)
tid = info['user_id']
return TARGET_ROLE_ZUZHANG, tid, None
if role and tid:
if role not in TARGET_ROLE_LABELS and role not in ('order', 'recharge', 'penalty'):
@@ -388,65 +492,34 @@ def check_complaint_blocks_withdraw(user_main, club_id=None):
return True, '', {}
def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20):
"""按昵称/UID 搜管事或组长,供投诉选人(返回 uid 仅供提交,前端展示昵称)"""
def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20, yonghuid=None):
"""已废弃搜索选人仅返回本人邀请人防乱投诉。keyword/club 忽略"""
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],
})
leixing = TYPE_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],
})
leixing = TYPE_ZUZHANG
else:
raise GongdanError('role 须为 guanshi 或 zuzhang', 'bad_role')
return {'list': rows, 'total': total, 'page': page, 'page_size': page_size, 'role': role}
info = peek_inviter_complaint_target(yonghuid, leixing)
rows = []
if info.get('can_complain') and info.get('user_id'):
rows.append({
'user_id': info['user_id'],
'nicheng': info.get('nicheng') or '',
'role': info.get('role') or role,
'role_label': info.get('role_label') or '',
})
return {
'list': rows,
'total': len(rows),
'page': 1,
'page_size': 1,
'role': role,
'can_complain': bool(info.get('can_complain')),
'reason': info.get('reason') or '',
'invite_only': True,
}
def list_gongdan_for_user(yonghuid, *, scope='created', club_id=None, page=1, page_size=20):
@@ -544,7 +617,11 @@ def create_gongdan(
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,
leixing,
guanshi_id=guanshi_id,
target_role=target_role,
target_user_id=target_user_id or guanshi_id,
yonghuid=yonghuid,
)
gid = _gen_gongdan_id()

View File

@@ -12,6 +12,7 @@ from gongdan.views import (
GongdanApplyInterveneView,
GongdanWithdrawCheckView,
GongdanSearchTargetView,
GongdanMyInviterTargetView,
GongdanMetaView,
ChongzhiRecordListView,
GongdanAdminListView,
@@ -31,6 +32,7 @@ urlpatterns = [
path('apply-intervene/', GongdanApplyInterveneView.as_view(), name='申请平台介入'),
path('withdraw-block-check/', GongdanWithdrawCheckView.as_view(), name='提现投诉拦截检查'),
path('search-target/', GongdanSearchTargetView.as_view(), name='搜索被投诉对象'),
path('my-inviter-target/', GongdanMyInviterTargetView.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

@@ -11,6 +11,7 @@ from rest_framework import status
from gongdan.constants import (
MAX_IMAGES, OSS_PREFIX, PERM_VIEW, PERM_HANDLE, TYPE_LABELS, STATUS_LABELS,
TYPE_GUANSHI, TYPE_ZUZHANG,
)
from gongdan.models import Gongdan
from gongdan.services import (
@@ -29,6 +30,8 @@ from gongdan.services import (
search_complaint_targets,
get_club_gongdan_config,
set_complaint_withdraw_block,
update_club_gongdan_config,
peek_inviter_complaint_target,
)
from utils.oss_utils import upload_to_oss, validate_image
@@ -284,7 +287,7 @@ class GongdanWithdrawCheckView(APIView):
class GongdanSearchTargetView(APIView):
"""搜索被投诉管事/组长。POST /gongdan/search-target/"""
"""本人邀请人(已禁止全库搜索)。POST /gongdan/search-target/"""
permission_classes = [IsAuthenticated]
def post(self, request):
@@ -296,21 +299,48 @@ class GongdanSearchTargetView(APIView):
club_id=_resolve_club(request),
page=d.get('page') or 1,
page_size=d.get('page_size') or 20,
yonghuid=request.user.UserUID,
)
return _ok(data)
except GongdanError as e:
return _err(str(e))
class GongdanMyInviterTargetView(APIView):
"""查询本人可投诉的邀请人。POST /gongdan/my-inviter-target/ body: {leixing:4|6}"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
leixing = int(request.data.get('leixing') or 0)
except (TypeError, ValueError):
return _err('投诉类型无效')
if leixing not in (TYPE_GUANSHI, TYPE_ZUZHANG):
return _err('仅管事/组长投诉需要邀请人')
return _ok(peek_inviter_complaint_target(request.user.UserUID, leixing))
class GongdanMetaView(APIView):
"""类型/状态枚举,供前端展示。GET /gongdan/meta/"""
"""类型/状态枚举 + 人工客服联系方式。GET /gongdan/meta/"""
permission_classes = [IsAuthenticated]
def get(self, request):
club_id = _resolve_club(request)
cfg = get_club_gongdan_config(club_id) if club_id else get_club_gongdan_config('')
return _ok({
'types': [{'value': k, 'label': v} for k, v in TYPE_LABELS.items()],
'statuses': [{'value': k, 'label': v} for k, v in STATUS_LABELS.items()],
'max_images': MAX_IMAGES,
'human_kefu': {
'wechat': cfg.get('human_kefu_wechat') or '',
'link': cfg.get('human_kefu_link') or '',
'tip': cfg.get('human_kefu_tip') or '',
'available': bool(cfg.get('human_kefu_available')),
},
'inviter_targets': {
'guanshi': peek_inviter_complaint_target(request.user.UserUID, TYPE_GUANSHI),
'zuzhang': peek_inviter_complaint_target(request.user.UserUID, TYPE_ZUZHANG),
},
})
@@ -516,9 +546,9 @@ class GongdanAdminHandleView(APIView):
class GongdanAdminClubConfigView(APIView):
"""
当前俱乐部工单开关。
当前俱乐部工单开关 + 人工客服联系方式
GET/POST /gongdan/admin/club-config/
字段complaint_withdraw_block(未结清投诉/牵连时禁止提现)
字段complaint_withdraw_block / human_kefu_wechat / human_kefu_link / human_kefu_tip
"""
permission_classes = [IsAuthenticated]
@@ -545,15 +575,25 @@ class GongdanAdminClubConfigView(APIView):
club_id = self._club_id(request)
if not club_id:
return _err('请先选择俱乐部')
raw = request.data.get('complaint_withdraw_block')
if raw is None:
return _err('缺少 complaint_withdraw_block')
if isinstance(raw, str):
enabled = raw.strip().lower() in ('1', 'true', 'yes', 'on')
else:
enabled = bool(raw)
d = request.data or {}
patch = {}
if 'complaint_withdraw_block' in d:
raw = d.get('complaint_withdraw_block')
if isinstance(raw, str):
patch['complaint_withdraw_block'] = raw.strip().lower() in ('1', 'true', 'yes', 'on')
else:
patch['complaint_withdraw_block'] = bool(raw)
for key in ('human_kefu_wechat', 'human_kefu_link', 'human_kefu_tip'):
if key in d:
patch[key] = d.get(key)
if not patch:
return _err('缺少可保存字段')
try:
data = set_complaint_withdraw_block(club_id, enabled)
# 兼容旧前端只传 withdraw 开关
if list(patch.keys()) == ['complaint_withdraw_block']:
data = set_complaint_withdraw_block(club_id, patch['complaint_withdraw_block'])
else:
data = update_club_gongdan_config(club_id, **patch)
return _ok(data, '已保存')
except GongdanError as e:
return _err(str(e))

View File

@@ -206,6 +206,75 @@ def serialize_merchant_stat(row) -> dict:
}
def pool_merchant_stat_payload(row) -> dict | None:
"""
抢单池展示用:无订单样本则不返回;
有订单则返回成交率/罚款率;平均成交时长仅有结算样本时返回。
"""
if not row:
return None
n = int(row.order_count or 0)
if n <= 0:
return None
full = serialize_merchant_stat(row)
out = {
'deal_rate': full['deal_rate'],
'fine_rate': full['fine_rate'],
'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%",
'fine_rate_text': f"{round(full['fine_rate'] * 100, 1)}%",
}
sc = int(full.get('settle_duration_sample_count') or 0)
if sc > 0 and full.get('avg_deal_hours'):
h = float(full['avg_deal_hours'])
out['avg_deal_hours'] = h
if h < 1:
out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟'
else:
out['avg_deal_hours_text'] = f'{round(h, 1)}小时'
return out
def batch_pool_merchant_stats(club_id: str, merchant_uids, order_club_map=None) -> dict:
"""
批量取商家抢单池指标。
order_club_map: {merchant_uid: order_club_id} 优先用订单所属俱乐部查指标。
返回 {merchant_uid: payload}
"""
from jituan.models import MerchantDealStat
uids = [str(u).strip() for u in (merchant_uids or []) if u and str(u).strip()]
if not uids:
return {}
default_club = (club_id or '').strip()
club_ids = {default_club} if default_club else set()
if order_club_map:
for cid in order_club_map.values():
c = (cid or '').strip()
if c:
club_ids.add(c)
if not club_ids:
return {}
rows = list(
MerchantDealStat.query.filter(
club_id__in=list(club_ids),
merchant_uid__in=uids,
)
)
by_key = {(r.club_id, r.merchant_uid): r for r in rows}
out = {}
for uid in uids:
prefer = ((order_club_map or {}).get(uid) or default_club or '').strip()
row = by_key.get((prefer, uid))
if not row:
for cid in club_ids:
row = by_key.get((cid, uid))
if row:
break
payload = pool_merchant_stat_payload(row)
if payload:
out[uid] = payload
return out
def serialize_dashou_stat(row) -> dict:
n = int(row.order_count or 0)
c = int(row.completed_count or 0)

View File

@@ -163,7 +163,7 @@ class DashouDingdanHuoquView(APIView):
'AssignedID', 'AssignedResponse', 'AssignedResponseAt', 'AssignedLaterNote',
'ProductTypeID', 'ImageURL', 'GrabRequirement',
'MembershipID', 'CommissionReq', 'Description', 'Remark', 'CreateTime',
'MerchantNickname'
'MerchantNickname', 'ClubID',
)
# 10. 转换为列表
@@ -308,6 +308,28 @@ class DashouDingdanHuoquView(APIView):
identity_tag_map = batch_identity_tags(club_id, identity_pairs) if identity_pairs else {}
# --- 商家成交指标(有样本才返回) ---
merchant_stat_map = {}
if merchant_uids:
try:
from jituan.services.deal_stats import batch_pool_merchant_stats
order_club_by_merchant = {}
for item in dingdan_list:
if item['Platform'] != 2:
continue
oid = item['OrderID']
sj = shangjia_id_map.get(oid)
if not sj:
continue
ocid = (item.get('ClubID') or club_id or '').strip()
if sj not in order_club_by_merchant and ocid:
order_club_by_merchant[sj] = ocid
merchant_stat_map = batch_pool_merchant_stats(
club_id, merchant_uids, order_club_map=order_club_by_merchant,
)
except Exception as e:
logger.error(f"批量商家成交指标失败: {e}", exc_info=True)
# 12. 格式化返回数据
formatted_list = []
for order in dingdan_list:
@@ -320,7 +342,7 @@ class DashouDingdanHuoquView(APIView):
order, yonghuid, club_id=club_id, user_clumber=user_clumber,
)
)
formatted_list.append({
row = {
'dingdan_id': order['OrderID'],
'zhuangtai': order['Status'],
'pingtai': order['Platform'],
@@ -353,7 +375,10 @@ class DashouDingdanHuoquView(APIView):
'shangjia_identity_biaoqian': identity_tag_map.get((sj_uid, SHENFEN_SHANGJIA), []) if sj_uid else [],
'zhiding_identity_biaoqian': identity_tag_map.get((zid, SHENFEN_DASHOU), []) if zid else [],
'shangjia_youzhi': bool(youzhi_map.get(sj_uid)) if sj_uid else False,
})
}
if sj_uid and sj_uid in merchant_stat_map:
row['shangjia_deal_stat'] = merchant_stat_map[sj_uid]
formatted_list.append(row)
has_more = (page * page_size) < total