From 4a8d21a742c8286111390916027527bbec507ccd Mon Sep 17 00:00:00 2001 From: Administrator <1037463791@qq.com> Date: Thu, 4 Sep 2025 16:35:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(billing):=201.=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=A1=E8=B4=B9=E4=BB=A3=E7=90=86=20Billin?= =?UTF-8?q?gChatServiceProxy=E4=BD=8D=E7=BD=AE=EF=BC=9Aruoyi-modules/ruoyi?= =?UTF-8?q?-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingCh?= =?UTF-8?q?atServiceProxy.java=20=E4=BD=9C=E7=94=A8=EF=BC=9A=E4=B8=BA?= =?UTF-8?q?=E6=89=80=E6=9C=89ChatService=E5=AE=9E=E7=8E=B0=E7=B1=BB?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E9=80=8F=E6=98=8E=E7=9A=84=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=8C=85=E8=A3=85=20=20=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=20=20=20AI=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=89=8D=E4=BD=99=E9=A2=9D=E9=A2=84=E6=A3=80=E6=9F=A5=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E6=97=A0=E6=95=88=E6=B6=88=E8=80=97=20=20=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=94=B6=E9=9B=86AI=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=86=85=E5=AE=B9=20=20=20=E7=BB=9F=E4=B8=80=E5=A4=84=E7=90=86?= =?UTF-8?q?AI=E5=9B=9E=E5=A4=8D=E7=9A=84=E4=BF=9D=E5=AD=98=E5=92=8C?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=20=20=20=20=E9=80=82=E9=85=8D=E5=A4=9A?= =?UTF-8?q?=E7=A7=8DAI=E6=9C=8D=E5=8A=A1=E7=9A=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=20=20=202.=20=E9=87=8D=E6=9E=84=E5=B7=A5?= =?UTF-8?q?=E5=8E=82=E7=B1=BB=20=20=20ChatServiceFactory=20=20=20=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=EF=BC=9A=E8=87=AA=E5=8A=A8=E4=B8=BA=E6=89=80=E6=9C=89?= =?UTF-8?q?ChatService=E5=8C=85=E8=A3=85=E8=AE=A1=E8=B4=B9=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20=20=E6=96=B0=E5=A2=9E=E6=96=B9=E6=B3=95=EF=BC=9Aget?= =?UTF-8?q?OriginalService()=20=E7=94=A8=E4=BA=8E=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E6=9C=AA=E5=8C=85=E8=A3=85=E7=9A=84=E5=8E=9F=E5=A7=8B=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E4=BC=98=E5=8A=BF=EF=BC=9A=E8=B0=83=E7=94=A8=E6=96=B9?= =?UTF-8?q?=E6=97=A0=E9=9C=80=E5=85=B3=E5=BF=83=E8=AE=A1=E8=B4=B9=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E5=AE=8C=E5=85=A8=E9=80=8F=E6=98=8E=20=203.?= =?UTF-8?q?=20=E5=A2=9E=E5=BC=BA=E8=AE=A1=E8=B4=B9=E6=9C=8D=E5=8A=A1=20ICh?= =?UTF-8?q?atCostService=20=E6=8E=A5=E5=8F=A3=20=20=20=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=96=B9=E6=B3=95=EF=BC=9AcheckBalanceSufficient()=20?= =?UTF-8?q?-=20=E4=BD=99=E9=A2=9D=E9=A2=84=E6=A3=80=E6=9F=A5=20=20=20=20?= =?UTF-8?q?=E5=88=86=E7=A6=BB=E5=85=B3=E6=B3=A8=E7=82=B9=EF=BC=9AsaveMessa?= =?UTF-8?q?ge()=20-=20=E4=BB=85=E4=BF=9D=E5=AD=98=E6=B6=88=E6=81=AF=20=20?= =?UTF-8?q?=20=20=20publishBillingEvent()=20-=20=E4=BB=85=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=AE=A1=E8=B4=B9=E4=BA=8B=E4=BB=B6=20=20=20=20=20ded?= =?UTF-8?q?uctToken()=20-=20=E4=BB=85=E6=89=A7=E8=A1=8C=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E6=89=A3=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/proxy/BillingChatServiceProxy.java | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java new file mode 100644 index 00000000..7de33d32 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java @@ -0,0 +1,304 @@ +package org.ruoyi.chat.service.chat.proxy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.chat.service.chat.IChatCostService; +import org.ruoyi.chat.service.chat.IChatService; +import org.ruoyi.common.chat.entity.chat.Message; +import org.ruoyi.common.chat.request.ChatRequest; +import org.ruoyi.common.chat.utils.TikTokensUtil; +import org.ruoyi.common.core.service.BaseContext; +import org.ruoyi.domain.bo.ChatMessageBo; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * 统一计费代理类 + * 自动处理所有ChatService的AI回复保存和计费逻辑 + */ +@Slf4j +@RequiredArgsConstructor +public class BillingChatServiceProxy implements IChatService { + + private final IChatService delegate; + private final IChatCostService chatCostService; + + @Override + public SseEmitter chat(ChatRequest chatRequest, SseEmitter emitter) { + // 🔥 在AI回复开始前检查余额是否充足 + if (!chatCostService.checkBalanceSufficient(chatRequest)) { + String errorMsg = "余额不足,无法使用AI服务,请充值后再试"; + log.warn("余额不足阻止AI回复,用户ID: {}, 模型: {}", + chatRequest.getUserId(), chatRequest.getModel()); + throw new RuntimeException(errorMsg); + } + + log.debug("余额检查通过,开始AI回复,用户ID: {}, 模型: {}", + chatRequest.getUserId(), chatRequest.getModel()); + + // 创建增强的SseEmitter,自动收集AI回复 + BillingSseEmitter billingEmitter = new BillingSseEmitter(emitter, chatRequest, chatCostService); + + try { + // 调用实际的聊天服务 + return delegate.chat(chatRequest, billingEmitter); + } catch (Exception e) { + log.error("聊天服务执行失败", e); + throw e; + } + } + + @Override + public String getCategory() { + return delegate.getCategory(); + } + + /** + * 增强的SseEmitter,自动处理AI回复的保存和计费 + */ + private static class BillingSseEmitter extends SseEmitter { + private final SseEmitter delegate; + private final ChatRequest chatRequest; + private final IChatCostService chatCostService; + private final StringBuilder aiResponseBuilder = new StringBuilder(); + private final AtomicBoolean completed = new AtomicBoolean(false); + + public BillingSseEmitter(SseEmitter delegate, ChatRequest chatRequest, IChatCostService chatCostService) { + super(delegate.getTimeout()); + this.delegate = delegate; + this.chatRequest = chatRequest; + this.chatCostService = chatCostService; + } + + @Override + public void send(Object object) throws IOException { + // 先发送给前端 + delegate.send(object); + + // 提取AI回复内容并累积 + String content = extractContentFromSseData(object); + if (content != null && !content.trim().isEmpty()) { + aiResponseBuilder.append(content); + log.debug("收集AI回复片段: {}", content); + } + } + + @Override + public void complete() { + if (completed.compareAndSet(false, true)) { + try { + // AI回复完成,保存消息和计费 + saveAiResponseAndBilling(); + delegate.complete(); + log.debug("AI回复完成,已保存并计费"); + } catch (Exception e) { + log.error("保存AI回复和计费失败", e); + delegate.completeWithError(e); + } + } + } + + @Override + public void completeWithError(Throwable ex) { + if (completed.compareAndSet(false, true)) { + log.warn("AI回复出错,跳过计费", ex); + delegate.completeWithError(ex); + } + } + + /** + * 保存AI回复并进行计费 + */ + private void saveAiResponseAndBilling() { + String aiResponse = aiResponseBuilder.toString().trim(); + if (aiResponse.isEmpty()) { + log.warn("AI回复内容为空,跳过保存和计费"); + return; + } + + try { + // 创建AI回复的ChatRequest + ChatRequest aiChatRequest = new ChatRequest(); + aiChatRequest.setUserId(chatRequest.getUserId()); + aiChatRequest.setSessionId(chatRequest.getSessionId()); + aiChatRequest.setRole(Message.Role.ASSISTANT.getName()); + aiChatRequest.setModel(chatRequest.getModel()); + aiChatRequest.setPrompt(aiResponse); + + // 设置会话token供异步线程使用 + if (chatRequest.getToken() != null) { + BaseContext.setCurrentToken(chatRequest.getToken()); + } + + // 保存AI回复消息 + chatCostService.saveMessage(aiChatRequest); + + // 发布计费事件 + chatCostService.publishBillingEvent(aiChatRequest); + + log.debug("AI回复保存和计费完成,用户ID: {}, 会话ID: {}, 回复长度: {}", + chatRequest.getUserId(), chatRequest.getSessionId(), aiResponse.length()); + + } catch (Exception e) { + log.error("保存AI回复和计费失败,用户ID: {}, 会话ID: {}", + chatRequest.getUserId(), chatRequest.getSessionId(), e); + // 不抛出异常,避免影响用户体验 + } + } + + /** + * 从SSE数据中提取AI回复内容 + * 适配不同AI服务的数据格式 + */ + private String extractContentFromSseData(Object sseData) { + if (sseData == null) { + return null; + } + + String dataStr = sseData.toString(); + + // 过滤明显的控制信号 + if (isControlSignal(dataStr)) { + return null; + } + + // 策略1: 直接字符串内容(DeepSeek等简单格式) + String directContent = extractDirectContent(dataStr); + if (directContent != null) { + return directContent; + } + + // 策略2: 解析JSON格式(OpenAI兼容格式) + String jsonContent = extractJsonContent(dataStr); + if (jsonContent != null) { + return jsonContent; + } + + // 策略3: SSE事件格式解析 + String sseContent = extractSseEventContent(dataStr); + if (sseContent != null) { + return sseContent; + } + + // 策略4: 兜底策略 - 如果是纯文本且不是控制信号,直接返回 + if (isPureTextContent(dataStr)) { + return dataStr; + } + + log.debug("无法解析的SSE数据格式: {}", dataStr); + return null; + } + + /** + * 判断是否为控制信号 + */ + private boolean isControlSignal(String data) { + if (data == null || data.trim().isEmpty()) { + return true; + } + + String trimmed = data.trim(); + return "[DONE]".equals(trimmed) + || "null".equals(trimmed) + || trimmed.startsWith("event:") + || trimmed.startsWith("id:") + || trimmed.startsWith("retry:"); + } + + /** + * 提取直接文本内容 + */ + private String extractDirectContent(String data) { + // 如果是纯文本且长度合理,直接返回 + if (data.length() > 0 && data.length() < 1000 && !data.contains("{") && !data.contains("[")) { + return data; + } + return null; + } + + /** + * 提取JSON格式内容 + */ + private String extractJsonContent(String data) { + try { + // 简化的JSON解析 + if (data.contains("\"content\":")) { + return parseContentFromJson(data); + } + } catch (Exception e) { + log.debug("JSON解析失败: {}", e.getMessage()); + } + return null; + } + + /** + * 提取SSE事件格式内容 + */ + private String extractSseEventContent(String data) { + if (data.startsWith("data:")) { + String jsonPart = data.substring(5).trim(); + return extractJsonContent(jsonPart); + } + return null; + } + + /** + * 判断是否为纯文本内容 + */ + private boolean isPureTextContent(String data) { + return data != null + && !data.trim().isEmpty() + && !data.contains("{") + && !data.contains("[") + && !data.contains("data:") + && data.length() < 500; // 合理的文本长度 + } + + /** + * 从事件字符串中解析内容 + */ + private String parseContentFromEventString(String eventString) { + // 简单的字符串解析逻辑,可以根据实际格式优化 + if (eventString.contains("data:")) { + int dataIndex = eventString.indexOf("data:"); + String dataContent = eventString.substring(dataIndex + 5).trim(); + return parseContentFromJson(dataContent); + } + return null; + } + + /** + * 从JSON字符串中解析内容 + */ + private String parseContentFromJson(String jsonStr) { + // 简化的JSON解析,实际项目中建议使用Jackson + if (jsonStr.contains("\"content\":\"")) { + int startIndex = jsonStr.indexOf("\"content\":\"") + 11; + int endIndex = jsonStr.indexOf("\"", startIndex); + if (endIndex > startIndex) { + return jsonStr.substring(startIndex, endIndex); + } + } + return null; + } + + // 委托其他方法到原始emitter + @Override + public void onCompletion(Runnable callback) { + delegate.onCompletion(callback); + } + + @Override + public void onError(Consumer callback) { + delegate.onError(callback); + } + + @Override + public void onTimeout(Runnable callback) { + delegate.onTimeout(callback); + } + } +} From 2b5fd810a44179aa833cbe91729ffe520ed66e76 Mon Sep 17 00:00:00 2001 From: Administrator <1037463791@qq.com> Date: Thu, 4 Sep 2025 16:41:14 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(billing):=20=E7=BB=9F=E4=B8=80=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E4=BB=A3=E7=90=86=E7=B1=BBBillingChatServiceProxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/chat/proxy/BillingChatServiceProxy.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java index 7de33d32..b414d86a 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java @@ -18,6 +18,7 @@ import java.util.function.Consumer; /** * 统一计费代理类 * 自动处理所有ChatService的AI回复保存和计费逻辑 + * */ @Slf4j @RequiredArgsConstructor @@ -31,12 +32,12 @@ public class BillingChatServiceProxy implements IChatService { // 🔥 在AI回复开始前检查余额是否充足 if (!chatCostService.checkBalanceSufficient(chatRequest)) { String errorMsg = "余额不足,无法使用AI服务,请充值后再试"; - log.warn("余额不足阻止AI回复,用户ID: {}, 模型: {}", + log.warn("余额不足阻止AI回复,用户ID: {}, 模型: {}", chatRequest.getUserId(), chatRequest.getModel()); throw new RuntimeException(errorMsg); } - log.debug("余额检查通过,开始AI回复,用户ID: {}, 模型: {}", + log.debug("余额检查通过,开始AI回复,用户ID: {}, 模型: {}", chatRequest.getUserId(), chatRequest.getModel()); // 创建增强的SseEmitter,自动收集AI回复