feat(chat): 新增聊天调用日志与动态配置支持

- 新增 KeyboardUserCallLog 实体及对应 Mapper、Service,用于记录每次聊天请求的模型、token、耗时、错误码等
- ChatController.talk() 在流式输出前后采集元数据,异步落库,支持错误码记录
- AppConfig 新增 QdrantConfig,支持 vectorSearchLimit 动态配置
- QdrantVectorService 改为从 Nacos 动态读取搜索条数,替代硬编码 limit=1
- UserController 登出时先清除用户会话再清除 token,避免并发异常
This commit is contained in:
2025-12-17 15:03:23 +08:00
parent a237bc2987
commit 86738e3d1b
9 changed files with 279 additions and 18 deletions

View File

@@ -13,15 +13,22 @@ public class AppConfig {
private UserRegisterProperties userRegisterProperties = new UserRegisterProperties();
private QdrantConfig qdrantConfig = new QdrantConfig();
@Data
public static class UserRegisterProperties {
/**
* 新用户注册时的免费使用次数
*/
//新用户注册时的免费使用次数
private Integer freeTrialQuota = 3;
//新用户注册时的奖励余额
private BigDecimal rewardBalance = BigDecimal.valueOf(0);
}
@Data
public static class QdrantConfig {
//向量搜索时的返回数量限制
private Integer vectorSearchLimit = 1;
}
}

View File

