"""投诉工单 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, 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, ) 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, ) 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, ) return _ok(data) 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: '罚款', 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(未结清投诉/牵连时禁止提现) """ 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('请先选择俱乐部') 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) try: data = set_complaint_withdraw_block(club_id, enabled) return _ok(data, '已保存') except GongdanError as e: return _err(str(e))