This commit is contained in:
wangli 2026-02-11 23:28:24 +08:00
parent ad500f84af
commit 8ab91f02c1
15 changed files with 681 additions and 74 deletions

View File

@ -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>

View File

@ -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());
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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);
}
// 检查响应

View File

@ -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);
}

View File

@ -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) {