mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-24 08:03:53 +08:00
fix: harden agent snapshot restore flow
This commit is contained in:
+6
-2
@@ -6,17 +6,20 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotRestoreDTO;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentSnapshotService;
|
||||
import xiaozhi.modules.agent.vo.AgentSnapshotVO;
|
||||
@@ -51,9 +54,10 @@ public class AgentSnapshotController {
|
||||
@PostMapping("/{snapshotId}/restore")
|
||||
@Operation(summary = "恢复智能体快照")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId) {
|
||||
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId,
|
||||
@RequestBody @Valid AgentSnapshotRestoreDTO request) {
|
||||
checkPermission(agentId);
|
||||
agentSnapshotService.restoreSnapshot(agentId, snapshotId);
|
||||
agentSnapshotService.restoreSnapshot(agentId, snapshotId, request.getCurrentStateToken());
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
|
||||
@@ -43,4 +43,13 @@ public interface AgentDao extends BaseDao<AgentEntity> {
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
AgentEntity selectByIdForUpdate(@Param("agentId") String agentId);
|
||||
|
||||
/**
|
||||
* 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。
|
||||
* 不更新所属用户、创建信息等不属于快照的字段。
|
||||
*
|
||||
* @param agent 已应用目标快照的智能体
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int updateSnapshotFields(@Param("agent") AgentEntity agent);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package xiaozhi.modules.agent.dao;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@@ -17,4 +19,11 @@ public interface AgentSnapshotDao extends BaseDao<AgentSnapshotEntity> {
|
||||
int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot);
|
||||
|
||||
int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit);
|
||||
|
||||
List<AgentSnapshotEntity> selectLegacyRedactionBatch(@Param("afterId") String afterId,
|
||||
@Param("limit") int limit,
|
||||
@Param("targetRedactionVersion") int targetRedactionVersion);
|
||||
|
||||
int updateRedactedSnapshots(@Param("snapshots") List<AgentSnapshotEntity> snapshots,
|
||||
@Param("redactionVersion") int redactionVersion);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "智能体快照恢复请求")
|
||||
public class AgentSnapshotRestoreDTO {
|
||||
@NotBlank
|
||||
@Schema(description = "预览时由服务端生成的当前配置状态指纹")
|
||||
private String currentStateToken;
|
||||
}
|
||||
@@ -47,4 +47,7 @@ public class AgentSnapshotEntity {
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
@Schema(description = "快照数据脱敏规则版本")
|
||||
private Integer redactionVersion;
|
||||
}
|
||||
|
||||
+3
-1
@@ -13,11 +13,13 @@ public interface AgentSnapshotService extends BaseService<AgentSnapshotEntity> {
|
||||
|
||||
AgentSnapshotVO getSnapshot(String agentId, String snapshotId);
|
||||
|
||||
void restoreSnapshot(String agentId, String snapshotId);
|
||||
void restoreSnapshot(String agentId, String snapshotId, String currentStateToken);
|
||||
|
||||
void deleteSnapshot(String agentId, String snapshotId);
|
||||
|
||||
Integer getCurrentVersionNo(String agentId);
|
||||
|
||||
void deleteByAgentId(String agentId);
|
||||
|
||||
long redactLegacySnapshots();
|
||||
}
|
||||
|
||||
+3
-2
@@ -311,8 +311,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
|
||||
// 锁定后查询现有实体和关联配置
|
||||
AgentEntity existingEntity = this.getAgentById(agentId);
|
||||
if (createSnapshot && agentSnapshotService.getCurrentVersionNo(agentId) == 0) {
|
||||
agentSnapshotService.createSnapshot(agentId, "initial");
|
||||
if (createSnapshot) {
|
||||
int currentVersionNo = agentSnapshotService.getCurrentVersionNo(agentId);
|
||||
agentSnapshotService.createSnapshot(agentId, currentVersionNo == 0 ? "initial" : "current");
|
||||
}
|
||||
|
||||
// 只更新提供的非空字段
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package xiaozhi.modules.agent.service.impl;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.modules.agent.service.AgentSnapshotService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentSnapshotRedactionRunner implements SmartInitializingSingleton {
|
||||
static final long ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS = 5_000;
|
||||
static final long ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS = 15_000;
|
||||
|
||||
private final AgentSnapshotService agentSnapshotService;
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
redactAndReport("startup");
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS,
|
||||
fixedDelay = ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS)
|
||||
public void redactLateRollingDeploymentWrites() {
|
||||
redactAndReport("rolling-deployment");
|
||||
}
|
||||
|
||||
private void redactAndReport(String trigger) {
|
||||
long startedAt = System.nanoTime();
|
||||
try {
|
||||
long migrated = agentSnapshotService.redactLegacySnapshots();
|
||||
long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
|
||||
if (migrated > 0) {
|
||||
log.warn("Agent snapshot legacy redaction trigger={} migrated={} durationMs={}. Rotate credentials "
|
||||
+ "that may have appeared in historical snapshot URLs, cookies, sessions, or structured "
|
||||
+ "headers.", trigger, migrated, durationMillis);
|
||||
} else if ("startup".equals(trigger)) {
|
||||
log.info("Agent snapshot legacy redaction startup pass completed: migrated=0 durationMs={}; "
|
||||
+ "rolling-deployment compensation starts after {} ms and repeats every {} ms.",
|
||||
durationMillis, ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS,
|
||||
ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
if ("startup".equals(trigger)) {
|
||||
log.error("Agent snapshot legacy redaction failed during startup; blocking application startup "
|
||||
+ "before it can accept traffic.", exception);
|
||||
} else {
|
||||
log.error("Agent snapshot legacy redaction failed during rolling-deployment compensation; the "
|
||||
+ "scheduler will retry on its next run.", exception);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1062
-78
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,17 @@ public class AgentSnapshotVO {
|
||||
private List<String> changedFields;
|
||||
private List<String> fieldOrder;
|
||||
private String source;
|
||||
@Schema(description = "恢复来源快照ID,仅恢复前自动备份版本有值")
|
||||
@Schema(description = "恢复来源快照ID,仅恢复结果版本有值")
|
||||
private String restoreFromSnapshotId;
|
||||
@Schema(description = "恢复来源版本号,仅恢复前自动备份版本有值")
|
||||
@Schema(description = "恢复来源版本号,仅恢复结果版本有值")
|
||||
private Integer restoreFromVersionNo;
|
||||
@Schema(description = "创建者,表示触发本次快照写入的操作人")
|
||||
private Long creator;
|
||||
private Date createdAt;
|
||||
private AgentSnapshotDataDTO snapshotData;
|
||||
private AgentSnapshotDataDTO afterSnapshotData;
|
||||
@Schema(description = "恢复预览对应的脱敏当前配置,仅详情接口有值")
|
||||
private AgentSnapshotDataDTO currentSnapshotData;
|
||||
@Schema(description = "恢复预览对应的当前配置状态指纹,仅详情接口有值")
|
||||
private String currentStateToken;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- changeset tykechen:202607101200
|
||||
ALTER TABLE `ai_agent_snapshot`
|
||||
ADD COLUMN `redaction_version` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '快照脱敏规则版本' AFTER `created_at`,
|
||||
ADD INDEX `idx_snapshot_redaction_version_id` (`redaction_version`, `id`);
|
||||
|
||||
-- rollback ALTER TABLE `ai_agent_snapshot` DROP INDEX `idx_snapshot_redaction_version_id`, DROP COLUMN `redaction_version`;
|
||||
@@ -704,3 +704,10 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607071530.sql
|
||||
- changeSet:
|
||||
id: 202607101200
|
||||
author: tykechen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607101200.sql
|
||||
|
||||
@@ -89,4 +89,32 @@
|
||||
FOR UPDATE
|
||||
</select>
|
||||
|
||||
<update id="updateSnapshotFields">
|
||||
UPDATE ai_agent
|
||||
SET agent_code = #{agent.agentCode,jdbcType=VARCHAR},
|
||||
agent_name = #{agent.agentName,jdbcType=VARCHAR},
|
||||
asr_model_id = #{agent.asrModelId,jdbcType=VARCHAR},
|
||||
vad_model_id = #{agent.vadModelId,jdbcType=VARCHAR},
|
||||
llm_model_id = #{agent.llmModelId,jdbcType=VARCHAR},
|
||||
slm_model_id = #{agent.slmModelId,jdbcType=VARCHAR},
|
||||
vllm_model_id = #{agent.vllmModelId,jdbcType=VARCHAR},
|
||||
tts_model_id = #{agent.ttsModelId,jdbcType=VARCHAR},
|
||||
tts_voice_id = #{agent.ttsVoiceId,jdbcType=VARCHAR},
|
||||
tts_language = #{agent.ttsLanguage,jdbcType=VARCHAR},
|
||||
tts_volume = #{agent.ttsVolume,jdbcType=INTEGER},
|
||||
tts_rate = #{agent.ttsRate,jdbcType=INTEGER},
|
||||
tts_pitch = #{agent.ttsPitch,jdbcType=INTEGER},
|
||||
mem_model_id = #{agent.memModelId,jdbcType=VARCHAR},
|
||||
intent_model_id = #{agent.intentModelId,jdbcType=VARCHAR},
|
||||
chat_history_conf = #{agent.chatHistoryConf,jdbcType=INTEGER},
|
||||
system_prompt = #{agent.systemPrompt,jdbcType=LONGVARCHAR},
|
||||
summary_memory = #{agent.summaryMemory,jdbcType=LONGVARCHAR},
|
||||
lang_code = #{agent.langCode,jdbcType=VARCHAR},
|
||||
language = #{agent.language,jdbcType=VARCHAR},
|
||||
sort = #{agent.sort,jdbcType=INTEGER},
|
||||
updater = #{agent.updater,jdbcType=BIGINT},
|
||||
updated_at = #{agent.updatedAt,jdbcType=TIMESTAMP}
|
||||
WHERE id = #{agent.id}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
restore_from_snapshot_id AS restoreFromSnapshotId,
|
||||
restore_from_version_no AS restoreFromVersionNo,
|
||||
creator,
|
||||
created_at AS createdAt
|
||||
created_at AS createdAt,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
ORDER BY version_no DESC
|
||||
@@ -37,7 +38,8 @@
|
||||
restore_from_snapshot_id AS restoreFromSnapshotId,
|
||||
restore_from_version_no AS restoreFromVersionNo,
|
||||
creator,
|
||||
created_at AS createdAt
|
||||
created_at AS createdAt,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
AND version_no > #{versionNo}
|
||||
@@ -57,7 +59,8 @@
|
||||
restore_from_snapshot_id,
|
||||
restore_from_version_no,
|
||||
creator,
|
||||
created_at
|
||||
created_at,
|
||||
redaction_version
|
||||
)
|
||||
SELECT #{snapshot.id},
|
||||
#{snapshot.agentId},
|
||||
@@ -69,7 +72,8 @@
|
||||
#{snapshot.restoreFromSnapshotId},
|
||||
#{snapshot.restoreFromVersionNo},
|
||||
#{snapshot.creator},
|
||||
#{snapshot.createdAt}
|
||||
#{snapshot.createdAt},
|
||||
#{snapshot.redactionVersion}
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{snapshot.agentId}
|
||||
</insert>
|
||||
@@ -89,4 +93,31 @@
|
||||
)
|
||||
</delete>
|
||||
|
||||
<select id="selectLegacyRedactionBatch" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
|
||||
SELECT id,
|
||||
snapshot_data AS snapshotData,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE redaction_version < #{targetRedactionVersion}
|
||||
AND (#{afterId} IS NULL OR id > #{afterId})
|
||||
ORDER BY id ASC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<update id="updateRedactedSnapshots">
|
||||
UPDATE ai_agent_snapshot
|
||||
SET snapshot_data = CASE id
|
||||
<foreach collection="snapshots" item="snapshot">
|
||||
WHEN #{snapshot.id} THEN #{snapshot.snapshotData}
|
||||
</foreach>
|
||||
ELSE snapshot_data
|
||||
END,
|
||||
redaction_version = #{redactionVersion}
|
||||
WHERE redaction_version < #{redactionVersion}
|
||||
AND id IN
|
||||
<foreach collection="snapshots" item="snapshot" open="(" separator="," close=")">
|
||||
#{snapshot.id}
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package xiaozhi.modules.agent.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
import xiaozhi.modules.agent.service.AgentSnapshotService;
|
||||
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class AgentSnapshotRedactionRunnerTest {
|
||||
|
||||
@Test
|
||||
void startupRedactionRunsSynchronouslyAndReportsCompensationWindow(CapturedOutput output) {
|
||||
assertTrue(SmartInitializingSingleton.class.isAssignableFrom(AgentSnapshotRedactionRunner.class));
|
||||
AgentSnapshotService service = mock(AgentSnapshotService.class);
|
||||
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
|
||||
when(service.redactLegacySnapshots()).thenReturn(0L);
|
||||
|
||||
runner.afterSingletonsInstantiated();
|
||||
|
||||
verify(service).redactLegacySnapshots();
|
||||
assertTrue(output.getAll().contains("startup pass completed"));
|
||||
assertTrue(output.getAll().contains("starts after 5000 ms and repeats every 15000 ms"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupRedactionFailureIsLoggedAndPropagatedToKeepStartupFailClosed(CapturedOutput output) {
|
||||
AgentSnapshotService service = mock(AgentSnapshotService.class);
|
||||
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
|
||||
IllegalStateException failure = new IllegalStateException("database unavailable");
|
||||
when(service.redactLegacySnapshots()).thenThrow(failure);
|
||||
|
||||
IllegalStateException thrown = assertThrows(IllegalStateException.class,
|
||||
runner::afterSingletonsInstantiated);
|
||||
|
||||
assertSame(failure, thrown);
|
||||
assertTrue(output.getAll().contains("blocking application startup before it can accept traffic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollingDeploymentRedactionReportsTriggerCountAndCredentialRotation(CapturedOutput output) {
|
||||
AgentSnapshotService service = mock(AgentSnapshotService.class);
|
||||
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
|
||||
when(service.redactLegacySnapshots()).thenReturn(3L);
|
||||
|
||||
runner.redactLateRollingDeploymentWrites();
|
||||
|
||||
assertTrue(output.getAll().contains("trigger=rolling-deployment migrated=3"));
|
||||
assertTrue(output.getAll().contains("Rotate credentials"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollingDeploymentScheduleKeepsLegacyWriteExposureWindowShort() throws Exception {
|
||||
Method method = AgentSnapshotRedactionRunner.class.getMethod("redactLateRollingDeploymentWrites");
|
||||
Scheduled scheduled = method.getAnnotation(Scheduled.class);
|
||||
|
||||
assertNotNull(scheduled);
|
||||
assertEquals(5_000, scheduled.initialDelay());
|
||||
assertEquals(15_000, scheduled.fixedDelay());
|
||||
assertTrue(scheduled.initialDelay() <= 10_000);
|
||||
assertTrue(scheduled.fixedDelay() <= 30_000);
|
||||
}
|
||||
}
|
||||
+1382
-29
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user