This commit is contained in:
27942
2026-03-05 10:27:28 +08:00
parent 45e21cb7a1
commit 2971bfad8a
18 changed files with 3023 additions and 5 deletions

187
server/api/followup.py Normal file
View File

@@ -0,0 +1,187 @@
# -*- coding: utf-8 -*-
"""
复聊配置 API需要登录
- GET /api/followup-configs -> 查询复聊配置列表
- POST /api/followup-configs -> 创建复聊配置
- GET /api/followup-configs/{id} -> 查询单个配置
- PUT /api/followup-configs/{id} -> 更新配置
- DELETE /api/followup-configs/{id} -> 删除配置
- GET /api/followup-scripts -> 查询话术列表
- POST /api/followup-scripts -> 创建话术
- GET /api/followup-scripts/{id} -> 查询单个话术
- PUT /api/followup-scripts/{id} -> 更新话术
- DELETE /api/followup-scripts/{id} -> 删除话术
- GET /api/followup-records -> 查询复聊记录
- POST /api/followup-records/send -> 手动发送复聊消息
"""
from rest_framework import status
from rest_framework.decorators import api_view
from server.core.response import api_success, api_error
from server.models import FollowUpConfig, FollowUpScript, FollowUpRecord, ContactRecord
from server.serializers import (
FollowUpConfigSerializer,
FollowUpScriptSerializer,
FollowUpRecordSerializer
)
# ────────────────────────── 复聊配置 ──────────────────────────
@api_view(["GET", "POST"])
def followup_config_list(request):
"""复聊配置列表。"""
if request.method == "GET":
position = request.query_params.get("position")
is_active = request.query_params.get("is_active")
qs = FollowUpConfig.objects.all()
if position:
qs = qs.filter(position__icontains=position)
if is_active is not None:
qs = qs.filter(is_active=is_active.lower() in ("true", "1"))
configs = qs.order_by("-created_at")
return api_success(FollowUpConfigSerializer(configs, many=True).data)
# POST
ser = FollowUpConfigSerializer(data=request.data)
ser.is_valid(raise_exception=True)
ser.save()
return api_success(ser.data, http_status=status.HTTP_201_CREATED)
@api_view(["GET", "PUT", "DELETE"])
def followup_config_detail(request, pk):
"""复聊配置详情。"""
try:
obj = FollowUpConfig.objects.get(pk=pk)
except FollowUpConfig.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND, "复聊配置不存在")
if request.method == "GET":
return api_success(FollowUpConfigSerializer(obj).data)
if request.method == "PUT":
ser = FollowUpConfigSerializer(obj, data=request.data, partial=True)
ser.is_valid(raise_exception=True)
ser.save()
return api_success(ser.data)
# DELETE
obj.delete()
return api_success(msg="复聊配置已删除")
# ────────────────────────── 复聊话术 ──────────────────────────
@api_view(["GET", "POST"])
def followup_script_list(request):
"""复聊话术列表。"""
if request.method == "GET":
config_id = request.query_params.get("config_id")
day_number = request.query_params.get("day_number")
qs = FollowUpScript.objects.all()
if config_id:
qs = qs.filter(config_id=config_id)
if day_number is not None:
qs = qs.filter(day_number=day_number)
scripts = qs.order_by("config_id", "day_number", "order")
return api_success(FollowUpScriptSerializer(scripts, many=True).data)
# POST
ser = FollowUpScriptSerializer(data=request.data)
ser.is_valid(raise_exception=True)
ser.save()
return api_success(ser.data, http_status=status.HTTP_201_CREATED)
@api_view(["GET", "PUT", "DELETE"])
def followup_script_detail(request, pk):
"""复聊话术详情。"""
try:
obj = FollowUpScript.objects.get(pk=pk)
except FollowUpScript.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND, "复聊话术不存在")
if request.method == "GET":
return api_success(FollowUpScriptSerializer(obj).data)
if request.method == "PUT":
ser = FollowUpScriptSerializer(obj, data=request.data, partial=True)
ser.is_valid(raise_exception=True)
ser.save()
return api_success(ser.data)
# DELETE
obj.delete()
return api_success(msg="复聊话术已删除")
# ────────────────────────── 复聊记录 ──────────────────────────
@api_view(["GET"])
def followup_record_list(request):
"""复聊记录列表。"""
contact_id = request.query_params.get("contact_id")
config_id = request.query_params.get("config_id")
got_reply = request.query_params.get("got_reply")
page = int(request.query_params.get("page", 1))
page_size = int(request.query_params.get("page_size", 20))
qs = FollowUpRecord.objects.all()
if contact_id:
qs = qs.filter(contact_id=contact_id)
if config_id:
qs = qs.filter(config_id=config_id)
if got_reply is not None:
qs = qs.filter(got_reply=got_reply.lower() in ("true", "1"))
total = qs.count()
start = (page - 1) * page_size
end = start + page_size
records = qs[start:end]
return api_success({
"total": total,
"page": page,
"page_size": page_size,
"results": FollowUpRecordSerializer(records, many=True).data,
})
@api_view(["POST"])
def followup_send_manual(request):
"""手动发送复聊消息。"""
contact_id = request.data.get("contact_id")
content = request.data.get("content", "").strip()
if not contact_id:
return api_error(status.HTTP_400_BAD_REQUEST, "请提供 contact_id")
if not content:
return api_error(status.HTTP_400_BAD_REQUEST, "请提供发送内容")
try:
contact = ContactRecord.objects.get(pk=contact_id)
except ContactRecord.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND, "联系人不存在")
# TODO: 这里需要调用浏览器自动化发送消息
# 暂时只记录到数据库
record = FollowUpRecord.objects.create(
contact_id=contact_id,
config_id=0, # 手动发送
script_id=0, # 手动发送
day_number=0,
content=content,
)
return api_success({
"record": FollowUpRecordSerializer(record).data,
"message": "复聊消息已发送"
})

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""
新增复聊配置表的数据库迁移
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('server', '0003_add_boss_id'),
]
operations = [
migrations.CreateModel(
name='FollowUpConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, verbose_name='配置名称')),
('position', models.CharField(max_length=64, verbose_name='岗位类型')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '复聊配置',
'verbose_name_plural': '复聊配置',
'db_table': 'follow_up_config',
},
),
migrations.CreateModel(
name='FollowUpScript',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('config_id', models.IntegerField(verbose_name='关联的复聊配置ID')),
('day_number', models.IntegerField(verbose_name='第几天1=第一天2=第二天0=往后一直)')),
('content', models.TextField(verbose_name='话术内容')),
('interval_hours', models.IntegerField(default=24, verbose_name='间隔小时数')),
('order', models.IntegerField(default=0, verbose_name='排序')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': '复聊话术',
'verbose_name_plural': '复聊话术',
'db_table': 'follow_up_script',
'ordering': ['config_id', 'day_number', 'order'],
},
),
migrations.CreateModel(
name='FollowUpRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('contact_id', models.IntegerField(verbose_name='关联的联系人ID')),
('config_id', models.IntegerField(verbose_name='使用的复聊配置ID')),
('script_id', models.IntegerField(verbose_name='使用的话术ID')),
('day_number', models.IntegerField(verbose_name='第几天')),
('content', models.TextField(verbose_name='发送的内容')),
('sent_at', models.DateTimeField(auto_now_add=True, verbose_name='发送时间')),
('got_reply', models.BooleanField(default=False, verbose_name='是否得到回复')),
('reply_content', models.TextField(default='', blank=True, verbose_name='回复内容')),
('replied_at', models.DateTimeField(null=True, blank=True, verbose_name='回复时间')),
],
options={
'verbose_name': '复聊记录',
'verbose_name_plural': '复聊记录',
'db_table': 'follow_up_record',
'ordering': ['-sent_at'],
},
),
]

