Junjie
2026-04-27 73a8fcbaaec22b972c7f52d9477271a246d17926
feat: add guarded auto tune apply and rollback service
7个文件已添加
1个文件已修改
1274 ■■■■■ 已修改文件
src/main/java/com/zy/ai/domain/autotune/AutoTuneJobStatus.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneRuleDefinition.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneTargetType.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneTriggerType.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/AutoTuneApplyService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AutoTuneApplyServiceImpl.java 790 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/RuntimeConfigServiceImpl.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java 285 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneJobStatus.java
New file
@@ -0,0 +1,20 @@
package com.zy.ai.domain.autotune;
public enum AutoTuneJobStatus {
    RUNNING("running"),
    SUCCESS("success"),
    PARTIAL_SUCCESS("partial_success"),
    NO_CHANGE("no_change"),
    FAILED("failed"),
    REJECTED("rejected");
    private final String code;
    AutoTuneJobStatus(String code) {
        this.code = code;
    }
    public String getCode() {
        return code;
    }
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneRuleDefinition.java
New file
@@ -0,0 +1,107 @@
package com.zy.ai.domain.autotune;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public final class AutoTuneRuleDefinition {
    private static final Map<String, Rule> RULE_MAP = buildRuleMap();
    private AutoTuneRuleDefinition() {
    }
    public static Rule findRule(String targetType, String targetKey) {
        if (targetType == null || targetKey == null) {
            return null;
        }
        return RULE_MAP.get(buildKey(targetType.trim(), targetKey.trim()));
    }
    public static Map<String, Rule> rules() {
        return RULE_MAP;
    }
    private static Map<String, Rule> buildRuleMap() {
        LinkedHashMap<String, Rule> ruleMap = new LinkedHashMap<>();
        add(ruleMap, AutoTuneTargetType.SYS_CONFIG, "aiAutoTuneIntervalMinutes", 5, 60, 5, 30, false);
        add(ruleMap, AutoTuneTargetType.SYS_CONFIG, "conveyorStationTaskLimit", 5, 200, 5, 20, false);
        add(ruleMap, AutoTuneTargetType.SYS_CONFIG, "crnOutBatchRunningLimit", 1, 20, 2, 20, false);
        add(ruleMap, AutoTuneTargetType.STATION, "outTaskLimit", 0, null, 1, 10, true);
        add(ruleMap, AutoTuneTargetType.CRN, "maxOutTask", 0, 10, 1, 10, false);
        add(ruleMap, AutoTuneTargetType.CRN, "maxInTask", 0, 10, 1, 10, false);
        add(ruleMap, AutoTuneTargetType.DUAL_CRN, "maxOutTask", 0, 10, 1, 10, false);
        add(ruleMap, AutoTuneTargetType.DUAL_CRN, "maxInTask", 0, 10, 1, 10, false);
        return Collections.unmodifiableMap(ruleMap);
    }
    private static void add(LinkedHashMap<String, Rule> ruleMap,
                            AutoTuneTargetType targetType,
                            String targetKey,
                            Integer minValue,
                            Integer maxValue,
                            int maxStep,
                            int cooldownMinutes,
                            boolean dynamicMaxValue) {
        Rule rule = new Rule(targetType, targetKey, minValue, maxValue, maxStep, cooldownMinutes, dynamicMaxValue);
        ruleMap.put(buildKey(targetType.getCode(), targetKey), rule);
    }
    private static String buildKey(String targetType, String targetKey) {
        return targetType + ":" + targetKey;
    }
    public static final class Rule {
        private final AutoTuneTargetType targetType;
        private final String targetKey;
        private final Integer minValue;
        private final Integer maxValue;
        private final int maxStep;
        private final int cooldownMinutes;
        private final boolean dynamicMaxValue;
        private Rule(AutoTuneTargetType targetType,
                     String targetKey,
                     Integer minValue,
                     Integer maxValue,
                     int maxStep,
                     int cooldownMinutes,
                     boolean dynamicMaxValue) {
            this.targetType = targetType;
            this.targetKey = targetKey;
            this.minValue = minValue;
            this.maxValue = maxValue;
            this.maxStep = maxStep;
            this.cooldownMinutes = cooldownMinutes;
            this.dynamicMaxValue = dynamicMaxValue;
        }
        public AutoTuneTargetType getTargetType() {
            return targetType;
        }
        public String getTargetKey() {
            return targetKey;
        }
        public Integer getMinValue() {
            return minValue;
        }
        public Integer getMaxValue() {
            return maxValue;
        }
        public int getMaxStep() {
            return maxStep;
        }
        public int getCooldownMinutes() {
            return cooldownMinutes;
        }
        public boolean isDynamicMaxValue() {
            return dynamicMaxValue;
        }
    }
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneTargetType.java
New file
@@ -0,0 +1,30 @@
package com.zy.ai.domain.autotune;
public enum AutoTuneTargetType {
    SYS_CONFIG("sys_config"),
    STATION("station"),
    CRN("crn"),
    DUAL_CRN("dual_crn");
    private final String code;
    AutoTuneTargetType(String code) {
        this.code = code;
    }
    public String getCode() {
        return code;
    }
    public static AutoTuneTargetType fromCode(String code) {
        if (code == null) {
            return null;
        }
        for (AutoTuneTargetType targetType : values()) {
            if (targetType.code.equals(code.trim())) {
                return targetType;
            }
        }
        return null;
    }
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneTriggerType.java
New file
@@ -0,0 +1,30 @@
package com.zy.ai.domain.autotune;
public enum AutoTuneTriggerType {
    AUTO("auto"),
    MANUAL("manual"),
    ROLLBACK("rollback");
    private final String code;
    AutoTuneTriggerType(String code) {
        this.code = code;
    }
    public String getCode() {
        return code;
    }
    public static String normalize(String code) {
        if (code == null || code.trim().isEmpty()) {
            return MANUAL.code;
        }
        String normalizedCode = code.trim();
        for (AutoTuneTriggerType triggerType : values()) {
            if (triggerType.code.equals(normalizedCode)) {
                return normalizedCode;
            }
        }
        return MANUAL.code;
    }
}
src/main/java/com/zy/ai/service/AutoTuneApplyService.java
New file
@@ -0,0 +1,11 @@
package com.zy.ai.service;
import com.zy.ai.domain.autotune.AutoTuneApplyRequest;
import com.zy.ai.domain.autotune.AutoTuneApplyResult;
public interface AutoTuneApplyService {
    AutoTuneApplyResult apply(AutoTuneApplyRequest request);
    AutoTuneApplyResult rollbackLastSuccessfulJob(String reason);
}
src/main/java/com/zy/ai/service/impl/AutoTuneApplyServiceImpl.java
New file
@@ -0,0 +1,790 @@
package com.zy.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.zy.ai.domain.autotune.AutoTuneApplyRequest;
import com.zy.ai.domain.autotune.AutoTuneApplyResult;
import com.zy.ai.domain.autotune.AutoTuneChangeCommand;
import com.zy.ai.domain.autotune.AutoTuneJobStatus;
import com.zy.ai.domain.autotune.AutoTuneRuleDefinition;
import com.zy.ai.domain.autotune.AutoTuneTargetType;
import com.zy.ai.domain.autotune.AutoTuneTriggerType;
import com.zy.ai.entity.AiAutoTuneChange;
import com.zy.ai.entity.AiAutoTuneJob;
import com.zy.ai.service.AiAutoTuneChangeService;
import com.zy.ai.service.AiAutoTuneJobService;
import com.zy.ai.service.AutoTuneApplyService;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.BasDualCrnp;
import com.zy.asrs.entity.BasStation;
import com.zy.asrs.entity.StationFlowCapacity;
import com.zy.asrs.service.BasCrnpService;
import com.zy.asrs.service.BasDualCrnpService;
import com.zy.asrs.service.BasStationService;
import com.zy.asrs.service.StationFlowCapacityService;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
@Service("autoTuneApplyService")
public class AutoTuneApplyServiceImpl implements AutoTuneApplyService {
    private static final String PROMPT_SCENE_CODE = "auto_tune_apply";
    private static final String DIRECTION_OUT = "OUT";
    @Autowired
    private AiAutoTuneJobService aiAutoTuneJobService;
    @Autowired
    private AiAutoTuneChangeService aiAutoTuneChangeService;
    @Autowired
    private ConfigService configService;
    @Autowired
    private BasStationService basStationService;
    @Autowired
    private BasCrnpService basCrnpService;
    @Autowired
    private BasDualCrnpService basDualCrnpService;
    @Autowired
    private StationFlowCapacityService stationFlowCapacityService;
    @Override
    @Transactional
    public AutoTuneApplyResult apply(AutoTuneApplyRequest request) {
        AutoTuneApplyRequest safeRequest = request == null ? new AutoTuneApplyRequest() : request;
        boolean dryRun = Boolean.TRUE.equals(safeRequest.getDryRun());
        Date now = new Date();
        AiAutoTuneJob job = createJob(safeRequest, dryRun, now);
        aiAutoTuneJobService.save(job);
        List<ValidatedChange> validatedChanges = validateChanges(safeRequest, dryRun, now);
        boolean hasRejectedChange = hasRejectedChange(validatedChanges);
        if (!dryRun && hasRejectedChange) {
            markAcceptedChangesAsBatchRejected(validatedChanges);
        }
        if (!dryRun && !hasRejectedChange) {
            applyValidatedChanges(validatedChanges);
        }
        List<AiAutoTuneChange> auditChanges = buildAuditChanges(job.getId(), validatedChanges, now);
        if (!auditChanges.isEmpty()) {
            aiAutoTuneChangeService.saveBatch(auditChanges);
        }
        finishJob(job, safeRequest, auditChanges, dryRun, now);
        aiAutoTuneJobService.updateById(job);
        return buildResult(job, auditChanges, dryRun);
    }
    @Override
    @Transactional
    public AutoTuneApplyResult rollbackLastSuccessfulJob(String reason) {
        Date now = new Date();
        AiAutoTuneJob rollbackJob = createRollbackJob(reason, now);
        aiAutoTuneJobService.save(rollbackJob);
        List<AiAutoTuneChange> sourceChanges = findLatestSuccessfulChanges();
        if (sourceChanges.isEmpty()) {
            rollbackJob.setStatus(AutoTuneJobStatus.REJECTED.getCode());
            rollbackJob.setFinishTime(now);
            rollbackJob.setRejectCount(0);
            rollbackJob.setSuccessCount(0);
            rollbackJob.setSummary("未找到可回滚的成功调参记录");
            rollbackJob.setErrorMessage("未找到可回滚的成功调参记录");
            aiAutoTuneJobService.updateById(rollbackJob);
            return buildResult(rollbackJob, new ArrayList<>(), false);
        }
        List<AiAutoTuneChange> rollbackChanges = new ArrayList<>();
        boolean refreshedConfigCache = false;
        for (AiAutoTuneChange sourceChange : sourceChanges) {
            AiAutoTuneChange rollbackChange = buildRollbackChange(rollbackJob.getId(), sourceChange, now);
            try {
                String currentValue = readCurrentValue(
                        sourceChange.getTargetType(),
                        sourceChange.getTargetId(),
                        sourceChange.getTargetKey()
                );
                rollbackChange.setOldValue(currentValue);
                writeValue(sourceChange.getTargetType(), sourceChange.getTargetId(), sourceChange.getTargetKey(), sourceChange.getOldValue());
                if (AutoTuneTargetType.SYS_CONFIG.getCode().equals(sourceChange.getTargetType())) {
                    refreshedConfigCache = true;
                }
                rollbackChange.setAppliedValue(sourceChange.getOldValue());
                rollbackChange.setResultStatus(ChangeStatus.SUCCESS.getCode());
            } catch (Exception exception) {
                rollbackChange.setResultStatus(ChangeStatus.FAILED.getCode());
                rollbackChange.setRejectReason(exception.getMessage());
            }
            rollbackChanges.add(rollbackChange);
        }
        if (refreshedConfigCache) {
            configService.refreshSystemConfigCache();
        }
        aiAutoTuneChangeService.saveBatch(rollbackChanges);
        finishRollbackJob(rollbackJob, rollbackChanges, now);
        aiAutoTuneJobService.updateById(rollbackJob);
        return buildResult(rollbackJob, rollbackChanges, false);
    }
    private List<ValidatedChange> validateChanges(AutoTuneApplyRequest request, boolean dryRun, Date now) {
        List<ValidatedChange> result = new ArrayList<>();
        if (request.getChanges() == null || request.getChanges().isEmpty()) {
            return result;
        }
        for (AutoTuneChangeCommand command : request.getChanges()) {
            result.add(validateChange(command, dryRun, now));
        }
        return result;
    }
    private ValidatedChange validateChange(AutoTuneChangeCommand command, boolean dryRun, Date now) {
        ValidatedChange validatedChange = new ValidatedChange(command);
        AutoTuneRuleDefinition.Rule rule = AutoTuneRuleDefinition.findRule(command.getTargetType(), command.getTargetKey());
        if (rule == null) {
            return validatedChange.reject("不支持的调参目标: " + command.getTargetType() + "/" + command.getTargetKey());
        }
        validatedChange.setRule(rule);
        Integer requestedValue = parseRequestedInt(command.getNewValue());
        if (requestedValue == null) {
            return validatedChange.reject(command.getTargetKey() + " 必须为整数");
        }
        validatedChange.setRequestedIntValue(requestedValue);
        validatedChange.setRequestedValue(String.valueOf(requestedValue));
        CurrentValue currentValue = readCurrentValueForValidation(command, rule);
        if (currentValue.getRejectReason() != null) {
            return validatedChange.reject(currentValue.getRejectReason());
        }
        validatedChange.setOldValue(currentValue.getOldValue());
        Integer maxValue = resolveMaxValue(command, rule, requestedValue);
        if (maxValue == null) {
            return validatedChange.reject("站点 " + command.getTargetId() + " 缺少 OUT 方向 bufferCapacity,无法证明 outTaskLimit 上限");
        }
        if (requestedValue < rule.getMinValue() || requestedValue > maxValue) {
            return validatedChange.reject(command.getTargetKey() + " 必须在 " + rule.getMinValue() + "~" + maxValue + " 范围内");
        }
        if (Objects.equals(currentValue.getNumericValue(), requestedValue)) {
            return validatedChange.accept(ChangeStatus.NO_CHANGE, null);
        }
        int step = Math.abs(requestedValue - currentValue.getNumericValue());
        if (step > rule.getMaxStep()) {
            return validatedChange.reject(command.getTargetKey() + " 单次调整步长不能超过 " + rule.getMaxStep());
        }
        Date cooldownExpireTime = findCooldownExpireTime(command, now);
        if (cooldownExpireTime != null) {
            validatedChange.setCooldownExpireTime(cooldownExpireTime);
            return validatedChange.reject("目标仍在冷却期,冷却截止时间: " + cooldownExpireTime);
        }
        Date nextCooldownExpireTime = new Date(now.getTime() + rule.getCooldownMinutes() * 60_000L);
        validatedChange.setCooldownExpireTime(nextCooldownExpireTime);
        return validatedChange.accept(dryRun ? ChangeStatus.DRY_RUN : ChangeStatus.PENDING, null);
    }
    private CurrentValue readCurrentValueForValidation(AutoTuneChangeCommand command, AutoTuneRuleDefinition.Rule rule) {
        AutoTuneTargetType targetType = rule.getTargetType();
        Integer targetId = parseTargetId(command.getTargetId(), targetType);
        if (!AutoTuneTargetType.SYS_CONFIG.equals(targetType) && targetId == null) {
            return CurrentValue.rejected("targetId 必须为整数");
        }
        if (AutoTuneTargetType.SYS_CONFIG.equals(targetType)) {
            Config config = configService.getOne(new QueryWrapper<Config>().eq("code", command.getTargetKey()).last("limit 1"));
            if (config == null) {
                return CurrentValue.rejected("运行参数不存在: " + command.getTargetKey());
            }
            return numericCurrentValue(config.getValue(), false, command.getTargetKey());
        }
        if (AutoTuneTargetType.STATION.equals(targetType)) {
            BasStation station = basStationService.getById(targetId);
            if (station == null) {
                return CurrentValue.rejected("站点不存在: " + command.getTargetId());
            }
            return numericCurrentValue(toText(station.getOutTaskLimit()), true, command.getTargetKey());
        }
        if (AutoTuneTargetType.CRN.equals(targetType)) {
            BasCrnp crnp = basCrnpService.getById(targetId);
            if (crnp == null) {
                return CurrentValue.rejected("堆垛机不存在: " + command.getTargetId());
            }
            Integer value = "maxOutTask".equals(command.getTargetKey()) ? crnp.getMaxOutTask() : crnp.getMaxInTask();
            return numericCurrentValue(toText(value), false, command.getTargetKey());
        }
        BasDualCrnp dualCrnp = basDualCrnpService.getById(targetId);
        if (dualCrnp == null) {
            return CurrentValue.rejected("双工位堆垛机不存在: " + command.getTargetId());
        }
        Integer value = "maxOutTask".equals(command.getTargetKey()) ? dualCrnp.getMaxOutTask() : dualCrnp.getMaxInTask();
        return numericCurrentValue(toText(value), false, command.getTargetKey());
    }
    private CurrentValue numericCurrentValue(String oldValue, boolean nullOrNegativeAsZero, String targetKey) {
        if (oldValue == null || oldValue.trim().isEmpty()) {
            if (nullOrNegativeAsZero) {
                return CurrentValue.accepted(null, 0);
            }
            return CurrentValue.rejected(targetKey + " 当前值为空,无法计算步长");
        }
        try {
            Integer parsedValue = Integer.valueOf(oldValue.trim());
            if (nullOrNegativeAsZero && parsedValue < 0) {
                return CurrentValue.accepted(oldValue, 0);
            }
            return CurrentValue.accepted(oldValue, parsedValue);
        } catch (Exception exception) {
            return CurrentValue.rejected(targetKey + " 当前值不是整数,无法计算步长");
        }
    }
    private Integer resolveMaxValue(AutoTuneChangeCommand command,
                                    AutoTuneRuleDefinition.Rule rule,
                                    Integer requestedValue) {
        if (!rule.isDynamicMaxValue()) {
            return rule.getMaxValue();
        }
        Integer targetId = parseTargetId(command.getTargetId(), rule.getTargetType());
        StationFlowCapacity capacity = stationFlowCapacityService.getOne(
                new QueryWrapper<StationFlowCapacity>()
                        .eq("station_id", targetId)
                        .eq("direction_code", DIRECTION_OUT)
                        .last("limit 1")
        );
        if (capacity == null || capacity.getBufferCapacity() == null) {
            return requestedValue == 0 ? 0 : null;
        }
        return Math.max(0, capacity.getBufferCapacity());
    }
    private Date findCooldownExpireTime(AutoTuneChangeCommand command, Date now) {
        String targetId = normalizeTargetId(command.getTargetType(), command.getTargetId());
        List<AiAutoTuneChange> recentChanges = aiAutoTuneChangeService.list(
                new QueryWrapper<AiAutoTuneChange>()
                        .eq("target_type", command.getTargetType())
                        .eq("target_id", targetId)
                        .eq("target_key", command.getTargetKey())
                        .eq("result_status", ChangeStatus.SUCCESS.getCode())
                        .gt("cooldown_expire_time", now)
                        .orderByDesc("create_time")
                        .last("limit 1")
        );
        if (recentChanges == null || recentChanges.isEmpty()) {
            return null;
        }
        AiAutoTuneChange latestChange = recentChanges.get(0);
        Date cooldownExpireTime = latestChange.getCooldownExpireTime();
        if (cooldownExpireTime == null || !cooldownExpireTime.after(now)) {
            return null;
        }
        return cooldownExpireTime;
    }
    private void applyValidatedChanges(List<ValidatedChange> validatedChanges) {
        boolean refreshConfigCache = false;
        for (ValidatedChange validatedChange : validatedChanges) {
            if (ChangeStatus.NO_CHANGE.equals(validatedChange.getStatus())) {
                continue;
            }
            AutoTuneChangeCommand command = validatedChange.getCommand();
            writeValue(command.getTargetType(), command.getTargetId(), command.getTargetKey(), validatedChange.getRequestedValue());
            validatedChange.accept(ChangeStatus.SUCCESS, null);
            validatedChange.setAppliedValue(validatedChange.getRequestedValue());
            if (AutoTuneTargetType.SYS_CONFIG.getCode().equals(command.getTargetType())) {
                refreshConfigCache = true;
            }
        }
        if (refreshConfigCache) {
            configService.refreshSystemConfigCache();
        }
    }
    private void writeValue(String targetType, String targetId, String targetKey, String value) {
        AutoTuneTargetType parsedTargetType = AutoTuneTargetType.fromCode(targetType);
        if (AutoTuneTargetType.SYS_CONFIG.equals(parsedTargetType)) {
            if (!configService.saveConfigValue(targetKey, value)) {
                throw new IllegalStateException("保存运行参数失败: " + targetKey);
            }
            return;
        }
        Integer parsedTargetId = parseTargetId(targetId, parsedTargetType);
        Integer intValue = value == null ? null : Integer.valueOf(value);
        if (AutoTuneTargetType.STATION.equals(parsedTargetType)) {
            boolean updated = basStationService.update(new UpdateWrapper<BasStation>()
                    .eq("station_id", parsedTargetId)
                    .set("out_task_limit", intValue));
            if (!updated) {
                throw new IllegalStateException("保存站点参数失败: " + targetId + "/" + targetKey);
            }
            return;
        }
        if (AutoTuneTargetType.CRN.equals(parsedTargetType)) {
            String column = "maxOutTask".equals(targetKey) ? "max_out_task" : "max_in_task";
            boolean updated = basCrnpService.update(new UpdateWrapper<BasCrnp>()
                    .eq("crn_no", parsedTargetId)
                    .set(column, intValue));
            if (!updated) {
                throw new IllegalStateException("保存堆垛机参数失败: " + targetId + "/" + targetKey);
            }
            return;
        }
        String column = "maxOutTask".equals(targetKey) ? "max_out_task" : "max_in_task";
        boolean updated = basDualCrnpService.update(new UpdateWrapper<BasDualCrnp>()
                .eq("crn_no", parsedTargetId)
                .set(column, intValue));
        if (!updated) {
            throw new IllegalStateException("保存双工位堆垛机参数失败: " + targetId + "/" + targetKey);
        }
    }
    private String readCurrentValue(String targetType, String targetId, String targetKey) {
        AutoTuneTargetType parsedTargetType = AutoTuneTargetType.fromCode(targetType);
        Integer parsedTargetId = parseTargetId(targetId, parsedTargetType);
        if (AutoTuneTargetType.SYS_CONFIG.equals(parsedTargetType)) {
            Config config = configService.getOne(new QueryWrapper<Config>().eq("code", targetKey).last("limit 1"));
            return config == null ? null : config.getValue();
        }
        if (AutoTuneTargetType.STATION.equals(parsedTargetType)) {
            BasStation station = basStationService.getById(parsedTargetId);
            return station == null ? null : toText(station.getOutTaskLimit());
        }
        if (AutoTuneTargetType.CRN.equals(parsedTargetType)) {
            BasCrnp crnp = basCrnpService.getById(parsedTargetId);
            if (crnp == null) {
                return null;
            }
            return toText("maxOutTask".equals(targetKey) ? crnp.getMaxOutTask() : crnp.getMaxInTask());
        }
        BasDualCrnp dualCrnp = basDualCrnpService.getById(parsedTargetId);
        if (dualCrnp == null) {
            return null;
        }
        return toText("maxOutTask".equals(targetKey) ? dualCrnp.getMaxOutTask() : dualCrnp.getMaxInTask());
    }
    private AiAutoTuneJob createJob(AutoTuneApplyRequest request, boolean dryRun, Date now) {
        AiAutoTuneJob job = new AiAutoTuneJob();
        job.setTriggerType(AutoTuneTriggerType.normalize(request.getTriggerType()));
        job.setStatus(AutoTuneJobStatus.RUNNING.getCode());
        job.setStartTime(now);
        job.setHasActiveTasks(0);
        job.setPromptSceneCode(PROMPT_SCENE_CODE);
        job.setSummary(dryRun ? "AI自动调参 dry-run: " + safeReason(request.getReason()) : safeReason(request.getReason()));
        job.setIntervalBefore(readIntervalMinutes());
        job.setSuccessCount(0);
        job.setRejectCount(0);
        job.setLlmCallCount(0);
        job.setPromptTokens(0);
        job.setCompletionTokens(0);
        job.setTotalTokens(0);
        job.setCreateTime(now);
        return job;
    }
    private AiAutoTuneJob createRollbackJob(String reason, Date now) {
        AiAutoTuneJob job = new AiAutoTuneJob();
        job.setTriggerType(AutoTuneTriggerType.ROLLBACK.getCode());
        job.setStatus(AutoTuneJobStatus.RUNNING.getCode());
        job.setStartTime(now);
        job.setHasActiveTasks(0);
        job.setPromptSceneCode(PROMPT_SCENE_CODE);
        job.setSummary(safeReason(reason));
        job.setIntervalBefore(readIntervalMinutes());
        job.setSuccessCount(0);
        job.setRejectCount(0);
        job.setLlmCallCount(0);
        job.setPromptTokens(0);
        job.setCompletionTokens(0);
        job.setTotalTokens(0);
        job.setCreateTime(now);
        return job;
    }
    private void finishJob(AiAutoTuneJob job,
                           AutoTuneApplyRequest request,
                           List<AiAutoTuneChange> auditChanges,
                           boolean dryRun,
                           Date now) {
        int successCount = countAccepted(auditChanges);
        int rejectCount = countRejected(auditChanges);
        job.setFinishTime(now);
        job.setSuccessCount(successCount);
        job.setRejectCount(rejectCount);
        job.setIntervalAfter(readIntervalMinutes());
        job.setStatus(resolveJobStatus(auditChanges, dryRun));
        job.setSummary(buildSummary(request.getReason(), successCount, rejectCount, dryRun));
        if (rejectCount > 0) {
            job.setErrorMessage(firstRejectReason(auditChanges));
        }
    }
    private void finishRollbackJob(AiAutoTuneJob job, List<AiAutoTuneChange> changes, Date now) {
        int successCount = countAccepted(changes);
        int rejectCount = countRejected(changes);
        job.setFinishTime(now);
        job.setSuccessCount(successCount);
        job.setRejectCount(rejectCount);
        job.setIntervalAfter(readIntervalMinutes());
        job.setStatus(rejectCount == 0 ? AutoTuneJobStatus.SUCCESS.getCode() : AutoTuneJobStatus.PARTIAL_SUCCESS.getCode());
        job.setSummary("回滚最近一次成功调参,成功 " + successCount + " 项,失败 " + rejectCount + " 项");
        if (rejectCount > 0) {
            job.setErrorMessage(firstRejectReason(changes));
        }
    }
    private String resolveJobStatus(List<AiAutoTuneChange> auditChanges, boolean dryRun) {
        if (auditChanges.isEmpty()) {
            return AutoTuneJobStatus.NO_CHANGE.getCode();
        }
        int rejectedCount = countRejected(auditChanges);
        int acceptedCount = countAccepted(auditChanges);
        if (rejectedCount == auditChanges.size()) {
            return AutoTuneJobStatus.REJECTED.getCode();
        }
        if (rejectedCount > 0) {
            return dryRun ? AutoTuneJobStatus.PARTIAL_SUCCESS.getCode() : AutoTuneJobStatus.REJECTED.getCode();
        }
        if (acceptedCount == 0) {
            return AutoTuneJobStatus.NO_CHANGE.getCode();
        }
        boolean allNoChange = true;
        for (AiAutoTuneChange change : auditChanges) {
            if (!ChangeStatus.NO_CHANGE.getCode().equals(change.getResultStatus())) {
                allNoChange = false;
                break;
            }
        }
        return allNoChange ? AutoTuneJobStatus.NO_CHANGE.getCode() : AutoTuneJobStatus.SUCCESS.getCode();
    }
    private List<AiAutoTuneChange> buildAuditChanges(Long jobId, List<ValidatedChange> validatedChanges, Date now) {
        List<AiAutoTuneChange> auditChanges = new ArrayList<>();
        for (ValidatedChange validatedChange : validatedChanges) {
            AiAutoTuneChange change = new AiAutoTuneChange();
            AutoTuneChangeCommand command = validatedChange.getCommand();
            change.setJobId(jobId);
            change.setTargetType(command.getTargetType());
            change.setTargetId(normalizeTargetId(command.getTargetType(), command.getTargetId()));
            change.setTargetKey(command.getTargetKey());
            change.setOldValue(validatedChange.getOldValue());
            change.setRequestedValue(validatedChange.getRequestedValue() == null ? command.getNewValue() : validatedChange.getRequestedValue());
            change.setAppliedValue(validatedChange.getAppliedValue());
            change.setResultStatus(validatedChange.getStatus().getCode());
            change.setRejectReason(validatedChange.getRejectReason());
            change.setCooldownExpireTime(validatedChange.getCooldownExpireTime());
            change.setCreateTime(now);
            auditChanges.add(change);
        }
        return auditChanges;
    }
    private AiAutoTuneChange buildRollbackChange(Long jobId, AiAutoTuneChange sourceChange, Date now) {
        AiAutoTuneChange rollbackChange = new AiAutoTuneChange();
        rollbackChange.setJobId(jobId);
        rollbackChange.setTargetType(sourceChange.getTargetType());
        rollbackChange.setTargetId(sourceChange.getTargetId());
        rollbackChange.setTargetKey(sourceChange.getTargetKey());
        rollbackChange.setRequestedValue(sourceChange.getOldValue());
        rollbackChange.setCreateTime(now);
        return rollbackChange;
    }
    private List<AiAutoTuneChange> findLatestSuccessfulChanges() {
        List<AiAutoTuneJob> jobs = aiAutoTuneJobService.list(
                new QueryWrapper<AiAutoTuneJob>()
                        .eq("status", AutoTuneJobStatus.SUCCESS.getCode())
                        .ne("trigger_type", AutoTuneTriggerType.ROLLBACK.getCode())
                        .orderByDesc("finish_time")
                        .last("limit 20")
        );
        if (jobs == null || jobs.isEmpty()) {
            return new ArrayList<>();
        }
        for (AiAutoTuneJob job : jobs) {
            List<AiAutoTuneChange> changes = aiAutoTuneChangeService.list(
                    new QueryWrapper<AiAutoTuneChange>()
                            .eq("job_id", job.getId())
                            .eq("result_status", ChangeStatus.SUCCESS.getCode())
                            .orderByAsc("id")
            );
            if (changes != null && !changes.isEmpty()) {
                return changes;
            }
        }
        return new ArrayList<>();
    }
    private AutoTuneApplyResult buildResult(AiAutoTuneJob job, List<AiAutoTuneChange> changes, boolean dryRun) {
        AutoTuneApplyResult result = new AutoTuneApplyResult();
        result.setDryRun(dryRun);
        result.setSuccess(AutoTuneJobStatus.SUCCESS.getCode().equals(job.getStatus())
                || AutoTuneJobStatus.NO_CHANGE.getCode().equals(job.getStatus()));
        result.setJobId(job.getId());
        result.setSummary(job.getSummary());
        result.setSuccessCount(job.getSuccessCount());
        result.setRejectCount(job.getRejectCount());
        result.setChanges(changes);
        return result;
    }
    private void markAcceptedChangesAsBatchRejected(List<ValidatedChange> validatedChanges) {
        for (ValidatedChange validatedChange : validatedChanges) {
            if (ChangeStatus.REJECTED.equals(validatedChange.getStatus())) {
                continue;
            }
            if (ChangeStatus.NO_CHANGE.equals(validatedChange.getStatus())) {
                continue;
            }
            validatedChange.reject("同批次存在被拒绝变更,未执行写入");
        }
    }
    private boolean hasRejectedChange(List<ValidatedChange> validatedChanges) {
        for (ValidatedChange validatedChange : validatedChanges) {
            if (ChangeStatus.REJECTED.equals(validatedChange.getStatus())) {
                return true;
            }
        }
        return false;
    }
    private Integer parseRequestedInt(String value) {
        if (value == null || value.trim().isEmpty()) {
            return null;
        }
        try {
            return Integer.valueOf(value.trim());
        } catch (Exception exception) {
            return null;
        }
    }
    private Integer parseTargetId(String targetId, AutoTuneTargetType targetType) {
        if (AutoTuneTargetType.SYS_CONFIG.equals(targetType)) {
            return null;
        }
        if (targetId == null || targetId.trim().isEmpty()) {
            return null;
        }
        try {
            return Integer.valueOf(targetId.trim());
        } catch (Exception exception) {
            return null;
        }
    }
    private String normalizeTargetId(String targetType, String targetId) {
        AutoTuneTargetType parsedTargetType = AutoTuneTargetType.fromCode(targetType);
        if (AutoTuneTargetType.SYS_CONFIG.equals(parsedTargetType)) {
            return "";
        }
        return targetId == null ? "" : targetId.trim();
    }
    private int readIntervalMinutes() {
        String value = configService.getConfigValue("aiAutoTuneIntervalMinutes", null);
        if (value == null || value.trim().isEmpty()) {
            return 0;
        }
        try {
            return Integer.parseInt(value.trim());
        } catch (Exception exception) {
            return 0;
        }
    }
    private String toText(Integer value) {
        return value == null ? null : String.valueOf(value);
    }
    private String safeReason(String reason) {
        return reason == null ? "" : reason.trim();
    }
    private String buildSummary(String reason, int successCount, int rejectCount, boolean dryRun) {
        String prefix = dryRun ? "AI自动调参 dry-run" : "AI自动调参";
        return prefix + ",成功 " + successCount + " 项,拒绝 " + rejectCount + " 项。原因: " + safeReason(reason);
    }
    private int countAccepted(List<AiAutoTuneChange> changes) {
        int count = 0;
        for (AiAutoTuneChange change : changes) {
            if (ChangeStatus.SUCCESS.getCode().equals(change.getResultStatus())
                    || ChangeStatus.DRY_RUN.getCode().equals(change.getResultStatus())
                    || ChangeStatus.NO_CHANGE.getCode().equals(change.getResultStatus())) {
                count++;
            }
        }
        return count;
    }
    private int countRejected(List<AiAutoTuneChange> changes) {
        int count = 0;
        for (AiAutoTuneChange change : changes) {
            if (ChangeStatus.REJECTED.getCode().equals(change.getResultStatus())
                    || ChangeStatus.FAILED.getCode().equals(change.getResultStatus())) {
                count++;
            }
        }
        return count;
    }
    private String firstRejectReason(List<AiAutoTuneChange> changes) {
        for (AiAutoTuneChange change : changes) {
            if (change.getRejectReason() != null && !change.getRejectReason().trim().isEmpty()) {
                return change.getRejectReason();
            }
        }
        return null;
    }
    private static class ValidatedChange {
        private final AutoTuneChangeCommand command;
        private AutoTuneRuleDefinition.Rule rule;
        private String oldValue;
        private String requestedValue;
        private String appliedValue;
        private Integer requestedIntValue;
        private ChangeStatus status = ChangeStatus.REJECTED;
        private String rejectReason;
        private Date cooldownExpireTime;
        private ValidatedChange(AutoTuneChangeCommand command) {
            this.command = command == null ? new AutoTuneChangeCommand() : command;
        }
        private ValidatedChange reject(String reason) {
            this.status = ChangeStatus.REJECTED;
            this.rejectReason = reason;
            this.appliedValue = null;
            return this;
        }
        private ValidatedChange accept(ChangeStatus status, String reason) {
            this.status = status;
            this.rejectReason = reason;
            if (ChangeStatus.NO_CHANGE.equals(status)) {
                this.appliedValue = this.oldValue;
            }
            return this;
        }
        public AutoTuneChangeCommand getCommand() {
            return command;
        }
        public AutoTuneRuleDefinition.Rule getRule() {
            return rule;
        }
        public void setRule(AutoTuneRuleDefinition.Rule rule) {
            this.rule = rule;
        }
        public String getOldValue() {
            return oldValue;
        }
        public void setOldValue(String oldValue) {
            this.oldValue = oldValue;
        }
        public String getRequestedValue() {
            return requestedValue;
        }
        public void setRequestedValue(String requestedValue) {
            this.requestedValue = requestedValue;
        }
        public String getAppliedValue() {
            return appliedValue;
        }
        public void setAppliedValue(String appliedValue) {
            this.appliedValue = appliedValue;
        }
        public Integer getRequestedIntValue() {
            return requestedIntValue;
        }
        public void setRequestedIntValue(Integer requestedIntValue) {
            this.requestedIntValue = requestedIntValue;
        }
        public ChangeStatus getStatus() {
            return status;
        }
        public String getRejectReason() {
            return rejectReason;
        }
        public Date getCooldownExpireTime() {
            return cooldownExpireTime;
        }
        public void setCooldownExpireTime(Date cooldownExpireTime) {
            this.cooldownExpireTime = cooldownExpireTime;
        }
    }
    private static class CurrentValue {
        private final String oldValue;
        private final Integer numericValue;
        private final String rejectReason;
        private CurrentValue(String oldValue, Integer numericValue, String rejectReason) {
            this.oldValue = oldValue;
            this.numericValue = numericValue;
            this.rejectReason = rejectReason;
        }
        private static CurrentValue accepted(String oldValue, Integer numericValue) {
            return new CurrentValue(oldValue, numericValue, null);
        }
        private static CurrentValue rejected(String rejectReason) {
            return new CurrentValue(null, null, rejectReason);
        }
        public String getOldValue() {
            return oldValue;
        }
        public Integer getNumericValue() {
            return numericValue;
        }
        public String getRejectReason() {
            return rejectReason;
        }
    }
    private enum ChangeStatus {
        PENDING("pending"),
        SUCCESS("success"),
        REJECTED("rejected"),
        FAILED("failed"),
        DRY_RUN("dry_run"),
        NO_CHANGE("no_change");
        private final String code;
        ChangeStatus(String code) {
            this.code = code;
        }
        public String getCode() {
            return code;
        }
    }
}
src/main/java/com/zy/asrs/service/impl/RuntimeConfigServiceImpl.java
@@ -96,6 +96,7 @@
    private static LinkedHashMap<String, RuntimeConfigRule> buildRuntimeConfigRuleMap() {
        LinkedHashMap<String, RuntimeConfigRule> ruleMap = new LinkedHashMap<>();
        putIntRule(ruleMap, "conveyorStationTaskLimit", 1, 1000);
        putIntRule(ruleMap, "aiAutoTuneIntervalMinutes", 5, 60);
        putIntRule(ruleMap, "stationCommandSendLength", 1, 200);
        putRatioRule(ruleMap, "stationCommandSegmentAdvanceRatio");
        putIntRule(ruleMap, "stationCommandConfigRefreshSeconds", 5, 300);
src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java
New file
@@ -0,0 +1,285 @@
package com.zy.ai.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.zy.ai.domain.autotune.AutoTuneApplyRequest;
import com.zy.ai.domain.autotune.AutoTuneApplyResult;
import com.zy.ai.domain.autotune.AutoTuneChangeCommand;
import com.zy.ai.entity.AiAutoTuneChange;
import com.zy.ai.entity.AiAutoTuneJob;
import com.zy.ai.service.impl.AutoTuneApplyServiceImpl;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.BasDualCrnp;
import com.zy.asrs.entity.BasStation;
import com.zy.asrs.entity.StationFlowCapacity;
import com.zy.asrs.service.BasCrnpService;
import com.zy.asrs.service.BasDualCrnpService;
import com.zy.asrs.service.BasStationService;
import com.zy.asrs.service.StationFlowCapacityService;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AutoTuneApplyServiceImplTest {
    private AutoTuneApplyServiceImpl service;
    @Mock
    private AiAutoTuneJobService aiAutoTuneJobService;
    @Mock
    private AiAutoTuneChangeService aiAutoTuneChangeService;
    @Mock
    private ConfigService configService;
    @Mock
    private BasStationService basStationService;
    @Mock
    private BasCrnpService basCrnpService;
    @Mock
    private BasDualCrnpService basDualCrnpService;
    @Mock
    private StationFlowCapacityService stationFlowCapacityService;
    @BeforeEach
    void setUp() {
        service = new AutoTuneApplyServiceImpl();
        ReflectionTestUtils.setField(service, "aiAutoTuneJobService", aiAutoTuneJobService);
        ReflectionTestUtils.setField(service, "aiAutoTuneChangeService", aiAutoTuneChangeService);
        ReflectionTestUtils.setField(service, "configService", configService);
        ReflectionTestUtils.setField(service, "basStationService", basStationService);
        ReflectionTestUtils.setField(service, "basCrnpService", basCrnpService);
        ReflectionTestUtils.setField(service, "basDualCrnpService", basDualCrnpService);
        ReflectionTestUtils.setField(service, "stationFlowCapacityService", stationFlowCapacityService);
        AtomicLong jobId = new AtomicLong(100);
        when(aiAutoTuneJobService.save(any(AiAutoTuneJob.class))).thenAnswer(invocation -> {
            AiAutoTuneJob job = invocation.getArgument(0);
            job.setId(jobId.incrementAndGet());
            return true;
        });
        when(aiAutoTuneJobService.updateById(any(AiAutoTuneJob.class))).thenReturn(true);
        when(aiAutoTuneChangeService.saveBatch(any(Collection.class))).thenReturn(true);
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
        when(configService.getConfigValue(eq("aiAutoTuneIntervalMinutes"), any())).thenReturn("10");
        when(configService.saveConfigValue(any(), any())).thenReturn(true);
        when(basStationService.update(any(Wrapper.class))).thenReturn(true);
        when(basCrnpService.update(any(Wrapper.class))).thenReturn(true);
        when(basDualCrnpService.update(any(Wrapper.class))).thenReturn(true);
    }
    @Test
    void rejectNonWhitelistedKey() {
        AutoTuneApplyResult result = service.apply(request(true, command("sys_config", null, "badKey", "10")));
        List<AiAutoTuneChange> changes = savedChanges();
        assertFalse(result.getSuccess());
        assertEquals("rejected", changes.get(0).getResultStatus());
        assertTrue(changes.get(0).getRejectReason().contains("不支持的调参目标"));
        verify(configService, never()).saveConfigValue(any(), any());
    }
    @Test
    void rejectOutOfRangeInterval() {
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("aiAutoTuneIntervalMinutes", "10"));
        AutoTuneApplyResult result = service.apply(request(true, command("sys_config", null, "aiAutoTuneIntervalMinutes", "100")));
        List<AiAutoTuneChange> changes = savedChanges();
        assertFalse(result.getSuccess());
        assertEquals("rejected", changes.get(0).getResultStatus());
        assertTrue(changes.get(0).getRejectReason().contains("5~60"));
    }
    @Test
    void rejectOverStepConveyorLimitChange() {
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
        service.apply(request(true, command("sys_config", null, "conveyorStationTaskLimit", "16")));
        List<AiAutoTuneChange> changes = savedChanges();
        assertEquals("rejected", changes.get(0).getResultStatus());
        assertTrue(changes.get(0).getRejectReason().contains("步长不能超过 5"));
    }
    @Test
    void rejectStationOutTaskLimitAboveDirectionalBufferCapacity() {
        when(basStationService.getById(101)).thenReturn(station(101, 1));
        when(stationFlowCapacityService.getOne(any(Wrapper.class))).thenReturn(capacity(101, "OUT", 2));
        service.apply(request(true, command("station", "101", "outTaskLimit", "3")));
        List<AiAutoTuneChange> changes = savedChanges();
        assertEquals("rejected", changes.get(0).getResultStatus());
        assertTrue(changes.get(0).getRejectReason().contains("0~2"));
    }
    @Test
    void rejectCooldownHit() {
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
        AiAutoTuneChange cooldownChange = new AiAutoTuneChange();
        cooldownChange.setResultStatus("success");
        cooldownChange.setCooldownExpireTime(new Date(System.currentTimeMillis() + 60_000L));
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(cooldownChange));
        service.apply(request(true, command("sys_config", null, "conveyorStationTaskLimit", "15")));
        List<AiAutoTuneChange> changes = savedChanges();
        assertEquals("rejected", changes.get(0).getResultStatus());
        assertTrue(changes.get(0).getRejectReason().contains("冷却期"));
    }
    @Test
    void applyMixedBatchSuccessfully() {
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
        when(basStationService.getById(101)).thenReturn(station(101, 1));
        when(stationFlowCapacityService.getOne(any(Wrapper.class))).thenReturn(capacity(101, "OUT", 2));
        when(basCrnpService.getById(1)).thenReturn(crn(1, 1, 1));
        when(basDualCrnpService.getById(2)).thenReturn(dualCrn(2, 1, 1));
        AutoTuneApplyResult result = service.apply(request(false,
                command("sys_config", null, "conveyorStationTaskLimit", "15"),
                command("station", "101", "outTaskLimit", "2"),
                command("crn", "1", "maxOutTask", "2"),
                command("dual_crn", "2", "maxInTask", "2")
        ));
        List<AiAutoTuneChange> changes = savedChanges();
        assertTrue(result.getSuccess());
        assertEquals(4, changes.size());
        assertEquals(4, result.getSuccessCount());
        assertEquals(0, result.getRejectCount());
        assertTrue(changes.stream().allMatch(change -> "success".equals(change.getResultStatus())));
        verify(configService).saveConfigValue("conveyorStationTaskLimit", "15");
        verify(configService).refreshSystemConfigCache();
        verify(basStationService).update(any(Wrapper.class));
        verify(basCrnpService).update(any(Wrapper.class));
        verify(basDualCrnpService).update(any(Wrapper.class));
    }
    @Test
    void rollbackLastJobSuccessfully() {
        AiAutoTuneJob latestJob = new AiAutoTuneJob();
        latestJob.setId(10L);
        when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(latestJob));
        AiAutoTuneChange configChange = successChange(10L, "sys_config", "", "conveyorStationTaskLimit", "10", "15");
        AiAutoTuneChange stationChange = successChange(10L, "station", "101", "outTaskLimit", "1", "2");
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(List.of(configChange, stationChange));
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "15"));
        when(basStationService.getById(101)).thenReturn(station(101, 2));
        AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback");
        List<AiAutoTuneChange> changes = savedChanges();
        assertTrue(result.getSuccess());
        assertEquals(2, changes.size());
        assertTrue(changes.stream().allMatch(change -> "success".equals(change.getResultStatus())));
        verify(configService).saveConfigValue("conveyorStationTaskLimit", "10");
        verify(configService).refreshSystemConfigCache();
        verify(basStationService).update(any(Wrapper.class));
    }
    private AutoTuneApplyRequest request(boolean dryRun, AutoTuneChangeCommand... commands) {
        AutoTuneApplyRequest request = new AutoTuneApplyRequest();
        request.setDryRun(dryRun);
        request.setReason("test");
        request.setTriggerType("manual");
        request.setChanges(List.of(commands));
        return request;
    }
    private AutoTuneChangeCommand command(String targetType, String targetId, String targetKey, String newValue) {
        AutoTuneChangeCommand command = new AutoTuneChangeCommand();
        command.setTargetType(targetType);
        command.setTargetId(targetId);
        command.setTargetKey(targetKey);
        command.setNewValue(newValue);
        return command;
    }
    private Config config(String code, String value) {
        Config config = new Config();
        config.setCode(code);
        config.setValue(value);
        config.setStatus((short) 1);
        return config;
    }
    private BasStation station(Integer stationId, Integer outTaskLimit) {
        BasStation station = new BasStation();
        station.setStationId(stationId);
        station.setOutTaskLimit(outTaskLimit);
        return station;
    }
    private BasCrnp crn(Integer crnNo, Integer maxOutTask, Integer maxInTask) {
        BasCrnp crnp = new BasCrnp();
        crnp.setCrnNo(crnNo);
        crnp.setMaxOutTask(maxOutTask);
        crnp.setMaxInTask(maxInTask);
        return crnp;
    }
    private BasDualCrnp dualCrn(Integer crnNo, Integer maxOutTask, Integer maxInTask) {
        BasDualCrnp dualCrnp = new BasDualCrnp();
        dualCrnp.setCrnNo(crnNo);
        dualCrnp.setMaxOutTask(maxOutTask);
        dualCrnp.setMaxInTask(maxInTask);
        return dualCrnp;
    }
    private StationFlowCapacity capacity(Integer stationId, String directionCode, Integer bufferCapacity) {
        StationFlowCapacity capacity = new StationFlowCapacity();
        capacity.setStationId(stationId);
        capacity.setDirectionCode(directionCode);
        capacity.setBufferCapacity(bufferCapacity);
        return capacity;
    }
    private AiAutoTuneChange successChange(Long jobId,
                                           String targetType,
                                           String targetId,
                                           String targetKey,
                                           String oldValue,
                                           String appliedValue) {
        AiAutoTuneChange change = new AiAutoTuneChange();
        change.setJobId(jobId);
        change.setTargetType(targetType);
        change.setTargetId(targetId);
        change.setTargetKey(targetKey);
        change.setOldValue(oldValue);
        change.setAppliedValue(appliedValue);
        change.setResultStatus("success");
        return change;
    }
    private List<AiAutoTuneChange> savedChanges() {
        ArgumentCaptor<Collection<AiAutoTuneChange>> captor = ArgumentCaptor.forClass(Collection.class);
        verify(aiAutoTuneChangeService).saveBatch(captor.capture());
        return new ArrayList<>(captor.getValue());
    }
}