init
This commit is contained in:
parent
ad500f84af
commit
8ab91f02c1
@ -16,6 +16,11 @@
|
||||
<artifactId>xianyu-common</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>top.biwin</groupId>
|
||||
<artifactId>xianyu-core</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
|
||||
|
||||
@ -47,7 +47,33 @@ public class AiController {
|
||||
.message("操作成功")
|
||||
.data(responseContent)
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
/**
|
||||
* 生成回复 (Core)
|
||||
*/
|
||||
@PostMapping("/generate-reply")
|
||||
public ResponseEntity<BaseResponse> generateReply(@RequestParam("message") String message,
|
||||
@RequestParam("chat_id") String chatId,
|
||||
@RequestParam("cookie_id") String cookieId,
|
||||
@RequestParam("user_id") String userId,
|
||||
@RequestParam("item_id") String itemId,
|
||||
@RequestParam(value = "item_title", required = false) String itemTitle,
|
||||
@RequestParam(value = "item_price", required = false) String itemPrice,
|
||||
@RequestParam(value = "item_desc", required = false) String itemDesc,
|
||||
@RequestParam(value = "skip_wait", defaultValue = "false") boolean skipWait) {
|
||||
|
||||
java.util.Map<String, Object> itemInfo = new java.util.HashMap<>();
|
||||
itemInfo.put("title", itemTitle);
|
||||
itemInfo.put("price", itemPrice);
|
||||
itemInfo.put("desc", itemDesc);
|
||||
|
||||
String reply = aiService.generateReply(message, itemInfo, chatId, cookieId, userId, itemId, skipWait);
|
||||
|
||||
return ResponseEntity.ok(BaseResponse.builder()
|
||||
.success(reply != null)
|
||||
.message(reply != null ? "回复生成成功" : "无需回复或生成失败")
|
||||
.data(reply)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +12,29 @@ package top.biwin.xianyu.ai.service;
|
||||
public interface AiService {
|
||||
|
||||
/**
|
||||
* 发送提示词并获取 AI 回复
|
||||
* 发送提示词并获取 AI 回复 (Simple)
|
||||
*
|
||||
* @param prompt 用户输入的提示词
|
||||
* @param provider 指定的 AI 提供商(如果为 null,则使用默认配置)
|
||||
* @return AI 的文本回复
|
||||
*/
|
||||
String chat(String prompt, String provider);
|
||||
|
||||
/**
|
||||
* 生成 AI 回复 (Core)
|
||||
* <p>
|
||||
* 包含意图检测、上下文管理、议价控制、去抖动等完整流程
|
||||
* </p>
|
||||
*
|
||||
* @param message 用户消息
|
||||
* @param itemInfo 商品信息 (title, price, desc)
|
||||
* @param chatId 会话 ID
|
||||
* @param cookieId 账号 Cookie ID
|
||||
* @param userId 对方用户 ID
|
||||
* @param itemId 商品 ID
|
||||
* @param skipWait 是否跳过等待 (外部防抖)
|
||||
* @return AI 回复内容,如果未生成则返回 null
|
||||
*/
|
||||
String generateReply(String message, java.util.Map<String, Object> itemInfo, String chatId,
|
||||
String cookieId, String userId, String itemId, boolean skipWait);
|
||||
}
|
||||
|
||||
@ -1,99 +1,279 @@
|
||||
package top.biwin.xianyu.ai.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.ChatClient;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.openai.OpenAiChatClient;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.stereotype.Service;
|
||||
import top.biwin.xianyu.ai.config.AiProperties;
|
||||
import top.biwin.xianyu.core.repository.AiConversationRepository;
|
||||
import top.biwin.xianyu.ai.service.AiService;
|
||||
import top.biwin.xianyu.ai.strategy.AiClientStrategy;
|
||||
import top.biwin.xianyu.ai.strategy.impl.OpenAiClientStrategy;
|
||||
import top.biwin.xianyu.ai.util.AiPromptUtils;
|
||||
import top.biwin.xianyu.core.entity.AiReplySettingEntity;
|
||||
import top.biwin.xianyu.core.repository.AiReplySettingRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI 服务实现类
|
||||
* <p>
|
||||
* 支持多厂商动态切换
|
||||
* </p>
|
||||
*
|
||||
* @author Little Code Sauce
|
||||
* @since 2025
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiServiceImpl implements AiService {
|
||||
public class AiServiceImpl implements AiService, InitializingBean {
|
||||
|
||||
private final AiProperties aiProperties;
|
||||
@Resource
|
||||
private AiProperties aiProperties;
|
||||
|
||||
// 本地缓存 ChatClient,避免重复创建 overhead
|
||||
private final Map<String, ChatClient> clientCache = new ConcurrentHashMap<>();
|
||||
@Resource
|
||||
private AiConversationRepository aiConversationRepository;
|
||||
|
||||
@Resource
|
||||
private AiReplySettingRepository aiReplySettingRepository; // Core module repository
|
||||
|
||||
@Resource
|
||||
private List<AiClientStrategy> aiClientStrategies;
|
||||
|
||||
/**
|
||||
* 会话锁 Map (chatId -> Lock)
|
||||
*/
|
||||
private final Map<String, Lock> chatLocks = new ConcurrentHashMap<>();
|
||||
|
||||
private AiClientStrategy defaultStrategy;
|
||||
|
||||
@Override
|
||||
public String chat(String promptText, String provider) {
|
||||
// 1. 确定使用的 provider
|
||||
String targetProvider = StrUtil.isBlank(provider) ? aiProperties.getDefaultProvider() : provider;
|
||||
log.info("Processing AI request with provider: {}", targetProvider);
|
||||
public void afterPropertiesSet() throws Exception {
|
||||
// 默认使用 OpenAi 策略
|
||||
this.defaultStrategy = aiClientStrategies.stream()
|
||||
.filter(s -> s instanceof OpenAiClientStrategy)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("No OpenAI Strategy found"));
|
||||
}
|
||||
|
||||
// 2. 获取或创建 Client
|
||||
ChatClient chatClient = getClient(targetProvider);
|
||||
if (chatClient == null) {
|
||||
return "Configuration for provider '" + targetProvider + "' not found. Please check your application.yml 🐶";
|
||||
@Override
|
||||
public String chat(String prompt, String provider) {
|
||||
// 简单 chat 实现,使用 application.yml 默认配置
|
||||
String providerName = StrUtil.isBlank(provider) ? aiProperties.getDefaultProvider() : provider;
|
||||
AiProperties.ProviderConfig config = aiProperties.getProviders().get(providerName);
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("Provider config not found: " + providerName);
|
||||
}
|
||||
|
||||
List<Map<String, String>> messages = new ArrayList<>();
|
||||
messages.add(Map.of("role", "user", "content", prompt));
|
||||
|
||||
AiClientStrategy strategy = findStrategy(providerName);
|
||||
return strategy.generate(config, messages);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateReply(String message, Map<String, Object> itemInfo, String chatId,
|
||||
String cookieId, String userId, String itemId, boolean skipWait) {
|
||||
|
||||
// 1. 检查 AI 是否启用
|
||||
AiReplySettingEntity settings = aiReplySettingRepository.findById(cookieId).orElse(null);
|
||||
if (settings == null || !Boolean.TRUE.equals(settings.getAiEnabled())) {
|
||||
log.info("AI Disabled for user: {}", cookieId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 执行调用
|
||||
try {
|
||||
// 注意:0.8.1 版本中 OpenAiChatClient.call(String) 返回 String
|
||||
return chatClient.call(promptText);
|
||||
// 2. 意图检测
|
||||
String intent = AiPromptUtils.detectIntent(message);
|
||||
log.info("Detected Intent: {} (User: {})", intent, cookieId);
|
||||
|
||||
// 3. 保存用户消息
|
||||
top.biwin.xianyu.core.entity.AiConversationEntity userMsg = new top.biwin.xianyu.core.entity.AiConversationEntity();
|
||||
userMsg.setCookieId(cookieId);
|
||||
userMsg.setChatId(chatId);
|
||||
userMsg.setUserId(userId);
|
||||
userMsg.setItemId(itemId);
|
||||
userMsg.setRole("user");
|
||||
userMsg.setContent(message);
|
||||
userMsg.setIntent(intent);
|
||||
// JPA 会自动填充 createdAt
|
||||
aiConversationRepository.save(userMsg);
|
||||
LocalDateTime messageCreatedAt = LocalDateTime.now(); // 近似值,用于比较
|
||||
|
||||
// 4. 去抖动等待 (模拟 Python logic)
|
||||
if (!skipWait) {
|
||||
log.info("【{}】Msg saved, waiting 10s debounce...", cookieId);
|
||||
try {
|
||||
Thread.sleep(10000);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.info("【{}】Msg saved, skip wait.", cookieId);
|
||||
}
|
||||
|
||||
// 5. 获取锁
|
||||
Lock lock = chatLocks.computeIfAbsent(chatId, k -> new ReentrantLock());
|
||||
lock.lock();
|
||||
try {
|
||||
// 6. 检查是否有更新的消息
|
||||
int checkSeconds = skipWait ? 6 : 25;
|
||||
LocalDateTime sinceTime = LocalDateTime.now().minusSeconds(checkSeconds);
|
||||
|
||||
List<top.biwin.xianyu.core.entity.AiConversationEntity> recentMsgs = aiConversationRepository.findRecentUserMessages(chatId, cookieId, sinceTime);
|
||||
if (CollUtil.isNotEmpty(recentMsgs)) {
|
||||
top.biwin.xianyu.core.entity.AiConversationEntity checkLast = recentMsgs.get(recentMsgs.size() - 1);
|
||||
// 简单判断 ID 或 内容,Python 是判断时间戳
|
||||
// 如果 DB 中最新一条消息比当前处理的消息更晚(ID更大),则跳过
|
||||
if (checkLast.getId() > userMsg.getId()) {
|
||||
log.info("【{}】Found newer message, skipping current. (Current ID: {}, Newest ID: {})", cookieId, userMsg.getId(), checkLast.getId());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 处理业务逻辑
|
||||
|
||||
// 获取上下文
|
||||
List<top.biwin.xianyu.core.entity.AiConversationEntity> contextList = aiConversationRepository.findContext(chatId, cookieId, org.springframework.data.domain.PageRequest.of(0, 20));
|
||||
List<String> contextHistory = contextList.stream()
|
||||
.map(c -> c.getRole() + ": " + c.getContent())
|
||||
.collect(Collectors.toList());
|
||||
// findContext 倒序,需要翻转回来给 Prompt
|
||||
CollUtil.reverse(contextHistory);
|
||||
|
||||
// 议价检查
|
||||
long bargainCount = aiConversationRepository.countBargains(chatId, cookieId);
|
||||
int maxBargainRounds = settings.getMaxBargainRounds() != null ? settings.getMaxBargainRounds() : 3;
|
||||
|
||||
if ("price".equals(intent) && bargainCount >= maxBargainRounds) {
|
||||
log.info("Bargain limit reached ({}/{})", bargainCount, maxBargainRounds);
|
||||
String refuseReply = "抱歉,这个价格已经是最优惠的了,不能再便宜了哦!";
|
||||
saveAssistantReply(chatId, cookieId, userId, itemId, refuseReply, intent);
|
||||
return refuseReply;
|
||||
}
|
||||
|
||||
// 构建 Prompt (使用 Settings)
|
||||
String systemPrompt = AiPromptUtils.DEFAULT_PROMPTS.getOrDefault(intent, AiPromptUtils.DEFAULT_PROMPTS.get("default"));
|
||||
if (StrUtil.isNotBlank(settings.getCustomPrompts())) {
|
||||
try {
|
||||
cn.hutool.json.JSONObject customPrompts = cn.hutool.json.JSONUtil.parseObj(settings.getCustomPrompts());
|
||||
if (customPrompts.containsKey(intent)) {
|
||||
systemPrompt = customPrompts.getStr(intent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse custom prompts for user {}: {}", cookieId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
String userPrompt = AiPromptUtils.buildUserPrompt(message, itemInfo, contextHistory,
|
||||
bargainCount, maxBargainRounds,
|
||||
settings.getMaxDiscountPercent() != null ? settings.getMaxDiscountPercent() : 10,
|
||||
settings.getMaxDiscountAmount() != null ? settings.getMaxDiscountAmount() : 100);
|
||||
|
||||
// 8. 调用 AI
|
||||
List<Map<String, String>> aiMessages = new ArrayList<>();
|
||||
aiMessages.add(Map.of("role", "system", "content", systemPrompt));
|
||||
aiMessages.add(Map.of("role", "user", "content", userPrompt));
|
||||
|
||||
// 确定配置: 优先使用 Settings 中的 Key, 否则尝试使用 application.yml
|
||||
AiProperties.ProviderConfig config = resolveProviderConfig(settings);
|
||||
|
||||
AiClientStrategy strategy = findStrategy(settings.getModelName()); // 或根据 config provider name
|
||||
|
||||
log.info("Generating reply via strategy: {}", strategy.getClass().getSimpleName());
|
||||
String reply = strategy.generate(config, aiMessages);
|
||||
|
||||
// 9. 保存回复
|
||||
saveAssistantReply(chatId, cookieId, userId, itemId, reply, intent);
|
||||
|
||||
return reply;
|
||||
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI call failed for provider: {}", targetProvider, e);
|
||||
return "AI request failed: " + e.getMessage();
|
||||
log.error("Error generating reply", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void saveAssistantReply(String chatId, String cookieId, String userId, String itemId, String content, String intent) {
|
||||
top.biwin.xianyu.core.entity.AiConversationEntity replyMsg = new top.biwin.xianyu.core.entity.AiConversationEntity();
|
||||
replyMsg.setCookieId(cookieId);
|
||||
replyMsg.setChatId(chatId);
|
||||
replyMsg.setUserId(userId);
|
||||
replyMsg.setItemId(itemId);
|
||||
replyMsg.setRole("assistant");
|
||||
replyMsg.setContent(content);
|
||||
replyMsg.setIntent(intent);
|
||||
aiConversationRepository.save(replyMsg);
|
||||
}
|
||||
|
||||
private AiClientStrategy findStrategy(String providerOrModel) {
|
||||
for (AiClientStrategy strategy : aiClientStrategies) {
|
||||
if (strategy.supports(providerOrModel)) {
|
||||
return strategy;
|
||||
}
|
||||
}
|
||||
return defaultStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或懒加载创建 ChatClient
|
||||
* 解析 AI 配置
|
||||
* 策略:
|
||||
* 1. 如果 Settings 中有完整 url/key, 构造临时 config.
|
||||
* 2. 如果 Settings 中只有 modelName, 尝试从 application.yml 匹配 provider.
|
||||
*/
|
||||
private ChatClient getClient(String providerName) {
|
||||
if (clientCache.containsKey(providerName)) {
|
||||
return clientCache.get(providerName);
|
||||
private AiProperties.ProviderConfig resolveProviderConfig(AiReplySettingEntity settings) {
|
||||
AiProperties.ProviderConfig config = new AiProperties.ProviderConfig();
|
||||
|
||||
// 优先使用 Settings (用户自定义)
|
||||
if (StrUtil.isNotBlank(settings.getApiKey())) {
|
||||
config.setApiKey(settings.getApiKey());
|
||||
config.setBaseUrl(settings.getBaseUrl());
|
||||
config.setModel(settings.getModelName());
|
||||
config.setTemperature(0.7); // Default
|
||||
return config;
|
||||
}
|
||||
|
||||
AiProperties.ProviderConfig config = aiProperties.getProviders().get(providerName);
|
||||
if (config == null) {
|
||||
log.warn("No configuration found for provider: {}", providerName);
|
||||
return null;
|
||||
// 尝试映射 Model Name 到 yml 配置
|
||||
// 简单映射: 如果 modelName 包含 "deepseek" -> deepseek provider
|
||||
String model = settings.getModelName();
|
||||
String providerKey = aiProperties.getDefaultProvider();
|
||||
|
||||
if (model != null) {
|
||||
model = model.toLowerCase();
|
||||
if (model.contains("deepseek")) providerKey = "deepseek";
|
||||
else if (model.contains("qwen")) providerKey = "qwen";
|
||||
else if (model.contains("gpt")) providerKey = "openai";
|
||||
else if (model.contains("moonshot")) providerKey = "moonshot";
|
||||
else if (model.contains("gemini")) providerKey = "gemini";
|
||||
}
|
||||
|
||||
log.info("Creating new ChatClient for provider: {} (URL: {}, Model: {})",
|
||||
providerName, config.getBaseUrl(), config.getModel());
|
||||
|
||||
try {
|
||||
// 构造 OpenAiApi
|
||||
// 0.8.1 API: new OpenAiApi(baseUrl, apiKey)
|
||||
OpenAiApi openAiApi = new OpenAiApi(config.getBaseUrl(), config.getApiKey());
|
||||
|
||||
// 构造 Options
|
||||
OpenAiChatOptions options = OpenAiChatOptions.builder()
|
||||
.withModel(config.getModel())
|
||||
.withTemperature(config.getTemperature().floatValue())
|
||||
.build();
|
||||
|
||||
// 构造 Client
|
||||
// 0.8.1 API: new OpenAiChatClient(api, options)
|
||||
OpenAiChatClient client = new OpenAiChatClient(openAiApi, options);
|
||||
|
||||
clientCache.put(providerName, client);
|
||||
return client;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create ChatClient for provider: {}", providerName, e);
|
||||
return null;
|
||||
AiProperties.ProviderConfig sysConfig = aiProperties.getProviders().get(providerKey);
|
||||
if (sysConfig != null) {
|
||||
// 如果系统配置存在,使用系统配置,但 Model 使用设置里的 (如果设置里有)
|
||||
AiProperties.ProviderConfig merged = new AiProperties.ProviderConfig();
|
||||
merged.setBaseUrl(sysConfig.getBaseUrl());
|
||||
merged.setApiKey(sysConfig.getApiKey());
|
||||
merged.setTemperature(sysConfig.getTemperature());
|
||||
merged.setModel(StrUtil.isNotBlank(settings.getModelName()) ? settings.getModelName() : sysConfig.getModel());
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
config.setBaseUrl(settings.getBaseUrl());
|
||||
config.setModel(settings.getModelName());
|
||||
config.setApiKey("");
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
package top.biwin.xianyu.ai.strategy;
|
||||
|
||||
import top.biwin.xianyu.ai.config.AiProperties;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 客户端策略接口
|
||||
*
|
||||
* @author Little Code Sauce
|
||||
* @since 2025
|
||||
*/
|
||||
public interface AiClientStrategy {
|
||||
|
||||
/**
|
||||
* 是否支持该提供商
|
||||
*
|
||||
* @param provider 提供商名称
|
||||
* @return true if supported
|
||||
*/
|
||||
boolean supports(String provider);
|
||||
|
||||
/**
|
||||
* 生成回复
|
||||
*
|
||||
* @param config 提供商配置
|
||||
* @param messages 消息列表 (包含 system prompt 和 user prompt)
|
||||
* @return AI 回复内容
|
||||
*/
|
||||
String generate(AiProperties.ProviderConfig config, List<Map<String, String>> messages);
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package top.biwin.xianyu.ai.strategy.impl;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.openai.OpenAiChatClient;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import top.biwin.xianyu.ai.config.AiProperties;
|
||||
import top.biwin.xianyu.ai.strategy.AiClientStrategy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OpenAI 及其兼容协议 (DeepSeek, Moonshot, etc.) 的客户端策略
|
||||
*
|
||||
* @author Little Code Sauce
|
||||
* @since 2025
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class OpenAiClientStrategy implements AiClientStrategy {
|
||||
|
||||
@Override
|
||||
public boolean supports(String provider) {
|
||||
// 支持 openai, deepseek, moonshot, grok, qwen (via compatible mode), gemini
|
||||
return List.of("openai", "deepseek", "moonshot", "grok", "qwen", "gemini", "doubao").contains(provider.toLowerCase());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generate(AiProperties.ProviderConfig config, List<Map<String, String>> messages) {
|
||||
log.info("Creating OpenAI Client for model: {} at {}", config.getModel(), config.getBaseUrl());
|
||||
|
||||
// 构建 OpenAiApi 客户端 (Spring AI 0.8.1+)
|
||||
OpenAiApi openAiApi = new OpenAiApi(config.getBaseUrl(), config.getApiKey());
|
||||
|
||||
// 构建 ChatClient
|
||||
OpenAiChatClient chatClient = new OpenAiChatClient(openAiApi, OpenAiChatOptions.builder()
|
||||
.withModel(config.getModel())
|
||||
.withTemperature(config.getTemperature().floatValue())
|
||||
.build());
|
||||
|
||||
// 转换消息格式
|
||||
List<Message> springAiMessages = new ArrayList<>();
|
||||
for (Map<String, String> msg : messages) {
|
||||
String role = msg.get("role");
|
||||
String content = msg.get("content");
|
||||
if ("system".equalsIgnoreCase(role)) {
|
||||
springAiMessages.add(new SystemMessage(content));
|
||||
} else if ("user".equalsIgnoreCase(role)) {
|
||||
springAiMessages.add(new UserMessage(content));
|
||||
}
|
||||
}
|
||||
|
||||
// 调用
|
||||
try {
|
||||
Prompt prompt = new Prompt(springAiMessages);
|
||||
return chatClient.call(prompt).getResult().getOutput().getContent();
|
||||
} catch (Exception e) {
|
||||
log.error("AI Generation Failed: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("AI Service Call Failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
package top.biwin.xianyu.ai.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 提示词工具类
|
||||
* <p>
|
||||
* 负责意图检测和提示词构建
|
||||
* </p>
|
||||
*
|
||||
* @author Little Code Sauce
|
||||
* @since 2025
|
||||
*/
|
||||
@Slf4j
|
||||
public class AiPromptUtils {
|
||||
|
||||
/**
|
||||
* 默认提示词模板
|
||||
*/
|
||||
public static final Map<String, String> DEFAULT_PROMPTS = Map.of(
|
||||
"price", """
|
||||
你是一位经验丰富的销售专家,擅长议价。
|
||||
语言要求:简短直接,每句≤10字,总字数≤40字。
|
||||
议价策略:
|
||||
1. 根据议价次数递减优惠:第1次小幅优惠,第2次中等优惠,第3次最大优惠
|
||||
2. 接近最大议价轮数时要坚持底线,强调商品价值
|
||||
3. 优惠不能超过设定的最大百分比和金额
|
||||
4. 语气要友好但坚定,突出商品优势
|
||||
注意:结合商品信息、对话历史和议价设置,给出合适的回复。
|
||||
""",
|
||||
|
||||
"tech", """
|
||||
你是一位技术专家,专业解答产品相关问题。
|
||||
语言要求:简短专业,每句≤10字,总字数≤40字。
|
||||
回答重点:产品功能、使用方法、注意事项。
|
||||
注意:基于商品信息回答,避免过度承诺。
|
||||
""",
|
||||
|
||||
"default", """
|
||||
你是一位资深电商卖家,提供优质客服。
|
||||
语言要求:简短友好,每句≤10字,总字数≤40字。
|
||||
回答重点:商品介绍、物流、售后等常见问题。
|
||||
注意:结合商品信息,给出实用建议。
|
||||
"""
|
||||
);
|
||||
|
||||
private static final List<String> PRICE_KEYWORDS = List.of(
|
||||
"便宜", "优惠", "刀", "降价", "包邮", "价格", "多少钱", "能少", "还能", "最低", "底价",
|
||||
"实诚价", "到100", "能到", "包个邮", "给个价", "什么价"
|
||||
);
|
||||
|
||||
private static final List<String> TECH_KEYWORDS = List.of(
|
||||
"怎么用", "参数", "坏了", "故障", "设置", "说明书", "功能", "用法", "教程", "驱动"
|
||||
);
|
||||
|
||||
/**
|
||||
* 本地意图检测
|
||||
*/
|
||||
public static String detectIntent(String message) {
|
||||
if (!StringUtils.hasText(message)) {
|
||||
return "default";
|
||||
}
|
||||
String msgLower = message.toLowerCase();
|
||||
|
||||
for (String kw : PRICE_KEYWORDS) {
|
||||
if (msgLower.contains(kw)) {
|
||||
log.debug("本地意图检测: price ({})", message);
|
||||
return "price";
|
||||
}
|
||||
}
|
||||
|
||||
for (String kw : TECH_KEYWORDS) {
|
||||
if (msgLower.contains(kw)) {
|
||||
log.debug("本地意图检测: tech ({})", message);
|
||||
return "tech";
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("本地意图检测: default ({})", message);
|
||||
return "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户 Prompt
|
||||
*/
|
||||
public static String buildUserPrompt(String message, Map<String, Object> itemInfo,
|
||||
List<String> contextHistory,
|
||||
long bargainCount, int maxBargainRounds,
|
||||
int maxDiscountPercent, int maxDiscountAmount) {
|
||||
|
||||
String itemDesc = String.format("""
|
||||
商品标题: %s
|
||||
商品价格: %s元
|
||||
商品描述: %s
|
||||
""",
|
||||
itemInfo.getOrDefault("title", "未知"),
|
||||
itemInfo.getOrDefault("price", "未知"),
|
||||
itemInfo.getOrDefault("desc", "无")
|
||||
);
|
||||
|
||||
String contextStr = String.join("\n", contextHistory);
|
||||
|
||||
return String.format("""
|
||||
商品信息:
|
||||
%s
|
||||
|
||||
对话历史:
|
||||
%s
|
||||
|
||||
议价设置:
|
||||
- 当前议价次数: %d
|
||||
- 最大议价轮数: %d
|
||||
- 最大优惠百分比: %d%%
|
||||
- 最大优惠金额: %d元
|
||||
|
||||
用户消息:%s
|
||||
|
||||
请根据以上信息生成回复:
|
||||
""", itemDesc, contextStr, bargainCount, maxBargainRounds, maxDiscountPercent, maxDiscountAmount, message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package top.biwin.xinayu.common.errcode;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*
|
||||
* @author wangli
|
||||
* @since 2026-02-11 22:41
|
||||
*/
|
||||
public enum BaseCode {
|
||||
NeedReLogin(10001, "登录已过期");
|
||||
private final Integer code;
|
||||
private final String msg;
|
||||
|
||||
BaseCode(Integer code, String msg) {
|
||||
this.code = code;
|
||||
this.msg = msg;
|
||||
}
|
||||
|
||||
public Integer getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getMsg() {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package top.biwin.xinayu.common.exception;
|
||||
|
||||
import top.biwin.xinayu.common.errcode.BaseCode;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*
|
||||
* @author wangli
|
||||
* @since 2026-02-11 22:44
|
||||
*/
|
||||
public class XianyuServerException extends RuntimeException {
|
||||
private final Integer code;
|
||||
private final String msg;
|
||||
|
||||
public XianyuServerException(BaseCode baseCode) {
|
||||
super(baseCode.getMsg());
|
||||
this.code = baseCode.getCode();
|
||||
this.msg = baseCode.getMsg();
|
||||
}
|
||||
|
||||
public Integer getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getMsg() {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package top.biwin.xianyu.core.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* AI 对话记录实体
|
||||
*
|
||||
* @author Little Code Sauce
|
||||
* @since 2025
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "ai_conversations", indexes = {
|
||||
@Index(name = "idx_chat_cookie", columnList = "chat_id, cookie_id"),
|
||||
@Index(name = "idx_created_at", columnList = "created_at")
|
||||
})
|
||||
public class AiConversationEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "cookie_id", length = 100)
|
||||
private String cookieId;
|
||||
|
||||
@Column(name = "chat_id", length = 100)
|
||||
private String chatId;
|
||||
|
||||
@Column(name = "user_id", length = 100)
|
||||
private String userId;
|
||||
|
||||
@Column(name = "item_id", length = 100)
|
||||
private String itemId;
|
||||
|
||||
/**
|
||||
* user / assistant / system
|
||||
*/
|
||||
@Column(length = 20)
|
||||
private String role;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 意图 (price, tech, default等)
|
||||
*/
|
||||
@Column(length = 50)
|
||||
private String intent;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package top.biwin.xianyu.core.repository;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import top.biwin.xianyu.core.entity.AiConversationEntity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 对话记录 Repository
|
||||
*
|
||||
* @author Little Code Sauce
|
||||
* @since 2025
|
||||
*/
|
||||
public interface AiConversationRepository extends JpaRepository<AiConversationEntity, Long> {
|
||||
|
||||
/**
|
||||
* 获取指定对话的上下文
|
||||
*/
|
||||
@Query("SELECT c FROM AiConversation c WHERE c.chatId = :chatId AND c.cookieId = :cookieId ORDER BY c.createdAt DESC")
|
||||
List<AiConversationEntity> findContext(@Param("chatId") String chatId, @Param("cookieId") String cookieId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 统计议价次数 (user role + price intent)
|
||||
*/
|
||||
@Query("SELECT COUNT(c) FROM AiConversation c WHERE c.chatId = :chatId AND c.cookieId = :cookieId AND c.intent = 'price' AND c.role = 'user'")
|
||||
long countBargains(@Param("chatId") String chatId, @Param("cookieId") String cookieId);
|
||||
|
||||
/**
|
||||
* 获取最近的用户消息
|
||||
*/
|
||||
@Query("SELECT c FROM AiConversation c WHERE c.chatId = :chatId AND c.cookieId = :cookieId AND c.role = 'user' AND c.createdAt > :sinceTime ORDER BY c.createdAt ASC")
|
||||
List<AiConversationEntity> findRecentUserMessages(@Param("chatId") String chatId, @Param("cookieId") String cookieId, @Param("sinceTime") LocalDateTime sinceTime);
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
package top.biwin.xianyu.core.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import top.biwin.xianyu.core.entity.AdminUserEntity;
|
||||
import top.biwin.xianyu.core.entity.AiReplySettingEntity;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
/**
|
||||
* AI 回复设置 Repository
|
||||
*
|
||||
* @author Little Code Sauce
|
||||
* @since 2025
|
||||
*/
|
||||
public interface AiReplySettingRepository extends JpaRepository<AiReplySettingEntity, String> {
|
||||
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
package top.biwin.xianyu.goofish.api.impl;
|
||||
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import top.biwin.xianyu.goofish.api.GoofishAbstractApi;
|
||||
import top.biwin.xinayu.common.errcode.BaseCode;
|
||||
import top.biwin.xinayu.common.exception.XianyuServerException;
|
||||
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Map;
|
||||
@ -39,7 +39,7 @@ public class GetWsTokenApi extends GoofishAbstractApi<String> {
|
||||
@Override
|
||||
public String call(String goofishId, String cookieStr, Map<String, Object> data) {
|
||||
try {
|
||||
log.debug("【{}】开始获取闲鱼 WebSocket Token", goofishId);
|
||||
log.debug("【{}】开始获取闲鱼 WebSocket Token", goofishId);
|
||||
java.net.http.HttpResponse<String> response = HTTP_CLIENT.send(buildRequest(goofishId, cookieStr, data), HttpResponse.BodyHandlers.ofString());
|
||||
String body = response.body();
|
||||
log.info("【{}】获取闲鱼 WebSocket Token 时,服务端返回的完整响应为: {}", goofishId, body);
|
||||
@ -48,7 +48,7 @@ public class GetWsTokenApi extends GoofishAbstractApi<String> {
|
||||
// 检查是否需要滑块验证
|
||||
if (needsCaptchaVerification(resJson)) {
|
||||
log.warn("【{}】检测到滑块验证要求,需要刷新Cookie", goofishId);
|
||||
return null;
|
||||
throw new XianyuServerException(BaseCode.NeedReLogin);
|
||||
}
|
||||
|
||||
// 检查响应
|
||||
|
||||
@ -25,6 +25,7 @@ import top.biwin.xianyu.goofish.service.GoofishApiService;
|
||||
import top.biwin.xianyu.goofish.util.CookieUtils;
|
||||
import top.biwin.xianyu.goofish.util.XianyuUtils;
|
||||
import top.biwin.xinayu.common.enums.WebSocketConnectionState;
|
||||
import top.biwin.xinayu.common.exception.XianyuServerException;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
@ -591,7 +592,9 @@ public class GoofishAccountWebsocket extends TextWebSocketHandler {
|
||||
closeWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (XianyuServerException xye) {
|
||||
log.warn("【{}】Token 刷新失败,登录账号已过期 cookie 已过期", goofishId, xye);
|
||||
closeWebSocket();
|
||||
} catch (Exception e) {
|
||||
log.error("【{}】Token刷新循环出错", goofishId, e);
|
||||
}
|
||||
|
||||
@ -16,8 +16,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
* @since 2026-01-20 23:51
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "top.biwin", exclude = {org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration.class})
|
||||
@EntityScan(basePackages = "top.biwin.xianyu.core.entity")
|
||||
@EnableJpaRepositories(basePackages = "top.biwin.xianyu.core.repository")
|
||||
@EntityScan(basePackages = "top.biwin.xianyu")
|
||||
@EnableJpaRepositories(basePackages = "top.biwin.xianyu")
|
||||
//@EnableConfigurationProperties
|
||||
public class XianyuFreedomApplication {
|
||||
public static void main(String[] args) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user