"""投诉工单 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, ) from gongdan.models import Gongdan from gongdan.services import ( GongdanError, create_gongdan, escalate_gongdan, handle_gongdan, serialize_gongdan, user_mark_dissatisfied, ) 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'), image_urls=images, ) return _ok(serialize_gongdan(gd, include_images=True, include_shengji=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 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): 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, }) 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 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)) 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), '已提交补充投诉') 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), '已标记不满意,可继续投诉') except GongdanError as e: return _err(str(e)) class GongdanMetaView(APIView): """类型/状态枚举,供前端展示。GET /gongdan/meta/""" permission_classes = [IsAuthenticated] def get(self, request): 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, }) 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)) 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: '罚款', } status_map = {9: '待支付', 3: '已支付', 2: '已取消', 8: '履约失败', 10: '业务失败'} rows = [] for o in qs[start:start + page_size]: rows.append({ 'dingdan_id': o.dingdan_id, 'leixing': o.leixing, 'leixing_label': type_map.get(o.leixing, str(o.leixing)), 'jine': str(o.jine), 'zhuangtai': o.zhuangtai, 'zhuangtai_label': status_map.get(o.zhuangtai, str(o.zhuangtai)), 'shuoming': o.shuoming or '', 'CreateTime': o.CreateTime.isoformat() if getattr(o, 'CreateTime', None) else '', }) 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)) 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)) 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(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]] # 统计基于俱乐部范围全量(不受当前筛选影响),方便客服一眼看到待办量 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(), 'escalated': stats_qs.filter(shengji_cishu__gt=0).filter( Q(zhuangtai=1) | Q(zhuangtai=3) ).count(), 'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3)).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 '', ) return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '处理成功') except GongdanError as e: return _err(str(e)) except Exception as e: logger.error('处理工单失败: %s', e, exc_info=True) return _err('处理失败', http=500)