feat(chat): 实现AI客服助手与手机号码识别功能

- 新增AI助手提示词模板,定义角色、目标与交互规则
- 实现手机号自动识别并触发消息发送工具- 添加RabbitMQ配置与消息收发组件
- 集成SendMessage工具支持用户留资通知
- 引入会话上下文管理工具类ConversationContext
- 升级聊天客户端配置,加载系统提示词与默认工具
- 增加数据库操作工具日志记录
- 添加Spring AMQP与Jackson依赖支持消息队列通信
This commit is contained in:
2025-10-27 20:23:42 +08:00
parent 5c0feab211
commit 501980046b
11 changed files with 359 additions and 7 deletions

View File

@@ -83,6 +83,14 @@
<groupId>com.fasterxml.jackson.datatype</groupId> <groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId> <artifactId>jackson-datatype-jsr310</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>

View File

@@ -4,10 +4,10 @@ import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.memory.redis.BaseRedisChatMemoryRepository; import com.alibaba.cloud.ai.memory.redis.BaseRedisChatMemoryRepository;
import com.alibaba.cloud.ai.memory.redis.LettuceRedisChatMemoryRepository; import com.alibaba.cloud.ai.memory.redis.LettuceRedisChatMemoryRepository;
import com.hanserwei.chat.tools.AiDBTools; import com.hanserwei.chat.tools.AiDBTools;
import com.hanserwei.chat.tools.SendMQMessageTools;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -31,6 +31,9 @@ public class ChatClientConfiguration {
private DashScopeChatModel dashScopeChatModel; private DashScopeChatModel dashScopeChatModel;
@Resource @Resource
private AiDBTools aiDBTools; private AiDBTools aiDBTools;
@Value("classpath:prompt/aiAssistant.st")
private org.springframework.core.io.Resource aiAssistantResource;
@Bean @Bean
public BaseRedisChatMemoryRepository redisChatMemoryRepository() { public BaseRedisChatMemoryRepository redisChatMemoryRepository() {
@@ -52,10 +55,11 @@ public class ChatClientConfiguration {
} }
@Bean @Bean
public ChatClient dashScopeChatClient(ChatMemory chatMemory) { public ChatClient dashScopeChatClient(ChatMemory chatMemory, SendMQMessageTools sendMQMessageTools) {
return ChatClient.builder(dashScopeChatModel) return ChatClient.builder(dashScopeChatModel)
.defaultTools(aiDBTools) .defaultTools(aiDBTools, sendMQMessageTools)
.defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build(), new SimpleLoggerAdvisor()) .defaultSystem(aiAssistantResource)
.defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build())
.build(); .build();
} }
} }

View File

@@ -0,0 +1,37 @@
package com.hanserwei.chat.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
@Bean
public DirectExchange directExchange() {
return new DirectExchange("chat.exchange");
}
@Bean
public Queue queue() {
return new Queue("chat.queue");
}
@Bean
public Binding binding() {
return BindingBuilder.bind(queue())
.to(directExchange())
.with("chat.routing.key");
}
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
}

View File

@@ -0,0 +1,30 @@
package com.hanserwei.chat.consumer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
@Slf4j
@Component
public class RabbitMQConsumer {
@RabbitListener(queues = "chat.queue")
public void receiveMessage(Object message) {
String messageText = extractMessage(message);
log.info("Received message text:\n{}", messageText);
}
private String extractMessage(Object message) {
return switch (message) {
case null -> "";
case String str -> str;
case byte[] body -> new String(body, StandardCharsets.UTF_8);
case Message amqpMessage -> new String(amqpMessage.getBody(), StandardCharsets.UTF_8);
default -> String.valueOf(message);
};
}
}

View File

