From 86738e3d1bb3592417e42ac69c496876f49cd6c4 Mon Sep 17 00:00:00 2001 From: ziin Date: Wed, 17 Dec 2025 15:03:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=96=B0=E5=A2=9E=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E8=B0=83=E7=94=A8=E6=97=A5=E5=BF=97=E4=B8=8E=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 KeyboardUserCallLog 实体及对应 Mapper、Service,用于记录每次聊天请求的模型、token、耗时、错误码等 - ChatController.talk() 在流式输出前后采集元数据,异步落库,支持错误码记录 - AppConfig 新增 QdrantConfig,支持 vectorSearchLimit 动态配置 - QdrantVectorService 改为从 Nacos 动态读取搜索条数,替代硬编码 limit=1 - UserController 登出时先清除用户会话再清除 token,避免并发异常 --- .../com/yolo/keyborad/config/AppConfig.java | 13 ++- .../keyborad/controller/ChatController.java | 97 +++++++++++++--- .../keyborad/controller/UserController.java | 2 + .../mapper/KeyboardUserCallLogMapper.java | 12 ++ .../model/entity/KeyboardUserCallLog.java | 106 ++++++++++++++++++ .../service/KeyboardUserCallLogService.java | 13 +++ .../impl/KeyboardUserCallLogServiceImpl.java | 18 +++ .../service/impl/QdrantVectorService.java | 11 +- .../mapper/KeyboardUserCallLogMapper.xml | 25 +++++ 9 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/yolo/keyborad/mapper/KeyboardUserCallLogMapper.java create mode 100644 src/main/java/com/yolo/keyborad/model/entity/KeyboardUserCallLog.java create mode 100644 src/main/java/com/yolo/keyborad/service/KeyboardUserCallLogService.java create mode 100644 src/main/java/com/yolo/keyborad/service/impl/KeyboardUserCallLogServiceImpl.java create mode 100644 src/main/resources/mapper/KeyboardUserCallLogMapper.xml diff --git a/src/main/java/com/yolo/keyborad/config/AppConfig.java b/src/main/java/com/yolo/keyborad/config/AppConfig.java index 6bb11d1..f24783c 100644 --- a/src/main/java/com/yolo/keyborad/config/AppConfig.java +++ b/src/main/java/com/yolo/keyborad/config/AppConfig.java @@ -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; + } } diff --git a/src/main/java/com/yolo/keyborad/controller/ChatController.java b/src/main/java/com/yolo/keyborad/controller/ChatController.java index f3bd5db..2c78dc1 100644 --- a/src/main/java/com/yolo/keyborad/controller/ChatController.java +++ b/src/main/java/com/yolo/keyborad/controller/ChatController.java @@ -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 modelRef = new AtomicReference<>(); + AtomicInteger inputTokens = new AtomicInteger(0); + AtomicInteger outputTokens = new AtomicInteger(0); + AtomicReference errorCodeRef = new AtomicReference<>(); + // 3. LLM 流式输出 Flux 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 chars = chunk.codePoints() + List 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 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() + ); } diff --git a/src/main/java/com/yolo/keyborad/controller/UserController.java b/src/main/java/com/yolo/keyborad/controller/UserController.java index 073bd20..dc3a196 100644 --- a/src/main/java/com/yolo/keyborad/controller/UserController.java +++ b/src/main/java/com/yolo/keyborad/controller/UserController.java @@ -57,6 +57,7 @@ public class UserController { @GetMapping("/logout") @Operation(summary = "退出登录", description = "退出登录接口") public BaseResponse logout() { + StpUtil.logout(StpUtil.getLoginIdAsLong()); StpUtil.logoutByTokenValue(StpUtil.getTokenValue()); return ResultUtils.success(true); } @@ -115,4 +116,5 @@ public class UserController { public BaseResponse resetPassWord(@RequestBody ResetPassWordDTO resetPassWordDTO) { return ResultUtils.success(userService.resetPassWord(resetPassWordDTO)); } + } \ No newline at end of file diff --git a/src/main/java/com/yolo/keyborad/mapper/KeyboardUserCallLogMapper.java b/src/main/java/com/yolo/keyborad/mapper/KeyboardUserCallLogMapper.java new file mode 100644 index 0000000..49c3947 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/mapper/KeyboardUserCallLogMapper.java @@ -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 { +} \ No newline at end of file diff --git a/src/main/java/com/yolo/keyborad/model/entity/KeyboardUserCallLog.java b/src/main/java/com/yolo/keyborad/model/entity/KeyboardUserCallLog.java new file mode 100644 index 0000000..c489d51 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/entity/KeyboardUserCallLog.java @@ -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; + +} \ No newline at end of file diff --git a/src/main/java/com/yolo/keyborad/service/KeyboardUserCallLogService.java b/src/main/java/com/yolo/keyborad/service/KeyboardUserCallLogService.java new file mode 100644 index 0000000..3f49eb9 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/KeyboardUserCallLogService.java @@ -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{ + + +} diff --git a/src/main/java/com/yolo/keyborad/service/impl/KeyboardUserCallLogServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/KeyboardUserCallLogServiceImpl.java new file mode 100644 index 0000000..5ab8250 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/impl/KeyboardUserCallLogServiceImpl.java @@ -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 implements KeyboardUserCallLogService{ + +} diff --git a/src/main/java/com/yolo/keyborad/service/impl/QdrantVectorService.java b/src/main/java/com/yolo/keyborad/service/impl/QdrantVectorService.java index 60d4e44..7e4b660 100644 --- a/src/main/java/com/yolo/keyborad/service/impl/QdrantVectorService.java +++ b/src/main/java/com/yolo/keyborad/service/impl/QdrantVectorService.java @@ -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 searchText(String userInput) { + AppConfig appConfig = cfgHolder.getRef().get(); + long t0 = System.currentTimeMillis(); float[] floats = this.embedTextToVector(userInput); long t1 = System.currentTimeMillis(); - List qdrantSearchItems = this.searchPoint(floats, 1); + List qdrantSearchItems = this.searchPoint(floats, appConfig.getQdrantConfig().getVectorSearchLimit()); long t2 = System.currentTimeMillis(); log.info("embedding = {} ms, qdrant = {} ms, total = {} ms", diff --git a/src/main/resources/mapper/KeyboardUserCallLogMapper.xml b/src/main/resources/mapper/KeyboardUserCallLogMapper.xml new file mode 100644 index 0000000..98db57e --- /dev/null +++ b/src/main/resources/mapper/KeyboardUserCallLogMapper.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + id, user_id, request_id, feature, model, input_tokens, output_tokens, total_tokens, + success, latency_ms, error_code, created_at + + \ No newline at end of file