diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java index 955c13dd..ee17f15f 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java @@ -10,7 +10,11 @@ import xiaozhi.modules.agent.entity.AgentSnapshotEntity; public interface AgentSnapshotDao extends BaseDao { Integer selectMaxVersionNo(@Param("agentId") String agentId); + AgentSnapshotEntity selectLatestSnapshot(@Param("agentId") String agentId); + AgentSnapshotEntity selectNextSnapshot(@Param("agentId") String agentId, @Param("versionNo") Integer versionNo); + int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot); + int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java index fe1d8e6e..f88c47ea 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java @@ -3,12 +3,11 @@ package xiaozhi.modules.agent.service; import xiaozhi.common.page.PageData; import xiaozhi.common.service.BaseService; import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; -import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.entity.AgentSnapshotEntity; import xiaozhi.modules.agent.vo.AgentSnapshotVO; public interface AgentSnapshotService extends BaseService { - void createSnapshot(String agentId, String source, AgentUpdateDTO pendingUpdate); + void createSnapshot(String agentId, String source); PageData page(String agentId, AgentSnapshotPageDTO params); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java index 75382ea7..3d6da990 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java @@ -311,15 +311,14 @@ public class AgentServiceImpl extends BaseServiceImpl imp // 锁定后查询现有实体和关联配置 AgentEntity existingEntity = this.getAgentById(agentId); - - if (createSnapshot) { - agentSnapshotService.createSnapshot(agentId, "config", dto); + if (createSnapshot && agentSnapshotService.getCurrentVersionNo(agentId) == 0) { + agentSnapshotService.createSnapshot(agentId, "initial"); } // 只更新提供的非空字段 - if (dto.getAgentName() != null) { - existingEntity.setAgentName(dto.getAgentName()); - } + if (dto.getAgentName() != null) { + existingEntity.setAgentName(dto.getAgentName()); + } if (dto.getAgentCode() != null) { existingEntity.setAgentCode(dto.getAgentCode()); } @@ -470,11 +469,14 @@ public class AgentServiceImpl extends BaseServiceImpl imp } boolean b = validateLLMIntentParams(existingEntity.getLlmModelId(), existingEntity.getIntentModelId()); - if (!b) { - throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); - } - this.updateById(existingEntity); - } + if (!b) { + throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); + } + this.updateById(existingEntity); + if (createSnapshot) { + agentSnapshotService.createSnapshot(agentId, "config"); + } + } /** * 验证大语言模型和意图识别的参数是否符合匹配 diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java index 28c9320a..3919b917 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java @@ -82,11 +82,20 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl changedFields = getChangedFields(snapshotData, pendingUpdate); - if (pendingUpdate != null && changedFields.isEmpty()) { + AgentSnapshotEntity previousSnapshot = agentSnapshotDao.selectLatestSnapshot(agentId); + List changedFields; + if (previousSnapshot == null) { + changedFields = List.of("initial"); + } else { + AgentSnapshotDataDTO previousData = JsonUtils.parseObject(previousSnapshot.getSnapshotData(), + AgentSnapshotDataDTO.class); + changedFields = getChangedFields(previousData, snapshotData); + } + if (changedFields.isEmpty()) { return; } @@ -96,7 +105,6 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl page(String agentId, AgentSnapshotPageDTO params) { - ensureInitialSnapshot(agentId); AgentSnapshotPageDTO pageParams = params == null ? new AgentSnapshotPageDTO() : params; Page page = new Page<>(pageParams.pageOrDefault(), pageParams.limitOrDefault()); page.addOrder(OrderItem.desc("version_no")); @@ -134,8 +142,9 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl changedFields = getChangedFields(currentData, restoreData); - insertSnapshot(agentId, currentAgent.getUserId(), "restore", currentData, changedFields, - snapshot.getId(), snapshot.getVersionNo()); + if (changedFields.isEmpty()) { + return; + } applyAgentFields(agent, restoreData); validateRestoreParams(agent); @@ -148,6 +157,8 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl changedFields, String restoreFromSnapshotId, Integer restoreFromVersionNo) { AgentSnapshotEntity entity = new AgentSnapshotEntity(); + entity.setId(UUID.randomUUID().toString().replace("-", "")); entity.setAgentId(agentId); entity.setUserId(userId); - entity.setVersionNo(allocateVersionNo(agentId)); entity.setSnapshotData(JsonUtils.toJsonString(redactSnapshotData(snapshotData))); entity.setChangedFields(JsonUtils.toJsonString(changedFields)); entity.setSource(StringUtils.defaultIfBlank(source, "config")); @@ -191,39 +202,23 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl 0) { - return; - } - AgentEntity agent = agentDao.selectByIdForUpdate(agentId); - if (agent == null) { + private void lockAgent(String agentId) { + if (agentDao.selectByIdForUpdate(agentId) == null) { throw new RenException(ErrorCode.AGENT_NOT_FOUND); } - maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId); - if (maxVersionNo != null && maxVersionNo > 0) { - return; - } - AgentInfoVO agentInfo = getAgentInfo(agentId); - insertSnapshot(agentId, agentInfo.getUserId(), "initial", buildSnapshotData(agentInfo), List.of("initial")); } private void pruneSnapshots(String agentId) { agentSnapshotDao.deleteOlderThanKeepLimit(agentId, MAX_SNAPSHOTS_PER_AGENT); } - private Integer allocateVersionNo(String agentId) { - Integer maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId); - if (maxVersionNo == null || maxVersionNo < 0) { - throw new RenException("快照版本号生成失败"); - } - return maxVersionNo + 1; - } - private AgentSnapshotEntity getSnapshotEntity(String agentId, String snapshotId) { AgentSnapshotEntity entity = selectById(snapshotId); if (entity == null || !Objects.equals(agentId, entity.getAgentId())) { @@ -434,7 +429,8 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl { if (key != null) { - result.put(String.valueOf(key), normalizeValue(value)); + String keyText = String.valueOf(key); + result.put(keyText, isSensitiveKey(keyText) ? SECRET_PLACEHOLDER : normalizeValue(value)); } }); return result; diff --git a/main/manager-api/src/main/resources/db/changelog/202607071530.sql b/main/manager-api/src/main/resources/db/changelog/202607071530.sql index 6d3460a6..be224228 100644 --- a/main/manager-api/src/main/resources/db/changelog/202607071530.sql +++ b/main/manager-api/src/main/resources/db/changelog/202607071530.sql @@ -1,17 +1,20 @@ -- liquibase formatted sql --- changeset xiaozhi:202607071530 +-- changeset tykechen:202607071530 CREATE TABLE IF NOT EXISTS `ai_agent_snapshot` ( `id` VARCHAR(32) NOT NULL COMMENT '快照ID', `agent_id` VARCHAR(32) NOT NULL COMMENT '智能体ID', `user_id` BIGINT DEFAULT NULL COMMENT '所属用户ID', `version_no` INT UNSIGNED NOT NULL COMMENT '版本号', - `snapshot_data` JSON NOT NULL COMMENT '快照数据', + `snapshot_data` JSON NOT NULL DEFAULT (JSON_OBJECT()) COMMENT '快照数据', `changed_fields` JSON DEFAULT NULL COMMENT '变更字段', `source` VARCHAR(32) DEFAULT 'config' COMMENT '快照来源', + `restore_from_snapshot_id` VARCHAR(32) DEFAULT NULL COMMENT '恢复来源快照ID', + `restore_from_version_no` INT UNSIGNED DEFAULT NULL COMMENT '恢复来源版本号', `creator` BIGINT DEFAULT NULL COMMENT '创建者', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_agent_version` (`agent_id`, `version_no`), - INDEX `idx_agent_created_at` (`agent_id`, `created_at`) + INDEX `idx_agent_created_at` (`agent_id`, `created_at`), + INDEX `idx_snapshot_user_created_at` (`user_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体配置快照表'; diff --git a/main/manager-api/src/main/resources/db/changelog/202607081150.sql b/main/manager-api/src/main/resources/db/changelog/202607081150.sql deleted file mode 100644 index b8496eb1..00000000 --- a/main/manager-api/src/main/resources/db/changelog/202607081150.sql +++ /dev/null @@ -1,7 +0,0 @@ --- liquibase formatted sql - --- changeset codex:202607081150 -ALTER TABLE `ai_agent_snapshot` - ADD COLUMN `restore_from_snapshot_id` VARCHAR(32) DEFAULT NULL COMMENT '恢复来源快照ID' AFTER `source`, - ADD COLUMN `restore_from_version_no` INT UNSIGNED DEFAULT NULL COMMENT '恢复来源版本号' AFTER `restore_from_snapshot_id`, - ADD INDEX `idx_snapshot_user_created_at` (`user_id`, `created_at`); diff --git a/main/manager-api/src/main/resources/db/changelog/202607081230.sql b/main/manager-api/src/main/resources/db/changelog/202607081230.sql deleted file mode 100644 index b62bb584..00000000 --- a/main/manager-api/src/main/resources/db/changelog/202607081230.sql +++ /dev/null @@ -1,5 +0,0 @@ --- liquibase formatted sql - --- changeset codex:202607081230 -ALTER TABLE `ai_agent_snapshot` - ALTER COLUMN `snapshot_data` SET DEFAULT (JSON_OBJECT()); diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index 4b8c323d..27ed05a3 100644 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -699,22 +699,8 @@ databaseChangeLog: path: classpath:db/changelog/202607011405.sql - changeSet: id: 202607071530 - author: Codex + author: tykechen changes: - sqlFile: encoding: utf8 path: classpath:db/changelog/202607071530.sql - - changeSet: - id: 202607081150 - author: Codex - changes: - - sqlFile: - encoding: utf8 - path: classpath:db/changelog/202607081150.sql - - changeSet: - id: 202607081230 - author: Codex - changes: - - sqlFile: - encoding: utf8 - path: classpath:db/changelog/202607081230.sql diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml b/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml index 7926cac7..ea5650c2 100644 --- a/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml +++ b/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml @@ -8,6 +8,24 @@ WHERE agent_id = #{agentId} + + + + INSERT INTO ai_agent_snapshot ( + id, + agent_id, + user_id, + version_no, + snapshot_data, + changed_fields, + source, + restore_from_snapshot_id, + restore_from_version_no, + creator, + created_at + ) + SELECT #{snapshot.id}, + #{snapshot.agentId}, + #{snapshot.userId}, + COALESCE(MAX(version_no), 0) + 1, + #{snapshot.snapshotData}, + #{snapshot.changedFields}, + #{snapshot.source}, + #{snapshot.restoreFromSnapshotId}, + #{snapshot.restoreFromVersionNo}, + #{snapshot.creator}, + #{snapshot.createdAt} + FROM ai_agent_snapshot + WHERE agent_id = #{snapshot.agentId} + + DELETE FROM ai_agent_snapshot WHERE agent_id = #{agentId} diff --git a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java index 2648145d..e3aad1b1 100644 --- a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java +++ b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java @@ -6,10 +6,11 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -25,6 +26,8 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; import xiaozhi.common.utils.JsonUtils; @@ -41,6 +44,7 @@ import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentSnapshotEntity; import xiaozhi.modules.agent.entity.AgentTagEntity; import xiaozhi.modules.agent.service.AgentContextProviderService; +import xiaozhi.modules.agent.service.AgentSnapshotService; import xiaozhi.modules.agent.vo.AgentSnapshotVO; import xiaozhi.modules.agent.vo.AgentInfoVO; import xiaozhi.modules.correctword.service.CorrectWordFileService; @@ -178,6 +182,40 @@ class AgentSnapshotServiceImplTest { assertFalse(roleConfig.contains("this.handleSaveAgentTags(agentId, tagNames)")); } + @Test + void firstAgentSaveKeepsInitialBaselineBeforePersistingNewState() { + AgentDao agentDao = mock(AgentDao.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotService snapshotService = mock(AgentSnapshotService.class); + AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null, + null, null, contextProviderService, null, correctWordFileService, snapshotService); + ReflectionTestUtils.setField(service, "baseDao", agentDao); + + String agentId = "agent-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + AgentInfoVO currentAgent = new AgentInfoVO(); + currentAgent.setId(agentId); + currentAgent.setAgentName("old-name"); + AgentUpdateDTO update = new AgentUpdateDTO(); + update.setAgentName("new-name"); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentAgent); + when(snapshotService.getCurrentVersionNo(agentId)).thenReturn(0); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(agentDao.updateById(any())).thenReturn(1); + + service.updateAgentById(agentId, update); + + InOrder inOrder = inOrder(agentDao, snapshotService); + inOrder.verify(snapshotService).createSnapshot(agentId, "initial"); + inOrder.verify(agentDao).updateById(argThat(agent -> "new-name".equals(agent.getAgentName()))); + inOrder.verify(snapshotService).createSnapshot(agentId, "config"); + } + @Test void agentSnapshotFieldAppliesRestorableAgentFields() { AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); @@ -218,40 +256,38 @@ class AgentSnapshotServiceImplTest { } @Test - void versionAllocationDoesNotDependOnSequenceTable() throws Exception { - String xml = Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml")); + void snapshotInsertAllocatesVersionInSingleSqlStatement() throws Exception { + String xml = normalizeWhitespace(Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml"))); String dao = Files.readString(Path.of("src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java")); - String createMigration = Files.readString(Path.of("src/main/resources/db/changelog/202607071530.sql")); - String master = Files.readString(Path.of("src/main/resources/db/changelog/db.changelog-master.yaml")); - assertFalse(xml.contains("ai_agent_snapshot_sequence")); + assertTrue(dao.contains("insertWithNextVersion")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("INSERT INTO ai_agent_snapshot")); + assertTrue(xml.contains("COALESCE(MAX(version_no), 0) + 1")); assertFalse(xml.contains("LAST_INSERT_ID")); - assertFalse(dao.contains("allocateVersionNo")); - assertFalse(dao.contains("selectLastInsertId")); - assertFalse(createMigration.contains("ai_agent_snapshot_sequence")); - assertFalse(master.contains("202607081430.sql")); + assertFalse(xml.contains("ai_agent_snapshot_sequence")); } @Test - void snapshotMigrationAddsRestoreTraceAndUserIndex() throws Exception { - String sql = Files.readString(Path.of("src/main/resources/db/changelog/202607081150.sql")); - String defaultSql = Files.readString(Path.of("src/main/resources/db/changelog/202607081230.sql")); + void snapshotMigrationIsMergedIntoSingleChangeLog() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/changelog/202607071530.sql")); String master = Files.readString(Path.of("src/main/resources/db/changelog/db.changelog-master.yaml")); assertTrue(sql.contains("restore_from_snapshot_id")); assertTrue(sql.contains("restore_from_version_no")); assertTrue(sql.contains("idx_snapshot_user_created_at")); - assertTrue(master.contains("202607081150.sql")); - assertTrue(defaultSql.contains("DEFAULT (JSON_OBJECT())")); - assertTrue(master.contains("202607081230.sql")); + assertTrue(sql.contains("DEFAULT (JSON_OBJECT())")); + assertTrue(master.contains("202607071530.sql")); + assertFalse(master.contains("202607081150.sql")); + assertFalse(master.contains("202607081230.sql")); } @Test void selectNextSnapshotLoadsRestoreTraceColumns() throws Exception { - String xml = Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml")); + String xml = normalizeWhitespace(Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml"))); assertTrue(xml.contains("restore_from_snapshot_id AS restoreFromSnapshotId")); - assertTrue(xml.contains("restore_from_version_no AS restoreFromVersionNo")); + assertTrue(xml.contains("restore_from_version_no AS restoreFromVersionNo")); } @Test @@ -296,7 +332,35 @@ class AgentSnapshotServiceImplTest { } @Test - void pageCreatesInitialSnapshotWhenAgentHasNoHistory() { + void pageDoesNotCreateInitialSnapshotWhenAgentHasNoHistory() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, null, null, null, + null, null, null, null, null); + + when(snapshotDao.selectPage(any(), any())).thenReturn(new Page(1, 10)); + + service.page("agent-id", new AgentSnapshotPageDTO()); + + verify(agentDao, never()).selectByIdForUpdate(any()); + verify(snapshotDao, never()).selectMaxVersionNo(any()); + verify(snapshotDao, never()).insertWithNextVersion(any()); + verify(snapshotDao, never()).deleteOlderThanKeepLimit(any(), anyInt()); + } + + @Test + void getCurrentVersionNoReturnsPersistedMaxVersion() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, + null, null, null, null, null); + + when(snapshotDao.selectMaxVersionNo("agent-id")).thenReturn(7); + + assertEquals(7, service.getCurrentVersionNo("agent-id")); + } + + @Test + void createSnapshotStoresCurrentStateAsInitialVersionWhenHistoryIsEmpty() { AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); AgentDao agentDao = mock(AgentDao.class); AgentTagDao tagDao = mock(AgentTagDao.class); @@ -311,17 +375,19 @@ class AgentSnapshotServiceImplTest { agentInfo.setId("agent-id"); agentInfo.setUserId(7L); agentInfo.setAgentName("agent"); - when(snapshotDao.selectMaxVersionNo("agent-id")).thenReturn(0, 0, 0); when(agentDao.selectByIdForUpdate("agent-id")).thenReturn(lockedAgent); when(agentDao.selectAgentInfoById("agent-id")).thenReturn(agentInfo); + when(snapshotDao.selectLatestSnapshot("agent-id")).thenReturn(null); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); when(correctWordFileService.getAgentCorrectWordFileIds("agent-id")).thenReturn(List.of()); + when(contextProviderService.getByAgentId("agent-id")).thenReturn(null); when(tagDao.selectByAgentId("agent-id")).thenReturn(List.of()); - when(snapshotDao.selectPage(any(), any())).thenReturn(new Page(1, 10)); - service.page("agent-id", new AgentSnapshotPageDTO()); + service.createSnapshot("agent-id", "config"); - verify(snapshotDao, times(3)).selectMaxVersionNo("agent-id"); - verify(snapshotDao).insert(argThat(snapshot -> Integer.valueOf(1).equals(snapshot.getVersionNo()))); + verify(snapshotDao).insertWithNextVersion(argThat(snapshot -> "config".equals(snapshot.getSource()) + && snapshot.getVersionNo() == null + && snapshot.getChangedFields().contains("initial"))); verify(snapshotDao).deleteOlderThanKeepLimit("agent-id", 100); } @@ -377,4 +443,8 @@ class AgentSnapshotServiceImplTest { data.setContextProviders(List.of(provider)); return data; } + + private String normalizeWhitespace(String value) { + return value.replaceAll("\\s+", " ").trim(); + } } diff --git a/main/manager-web/src/components/AgentSnapshotDialog.vue b/main/manager-web/src/components/AgentSnapshotDialog.vue index 9b09a00a..af3e6e91 100644 --- a/main/manager-web/src/components/AgentSnapshotDialog.vue +++ b/main/manager-web/src/components/AgentSnapshotDialog.vue @@ -26,7 +26,7 @@
#{{ scope.row.versionNo }} @@ -130,7 +130,59 @@ class="diff-item" >
{{ item.label }}
-
+
+
+
{{ item.valueTitle }}
+
+
+
+
+ +
+
+ {{ functionState.name }} +
+
+
+ +
+
+ {{ item.valueTitle }} + {{ functionState.status }} +
+
+
+ {{ param.label }} + {{ param.value }} +
+
+
{{ functionState.note }}
+
+
+
+
{{ $t('agentSnapshot.emptyValue') }}
+
+
+
+
{{ item.afterText }}
+
+
+
+
{ + const value = this.getFieldValue(snapshotData, field); + const displayType = this.displayType(field); + return { + field, + label: this.fieldLabel(field), + beforeValue: null, + afterValue: value, + beforeText: "", + afterText: this.formatDisplayValue(field, value, snapshotData), + displayType, + single: true, + valueTitle: this.$t("agentSnapshot.configValue"), + functionStates: displayType === "functions" + ? this.buildSingleFunctionStates(value) + : [], + complex: this.isComplexValue(value) + }; + }); + }, buildDiffs(beforeData, afterData, changedFields, options = {}) { const fields = this.resolveDiffFields(beforeData, afterData, changedFields, options.fieldOrder); return fields.map((field) => { @@ -625,22 +713,19 @@ export default { this.deletingSnapshotId = null; }, snapshotRowKey(row) { - if (row?.isCurrentVersion) { - return "current-version"; - } return row?.id || `${row?.versionNo || ""}-${row?.createdAt || ""}`; }, snapshotRowClassName({ row }) { - return row?.isCurrentVersion ? "current-version-row" : ""; + return row?.isLatestSnapshot ? "current-version-row" : ""; }, canViewSnapshot(row) { - return !!row && (row.isCurrentVersion || row.previousSnapshotId || row.previousVersionNo); + return !!row?.id; }, canRestoreSnapshot(row) { - return !!row && !row.isCurrentVersion; + return !!row && !row.isLatestSnapshot; }, canDeleteSnapshot(row) { - return !!row && !row.isCurrentVersion && !row.isLatestSnapshot; + return !!row && !row.isLatestSnapshot; }, fetchSnapshots(options = {}) { if (!this.agentId) { @@ -673,24 +758,12 @@ export default { if (!this.historyAnchorVersionNo && historyRows[0]?.versionNo) { this.historyAnchorVersionNo = Number(historyRows[0].versionNo); } - const currentRow = this.buildCurrentVersionRow(historyRows[0]); - const historyOffset = currentRow ? 1 : 0; this.historyTotal = data.data?.total || 0; - this.total = this.historyTotal + historyOffset; - if (this.page === 1) { - const firstPageRows = historyRows.slice(0, currentRow ? this.limit - 1 : this.limit); - this.snapshots = [ - ...(currentRow ? [currentRow] : []), - ...this.applyPreviousChangedFields(firstPageRows, historyRows) - ]; - } else { - const historyStart = Math.max(displayStart - historyOffset, 0); - const historyEnd = Math.max(displayEnd - historyOffset, 0); - this.snapshots = this.applyPreviousChangedFields( - historyRows.slice(historyStart, historyEnd), - historyRows - ); - } + this.total = this.historyTotal; + this.snapshots = this.applyPreviousChangedFields( + historyRows.slice(displayStart, displayEnd), + historyRows + ); } else { this.$message.error(data.msg || this.$t("agentSnapshot.fetchFailed")); } @@ -827,8 +900,7 @@ export default { this.deletingSnapshotId = null; if (data.code === 0) { this.$message.success(this.$t("agentSnapshot.deleteSuccess")); - const savedRowsOnPage = this.snapshots.filter((item) => !item.isCurrentVersion); - if (savedRowsOnPage.length <= 1 && this.page > 1) { + if (this.snapshots.length <= 1 && this.page > 1) { this.page -= 1; } this.fetchSnapshots(); @@ -851,9 +923,9 @@ export default { return propVersionNo; } - const currentRow = this.snapshots.find((item) => item.isCurrentVersion); - if (currentRow?.versionNo) { - return currentRow.versionNo; + const latestRow = this.snapshots.find((item) => item.isLatestSnapshot) || this.snapshots[0]; + if (latestRow?.versionNo) { + return latestRow.versionNo; } return null; @@ -907,10 +979,10 @@ export default { applyPreviousChangedFields(rows, sourceRows) { return rows.map((row) => { const versionNo = Number(row.versionNo); - if (!Number.isFinite(versionNo) || versionNo <= 1) { + if (!Number.isFinite(versionNo)) { return { ...row, - changedFields: [] + changedFields: row.changedFields || [] }; } const previousRow = this.findPreviousSnapshotRow(row, sourceRows); @@ -918,7 +990,7 @@ export default { ...row, previousSnapshotId: previousRow?.id || null, previousVersionNo: previousRow?.versionNo || null, - changedFields: previousRow?.changedFields || [] + changedFields: row.changedFields || [] }; }); }, @@ -928,78 +1000,83 @@ export default { return null; } return (sourceRows || []).find((item) => { - return !item.isCurrentVersion && Number(item.versionNo) < versionNo; + return Number(item.versionNo) < versionNo; }) || null; }, - buildCurrentVersionRow(latestSnapshot) { - const latestVersionNo = Number(latestSnapshot?.versionNo) || 0; - const propVersionNo = Number(this.currentVersionNo); - if (!Number.isFinite(propVersionNo) || propVersionNo <= latestVersionNo) { - return null; - } - return { - id: "__current__", - agentId: this.agentId, - versionNo: propVersionNo, - source: "current", - createdAt: latestSnapshot?.createdAt || null, - changedFields: latestSnapshot?.changedFields || [], - fieldOrder: latestSnapshot?.fieldOrder || [], - isCurrentVersion: true, - previousVersionNo: latestVersionNo || null, - previousSnapshotId: latestSnapshot?.id || null - }; - }, buildVersionDiffSnapshot(row) { - if (row?.isCurrentVersion) { - return this.buildCurrentVersionDiffSnapshot(row); - } return this.buildSavedVersionDiffSnapshot(row); }, - buildCurrentVersionDiffSnapshot(row) { - const currentVersionNo = Number(row.versionNo); - return Promise.all([ - this.fetchPreviousSnapshotDetail(row), - this.fetchCurrentAgentData() - ]).then(([previousSnapshot, currentData]) => ({ - ...row, - versionNo: currentVersionNo, - changedFields: previousSnapshot.changedFields || [], - snapshotData: previousSnapshot.snapshotData || {}, - afterSnapshotData: currentData, - beforeVersionNo: previousSnapshot.versionNo, - afterVersionNo: currentVersionNo, - fieldOrder: previousSnapshot.fieldOrder || row.fieldOrder || [] - })); - }, buildSavedVersionDiffSnapshot(row) { const versionNo = Number(row.versionNo); - return Promise.all([ - this.fetchSnapshotDetail(row.id), - this.fetchPreviousSnapshotDetail(row) - ]).then(([selectedSnapshot, previousSnapshot]) => ({ + const displayVersionNo = Number.isFinite(versionNo) ? versionNo : row.versionNo; + return this.fetchSnapshotDetail(row.id).then((selectedSnapshot) => { + if (!this.hasPreviousSnapshot(row)) { + return this.buildSingleSnapshotDetail(selectedSnapshot, row, displayVersionNo); + } + + return this.fetchPreviousSnapshotDetail(row).then((previousSnapshot) => { + if (!previousSnapshot?.id) { + return this.buildSingleSnapshotDetail(selectedSnapshot, row, displayVersionNo); + } + const adjacentVersion = this.isAdjacentVersion(previousSnapshot.versionNo, displayVersionNo); + return { + ...selectedSnapshot, + versionNo: displayVersionNo, + changedFields: adjacentVersion ? (selectedSnapshot.changedFields || row.changedFields || []) : [], + snapshotData: previousSnapshot.snapshotData || {}, + afterSnapshotData: selectedSnapshot.snapshotData || {}, + beforeVersionNo: previousSnapshot.versionNo, + afterVersionNo: displayVersionNo, + fieldOrder: selectedSnapshot.fieldOrder || previousSnapshot.fieldOrder || row.fieldOrder || [] + }; + }); + }); + }, + buildSingleSnapshotDetail(selectedSnapshot, row, displayVersionNo) { + return { ...selectedSnapshot, - versionNo, - changedFields: previousSnapshot.changedFields || [], - snapshotData: previousSnapshot.snapshotData || {}, + versionNo: displayVersionNo, + isSingleSnapshot: true, + changedFields: selectedSnapshot.changedFields || row.changedFields || [], + snapshotData: selectedSnapshot.snapshotData || {}, afterSnapshotData: selectedSnapshot.snapshotData || {}, - beforeVersionNo: previousSnapshot.versionNo, - afterVersionNo: versionNo, - fieldOrder: selectedSnapshot.fieldOrder || previousSnapshot.fieldOrder || row.fieldOrder || [] - })); + afterVersionNo: displayVersionNo, + fieldOrder: selectedSnapshot.fieldOrder || row.fieldOrder || [] + }; + }, + isAdjacentVersion(beforeVersionNo, afterVersionNo) { + const beforeVersion = Number(beforeVersionNo); + const afterVersion = Number(afterVersionNo); + return Number.isFinite(beforeVersion) && Number.isFinite(afterVersion) && afterVersion - beforeVersion === 1; + }, + hasPreviousSnapshot(row) { + const versionNo = Number(row?.versionNo); + return !!( + row?.previousSnapshotId || + row?.previousVersionNo || + (Number.isFinite(versionNo) && versionNo > 1) + ); }, fetchPreviousSnapshotDetail(row) { if (row?.previousSnapshotId) { - return this.fetchSnapshotDetail(row.previousSnapshotId); + return this.fetchSnapshotDetail(row.previousSnapshotId) + .catch(() => this.fetchPreviousSnapshotDetailWithoutId(row)); } + return this.fetchPreviousSnapshotDetailWithoutId(row); + }, + fetchPreviousSnapshotDetailWithoutId(row) { if (row?.previousVersionNo) { - return this.fetchSnapshotDetailByVersion(row.previousVersionNo); + return this.fetchSnapshotDetailByVersion(row.previousVersionNo) + .catch(() => this.fetchNearestPreviousSnapshotDetail(row)); } + return this.fetchNearestPreviousSnapshotDetail(row); + }, + fetchNearestPreviousSnapshotDetail(row) { return this.fetchSnapshotRowBeforeVersion(row?.versionNo).then((previousRow) => { if (!previousRow?.id) { - return Promise.reject(new Error("previous snapshot not found")); + return null; } - return this.fetchSnapshotDetail(previousRow.id); + return this.fetchSnapshotDetail(previousRow.id).catch(() => null); }); }, fetchSnapshotDetailByVersion(versionNo) { @@ -1017,7 +1094,7 @@ export default { } const cached = this.snapshots.find((item) => { - return !item.isCurrentVersion && Number(item.versionNo) === version; + return Number(item.versionNo) === version; }); if (cached) { return Promise.resolve(cached); @@ -1388,6 +1465,15 @@ export default { note: params.length ? "" : this.$t("agentSnapshot.function.noParams") }; }, + buildSingleFunctionStates(value) { + return this.normalizeFunctions(value).map((func) => ({ + pluginId: func.pluginId, + name: func.name, + status: this.$t("agentSnapshot.function.enabled"), + params: func.params, + note: func.params.length ? "" : this.$t("agentSnapshot.function.noParams") + })); + }, changedFunctionParamKeys(pluginId, beforeParams, afterParams) { const safeBeforeParams = beforeParams || {}; const safeAfterParams = afterParams || {}; @@ -1447,17 +1533,19 @@ export default { if (value === null || value === undefined || value === "") { return this.$t("agentSnapshot.emptyValue"); } - if (typeof value === "object") { - return JSON.stringify(value); + const displayValue = this.localizedSnapshotDisplayValue(value); + if (typeof displayValue === "object") { + return JSON.stringify(displayValue); } - return String(value); + return String(displayValue); }, renderMarkdownValue(value) { - if (value === null || value === undefined || String(value).trim() === "") { + const displayValue = this.localizedSnapshotDisplayValue(value); + if (displayValue === null || displayValue === undefined || String(displayValue).trim() === "") { return `${this.escapeHtml(this.$t("agentSnapshot.emptyValue"))}`; } - const lines = String(value).replace(/\r\n/g, "\n").split("\n"); + const lines = String(displayValue).replace(/\r\n/g, "\n").split("\n"); const html = []; let paragraph = []; const listStack = []; @@ -1616,7 +1704,29 @@ export default { return data[field]; }, isSameValue(left, right) { - return JSON.stringify(this.normalizeValue(left)) === JSON.stringify(this.normalizeValue(right)); + return this.isEquivalentValue(this.normalizeValue(left), this.normalizeValue(right)); + }, + isEquivalentValue(left, right) { + if (left === SNAPSHOT_SECRET_REDACTED || right === SNAPSHOT_SECRET_REDACTED) { + return true; + } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + return left.every((item, index) => this.isEquivalentValue(item, right[index])); + } + if (this.isPlainObject(left) || this.isPlainObject(right)) { + if (!this.isPlainObject(left) || !this.isPlainObject(right)) { + return false; + } + const keys = Array.from(new Set([ + ...Object.keys(left), + ...Object.keys(right) + ])); + return keys.every((key) => this.isEquivalentValue(left[key], right[key])); + } + return left === right; }, normalizeValue(value) { if (Array.isArray(value)) { @@ -1630,6 +1740,9 @@ export default { } return value === undefined ? null : value; }, + isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); + }, formatDisplayValue(field, value, data = {}) { if (MODEL_FIELD_TYPES[field]) { return this.modelDisplayName(value); @@ -1663,13 +1776,29 @@ export default { if (Array.isArray(value) && value.length === 0) { return this.$t("agentSnapshot.emptyValue"); } - if (Array.isArray(value) && value.every((item) => this.isPrimitiveValue(item))) { - return value.join(", "); + const displayValue = this.localizedSnapshotDisplayValue(value); + if (Array.isArray(displayValue) && displayValue.every((item) => this.isPrimitiveValue(item))) { + return displayValue.join(", "); } - if (this.isComplexValue(value)) { - return JSON.stringify(value, null, 2); + if (this.isComplexValue(displayValue)) { + return JSON.stringify(displayValue, null, 2); } - return String(value); + return String(displayValue); + }, + localizedSnapshotDisplayValue(value) { + if (value === SNAPSHOT_SECRET_REDACTED) { + return this.$t("agentSnapshot.secretRedacted"); + } + if (Array.isArray(value)) { + return value.map((item) => this.localizedSnapshotDisplayValue(item)); + } + if (value && typeof value === "object") { + return Object.keys(value).reduce((result, key) => { + result[key] = this.localizedSnapshotDisplayValue(value[key]); + return result; + }, {}); + } + return value; }, isPrimitiveValue(value) { return value === null || ["string", "number", "boolean"].includes(typeof value); @@ -1821,6 +1950,10 @@ export default { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.diff-values.is-single { + grid-template-columns: minmax(0, 1fr); +} + .diff-pane { min-width: 0; padding: 12px 14px; @@ -1831,6 +1964,10 @@ export default { border-left: 1px solid #e9e9e9; } +.diff-values.is-single .diff-after { + border-left: 0; +} + .diff-pane-title { margin-bottom: 8px; color: #3d4566; @@ -1883,6 +2020,10 @@ export default { background: #fff; } +.function-change-view.is-single { + padding: 0; +} + .function-toggle-list { display: flex; flex-direction: column; @@ -1912,6 +2053,10 @@ export default { background: #fbfdff; } +.function-toggle-card.is-single .function-state-pane { + margin-top: 10px; +} + .function-toggle-head { display: flex; gap: 10px; diff --git a/main/manager-web/src/i18n/de.js b/main/manager-web/src/i18n/de.js index 53824099..eeb2ae4b 100644 --- a/main/manager-web/src/i18n/de.js +++ b/main/manager-web/src/i18n/de.js @@ -831,6 +831,8 @@ export default { 'agentSnapshot.before': 'Vorher', 'agentSnapshot.after': 'Nachher', 'agentSnapshot.emptyValue': 'Keine', + 'agentSnapshot.secretRedacted': 'Geheimer Schlüssel maskiert', + 'agentSnapshot.configValue': 'Konfigurationswert', 'agentSnapshot.noChangedContent': 'Keine anzeigbaren Änderungen', 'agentSnapshot.noFunctionChange': 'Keine Funktionsänderungen', 'agentSnapshot.beforeChange': 'Vor der Änderung', diff --git a/main/manager-web/src/i18n/en.js b/main/manager-web/src/i18n/en.js index 721faf0d..8e258c60 100644 --- a/main/manager-web/src/i18n/en.js +++ b/main/manager-web/src/i18n/en.js @@ -881,6 +881,8 @@ export default { 'agentSnapshot.before': 'Before', 'agentSnapshot.after': 'After', 'agentSnapshot.emptyValue': 'None', + 'agentSnapshot.secretRedacted': 'Secret redacted', + 'agentSnapshot.configValue': 'Config Value', 'agentSnapshot.noChangedContent': 'No changes to display', 'agentSnapshot.noFunctionChange': 'No function changes', 'agentSnapshot.beforeChange': 'Before Change', diff --git a/main/manager-web/src/i18n/pt_BR.js b/main/manager-web/src/i18n/pt_BR.js index 8d28d128..a53ba976 100644 --- a/main/manager-web/src/i18n/pt_BR.js +++ b/main/manager-web/src/i18n/pt_BR.js @@ -831,6 +831,8 @@ export default { 'agentSnapshot.before': 'Antes', 'agentSnapshot.after': 'Depois', 'agentSnapshot.emptyValue': 'Nenhum', + 'agentSnapshot.secretRedacted': 'Chave secreta mascarada', + 'agentSnapshot.configValue': 'Valor da configuração', 'agentSnapshot.noChangedContent': 'Nenhuma alteração para exibir', 'agentSnapshot.noFunctionChange': 'Sem alterações de função', 'agentSnapshot.beforeChange': 'Antes da Alteração', diff --git a/main/manager-web/src/i18n/vi.js b/main/manager-web/src/i18n/vi.js index 3fbd3cab..a2565f9f 100644 --- a/main/manager-web/src/i18n/vi.js +++ b/main/manager-web/src/i18n/vi.js @@ -831,6 +831,8 @@ export default { 'agentSnapshot.before': 'Trước', 'agentSnapshot.after': 'Sau', 'agentSnapshot.emptyValue': 'Không có', + 'agentSnapshot.secretRedacted': 'Khóa bí mật đã được ẩn', + 'agentSnapshot.configValue': 'Giá trị cấu hình', 'agentSnapshot.noChangedContent': 'Không có thay đổi để hiển thị', 'agentSnapshot.noFunctionChange': 'Không có thay đổi chức năng', 'agentSnapshot.beforeChange': 'Trước thay đổi', diff --git a/main/manager-web/src/i18n/zh_CN.js b/main/manager-web/src/i18n/zh_CN.js index 3211e3ff..3a583cea 100644 --- a/main/manager-web/src/i18n/zh_CN.js +++ b/main/manager-web/src/i18n/zh_CN.js @@ -881,6 +881,8 @@ export default { 'agentSnapshot.before': '变化前', 'agentSnapshot.after': '变化后', 'agentSnapshot.emptyValue': '无', + 'agentSnapshot.secretRedacted': '密钥已脱敏', + 'agentSnapshot.configValue': '配置值', 'agentSnapshot.noChangedContent': '暂无可展示的变更内容', 'agentSnapshot.noFunctionChange': '功能配置无变化', 'agentSnapshot.beforeChange': '变化前', diff --git a/main/manager-web/src/i18n/zh_TW.js b/main/manager-web/src/i18n/zh_TW.js index edd0a7f1..d2c1f8e5 100644 --- a/main/manager-web/src/i18n/zh_TW.js +++ b/main/manager-web/src/i18n/zh_TW.js @@ -831,6 +831,8 @@ export default { 'agentSnapshot.before': '之前', 'agentSnapshot.after': '之後', 'agentSnapshot.emptyValue': '無', + 'agentSnapshot.secretRedacted': '密鑰已脫敏', + 'agentSnapshot.configValue': '配置值', 'agentSnapshot.noChangedContent': '暫無可展示的變更內容', 'agentSnapshot.noFunctionChange': '功能配置無變化', 'agentSnapshot.beforeChange': '變化前',