send-stream

This commit is contained in:
wing
2026-02-22 20:29:37 +08:00
parent 4e15e2459e
commit ca633f74b6
311 changed files with 511 additions and 68449 deletions

View File

@@ -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, "预扣记录已确认或已取消");
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -60,6 +60,9 @@ public class PointRecordDO extends BaseDO {
* 备注
*/
private String remark;
/**
* 状态pending-预扣 confirmed-已确认 canceled-已取消
*/
private String status;
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -141,6 +141,12 @@ public class TikDigitalHumanTaskDO extends TenantBaseDO {
*/
private LocalDateTime finishTime;
// ========== 积分扣减 ==========
/**
* 预扣记录ID用于任务完成后确认/取消扣费)
*/
private Long pendingRecordId;
// ========== 可灵特有字段 ==========
/**
* 可灵人脸识别会话ID从identify-face接口获取

View File

@@ -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);

View File

@@ -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={}]",