diff --git a/a_long_dianjing/settings.py b/a_long_dianjing/settings.py index 7311cac..84c4ceb 100644 --- a/a_long_dianjing/settings.py +++ b/a_long_dianjing/settings.py @@ -42,6 +42,7 @@ INSTALLED_APPS = [ 'rank', 'merchant_ops', 'jituan', + 'gongdan', ] MIDDLEWARE = [ diff --git a/a_long_dianjing/urls.py b/a_long_dianjing/urls.py index 0646203..a2e37ac 100644 --- a/a_long_dianjing/urls.py +++ b/a_long_dianjing/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ path('shangdian/', include('shop.urls')), path('dengji/', include('rank.urls')), path('jituan/', include('jituan.urls')), + path('gongdan/', include('gongdan.urls')), ] # 重要说明: diff --git a/gongdan/__init__.py b/gongdan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gongdan/apps.py b/gongdan/apps.py new file mode 100644 index 0000000..2977659 --- /dev/null +++ b/gongdan/apps.py @@ -0,0 +1,8 @@ +"""投诉工单应用。""" +from django.apps import AppConfig + + +class GongdanConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gongdan' + verbose_name = '投诉工单' diff --git a/gongdan/constants.py b/gongdan/constants.py new file mode 100644 index 0000000..2598f6d --- /dev/null +++ b/gongdan/constants.py @@ -0,0 +1,42 @@ +"""投诉工单常量。""" + +# 投诉类型 +TYPE_ORDER = 1 # 订单投诉(需订单 OrderID) +TYPE_RECHARGE = 2 # 充值投诉(需 Czjilu.dingdan_id) +TYPE_PENALTY = 3 # 罚款投诉(需罚单 Penalty.id) +TYPE_GUANSHI = 4 # 管事投诉 +TYPE_FRAUD = 5 # 被骗投诉 + +TYPE_CHOICES = ( + (TYPE_ORDER, '订单投诉'), + (TYPE_RECHARGE, '充值投诉'), + (TYPE_PENALTY, '罚款投诉'), + (TYPE_GUANSHI, '管事投诉'), + (TYPE_FRAUD, '被骗投诉'), +) + +TYPE_LABELS = dict(TYPE_CHOICES) + +# 状态:1处理中 2已处理 3不满意(可升级)4已关闭 +STATUS_PROCESSING = 1 +STATUS_DONE = 2 +STATUS_DISSATISFIED = 3 +STATUS_CLOSED = 4 + +STATUS_CHOICES = ( + (STATUS_PROCESSING, '处理中'), + (STATUS_DONE, '已处理'), + (STATUS_DISSATISFIED, '不满意'), + (STATUS_CLOSED, '已关闭'), +) + +STATUS_LABELS = dict(STATUS_CHOICES) + +MAX_IMAGES = 9 +MAX_SHUOMING_LEN = 500 +MAX_SHENGJI_TIMES = 5 +OSS_PREFIX = 'gongdan' + +# 权限码 +PERM_VIEW = 'gdck666' # 查看工单 +PERM_HANDLE = 'gdcl666' # 处理工单 diff --git a/gongdan/migrations/0001_initial.py b/gongdan/migrations/0001_initial.py new file mode 100644 index 0000000..d456bef --- /dev/null +++ b/gongdan/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated manually for gongdan + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Gongdan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('gongdan_id', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='工单号')), + ('leixing', models.IntegerField(choices=[(1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'), (4, '管事投诉'), (5, '被骗投诉')], db_index=True, verbose_name='投诉类型')), + ('order_id', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='订单ID')), + ('chongzhi_id', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='充值单ID')), + ('fadan_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='罚单ID')), + ('guanshi_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='被投诉管事UID')), + ('shuoming', models.CharField(max_length=500, verbose_name='投诉说明')), + ('shenqingren_id', models.CharField(db_index=True, max_length=32, verbose_name='申请人UserUID')), + ('club_id', models.CharField(blank=True, db_index=True, max_length=16, null=True, verbose_name='俱乐部')), + ('zhuangtai', models.IntegerField(choices=[(1, '处理中'), (2, '已处理'), (3, '不满意'), (4, '已关闭')], db_index=True, default=1, verbose_name='状态')), + ('chuli_jieguo', models.CharField(blank=True, max_length=500, null=True, verbose_name='处理结果说明')), + ('chuliren_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='处理人UID')), + ('chuliren_name', models.CharField(blank=True, max_length=64, null=True, verbose_name='处理人展示名')), + ('chuli_time', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')), + ('shengji_cishu', models.IntegerField(default=0, verbose_name='升级次数')), + ('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={'db_table': 'gongdan', 'verbose_name': '投诉工单', 'ordering': ['-CreateTime']}, + ), + migrations.CreateModel( + name='GongdanImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('gongdan_id', models.CharField(db_index=True, max_length=32, verbose_name='工单号冗余')), + ('url', models.CharField(max_length=512, verbose_name='图片URL')), + ('paixu', models.IntegerField(default=0, verbose_name='排序')), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('gongdan', models.ForeignKey(db_column='gongdan_pk', on_delete=django.db.models.deletion.CASCADE, related_name='images', to='gongdan.gongdan')), + ], + options={'db_table': 'gongdan_image', 'verbose_name': '工单证据图', 'ordering': ['paixu', 'id']}, + ), + migrations.CreateModel( + name='GongdanShengji', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('gongdan_id', models.CharField(db_index=True, max_length=32, verbose_name='工单号冗余')), + ('cishu', models.IntegerField(verbose_name='第几次升级')), + ('shuoming', models.CharField(max_length=500, verbose_name='补充说明')), + ('shenqingren_id', models.CharField(db_index=True, max_length=32, verbose_name='申请人')), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('gongdan', models.ForeignKey(db_column='gongdan_pk', on_delete=django.db.models.deletion.CASCADE, related_name='shengji_list', to='gongdan.gongdan')), + ], + options={ + 'db_table': 'gongdan_shengji', + 'verbose_name': '工单升级记录', + 'ordering': ['cishu', 'id'], + 'unique_together': {('gongdan', 'cishu')}, + }, + ), + ] diff --git a/gongdan/migrations/__init__.py b/gongdan/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gongdan/models.py b/gongdan/models.py new file mode 100644 index 0000000..03b180b --- /dev/null +++ b/gongdan/models.py @@ -0,0 +1,71 @@ +"""投诉工单模型:主单 + 证据图 + 升级补充说明。""" +from django.db import models +from gvsdsdk.model_base import QModel + +from gongdan.constants import STATUS_PROCESSING, TYPE_CHOICES, STATUS_CHOICES + + +class Gongdan(QModel): + """投诉工单主表。""" + gongdan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='工单号') + leixing = models.IntegerField(db_index=True, choices=TYPE_CHOICES, verbose_name='投诉类型') + # 按类型选填关联业务 ID(字符串统一存,便于展示与复制) + order_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='订单ID') + chongzhi_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='充值单ID') + fadan_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='罚单ID') + guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID') + + shuoming = models.CharField(max_length=500, verbose_name='投诉说明') + shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人UserUID') + club_id = models.CharField(max_length=16, null=True, blank=True, db_index=True, verbose_name='俱乐部') + + zhuangtai = models.IntegerField( + default=STATUS_PROCESSING, db_index=True, choices=STATUS_CHOICES, verbose_name='状态', + ) + chuli_jieguo = models.CharField(max_length=500, null=True, blank=True, verbose_name='处理结果说明') + chuliren_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='处理人UID') + chuliren_name = models.CharField(max_length=64, null=True, blank=True, verbose_name='处理人展示名') + chuli_time = models.DateTimeField(null=True, blank=True, verbose_name='处理时间') + + shengji_cishu = models.IntegerField(default=0, verbose_name='升级次数') + CreateTime = models.DateTimeField(auto_now_add=True, db_index=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'gongdan' + verbose_name = '投诉工单' + ordering = ['-CreateTime'] + + +class GongdanImage(QModel): + """工单证据图(最多9张,以主单当前版本为准;升级时可整体替换)。""" + gongdan = models.ForeignKey( + Gongdan, on_delete=models.CASCADE, related_name='images', db_column='gongdan_pk', + ) + gongdan_id = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余') + url = models.CharField(max_length=512, verbose_name='图片URL') + paixu = models.IntegerField(default=0, verbose_name='排序') + CreateTime = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'gongdan_image' + verbose_name = '工单证据图' + ordering = ['paixu', 'id'] + + +class GongdanShengji(QModel): + """升级/继续投诉补充说明(只记文字与次数;图片改主表 images)。""" + gongdan = models.ForeignKey( + Gongdan, on_delete=models.CASCADE, related_name='shengji_list', db_column='gongdan_pk', + ) + gongdan_id = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余') + cishu = models.IntegerField(verbose_name='第几次升级') + shuoming = models.CharField(max_length=500, verbose_name='补充说明') + shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人') + CreateTime = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'gongdan_shengji' + verbose_name = '工单升级记录' + ordering = ['cishu', 'id'] + unique_together = [['gongdan', 'cishu']] diff --git a/gongdan/services.py b/gongdan/services.py new file mode 100644 index 0000000..c870234 --- /dev/null +++ b/gongdan/services.py @@ -0,0 +1,292 @@ +"""工单业务:创建 / 升级 / 客服处理(幂等与校验)。""" +from __future__ import annotations + +import logging +import random +import time +from decimal import Decimal + +from django.db import transaction +from django.utils import timezone + +from gongdan.constants import ( + TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, + TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED, + STATUS_LABELS, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, OSS_PREFIX, +) +from gongdan.models import Gongdan, GongdanImage, GongdanShengji + +logger = logging.getLogger(__name__) + + +class GongdanError(Exception): + def __init__(self, message, code='error'): + super().__init__(message) + self.code = code + + +def _gen_gongdan_id(): + ts = int(time.time() * 1000) + ns = int(time.time_ns() // 1000) % 1000000 + rnd = random.randint(0, 99) + return f'GD{ts:011d}{ns:06d}{rnd:02d}' + + +def _trim(text, max_len=MAX_SHUOMING_LEN): + return (text or '').strip()[:max_len] + + +def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None): + """按类型校验必填关联 ID,并尽量核对库内是否存在。""" + order_id = (order_id or '').strip() or None + chongzhi_id = (chongzhi_id or '').strip() or None + fadan_id = (fadan_id or '').strip() or None + guanshi_id = (guanshi_id or '').strip() or None + + if leixing == TYPE_ORDER: + if not order_id: + raise GongdanError('订单投诉请填写订单ID', 'need_order_id') + from orders.models import Order + if not Order.query.filter(OrderID=order_id).exists(): + raise GongdanError('订单不存在,请核对订单ID', 'order_not_found') + return order_id, None, None, None + + if leixing == TYPE_RECHARGE: + if not chongzhi_id: + raise GongdanError('充值投诉请填写充值记录ID', 'need_chongzhi_id') + from products.models import Czjilu + if not Czjilu.query.filter(dingdan_id=chongzhi_id).exists(): + raise GongdanError('充值记录不存在,请核对充值ID', 'chongzhi_not_found') + return None, chongzhi_id, None, None + + if leixing == TYPE_PENALTY: + if not fadan_id: + raise GongdanError('罚款投诉请填写罚单ID', 'need_fadan_id') + from orders.models import Penalty + try: + fid = int(fadan_id) + except (TypeError, ValueError): + raise GongdanError('罚单ID格式不正确', 'bad_fadan_id') + if not Penalty.query.filter(id=fid).exists(): + raise GongdanError('罚单不存在,请核对罚单ID', 'fadan_not_found') + return None, None, str(fid), None + + if leixing == TYPE_GUANSHI: + # 管事UID 选填,有则校验存在 + if guanshi_id: + from users.models import UserGuanshi + if not UserGuanshi.objects.filter(user__UserUID=guanshi_id).exists(): + raise GongdanError('管事不存在,请核对管事ID', 'guanshi_not_found') + return None, None, None, guanshi_id + + if leixing == TYPE_FRAUD: + return None, None, None, None + + raise GongdanError('不支持的投诉类型', 'bad_type') + + +def serialize_gongdan(gd, include_images=True, include_shengji=False): + data = { + 'gongdan_id': gd.gongdan_id, + 'leixing': gd.leixing, + 'leixing_label': TYPE_LABELS.get(gd.leixing, str(gd.leixing)), + 'order_id': gd.order_id or '', + 'chongzhi_id': gd.chongzhi_id or '', + 'fadan_id': gd.fadan_id or '', + 'guanshi_id': gd.guanshi_id or '', + 'shuoming': gd.shuoming or '', + 'shenqingren_id': gd.shenqingren_id, + 'club_id': gd.club_id or '', + 'zhuangtai': gd.zhuangtai, + 'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)), + 'chuli_jieguo': gd.chuli_jieguo or '', + 'chuliren_id': gd.chuliren_id or '', + 'chuliren_name': gd.chuliren_name or '', + 'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '', + 'shengji_cishu': gd.shengji_cishu or 0, + 'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '', + 'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '', + } + if include_images: + data['images'] = [ + {'url': img.url, 'paixu': img.paixu} + for img in gd.images.all().order_by('paixu', 'id') + ] + if include_shengji: + data['shengji_list'] = [ + { + 'cishu': s.cishu, + 'shuoming': s.shuoming, + 'CreateTime': s.CreateTime.isoformat() if s.CreateTime else '', + } + for s in gd.shengji_list.all().order_by('cishu', 'id') + ] + return data + + +def _replace_images(gd, image_urls): + urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()] + if len(urls) > MAX_IMAGES: + raise GongdanError(f'最多上传{MAX_IMAGES}张图片', 'too_many_images') + GongdanImage.objects.filter(gongdan=gd).delete() + for i, url in enumerate(urls): + GongdanImage.objects.create( + gongdan=gd, + gongdan_id=gd.gongdan_id, + url=url[:512], + paixu=i, + ) + + +@transaction.atomic +def create_gongdan( + *, + yonghuid, + leixing, + shuoming, + club_id=None, + order_id=None, + chongzhi_id=None, + fadan_id=None, + guanshi_id=None, + image_urls=None, +): + try: + leixing = int(leixing) + except (TypeError, ValueError): + raise GongdanError('投诉类型无效', 'bad_type') + if leixing not in TYPE_LABELS: + raise GongdanError('投诉类型无效', 'bad_type') + + text = _trim(shuoming) + if not text: + raise GongdanError('请填写投诉说明', 'need_shuoming') + + order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs( + leixing, order_id, chongzhi_id, fadan_id, guanshi_id, + ) + + gid = _gen_gongdan_id() + for _ in range(5): + if not Gongdan.query.filter(gongdan_id=gid).exists(): + break + gid = _gen_gongdan_id() + + gd = Gongdan.objects.create( + gongdan_id=gid, + leixing=leixing, + order_id=order_id, + chongzhi_id=chongzhi_id, + fadan_id=fadan_id, + guanshi_id=guanshi_id, + shuoming=text, + shenqingren_id=yonghuid, + club_id=club_id or None, + zhuangtai=STATUS_PROCESSING, + shengji_cishu=0, + ) + _replace_images(gd, image_urls) + logger.info('创建工单 %s type=%s user=%s', gid, leixing, yonghuid) + return gd + + +@transaction.atomic +def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None): + """不满意后继续投诉:写升级记录;可选整体替换证据图。""" + gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() + if not gd: + raise GongdanError('工单不存在', 'not_found') + if gd.shenqingren_id != yonghuid: + raise GongdanError('无权操作此工单', 'forbidden') + if gd.zhuangtai not in (STATUS_DONE, STATUS_DISSATISFIED): + raise GongdanError('当前状态不可继续投诉,请等待客服处理', 'bad_status') + if (gd.shengji_cishu or 0) >= MAX_SHENGJI_TIMES: + raise GongdanError(f'最多升级{MAX_SHENGJI_TIMES}次,请联系客服', 'too_many_shengji') + + text = _trim(shuoming) + if not text: + raise GongdanError('请填写补充说明', 'need_shuoming') + + next_cishu = (gd.shengji_cishu or 0) + 1 + GongdanShengji.objects.create( + gongdan=gd, + gongdan_id=gd.gongdan_id, + cishu=next_cishu, + shuoming=text, + shenqingren_id=yonghuid, + ) + gd.shengji_cishu = next_cishu + gd.zhuangtai = STATUS_PROCESSING + gd.chuli_jieguo = '' + gd.chuli_time = None + # 保留原处理人记录便于审计,但状态回到处理中 + gd.save(update_fields=[ + 'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime', + ]) + if image_urls is not None: + _replace_images(gd, image_urls) + logger.info('工单升级 %s cishu=%s', gongdan_id, next_cishu) + return gd + + +@transaction.atomic +def handle_gongdan( + *, + gongdan_id, + handler_uid, + handler_name, + action, + chuli_jieguo='', +): + """ + 客服处理: + action=done → 已处理 + action=dissatisfied → 标记不满意(一般由用户侧触发,客服 sparingly) + action=close → 关闭 + action=processing → 重新标处理中 + """ + gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() + if not gd: + raise GongdanError('工单不存在', 'not_found') + + action = (action or '').strip().lower() + jieguo = _trim(chuli_jieguo) + + if action in ('done', 'finish', 'resolve'): + if not jieguo: + raise GongdanError('请填写处理结果', 'need_jieguo') + gd.zhuangtai = STATUS_DONE + gd.chuli_jieguo = jieguo + gd.chuliren_id = handler_uid + gd.chuliren_name = (handler_name or '')[:64] + gd.chuli_time = timezone.now() + elif action in ('close',): + gd.zhuangtai = STATUS_CLOSED + gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭' + gd.chuliren_id = handler_uid + gd.chuliren_name = (handler_name or '')[:64] + gd.chuli_time = timezone.now() + elif action in ('processing', 'reopen'): + gd.zhuangtai = STATUS_PROCESSING + elif action in ('dissatisfied',): + gd.zhuangtai = STATUS_DISSATISFIED + else: + raise GongdanError('未知处理动作', 'bad_action') + + gd.save() + logger.info('工单处理 %s action=%s by=%s', gongdan_id, action, handler_uid) + return gd + + +@transaction.atomic +def user_mark_dissatisfied(*, gongdan_id, yonghuid): + gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first() + if not gd: + raise GongdanError('工单不存在', 'not_found') + if gd.shenqingren_id != yonghuid: + raise GongdanError('无权操作此工单', 'forbidden') + if gd.zhuangtai != STATUS_DONE: + raise GongdanError('仅已处理的工单可标记不满意', 'bad_status') + gd.zhuangtai = STATUS_DISSATISFIED + gd.save(update_fields=['zhuangtai', 'UpdateTime']) + return gd diff --git a/gongdan/urls.py b/gongdan/urls.py new file mode 100644 index 0000000..a7cd4ed --- /dev/null +++ b/gongdan/urls.py @@ -0,0 +1,27 @@ +from django.urls import path + +from gongdan.views import ( + GongdanUploadImageView, + GongdanCreateView, + GongdanMyListView, + GongdanDetailView, + GongdanEscalateView, + GongdanDissatisfiedView, + GongdanMetaView, + ChongzhiRecordListView, + GongdanAdminListView, + GongdanAdminHandleView, +) + +urlpatterns = [ + path('upload-image/', GongdanUploadImageView.as_view(), name='工单图片上传'), + path('create/', GongdanCreateView.as_view(), name='创建投诉工单'), + path('my-list/', GongdanMyListView.as_view(), name='我的投诉列表'), + path('detail/', GongdanDetailView.as_view(), name='工单详情'), + path('escalate/', GongdanEscalateView.as_view(), name='继续投诉升级'), + path('dissatisfied/', GongdanDissatisfiedView.as_view(), name='标记不满意'), + path('meta/', GongdanMetaView.as_view(), name='工单枚举'), + path('chongzhi-list/', ChongzhiRecordListView.as_view(), name='我的充值记录'), + path('admin/list/', GongdanAdminListView.as_view(), name='客服工单列表'), + path('admin/handle/', GongdanAdminHandleView.as_view(), name='客服处理工单'), +] diff --git a/gongdan/views.py b/gongdan/views.py new file mode 100644 index 0000000..1084acc --- /dev/null +++ b/gongdan/views.py @@ -0,0 +1,331 @@ +"""投诉工单 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') + 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) diff --git a/jituan/management/commands/seed_gvsdsdk_permissions.py b/jituan/management/commands/seed_gvsdsdk_permissions.py index b2d1a26..7a66353 100644 --- a/jituan/management/commands/seed_gvsdsdk_permissions.py +++ b/jituan/management/commands/seed_gvsdsdk_permissions.py @@ -26,6 +26,18 @@ EXTRA_PERMISSIONS = [ '配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)', '会话管理', ), + ( + 'gdck666', + '投诉工单查看', + '查看用户投诉工单列表与详情', + '投诉工单', + ), + ( + 'gdcl666', + '投诉工单处理', + '处理/关闭用户投诉工单', + '投诉工单', + ), ( 'sfbq666', '身份装饰标签管理',