feat(chat): 新增聊天调用日志与动态配置支持
- 新增 KeyboardUserCallLog 实体及对应 Mapper、Service,用于记录每次聊天请求的模型、token、耗时、错误码等 - ChatController.talk() 在流式输出前后采集元数据,异步落库,支持错误码记录 - AppConfig 新增 QdrantConfig,支持 vectorSearchLimit 动态配置 - QdrantVectorService 改为从 Nacos 动态读取搜索条数,替代硬编码 limit=1 - UserController 登出时先清除用户会话再清除 token,避免并发异常
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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>{
|
||||
|
||||
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
25
src/main/resources/mapper/KeyboardUserCallLogMapper.xml
Normal file
25
src/main/resources/mapper/KeyboardUserCallLogMapper.xml
Normal 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>
|
||||
Reference in New Issue
Block a user