From 5a2e08f87dc73e58ff526e09b0167001218063ca Mon Sep 17 00:00:00 2001 From: Administrator <1037463791@qq.com> Date: Fri, 8 Aug 2025 13:39:37 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=AE=E9=A2=98=E6=A6=82=E8=BF=B0=201.?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=B6=88=E6=81=AF=E5=92=8C=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=AD=98=E5=9C=A8=E8=80=A6=E5=90=88=202.?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=AE=A1=E8=B4=B9=E9=80=BB=E8=BE=91=EF=BC=9A?= =?UTF-8?q?=20=E6=8C=89=E6=AC=A1=E8=AE=A1=E8=B4=B9=E8=A2=AB=E9=98=88?= =?UTF-8?q?=E5=80=BC=E9=99=90=E5=88=B6=EF=BC=9A=E6=97=A7=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E6=8A=8A=20TIMES=20=E5=88=86=E6=94=AF=E6=94=BE=E5=9C=A8=20tota?= =?UTF-8?q?lTokens=20=E2=89=A5=20100=20=E7=9A=84=E5=A4=A7=E5=88=86?= =?UTF-8?q?=E6=94=AF=E9=87=8C=EF=BC=8C=E5=AF=BC=E8=87=B4=E6=B2=A1=E5=88=B0?= =?UTF-8?q?100=20token=E6=97=B6=E4=B8=8D=E6=89=A3=E8=B4=B9=EF=BC=8C?= =?UTF-8?q?=E8=BF=9D=E8=83=8C=E2=80=9C=E6=AF=8F=E6=AC=A1=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=B0=B1=E6=89=A3=E8=B4=B9=E2=80=9D=E7=9A=84=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E3=80=82=20token=E7=B4=AF=E8=AE=A1=E4=B8=8D=E5=BD=93=EF=BC=9AT?= =?UTF-8?q?IMES=20=E5=88=86=E6=94=AF=E5=8F=AA=E6=89=A3=E8=B4=B9=E4=B8=8D?= =?UTF-8?q?=E5=A4=84=E7=90=86=E7=B4=AF=E8=AE=A1=EF=BC=8C=E5=90=8C=E6=97=B6?= =?UTF-8?q?=E5=9C=A8=20totalTokens=20<=20100=20=E6=97=B6=E4=B8=8D=E4=BC=9A?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E4=BB=BB=E4=BD=95TIMES=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E7=B4=AF=E8=AE=A1=E4=BC=9A=E6=97=A0=E6=84=8F=E4=B9=89?= =?UTF-8?q?=E5=A2=9E=E9=95=BF=E3=80=82=20=E7=B2=92=E5=BA=A6=E4=B8=8D?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=EF=BC=9ATOKEN=20=E8=AE=A1=E8=B4=B9=E4=B8=80?= =?UTF-8?q?=E6=97=A6=E8=BE=BE=E9=98=88=E5=80=BC=E5=B0=B1=E6=8A=8A=20total?= =?UTF-8?q?=20=E5=85=A8=E6=89=A3=E5=AE=8C=E5=B9=B6=E6=B8=85=E9=9B=B6?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=88=A9=E4=BA=8E=E5=AF=B9=E8=B4=A6=E4=B8=8E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C=E3=80=82=20=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E6=96=B9=E5=BC=8F=EF=BC=9A=E4=BD=BF=E7=94=A8=20System?= =?UTF-8?q?.out.println=EF=BC=8C=E4=B8=8D=E5=88=A9=E4=BA=8E=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E8=BF=BD=E8=B8=AA=E3=80=82=203.=E5=BB=BA=E8=AE=AE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E4=B8=8D=E8=A6=81=E5=AD=98=E6=89=A3?= =?UTF-8?q?=E9=99=A4=E9=87=91=E9=A2=9D=E5=92=8C=E7=B4=AF=E8=AE=A1=E6=B6=88?= =?UTF-8?q?=E8=80=97token=EF=BC=8C=E6=B6=88=E6=81=AF=E8=A1=A8=E9=87=8C?= =?UTF-8?q?=E4=B8=8D=E9=9C=80=E8=A6=81=E5=AD=98=E2=80=9C=E7=B4=AF=E8=AE=A1?= =?UTF-8?q?=E5=88=B0=E7=9B=AE=E5=89=8D=E4=B8=BA=E6=AD=A2=E5=A4=9A=E5=B0=91?= =?UTF-8?q?=E2=80=9D=EF=BC=8C=E5=90=A6=E5=88=99=E6=AF=8F=E6=9D=A1=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=83=BD=E5=8F=98=E6=88=90=E5=BF=AB=E7=85=A7=EF=BC=8C?= =?UTF-8?q?=E6=97=A2=E5=86=97=E4=BD=99=E5=8F=88=E6=98=93=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动要点 1.新增独立方法 saveMessage(ChatRequest): 只落库。 publishBillingEvent(ChatRequest): 只发布异步计费事件。 保留组合方法 saveMessageAndPublishEvent(ChatRequest) 以便需要一行调用时使用。 调用处已改为“先保存,再发布事件” SseServiceImpl: 先 saveMessage,再 publishBillingEvent。 SSEEventSourceListener: 同上。 DifyServiceImpl: 同上。 2.计费模式分流: TIMES:每次调用直接扣费,不累计。 TOKEN:按阈值(100)批量扣费,保留余数,账单颗粒稳定。 保留余数:total = prev + delta;billable = floor(total/threshold)threshold;remainder = total % threshold。 日志替换:统一使用 log.debug。 结构更清晰、可维护。 所有金额计算统一用 BigDecimal,保留两位小数,RoundingMode.HALF_UP 按次计费:每次直接扣费(BigDecimal),边界转 Double 按 token 计费:按阈值批量结算,保留余数;费用=单价(BigDecimal)×可结算token数 --- .../chat/event/ChatMessageCreatedEvent.java | 31 ++++ .../chat/listener/BillingEventListener.java | 37 +++++ .../chat/listener/SSEEventSourceListener.java | 4 +- .../chat/service/chat/IChatCostService.java | 16 ++ .../chat/impl/ChatCostServiceImpl.java | 157 +++++++++--------- .../service/chat/impl/DifyServiceImpl.java | 4 +- .../service/chat/impl/SseServiceImpl.java | 5 +- 7 files changed, 171 insertions(+), 83 deletions(-) create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/event/ChatMessageCreatedEvent.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/listener/BillingEventListener.java diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/event/ChatMessageCreatedEvent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/event/ChatMessageCreatedEvent.java new file mode 100644 index 00000000..7ecaea26 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/event/ChatMessageCreatedEvent.java @@ -0,0 +1,31 @@ +package org.ruoyi.chat.event; + +import org.springframework.context.ApplicationEvent; + +/** + * 聊天消息创建事件(用于异步计费/累计等) + */ +public class ChatMessageCreatedEvent extends ApplicationEvent { + + private final Long userId; + private final Long sessionId; + private final String modelName; + private final String role; + private final String content; + + public ChatMessageCreatedEvent(Long userId, Long sessionId, String modelName, String role, String content) { + super(userId); + this.userId = userId; + this.sessionId = sessionId; + this.modelName = modelName; + this.role = role; + this.content = content; + } + + public Long getUserId() { return userId; } + public Long getSessionId() { return sessionId; } + public String getModelName() { return modelName; } + public String getRole() { return role; } + public String getContent() { return content; } +} + diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/listener/BillingEventListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/listener/BillingEventListener.java new file mode 100644 index 00000000..427730c0 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/listener/BillingEventListener.java @@ -0,0 +1,37 @@ +package org.ruoyi.chat.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.chat.event.ChatMessageCreatedEvent; +import org.ruoyi.chat.service.chat.IChatCostService; +import org.ruoyi.common.chat.request.ChatRequest; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BillingEventListener { + + private final IChatCostService chatCostService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onChatMessageCreated(ChatMessageCreatedEvent event) { + try { + ChatRequest chatRequest = new ChatRequest(); + chatRequest.setUserId(event.getUserId()); + chatRequest.setSessionId(event.getSessionId()); + chatRequest.setModel(event.getModelName()); + chatRequest.setRole(event.getRole()); + chatRequest.setPrompt(event.getContent()); + // 异步执行计费累计与扣费 + chatCostService.deductToken(chatRequest); + } catch (Exception ex) { + log.error("BillingEventListener onChatMessageCreated error", ex); + } + } +} + diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/listener/SSEEventSourceListener.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/listener/SSEEventSourceListener.java index c91e28de..b8c8167e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/listener/SSEEventSourceListener.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/listener/SSEEventSourceListener.java @@ -87,7 +87,9 @@ public class SSEEventSourceListener extends EventSourceListener { chatRequest.setPrompt(stringBuffer.toString()); // 记录会话token BaseContext.setCurrentToken(token); - chatCostService.deductToken(chatRequest); + // 先保存助手消息,再发布异步计费事件 + chatCostService.saveMessage(chatRequest); + chatCostService.publishBillingEvent(chatRequest); return; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/IChatCostService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/IChatCostService.java index 73c0c443..36104f9f 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/IChatCostService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/IChatCostService.java @@ -19,6 +19,22 @@ public interface IChatCostService { void deductToken(ChatRequest chatRequest); + /** + * 保存聊天消息记录(不进行计费) + * + * @param chatRequest 对话信息 + */ + void saveMessage(ChatRequest chatRequest); + + + + /** + * 仅发布异步计费事件(不做入库) + * + * @param chatRequest 对话信息 + */ + void publishBillingEvent(ChatRequest chatRequest); + /** * 直接扣除用户的余额 * diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/ChatCostServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/ChatCostServiceImpl.java index c460bba4..0511cbb1 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/ChatCostServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/ChatCostServiceImpl.java @@ -3,7 +3,10 @@ package org.ruoyi.chat.service.chat.impl; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.math.BigDecimal; +import java.math.RoundingMode; import org.ruoyi.chat.enums.BillingType; +import org.ruoyi.chat.event.ChatMessageCreatedEvent; import org.ruoyi.chat.enums.UserGradeType; import org.ruoyi.chat.service.chat.IChatCostService; import org.ruoyi.common.chat.request.ChatRequest; @@ -20,6 +23,7 @@ import org.ruoyi.service.IChatModelService; import org.ruoyi.service.IChatTokenService; import org.ruoyi.system.domain.SysUser; import org.ruoyi.system.mapper.SysUserMapper; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -42,106 +46,97 @@ public class ChatCostServiceImpl implements IChatCostService { private final IChatModelService chatModelService; + private final ApplicationEventPublisher eventPublisher; + /** - * 扣除用户余额 + * 扣除用户余额(仅计费与累计,不保存消息) */ @Override public void deductToken(ChatRequest chatRequest) { - - - if(chatRequest.getUserId()==null || chatRequest.getSessionId()==null){ + if (chatRequest.getUserId() == null || chatRequest.getSessionId() == null) { return; } - int tokens = TikTokensUtil.tokens(chatRequest.getModel(), chatRequest.getPrompt()); - - System.out.println("deductToken->本次提交token数 : "+tokens); + log.debug("deductToken->本次提交token数: {}", tokens); String modelName = chatRequest.getModel(); + ChatModelVo chatModelVo = chatModelService.selectModelByName(modelName); + BigDecimal unitPrice = BigDecimal.valueOf(chatModelVo.getModelPrice()); - ChatMessageBo chatMessageBo = new ChatMessageBo(); + // 按次计费:每次调用都直接扣费,不累计token + if (BillingType.TIMES.getCode().equals(chatModelVo.getModelType())) { + BigDecimal numberCost = unitPrice.setScale(2, RoundingMode.HALF_UP); + deductUserBalance(chatRequest.getUserId(), numberCost.doubleValue()); + log.debug("deductToken->按次数扣费,费用: {},模型: {}", numberCost, modelName); + return; + } - // 设置用户id - chatMessageBo.setUserId(chatRequest.getUserId()); - // 设置会话id - chatMessageBo.setSessionId(chatRequest.getSessionId()); - - // 设置对话角色 - chatMessageBo.setRole(chatRequest.getRole()); - - // 设置对话内容 - chatMessageBo.setContent(chatRequest.getPrompt()); - - // 设置模型名字 - chatMessageBo.setModelName(chatRequest.getModel()); + // 按token计费:累加并按阈值批量扣费,保留余数 + final int threshold = 100; // 获得记录的累计token数 - ChatUsageToken chatToken = chatTokenService.queryByUserId(chatMessageBo.getUserId(), modelName); - - + ChatUsageToken chatToken = chatTokenService.queryByUserId(chatRequest.getUserId(), modelName); if (chatToken == null) { chatToken = new ChatUsageToken(); chatToken.setToken(0); } - // 计算总token数 - int totalTokens = chatToken.getToken() + tokens; - - //当前未付费token - int token = chatToken.getToken(); - - System.out.println("deductToken->未付费的token数 : "+token); - System.out.println("deductToken->本次提交+未付费token数 : "+totalTokens); - - - //扣费核心逻辑(总token大于100就要对未结清的token进行扣费) - if (totalTokens >= 100) {// 如果总token数大于等于100,进行费用扣除 - - ChatModelVo chatModelVo = chatModelService.selectModelByName(modelName); - double cost = chatModelVo.getModelPrice(); - if (BillingType.TIMES.getCode().equals(chatModelVo.getModelType())) { - // 按次数扣费 - deductUserBalance(chatMessageBo.getUserId(), cost); - chatMessageBo.setDeductCost(cost); - }else { - // 按token扣费 - Double numberCost = totalTokens * cost; - System.out.println("deductToken->按token扣费 计算token数量: "+totalTokens); - System.out.println("deductToken->按token扣费 每token的价格: "+cost); - - deductUserBalance(chatMessageBo.getUserId(), numberCost); - chatMessageBo.setDeductCost(numberCost); - - // 保存剩余tokens - chatToken.setModelName(modelName); - chatToken.setUserId(chatMessageBo.getUserId()); - chatToken.setToken(0);//因为判断大于100token直接全部计算扣除了所以这里直接=0就可以了 - chatTokenService.editToken(chatToken); - } - + int previousUnpaid = chatToken.getToken(); + int totalTokens = previousUnpaid + tokens; + log.debug("deductToken->未付费token数: {},本次累计后总数: {}", previousUnpaid, totalTokens); + int billable = (totalTokens / threshold) * threshold; // 可计费整批token数 + int remainder = totalTokens - billable; // 结算后保留的余数 + if (billable > 0) { + BigDecimal numberCost = unitPrice + .multiply(BigDecimal.valueOf(billable)) + .setScale(2, RoundingMode.HALF_UP); + log.debug("deductToken->按token扣费,结算token数量: {},单价: {},费用: {}", billable, unitPrice, numberCost); + deductUserBalance(chatRequest.getUserId(), numberCost.doubleValue()); } else { - //不满100Token,不需要进行扣费啊啊啊 - //deductUserBalance(chatMessageBo.getUserId(), 0.0); - chatMessageBo.setDeductCost(0d); - chatMessageBo.setRemark("不满100Token,计入下一次!"); - System.out.println("deductToken->不满100Token,计入下一次!"); - chatToken.setToken(totalTokens); - chatToken.setModelName(chatMessageBo.getModelName()); - chatToken.setUserId(chatMessageBo.getUserId()); - chatTokenService.editToken(chatToken); + log.debug("deductToken->未达到计费阈值({}),累积到下次", threshold); } + // 保存剩余tokens(保留余数) + chatToken.setModelName(modelName); + chatToken.setUserId(chatRequest.getUserId()); + chatToken.setToken(remainder); + chatTokenService.editToken(chatToken); + } + /** + * 保存聊天消息记录(不进行计费) + */ + @Override + public void saveMessage(ChatRequest chatRequest) { + if (chatRequest.getUserId() == null || chatRequest.getSessionId() == null) { + return; + } + ChatMessageBo chatMessageBo = new ChatMessageBo(); + chatMessageBo.setUserId(chatRequest.getUserId()); + chatMessageBo.setSessionId(chatRequest.getSessionId()); + chatMessageBo.setRole(chatRequest.getRole()); + chatMessageBo.setContent(chatRequest.getPrompt()); + chatMessageBo.setModelName(chatRequest.getModel()); + - // 保存消息记录 chatMessageService.insertByBo(chatMessageBo); + } - System.out.println("deductToken->chatMessageService.insertByBo(: "+chatMessageBo); - System.out.println("----------------------------------------"); + + + @Override + public void publishBillingEvent(ChatRequest chatRequest) { + eventPublisher.publishEvent(new ChatMessageCreatedEvent( + chatRequest.getUserId(), + chatRequest.getSessionId(), + chatRequest.getModel(), + chatRequest.getRole(), + chatRequest.getPrompt() + )); } /** @@ -158,22 +153,26 @@ public class ChatCostServiceImpl implements IChatCostService { return; } - Double userBalance = sysUser.getUserBalance(); + BigDecimal userBalance = BigDecimal.valueOf(sysUser.getUserBalance() == null ? 0D : sysUser.getUserBalance()) + .setScale(2, RoundingMode.HALF_UP); + BigDecimal cost = BigDecimal.valueOf(numberCost == null ? 0D : numberCost) + .setScale(2, RoundingMode.HALF_UP); + log.debug("deductUserBalance->准备扣除: {},当前余额: {}", cost, userBalance); - System.out.println("deductUserBalance->准备扣除:numberCost: "+numberCost); - System.out.println("deductUserBalance->剩余金额:userBalance: "+userBalance); - - - if (userBalance < numberCost || userBalance == 0) { + if (userBalance.compareTo(cost) < 0 || userBalance.compareTo(BigDecimal.ZERO) == 0) { throw new ServiceException("余额不足, 请充值"); } - + BigDecimal newBalance = userBalance.subtract(cost); + if (newBalance.compareTo(BigDecimal.ZERO) < 0) { + newBalance = BigDecimal.ZERO; + } + newBalance = newBalance.setScale(2, RoundingMode.HALF_UP); sysUserMapper.update(null, new LambdaUpdateWrapper() - .set(SysUser::getUserBalance, Math.max(userBalance - numberCost, 0)) + .set(SysUser::getUserBalance, newBalance.doubleValue()) .eq(SysUser::getUserId, userId)); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/DifyServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/DifyServiceImpl.java index ac3ebab7..3d0eeefa 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/DifyServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/DifyServiceImpl.java @@ -111,7 +111,9 @@ public class DifyServiceImpl implements IChatService { chatRequestResponse.setUserId(chatRequest.getUserId()); chatRequestResponse.setSessionId(chatRequest.getSessionId()); chatRequestResponse.setPrompt(respMessage.toString()); - chatCostService.deductToken(chatRequestResponse); + // 先保存助手消息,再发布异步计费事件 + chatCostService.saveMessage(chatRequestResponse); + chatCostService.publishBillingEvent(chatRequestResponse); } @Override diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/SseServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/SseServiceImpl.java index 269fcac0..59c70d41 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/SseServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/SseServiceImpl.java @@ -101,8 +101,9 @@ public class SseServiceImpl implements ISseService { } - // 保存消息记录 并扣除费用 - chatCostService.deductToken(chatRequest); + // 先保存消息,再发布异步计费事件 + chatCostService.saveMessage(chatRequest); + chatCostService.publishBillingEvent(chatRequest); chatRequest.setUserId(chatCostService.getUserId()); if(chatRequest.getSessionId()==null){ ChatSessionBo chatSessionBo = new ChatSessionBo();