View File

@@ -197,6 +197,65 @@ class SystemConfig(models.Model):
return self.key
class FollowUpConfig(models.Model):
"""复聊配置表。"""
name = models.CharField(max_length=128, verbose_name="配置名称")
position = models.CharField(max_length=64, verbose_name="岗位类型")
is_active = models.BooleanField(default=True, verbose_name="是否启用")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
class Meta:
db_table = "follow_up_config"
verbose_name = "复聊配置"
verbose_name_plural = verbose_name
def __str__(self):
return f"{self.name} ({self.position})"
class FollowUpScript(models.Model):
"""复聊话术表(支持多轮回复)。"""
config_id = models.IntegerField(verbose_name="关联的复聊配置ID")
day_number = models.IntegerField(verbose_name="第几天1=第一天2=第二天0=往后一直)")
content = models.TextField(verbose_name="话术内容")
interval_hours = models.IntegerField(default=24, verbose_name="间隔小时数")
order = models.IntegerField(default=0, verbose_name="排序")
is_active = models.BooleanField(default=True, verbose_name="是否启用")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
class Meta:
db_table = "follow_up_script"
verbose_name = "复聊话术"
verbose_name_plural = verbose_name
ordering = ['config_id', 'day_number', 'order']
def __str__(self):
return f"{self.day_number}天 - {self.content[:20]}"
class FollowUpRecord(models.Model):
"""复聊记录表(记录每次发送的话术和回复)。"""
contact_id = models.IntegerField(verbose_name="关联的联系人ID")
config_id = models.IntegerField(verbose_name="使用的复聊配置ID")
script_id = models.IntegerField(verbose_name="使用的话术ID")
day_number = models.IntegerField(verbose_name="第几天")
content = models.TextField(verbose_name="发送的内容")
sent_at = models.DateTimeField(auto_now_add=True, verbose_name="发送时间")
got_reply = models.BooleanField(default=False, verbose_name="是否得到回复")
reply_content = models.TextField(default="", blank=True, verbose_name="回复内容")
replied_at = models.DateTimeField(null=True, blank=True, verbose_name="回复时间")
class Meta:
db_table = "follow_up_record"
verbose_name = "复聊记录"
verbose_name_plural = verbose_name
ordering = ['-sent_at']
def __str__(self):
return f"联系人{self.contact_id} - 第{self.day_number}"
# ══════════════════════════════════════════════════════════════
# Pydantic 内存模型(非数据库,用于 Worker 运行时状态与任务调度)
# ══════════════════════════════════════════════════════════════

