feat: 小程序客服话术表与自动回复接口

- 新增 MiniappScriptScene / MiniappScriptAutoReply 模型与迁移
- 新增 /peizhi/huashuhq、/peizhi/huashu_match 接口及 Admin 配置

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-01 11:59:21 +08:00
parent c7270bcff7
commit b2fcff303b
5 changed files with 222 additions and 7 deletions

View File

@@ -1 +1,16 @@
from django.contrib import admin
from .models import MiniappScriptScene, MiniappScriptAutoReply
@admin.register(MiniappScriptScene)
class MiniappScriptSceneAdmin(admin.ModelAdmin):
list_display = ('club_id', 'scene_key', 'title', 'enabled', 'UpdateTime')
list_filter = ('club_id', 'enabled')
search_fields = ('scene_key', 'title', 'content')
@admin.register(MiniappScriptAutoReply)
class MiniappScriptAutoReplyAdmin(admin.ModelAdmin):
list_display = ('club_id', 'keyword', 'reply_type', 'sort_order', 'enabled', 'UpdateTime')
list_filter = ('club_id', 'reply_type', 'enabled')
search_fields = ('keyword', 'reply_content')

View File

@@ -0,0 +1,52 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('config', '0011_clubminiappicon_is_uploaded'),
]
operations = [
migrations.CreateModel(
name='MiniappScriptScene',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID')),
('scene_key', models.CharField(db_index=True, max_length=64, verbose_name='场景键')),
('title', models.CharField(blank=True, default='', max_length=128, verbose_name='标题')),
('content', models.TextField(blank=True, default='', verbose_name='正文')),
('confirm_text', models.CharField(blank=True, default='', max_length=16, verbose_name='确认按钮')),
('cancel_text', models.CharField(blank=True, default='', max_length=16, verbose_name='取消按钮')),
('enabled', models.BooleanField(default=True, verbose_name='启用')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '小程序场景话术',
'verbose_name_plural': '小程序场景话术',
'db_table': 'miniapp_script_scene',
'unique_together': {('club_id', 'scene_key')},
},
),
migrations.CreateModel(
name='MiniappScriptAutoReply',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID')),
('keyword', models.CharField(max_length=200, verbose_name='关键词')),
('reply_type', models.CharField(choices=[('text', '文本'), ('image', '图片')], default='text', max_length=16, verbose_name='回复类型')),
('reply_content', models.TextField(verbose_name='回复内容(文本或图片URL)')),
('sort_order', models.IntegerField(default=0, verbose_name='排序(大优先)')),
('enabled', models.BooleanField(default=True, verbose_name='启用')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '客服自动回复规则',
'verbose_name_plural': '客服自动回复规则',
'db_table': 'miniapp_script_auto_reply',
'ordering': ['-sort_order', 'id'],
},
),
]

View File

@@ -520,3 +520,50 @@ class ClubPindaoImage(QModel):
def __str__(self):
return f'{self.club_id} 频道图{self.sort_order}'
class MiniappScriptScene(QModel):
"""小程序固定场景话术(欢迎语、发图确认等)"""
club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID')
scene_key = models.CharField(max_length=64, db_index=True, verbose_name='场景键')
title = models.CharField(max_length=128, blank=True, default='', verbose_name='标题')
content = models.TextField(blank=True, default='', verbose_name='正文')
confirm_text = models.CharField(max_length=16, blank=True, default='', verbose_name='确认按钮')
cancel_text = models.CharField(max_length=16, blank=True, default='', verbose_name='取消按钮')
enabled = models.BooleanField(default=True, verbose_name='启用')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'miniapp_script_scene'
verbose_name = '小程序场景话术'
verbose_name_plural = verbose_name
unique_together = [['club_id', 'scene_key']]
def __str__(self):
return f'{self.club_id}:{self.scene_key}'
class MiniappScriptAutoReply(QModel):
"""客服关键词自动回复(支持文本/图片)"""
REPLY_TEXT = 'text'
REPLY_IMAGE = 'image'
REPLY_TYPE_CHOICES = [(REPLY_TEXT, '文本'), (REPLY_IMAGE, '图片')]
club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID')
keyword = models.CharField(max_length=200, verbose_name='关键词')
reply_type = models.CharField(max_length=16, default=REPLY_TEXT, choices=REPLY_TYPE_CHOICES, verbose_name='回复类型')
reply_content = models.TextField(verbose_name='回复内容(文本或图片URL)')
sort_order = models.IntegerField(default=0, verbose_name='排序(大优先)')
enabled = models.BooleanField(default=True, verbose_name='启用')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'miniapp_script_auto_reply'
verbose_name = '客服自动回复规则'
verbose_name_plural = verbose_name
ordering = ['-sort_order', 'id']
def __str__(self):
return f'{self.club_id}:{self.keyword}'

