From 18e5c47a06fc31f2c786d91a088a168348710b32 Mon Sep 17 00:00:00 2001 From: Tyke Chen <190473011+chentyke@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:21:05 +0800 Subject: [PATCH] fix(manager): align frontend and backend field contracts --- .../modules/timbre/dto/TimbreDataDTO.java | 4 +- .../modules/timbre/entity/TimbreEntity.java | 6 +- .../service/impl/TimbreServiceImpl.java | 3 + .../modules/timbre/vo/TimbreDetailsVO.java | 2 +- .../service/impl/TimbreServiceImplTest.java | 74 ++++++++++++ main/manager-mobile/package.json | 2 +- main/manager-mobile/src/api/agent/agent.ts | 18 ++- main/manager-mobile/src/api/agent/types.ts | 8 ++ main/manager-mobile/src/api/device/types.ts | 2 +- .../agent/components/voicePreviewUtils.mjs | 42 +++++++ .../components/voicePreviewUtils.test.mjs | 50 ++++++++ main/manager-mobile/src/pages/agent/edit.vue | 112 +++++++++++++----- .../src/pages/device/deviceTimeUtils.mjs | 14 +++ .../src/pages/device/deviceTimeUtils.test.mjs | 15 +++ .../manager-mobile/src/pages/device/index.vue | 9 +- .../src/apis/module/correctWord.js | 2 +- main/manager-web/src/apis/module/timbre.js | 3 +- .../src/views/AddressBookManagement.vue | 73 ++++++++---- .../tests/frontendContract.test.mjs | 63 ++++++++++ .../manager-web/tests/timbreContract.test.mjs | 17 +++ 20 files changed, 455 insertions(+), 64 deletions(-) create mode 100644 main/manager-mobile/src/pages/agent/components/voicePreviewUtils.mjs create mode 100644 main/manager-mobile/src/pages/agent/components/voicePreviewUtils.test.mjs create mode 100644 main/manager-mobile/src/pages/device/deviceTimeUtils.mjs create mode 100644 main/manager-mobile/src/pages/device/deviceTimeUtils.test.mjs create mode 100644 main/manager-web/tests/frontendContract.test.mjs create mode 100644 main/manager-web/tests/timbreContract.test.mjs diff --git a/main/manager-api/src/main/java/xiaozhi/modules/timbre/dto/TimbreDataDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/timbre/dto/TimbreDataDTO.java index 202c4eaf..3f43b532 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/timbre/dto/TimbreDataDTO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/timbre/dto/TimbreDataDTO.java @@ -34,7 +34,7 @@ public class TimbreDataDTO { @Schema(description = "排序") @Min(value = 0, message = "{sort.number}") - private long sort; + private Long sort; @Schema(description = "对应 TTS 模型主键") @NotBlank(message = "{timbre.ttsModelId.require}") @@ -46,4 +46,4 @@ public class TimbreDataDTO { @Schema(description = "音频播放地址") private String voiceDemo; -} \ No newline at end of file +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/timbre/entity/TimbreEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/timbre/entity/TimbreEntity.java index 508c3f32..d7b192a3 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/timbre/entity/TimbreEntity.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/timbre/entity/TimbreEntity.java @@ -3,6 +3,7 @@ package xiaozhi.modules.timbre.entity; import java.util.Date; import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; @@ -41,7 +42,8 @@ public class TimbreEntity { private String referenceText; @Schema(description = "排序") - private long sort; + @TableField(updateStrategy = FieldStrategy.NOT_NULL) + private Long sort; @Schema(description = "对应 TTS 模型主键") private String ttsModelId; @@ -68,4 +70,4 @@ public class TimbreEntity { @TableField(fill = FieldFill.INSERT) private Date createDate; -} \ No newline at end of file +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImpl.java index de6c7267..38c639a2 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImpl.java @@ -99,6 +99,9 @@ public class TimbreServiceImpl extends BaseServiceImpl @Transactional(rollbackFor = Exception.class) public void save(TimbreDataDTO dto) { isTtsModelId(dto.getTtsModelId()); + if (dto.getSort() == null) { + dto.setSort(0L); + } TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class); baseDao.insert(timbreEntity); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/timbre/vo/TimbreDetailsVO.java b/main/manager-api/src/main/java/xiaozhi/modules/timbre/vo/TimbreDetailsVO.java index 2cae1a53..be32b439 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/timbre/vo/TimbreDetailsVO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/timbre/vo/TimbreDetailsVO.java @@ -32,7 +32,7 @@ public class TimbreDetailsVO implements Serializable { private String referenceText; @Schema(description = "排序") - private long sort; + private Long sort; @Schema(description = "对应 TTS 模型主键") private String ttsModelId; diff --git a/main/manager-api/src/test/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImplTest.java b/main/manager-api/src/test/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImplTest.java index 3e5eaa81..985bc886 100644 --- a/main/manager-api/src/test/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImplTest.java +++ b/main/manager-api/src/test/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImplTest.java @@ -2,16 +2,20 @@ package xiaozhi.modules.timbre.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; import xiaozhi.common.redis.RedisUtils; import xiaozhi.modules.timbre.dao.TimbreDao; +import xiaozhi.modules.timbre.dto.TimbreDataDTO; import xiaozhi.modules.timbre.entity.TimbreEntity; +import xiaozhi.modules.timbre.vo.TimbreDetailsVO; import xiaozhi.modules.voiceclone.dao.VoiceCloneDao; import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity; @@ -54,4 +58,74 @@ class TimbreServiceImplTest { assertNull(service.getDefaultLanguageById("voice-id")); } + + @Test + void updateLeavesSortOutOfTheUpdateWhenRequestOmitsIt() { + TimbreDao timbreDao = mock(TimbreDao.class); + RedisUtils redisUtils = mock(RedisUtils.class); + TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, mock(VoiceCloneDao.class), redisUtils); + ReflectionTestUtils.setField(service, "baseDao", timbreDao); + + TimbreDataDTO dto = validTimbreData(); + service.update("voice-id", dto); + + verify(timbreDao, never()).selectById("voice-id"); + verify(timbreDao).updateById(argThat((TimbreEntity entity) -> + "voice-id".equals(entity.getId()) && entity.getSort() == null)); + verify(redisUtils).delete("timbre:details:voice-id"); + } + + @Test + void updateUsesExplicitSortWithoutLoadingExistingTimbre() { + TimbreDao timbreDao = mock(TimbreDao.class); + TimbreServiceImpl service = new TimbreServiceImpl( + timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class)); + ReflectionTestUtils.setField(service, "baseDao", timbreDao); + TimbreDataDTO dto = validTimbreData(); + dto.setSort(0L); + + service.update("voice-id", dto); + + verify(timbreDao, never()).selectById("voice-id"); + verify(timbreDao).updateById(argThat((TimbreEntity entity) -> entity.getSort() == 0L)); + } + + @Test + void saveDefaultsOmittedSortToZero() { + TimbreDao timbreDao = mock(TimbreDao.class); + TimbreServiceImpl service = new TimbreServiceImpl( + timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class)); + ReflectionTestUtils.setField(service, "baseDao", timbreDao); + + service.save(validTimbreData()); + + verify(timbreDao).insert(argThat((TimbreEntity entity) -> + "测试音色".equals(entity.getName()) && entity.getSort() == 0L)); + } + + @Test + void getSupportsLegacyRowsWithNullSort() { + TimbreDao timbreDao = mock(TimbreDao.class); + RedisUtils redisUtils = mock(RedisUtils.class); + TimbreServiceImpl service = new TimbreServiceImpl( + timbreDao, mock(VoiceCloneDao.class), redisUtils); + ReflectionTestUtils.setField(service, "baseDao", timbreDao); + TimbreEntity entity = new TimbreEntity(); + entity.setId("voice-id"); + entity.setSort(null); + when(timbreDao.selectById("voice-id")).thenReturn(entity); + + TimbreDetailsVO details = service.get("voice-id"); + + assertNull(details.getSort()); + } + + private TimbreDataDTO validTimbreData() { + TimbreDataDTO dto = new TimbreDataDTO(); + dto.setLanguages("中文"); + dto.setName("测试音色"); + dto.setTtsModelId("TTS_Test"); + dto.setTtsVoice("test-voice"); + return dto; + } } diff --git a/main/manager-mobile/package.json b/main/manager-mobile/package.json index 80aaee8e..7c85f0a6 100644 --- a/main/manager-mobile/package.json +++ b/main/manager-mobile/package.json @@ -69,7 +69,7 @@ "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", "build:quickapp-webview-union": "uni build -p quickapp-webview-union", "type-check": "vue-tsc --noEmit", - "test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs", + "test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs src/pages/agent/components/voicePreviewUtils.test.mjs src/pages/device/deviceTimeUtils.test.mjs", "openapi-ts-request": "openapi-ts", "prepare": "git init && husky", "lint": "eslint", diff --git a/main/manager-mobile/src/api/agent/agent.ts b/main/manager-mobile/src/api/agent/agent.ts index e436bbb1..bfaca352 100644 --- a/main/manager-mobile/src/api/agent/agent.ts +++ b/main/manager-mobile/src/api/agent/agent.ts @@ -8,6 +8,7 @@ import type { ModelOption, PageData, RoleTemplate, + TtsVoice, } from './types' import { http } from '@/http/request/alova' @@ -89,7 +90,7 @@ export function deleteAgent(id: string) { // 获取TTS音色列表 export function getTTSVoices(ttsModelId: string, voiceName: string = '') { - return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, { + return http.Get(`/models/${ttsModelId}/voices`, { params: { voiceName, }, @@ -214,7 +215,7 @@ export function updateAgentTags(agentId: string, data) { // 获取所有语言 export function getAllLanguage(modelId: string) { - return http.Get<{ id: string, name: string, languages: string }[]>(`/models/${modelId}/voices`, { + return http.Get(`/models/${modelId}/voices`, { meta: { ignoreAuth: false, toast: false, @@ -225,6 +226,19 @@ export function getAllLanguage(modelId: string) { }) } +/** + * 获取克隆音色的临时播放ID + * @param cloneId 克隆音色记录ID + */ +export function getVoiceCloneAudioId(cloneId: string) { + return http.Post(`/voiceClone/audio/${cloneId}`, {}, { + meta: { + ignoreAuth: false, + toast: false, + }, + }) +} + // 获取智能体历史版本列表 export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) { return http.Get>(`/agent/${agentId}/snapshots`, { diff --git a/main/manager-mobile/src/api/agent/types.ts b/main/manager-mobile/src/api/agent/types.ts index 990098f9..440f5070 100644 --- a/main/manager-mobile/src/api/agent/types.ts +++ b/main/manager-mobile/src/api/agent/types.ts @@ -114,6 +114,14 @@ export interface CorrectWordFile { wordCount?: number } +export interface TtsVoice { + id: string + name: string + voiceDemo?: string | null + languages?: string | null + isClone?: boolean | null +} + // 角色模板数据类型 export interface RoleTemplate { id: string diff --git a/main/manager-mobile/src/api/device/types.ts b/main/manager-mobile/src/api/device/types.ts index 4b6165c5..8b0de4e1 100644 --- a/main/manager-mobile/src/api/device/types.ts +++ b/main/manager-mobile/src/api/device/types.ts @@ -7,7 +7,7 @@ export interface Device { id: string userId: string macAddress: string - lastConnectedAt: string + lastConnectedAtTimestamp: string | null autoUpdate: number board: string alias?: string diff --git a/main/manager-mobile/src/pages/agent/components/voicePreviewUtils.mjs b/main/manager-mobile/src/pages/agent/components/voicePreviewUtils.mjs new file mode 100644 index 00000000..94f3cdd9 --- /dev/null +++ b/main/manager-mobile/src/pages/agent/components/voicePreviewUtils.mjs @@ -0,0 +1,42 @@ +/** @param {Record} voice */ +export function hasVoicePreview(voice) { + return Boolean(voice?.isClone || voice?.voiceDemo || voice?.voice_demo) +} + +export function createVoicePreviewRequestGate() { + let sequence = 0 + + return { + begin() { + sequence += 1 + return sequence + }, + invalidate() { + sequence += 1 + }, + isCurrent(requestId) { + return requestId === sequence + }, + } +} + +/** + * @param {{ id: string, isClone?: boolean, voiceDemo?: string | null }} voice + * @param {(cloneId: string) => Promise} getCloneAudioId + * @param {string} baseUrl + */ +export async function resolveVoicePreviewUrl(voice, getCloneAudioId, baseUrl) { + if (!voice?.isClone) { + return typeof voice?.voiceDemo === 'string' ? voice.voiceDemo : '' + } + if (!voice.id) { + return '' + } + + const uuid = await getCloneAudioId(voice.id) + if (!uuid) { + return '' + } + + return `${baseUrl.replace(/\/+$/, '')}/voiceClone/play/${encodeURIComponent(uuid)}` +} diff --git a/main/manager-mobile/src/pages/agent/components/voicePreviewUtils.test.mjs b/main/manager-mobile/src/pages/agent/components/voicePreviewUtils.test.mjs new file mode 100644 index 00000000..d0284d5f --- /dev/null +++ b/main/manager-mobile/src/pages/agent/components/voicePreviewUtils.test.mjs @@ -0,0 +1,50 @@ +/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './voicePreviewUtils.mjs' + +test('keeps normal voice previews on their direct URL', async () => { + let cloneRequestCount = 0 + const url = await resolveVoicePreviewUrl({ + id: 'normal-voice', + isClone: false, + voiceDemo: 'https://cdn.example.test/normal.wav', + }, async () => { + cloneRequestCount += 1 + return 'unused' + }, 'https://api.example.test') + + assert.equal(url, 'https://cdn.example.test/normal.wav') + assert.equal(cloneRequestCount, 0) +}) + +test('uses the clone record id to obtain and construct a temporary play URL', async () => { + let requestedCloneId = '' + const url = await resolveVoicePreviewUrl({ + id: 'clone-record-id', + isClone: true, + voiceDemo: 'provider-speaker-id-must-not-be-played', + }, async (cloneId) => { + requestedCloneId = cloneId + return 'temporary uuid' + }, 'https://api.example.test/') + + assert.equal(requestedCloneId, 'clone-record-id') + assert.equal(url, 'https://api.example.test/voiceClone/play/temporary%20uuid') +}) + +test('shows a preview control for cloned voices even without voiceDemo', () => { + assert.equal(hasVoicePreview({ id: 'clone-record-id', isClone: true }), true) + assert.equal(hasVoicePreview({ id: 'normal-voice', isClone: false, voiceDemo: '' }), false) +}) + +test('invalidates an older request when the same voice is cancelled and retried', () => { + const gate = createVoicePreviewRequestGate() + const firstRequest = gate.begin() + + gate.invalidate() + const retryRequest = gate.begin() + + assert.equal(gate.isCurrent(firstRequest), false) + assert.equal(gate.isCurrent(retryRequest), true) +}) diff --git a/main/manager-mobile/src/pages/agent/edit.vue b/main/manager-mobile/src/pages/agent/edit.vue index e3cf4e91..8e4f65b7 100644 --- a/main/manager-mobile/src/pages/agent/edit.vue +++ b/main/manager-mobile/src/pages/agent/edit.vue @@ -1,12 +1,14 @@