diff --git a/frontend/app/web-gold/src/api/pointRecord.js b/frontend/app/web-gold/src/api/pointRecord.js new file mode 100644 index 0000000000..dcad154b57 --- /dev/null +++ b/frontend/app/web-gold/src/api/pointRecord.js @@ -0,0 +1,19 @@ +import http from './http' +import { API_BASE } from '@gold/config/api' + +/** + * 积分记录 API + */ + +/** + * 获取当前用户积分记录分页 + * @param {Object} params - 分页参数 + * @param {number} params.pageNo - 页码 + * @param {number} params.pageSize - 每页数量 + * @param {string} params.type - 变动类型 increase/decrease + * @param {string} params.bizType - 业务类型 + * @returns {Promise} + */ +export function getPointRecordPage(params = {}) { + return http.get(`${API_BASE.APP_MEMBER}/tik/point-record/page`, { params }) +} diff --git a/frontend/app/web-gold/src/components/PipelineProgress.vue b/frontend/app/web-gold/src/components/PipelineProgress.vue deleted file mode 100644 index 28fe6c1a74..0000000000 --- a/frontend/app/web-gold/src/components/PipelineProgress.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - - {{ stateLabel }} - {{ stateDescription }} - - - - - - - {{ error }} - 重试 - - - - - - - diff --git a/frontend/app/web-gold/src/components/VoiceSelector.vue b/frontend/app/web-gold/src/components/VoiceSelector.vue index cc3474729b..2aeeaec778 100644 --- a/frontend/app/web-gold/src/components/VoiceSelector.vue +++ b/frontend/app/web-gold/src/components/VoiceSelector.vue @@ -18,7 +18,7 @@ @@ -74,6 +74,7 @@ let player = null const playerContainer = ref(null) const audioUrl = ref('') const currentVoiceName = ref('') +const isPlayerInitializing = ref(false) // 默认封面图片(音频波形图标) const defaultCover = `data:image/svg+xml;base64,${btoa(` @@ -154,6 +155,8 @@ const handleVoiceChange = (value, option) => { const handleSynthesize = () => { if (!selectedVoiceId.value) return + // 防止在播放器初始化过程中重复点击 + if (isPlayerInitializing.value) return const voice = userVoiceCards.value.find(v => v.id === selectedVoiceId.value) if (!voice) return @@ -171,23 +174,17 @@ watch(() => props.speechRate, (newRate) => { setSpeechRate(newRate) }, { immediate: true }) -/** - * 处理音色 - */ const handlePlayVoiceSample = (voice) => { currentVoiceName.value = voice.name playVoiceSample( voice, (data) => { const url = data.audioUrl || data.objectUrl - if (!url) { - console.error('无效的音频数据格式', data) - return - } + if (!url) return initPlayer(url) }, (error) => { - console.error('音频播放失败', error) + // 音频播放失败,静默处理 }, { autoPlay: false } // 禁用自动播放,由 APlayer 控制 ) @@ -197,31 +194,46 @@ const handlePlayVoiceSample = (voice) => { * 初始化 APlayer */ const initPlayer = (url) => { + // 防止并发初始化 + if (isPlayerInitializing.value) { + return + } + + isPlayerInitializing.value = true destroyPlayer() audioUrl.value = url nextTick(() => { - player = new APlayer({ - container: playerContainer.value, - autoplay: true, - theme: '#3b82f6', - volume: 0.7, - loop: 'none', - audio: [{ - name: currentVoiceName.value || '语音合成', - artist: '合成', - url: url, - cover: defaultCover - }] - }) + try { + player = new APlayer({ + container: playerContainer.value, + autoplay: true, + theme: '#3b82f6', + volume: 0.7, + loop: 'none', + audio: [{ + name: currentVoiceName.value || '语音合成', + artist: '合成', + url: url, + cover: defaultCover + }] + }) - player.on('ended', () => { - player.seek(0) - }) + player.on('ended', () => { + player.seek(0) + }) - player.on('error', (e) => { - console.error('APlayer 播放错误:', e) - }) + player.on('error', (e) => { + console.error('APlayer 播放错误:', e) + }) + + player.on('canplay', () => { + isPlayerInitializing.value = false + }) + } catch (e) { + console.error('APlayer 初始化失败:', e) + isPlayerInitializing.value = false + } }) } @@ -244,8 +256,11 @@ const downloadAudio = () => { * 销毁播放器 */ const destroyPlayer = () => { + isPlayerInitializing.value = false if (player) { try { + // 先暂停播放,防止销毁过程中出错 + player.pause() player.destroy() } catch (e) { console.error('销毁播放器失败:', e) @@ -259,8 +274,6 @@ const destroyPlayer = () => { audioUrl.value = '' } -defineExpose({}) - onMounted(async () => { await voiceStore.refresh() }) diff --git a/frontend/app/web-gold/src/composables/useTTS.js b/frontend/app/web-gold/src/composables/useTTS.js index cf383a62f6..a21e90629e 100644 --- a/frontend/app/web-gold/src/composables/useTTS.js +++ b/frontend/app/web-gold/src/composables/useTTS.js @@ -89,15 +89,15 @@ export function useTTS(options = {}) { } audio.play() - .then(function() { + .then(() => { previewAudio = audio audio.onended = cleanup - audio.onerror = function() { + audio.onerror = () => { cleanup() message.error('播放失败') } }) - .catch(function() { + .catch(() => { cleanup() message.error('播放失败') }) @@ -159,11 +159,11 @@ export function useTTS(options = {}) { playAudioPreview(previewObjectUrl, { revokeOnEnd: false, - onEnded: function() { + onEnded() { if (audioData.objectUrl?.startsWith('blob:')) { URL.revokeObjectURL(audioData.objectUrl) } - onEnded && onEnded() + onEnded?.() } }) } @@ -244,7 +244,7 @@ export function useTTS(options = {}) { } else { resetPreviewState() } - onSuccess && onSuccess(cachedAudio) + onSuccess?.(cachedAudio) return } @@ -252,11 +252,11 @@ export function useTTS(options = {}) { const params = buildPreviewParams(voice) if (!params) { resetPreviewState() - onError && onError(new Error('参数构建失败')) + onError?.(new Error('参数构建失败')) return } - const res = await VoiceService.preview(params) + const res = await VoiceService.synthesize(params) if (res.code !== 0) { message.error(res.msg || '试听失败') resetPreviewState() @@ -269,7 +269,7 @@ export function useTTS(options = {}) { if (options.autoPlay !== false) { playAudioPreview(res.data.audioUrl, { revokeOnEnd: true, - onEnded: function() { + onEnded() { URL.revokeObjectURL(res.data.audioUrl) } }) @@ -279,7 +279,7 @@ export function useTTS(options = {}) { const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey) resetPreviewState() if (options.autoPlay !== false) { - playCachedAudio(audioData, function() { + playCachedAudio(audioData, () => { URL.revokeObjectURL(audioData.objectUrl) }) } @@ -292,7 +292,7 @@ export function useTTS(options = {}) { } catch (error) { message.error('试听失败') resetPreviewState() - onError && onError(error) + onError?.(error) } } @@ -335,7 +335,7 @@ export function useTTS(options = {}) { * 清除音频缓存 */ function clearAudioCache() { - previewAudioCache.forEach(function(audioData) { + previewAudioCache.forEach((audioData) => { URL.revokeObjectURL(audioData.objectUrl) }) previewAudioCache.clear() diff --git a/frontend/app/web-gold/src/views/kling/IdentifyFace.vue b/frontend/app/web-gold/src/views/kling/IdentifyFace.vue index 3860e5516d..aa4e545b56 100644 --- a/frontend/app/web-gold/src/views/kling/IdentifyFace.vue +++ b/frontend/app/web-gold/src/views/kling/IdentifyFace.vue @@ -10,17 +10,17 @@ 输入播文案 - 当前字数:{{ ttsText?.length || 0 }}字 + 当前字数:{{ store.text?.length || 0 }}字 @@ -33,9 +33,9 @@ 选择音色 @@ -44,13 +44,13 @@ 标准版 (1x积分) Pro 旗舰版 (3x积分) @@ -69,8 +69,8 @@ @@ -84,8 +84,8 @@ @@ -98,32 +98,32 @@ - + - {{ videoState.selectedVideo.fileName }} - {{ formatDuration(videoState.selectedVideo.duration) }} + {{ store.selectedVideo.fileName }} + {{ formatDuration(store.selectedVideo.duration) }} - 更换 + 更换 - + - + 点击上传新视频 支持 MP4、MOV (需 >3秒) @@ -132,52 +132,51 @@ - {{ videoState.videoFile?.name }} - 更换 + {{ store.videoFile?.name }} + 更换 - - + + + + {{ store.stepLabel }} + {{ store.progress }}% + + + + + {{ store.error }} + 重试 + + + + 任务已提交成功 + 重新生成 + + - {{ isPipelineBusy ? pipelineStateLabel + '...' : '生成数字人视频' }} + {{ store.isBusy ? store.stepLabel + '...' : '生成数字人视频' }} - - - 任务已提交成功 - 重新生成 - @@ -207,90 +206,85 @@ - + @@ -160,20 +235,64 @@ onMounted(async () => { - - + + - 查看全部 + 共 {{ recordsPagination.total }} 条记录 - - - - - - - 暂无最近活动记录 - - + + + + + + + + + + + + + + {{ item.reason || getBizTypeName(item.bizType) }} + + {{ getStatusInfo(item.status).text }} + + + + + + {{ formatRecordTime(item.createTime) }} + {{ getBizTypeName(item.bizType) }} + + + + + {{ item.type === 'increase' ? '+' : '-' }}{{ Math.abs(item.pointAmount) }} + + + + + + + + 暂无积分记录 + + @@ -382,4 +501,82 @@ onMounted(async () => { color: #52c41a; margin-right: 12px; } + +/* Point Record List */ +.activity-card { + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03); +} + +.record-count { + color: var(--color-text-secondary); + font-size: 13px; +} + +.point-record-list { + max-height: 400px; + overflow-y: auto; +} + +.record-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; +} + +.record-icon.increase { + background: rgba(82, 196, 26, 0.1); + color: #52c41a; +} + +.record-icon.decrease { + background: rgba(255, 77, 79, 0.1); + color: #ff4d4f; +} + +.record-title { + display: flex; + align-items: center; + gap: 8px; +} + +.record-reason { + font-weight: 500; + color: var(--color-text); +} + +.record-desc { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--color-text-secondary); +} + +.record-biz-type { + padding: 1px 6px; + background: rgba(0, 0, 0, 0.04); + border-radius: 4px; +} + +.record-amount { + font-size: 18px; + font-weight: 600; +} + +.record-amount.increase { + color: #52c41a; +} + +.record-amount.decrease { + color: #ff4d4f; +} + +.record-pagination { + margin-top: 16px; + text-align: center; +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/AppPointRecordController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/AppPointRecordController.java new file mode 100644 index 0000000000..776b4aa24e --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/AppPointRecordController.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.tik.muye.pointrecord; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.tik.muye.pointrecord.dal.PointRecordDO; +import cn.iocoder.yudao.module.tik.muye.pointrecord.service.PointRecordService; +import cn.iocoder.yudao.module.tik.muye.pointrecord.vo.PointRecordPageReqVO; +import cn.iocoder.yudao.module.tik.muye.pointrecord.vo.PointRecordRespVO; +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.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; + +/** + * 用户 App - 积分记录 + */ +@Tag(name = "用户 App - 积分记录") +@RestController +@RequestMapping("/api/tik/point-record") +@Validated +public class AppPointRecordController { + + @Resource + private PointRecordService pointRecordService; + + @GetMapping("/page") + @Operation(summary = "获取当前用户积分记录分页") + public CommonResult> getPointRecordPage(@Valid PointRecordPageReqVO pageReqVO) { + // 强制使用当前登录用户ID,防止查询其他用户数据 + pageReqVO.setUserId(SecurityFrameworkUtils.getLoginUserId()); + PageResult pageResult = pointRecordService.getPointRecordPage(pageReqVO); + return success(toBean(pageResult, PointRecordRespVO.class)); + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/vo/PointRecordRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/vo/PointRecordRespVO.java index 5b2e9e7667..c725c6ac8d 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/vo/PointRecordRespVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/pointrecord/vo/PointRecordRespVO.java @@ -52,6 +52,10 @@ public class PointRecordRespVO { @ExcelProperty("备注") private String remark; + @Schema(description = "状态:pending-预扣 confirmed-已确认 canceled-已取消", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("状态") + private String status; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) @ExcelProperty("创建时间") private LocalDateTime createTime; diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java index c0a8689924..d7a03133a0 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java @@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncResultRespVO; import cn.iocoder.yudao.module.tik.kling.service.KlingService; import cn.iocoder.yudao.module.tik.kling.dto.KlingLipSyncQueryResponse; import cn.iocoder.yudao.module.tik.kling.vo.response.KlingLipSyncVideoVO; +import cn.iocoder.yudao.module.tik.muye.points.service.PointsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; @@ -35,6 +36,7 @@ public class LatentsyncPollingService { private final LatentsyncService latentsyncService; private final StringRedisTemplate stringRedisTemplate; private final KlingService klingService; + private final PointsService pointsService; // ========== 常量 ========== private static final String REDIS_POLLING_PREFIX = "latentsync:polling:"; @@ -218,6 +220,10 @@ public class LatentsyncPollingService { @Transactional(rollbackFor = Exception.class) private void completeTask(Long taskId, String videoUrl, String requestId) { try { + // 1. 先查询任务获取预扣记录ID + TikDigitalHumanTaskDO task = TenantUtils.executeIgnore(() -> taskMapper.selectById(taskId)); + + // 2. 更新任务状态 TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); updateObj.setId(taskId); updateObj.setStatus("SUCCESS"); @@ -228,7 +234,17 @@ public class LatentsyncPollingService { TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj)); - // 缓存结果 + // 3. 确认预扣(任务成功,实际扣费) + if (task != null && task.getPendingRecordId() != null) { + try { + pointsService.confirmPendingDeduct(task.getPendingRecordId()); + log.info("[completeTask][任务({})成功,确认扣费,预扣记录ID({})]", taskId, task.getPendingRecordId()); + } catch (Exception e) { + log.error("[completeTask][确认扣费失败,taskId={},recordId={}]", taskId, task.getPendingRecordId(), e); + } + } + + // 4. 缓存结果 String resultKey = REDIS_RESULT_PREFIX + taskId; stringRedisTemplate.opsForValue().set(resultKey, videoUrl, RESULT_CACHE_TIME); @@ -248,6 +264,10 @@ public class LatentsyncPollingService { @Transactional(rollbackFor = Exception.class) private void markTaskFailed(Long taskId, String errorMessage) { try { + // 1. 先查询任务获取预扣记录ID + TikDigitalHumanTaskDO task = TenantUtils.executeIgnore(() -> taskMapper.selectById(taskId)); + + // 2. 更新任务状态 TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); updateObj.setId(taskId); updateObj.setStatus("FAILED"); @@ -256,6 +276,16 @@ public class LatentsyncPollingService { TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj)); + // 3. 取消预扣(任务失败,不扣费) + if (task != null && task.getPendingRecordId() != null) { + try { + pointsService.cancelPendingDeduct(task.getPendingRecordId()); + log.info("[markTaskFailed][任务({})失败,取消预扣,预扣记录ID({})]", taskId, task.getPendingRecordId()); + } catch (Exception e) { + log.error("[markTaskFailed][取消预扣失败,taskId={},recordId={}]", taskId, task.getPendingRecordId(), e); + } + } + log.warn("[markTaskFailed][任务失败][taskId={}, error={}]", taskId, errorMessage); } catch (Exception e) { log.error("[markTaskFailed][标记任务失败失败][taskId={}]", taskId, e); @@ -266,6 +296,10 @@ public class LatentsyncPollingService { * 更新任务状态 */ private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress, String errorMessage) { + // 1. 先查询任务获取预扣记录ID + TikDigitalHumanTaskDO task = TenantUtils.executeIgnore(() -> taskMapper.selectById(taskId)); + + // 2. 更新任务状态 TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); updateObj.setId(taskId); updateObj.setStatus(status); @@ -274,11 +308,29 @@ public class LatentsyncPollingService { if ("SUCCESS".equals(status)) { updateObj.setFinishTime(LocalDateTime.now()); + // 确认预扣(任务成功) + if (task != null && task.getPendingRecordId() != null) { + try { + pointsService.confirmPendingDeduct(task.getPendingRecordId()); + log.info("[updateTaskStatus][任务({})成功,确认扣费]", taskId); + } catch (Exception e) { + log.error("[updateTaskStatus][确认扣费失败,taskId={}]", taskId, e); + } + } } else if ("PROCESSING".equals(status)) { updateObj.setStartTime(LocalDateTime.now()); } else if ("FAILED".equals(status)) { updateObj.setErrorMessage(errorMessage); updateObj.setFinishTime(LocalDateTime.now()); + // 取消预扣(任务失败) + if (task != null && task.getPendingRecordId() != null) { + try { + pointsService.cancelPendingDeduct(task.getPendingRecordId()); + log.info("[updateTaskStatus][任务({})失败,取消预扣]", taskId); + } catch (Exception e) { + log.error("[updateTaskStatus][取消预扣失败,taskId={}]", taskId, e); + } + } } TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj)); 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 3bddba7831..15f125f1cd 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 @@ -391,10 +391,11 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { transcriptionText = reqVO.getTranscriptionText(); } - String finalText = determineSynthesisText( - transcriptionText, - reqVO.getInputText(), - false); + // transcriptionText 仅用于提高克隆质量,不拼接到合成文本 + String finalText = reqVO.getInputText(); + if (StrUtil.isBlank(finalText)) { + throw exception(VOICE_TTS_FAILED, "请提供需要合成的文本内容"); + } String cacheKey = buildCacheKey(SYNTH_CACHE_PREFIX, voiceId, @@ -444,7 +445,7 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { log.error("[synthesizeVoice][积分扣减失败: {}]", e.getMessage()); } - // 【安全方案】不暴露OSS链接,直接返回Base64编码的音频数据 + // 不暴露OSS链接,直接返回Base64编码的音频数据 String audioBase64 = Base64.getEncoder().encodeToString(ttsResult.getAudio()); log.info("[synthesizeVoice][合成成功,配音编号({}),voiceId({}),format({}),audioSize={}]", voiceConfigId, finalVoiceId, format, ttsResult.getAudio().length); @@ -617,9 +618,6 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { /** * 从URL中提取原始URL(去除查询参数和锚点) - * - * @param url 可能包含查询参数的URL - * @return 原始URL(去除查询参数和锚点) */ private String extractRawUrl(String url) { if (StrUtil.isBlank(url)) { @@ -627,10 +625,8 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { } try { java.net.URL urlObj = new java.net.URL(url); - // 只使用协议、主机、路径部分,忽略查询参数和锚点 return urlObj.getProtocol() + "://" + urlObj.getHost() + urlObj.getPath(); } catch (Exception e) { - // 如果URL解析失败,使用简单方式去除查询参数 return url.split("\\?")[0].split("#")[0]; } } @@ -644,18 +640,15 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { String instruction, String audioFormat, Integer sampleRate) { - // 构建标识符:优先使用voiceId,如果没有则使用fileUrl的稳定部分(去除查询参数) String identifier; if (StrUtil.isNotBlank(voiceId)) { identifier = voiceId; } else if (StrUtil.isNotBlank(fileUrl)) { - // 对于fileUrl,提取稳定部分(去除预签名URL的查询参数,避免缓存key不稳定) identifier = extractRawUrl(fileUrl); } else { identifier = "no-voice"; } - // 获取默认配置 String defaultFormat = getDefaultFormat(); Integer defaultSampleRate = getDefaultSampleRate(); @@ -667,8 +660,7 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { instruction, StrUtil.blankToDefault(audioFormat, defaultFormat), sampleRate != null ? sampleRate : defaultSampleRate); - String hash = cn.hutool.crypto.SecureUtil.sha256(payload); - return prefix + hash; + return prefix + cn.hutool.crypto.SecureUtil.sha256(payload); } private PreviewCacheEntry getPreviewCache(String key) {
{{ videoState.videoFile?.name }}
{{ store.videoFile?.name }}
暂无最近活动记录
暂无积分记录