View File

@@ -5,7 +5,7 @@ from .views import ShangpinGonggaoView, AdminUpdateConfigView, AdminUploadImageV
ShangjiaGengxinMobanView, ShangjiaShanchuMobanView, ShangjiaGenerateLinkView,KehuGetDingdanLianjieView, \
KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView,\
GetDynamicConfigView, HaibaoPeizhiView, PopupConfigView, GetWithdrawModeView,CheckPhoneAuthView,\
ShangjiaLianjieListView
ShangjiaLianjieListView, HuashuHqView, HuashuMatchView
urlpatterns = [
@@ -39,4 +39,6 @@ urlpatterns = [
path('yhbdsjh', CheckPhoneAuthView.as_view(), name='判断是否需要手机号绑定'),
path('hqsjlslj', ShangjiaLianjieListView.as_view(), name='商家获取生成链接'),
path('huashuhq', HuashuHqView.as_view(), name='小程序场景话术获取'),
path('huashu_match', HuashuMatchView.as_view(), name='客服关键词自动回复'),
]

View File

@@ -35,7 +35,8 @@ from utils.chat_utils import subscribe_merchant_link_chat
from .models import (
Gonggao, Lunbo, Tupianpeizhi, Qunpeizhi,
ShangjiaMoban, ShangjiaLianjie, PopupPage, PopupConfig, WithdrawConfig
ShangjiaMoban, ShangjiaLianjie, PopupPage, PopupConfig, WithdrawConfig,
MiniappScriptScene, MiniappScriptAutoReply,
)
from users.models import (
@@ -2842,8 +2843,8 @@ class GuanshiQRCodeView(APIView):
logger.info(f"获取到 access_token (前10位): {access_token[:10]}... club={club_id}")
encoded_invite = urllib.parse.quote(invite_code, safe='')
# 打手端已合并至 fighterTabBar与旧版 dashouduan 同为扫码入口,fighter.js 处理 inviteCode 自动注册
page_path = f'pages/fighter/fighter?inviteCode={encoded_invite}'
# 兼容页 dashouduan 跳转打手中心;fighter.js 处理 inviteCode 自动注册
page_path = f'pages/dashouduan/dashouduan?inviteCode={encoded_invite}'
wx_url = f'https://api.weixin.qq.com/wxa/getwxacode?access_token={access_token}'
post_data = {
'path': page_path,
@@ -3078,8 +3079,8 @@ class ZuzhangHaibaoView(APIView):
# ===== 修改点:使用 A 接口getwxacode =====
encoded_invite = urllib.parse.quote(invite_code, safe='')
# 管事注册:打手中心 fighter.js 已支持 registerType=guanshi + inviteCode 自动注册
page_path = f'pages/fighter/fighter?registerType=guanshi&inviteCode={encoded_invite}'
# 兼容页 guanshiduan 跳转打手中心(管事 registerType
page_path = f'pages/guanshiduan/guanshiduan?inviteCode={encoded_invite}'
wx_resp, wx_error = self._request_wx_qrcode(page_path, access_token)
if wx_error and is_weixin_token_invalid(wx_error.get('errcode'), wx_error.get('errmsg', '')):
@@ -3377,4 +3378,102 @@ class CheckPhoneAuthView(APIView):
boss = UserBoss.query.get(user=user)
except UserBoss.DoesNotExist:
return False
return (boss.alldingdan or 0) > 0
return (boss.alldingdan or 0) > 0
# ---------- 小程序客服话术 / 自动回复 ----------
MINIAPP_SCRIPT_DEFAULTS = {
'cs_welcome': {
'title': '',
'content': '你好,请问有什么可以帮到您的?',
'confirm_text': '',
'cancel_text': '',
},
'chat_image_confirm': {
'title': '温馨提示',
'content': '平台禁止任何违法违规行为,聊天内容仅限于商家与接单员正常业务对接。严禁赌博、诈骗及其他非法行为,违者后果自负。',
'confirm_text': '确认',
'cancel_text': '取消',
},
}
def _resolve_script_club_id(request):
from jituan.services.club_context import resolve_club_id_from_request
from jituan.services.club_user import get_user_club_id
user = getattr(request, 'user', None)
club_id = get_user_club_id(user) if user and getattr(user, 'is_authenticated', False) else None
if not club_id:
club_id = resolve_club_id_from_request(request)
return club_id or 'xq'
def _merge_script_scene(scene_key, row):
base = dict(MINIAPP_SCRIPT_DEFAULTS.get(scene_key) or {})
if not row:
return base
if row.title:
base['title'] = row.title
if row.content:
base['content'] = row.content
if row.confirm_text:
base['confirm_text'] = row.confirm_text
if row.cancel_text:
base['cancel_text'] = row.cancel_text
return base
class HuashuHqView(APIView):
"""POST /peizhi/huashuhq 批量获取场景话术"""
permission_classes = [IsAuthenticated]
def post(self, request):
scene_keys = request.data.get('scene_keys') or []
if not isinstance(scene_keys, list) or not scene_keys:
return Response({'code': 400, 'msg': 'scene_keys 不能为空', 'data': None}, status=400)
club_id = _resolve_script_club_id(request)
rows = MiniappScriptScene.query.filter(
club_id=club_id, scene_key__in=scene_keys, enabled=True,
)
row_map = {r.scene_key: r for r in rows}
data = {}
for key in scene_keys:
if not isinstance(key, str) or not key.strip():
continue
data[key.strip()] = _merge_script_scene(key.strip(), row_map.get(key.strip()))
return Response({'code': 200, 'msg': 'success', 'data': data})
class HuashuMatchView(APIView):
"""POST /peizhi/huashu_match 客服关键词自动回复匹配"""
permission_classes = [IsAuthenticated]
def post(self, request):
scene_key = (request.data.get('scene_key') or '').strip()
user_text = (request.data.get('user_text') or '').strip()
if scene_key != 'cs_auto_reply':
return Response({'code': 400, 'msg': '不支持的 scene_key', 'data': {'matched': False, 'content': ''}})
if not user_text:
return Response({'code': 200, 'msg': 'success', 'data': {'matched': False, 'content': ''}})
club_id = _resolve_script_club_id(request)
rules = MiniappScriptAutoReply.query.filter(
club_id=club_id, enabled=True,
).order_by('-sort_order', 'id')
text_lower = user_text.lower()
for rule in rules:
kw = (rule.keyword or '').strip()
if not kw:
continue
if kw.lower() in text_lower or kw in user_text:
reply_type = rule.reply_type or MiniappScriptAutoReply.REPLY_TEXT
return Response({
'code': 200,
'msg': 'success',
'data': {
'matched': True,
'content': rule.reply_content or '',
'reply_type': reply_type,
},
})
return Response({'code': 200, 'msg': 'success', 'data': {'matched': False, 'content': ''}})