@@ -1,5 +1,6 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.context.mock.SaTokenContextMockUtil;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
@@ -12,7 +13,9 @@ import com.yolo.keyborad.model.dto.chat.ChatReq;
import com.yolo.keyborad.model.dto.chat.ChatSaveReq;
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
import com.yolo.keyborad.model.entity.KeyboardCharacter;
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
import com.yolo.keyborad.service.KeyboardCharacterService;
import com.yolo.keyborad.service.KeyboardUserCallLogService;
import com.yolo.keyborad.service.impl.QdrantVectorService;
import io.qdrant.client.grpc.JsonWithInt;
import io.swagger.v3.oas.annotations.Operation;
@@ -28,9 +31,10 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/*
* @author: ziin
@@ -57,6 +61,9 @@ public class ChatController {
@Resource
private KeyboardCharacterService keyboardCharacterService;
@Resource
private KeyboardUserCallLogService callLogService;
@PostMapping("/talk")
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
@@ -89,6 +96,14 @@ public class ChatController {
throw new BusinessException(ErrorCode.CHAT_CHARACTER_NOT_FOUND);
}
// 初始化调用日志
String requestId = IdUtil.fastSimpleUUID();
long startTime = System.currentTimeMillis();
AtomicReference<String> modelRef = new AtomicReference<>();
AtomicInteger inputTokens = new AtomicInteger(0);
AtomicInteger outputTokens = new AtomicInteger(0);
AtomicReference<String> errorCodeRef = new AtomicReference<>();
// 3. LLM 流式输出
Flux<ChatStreamMessage> llmFlux = client
.prompt(character.getPrompt())
@@ -103,10 +118,35 @@ public class ChatController {
.user(StpUtil.getLoginIdAsString())
.build())
.stream()
.content()
.concatMap(chunk -> {
.chatResponse()
.concatMap(response -> {
// 提取 metadata
if (response.getMetadata() != null) {
var metadata = response.getMetadata();
if (metadata.getModel() != null) {
modelRef.set(metadata.getModel());
}
if (metadata.getUsage() != null) {
var usage = metadata.getUsage();
if (usage.getPromptTokens() != null) {
inputTokens.set(usage.getPromptTokens());
}
if (usage.getCompletionTokens() != null) {
outputTokens.set(usage.getCompletionTokens());
}
}
}
// 获取内容
String content = response.getResult().getOutput().getText();
if (content == null || content.isEmpty()) {
return Flux.empty();
}
// 拆成单字符
List<String> chars = chunk.codePoints()
List<String> chars = content.codePoints()
.mapToObj(cp -> new String(Character.toChars(cp)))
.toList();
@@ -127,7 +167,10 @@ public class ChatController {
return Flux.fromIterable(batched)
.map(s -> new ChatStreamMessage("llm_chunk", s));
})
.doOnError(error -> log.error("LLM调用失败", error))
.doOnError(error -> {
log.error("LLM调用失败", error);
errorCodeRef.set("LLM_ERROR");
})
.onErrorResume(error ->
Flux.just(new ChatStreamMessage("error", "LLM服务暂时不可用请稍后重试"))
);
@@ -151,13 +194,39 @@ public class ChatController {
Flux<ChatStreamMessage> merged =
Flux.merge(llmFlux, searchFlux)
.concatWith(doneFlux);
// 7. SSE 包装
return merged.map(msg ->
ServerSentEvent.builder(msg)
.event(msg.getType())
.build()
);
String tokenValue = StpUtil.getTokenValue();
// 7. SSE 包装并记录调用日志
return merged
.doFinally(signalType -> {
// 异步保存调用日志
Mono.fromRunnable(() -> {
try {
KeyboardUserCallLog callLog = new KeyboardUserCallLog();
SaTokenContextMockUtil.setMockContext(()->{
StpUtil.setTokenValueToStorage(tokenValue);
callLog.setUserId(StpUtil.getLoginIdAsLong());
});
callLog.setRequestId(requestId);
callLog.setFeature("chat_talk");
callLog.setModel(modelRef.get());
callLog.setInputTokens(inputTokens.get());
callLog.setOutputTokens(outputTokens.get());
callLog.setTotalTokens(inputTokens.get() + outputTokens.get());
callLog.setSuccess(errorCodeRef.get() == null);
callLog.setLatencyMs((int) (System.currentTimeMillis() - startTime));
callLog.setErrorCode(errorCodeRef.get());
callLog.setCreatedAt(new Date());
callLogService.save(callLog);
} catch (Exception e) {
log.error("保存调用日志失败", e);
}
}).subscribeOn(Schedulers.boundedElastic()).subscribe();
})
.map(msg ->
ServerSentEvent.builder(msg)
.event(msg.getType())
.build()
);
}

View File

@@ -57,6 +57,7 @@ public class UserController {
@GetMapping("/logout")
@Operation(summary = "退出登录", description = "退出登录接口")
public BaseResponse<Boolean> logout() {
StpUtil.logout(StpUtil.getLoginIdAsLong());
StpUtil.logoutByTokenValue(StpUtil.getTokenValue());
return ResultUtils.success(true);
}
@@ -115,4 +116,5 @@ public class UserController {
public BaseResponse<Boolean> resetPassWord(@RequestBody ResetPassWordDTO resetPassWordDTO) {
return ResultUtils.success(userService.resetPassWord(resetPassWordDTO));
}
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
/*
* @author: ziin
* @date: 2025/12/17 13:29
*/
public interface KeyboardUserCallLogMapper extends BaseMapper<KeyboardUserCallLog> {
}

View File