@@ -2,7 +2,10 @@ package com.hanserwei.chat.controller;
import com.hanserwei.chat.model.dto.ChatMessageDTO; import com.hanserwei.chat.model.dto.ChatMessageDTO;
import com.hanserwei.chat.model.vo.AIResponse; import com.hanserwei.chat.model.vo.AIResponse;
import com.hanserwei.chat.tools.SendMQMessageTools;
import com.hanserwei.chat.utils.ConversationContext;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -12,15 +15,26 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j
@RestController @RestController
@RequestMapping("/ai") @RequestMapping("/ai")
public class AiChatController { public class AiChatController {
@Resource @Resource
private ChatClient dashScopeChatClient; private ChatClient dashScopeChatClient;
@Resource
private SendMQMessageTools sendMQMessageTools;
private static final Pattern PHONE_PATTERN = Pattern.compile("(?<!\\d)(1[3-9]\\d{9})(?!\\d)");
@PostMapping(path = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(path = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<AIResponse> chatWithAi(@RequestBody ChatMessageDTO chatMessageDTO) { public Flux<AIResponse> chatWithAi(@RequestBody ChatMessageDTO chatMessageDTO) {
log.info("会话ID{}", chatMessageDTO.getConversionId());
ConversationContext.setConversationId(chatMessageDTO.getConversionId());
triggerSendMessageIfPhonePresent(chatMessageDTO);
return dashScopeChatClient.prompt() return dashScopeChatClient.prompt()
.user(chatMessageDTO.getMessage()) .user(chatMessageDTO.getMessage())
@@ -29,8 +43,26 @@ public class AiChatController {
.chatResponse() .chatResponse()
.mapNotNull(chatResponse -> AIResponse.builder() .mapNotNull(chatResponse -> AIResponse.builder()
.v(chatResponse.getResult().getOutput().getText()) .v(chatResponse.getResult().getOutput().getText())
.build()); .build())
.contextWrite(ctx -> ConversationContext.withConversationId(chatMessageDTO.getConversionId()))
.doFinally(signalType -> ConversationContext.clear());
} }
private void triggerSendMessageIfPhonePresent(ChatMessageDTO chatMessageDTO) {
String message = chatMessageDTO.getMessage();
if (message == null || message.isEmpty()) {
return;
}
Matcher matcher = PHONE_PATTERN.matcher(message);
if (!matcher.find()) {
return;
}
String phoneNumber = matcher.group(1);
log.info("检测到手机号:{}会话ID{}", phoneNumber, chatMessageDTO.getConversionId());
sendMQMessageTools.sendMQMessage(phoneNumber)
.doOnError(error -> log.error("触发发送消息工具失败,手机号:{},错误:{}", phoneNumber, error.getMessage(), error))
.subscribe(result -> log.info("已触发发送消息工具,手机号:{},结果:{}", phoneNumber, result));
}
} }

View File

@@ -0,0 +1,29 @@
package com.hanserwei.chat.publisher;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class RabbitMQPublisher {
@Resource
private RabbitTemplate rabbitTemplate;
/**
* 发送消息
*
* @param exchange 交换机
* @param routingKey 路由键
* @param message 消息
*/
public void send(String exchange, String routingKey, Object message) {
try {
rabbitTemplate.convertAndSend(exchange, routingKey, message);
} catch (Exception e) {
log.error("RabbitMQ发送消息失败{}", e.getMessage());
}
}
}

View File

@@ -4,12 +4,14 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hanserwei.chat.domain.dataobject.User; import com.hanserwei.chat.domain.dataobject.User;
import com.hanserwei.chat.service.UserService; import com.hanserwei.chat.service.UserService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
@Slf4j
@Component @Component
public class AiDBTools { public class AiDBTools {
@@ -18,21 +20,25 @@ public class AiDBTools {
@Tool(name = "findAll", description = "查询所有用户") @Tool(name = "findAll", description = "查询所有用户")
public List<User> findAll() { public List<User> findAll() {
log.info("AiDBTools: findAll");
return userService.list(); return userService.list();
} }
@Tool(name = "findAllByIdIn", description = "根据id列表查询用户") @Tool(name = "findAllByIdIn", description = "根据id列表查询用户")
public List<User> findAllByIdIn(@ToolParam(description = "用户id列表") List<Long> ids) { public List<User> findAllByIdIn(@ToolParam(description = "用户id列表") List<Long> ids) {
log.info("AiDBTools: findAllByIdIn");
return userService.listByIds(ids); return userService.listByIds(ids);
} }
@Tool(name = "findById", description = "根据id查询用户") @Tool(name = "findById", description = "根据id查询用户")
public User findById(Long id) { public User findById(Long id) {
log.info("AiDBTools: findById");
return userService.getById(id); return userService.getById(id);
} }
@Tool(name = "findByName", description = "根据名称查询用户") @Tool(name = "findByName", description = "根据名称查询用户")
public User findByName(String name) { public User findByName(String name) {
log.info("AiDBTools: findByName");
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(User.class) LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(User.class)
.eq(User::getName, name); .eq(User::getName, name);
return userService.getOne(queryWrapper); return userService.getOne(queryWrapper);
@@ -40,6 +46,7 @@ public class AiDBTools {
@Tool(name = "findByNameLike", description = "根据名称模糊查询用户") @Tool(name = "findByNameLike", description = "根据名称模糊查询用户")
public List<User> findByNameLike(String name) { public List<User> findByNameLike(String name) {
log.info("AiDBTools: findByNameLike");
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(User.class) LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(User.class)
.like(User::getName, name); .like(User::getName, name);
return userService.list(queryWrapper); return userService.list(queryWrapper);
@@ -47,6 +54,7 @@ public class AiDBTools {
@Tool(name = "findByAge", description = "根据年龄查询用户") @Tool(name = "findByAge", description = "根据年龄查询用户")
public List<User> findByAge(Integer age) { public List<User> findByAge(Integer age) {
log.info("AiDBTools: findByAge");
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(User.class) LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(User.class)
.eq(User::getAge, age); .eq(User::getAge, age);
return userService.list(queryWrapper); return userService.list(queryWrapper);
@@ -54,6 +62,7 @@ public class AiDBTools {
@Tool(name = "findByAgeBetween", description = "根据年龄范围查询用户") @Tool(name = "findByAgeBetween", description = "根据年龄范围查询用户")
public List<User> findByAgeBetween(Integer start, Integer end) { public List<User> findByAgeBetween(Integer start, Integer end) {
log.info("AiDBTools: findByAgeBetween");
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(User.class) LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(User.class)
.between(User::getAge, start, end); .between(User::getAge, start, end);
return userService.list(queryWrapper); return userService.list(queryWrapper);
@@ -68,6 +77,7 @@ public class AiDBTools {
name (String), email (String), 和 age (Integer)。 name (String), email (String), 和 age (Integer)。
""") """)
public void insert(@ToolParam(description = "用户对象") User user) { public void insert(@ToolParam(description = "用户对象") User user) {
log.info("AiDBTools: insert");
userService.save(user); userService.save(user);
} }
@@ -77,11 +87,13 @@ public class AiDBTools {
并携带要修改的字段,例如 name (String), email (String), 或 age (Integer)。 并携带要修改的字段,例如 name (String), email (String), 或 age (Integer)。
""") """)
public void update(@ToolParam(description = "用户对象") User user) { public void update(@ToolParam(description = "用户对象") User user) {
log.info("AiDBTools: update");
userService.updateById(user); userService.updateById(user);
} }
@Tool(name = "delete", description = "删除用户") @Tool(name = "delete", description = "删除用户")
public void delete(Long id) { public void delete(Long id) {
log.info("AiDBTools: delete");
userService.removeById(id); userService.removeById(id);
} }

View File

@@ -0,0 +1,105 @@
package com.hanserwei.chat.tools;
import com.hanserwei.chat.publisher.RabbitMQPublisher;
import com.hanserwei.chat.utils.ConversationContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.AbstractMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Slf4j
@Component
public class SendMQMessageTools {
@Resource
private RabbitMQPublisher rabbitMQPublisher;
@Resource
private ChatMemory chatMemory;
private static final int HISTORY_LIMIT = 10;
/**
* 发送RabbitMQ消息
*
* @param phoneNumber 手机号码
*/
@Tool(name = "SendMessage",
description = "当用户留下手机号码时立即调用此工具发送消息通知管理员。手机号码应该是11位数字。")
public Mono<String> sendMQMessage(
@ToolParam(description = "用户提供的手机号码必须是11位数字") String phoneNumber
) {
return ConversationContext.getConversationIdMono()
.defaultIfEmpty(ConversationContext.getConversationId())
.map(conversationId -> {
String exchange = "chat.exchange";
String routingKey = "chat.routing.key";
String recentConversation = buildRecentConversation(conversationId);
String message = "用户留了手机号:" + phoneNumber + "会话ID" + conversationId + recentConversation;
log.info("SendMQMessageTools 发送消息: {}", message);
rabbitMQPublisher.send(exchange, routingKey, message);
// 确保日志能被记录
log.info("SendMQMessageTools 消息发送完成");
return "消息发送成功";
});
}
private String buildRecentConversation(Long conversationId) {
if (conversationId == null) {
return "。最近对话:暂无记录";
}
List<Message> history = null;
try {
history = chatMemory.get(String.valueOf(conversationId));
} catch (Exception ex) {
log.warn("获取会话{}历史记录失败: {}", conversationId, ex.getMessage(), ex);
}
if (history == null || history.isEmpty()) {
return "。最近对话:暂无记录";
}
int skip = Math.max(0, history.size() - HISTORY_LIMIT);
String recent = history.stream()
.skip(skip)
.map(SendMQMessageTools::formatMessage)
.filter(Objects::nonNull)
.collect(Collectors.joining("\n"));
if (recent.isEmpty()) {
return "。最近对话:暂无记录";
}
return "。最近对话:\n" + recent;
}
private static String formatMessage(Message message) {
if (message == null) {
return null;
}
MessageType messageType = message.getMessageType();
String role = messageType != null ? messageType.name() : "UNKNOWN";
String text = (message instanceof AbstractMessage abstractMessage)
? abstractMessage.getText()
: message.toString();
if (text == null || text.isBlank()) {
return null;
}
return role + ": " + text.strip();
}
}

View File

@@ -0,0 +1,63 @@
package com.hanserwei.chat.utils;
import reactor.core.publisher.Mono;
import reactor.util.context.Context;
import reactor.util.context.ContextView;
/**
* 会话上下文工具类用于在线程本地存储中保存会话ID
* 同时支持Reactor Context以支持响应式编程中的上下文传递
*/
public class ConversationContext {
private static final ThreadLocal<Long> CONVERSATION_ID_HOLDER = new ThreadLocal<>();
// Reactor Context key
private static final String CONVERSATION_ID_KEY = "conversationId";
/**
* 设置当前线程的会话ID
* @param conversationId 会话ID
*/
public static void setConversationId(Long conversationId) {
CONVERSATION_ID_HOLDER.set(conversationId);
}
/**
* 获取当前线程的会话ID
* @return 会话ID
*/
public static Long getConversationId() {
// 首先尝试从ThreadLocal获取
return CONVERSATION_ID_HOLDER.get();
}
/**
* 从Reactor Context中获取会话ID
* @return 包含会话ID的Mono
*/
public static Mono<Long> getConversationIdMono() {
return Mono.deferContextual(ctx -> {
if (ctx.hasKey(CONVERSATION_ID_KEY)) {
return Mono.just(ctx.get(CONVERSATION_ID_KEY));
}
return Mono.empty();
});
}
/**
* 在Reactor Context中设置会话ID
* @param conversationId 会话ID
* @return Context
*/
public static Context withConversationId(Long conversationId) {
return Context.of(CONVERSATION_ID_KEY, conversationId);
}
/**
* 清理当前线程的会话ID
*/
public static void clear() {
CONVERSATION_ID_HOLDER.remove();
}
}

View File

@@ -26,6 +26,15 @@ spring:
max-wait: 10000 max-wait: 10000
min-idle: 10 min-idle: 10
time-between-eviction-runs: 10000 time-between-eviction-runs: 10000
rabbitmq:
host: localhost
port: 5672
username: admin
password: admin123
virtual-host: /snailsAi
listener:
simple:
prefetch: 1
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/postgres?serverTimezone=Asia/Shanghai url: jdbc:postgresql://localhost:5432/postgres?serverTimezone=Asia/Shanghai

View File

@@ -0,0 +1,23 @@
### 🎯 ****
****AIHanserUniverse********
使 / / //
### 📜 ****
1. **** ****
2. **** ********
> **"如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。"**
3. ****
* ****11位数字**SendMessage**
* **** "感谢您留下手机号码我们已收到您的信息专业的客服人员将在24小时内与您联系请保持手机畅通。"
### 💡 ****
| 步骤 | | AI助手的行为 |
| :--- | :--- | :--- |
| **1 ()** | "你们产品的价格是多少?" | 1. <br> 2. **** "如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。" |
| **2 ()** | "好的这是我的手机号13800001234" | 1. **** <br> 2. **** `SendMessage` <br> 3. **** "感谢您留下手机号码我们已收到您的信息专业的客服人员将在24小时内与您联系请保持手机畅通。" |
| **3 ()** | "你们的退货政策呢?" | 1. 退 <br> 2. **** "如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。" |