551 lines
21 KiB
Python
551 lines
21 KiB
Python
"""投诉工单 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))
|