@@ -0,0 +1,106 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/17 13:29
*/
/**
* 用户每次调用日志用于记录token、模型、耗时、成功率等
*/
@Schema(description="用户每次调用日志用于记录token、模型、耗时、成功率等")
@Data
@TableName(value = "keyboard_user_call_log")
public class KeyboardUserCallLog {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="")
private Long id;
/**
* 用户ID
*/
@TableField(value = "user_id")
@Schema(description="用户ID")
private Long userId;
/**
* 幂等请求ID避免重试导致重复记录
*/
@TableField(value = "request_id")
@Schema(description="幂等请求ID避免重试导致重复记录")
private String requestId;
/**
* 调用功能来源
*/
@TableField(value = "feature")
@Schema(description="调用功能来源")
private String feature;
/**
* 调用的模型名称
*/
@TableField(value = "model")
@Schema(description="调用的模型名称")
private String model;
/**
* 输入token数
*/
@TableField(value = "input_tokens")
@Schema(description="输入token数")
private Integer inputTokens;
/**
* 输出token数
*/
@TableField(value = "output_tokens")
@Schema(description="输出token数")
private Integer outputTokens;
/**
* 总token数input+output
*/
@TableField(value = "total_tokens")
@Schema(description="总token数input+output")
private Integer totalTokens;
/**
* 调用是否成功
*/
@TableField(value = "success")
@Schema(description="调用是否成功")
private Boolean success;
/**
* 调用耗时(毫秒)
*/
@TableField(value = "latency_ms")
@Schema(description="调用耗时(毫秒)")
private Integer latencyMs;
/**
* 失败错误码(可空)
*/
@TableField(value = "error_code")
@Schema(description="失败错误码(可空)")
private String errorCode;
/**
* 调用记录创建时间
*/
@TableField(value = "created_at")
@Schema(description="调用记录创建时间")
private Date createdAt;
}

View File

@@ -0,0 +1,13 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
import com.baomidou.mybatisplus.extension.service.IService;
/*
* @author: ziin
* @date: 2025/12/17 13:29
*/
public interface KeyboardUserCallLogService extends IService<KeyboardUserCallLog>{
}

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
import com.yolo.keyborad.mapper.KeyboardUserCallLogMapper;
import com.yolo.keyborad.service.KeyboardUserCallLogService;
/*
* @author: ziin
* @date: 2025/12/17 13:29
*/
@Service
public class KeyboardUserCallLogServiceImpl extends ServiceImpl<KeyboardUserCallLogMapper, KeyboardUserCallLog> implements KeyboardUserCallLogService{
}

View File

@@ -3,6 +3,8 @@ package com.yolo.keyborad.service.impl;
import com.google.common.primitives.Floats;
import com.google.common.util.concurrent.ListenableFuture;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.AppConfig;
import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.vo.QdrantSearchItem;
import io.qdrant.client.QdrantClient;
@@ -35,6 +37,11 @@ public class QdrantVectorService {
@Resource
private EmbeddingModel embeddingModel;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
public QdrantVectorService(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
this.cfgHolder = cfgHolder;
}
/**
* 插入/更新一条向量数据
*
@@ -179,12 +186,14 @@ public class QdrantVectorService {
}
public List<QdrantSearchItem> searchText(String userInput) {
AppConfig appConfig = cfgHolder.getRef().get();
long t0 = System.currentTimeMillis();
float[] floats = this.embedTextToVector(userInput);
long t1 = System.currentTimeMillis();
List<QdrantSearchItem> qdrantSearchItems = this.searchPoint(floats, 1);
List<QdrantSearchItem> qdrantSearchItems = this.searchPoint(floats, appConfig.getQdrantConfig().getVectorSearchLimit());
long t2 = System.currentTimeMillis();
log.info("embedding = {} ms, qdrant = {} ms, total = {} ms",

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardUserCallLogMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserCallLog">
<!--@mbg.generated-->
<!--@Table keyboard_user_call_log-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="request_id" jdbcType="VARCHAR" property="requestId" />
<result column="feature" jdbcType="VARCHAR" property="feature" />
<result column="model" jdbcType="VARCHAR" property="model" />
<result column="input_tokens" jdbcType="INTEGER" property="inputTokens" />
<result column="output_tokens" jdbcType="INTEGER" property="outputTokens" />
<result column="total_tokens" jdbcType="INTEGER" property="totalTokens" />
<result column="success" jdbcType="BOOLEAN" property="success" />
<result column="latency_ms" jdbcType="INTEGER" property="latencyMs" />
<result column="error_code" jdbcType="VARCHAR" property="errorCode" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, request_id, feature, model, input_tokens, output_tokens, total_tokens,
success, latency_ms, error_code, created_at
</sql>
</mapper>