feat: 投诉工单后端(模型/接口/迁移/权限)
支持用户提交投诉、证据图上传、升级补充;客服列表处理;充值记录列表供投诉选ID。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
||||
'rank',
|
||||
'merchant_ops',
|
||||
'jituan',
|
||||
'gongdan',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -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')),
|
||||
]
|
||||
|
||||
# 重要说明:
|
||||
|
||||
0
gongdan/__init__.py
Normal file
0
gongdan/__init__.py
Normal file
8
gongdan/apps.py
Normal file
8
gongdan/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""投诉工单应用。"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GongdanConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'gongdan'
|
||||
verbose_name = '投诉工单'
|
||||
42
gongdan/constants.py
Normal file
42
gongdan/constants.py
Normal file
@@ -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' # 处理工单
|
||||
68
gongdan/migrations/0001_initial.py
Normal file
68
gongdan/migrations/0001_initial.py
Normal file
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
gongdan/migrations/__init__.py
Normal file
0
gongdan/migrations/__init__.py
Normal file
71
gongdan/models.py
Normal file
71
gongdan/models.py
Normal file
@@ -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']]
|
||||
292
gongdan/services.py
Normal file
292
gongdan/services.py
Normal file
@@ -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
|
||||
27
gongdan/urls.py
Normal file
27
gongdan/urls.py
Normal file
@@ -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='客服处理工单'),
|
||||
]
|
||||
331
gongdan/views.py
Normal file
331
gongdan/views.py
Normal file
@@ -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)
|
||||
@@ -26,6 +26,18 @@ EXTRA_PERMISSIONS = [
|
||||
'配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)',
|
||||
'会话管理',
|
||||
),
|
||||
(
|
||||
'gdck666',
|
||||
'投诉工单查看',
|
||||
'查看用户投诉工单列表与详情',
|
||||
'投诉工单',
|
||||
),
|
||||
(
|
||||
'gdcl666',
|
||||
'投诉工单处理',
|
||||
'处理/关闭用户投诉工单',
|
||||
'投诉工单',
|
||||
),
|
||||
(
|
||||
'sfbq666',
|
||||
'身份装饰标签管理',
|
||||
|
||||
Reference in New Issue
Block a user