Files
Django/gongdan/views.py
2026-07-27 04:15:59 +08:00

603 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""投诉工单 API用户提交/列表/升级 + 图片上传 + 客服处理。"""
import logging
import uuid
from django.utils import timezone
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
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 (
GongdanError,
create_gongdan,
escalate_gongdan,
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,
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
logger = logging.getLogger(__name__)
def _ok(data=None, message='ok', code=200):
body = {'code': code, 'message': message, 'msg': message}
if data is not None:
body['data'] = data
return Response(body, status=status.HTTP_200_OK)
def _err(message, http=400, code=None):
c = code if code is not None else http
return Response(
{'code': c, 'message': message, 'msg': message},
status=http,
)
def _resolve_club(request):
try:
from jituan.services.club_write import resolve_club_id_for_write
return resolve_club_id_for_write(request, request.user)
except Exception:
return getattr(request.user, 'club_id', None) or None
class GongdanUploadImageView(APIView):
"""上传证据图,返回 URL。路径POST /gongdan/upload-image/"""
permission_classes = [IsAuthenticated]
parser_classes = (MultiPartParser, FormParser)
def post(self, request):
f = request.FILES.get('file') or request.FILES.get('image') or request.FILES.get('tupian')
if not f:
return _err('请选择图片')
ok, msg = validate_image(f)
if not ok:
return _err(msg)
ext = (f.name.rsplit('.', 1)[-1] if '.' in f.name else 'jpg').lower()
if ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'):
ext = 'jpg'
uid = getattr(request.user, 'UserUID', 'anon')
day = timezone.now().strftime('%Y%m%d')
path = f'{OSS_PREFIX}/{day}/{uid}/{uuid.uuid4().hex}.{ext}'
url = upload_to_oss(f, path)
if not url:
return _err('上传失败,请稍后重试', http=500)
return _ok({'url': url}, '上传成功')
class GongdanCreateView(APIView):
"""创建投诉工单。POST /gongdan/create/"""
permission_classes = [IsAuthenticated]
parser_classes = (JSONParser, FormParser, MultiPartParser)
def post(self, request):
d = request.data
images = d.get('images') or d.get('image_urls') or []
if isinstance(images, str):
images = [x.strip() for x in images.split(',') if x.strip()]
try:
gd = create_gongdan(
yonghuid=request.user.UserUID,
leixing=d.get('leixing'),
shuoming=d.get('shuoming') or d.get('content') or '',
club_id=_resolve_club(request),
order_id=d.get('order_id') or d.get('OrderID'),
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,
contact_qq=d.get('contact_qq') or d.get('qq') or '',
contact_wechat=d.get('contact_wechat') or d.get('wechat') or d.get('wx') or '',
contact_phone=d.get('contact_phone') or d.get('phone') or d.get('mobile') or '',
)
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:
logger.error('创建工单失败: %s', e, exc_info=True)
return _err('提交失败,请稍后重试', http=500)
class GongdanMyListView(APIView):
"""我的投诉列表。POST/GET /gongdan/my-list/"""
permission_classes = [IsAuthenticated]
def get(self, request):
return self._list(request)
def post(self, request):
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)
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):
"""工单详情。POST /gongdan/detail/ {gongdan_id}"""
permission_classes = [IsAuthenticated]
def post(self, request):
gid = (request.data.get('gongdan_id') or '').strip()
if not gid:
return _err('工单号不能为空')
gd = Gongdan.query.filter(gongdan_id=gid).first()
if not gd:
return _err('工单不存在', http=404)
# 申请人 / 被投诉人 / 牵连组长 / 客服
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):
"""继续投诉/升级。POST /gongdan/escalate/"""
permission_classes = [IsAuthenticated]
def post(self, request):
d = request.data
images = d.get('images')
if images is not None and isinstance(images, str):
images = [x.strip() for x in images.split(',') if x.strip()]
try:
gd = escalate_gongdan(
gongdan_id=(d.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
shuoming=d.get('shuoming') or '',
image_urls=images,
)
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:
logger.error('升级工单失败: %s', e, exc_info=True)
return _err('提交失败', http=500)
class GongdanDissatisfiedView(APIView):
"""标记不满意。POST /gongdan/dissatisfied/"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
gd = user_mark_dissatisfied(
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 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,
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/"""
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),
},
})
class ChongzhiRecordListView(APIView):
"""当前用户充值记录Czjilu供投诉选 ID / 充值记录页。POST /gongdan/chongzhi-list/"""
permission_classes = [IsAuthenticated]
def post(self, request):
from products.models import Czjilu
from products.czjilu_types import (
CZJILU_LEIXING_HUIYUAN, CZJILU_LEIXING_YAJIN, CZJILU_LEIXING_JIFEN,
CZJILU_LEIXING_SHANGJIA,
)
page = int(request.data.get('page') or 1)
page_size = min(int(request.data.get('page_size') or 20), 50)
qs = Czjilu.query.filter(yonghuid=request.user.UserUID).order_by('-CreateTime')
leixing = request.data.get('leixing')
if leixing not in (None, ''):
qs = qs.filter(leixing=int(leixing))
# 投诉选单:仅已支付;普通充值记录页不传此参数则展示全部状态
only_paid = str(
request.data.get('only_paid')
or request.data.get('for_complaint')
or ''
).lower() in ('1', 'true', 'yes')
if only_paid:
from products.czjilu_types import CZJILU_PAID_STATUS
qs = qs.filter(zhuangtai=CZJILU_PAID_STATUS)
total = qs.count()
start = max(page - 1, 0) * page_size
type_map = {
CZJILU_LEIXING_HUIYUAN: '会员',
CZJILU_LEIXING_YAJIN: '押金',
CZJILU_LEIXING_JIFEN: '积分',
CZJILU_LEIXING_SHANGJIA: '商家充值',
5: '考核',
6: '罚款',
7: '转盘',
}
# 与 czjilu 实际状态一致:待支付/已取消/已支付等全量展示,不筛选
status_map = {
9: '待支付',
3: '已支付',
2: '已取消',
1: '处理中',
8: '履约失败',
10: '业务失败',
0: '未知',
}
rows = []
for o in qs[start:start + page_size]:
z = o.zhuangtai
try:
z_int = int(z) if z is not None else None
except (TypeError, ValueError):
z_int = None
ct = ''
if getattr(o, 'CreateTime', None):
try:
ct = o.CreateTime.strftime('%Y-%m-%d %H:%M:%S')
except Exception:
ct = o.CreateTime.isoformat()
rows.append({
'dingdan_id': o.dingdan_id,
'leixing': o.leixing,
'leixing_label': type_map.get(o.leixing, f'类型{o.leixing}'),
'jine': str(o.jine),
'zhuangtai': z_int if z_int is not None else z,
'zhuangtai_label': status_map.get(z_int, f'状态{z}' if z is not None else '未知'),
'shuoming': o.shuoming or '',
'club_id': getattr(o, 'club_id', '') or '',
'CreateTime': ct,
})
return _ok({'list': rows, 'total': total, 'page': page, 'page_size': page_size})
# ---------- 客服后台 ----------
def _require_kefu_perm(request, *need_codes):
from backend.utils import verify_kefu_permission
username = request.data.get('username') or request.query_params.get('username') or getattr(request.user, 'Phone', None)
kefu, perms_or_resp = verify_kefu_permission(request, username)
if not kefu:
return None, None, perms_or_resp
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
is_admin = (
getattr(request.user, 'UserType', '') == 'admin'
or getattr(request.user, 'IsSuperuser', False)
or '000001' in perms
)
if is_admin:
return kefu, perms, None
if not any(c in perms for c in need_codes):
return None, None, _err('无工单权限', http=403)
return kefu, perms, None
class GongdanAdminListView(APIView):
"""客服工单列表。POST /gongdan/admin/list/"""
permission_classes = [IsAuthenticated]
def post(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
if err:
return err
page = int(request.data.get('page') or 1)
page_size = min(int(request.data.get('page_size') or 20), 100)
from django.db.models import Q
qs = Gongdan.query.all().order_by('-CreateTime')
try:
from jituan.services.club_context import filter_club_char_field
qs = filter_club_char_field(qs, request, field='club_id')
except Exception:
pass
zhuangtai = request.data.get('zhuangtai')
leixing = request.data.get('leixing')
keyword = (request.data.get('keyword') or '').strip()
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) | 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) | 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_admin=True)
for g in qs[start:start + page_size]
]
# 统计基于俱乐部范围全量(不受当前筛选影响),方便客服一眼看到待办量
stats_qs = Gongdan.query.all()
try:
from jituan.services.club_context import filter_club_char_field
stats_qs = filter_club_char_field(stats_qs, request, field='club_id')
except Exception:
pass
stats = {
'total': stats_qs.count(),
'pending': stats_qs.filter(zhuangtai=1).count(),
'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=5)
).count(),
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)).count(),
}
return _ok({
'list': rows,
'total': total,
'page': page,
'page_size': page_size,
'stats': stats,
})
class GongdanAdminHandleView(APIView):
"""客服处理工单。POST /gongdan/admin/handle/"""
permission_classes = [IsAuthenticated]
def post(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
if err:
return err
d = request.data
handler_uid = getattr(request.user, 'UserUID', '') or getattr(request.user, 'Phone', '')
handler_name = getattr(request.user, 'NickName', None) or getattr(request.user, 'Phone', '') or handler_uid
try:
gd = handle_gongdan(
gongdan_id=(d.get('gongdan_id') or '').strip(),
handler_uid=handler_uid,
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),
'处理成功',
)
except GongdanError as e:
return _err(str(e))
except Exception as e:
logger.error('处理工单失败: %s', e, exc_info=True)
return _err('处理失败', http=500)
class GongdanAdminClubConfigView(APIView):
"""
当前俱乐部工单开关 + 人工客服联系方式。
GET/POST /gongdan/admin/club-config/
字段complaint_withdraw_block / human_kefu_wechat / human_kefu_link / human_kefu_tip
"""
permission_classes = [IsAuthenticated]
def _club_id(self, request):
try:
from jituan.services.club_context import resolve_club_id_from_request
return (resolve_club_id_from_request(request) or '').strip()
except Exception:
return (getattr(request.user, 'ClubID', None) or getattr(request.user, 'club_id', None) or '').strip()
def get(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
if err:
return err
club_id = self._club_id(request)
if not club_id:
return _err('请先选择俱乐部')
return _ok(get_club_gongdan_config(club_id))
def post(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
if err:
return err
club_id = self._club_id(request)
if not club_id:
return _err('请先选择俱乐部')
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:
# 兼容旧前端只传 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))