From 2aa459889aaa5dd3d5de84d1eef4e3d153d40137 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sat, 7 Mar 2026 23:39:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yudao/module/infra/api/file/FileApi.java | 7 + .../module/infra/api/file/FileApiImpl.java | 9 + .../module/tik/dify/client/DifyClient.java | 36 +- .../tik/dify/service/DifyServiceImpl.java | 76 ++- .../module/tik/dify/vo/DifyChatRespVO.java | 21 + .../tik/mix/service/MixTaskServiceImpl.java | 8 +- .../controller/AiUsageStatsController.java | 74 +++ .../service/AiUsageStatsService.java | 66 +++ .../service/AiUsageStatsServiceImpl.java | 243 +++++++++ .../vo/AiUsageAppStatsRespVO.java | 49 ++ .../vo/AiUsageOverviewRespVO.java | 60 +++ .../vo/AiUsageStatsPageReqVO.java | 40 ++ .../aiusagestats/vo/AiUsageTrendRespVO.java | 45 ++ .../vo/AiUsageUserStatsRespVO.java | 43 ++ .../service/MemberUserProfileServiceImpl.java | 44 +- .../muye/pointrecord/dal/PointRecordDO.java | 16 + .../pointrecord/mapper/PointRecordMapper.java | 90 +++- .../muye/points/service/PointsService.java | 22 + .../points/service/PointsServiceImpl.java | 77 +++ .../service/TikUserVoiceServiceImpl.java | 26 +- .../voice/vo/AppTikUserVoiceCreateReqVO.java | 2 +- .../src/api/muye/aiusagestats/index.ts | 92 ++++ .../src/views/muye/aiusage/index.vue | 474 ++++++++++++++++++ .../src/views/muye/pointrecord/index.vue | 23 +- 24 files changed, 1575 insertions(+), 68 deletions(-) create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/controller/AiUsageStatsController.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/service/AiUsageStatsService.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/service/AiUsageStatsServiceImpl.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageAppStatsRespVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageOverviewRespVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageStatsPageReqVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageTrendRespVO.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageUserStatsRespVO.java create mode 100644 yudao-ui-admin-vue3/src/api/muye/aiusagestats/index.ts create mode 100644 yudao-ui-admin-vue3/src/views/muye/aiusage/index.vue diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java index f66dc71acc..03ffd82744 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java @@ -72,4 +72,11 @@ public interface FileApi { */ String getMasterFileDomain(); + /** + * 根据OSS URL删除文件 + * + * @param ossUrl OSS文件完整URL + */ + void deleteFileByUrl(@NotEmpty(message = "URL 不能为空") String ossUrl); + } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java index b5b5d01083..db3dd7f887 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java @@ -49,4 +49,13 @@ public class FileApiImpl implements FileApi { return null; } + @Override + public void deleteFileByUrl(String ossUrl) { + try { + fileService.deleteFileByUrl(ossUrl); + } catch (Exception e) { + throw new RuntimeException("删除文件失败: " + e.getMessage(), e); + } + } + } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/client/DifyClient.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/client/DifyClient.java index b94327945d..8419e952de 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/client/DifyClient.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/client/DifyClient.java @@ -174,7 +174,24 @@ public class DifyClient { return switch (eventType) { case "message", "agent_message" -> DifyChatRespVO.message(answer, conversationId); - case "workflow_finished", "message_end" -> DifyChatRespVO.done(conversationId, null); + case "workflow_finished", "message_end" -> { + // 解析 token 使用信息 + @SuppressWarnings("unchecked") + Map metadata = (Map) data.get("metadata"); + if (metadata != null) { + @SuppressWarnings("unchecked") + Map usage = (Map) metadata.get("usage"); + if (usage != null) { + Integer promptTokens = parseTokenCount(usage.get("prompt_tokens")); + Integer completionTokens = parseTokenCount(usage.get("completion_tokens")); + Integer totalTokens = parseTokenCount(usage.get("total_tokens")); + log.debug("[parseSSEEvent] message_end with tokens: prompt={}, completion={}, total={}", + promptTokens, completionTokens, totalTokens); + yield DifyChatRespVO.doneWithTokens(conversationId, null, promptTokens, completionTokens, totalTokens); + } + } + yield DifyChatRespVO.done(conversationId, null); + } case "error" -> DifyChatRespVO.error(answer); // chatflow 节点事件,尝试提取内容 case "node_finished" -> { @@ -273,4 +290,21 @@ public class DifyClient { .block(); } + /** + * 解析 token 数量(处理 Number 类型) + */ + private Integer parseTokenCount(Object value) { + if (value == null) { + return 0; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException e) { + return 0; + } + } + } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/service/DifyServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/service/DifyServiceImpl.java index d0a2fddac4..3967cb56db 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/service/DifyServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/service/DifyServiceImpl.java @@ -49,6 +49,8 @@ public class DifyServiceImpl implements DifyService { AtomicLong pendingRecordId = new AtomicLong(); // 用于存储会话ID AtomicReference conversationIdRef = new AtomicReference<>(reqVO.getConversationId()); + // 用于存储 token 使用信息 + AtomicReference tokenUsageRef = new AtomicReference<>(); // Dify 用户标识(固定格式) String difyUserId = "user-" + userId; @@ -70,12 +72,13 @@ public class DifyServiceImpl implements DifyService { // 3. 预检积分 pointsService.checkPoints(userId, config.getConsumePoints()); - // 4. 创建预扣记录 + // 4. 创建预扣记录(带 serviceCode) Long recordId = pointsService.createPendingDeduct( userId, config.getConsumePoints(), "dify_chat", - reqVO.getAgentId().toString() + reqVO.getAgentId().toString(), + config.getServiceCode() ); pendingRecordId.set(recordId); @@ -95,13 +98,29 @@ public class DifyServiceImpl implements DifyService { if (resp.getConversationId() != null) { conversationIdRef.set(resp.getConversationId()); } + // 捕获 token 使用信息 + if (resp.getTotalTokens() != null && resp.getTotalTokens() > 0) { + tokenUsageRef.set(resp); + } }) - // 7. 流结束时确认扣费 + // 7. 流结束时确认扣费(带 token) .doOnComplete(() -> { if (pendingRecordId.get() > 0) { try { - pointsService.confirmPendingDeduct(pendingRecordId.get()); - log.info("[chatStream] 流结束,确认扣费,记录ID: {}", pendingRecordId.get()); + DifyChatRespVO tokenUsage = tokenUsageRef.get(); + if (tokenUsage != null) { + pointsService.confirmPendingDeductWithTokens( + pendingRecordId.get(), + tokenUsage.getInputTokens(), + tokenUsage.getOutputTokens(), + tokenUsage.getTotalTokens() + ); + log.info("[chatStream] 流结束,确认扣费(带token),记录ID: {}, tokens: {}", + pendingRecordId.get(), tokenUsage.getTotalTokens()); + } else { + pointsService.confirmPendingDeduct(pendingRecordId.get()); + log.info("[chatStream] 流结束,确认扣费(无token),记录ID: {}", pendingRecordId.get()); + } } catch (Exception e) { log.error("[chatStream] 确认扣费失败", e); } @@ -133,6 +152,15 @@ public class DifyServiceImpl implements DifyService { }) // 10. 在最后添加 done 事件 .concatWith(Mono.defer(() -> { + DifyChatRespVO tokenUsage = tokenUsageRef.get(); + if (tokenUsage != null) { + return Mono.just(DifyChatRespVO.doneWithTokens( + conversationIdRef.get(), null, + tokenUsage.getInputTokens(), + tokenUsage.getOutputTokens(), + tokenUsage.getTotalTokens() + )); + } return Mono.just(DifyChatRespVO.done(conversationIdRef.get(), null)); })) .onErrorResume(e -> { @@ -147,6 +175,8 @@ public class DifyServiceImpl implements DifyService { AtomicLong pendingRecordId = new AtomicLong(); // 用于存储会话ID AtomicReference conversationIdRef = new AtomicReference<>(""); + // 用于存储 token 使用信息 + AtomicReference tokenUsageRef = new AtomicReference<>(); // Dify 用户标识(固定格式) String difyUserId = "user-" + userId; @@ -168,12 +198,13 @@ public class DifyServiceImpl implements DifyService { // 3. 预检积分 pointsService.checkPoints(userId, config.getConsumePoints()); - // 4. 创建预扣记录 + // 4. 创建预扣记录(带 serviceCode) Long recordId = pointsService.createPendingDeduct( userId, config.getConsumePoints(), "forecast_rewrite", - reqVO.getModelType() + reqVO.getModelType(), + config.getServiceCode() ); pendingRecordId.set(recordId); @@ -213,13 +244,29 @@ public class DifyServiceImpl implements DifyService { if (resp.getConversationId() != null) { conversationIdRef.set(resp.getConversationId()); } + // 捕获 token 使用信息 + if (resp.getTotalTokens() != null && resp.getTotalTokens() > 0) { + tokenUsageRef.set(resp); + } }) - // 8. 流结束时确认扣费 + // 8. 流结束时确认扣费(带 token) .doOnComplete(() -> { if (pendingRecordId.get() > 0) { try { - pointsService.confirmPendingDeduct(pendingRecordId.get()); - log.info("[rewriteStream] 流结束,确认扣费,记录ID: {}", pendingRecordId.get()); + DifyChatRespVO tokenUsage = tokenUsageRef.get(); + if (tokenUsage != null) { + pointsService.confirmPendingDeductWithTokens( + pendingRecordId.get(), + tokenUsage.getInputTokens(), + tokenUsage.getOutputTokens(), + tokenUsage.getTotalTokens() + ); + log.info("[rewriteStream] 流结束,确认扣费(带token),记录ID: {}, tokens: {}", + pendingRecordId.get(), tokenUsage.getTotalTokens()); + } else { + pointsService.confirmPendingDeduct(pendingRecordId.get()); + log.info("[rewriteStream] 流结束,确认扣费(无token),记录ID: {}", pendingRecordId.get()); + } } catch (Exception e) { log.error("[rewriteStream] 确认扣费失败", e); } @@ -250,6 +297,15 @@ public class DifyServiceImpl implements DifyService { }) // 11. 在最后添加 done 事件 .concatWith(Mono.defer(() -> { + DifyChatRespVO tokenUsage = tokenUsageRef.get(); + if (tokenUsage != null) { + return Mono.just(DifyChatRespVO.doneWithTokens( + conversationIdRef.get(), null, + tokenUsage.getInputTokens(), + tokenUsage.getOutputTokens(), + tokenUsage.getTotalTokens() + )); + } return Mono.just(DifyChatRespVO.done(conversationIdRef.get(), null)); })) .onErrorResume(e -> { diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/vo/DifyChatRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/vo/DifyChatRespVO.java index 3e9a6da2b9..2940402b6a 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/vo/DifyChatRespVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/vo/DifyChatRespVO.java @@ -31,6 +31,15 @@ public class DifyChatRespVO { @Schema(description = "错误信息") private String errorMessage; + @Schema(description = "输入token数") + private Integer inputTokens; + + @Schema(description = "输出token数") + private Integer outputTokens; + + @Schema(description = "总token数") + private Integer totalTokens; + /** 事件类型常量 */ public static final String EVENT_MESSAGE = "message"; public static final String EVENT_DONE = "done"; @@ -52,6 +61,18 @@ public class DifyChatRespVO { .build(); } + public static DifyChatRespVO doneWithTokens(String conversationId, Integer consumePoints, + Integer inputTokens, Integer outputTokens, Integer totalTokens) { + return DifyChatRespVO.builder() + .event(EVENT_DONE) + .conversationId(conversationId) + .consumePoints(consumePoints) + .inputTokens(inputTokens) + .outputTokens(outputTokens) + .totalTokens(totalTokens) + .build(); + } + public static DifyChatRespVO error(String errorMessage) { return DifyChatRespVO.builder() .event(EVENT_ERROR) diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java index 094a86d272..2263f43732 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java @@ -6,7 +6,7 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.hutool.core.util.StrUtil; -import cn.iocoder.yudao.module.infra.service.file.FileService; +import cn.iocoder.yudao.module.infra.api.file.FileApi; import com.alibaba.ttl.TtlRunnable; import cn.iocoder.yudao.module.tik.mix.client.IceClient; import cn.iocoder.yudao.module.tik.mix.constants.MixTaskConstants; @@ -45,7 +45,7 @@ public class MixTaskServiceImpl implements MixTaskService { private final IceClient iceClient; @Resource - private FileService fileService; + private FileApi fileApi; @Override @Transactional(rollbackFor = Exception.class) @@ -115,7 +115,7 @@ public class MixTaskServiceImpl implements MixTaskService { log.info("[MixTask][删除OSS文件] taskId={}, fileCount={}", id, outputUrls.size()); for (String url : outputUrls) { try { - fileService.deleteFileByUrl(url); + fileApi.deleteFileByUrl(url); } catch (Exception e) { log.error("[MixTask][删除单个OSS文件失败] taskId={}, url={}", id, url, e); } @@ -432,7 +432,7 @@ public class MixTaskServiceImpl implements MixTaskService { return null; } // 使用FileService生成签名URL - return fileService.presignGetUrl(ossUrl, expirationSeconds); + return fileApi.presignGetUrl(ossUrl, expirationSeconds); } catch (Exception e) { log.error("[MixTask][生成签名URL失败] url={}", ossUrl, e); return null; diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/controller/AiUsageStatsController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/controller/AiUsageStatsController.java new file mode 100644 index 0000000000..211bbc5893 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/controller/AiUsageStatsController.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.tik.muye.aiusagestats.controller; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.tik.muye.aiusagestats.service.AiUsageStatsService; +import cn.iocoder.yudao.module.tik.muye.aiusagestats.vo.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +/** + * AI 使用统计 Controller + */ +@Tag(name = "管理后台 - AI 使用统计") +@RestController +@RequestMapping("/admin-api/muye/ai-usage-stats") +@Validated +public class AiUsageStatsController { + + @Resource + private AiUsageStatsService aiUsageStatsService; + + @GetMapping("/overview") + @Operation(summary = "获取概览统计") + public CommonResult getOverview( + @Parameter(description = "开始时间") @RequestParam(required = false) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime startTime, + @Parameter(description = "结束时间") @RequestParam(required = false) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime endTime, + @Parameter(description = "业务类型") @RequestParam(required = false) String bizType) { + return success(aiUsageStatsService.getOverview(startTime, endTime, bizType)); + } + + @GetMapping("/user-stats") + @Operation(summary = "获取用户统计分页") + public CommonResult> getUserStatsPage(@Valid AiUsageStatsPageReqVO reqVO) { + return success(aiUsageStatsService.getUserStatsPage(reqVO)); + } + + @GetMapping("/app-stats") + @Operation(summary = "获取应用统计列表") + public CommonResult> getAppStats( + @Parameter(description = "开始时间") @RequestParam(required = false) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime startTime, + @Parameter(description = "结束时间") @RequestParam(required = false) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime endTime, + @Parameter(description = "业务类型") @RequestParam(required = false) String bizType) { + return success(aiUsageStatsService.getAppStats(startTime, endTime, bizType)); + } + + @GetMapping("/trend") + @Operation(summary = "获取趋势数据") + public CommonResult getTrend( + @Parameter(description = "开始时间") @RequestParam(required = false) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime startTime, + @Parameter(description = "结束时间") @RequestParam(required = false) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime endTime, + @Parameter(description = "业务类型") @RequestParam(required = false) String bizType, + @Parameter(description = "类型(day-按天,hour-按小时)") @RequestParam(defaultValue = "day") String type) { + return success(aiUsageStatsService.getTrend(startTime, endTime, bizType, type)); + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/service/AiUsageStatsService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/service/AiUsageStatsService.java new file mode 100644 index 0000000000..95abfed788 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/service/AiUsageStatsService.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.tik.muye.aiusagestats.service; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.tik.muye.aiusagestats.vo.*; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +/** + * AI 使用统计 Service 接口 + */ +public interface AiUsageStatsService { + + /** + * 获取概览统计 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param bizType 业务类型(可选) + * @return 概览统计 + */ + AiUsageOverviewRespVO getOverview( + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime startTime, + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime endTime, + String bizType); + + /** + * 获取用户统计分页 + * + * @param reqVO 查询条件 + * @return 用户统计分页 + */ + PageResult getUserStatsPage(AiUsageStatsPageReqVO reqVO); + + /** + * 获取应用统计列表 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param bizType 业务类型(可选) + * @return 应用统计列表 + */ + List getAppStats( + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime startTime, + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime endTime, + String bizType); + + /** + * 获取趋势数据 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param bizType 业务类型(可选) + * @param type 类型(day-按天,hour-按小时) + * @return 趋势数据 + */ + AiUsageTrendRespVO getTrend( + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime startTime, + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) LocalDateTime endTime, + String bizType, + String type); + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/service/AiUsageStatsServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/service/AiUsageStatsServiceImpl.java new file mode 100644 index 0000000000..7ea0ca95bb --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/service/AiUsageStatsServiceImpl.java @@ -0,0 +1,243 @@ +package cn.iocoder.yudao.module.tik.muye.aiusagestats.service; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.tik.muye.aiusagestats.vo.*; +import cn.iocoder.yudao.module.tik.muye.aiserviceconfig.dal.AiServiceConfigDO; +import cn.iocoder.yudao.module.tik.muye.aiserviceconfig.mapper.AiServiceConfigMapper; +import cn.iocoder.yudao.module.tik.muye.pointrecord.dal.PointRecordDO; +import cn.iocoder.yudao.module.tik.muye.pointrecord.mapper.PointRecordMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * AI 使用统计 Service 实现类 + */ +@Service +@Validated +@Slf4j +public class AiUsageStatsServiceImpl implements AiUsageStatsService { + + @Resource + private PointRecordMapper pointRecordMapper; + + @Resource + private AiServiceConfigMapper aiServiceConfigMapper; + + /** + * 业务类型名称映射 + */ + private static final Map BIZ_TYPE_NAMES = new HashMap<>(); + static { + BIZ_TYPE_NAMES.put("dify_chat", "AI对话"); + BIZ_TYPE_NAMES.put("forecast_rewrite", "文案改写"); + BIZ_TYPE_NAMES.put("voice_tts", "语音合成"); + BIZ_TYPE_NAMES.put("digital_human", "数字人"); + BIZ_TYPE_NAMES.put("tikhub_fetch", "TikHub获取"); + BIZ_TYPE_NAMES.put("signin", "签到"); + BIZ_TYPE_NAMES.put("recharge", "充值"); + BIZ_TYPE_NAMES.put("exchange", "兑换"); + BIZ_TYPE_NAMES.put("admin", "后台调整"); + BIZ_TYPE_NAMES.put("gift", "礼包赠送"); + } + + @Override + public AiUsageOverviewRespVO getOverview(LocalDateTime startTime, LocalDateTime endTime, String bizType) { + // 查询业务类型统计 + List> bizTypeStatsList = pointRecordMapper.selectBizTypeStats(startTime, endTime); + + // 计算汇总数据 + long totalCallCount = 0; + long totalConsumePoints = 0; + long totalTokens = 0; + + List bizTypeStats = new ArrayList<>(); + for (Map stats : bizTypeStatsList) { + String bt = (String) stats.get("biz_type"); + // 如果指定了业务类型,只统计该类型 + if (bizType != null && !bizType.isEmpty() && !bizType.equals(bt)) { + continue; + } + + long callCount = getLongValue(stats, "call_count"); + long consumePoints = getLongValue(stats, "consume_points"); + long tokens = getLongValue(stats, "total_tokens"); + + totalCallCount += callCount; + totalConsumePoints += consumePoints; + totalTokens += tokens; + + bizTypeStats.add(AiUsageOverviewRespVO.BizTypeStats.builder() + .bizType(bt) + .bizTypeName(BIZ_TYPE_NAMES.getOrDefault(bt, bt)) + .callCount(callCount) + .consumePoints(consumePoints) + .totalTokens(tokens) + .build()); + } + + // 查询活跃用户数 + Long activeUserCount = pointRecordMapper.selectCount( + new LambdaQueryWrapperX() + .betweenIfPresent(PointRecordDO::getCreateTime, startTime, endTime) + .eqIfPresent(PointRecordDO::getBizType, bizType) + .eq(PointRecordDO::getStatus, "confirmed") + .eq(PointRecordDO::getType, "decrease") + .select(PointRecordDO::getUserId) + .groupBy(PointRecordDO::getUserId) + ); + + return AiUsageOverviewRespVO.builder() + .totalCallCount(totalCallCount) + .totalConsumePoints(totalConsumePoints) + .totalTokens(totalTokens) + .activeUserCount(activeUserCount) + .bizTypeStats(bizTypeStats) + .build(); + } + + @Override + public PageResult getUserStatsPage(AiUsageStatsPageReqVO reqVO) { + // 查询用户统计数据 + List> userStatsList = pointRecordMapper.selectUserStats( + reqVO.getStartTime(), reqVO.getEndTime(), reqVO.getBizType(), reqVO.getUserId()); + + // 计算总数 + long total = userStatsList.size(); + + // 分页处理 + int fromIndex = (reqVO.getPageNo() - 1) * reqVO.getPageSize(); + int toIndex = Math.min(fromIndex + reqVO.getPageSize(), userStatsList.size()); + + if (fromIndex >= userStatsList.size()) { + return new PageResult<>(Collections.emptyList(), total); + } + + List> pagedList = userStatsList.subList(fromIndex, toIndex); + + // 转换为 VO + List list = pagedList.stream() + .map(this::convertToUserStatsVO) + .collect(Collectors.toList()); + + return new PageResult<>(list, total); + } + + @Override + public List getAppStats(LocalDateTime startTime, LocalDateTime endTime, String bizType) { + // 查询应用统计数据 + List> appStatsList = pointRecordMapper.selectAppStats( + startTime, endTime, bizType); + + // 获取服务配置信息 + Map configMap = new HashMap<>(); + List configs = aiServiceConfigMapper.selectList( + new LambdaQueryWrapperX().eq(AiServiceConfigDO::getStatus, 1)); + for (AiServiceConfigDO config : configs) { + configMap.put(config.getServiceCode(), config); + } + + // 转换为 VO + return appStatsList.stream() + .map(stats -> convertToAppStatsVO(stats, configMap)) + .collect(Collectors.toList()); + } + + @Override + public AiUsageTrendRespVO getTrend(LocalDateTime startTime, LocalDateTime endTime, String bizType, String type) { + // 查询趋势数据 + List> trendList = pointRecordMapper.selectTrend( + startTime, endTime, bizType, "day".equals(type) ? "%Y-%m-%d" : "%Y-%m-%d %H:00"); + + // 转换为 VO + List items = trendList.stream() + .map(this::convertToTrendItem) + .collect(Collectors.toList()); + + return AiUsageTrendRespVO.builder() + .trendList(items) + .build(); + } + + /** + * 转换为用户统计 VO + */ + private AiUsageUserStatsRespVO convertToUserStatsVO(Map stats) { + Long callCount = getLongValue(stats, "call_count"); + Long consumePoints = getLongValue(stats, "consume_points"); + + return AiUsageUserStatsRespVO.builder() + .userId(getLongValue(stats, "user_id")) + .mobile((String) stats.get("mobile")) + .callCount(callCount) + .consumePoints(consumePoints) + .inputTokens(getLongValue(stats, "input_tokens")) + .outputTokens(getLongValue(stats, "output_tokens")) + .totalTokens(getLongValue(stats, "total_tokens")) + .avgPointsPerCall(callCount > 0 ? (double) consumePoints / callCount : 0.0) + .build(); + } + + /** + * 转换为应用统计 VO + */ + private AiUsageAppStatsRespVO convertToAppStatsVO(Map stats, + Map configMap) { + String serviceCode = (String) stats.get("service_code"); + Long callCount = getLongValue(stats, "call_count"); + Long consumePoints = getLongValue(stats, "consume_points"); + Long totalTokens = getLongValue(stats, "total_tokens"); + + AiServiceConfigDO config = configMap.get(serviceCode); + + return AiUsageAppStatsRespVO.builder() + .serviceCode(serviceCode) + .serviceName(config != null ? config.getServiceName() : serviceCode) + .platform(config != null ? config.getPlatform() : "") + .callCount(callCount) + .consumePoints(consumePoints) + .inputTokens(getLongValue(stats, "input_tokens")) + .outputTokens(getLongValue(stats, "output_tokens")) + .totalTokens(totalTokens) + .avgPointsPerCall(callCount > 0 ? (double) consumePoints / callCount : 0.0) + .avgTokensPerCall(callCount > 0 ? (double) totalTokens / callCount : 0.0) + .build(); + } + + /** + * 转换为趋势数据项 + */ + private AiUsageTrendRespVO.TrendItem convertToTrendItem(Map stats) { + return AiUsageTrendRespVO.TrendItem.builder() + .time((String) stats.get("time")) + .callCount(getLongValue(stats, "call_count")) + .consumePoints(getLongValue(stats, "consume_points")) + .totalTokens(getLongValue(stats, "total_tokens")) + .build(); + } + + /** + * 安全获取 Long 值 + */ + private Long getLongValue(Map map, String key) { + Object value = map.get(key); + if (value == null) { + return 0L; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.parseLong(value.toString()); + } catch (NumberFormatException e) { + return 0L; + } + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageAppStatsRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageAppStatsRespVO.java new file mode 100644 index 0000000000..2f274ecbd7 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageAppStatsRespVO.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.tik.muye.aiusagestats.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * AI 使用应用统计响应 VO + */ +@Schema(description = "AI 使用应用统计响应") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiUsageAppStatsRespVO { + + @Schema(description = "服务标识") + private String serviceCode; + + @Schema(description = "服务名称") + private String serviceName; + + @Schema(description = "平台") + private String platform; + + @Schema(description = "调用次数") + private Long callCount; + + @Schema(description = "消耗积分") + private Long consumePoints; + + @Schema(description = "输入 Token 数") + private Long inputTokens; + + @Schema(description = "输出 Token 数") + private Long outputTokens; + + @Schema(description = "总 Token 数") + private Long totalTokens; + + @Schema(description = "平均每次消耗积分") + private Double avgPointsPerCall; + + @Schema(description = "平均每次 Token 数") + private Double avgTokensPerCall; + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageOverviewRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageOverviewRespVO.java new file mode 100644 index 0000000000..3f6406cb82 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageOverviewRespVO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.tik.muye.aiusagestats.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * AI 使用概览统计响应 VO + */ +@Schema(description = "AI 使用概览统计响应") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiUsageOverviewRespVO { + + @Schema(description = "总调用次数") + private Long totalCallCount; + + @Schema(description = "总消耗积分") + private Long totalConsumePoints; + + @Schema(description = "总 Token 数") + private Long totalTokens; + + @Schema(description = "活跃用户数") + private Long activeUserCount; + + @Schema(description = "业务类型统计列表") + private List bizTypeStats; + + /** + * 业务类型统计 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class BizTypeStats { + @Schema(description = "业务类型") + private String bizType; + + @Schema(description = "业务类型名称") + private String bizTypeName; + + @Schema(description = "调用次数") + private Long callCount; + + @Schema(description = "消耗积分") + private Long consumePoints; + + @Schema(description = "Token 数") + private Long totalTokens; + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageStatsPageReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageStatsPageReqVO.java new file mode 100644 index 0000000000..34d37340ba --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageStatsPageReqVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.tik.muye.aiusagestats.vo; + +import cn.iocoder.yudao.framework.common.pojo.SortablePageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +/** + * AI 使用统计查询请求 VO + */ +@Schema(description = "AI 使用统计查询请求") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class AiUsageStatsPageReqVO extends SortablePageParam { + + @Schema(description = "开始时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startTime; + + @Schema(description = "结束时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endTime; + + @Schema(description = "业务类型") + private String bizType; + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "服务标识") + private String serviceCode; + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageTrendRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageTrendRespVO.java new file mode 100644 index 0000000000..5aec6f5fe7 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageTrendRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.tik.muye.aiusagestats.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * AI 使用趋势统计响应 VO + */ +@Schema(description = "AI 使用趋势统计响应") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiUsageTrendRespVO { + + @Schema(description = "趋势数据列表") + private List trendList; + + /** + * 趋势数据项 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TrendItem { + @Schema(description = "时间(日期或小时)") + private String time; + + @Schema(description = "调用次数") + private Long callCount; + + @Schema(description = "消耗积分") + private Long consumePoints; + + @Schema(description = "Token 数") + private Long totalTokens; + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageUserStatsRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageUserStatsRespVO.java new file mode 100644 index 0000000000..2eb08a3f79 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aiusagestats/vo/AiUsageUserStatsRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.tik.muye.aiusagestats.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * AI 使用用户统计响应 VO + */ +@Schema(description = "AI 使用用户统计响应") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiUsageUserStatsRespVO { + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "手机号") + private String mobile; + + @Schema(description = "调用次数") + private Long callCount; + + @Schema(description = "消耗积分") + private Long consumePoints; + + @Schema(description = "输入 Token 数") + private Long inputTokens; + + @Schema(description = "输出 Token 数") + private Long outputTokens; + + @Schema(description = "总 Token 数") + private Long totalTokens; + + @Schema(description = "平均每次消耗积分") + private Double avgPointsPerCall; + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileServiceImpl.java index 19598142cb..1921400db0 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileServiceImpl.java @@ -1,6 +1,5 @@ package cn.iocoder.yudao.module.tik.muye.memberuserprofile.service; - import cn.iocoder.yudao.framework.common.exception.ErrorCode; import cn.iocoder.yudao.module.member.api.user.MemberUserApi; import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; @@ -8,14 +7,15 @@ import cn.iocoder.yudao.module.tik.muye.memberuserprofile.dal.MemberUserProfileD import cn.iocoder.yudao.module.tik.muye.memberuserprofile.mapper.MemberUserProfileMapper; import cn.iocoder.yudao.module.tik.muye.memberuserprofile.vo.MemberUserProfilePageReqVO; import cn.iocoder.yudao.module.tik.muye.memberuserprofile.vo.MemberUserProfileSaveReqVO; -import org.springframework.stereotype.Service; -import jakarta.annotation.Resource; -import org.springframework.validation.annotation.Validated; - -import java.time.LocalDateTime; import java.math.BigDecimal; import java.math.RoundingMode; -import java.util.*; +import java.time.LocalDateTime; +import java.util.List; + +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -96,11 +96,7 @@ public class MemberUserProfileServiceImpl implements MemberUserProfileService { profile.setUserId(String.valueOf(userId)); // 获取用户手机号 MemberUserRespDTO user = memberUserApi.getUser(userId); - if (user != null) { - profile.setMobile(user.getMobile()); - } else { - profile.setMobile(""); - } + profile.setMobile(user != null ? user.getMobile() : ""); profile.setRegisterTime(LocalDateTime.now()); profile.setLastLoginTime(LocalDateTime.now()); profile.setTotalPoints(0); @@ -136,28 +132,24 @@ public class MemberUserProfileServiceImpl implements MemberUserProfileService { @Override public boolean increaseUsedStorage(String userId, long fileSizeBytes) { - // 确保档案存在(兼容旧用户) createIfAbsent(Long.parseLong(userId)); - - // 将字节转换为GB(保留6位小数) - BigDecimal storageGb = new BigDecimal(fileSizeBytes).divide(GB_TO_BYTES, 6, RoundingMode.HALF_UP); - String storageGbStr = storageGb.toPlainString(); - - int affectedRows = memberUserProfileMapper.updateStorageIncrease(userId, storageGbStr); - return affectedRows > 0; + String storageGbStr = convertBytesToGb(fileSizeBytes); + return memberUserProfileMapper.updateStorageIncrease(userId, storageGbStr) > 0; } @Override public boolean decreaseUsedStorage(String userId, long fileSizeBytes) { - // 确保档案存在(兼容旧用户) createIfAbsent(Long.parseLong(userId)); + String storageGbStr = convertBytesToGb(fileSizeBytes); + return memberUserProfileMapper.updateStorageDecrease(userId, storageGbStr) > 0; + } - // 将字节转换为GB(保留6位小数) + /** + * 将字节转换为GB字符串 + */ + private String convertBytesToGb(long fileSizeBytes) { BigDecimal storageGb = new BigDecimal(fileSizeBytes).divide(GB_TO_BYTES, 6, RoundingMode.HALF_UP); - String storageGbStr = storageGb.toPlainString(); - - int affectedRows = memberUserProfileMapper.updateStorageDecrease(userId, storageGbStr); - return affectedRows > 0; + return storageGb.toPlainString(); } @Override diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/dal/PointRecordDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/dal/PointRecordDO.java index e3ab0be0ca..2ffcaf27e1 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/dal/PointRecordDO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/dal/PointRecordDO.java @@ -64,5 +64,21 @@ public class PointRecordDO extends BaseDO { * 状态:pending-预扣 confirmed-已确认 canceled-已取消 */ private String status; + /** + * AI服务标识(对应muye_ai_service_config.service_code) + */ + private String serviceCode; + /** + * 输入token数 + */ + private Integer inputTokens; + /** + * 输出token数 + */ + private Integer outputTokens; + /** + * 总token数 + */ + private Integer totalTokens; } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/mapper/PointRecordMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/mapper/PointRecordMapper.java index d40dd8c827..2c6cee83e8 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/mapper/PointRecordMapper.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/mapper/PointRecordMapper.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.tik.muye.pointrecord.mapper; +import java.time.LocalDateTime; import java.util.*; import cn.iocoder.yudao.framework.common.pojo.PageResult; @@ -8,6 +9,8 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.module.tik.muye.pointrecord.dal.PointRecordDO; import cn.iocoder.yudao.module.tik.muye.pointrecord.vo.PointRecordPageReqVO; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; /** @@ -41,4 +44,89 @@ public interface PointRecordMapper extends BaseMapperX { "WHERE status = 'pending' AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE)") int cancelExpiredPendingRecords(); -} \ No newline at end of file + /** + * 按业务类型统计 + */ + @Select("") + List> selectBizTypeStats(@Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 按用户统计 + */ + @Select("") + List> selectUserStats(@Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime, + @Param("bizType") String bizType, + @Param("userId") Long userId); + + /** + * 按应用统计 + */ + @Select("") + List> selectAppStats(@Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime, + @Param("bizType") String bizType); + + /** + * 按时间趋势统计 + */ + @Select("") + List> selectTrend(@Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime, + @Param("bizType") String bizType, + @Param("dateFormat") String dateFormat); + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/points/service/PointsService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/points/service/PointsService.java index a2bb6b3957..299adf9f75 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/points/service/PointsService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/points/service/PointsService.java @@ -66,4 +66,26 @@ public interface PointsService { */ void cancelPendingDeduct(Long recordId); + /** + * 创建预扣(带服务标识) + * + * @param userId 用户ID + * @param points 预扣积分数量 + * @param bizType 业务类型 + * @param bizId 业务关联ID + * @param serviceCode AI服务标识 + * @return 预扣记录ID + */ + Long createPendingDeduct(String userId, Integer points, String bizType, String bizId, String serviceCode); + + /** + * 确认预扣并更新 token 信息 + * + * @param recordId 预扣记录ID + * @param inputTokens 输入token数 + * @param outputTokens 输出token数 + * @param totalTokens 总token数 + */ + void confirmPendingDeductWithTokens(Long recordId, Integer inputTokens, Integer outputTokens, Integer totalTokens); + } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/points/service/PointsServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/points/service/PointsServiceImpl.java index 0ea48539ac..17dfbb28db 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/points/service/PointsServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/points/service/PointsServiceImpl.java @@ -191,4 +191,81 @@ public class PointsServiceImpl implements PointsService { recordId, record.getUserId(), Math.abs(record.getPointAmount())); } + @Override + @Transactional(rollbackFor = Exception.class) + public Long createPendingDeduct(String userId, Integer points, String bizType, String bizId, String serviceCode) { + // 1. 预检积分 + checkPoints(userId, points); + + // 2. 查询当前余额 + MemberUserProfileDO profile = memberUserProfileMapper.selectByUserId(userId); + + // 2.1 获取用户手机号 + MemberUserDO user = memberUserService.getUser(Long.parseLong(userId)); + String mobile = user != null ? user.getMobile() : ""; + + // 3. 创建预扣记录(待确认状态) + PointRecordDO record = PointRecordDO.builder() + .userId(Long.parseLong(userId)) + .mobile(mobile) + .type("decrease") + .pointAmount(-points) + .balance(profile.getRemainingPoints()) + .reason(bizType + "(预扣)") + .bizType(bizType) + .bizId(bizId != null ? bizId : UUID.randomUUID().toString()) + .serviceCode(serviceCode) + .inputTokens(0) + .outputTokens(0) + .totalTokens(0) + .status(STATUS_PENDING) + .build(); + pointRecordMapper.insert(record); + + log.info("[createPendingDeduct] 用户 {} 创建预扣 {} 积分,业务类型 {},服务标识 {},记录ID {}", + userId, points, bizType, serviceCode, record.getId()); + return record.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void confirmPendingDeductWithTokens(Long recordId, Integer inputTokens, Integer outputTokens, Integer totalTokens) { + // 1. 查询预扣记录 + PointRecordDO record = pointRecordMapper.selectById(recordId); + if (record == null) { + throw exception(POINTS_PENDING_NOT_FOUND); + } + + // 2. 校验状态 + if (!STATUS_PENDING.equals(record.getStatus())) { + throw exception(POINTS_PENDING_ALREADY_CONFIRMED); + } + + // 3. 获取扣减信息 + String userId = record.getUserId().toString(); + Integer points = Math.abs(record.getPointAmount()); + + // 4. 原子扣减积分 + int affectedRows = memberUserProfileMapper.updatePointsDeduct(userId, points); + if (affectedRows == 0) { + log.warn("[confirmPendingDeductWithTokens] 积分扣减失败,可能余额不足,记录ID {}", recordId); + throw exception(POINTS_DEDUCT_FAILED); + } + + // 5. 查询扣减后余额 + MemberUserProfileDO profile = memberUserProfileMapper.selectByUserId(userId); + + // 6. 更新预扣记录状态和 token 信息 + record.setStatus(STATUS_CONFIRMED); + record.setBalance(profile.getRemainingPoints()); + record.setReason(record.getReason().replace("(预扣)", "")); + record.setInputTokens(inputTokens != null ? inputTokens : 0); + record.setOutputTokens(outputTokens != null ? outputTokens : 0); + record.setTotalTokens(totalTokens != null ? totalTokens : 0); + pointRecordMapper.updateById(record); + + log.info("[confirmPendingDeductWithTokens] 确认预扣记录 {},用户 {} 扣减 {} 积分,tokens: {}/{}/{}", + recordId, userId, points, inputTokens, outputTokens, totalTokens); + } + } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java index 76f2f6e1e2..673de95102 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java @@ -113,8 +113,17 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { public Long createVoice(AppTikUserVoiceCreateReqVO createReqVO) { Long userId = SecurityFrameworkUtils.getLoginUserId(); - // 1. 校验文件是否存在且属于voice分类 - FileDO fileDO = fileMapper.selectById(createReqVO.getFileId()); + // 1. 前端传入的是 userFileId(tik_user_file.id),先查询用户文件记录 + TikUserFileDO userFile = userFileMapper.selectOne(new LambdaQueryWrapperX() + .eq(TikUserFileDO::getId, createReqVO.getFileId()) + .eq(TikUserFileDO::getFileCategory, "voice") + .eq(TikUserFileDO::getUserId, userId)); + if (userFile == null) { + throw exception(VOICE_FILE_NOT_EXISTS, "文件不存在或不属于voice分类"); + } + + // 2. 通过 userFile.fileId(infra_file.id)查询实际文件信息 + FileDO fileDO = fileMapper.selectById(userFile.getFileId()); if (fileDO == null) { throw exception(VOICE_FILE_NOT_EXISTS); } @@ -126,15 +135,6 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { String.format("音频文件过大(%.1fMB),请上传小于5MB的音频文件", sizeMB)); } - // 验证文件分类是否为voice(通过tik_user_file表查询) - TikUserFileDO userFile = userFileMapper.selectOne(new LambdaQueryWrapperX() - .eq(TikUserFileDO::getFileId, createReqVO.getFileId()) - .eq(TikUserFileDO::getFileCategory, "voice") - .eq(TikUserFileDO::getUserId, userId)); - if (userFile == null) { - throw exception(VOICE_FILE_NOT_EXISTS, "文件不存在或不属于voice分类"); - } - // 2. 校验名称是否重复 TikUserVoiceDO existingVoice = voiceMapper.selectOne(new LambdaQueryWrapperX() .eq(TikUserVoiceDO::getUserId, userId) @@ -144,11 +144,11 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { throw exception(VOICE_NAME_DUPLICATE); } - // 3. 创建配音记录 + // 3. 创建配音记录(fileId 存储 infra_file.id,用于后续查询文件URL) TikUserVoiceDO voice = new TikUserVoiceDO() .setUserId(userId) .setName(createReqVO.getName()) - .setFileId(createReqVO.getFileId()) + .setFileId(userFile.getFileId()) // 存储 infra_file.id .setLanguage(StrUtil.blankToDefault(createReqVO.getLanguage(), "zh-CN")) .setGender(StrUtil.blankToDefault(createReqVO.getGender(), "female")) .setNote(createReqVO.getNote()) diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceCreateReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceCreateReqVO.java index 2f8209bef9..fe2b0f28d8 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceCreateReqVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikUserVoiceCreateReqVO.java @@ -19,7 +19,7 @@ public class AppTikUserVoiceCreateReqVO { @NotBlank(message = "配音名称不能为空") private String name; - @Schema(description = "音频文件编号(关联 infra_file.id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @Schema(description = "用户文件编号(关联 tik_user_file.id,上传文件后返回的 userFileId)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") @NotNull(message = "音频文件编号不能为空") private Long fileId; diff --git a/yudao-ui-admin-vue3/src/api/muye/aiusagestats/index.ts b/yudao-ui-admin-vue3/src/api/muye/aiusagestats/index.ts new file mode 100644 index 0000000000..b5d278b429 --- /dev/null +++ b/yudao-ui-admin-vue3/src/api/muye/aiusagestats/index.ts @@ -0,0 +1,92 @@ +import request from '@/config/axios' + +export interface AiUsageOverview { + totalCallCount: number + totalConsumePoints: number + totalTokens: number + activeUserCount: number + bizTypeStats: BizTypeStats[] +} + +export interface BizTypeStats { + bizType: string + bizTypeName: string + callCount: number + consumePoints: number + totalTokens: number +} + +export interface AiUsageUserStats { + userId: number + mobile: string + callCount: number + consumePoints: number + inputTokens: number + outputTokens: number + totalTokens: number + avgPointsPerCall: number +} + +export interface AiUsageAppStats { + serviceCode: string + serviceName: string + platform: string + callCount: number + consumePoints: number + inputTokens: number + outputTokens: number + totalTokens: number + avgPointsPerCall: number + avgTokensPerCall: number +} + +export interface AiUsageTrend { + trendList: TrendItem[] +} + +export interface TrendItem { + time: string + callCount: number + consumePoints: number + totalTokens: number +} + +// 获取概览统计 +export const getAiUsageOverview = (params: { + startTime?: string + endTime?: string + bizType?: string +}) => { + return request.get({ url: '/muye/ai-usage-stats/overview', params }) +} + +// 获取用户统计分页 +export const getAiUsageUserStatsPage = (params: { + pageNo: number + pageSize: number + startTime?: string + endTime?: string + bizType?: string + userId?: number +}) => { + return request.get({ url: '/muye/ai-usage-stats/user-stats', params }) +} + +// 获取应用统计列表 +export const getAiUsageAppStats = (params: { + startTime?: string + endTime?: string + bizType?: string +}) => { + return request.get({ url: '/muye/ai-usage-stats/app-stats', params }) +} + +// 获取趋势数据 +export const getAiUsageTrend = (params: { + startTime?: string + endTime?: string + bizType?: string + type?: string +}) => { + return request.get({ url: '/muye/ai-usage-stats/trend', params }) +} diff --git a/yudao-ui-admin-vue3/src/views/muye/aiusage/index.vue b/yudao-ui-admin-vue3/src/views/muye/aiusage/index.vue new file mode 100644 index 0000000000..7bc96b2612 --- /dev/null +++ b/yudao-ui-admin-vue3/src/views/muye/aiusage/index.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/yudao-ui-admin-vue3/src/views/muye/pointrecord/index.vue b/yudao-ui-admin-vue3/src/views/muye/pointrecord/index.vue index ff08793082..2ed0b24835 100644 --- a/yudao-ui-admin-vue3/src/views/muye/pointrecord/index.vue +++ b/yudao-ui-admin-vue3/src/views/muye/pointrecord/index.vue @@ -247,43 +247,42 @@ const openForm = (type: string, id?: number) => { /** 删除按钮操作 */ const handleDelete = async (id: number) => { try { - // 删除的二次确认 await message.delConfirm() - // 发起删除 await PointRecordApi.deletePointRecord(id) message.success(t('common.delSuccess')) - // 刷新列表 await getList() - } catch {} + } catch { + // 用户取消操作 + } } /** 批量删除积分记录 */ const handleDeleteBatch = async () => { try { - // 删除的二次确认 await message.delConfirm() - await PointRecordApi.deletePointRecordList(checkedIds.value); - checkedIds.value = []; + await PointRecordApi.deletePointRecordList(checkedIds.value) + checkedIds.value = [] message.success(t('common.delSuccess')) - await getList(); - } catch {} + await getList() + } catch { + // 用户取消操作 + } } const checkedIds = ref([]) const handleRowCheckboxChange = (records: PointRecord[]) => { - checkedIds.value = records.map((item) => item.id!); + checkedIds.value = records.map((item) => item.id!) } /** 导出按钮操作 */ const handleExport = async () => { try { - // 导出的二次确认 await message.exportConfirm() - // 发起导出 exportLoading.value = true const data = await PointRecordApi.exportPointRecord(queryParams) download.excel(data, '积分记录.xls') } catch { + // 用户取消操作 } finally { exportLoading.value = false }