send-stream
This commit is contained in:
@@ -42,4 +42,11 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode VOICE_TTS_FAILED = new ErrorCode(1_030_001_005, "语音合成失败");
|
||||
ErrorCode LATENTSYNC_SUBMIT_FAILED = new ErrorCode(1_030_001_101, "口型同步任务提交失败");
|
||||
|
||||
// ========== 积分相关错误码 1-030-003-000 ==========
|
||||
ErrorCode POINTS_INSUFFICIENT = new ErrorCode(1_030_003_001, "积分不足");
|
||||
ErrorCode POINTS_CONFIG_NOT_FOUND = new ErrorCode(1_030_003_002, "积分配置不存在");
|
||||
ErrorCode POINTS_DEDUCT_FAILED = new ErrorCode(1_030_003_003, "积分扣减失败");
|
||||
ErrorCode POINTS_PENDING_NOT_FOUND = new ErrorCode(1_030_003_004, "预扣记录不存在");
|
||||
ErrorCode POINTS_PENDING_ALREADY_CONFIRMED = new ErrorCode(1_030_003_005, "预扣记录已确认或已取消");
|
||||
|
||||
}
|
||||
|
||||
@@ -39,4 +39,14 @@ public interface AiModelConfigMapper extends BaseMapperX<AiModelConfigDO> {
|
||||
.orderByDesc(AiModelConfigDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据平台和模型类型查询配置
|
||||
*/
|
||||
default AiModelConfigDO selectByPlatformAndModelType(String platform, String modelType) {
|
||||
return selectOne(new LambdaQueryWrapperX<AiModelConfigDO>()
|
||||
.eq(AiModelConfigDO::getPlatform, platform)
|
||||
.eq(AiModelConfigDO::getModelType, modelType)
|
||||
.eq(AiModelConfigDO::getStatus, 1));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.tik.muye.memberuserprofile.dal.MemberUserProfileDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.memberuserprofile.vo.MemberUserProfilePageReqVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/**
|
||||
* 会员用户档案 Mapper
|
||||
@@ -36,4 +38,25 @@ public interface MemberUserProfileMapper extends BaseMapperX<MemberUserProfileDO
|
||||
.orderByDesc(MemberUserProfileDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID查询档案
|
||||
*/
|
||||
default MemberUserProfileDO selectByUserId(String userId) {
|
||||
return selectOne(new LambdaQueryWrapperX<MemberUserProfileDO>()
|
||||
.eq(MemberUserProfileDO::getUserId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子扣减积分(乐观锁)
|
||||
* @param userId 用户ID
|
||||
* @param points 扣减积分数量(正数)
|
||||
* @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") String userId, @Param("points") Integer points);
|
||||
|
||||
}
|
||||
@@ -60,6 +60,9 @@ public class PointRecordDO extends BaseDO {
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 状态:pending-预扣 confirmed-已确认 canceled-已取消
|
||||
*/
|
||||
private String status;
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.Update;
|
||||
|
||||
/**
|
||||
* 积分记录 Mapper
|
||||
@@ -32,4 +33,11 @@ public interface PointRecordMapper extends BaseMapperX<PointRecordDO> {
|
||||
.orderByDesc(PointRecordDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消过期的预扣记录(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,6 +2,8 @@ package cn.iocoder.yudao.module.tik.tikhup.service;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.dal.AiModelConfigDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.points.service.PointsService;
|
||||
import cn.iocoder.yudao.module.tik.tikhup.mapper.TikPromptMapper;
|
||||
import cn.iocoder.yudao.module.tik.tikhup.mapper.TikTokenMapper;
|
||||
import cn.iocoder.yudao.module.tik.tikhup.vo.TikPromptVO;
|
||||
@@ -27,12 +29,33 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class TikHupServiceImpl implements TikHupService {
|
||||
|
||||
/** TikHub 平台标识 */
|
||||
private static final String PLATFORM_TIKHUB = "tikhub";
|
||||
/** TikHub 数据抓取模型类型 */
|
||||
private static final String MODEL_TYPE_FETCH = "fetch";
|
||||
|
||||
private final TikTokenMapper tikTokenMapper;
|
||||
|
||||
private final TikPromptMapper tikPromptMapper;
|
||||
|
||||
private final PointsService pointsService;
|
||||
|
||||
@Override
|
||||
public Object fetch_user_post_videos(String type,String sec_user_id, int max_cursor, int count){
|
||||
// 1. 获取当前用户ID
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
String userId = loginUserId != null ? loginUserId.toString() : "1";
|
||||
|
||||
// 2. 获取积分配置并预检
|
||||
AiModelConfigDO config = null;
|
||||
try {
|
||||
config = pointsService.getConfig(PLATFORM_TIKHUB, MODEL_TYPE_FETCH);
|
||||
pointsService.checkPoints(userId, config.getConsumePoints());
|
||||
} catch (Exception e) {
|
||||
log.error("[fetch_user_post_videos] 积分预检失败: {}", e.getMessage());
|
||||
return CommonResult.error(400, e.getMessage());
|
||||
}
|
||||
|
||||
String url = StringUtils.EMPTY;
|
||||
if("xiaohongshu".equals(type)){
|
||||
url = "https://api.tikhub.io/api/v1/xiaohongshu/app/get_user_info";
|
||||
@@ -49,7 +72,13 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
.header("Authorization", "Bearer "+Authorization)
|
||||
.asString();
|
||||
if(response.getStatus() == 200){
|
||||
Long userId = SecurityFrameworkUtils.getLoginUser().getId();
|
||||
// 3. API 调用成功,扣减积分
|
||||
try {
|
||||
pointsService.deductPoints(userId, config.getConsumePoints(), "tikhub_fetch", type);
|
||||
log.info("[fetch_user_post_videos] 用户 {} 扣减 {} 积分", userId, config.getConsumePoints());
|
||||
} catch (Exception e) {
|
||||
log.error("[fetch_user_post_videos] 积分扣减失败: {}", e.getMessage());
|
||||
}
|
||||
return JSON.parseObject(response.getBody());
|
||||
}
|
||||
}catch (Exception e){
|
||||
@@ -70,7 +99,19 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
return CommonResult.error(400, "请求方法类型不能为空");
|
||||
}
|
||||
|
||||
// 2. 获取接口配置信息
|
||||
// 2. 获取当前用户ID并预检积分
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
String userId = loginUserId != null ? loginUserId.toString() : "1";
|
||||
AiModelConfigDO config = null;
|
||||
try {
|
||||
config = pointsService.getConfig(PLATFORM_TIKHUB, MODEL_TYPE_FETCH);
|
||||
pointsService.checkPoints(userId, config.getConsumePoints());
|
||||
} catch (Exception e) {
|
||||
log.error("[postTikHup] 积分预检失败: {}", e.getMessage());
|
||||
return CommonResult.error(400, e.getMessage());
|
||||
}
|
||||
|
||||
// 3. 获取接口配置信息
|
||||
TikTokenVO tikTokenVO = tikTokenMapper.getInterfaceUrl(type);
|
||||
if (tikTokenVO == null) {
|
||||
log.error("postTikHup: 未找到接口类型 {} 的配置信息", type);
|
||||
@@ -79,7 +120,7 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
|
||||
String authorization = tikTokenVO.getPlatformToken();
|
||||
String url = tikTokenVO.getPlatformUrl();
|
||||
|
||||
|
||||
if (StringUtils.isBlank(authorization)) {
|
||||
log.error("postTikHup: 接口类型 {} 的 token 为空", type);
|
||||
return CommonResult.error(500, "接口配置错误:token 为空");
|
||||
@@ -89,7 +130,7 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
return CommonResult.error(500, "接口配置错误:URL 为空");
|
||||
}
|
||||
|
||||
// 3. 统一转换为小写进行比较(兼容大小写)
|
||||
// 4. 统一转换为小写进行比较(兼容大小写)
|
||||
String methodTypeLower = methodType.toLowerCase();
|
||||
String paramTypeLower = paramType != null ? paramType.toLowerCase() : "";
|
||||
|
||||
@@ -97,7 +138,7 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
Unirest.setTimeouts(0, 0);
|
||||
HttpResponse<String> response;
|
||||
|
||||
// 4. 根据请求方法和参数类型构建请求
|
||||
// 5. 根据请求方法和参数类型构建请求
|
||||
if ("post".equals(methodTypeLower) && "json".equals(paramTypeLower)) {
|
||||
// POST + JSON: 将 urlParams 作为 JSON body
|
||||
log.debug("postTikHup: POST JSON 请求, URL: {}, Body: {}", url, urlParams);
|
||||
@@ -131,12 +172,19 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
.asString();
|
||||
}
|
||||
|
||||
// 5. 检查响应状态码
|
||||
// 6. 检查响应状态码
|
||||
int statusCode = response.getStatus();
|
||||
String responseBody = response.getBody();
|
||||
|
||||
|
||||
if (statusCode == 200) {
|
||||
if (StringUtils.isNotBlank(responseBody)) {
|
||||
// 7. API 调用成功,扣减积分
|
||||
try {
|
||||
pointsService.deductPoints(userId, config.getConsumePoints(), "tikhub_fetch", type);
|
||||
log.info("[postTikHup] 用户 {} 扣减 {} 积分", userId, config.getConsumePoints());
|
||||
} catch (Exception e) {
|
||||
log.error("[postTikHup] 积分扣减失败: {}", e.getMessage());
|
||||
}
|
||||
// 尝试解析为 JSON,如果失败则直接返回字符串
|
||||
try {
|
||||
return JSON.parseObject(responseBody);
|
||||
|
||||
@@ -141,6 +141,12 @@ public class TikDigitalHumanTaskDO extends TenantBaseDO {
|
||||
*/
|
||||
private LocalDateTime finishTime;
|
||||
|
||||
// ========== 积分扣减 ==========
|
||||
/**
|
||||
* 预扣记录ID(用于任务完成后确认/取消扣费)
|
||||
*/
|
||||
private Long pendingRecordId;
|
||||
|
||||
// ========== 可灵特有字段 ==========
|
||||
/**
|
||||
* 可灵人脸识别会话ID(从identify-face接口获取)
|
||||
|
||||
@@ -17,6 +17,8 @@ import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
|
||||
import cn.iocoder.yudao.module.tik.kling.dto.KlingLipSyncCreateResponse;
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.dal.AiModelConfigDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.points.service.PointsService;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikUserVoiceDO;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
|
||||
@@ -63,6 +65,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
private final LatentsyncPollingService latentsyncPollingService;
|
||||
private final KlingService klingService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final PointsService pointsService;
|
||||
|
||||
/**
|
||||
* 预签名URL过期时间(24小时)
|
||||
@@ -79,6 +82,11 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
*/
|
||||
private static final Duration CACHE_EXPIRE_TIME = Duration.ofHours(24);
|
||||
|
||||
/** 积分平台和类型常量 */
|
||||
private static final String PLATFORM_DIGITAL_HUMAN = "digital_human";
|
||||
private static final String MODEL_TYPE_LATENTSYNC = "latentsync";
|
||||
private static final String MODEL_TYPE_KLING = "kling";
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createTask(AppTikDigitalHumanCreateReqVO reqVO) {
|
||||
@@ -87,11 +95,25 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
// 1. 验证输入参数
|
||||
validateTaskInput(reqVO, userId);
|
||||
|
||||
// 2. 创建任务记录
|
||||
// 2. 积分预检和预扣
|
||||
String aiProvider = StrUtil.blankToDefault(reqVO.getAiProvider(), "302ai");
|
||||
String modelType = "kling".equalsIgnoreCase(aiProvider) ? MODEL_TYPE_KLING : MODEL_TYPE_LATENTSYNC;
|
||||
AiModelConfigDO config = pointsService.getConfig(PLATFORM_DIGITAL_HUMAN, modelType);
|
||||
pointsService.checkPoints(userId.toString(), config.getConsumePoints());
|
||||
Long pendingRecordId = pointsService.createPendingDeduct(
|
||||
userId.toString(),
|
||||
config.getConsumePoints(),
|
||||
"digital_human",
|
||||
aiProvider
|
||||
);
|
||||
log.info("[createTask][用户 {} 创建预扣 {} 积分,预扣记录ID {}]", userId, config.getConsumePoints(), pendingRecordId);
|
||||
|
||||
// 3. 创建任务记录
|
||||
TikDigitalHumanTaskDO task = createTaskRecord(reqVO, userId);
|
||||
task.setPendingRecordId(pendingRecordId); // 保存预扣记录ID
|
||||
taskMapper.insert(task);
|
||||
|
||||
// 3. ✅ 立即处理预生成音频(保存为临时文件,供后续步骤使用)
|
||||
// 4. ✅ 立即处理预生成音频(保存为临时文件,供后续步骤使用)
|
||||
Long taskId = task.getId();
|
||||
if (reqVO.getPreGeneratedAudio() != null && StrUtil.isNotBlank(reqVO.getPreGeneratedAudio().getAudioBase64())) {
|
||||
try {
|
||||
@@ -224,6 +246,16 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
throw ServiceExceptionUtil.exception(ErrorCodeConstants.DIGITAL_HUMAN_TASK_CANNOT_CANCEL);
|
||||
}
|
||||
|
||||
// 取消预扣(用户取消任务,不扣费)
|
||||
if (task.getPendingRecordId() != null) {
|
||||
try {
|
||||
pointsService.cancelPendingDeduct(task.getPendingRecordId());
|
||||
log.info("[cancelTask][任务 {} 取消预扣,预扣记录ID {}]", taskId, task.getPendingRecordId());
|
||||
} catch (Exception e) {
|
||||
log.error("[cancelTask][取消预扣失败: {}]", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 更新任务状态
|
||||
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
|
||||
updateObj.setId(taskId);
|
||||
@@ -744,6 +776,9 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
*/
|
||||
private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress,
|
||||
String message, String resultVideoUrl, String errorDetail) {
|
||||
// 查询任务获取预扣记录ID
|
||||
TikDigitalHumanTaskDO task = taskMapper.selectById(taskId);
|
||||
|
||||
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
|
||||
updateObj.setId(taskId);
|
||||
updateObj.setStatus(status);
|
||||
@@ -753,6 +788,15 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
if ("SUCCESS".equals(status)) {
|
||||
updateObj.setResultVideoUrl(resultVideoUrl);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
// 确认预扣(任务成功,实际扣费)
|
||||
if (task != null && task.getPendingRecordId() != null) {
|
||||
try {
|
||||
pointsService.confirmPendingDeduct(task.getPendingRecordId());
|
||||
log.info("[updateTaskStatus][任务 {} 成功,确认扣费,预扣记录ID {}]", taskId, task.getPendingRecordId());
|
||||
} catch (Exception e) {
|
||||
log.error("[updateTaskStatus][确认扣费失败: {}]", e.getMessage());
|
||||
}
|
||||
}
|
||||
// 缓存结果
|
||||
if (StrUtil.isNotBlank(resultVideoUrl)) {
|
||||
cacheTaskResult(taskId, resultVideoUrl);
|
||||
@@ -763,6 +807,15 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
updateObj.setErrorMessage(message);
|
||||
updateObj.setErrorDetail(errorDetail);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
// 取消预扣(任务失败,不扣费)
|
||||
if (task != null && task.getPendingRecordId() != null) {
|
||||
try {
|
||||
pointsService.cancelPendingDeduct(task.getPendingRecordId());
|
||||
log.info("[updateTaskStatus][任务 {} 失败,取消预扣,预扣记录ID {}]", taskId, task.getPendingRecordId());
|
||||
} catch (Exception e) {
|
||||
log.error("[updateTaskStatus][取消预扣失败: {}]", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
taskMapper.updateById(updateObj);
|
||||
|
||||
@@ -26,6 +26,8 @@ import cn.iocoder.yudao.module.tik.voice.client.dto.VoiceCloneRequest;
|
||||
import cn.iocoder.yudao.module.tik.voice.client.dto.VoiceCloneResult;
|
||||
import cn.iocoder.yudao.module.tik.voice.client.dto.VoiceTtsRequest;
|
||||
import cn.iocoder.yudao.module.tik.voice.client.dto.VoiceTtsResult;
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.dal.AiModelConfigDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.points.service.PointsService;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikUserVoiceDO;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikUserVoiceMapper;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikUserVoiceCreateReqVO;
|
||||
@@ -108,6 +110,14 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
private static final String MODEL_COSYVOICE = "cosyvoice-v3-flash";
|
||||
private static final String MODEL_SILICONFLOW = "IndexTeam/IndexTTS-2";
|
||||
|
||||
/** 积分平台和类型常量 */
|
||||
private static final String PLATFORM_VOICE = "voice";
|
||||
private static final String MODEL_TYPE_TTS = "tts";
|
||||
private static final String MODEL_TYPE_CLONE = "clone";
|
||||
|
||||
@Resource
|
||||
private PointsService pointsService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createVoice(AppTikUserVoiceCreateReqVO createReqVO) {
|
||||
@@ -151,6 +161,10 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
// 4. 调用语音克隆服务,生成 voice_id
|
||||
if (StrUtil.isNotBlank(createReqVO.getText())) {
|
||||
try {
|
||||
// 4.1 获取积分配置并预检
|
||||
AiModelConfigDO config = pointsService.getConfig(PLATFORM_VOICE, MODEL_TYPE_CLONE);
|
||||
pointsService.checkPoints(userId.toString(), config.getConsumePoints());
|
||||
|
||||
log.info("[createVoice][开始语音复刻,配音编号({}),文件ID({}),供应商({})]",
|
||||
voice.getId(), fileDO.getId(), createReqVO.getProviderType());
|
||||
String fileAccessUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
@@ -171,9 +185,18 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
voice.setVoiceId(voiceId);
|
||||
voiceMapper.updateById(voice);
|
||||
|
||||
// 4.2 音色克隆成功,扣减积分
|
||||
try {
|
||||
pointsService.deductPoints(userId.toString(), config.getConsumePoints(), "voice_clone", voice.getId().toString());
|
||||
log.info("[createVoice][用户 {} 扣减 {} 积分(音色克隆)]", userId, config.getConsumePoints());
|
||||
} catch (Exception e) {
|
||||
log.error("[createVoice][积分扣减失败: {}]", e.getMessage());
|
||||
}
|
||||
|
||||
log.info("[createVoice][语音复刻成功,配音编号({}),voice_id({})]", voice.getId(), voiceId);
|
||||
} catch (Exception e) {
|
||||
log.error("[createVoice][语音复刻失败,配音编号({}),错误信息: {}]", voice.getId(), e.getMessage(), e);
|
||||
// 失败不扣费
|
||||
}
|
||||
} else {
|
||||
log.info("[createVoice][未提供文本,跳过语音复刻,配音编号({})]", voice.getId());
|
||||
@@ -448,6 +471,10 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
return buildSynthResponseFromCache(reqVO, synthCache);
|
||||
}
|
||||
|
||||
// 获取积分配置并预检(缓存未命中,需要实际调用 TTS)
|
||||
AiModelConfigDO ttsConfig = pointsService.getConfig(PLATFORM_VOICE, MODEL_TYPE_TTS);
|
||||
pointsService.checkPoints(userId.toString(), ttsConfig.getConsumePoints());
|
||||
|
||||
// 使用 Provider 接口进行 TTS 合成(支持前端选择供应商,不传则使用默认)
|
||||
VoiceCloneProvider provider = voiceProviderFactory.getProvider(reqVO.getProviderType());
|
||||
VoiceTtsRequest ttsRequest = VoiceTtsRequest.builder()
|
||||
@@ -469,6 +496,14 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
String format = defaultFormat(ttsResult.getFormat(), reqVO.getAudioFormat());
|
||||
String finalVoiceId = StrUtil.blankToDefault(voiceId, getDefaultVoiceId());
|
||||
|
||||
// TTS 合成成功,扣减积分
|
||||
try {
|
||||
pointsService.deductPoints(userId.toString(), ttsConfig.getConsumePoints(), "voice_tts", voiceConfigId != null ? voiceConfigId.toString() : finalVoiceId);
|
||||
log.info("[synthesizeVoice][用户 {} 扣减 {} 积分(TTS合成)]", userId, ttsConfig.getConsumePoints());
|
||||
} catch (Exception e) {
|
||||
log.error("[synthesizeVoice][积分扣减失败: {}]", e.getMessage());
|
||||
}
|
||||
|
||||
// 【安全方案】不暴露OSS链接,直接返回Base64编码的音频数据
|
||||
String audioBase64 = Base64.getEncoder().encodeToString(ttsResult.getAudio());
|
||||
log.info("[synthesizeVoice][合成成功,配音编号({}),voiceId({}),format({}),audioSize={}]",
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# AI 服务积分扣减公共服务设计文档
|
||||
|
||||
> 版本: v1.2
|
||||
> 日期: 2025-02-20
|
||||
> 状态: 待确认
|
||||
> 用途: 业务规范文档,供后续开发参考
|
||||
|
||||
---
|
||||
|
||||
# 模块一:公共积分扣减服务
|
||||
|
||||
## 1.1 服务定位
|
||||
|
||||
**积分扣减公共服务** - 位于 `tik` 模块下,供所有 AI 服务复用的基础设施。
|
||||
|
||||
**职责:**
|
||||
- 积分配置查询
|
||||
- 积分预检与扣减
|
||||
- 预扣记录管理
|
||||
- 积分流水记录
|
||||
|
||||
## 1.2 核心能力
|
||||
|
||||
| 能力 | 说明 | 使用场景 |
|
||||
|------|------|---------|
|
||||
| 获取积分配置 | 根据平台+类型获取消耗积分 | 所有业务调用前 |
|
||||
| 预检积分 | 检查余额是否充足,不足抛异常 | 调用前验证 |
|
||||
| 即时扣减 | 直接扣减并记录流水 | 同步调用成功后 |
|
||||
| 创建预扣 | 创建待确认的扣减记录 | 流式/异步任务开始时 |
|
||||
| 确认扣减 | 确认预扣,实际扣减积分 | 流式结束/任务成功时 |
|
||||
| 取消预扣 | 删除预扣记录,不扣费 | 流式出错/任务失败时 |
|
||||
|
||||
## 1.3 数据模型
|
||||
|
||||
### 用户积分(muye_member_user_profile)
|
||||
- `remaining_points` - 剩余积分(扣减来源)
|
||||
- `used_points` - 已用积分
|
||||
|
||||
### 积分记录(muye_point_record)
|
||||
- `point_amount` - 变动数量(负数为扣减)
|
||||
- `biz_type` - 业务类型(dify_chat/voice_tts 等)
|
||||
- `biz_id` - 业务关联ID
|
||||
- `status` - 状态:`pending`(预扣) / `confirmed`(已确认) / `canceled`(已取消)
|
||||
|
||||
### 积分配置(muye_ai_model_config)
|
||||
- `platform` - 平台:dify / tikhub / voice / digital_human
|
||||
- `model_type` - 类型:high/low / tts/clone / latentsync/kling
|
||||
- `consume_points` - 消耗积分
|
||||
- `api_key` - API 密钥(Dify 工作流密钥等)★
|
||||
- `api_url` - API 地址(可选,覆盖默认配置)
|
||||
|
||||
## 1.4 业务规则
|
||||
|
||||
### 积分扣减规则
|
||||
- **原子性**:使用 SQL 条件更新,确保不会超扣
|
||||
- **乐观锁**:`WHERE remaining_points >= points`
|
||||
- **幂等性**:同一预扣记录只能确认/取消一次
|
||||
|
||||
### 预扣过期规则
|
||||
- 预扣记录超过 30 分钟自动清理
|
||||
- 定时任务每 5 分钟执行一次清理
|
||||
|
||||
### 异常处理
|
||||
- 积分不足:抛出 `POINTS_INSUFFICIENT` 异常
|
||||
- 配置不存在:抛出 `POINTS_CONFIG_NOT_FOUND` 异常
|
||||
- 扣减失败:抛出 `POINTS_DEDUCT_FAILED` 异常
|
||||
|
||||
## 1.5 依赖关系
|
||||
|
||||
```
|
||||
业务服务(Dify/TikHub/Voice/DigitalHuman)
|
||||
↓
|
||||
PointsService(公共服务)
|
||||
↓
|
||||
├── AiModelConfigService → 获取积分配置
|
||||
├── MemberUserProfileMapper → 扣减积分
|
||||
└── PointRecordMapper → 记录流水
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 模块二:Dify 工作流集成
|
||||
|
||||
## 2.1 业务概述
|
||||
|
||||
**Dify 工作流** - AI 对话服务,支持智能体配置和流式响应。
|
||||
|
||||
**设计原则:**
|
||||
- 所有智能体共用一个"文案生成"工作流(同一个 api_key)
|
||||
- 智能体之间只通过 `systemPrompt` 区分
|
||||
- 前端只需传 `agentId`
|
||||
|
||||
**核心流程:**
|
||||
1. 前端传入 `agentId` + `content`
|
||||
2. 后端通过 `agentId` 获取智能体的 `systemPrompt`
|
||||
3. 调用 Dify API(固定工作流),传入 `sysPrompt` 参数
|
||||
4. 流式返回内容
|
||||
5. 流结束或用户停止时扣费
|
||||
|
||||
**调用Dify 工作流参数说明**
|
||||
|
||||
|
||||
## 2.2 扣费流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Dify 流式扣费流程 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. 获取智能体配置(systemPrompt) │
|
||||
│ 2. 获取积分配置(platform=dify) │
|
||||
│ 3. 预检积分 → 积分不足则拒绝 │
|
||||
│ 4. 创建预扣记录 │
|
||||
│ 5. 调用 Dify 流式 API(传入 sysPrompt) │
|
||||
│ ├─ 流正常结束 → 确认扣费(全额) │
|
||||
│ ├─ 用户取消 → 确认扣费(按实际消耗或最低消费) │
|
||||
│ └─ 出错 → 取消预扣(不扣费) │
|
||||
│ 6. 记录使用记录 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2.3 接口定义
|
||||
|
||||
### 阻塞模式
|
||||
- **URL**: `POST /api/tik/dify/chat`
|
||||
- **入参**: agentId, content, conversationId
|
||||
- **出参**: content, conversationId, consumePoints
|
||||
|
||||
### 流式模式
|
||||
- **URL**: `POST /api/tik/dify/chat/stream`
|
||||
- **入参**: agentId, content, conversationId
|
||||
- **出参**: SSE 流(event: message / done)
|
||||
|
||||
**说明:**
|
||||
- `agentId` 用于获取智能体的 `systemPrompt`
|
||||
- 所有智能体共用同一个 Dify 工作流
|
||||
|
||||
## 2.4 配置方案
|
||||
|
||||
**Dify 配置存储在 `muye_ai_model_config` 表:**
|
||||
|
||||
| 字段 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| model_name | Dify 文案生成 | 配置名称 |
|
||||
| platform | dify | 平台标识 |
|
||||
| model_type | writing | 类型 |
|
||||
| consume_points | 10 | 消耗积分 |
|
||||
| api_key | app-xxx | Dify 工作流密钥 ★ |
|
||||
| status | 1 | 启用 |
|
||||
|
||||
**配置文件只存公共配置:**
|
||||
|
||||
```yaml
|
||||
yudao:
|
||||
dify:
|
||||
api-url: http://8.155.172.147:8088
|
||||
timeout: 60
|
||||
```
|
||||
|
||||
## 2.5 智能体表(无需修改)
|
||||
|
||||
现有 `muye_ai_agent` 表已有字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| agent_id | 智能体ID |
|
||||
| agent_name | 智能体名称 |
|
||||
| system_prompt | 系统提示词(传递给 Dify 的 sysPrompt 参数) |
|
||||
| status | 状态 |
|
||||
|
||||
**调用流程:**
|
||||
1. 通过 `agentId` 获取智能体的 `systemPrompt`
|
||||
2. 从 `muye_ai_model_config` 获取 `api_key` + `consume_points`(platform=dify)
|
||||
3. 调用 Dify API,传入 `sysPrompt` 参数
|
||||
|
||||
---
|
||||
|
||||
# 模块三:各业务积分扣减集成
|
||||
|
||||
## 3.1 扣费模式总览
|
||||
|
||||
| 业务 | 扣费模式 | 扣费时机 | 失败处理 |
|
||||
|------|---------|---------|---------|
|
||||
| Dify 工作流 | 流式结束扣费 | 流结束/用户停止时 | 不扣费 |
|
||||
| TikHub | 成功后扣费 | API 调用成功后 | 不扣费 |
|
||||
| 语音合成 | 成功后扣费 | TTS 生成成功后 | 不扣费 |
|
||||
| 数字人合成 | 任务完成扣费 | 任务成功完成时 | 不扣费 |
|
||||
|
||||
**统一原则:失败不扣费**
|
||||
|
||||
## 3.2 TikHub 集成
|
||||
|
||||
### 业务场景
|
||||
- 抖音/小红书数据抓取
|
||||
- 用户信息、视频、帖子等
|
||||
|
||||
### 扣费流程
|
||||
```
|
||||
1. 获取积分配置(platform=tikhub, modelType=fetch)
|
||||
2. 预检积分
|
||||
3. 调用 TikHub API
|
||||
4. 成功 → 扣减积分
|
||||
5. 失败 → 不扣费
|
||||
```
|
||||
|
||||
### 积分配置
|
||||
| platform | model_type | consume_points |
|
||||
|----------|------------|----------------|
|
||||
| tikhub | fetch | 5 |
|
||||
|
||||
## 3.3 语音合成集成
|
||||
|
||||
### 业务场景
|
||||
- TTS 语音生成
|
||||
- 音色克隆
|
||||
|
||||
### 扣费流程
|
||||
```
|
||||
1. 获取积分配置(platform=voice, modelType=tts/clone)
|
||||
2. 预检积分
|
||||
3. 执行语音合成
|
||||
4. 成功 → 扣减积分
|
||||
5. 失败 → 不扣费
|
||||
```
|
||||
|
||||
### 积分配置
|
||||
| platform | model_type | consume_points |
|
||||
|----------|------------|----------------|
|
||||
| voice | tts | 2 |
|
||||
| voice | clone | 10 |
|
||||
|
||||
## 3.4 数字人合成集成
|
||||
|
||||
### 业务场景
|
||||
- 口型同步视频生成
|
||||
- 支持 Latentsync / 可灵 两种供应商
|
||||
|
||||
### 扣费流程
|
||||
```
|
||||
1. 获取积分配置(platform=digital_human, modelType=latentsync/kling)
|
||||
2. 预检积分
|
||||
3. 创建预扣记录
|
||||
4. 创建任务并异步处理
|
||||
5. 任务成功 → 确认扣费
|
||||
6. 任务失败/取消 → 取消预扣
|
||||
```
|
||||
|
||||
### 积分配置
|
||||
| platform | model_type | consume_points |
|
||||
|----------|------------|----------------|
|
||||
| digital_human | latentsync | 15 |
|
||||
| digital_human | kling | 20 |
|
||||
|
||||
---
|
||||
|
||||
# 附录
|
||||
|
||||
## A. 业务类型枚举
|
||||
|
||||
| 类型码 | 说明 |
|
||||
|--------|------|
|
||||
| dify_chat | Dify对话 |
|
||||
| ai_chat | AI聊天 |
|
||||
| image_gen | 图像生成 |
|
||||
| tikhub_fetch | TikHub数据抓取 |
|
||||
| voice_tts | 语音合成 |
|
||||
| voice_clone | 音色克隆 |
|
||||
| digital_human | 数字人合成 |
|
||||
| kling_video | 可灵视频 |
|
||||
|
||||
## B. 异常码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 1001001 | 积分不足 |
|
||||
| 1001002 | 积分配置不存在 |
|
||||
| 1001003 | 积分扣减失败 |
|
||||
|
||||
## C. 开发计划
|
||||
|
||||
| 步骤 | 内容 |
|
||||
|------|------|
|
||||
| 1 | 数据库:muye_point_record 新增 status 字段 |
|
||||
| 2 | 公共服务:PointsService 接口 + 实现 |
|
||||
| 3 | Dify 集成:配置类 + 客户端 + 服务 + Controller |
|
||||
| 4 | TikHub 集成:添加积分扣减逻辑 |
|
||||
| 5 | 语音合成集成:添加积分扣减逻辑 |
|
||||
| 6 | 数字人集成:添加积分扣减逻辑 |
|
||||
| 7 | 测试验证 |
|
||||
|
||||
**无需修改的表:**
|
||||
- `muye_ai_agent` - 无需改动
|
||||
- `muye_ai_model_config` - 已有 api_key 字段,无需改动
|
||||
|
||||
## D. 成熟度检查
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|--------|------|
|
||||
| 原子扣减 | SQL 条件更新,防止超扣 |
|
||||
| 乐观锁 | WHERE remaining_points >= points |
|
||||
| 预扣机制 | 支持流式/异步场景 |
|
||||
| 过期清理 | 30分钟自动清理预扣 |
|
||||
| 事务隔离 | 核心操作加事务 |
|
||||
| 异常处理 | 统一错误码 |
|
||||
| 配置化 | 积分消耗可配置 |
|
||||
| 业务解耦 | 公共服务复用 |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user