View File

@@ -4,7 +4,10 @@ DRF 序列化器。
"""
from rest_framework import serializers
from server.models import BossAccount, TaskLog, FilterConfig, ChatScript, ContactRecord, SystemConfig
from server.models import (
BossAccount, TaskLog, FilterConfig, ChatScript, ContactRecord, SystemConfig,
FollowUpConfig, FollowUpScript, FollowUpRecord
)
# ────────────────────────── 账号 ──────────────────────────
@@ -122,3 +125,35 @@ class SystemConfigSerializer(serializers.ModelSerializer):
model = SystemConfig
fields = "__all__"
read_only_fields = ["updated_at"]
# ────────────────────────── 复聊配置 ──────────────────────────
class FollowUpScriptSerializer(serializers.ModelSerializer):
"""复聊话术序列化器。"""
class Meta:
model = FollowUpScript
fields = "__all__"
read_only_fields = ["id", "created_at"]
class FollowUpConfigSerializer(serializers.ModelSerializer):
"""复聊配置序列化器(包含关联的话术列表)。"""
scripts = serializers.SerializerMethodField()
class Meta:
model = FollowUpConfig
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
def get_scripts(self, obj):
"""获取该配置下的所有话术。"""
scripts = FollowUpScript.objects.filter(config_id=obj.id, is_active=True).order_by('day_number', 'order')
return FollowUpScriptSerializer(scripts, many=True).data
class FollowUpRecordSerializer(serializers.ModelSerializer):
"""复聊记录序列化器。"""
class Meta:
model = FollowUpRecord
fields = "__all__"
read_only_fields = ["id", "sent_at"]

View File

@@ -5,7 +5,10 @@ from django.urls import path, re_path
from django.conf import settings
from django.views.static import serve
from server.api import auth, accounts, tasks, workers, filters, scripts, contacts, stats, settings as api_settings
from server.api import (
auth, accounts, tasks, workers, filters, scripts, contacts, stats,
settings as api_settings, followup
)
urlpatterns = [
# ─── 健康检查 ───
@@ -46,6 +49,14 @@ urlpatterns = [
path("api/contacts/export", contacts.contact_export),
path("api/contacts/<int:pk>", contacts.contact_detail),
# ─── 复聊配置 ───
path("api/followup-configs", followup.followup_config_list),
path("api/followup-configs/<int:pk>", followup.followup_config_detail),
path("api/followup-scripts", followup.followup_script_list),
path("api/followup-scripts/<int:pk>", followup.followup_script_detail),
path("api/followup-records", followup.followup_record_list),
path("api/followup-records/send", followup.followup_send_manual),
# ─── 统计分析 ───
path("api/stats", stats.stats_overview),
path("api/stats/daily", stats.stats_daily),