问题概述

1.保存消息和计费逻辑存在耦合
2.修改计费逻辑:
按次计费被阈值限制:旧逻辑把 TIMES 分支放在 totalTokens ≥ 100 的大分支里,导致没到100 token时不扣费,违背“每次调用就扣费”的语义。
token累计不当:TIMES 分支只扣费不处理累计,同时在 totalTokens < 100 时不会进入任何TIMES逻辑,累计会无意义增长。
粒度不稳定:TOKEN 计费一旦达阈值就把 total 全扣完并清零,不利于对账与用户体验。
打印方式:使用 System.out.println,不利于生产追踪。
3.建议数据库不要存扣除金额和累计消耗token,消息表里不需要存“累计到目前为止多少”,否则每条消息都变成快照,既冗余又易不一致

改动要点
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数
This commit is contained in:
Administrator
2025-08-08 13:39:37 +08:00
parent 210a9d9b14
commit 5a2e08f87d
7 changed files with 171 additions and 83 deletions

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,22 @@ public interface IChatCostService {
void deductToken(ChatRequest chatRequest);
/**
* 保存聊天消息记录(不进行计费)
*
* @param chatRequest 对话信息
*/
void saveMessage(ChatRequest chatRequest);
/**
* 仅发布异步计费事件(不做入库)
*
* @param chatRequest 对话信息
*/
void publishBillingEvent(ChatRequest chatRequest);
/**
* 直接扣除用户的余额
*

View File

@@ -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<SysUser>()
.set(SysUser::getUserBalance, Math.max(userBalance - numberCost, 0))
.set(SysUser::getUserBalance, newBalance.doubleValue())
.eq(SysUser::getUserId, userId));
}

View File

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

View File

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