seed_kefu_menu 后侧栏才能出现「投诉工单」;admin list 使用 club 上下文过滤。 Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
13 KiB
Python
337 lines
13 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,
|
||
)
|
||
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)
|
||
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()
|
||
if zhuangtai not in (None, ''):
|
||
qs = qs.filter(zhuangtai=int(zhuangtai))
|
||
if leixing not in (None, ''):
|
||
qs = qs.filter(leixing=int(leixing))
|
||
if keyword:
|
||
from django.db.models import Q
|
||
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]]
|
||
return _ok({'list': rows, 'total': total, 'page': page, 'page_size': page_size})
|
||
|
||
|
||
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)
|