send-stream
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package cn.iocoder.yudao.module.tik.dify.client;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.dify.config.DifyProperties;
|
||||
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatRespVO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Dify API 客户端
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class DifyClient {
|
||||
|
||||
@Resource
|
||||
private DifyProperties difyProperties;
|
||||
|
||||
/**
|
||||
* 调用 Dify 工作流流式 API
|
||||
*
|
||||
* @param apiKey Dify API Key
|
||||
* @param content 用户输入
|
||||
* @param systemPrompt 系统提示词
|
||||
* @param conversationId 会话ID(可选)
|
||||
* @return 流式响应
|
||||
*/
|
||||
public Flux<DifyChatRespVO> chatStream(String apiKey, String content, String systemPrompt, String conversationId) {
|
||||
String apiUrl = difyProperties.getApiUrl() + "/v1/workflows/run";
|
||||
|
||||
// 构建请求体
|
||||
Map<String, Object> inputs = new HashMap<>();
|
||||
inputs.put("sysPrompt", systemPrompt);
|
||||
inputs.put("userInput", content);
|
||||
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("inputs", inputs);
|
||||
requestBody.put("response_mode", "streaming");
|
||||
requestBody.put("user", "user-" + System.currentTimeMillis());
|
||||
|
||||
AtomicReference<String> responseConversationId = new AtomicReference<>(conversationId);
|
||||
StringBuilder fullContent = new StringBuilder();
|
||||
|
||||
WebClient webClient = WebClient.builder()
|
||||
.baseUrl(apiUrl)
|
||||
.defaultHeader("Authorization", "Bearer " + apiKey)
|
||||
.defaultHeader("Content-Type", "application/json")
|
||||
.build();
|
||||
|
||||
return webClient.post()
|
||||
.bodyValue(requestBody)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
.retrieve()
|
||||
.bodyToFlux(String.class)
|
||||
.map(this::parseSSEEvent)
|
||||
.filter(resp -> resp != null)
|
||||
.doOnNext(resp -> {
|
||||
if (resp.getConversationId() != null) {
|
||||
responseConversationId.set(resp.getConversationId());
|
||||
}
|
||||
if (resp.getContent() != null) {
|
||||
fullContent.append(resp.getContent());
|
||||
}
|
||||
})
|
||||
.doOnComplete(() -> {
|
||||
log.info("[chatStream] Dify 流式响应完成,会话ID: {}, 内容长度: {}",
|
||||
responseConversationId.get(), fullContent.length());
|
||||
})
|
||||
.doOnError(e -> log.error("[chatStream] Dify 流式响应错误", e));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SSE 事件
|
||||
*/
|
||||
private DifyChatRespVO parseSSEEvent(String event) {
|
||||
if (event == null || event.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析 SSE 事件格式
|
||||
// data: {"event": "message", "answer": "xxx", "conversation_id": "xxx"}
|
||||
if (event.startsWith("data:")) {
|
||||
String jsonStr = event.substring(5).trim();
|
||||
if (jsonStr.isEmpty() || jsonStr.equals("[DONE]")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 简单解析 JSON(实际项目建议使用 Jackson/Gson)
|
||||
String eventType = extractJsonValue(jsonStr, "event");
|
||||
String answer = extractJsonValue(jsonStr, "answer");
|
||||
String conversationId = extractJsonValue(jsonStr, "conversation_id");
|
||||
|
||||
if ("message".equals(eventType) || "agent_message".equals(eventType)) {
|
||||
return DifyChatRespVO.message(answer, conversationId);
|
||||
} else if ("workflow_finished".equals(eventType) || "message_end".equals(eventType)) {
|
||||
return DifyChatRespVO.done(conversationId, null);
|
||||
} else if ("error".equals(eventType)) {
|
||||
return DifyChatRespVO.error(answer);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[parseSSEEvent] 解析 SSE 事件失败: {}", event, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单提取 JSON 值
|
||||
*/
|
||||
private String extractJsonValue(String json, String key) {
|
||||
String pattern = "\"" + key + "\"\\s*:\\s*\"";
|
||||
int start = json.indexOf(pattern);
|
||||
if (start == -1) {
|
||||
// 尝试非字符串格式
|
||||
pattern = "\"" + key + "\"\\s*:\\s*";
|
||||
start = json.indexOf(pattern);
|
||||
if (start == -1) return null;
|
||||
start += pattern.length();
|
||||
int end = json.indexOf(",", start);
|
||||
if (end == -1) end = json.indexOf("}", start);
|
||||
if (end == -1) return null;
|
||||
return json.substring(start, end).trim();
|
||||
}
|
||||
start += pattern.length();
|
||||
int end = json.indexOf("\"", start);
|
||||
if (end == -1) return null;
|
||||
return json.substring(start, end);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.iocoder.yudao.module.tik.dify.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Dify 配置属性
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "yudao.dify")
|
||||
public class DifyProperties {
|
||||
|
||||
/**
|
||||
* Dify API 地址
|
||||
*/
|
||||
private String apiUrl;
|
||||
|
||||
/**
|
||||
* 请求超时时间(秒)
|
||||
*/
|
||||
private Integer timeout = 60;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cn.iocoder.yudao.module.tik.dify.controller;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.tik.dify.service.DifyService;
|
||||
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatReqVO;
|
||||
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatRespVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* Dify 工作流控制器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Tag(name = "用户 App - Dify 工作流")
|
||||
@RestController
|
||||
@RequestMapping("/api/tik/dify")
|
||||
public class AppDifyController {
|
||||
|
||||
@Resource
|
||||
private DifyService difyService;
|
||||
|
||||
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
@Operation(summary = "流式聊天")
|
||||
public Flux<CommonResult<DifyChatRespVO>> chatStream(@Valid @RequestBody DifyChatReqVO reqVO) {
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
String userId = loginUserId != null ? loginUserId.toString() : "1"; // 默认用户ID
|
||||
|
||||
return difyService.chatStream(reqVO, userId)
|
||||
.map(CommonResult::success);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cn.iocoder.yudao.module.tik.dify.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatReqVO;
|
||||
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatRespVO;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* Dify 服务接口
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface DifyService {
|
||||
|
||||
/**
|
||||
* 流式聊天(带积分扣减)
|
||||
*
|
||||
* @param reqVO 请求参数
|
||||
* @param userId 用户ID
|
||||
* @return 流式响应
|
||||
*/
|
||||
Flux<DifyChatRespVO> chatStream(DifyChatReqVO reqVO, String userId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package cn.iocoder.yudao.module.tik.dify.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.dify.client.DifyClient;
|
||||
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatReqVO;
|
||||
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatRespVO;
|
||||
import cn.iocoder.yudao.module.tik.muye.aiagent.dal.AiAgentDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.aiagent.service.AiAgentService;
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.dal.AiModelConfigDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.points.service.PointsService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Dify 服务实现类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class DifyServiceImpl implements DifyService {
|
||||
|
||||
/** Dify 平台标识 */
|
||||
private static final String PLATFORM_DIFY = "dify";
|
||||
/** Dify 模型类型 */
|
||||
private static final String MODEL_TYPE_WRITING = "writing";
|
||||
|
||||
@Resource
|
||||
private AiAgentService aiAgentService;
|
||||
|
||||
@Resource
|
||||
private PointsService pointsService;
|
||||
|
||||
@Resource
|
||||
private DifyClient difyClient;
|
||||
|
||||
@Override
|
||||
public Flux<DifyChatRespVO> chatStream(DifyChatReqVO reqVO, String userId) {
|
||||
// 用于存储预扣记录ID
|
||||
AtomicLong pendingRecordId = new AtomicLong();
|
||||
// 用于存储会话ID
|
||||
AtomicReference<String> conversationIdRef = new AtomicReference<>(reqVO.getConversationId());
|
||||
|
||||
return Mono.fromCallable(() -> {
|
||||
// 1. 获取智能体配置
|
||||
AiAgentDO agent = aiAgentService.getAiAgent(reqVO.getAgentId());
|
||||
if (agent == null) {
|
||||
throw new RuntimeException("智能体不存在");
|
||||
}
|
||||
|
||||
// 2. 获取积分配置
|
||||
AiModelConfigDO config = pointsService.getConfig(PLATFORM_DIFY, MODEL_TYPE_WRITING);
|
||||
|
||||
// 3. 预检积分
|
||||
pointsService.checkPoints(userId, config.getConsumePoints());
|
||||
|
||||
// 4. 创建预扣记录
|
||||
Long recordId = pointsService.createPendingDeduct(
|
||||
userId,
|
||||
config.getConsumePoints(),
|
||||
"dify_chat",
|
||||
reqVO.getAgentId().toString()
|
||||
);
|
||||
pendingRecordId.set(recordId);
|
||||
|
||||
// 5. 返回调用参数
|
||||
return new DifyChatContext(agent.getSystemPrompt(), config.getApiKey(), config.getConsumePoints());
|
||||
})
|
||||
.flatMapMany(context -> {
|
||||
// 6. 调用 Dify 流式 API
|
||||
return difyClient.chatStream(
|
||||
context.apiKey(),
|
||||
reqVO.getContent(),
|
||||
context.systemPrompt(),
|
||||
reqVO.getConversationId()
|
||||
)
|
||||
.doOnNext(resp -> {
|
||||
if (resp.getConversationId() != null) {
|
||||
conversationIdRef.set(resp.getConversationId());
|
||||
}
|
||||
})
|
||||
// 7. 流结束时确认扣费
|
||||
.doOnComplete(() -> {
|
||||
if (pendingRecordId.get() > 0) {
|
||||
try {
|
||||
pointsService.confirmPendingDeduct(pendingRecordId.get());
|
||||
log.info("[chatStream] 流结束,确认扣费,记录ID: {}", pendingRecordId.get());
|
||||
} catch (Exception e) {
|
||||
log.error("[chatStream] 确认扣费失败", e);
|
||||
}
|
||||
}
|
||||
})
|
||||
// 8. 流出错时取消预扣
|
||||
.doOnError(e -> {
|
||||
if (pendingRecordId.get() > 0) {
|
||||
try {
|
||||
pointsService.cancelPendingDeduct(pendingRecordId.get());
|
||||
log.info("[chatStream] 流出错,取消预扣,记录ID: {}", pendingRecordId.get());
|
||||
} catch (Exception ex) {
|
||||
log.error("[chatStream] 取消预扣失败", ex);
|
||||
}
|
||||
}
|
||||
})
|
||||
// 9. 用户取消时确认扣费(已消费的部分)
|
||||
.doOnCancel(() -> {
|
||||
if (pendingRecordId.get() > 0) {
|
||||
try {
|
||||
// 用户主动取消,仍然扣费(按最低消费)
|
||||
pointsService.confirmPendingDeduct(pendingRecordId.get());
|
||||
log.info("[chatStream] 用户取消,确认扣费,记录ID: {}", pendingRecordId.get());
|
||||
} catch (Exception e) {
|
||||
log.error("[chatStream] 用户取消后扣费失败", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
// 10. 在最后添加 done 事件
|
||||
.concatWith(Mono.defer(() -> {
|
||||
return Mono.just(DifyChatRespVO.done(conversationIdRef.get(), null));
|
||||
}))
|
||||
.onErrorResume(e -> {
|
||||
log.error("[chatStream] Dify 聊天异常", e);
|
||||
return Flux.just(DifyChatRespVO.error(e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dify 聊天上下文
|
||||
*/
|
||||
private record DifyChatContext(String systemPrompt, String apiKey, Integer consumePoints) {}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.tik.dify.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Dify 聊天请求 VO
|
||||
*/
|
||||
@Schema(description = "Dify 聊天请求")
|
||||
@Data
|
||||
public class DifyChatReqVO {
|
||||
|
||||
@Schema(description = "智能体ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "智能体ID不能为空")
|
||||
private Long agentId;
|
||||
|
||||
@Schema(description = "用户输入内容", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotEmpty(message = "内容不能为空")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "会话ID(可选,首次对话不传)")
|
||||
private String conversationId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.iocoder.yudao.module.tik.dify.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Dify 聊天响应 VO
|
||||
*/
|
||||
@Schema(description = "Dify 聊天响应")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DifyChatRespVO {
|
||||
|
||||
@Schema(description = "事件类型:message/done/error")
|
||||
private String event;
|
||||
|
||||
@Schema(description = "消息内容")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "会话ID")
|
||||
private String conversationId;
|
||||
|
||||
@Schema(description = "消耗积分")
|
||||
private Integer consumePoints;
|
||||
|
||||
@Schema(description = "错误信息")
|
||||
private String errorMessage;
|
||||
|
||||
/** 事件类型常量 */
|
||||
public static final String EVENT_MESSAGE = "message";
|
||||
public static final String EVENT_DONE = "done";
|
||||
public static final String EVENT_ERROR = "error";
|
||||
|
||||
public static DifyChatRespVO message(String content, String conversationId) {
|
||||
return DifyChatRespVO.builder()
|
||||
.event(EVENT_MESSAGE)
|
||||
.content(content)
|
||||
.conversationId(conversationId)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static DifyChatRespVO done(String conversationId, Integer consumePoints) {
|
||||
return DifyChatRespVO.builder()
|
||||
.event(EVENT_DONE)
|
||||
.conversationId(conversationId)
|
||||
.consumePoints(consumePoints)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static DifyChatRespVO error(String errorMessage) {
|
||||
return DifyChatRespVO.builder()
|
||||
.event(EVENT_ERROR)
|
||||
.errorMessage(errorMessage)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.iocoder.yudao.module.tik.muye.points.job;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.muye.pointrecord.mapper.PointRecordMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 预扣过期清理定时任务
|
||||
*
|
||||
* 功能:清理超过30分钟的预扣记录,将其状态更新为已取消
|
||||
* 执行频率:每5分钟执行一次
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class PointsPendingCleanJob {
|
||||
|
||||
@Resource
|
||||
private PointRecordMapper pointRecordMapper;
|
||||
|
||||
/**
|
||||
* 每5分钟清理一次过期的预扣记录
|
||||
*/
|
||||
@Scheduled(fixedRate = 5 * 60 * 1000)
|
||||
public void cleanExpiredPendingRecords() {
|
||||
log.info("[cleanExpiredPendingRecords][开始清理过期预扣记录]");
|
||||
try {
|
||||
int affectedRows = pointRecordMapper.cancelExpiredPendingRecords();
|
||||
log.info("[cleanExpiredPendingRecords][清理完成,共取消 {} 条过期预扣记录]", affectedRows);
|
||||
} catch (Exception e) {
|
||||
log.error("[cleanExpiredPendingRecords][清理过期预扣记录失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cn.iocoder.yudao.module.tik.muye.points.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.dal.AiModelConfigDO;
|
||||
|
||||
/**
|
||||
* 积分扣减公共服务
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface PointsService {
|
||||
|
||||
/**
|
||||
* 获取积分配置
|
||||
*
|
||||
* @param platform 平台标识(dify/tikhub/voice/digital_human)
|
||||
* @param modelType 模型类型
|
||||
* @return 积分配置
|
||||
*/
|
||||
AiModelConfigDO getConfig(String platform, String modelType);
|
||||
|
||||
/**
|
||||
* 预检积分(余额不足抛异常)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param points 所需积分
|
||||
*/
|
||||
void checkPoints(String userId, Integer points);
|
||||
|
||||
/**
|
||||
* 即时扣减(同步场景)
|
||||
* 直接扣减积分并记录流水
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param points 扣减积分数量
|
||||
* @param bizType 业务类型
|
||||
* @param bizId 业务关联ID
|
||||
* @return 记录ID
|
||||
*/
|
||||
Long deductPoints(String userId, Integer points, String bizType, String bizId);
|
||||
|
||||
/**
|
||||
* 创建预扣(流式/异步场景)
|
||||
* 创建待确认的扣减记录,不实际扣减积分
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param points 预扣积分数量
|
||||
* @param bizType 业务类型
|
||||
* @param bizId 业务关联ID
|
||||
* @return 预扣记录ID
|
||||
*/
|
||||
Long createPendingDeduct(String userId, Integer points, String bizType, String bizId);
|
||||
|
||||
/**
|
||||
* 确认预扣(实际扣减)
|
||||
* 执行实际积分扣减,更新预扣记录状态
|
||||
*
|
||||
* @param recordId 预扣记录ID
|
||||
*/
|
||||
void confirmPendingDeduct(Long recordId);
|
||||
|
||||
/**
|
||||
* 取消预扣(不扣费)
|
||||
* 更新预扣记录状态为已取消
|
||||
*
|
||||
* @param recordId 预扣记录ID
|
||||
*/
|
||||
void cancelPendingDeduct(Long recordId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package cn.iocoder.yudao.module.tik.muye.points.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.dal.AiModelConfigDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.mapper.AiModelConfigMapper;
|
||||
import cn.iocoder.yudao.module.tik.muye.memberuserprofile.dal.MemberUserProfileDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.memberuserprofile.mapper.MemberUserProfileMapper;
|
||||
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.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* 积分扣减公共服务 实现类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class PointsServiceImpl implements PointsService {
|
||||
|
||||
/** 预扣状态:待确认 */
|
||||
private static final String STATUS_PENDING = "pending";
|
||||
/** 预扣状态:已确认 */
|
||||
private static final String STATUS_CONFIRMED = "confirmed";
|
||||
/** 预扣状态:已取消 */
|
||||
private static final String STATUS_CANCELED = "canceled";
|
||||
|
||||
@Resource
|
||||
private AiModelConfigMapper aiModelConfigMapper;
|
||||
|
||||
@Resource
|
||||
private MemberUserProfileMapper memberUserProfileMapper;
|
||||
|
||||
@Resource
|
||||
private PointRecordMapper pointRecordMapper;
|
||||
|
||||
@Override
|
||||
public AiModelConfigDO getConfig(String platform, String modelType) {
|
||||
AiModelConfigDO config = aiModelConfigMapper.selectByPlatformAndModelType(platform, modelType);
|
||||
if (config == null) {
|
||||
throw exception(POINTS_CONFIG_NOT_FOUND);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkPoints(String userId, Integer points) {
|
||||
MemberUserProfileDO profile = memberUserProfileMapper.selectByUserId(userId);
|
||||
if (profile == null || profile.getRemainingPoints() == null || profile.getRemainingPoints() < points) {
|
||||
throw exception(POINTS_INSUFFICIENT);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long deductPoints(String userId, Integer points, String bizType, String bizId) {
|
||||
// 1. 原子扣减积分
|
||||
int affectedRows = memberUserProfileMapper.updatePointsDeduct(userId, points);
|
||||
if (affectedRows == 0) {
|
||||
throw exception(POINTS_DEDUCT_FAILED);
|
||||
}
|
||||
|
||||
// 2. 查询扣减后余额
|
||||
MemberUserProfileDO profile = memberUserProfileMapper.selectByUserId(userId);
|
||||
|
||||
// 3. 创建积分记录(已确认状态)
|
||||
PointRecordDO record = PointRecordDO.builder()
|
||||
.userId(Long.parseLong(userId))
|
||||
.type("decrease")
|
||||
.pointAmount(-points)
|
||||
.balance(profile.getRemainingPoints())
|
||||
.reason(bizType)
|
||||
.bizType(bizType)
|
||||
.bizId(bizId)
|
||||
.status(STATUS_CONFIRMED)
|
||||
.build();
|
||||
pointRecordMapper.insert(record);
|
||||
|
||||
log.info("[deductPoints] 用户 {} 扣减积分 {},业务类型 {},记录ID {}", userId, points, bizType, record.getId());
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createPendingDeduct(String userId, Integer points, String bizType, String bizId) {
|
||||
// 1. 预检积分
|
||||
checkPoints(userId, points);
|
||||
|
||||
// 2. 查询当前余额
|
||||
MemberUserProfileDO profile = memberUserProfileMapper.selectByUserId(userId);
|
||||
|
||||
// 3. 创建预扣记录(待确认状态)
|
||||
PointRecordDO record = PointRecordDO.builder()
|
||||
.userId(Long.parseLong(userId))
|
||||
.type("decrease")
|
||||
.pointAmount(-points)
|
||||
.balance(profile.getRemainingPoints())
|
||||
.reason(bizType + "(预扣)")
|
||||
.bizType(bizType)
|
||||
.bizId(bizId != null ? bizId : UUID.randomUUID().toString())
|
||||
.status(STATUS_PENDING)
|
||||
.build();
|
||||
pointRecordMapper.insert(record);
|
||||
|
||||
log.info("[createPendingDeduct] 用户 {} 创建预扣 {} 积分,业务类型 {},记录ID {}",
|
||||
userId, points, bizType, record.getId());
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void confirmPendingDeduct(Long recordId) {
|
||||
// 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("[confirmPendingDeduct] 积分扣减失败,可能余额不足,记录ID {}", recordId);
|
||||
throw exception(POINTS_DEDUCT_FAILED);
|
||||
}
|
||||
|
||||
// 5. 查询扣减后余额
|
||||
MemberUserProfileDO profile = memberUserProfileMapper.selectByUserId(userId);
|
||||
|
||||
// 6. 更新预扣记录状态
|
||||
record.setStatus(STATUS_CONFIRMED);
|
||||
record.setBalance(profile.getRemainingPoints());
|
||||
record.setReason(record.getReason().replace("(预扣)", ""));
|
||||
pointRecordMapper.updateById(record);
|
||||
|
||||
log.info("[confirmPendingDeduct] 确认预扣记录 {},用户 {} 扣减 {} 积分",
|
||||
recordId, userId, points);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cancelPendingDeduct(Long recordId) {
|
||||
// 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. 更新为已取消状态
|
||||
record.setStatus(STATUS_CANCELED);
|
||||
pointRecordMapper.updateById(record);
|
||||
|
||||
log.info("[cancelPendingDeduct] 取消预扣记录 {},用户 {},积分 {}",
|
||||
recordId, record.getUserId(), Math.abs(record.getPointAmount()));
|
||||
}
|
||||
|
||||
}
|
||||
299
yudao-module-tik/src/main/resources/IMPLEMENTATION_PLAN.md
Normal file
299
yudao-module-tik/src/main/resources/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# AI 服务积分扣减公共服务 - 实现计划
|
||||
|
||||
> 版本: v1.0
|
||||
> 日期: 2026-02-22
|
||||
> 基于设计文档: points-service-integration.md
|
||||
|
||||
---
|
||||
|
||||
## 一、实现概览
|
||||
|
||||
### 当前状态分析
|
||||
|
||||
| 组件 | 文件路径 | 当前状态 | 需要修改 |
|
||||
|------|---------|---------|---------|
|
||||
| PointRecordDO | `muye/pointrecord/dal/PointRecordDO.java` | 完整 | 新增 status 字段 |
|
||||
| MemberUserProfileMapper | `muye/memberuserprofile/mapper/MemberUserProfileMapper.java` | 仅查询 | 新增原子扣减方法 |
|
||||
| AiModelConfigMapper | `muye/aimodelconfig/mapper/AiModelConfigMapper.java` | 仅分页 | 新增按平台查询方法 |
|
||||
| PointsService | 不存在 | **需新建** | 新建接口+实现 |
|
||||
| DifyService | 不存在 | **需新建** | 新建服务 |
|
||||
|
||||
---
|
||||
|
||||
## 二、实现步骤
|
||||
|
||||
### 步骤 1: 数据库层修改
|
||||
|
||||
#### 1.1 PointRecordDO 新增 status 字段
|
||||
|
||||
**文件**: `muye/pointrecord/dal/PointRecordDO.java`
|
||||
|
||||
```java
|
||||
// 新增字段
|
||||
private String status; // 状态:pending(预扣) / confirmed(已确认) / canceled(已取消)
|
||||
```
|
||||
|
||||
**数据库迁移**:
|
||||
```sql
|
||||
ALTER TABLE muey_point_record ADD COLUMN status VARCHAR(20) DEFAULT 'confirmed' COMMENT '状态:pending-预扣 confirmed-已确认 canceled-已取消';
|
||||
```
|
||||
|
||||
#### 1.2 MemberUserProfileMapper 新增方法
|
||||
|
||||
**文件**: `muye/memberuserprofile/mapper/MemberUserProfileMapper.java`
|
||||
|
||||
```java
|
||||
/**
|
||||
* 根据用户ID查询档案
|
||||
*/
|
||||
default MemberUserProfileDO selectByUserId(Long userId) {
|
||||
return selectOne(new LambdaQueryWrapperX<MemberUserProfileDO>()
|
||||
.eq(MemberUserProfileDO::getUserId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子扣减积分(乐观锁)
|
||||
* @return 影响行数,0表示余额不足
|
||||
*/
|
||||
@Update("UPDATE muey_member_user_profile " +
|
||||
"SET remaining_points = remaining_points - #{points}, " +
|
||||
" used_points = used_points + #{points}, " +
|
||||
" update_time = NOW() " +
|
||||
"WHERE user_id = #{userId} AND remaining_points >= #{points}")
|
||||
int updatePointsDeduct(@Param("userId") Long userId, @Param("points") Integer points);
|
||||
```
|
||||
|
||||
#### 1.3 AiModelConfigMapper 新增方法
|
||||
|
||||
**文件**: `muye/aimodelconfig/mapper/AiModelConfigMapper.java`
|
||||
|
||||
```java
|
||||
/**
|
||||
* 根据平台和模型类型查询配置
|
||||
*/
|
||||
default AiModelConfigDO selectByPlatformAndModelType(String platform, String modelType) {
|
||||
return selectOne(new LambdaQueryWrapperX<AiModelConfigDO>()
|
||||
.eq(AiModelConfigDO::getPlatform, platform)
|
||||
.eq(AiModelConfigDO::getModelType, modelType)
|
||||
.eq(AiModelConfigDO::getStatus, 1));
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 PointRecordMapper 新增方法
|
||||
|
||||
**文件**: `muye/pointrecord/mapper/PointRecordMapper.java`
|
||||
|
||||
```java
|
||||
/**
|
||||
* 取消过期的预扣记录(30分钟前)
|
||||
*/
|
||||
@Update("UPDATE muey_point_record SET status = 'canceled', update_time = NOW() " +
|
||||
"WHERE status = 'pending' AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE)")
|
||||
int cancelExpiredPendingRecords();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2: 公共积分服务
|
||||
|
||||
#### 2.1 新建 PointsService 接口
|
||||
|
||||
**文件**: `muye/points/service/PointsService.java`(新建)
|
||||
|
||||
```java
|
||||
public interface PointsService {
|
||||
|
||||
/**
|
||||
* 获取积分配置
|
||||
*/
|
||||
AiModelConfigDO getConfig(String platform, String modelType);
|
||||
|
||||
/**
|
||||
* 预检积分(余额不足抛异常)
|
||||
*/
|
||||
void checkPoints(Long userId, Integer points);
|
||||
|
||||
/**
|
||||
* 即时扣减(同步场景)
|
||||
*/
|
||||
Long deductPoints(Long userId, Integer points, String bizType, String bizId);
|
||||
|
||||
/**
|
||||
* 创建预扣(流式/异步场景)
|
||||
* @return 预扣记录ID
|
||||
*/
|
||||
Long createPendingDeduct(Long userId, Integer points, String bizType, String bizId);
|
||||
|
||||
/**
|
||||
* 确认预扣(实际扣减)
|
||||
*/
|
||||
void confirmPendingDeduct(Long recordId);
|
||||
|
||||
/**
|
||||
* 取消预扣(不扣费)
|
||||
*/
|
||||
void cancelPendingDeduct(Long recordId);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 新建 PointsServiceImpl 实现
|
||||
|
||||
**文件**: `muye/points/service/PointsServiceImpl.java`(新建)
|
||||
|
||||
核心逻辑:
|
||||
- `deductPoints`: 调用 Mapper 原子扣减,失败抛 `POINTS_DEDUCT_FAILED`
|
||||
- `createPendingDeduct`: 创建 status=pending 的记录
|
||||
- `confirmPendingDeduct`: 执行实际扣减 + 更新 status=confirmed
|
||||
- `cancelPendingDeduct`: 更新 status=canceled
|
||||
|
||||
#### 2.3 新建错误码常量
|
||||
|
||||
**文件**: `ErrorCodeConstants.java`(修改)
|
||||
|
||||
```java
|
||||
// 积分相关错误码 1001001-1001003
|
||||
ErrorCode POINTS_INSUFFICIENT = new ErrorCode(1001001, "积分不足");
|
||||
ErrorCode POINTS_CONFIG_NOT_FOUND = new ErrorCode(1001002, "积分配置不存在");
|
||||
ErrorCode POINTS_DEDUCT_FAILED = new ErrorCode(1001003, "积分扣减失败");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3: Dify 工作流集成
|
||||
|
||||
#### 3.1 新建 Dify 配置类
|
||||
|
||||
**文件**: `dify/config/DifyProperties.java`(新建)
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "yudao.dify")
|
||||
public class DifyProperties {
|
||||
private String apiUrl;
|
||||
private Integer timeout = 60;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 新建 DifyClient
|
||||
|
||||
**文件**: `dify/client/DifyClient.java`(新建)
|
||||
|
||||
- 调用 Dify 工作流 API
|
||||
- 支持流式响应
|
||||
- 传入 sysPrompt 参数
|
||||
|
||||
#### 3.3 新建 DifyService
|
||||
|
||||
**文件**: `dify/service/DifyService.java`(新建)
|
||||
|
||||
```java
|
||||
public interface DifyService {
|
||||
/**
|
||||
* 流式聊天(带积分扣减)
|
||||
*/
|
||||
Flux<DifyChatRespVO> chatStream(DifyChatReqVO reqVO, Long userId);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4 新建 DifyController
|
||||
|
||||
**文件**: `dify/controller/AppDifyController.java`(新建)
|
||||
|
||||
```java
|
||||
@PostMapping("/api/tik/dify/chat/stream")
|
||||
public Flux<CommonResult<DifyChatRespVO>> chatStream(@RequestBody DifyChatReqVO reqVO);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 4: 集成到现有服务
|
||||
|
||||
#### 4.1 AiChatMessageService 集成
|
||||
|
||||
**文件**: `service/chat/AiChatMessageServiceImpl.java`(修改)
|
||||
|
||||
在 `sendChatMessageStream` 方法中:
|
||||
1. 调用前:`pointsService.checkPoints()`
|
||||
2. 创建预扣:`pointsService.createPendingDeduct()`
|
||||
3. 流结束:`pointsService.confirmPendingDeduct()`
|
||||
4. 出错/取消:`pointsService.cancelPendingDeduct()`
|
||||
|
||||
---
|
||||
|
||||
### 步骤 5: 定时任务
|
||||
|
||||
#### 5.1 预扣过期清理任务
|
||||
|
||||
**文件**: `job/PointsPendingCleanJob.java`(新建)
|
||||
|
||||
- 每 5 分钟执行
|
||||
- 调用 `pointRecordMapper.cancelExpiredPendingRecords()`
|
||||
|
||||
---
|
||||
|
||||
## 三、文件清单
|
||||
|
||||
### 新建文件
|
||||
|
||||
| 文件 | 路径 |
|
||||
|------|------|
|
||||
| PointsService | `muye/points/service/PointsService.java` |
|
||||
| PointsServiceImpl | `muye/points/service/PointsServiceImpl.java` |
|
||||
| DifyProperties | `dify/config/DifyProperties.java` |
|
||||
| DifyClient | `dify/client/DifyClient.java` |
|
||||
| DifyService | `dify/service/DifyService.java` |
|
||||
| DifyServiceImpl | `dify/service/DifyServiceImpl.java` |
|
||||
| DifyReqVO | `dify/vo/DifyChatReqVO.java` |
|
||||
| DifyRespVO | `dify/vo/DifyChatRespVO.java` |
|
||||
| AppDifyController | `dify/controller/AppDifyController.java` |
|
||||
| PointsPendingCleanJob | `job/PointsPendingCleanJob.java` |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| PointRecordDO | 新增 status 字段 |
|
||||
| MemberUserProfileMapper | 新增 selectByUserId、updatePointsDeduct 方法 |
|
||||
| AiModelConfigMapper | 新增 selectByPlatformAndModelType 方法 |
|
||||
| PointRecordMapper | 新增 cancelExpiredPendingRecords 方法 |
|
||||
| ErrorCodeConstants | 新增积分相关错误码 |
|
||||
| AiChatMessageServiceImpl | 集成积分扣减逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 四、数据库变更
|
||||
|
||||
```sql
|
||||
-- 1. 积分记录表新增状态字段
|
||||
ALTER TABLE muey_point_record
|
||||
ADD COLUMN status VARCHAR(20) DEFAULT 'confirmed'
|
||||
COMMENT '状态:pending-预扣 confirmed-已确认 canceled-已取消';
|
||||
|
||||
-- 2. 添加索引(可选优化)
|
||||
CREATE INDEX idx_point_record_status_time
|
||||
ON muey_point_record(status, create_time);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、依赖关系
|
||||
|
||||
```
|
||||
业务层
|
||||
├── DifyService ──────┐
|
||||
├── AiChatMessageService ──┼──→ PointsService(公共服务)
|
||||
├── TikHubService ─────────┤ │
|
||||
├── VoiceService ──────────┤ ├── AiModelConfigMapper(配置查询)
|
||||
└── DigitalHumanService ───┘ ├── MemberUserProfileMapper(积分扣减)
|
||||
└── PointRecordMapper(流水记录)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、验收标准
|
||||
|
||||
- [ ] PointsService 单元测试通过
|
||||
- [ ] Dify 流式接口正常返回
|
||||
- [ ] 积分不足时抛出正确异常
|
||||
- [ ] 流式中断时预扣正确取消
|
||||
- [ ] 预扣过期定时任务正常运行
|
||||
- [ ] 积分扣减原子性(并发不超扣)
|
||||
Reference in New Issue
Block a user