1

问题背景

当先的课表系统中,有一项自动开关门的功能。系统每 2 分钟执行一次巡检

如果在开关门过程中失败了,会进行钉钉消息的推送提醒

现在我们面临一个这个的问题:

08:00 失败
08:02 失败
08:04 失败
08:06 失败
...

钉钉会一直接收失败的信息:
A322 开门失败
A322 开门失败
A322 开门失败
A322 开门失败
...

此时,管理群中的消息会发生爆炸💥

image.png

问题的关键

让我们来看看之前的失败处理机制:

/**
 * 处理单个教室的门状态
 * 逻辑说明:
 * 1. 根据教室查询该教室所有门的数据。
 * 2. 根据当前日期和时间判断教室门是否应该常开(期望状态 expectedOpen)。
 * 3. 遍历每一扇门:
 *      a. 获取门对应的控制器信息(DoorDevice)。
 *         - 如果门没有绑定控制器,跳过。
 *      b. 查询控制器上门的真实状态 realOpen(boolean,true=开,false=非开)。
 *      c. 如果控制器状态与期望状态不一致,向控制器下发指令进行状态修正:
 *         - 如果期望开门,设置为“常开”;
 *         - 否则设置为“在线”。
 *      e. 根据期望状态更新数据库中门的状态:
 * 4. 异常处理:
 *      - 方法内部捕获异常,保证定时任务不会因为单门异常而中断。
 *
 * @param room 当前教室
 * @param term 当前学期
 * @param date 当前日期
 * @param now  当前时间
 */
public Boolean handleRoomDoor(Room room, Term term, LocalDate date, LocalTime now) {
    // 1. 根据教室查询对应的门
    List<LockAccess> lockAccesses = this.lockAccessService.findByRoom(room);

    // 2. 当前时间是否应该常开
    Boolean expectedOpen = this.doorShouldBeOpen(room, term, date, now);
    boolean operated = false;

    for (LockAccess lockAccess : lockAccesses) {
        try {
            // 1 手动控制期判断
            if (isManualControlMode(lockAccess)) {
                logger.debug("教室 {} 处于手动控制期,跳过自动巡检", room.getName());
                continue;
            }

            // 2 自动控制
            Boolean result = handleAutoControlModeDoor(room, lockAccess, expectedOpen);
            if (result == null) {continue;}   // 不用进行操作
            operated = true;
            if (!result) { return false; }   // 👈 开门失败返回为false
        } catch (Exception e) {
            // todo: 抛出异常
            logger.warn("【自动开门】异常:room=" + room.getName()  + ", doorId=" + lockAccess.getId() + ", msg=" + e.getMessage());
            return false;    // 👈 失败返回为false
        }
    }
    return operated ? true : null;
}

可以看得出我们对失败的情况,并没有去处理,而仅仅的返回的一个 false,这样的话,在定时任务 checkAndCorrentDoorStatus 这个方法中会每 2 分钟执行,然后发送信息

@Scheduled(fixedDelay = 2 * 60 * 1000, initialDelay = 30 * 1000)
    public void checkAndCorrectDoorStatus() {
        // 1.获取对应的学期
        Term term = this.termRepository.findTermsByStateIsTrueAndDeletedFalse()
                .stream()
                .findFirst()
                .orElseThrow(() -> new RuntimeException("没有找到激活学期"));

        LocalDate today = LocalDate.now();
        LocalTime now = LocalTime.now();

        // 2. 获取所有的教室数据
        List<Room> rooms = (List<Room>) this.roomRepository.findAll();
        Map<Boolean, List<String>> successRoomsMap = new HashMap<>();        // 操作成功的教室
        Map<Boolean, List<String>> failRoomsMap = new HashMap<>();           // 操作失败的教室

        successRoomsMap.put(true, new ArrayList<>());
        successRoomsMap.put(false, new ArrayList<>());

        failRoomsMap.put(true, new ArrayList<>());
        failRoomsMap.put(false, new ArrayList<>());

        // 按房间号升序排序
        rooms.sort(Comparator.comparing(Room::getName));

        // 3. 遍历所有的教室数据,去进行开门或关门操作
        for (Room room : rooms) {
            Boolean result = this.handleRoomDoor(room, term, today, now);     // 👈 这里调用
            boolean expectedOpen = this.doorShouldBeOpen(room, term, today, now);
            if (result == null) {
                continue;
            }
            // 处理门的结果是否成功
            if (result) {
                successRoomsMap.get(expectedOpen).add(room.getName());
            } else {
                failRoomsMap.get(expectedOpen).add(room.getName());
            }
        }
        // 此处的true和false意味着是否期待开门,true:开门    false:关门
        List<String> openSuccessRooms = successRoomsMap.get(true);
        List<String> openFailRooms = failRoomsMap.get(true);
        List<String> closeFailRooms = failRoomsMap.get(false);
        this.sendDingDingMessage(openSuccessRooms, openFailRooms, closeFailRooms);
    }
sequenceDiagram
    participant Scheduler as 定时调度器
    participant Task as checkAndCorrectDoorStatus
    participant Handler as handleRoomDoor
    participant DingTalk as 钉钉消息

    loop 每2分钟执行一次
        Scheduler->>Task: 触发定时任务
        
        Task->>Task: 获取学期/日期/时间/教室列表
        
        loop 遍历每个教室
            Task->>Handler: handleRoomDoor(room)
            Handler-->>Task: 返回 true / false / null
        end
        
        Task->>Task: 统计成功/失败房间
        
        alt 存在失败房间
            Task->>DingTalk: 发送失败告警消息
            Note over DingTalk: 🔴 包含失败房间列表
        else 全部成功
            Task->>DingTalk: 发送成功通知(可选)
        end
        
        Note over Scheduler,DingTalk: ⏰ 2分钟后再次执行,问题未修复则重复发送
    end

如何解决呢

本质上,我们需要对开/关门失败的教室进行处理。对反复失败的教室进行标记,同时还需要有一定次数的重试机制。而不是一直重复一直重复

解决方案

最后的解决方案,还是得益于老师的思想点播

自己的浅层想法

🤔 一开始我的解决方法:失败后,尝试 5 次。
如果第 5 次仍旧失败。那么在此推送一条提醒信息(类似于 A322开门失败,已连续尝试5次,停止本轮重试。)并且停止尝试,等到下一个关门的时间点,才再次尝试

在尝试的这 5 次中,如果有一次成功,那么发送消息。如果一直都是失败,只在最后一次发送消息

老师给出的具体方向 🧭

潘老师给出了更为详细的实现方向:

image.png

老师并没有 失败多少次 的角度思考,而是从 状态是否发生变化 的角度来考虑解决方向(这个非常接近 Angular 的变更检测思想,关注状态而非其他)

最后呢,稍微将两种方式结合了一下。既实现老师提供的消息缓存、对比是否发生变化,如果发生变化再进行消息的发送;同时,实现次数限制的失败熔断机制

最终实现

核心实现的时序图:

🟢 熔断机制(5次失败后停止重试)

flowchart TD
    A[开门/关门失败] --> B[失败计数 +1]
    B --> C{失败次数 == 5?}
    C -->|否| D[继续重试<br/>不发送消息]
    C -->|是| E[设置熔断=true]
    E --> F[发送钉钉消息]
    F --> G[后续定时任务跳过该教室]
    
    H[操作成功] --> I[重置失败计数=0]
    I --> J[熔断=false]
    J --> K[发送恢复通知]
            

消息去重机制

flowchart LR
    A[准备发送消息] --> B{消息类型?}
    B -->|INFO| C[直接发送<br/>成功/恢复通知]
    B -->|ERROR| D{与缓存消息对比?}
    D -->|相同| E[跳过发送<br/>避免重复]
    D -->|不同| F[更新缓存]
    F --> G[发送钉钉消息]
            
1. 消息类型的规定
/**
 * 自动开关门发送钉钉消息的类型
 */
@Getter
public enum DoorMessageType {
    INFO((byte) 0, "自动开关门成功"),
    ERROR((byte) 1, "自动开关门失败");

    private final byte value;
    private final String description;

    DoorMessageType(byte value, String description) {
        this.value = value;
        this.description = description;
    }
}
2. 用于标记重试次数的数据model
/**
 * 自动开关门失败次数、是否停止重试数据模型
 * 用于判断重试次数是否达到熔断次数;达到=>停止重试
 */
@Data
public class DoorRetryState {
    private Boolean expectedOpen;   // 当前目标状态(true:开门;false:关门)
    private int failCount;          // 连续失败次数
    private boolean exhausted;      // 是否已经停止重试
}
3. 新增统一的钉钉信息发送入口方法
/**
 * 根据信息状态来决定是否发送钉钉消息
 * - 当 type = INFO 时,自动开关门成功:此时不发送消息,而是打印日志
 * - 当 type = ERROR 时,自动开关门失败:
 * 此时,判断 message 与上次消息是否一致。一致,不发送;不一致,发送失败信息
 * @param type 门锁信息类型
 * @param message 消息内容
 */
public void sendDingMessage(DoorMessageType type, String message) {
    if (type == DoorMessageType.INFO) {
        this.dingDingService.postToDingDingRobotOfText(message);
        return;
    }

    String cacheMessage = messageCacheMap.get(type);
    if (Objects.equals(cacheMessage, message)) {
        logger.debug("动开关门失败消息未变化,跳过发送!");
        return;
    }

    messageCacheMap.put(type, message);
    this.dingDingService.postToDingDingRobotOfText(message);
}
4. 添加所需要的重试相关方法
  1. 获取重试状态
/**
 * 获取重试状态
 * 
 * @param room 实验室
 * @param expectedOpen 目标状态
 * @return 返回重置后的“重试状态”
 */
public DoorRetryState getRetryState(Room room, boolean expectedOpen) {
    DoorRetryState state = retryStateMap.computeIfAbsent(room.getId(), id -> new DoorRetryState());

    if (!Objects.equals(state.getExpectedOpen(), expectedOpen)) {
        logger.info("教室[{}]进入新的控制阶段,重置重试状态", room.getName());
        state.setExpectedOpen(expectedOpen);
        state.setFailCount(0);
        state.setExhausted(false);
    }

    return state;
}
  1. 开门失败的处理
/**
 * 教室开关门失败处理
 * 开关门失败后,设置 DoorRetryState 的失败信息
 * 在尝试 5 次仍旧失败后,发送钉钉消息提示用户
 * @param room 实验教室
 * @param expectedOpen 目标状态
 */
public void recordFail(Room room, boolean expectedOpen) {
    DoorRetryState state = getRetryState(room, expectedOpen);
    state.setFailCount(state.getFailCount() + 1);
    logger.warn("教室[{}]自动{}失败,第{}次", room.getName(), expectedOpen ? "开门" : "关门", state.getFailCount());

    if (state.getFailCount() == 5) {
        state.setExhausted(true);

        String message = String.format("【课表自动开门】\\n\\n%s%s连续失败5次,系统已停止重试", room.getName(), expectedOpen ? "开门" : "关门");
        sendDingMessage(DoorMessageType.ERROR, message);

        logger.error("教室[{}]自动{}连续失败5次,停止重试", room.getName(), expectedOpen ? "开门" : "关门");
    }
}
  1. 重试成功的处理
/**
 * 重试过程中,成功开关门的处理
 * 在重试过程中,如果成功开/关门。此时清空失败计数,并设置exhausted=false
 * @param room 实验教室
 * @param expectedOpen 目标状态
 */
public void recordSuccess(Room room, boolean expectedOpen) {
    DoorRetryState state = getRetryState(room, expectedOpen);
    if (state.getFailCount() > 0) {
        logger.info("教室[{}]自动{}恢复成功,清空失败计数", room.getName(), expectedOpen ? "开门" : "关门");
        String message = String.format(
                "【课表自动开门】\n\n%s%s恢复成功",
                room.getName(),
                expectedOpen ? "开门" : "关门"
        );
        sendDingMessage(DoorMessageType.INFO, message);
    }

    state.setFailCount(0);
    state.setExhausted(false);
}
5. 在 handleRoomDoor 中添加失败的处理
public Boolean handleRoomDoor(Room room, Term term, LocalDate date, LocalTime now) {
        // 1. 根据教室查询对应的门
        List<LockAccess> lockAccesses = this.lockAccessService.findByRoom(room);

        // 2. 当前时间是否应该常开
        Boolean expectedOpen = this.doorShouldBeOpen(room, term, date, now);

        // 3. 判断当前教室是否已熔断 👈
        DoorRetryState retryState = getRetryState(room, expectedOpen);
        if (retryState.isExhausted()) {
            logger.warn("教室[{}]自动{}已熔断,跳过重试", room.getName(), expectedOpen ? "开门" : "关门");
            return null;
        }

        boolean operated = false;
        for (LockAccess lockAccess : lockAccesses) {
            try {
                // 1 手动控制期判断
                if (isManualControlMode(lockAccess)) {
                    logger.debug("教室 {} 处于手动控制期,跳过自动巡检", room.getName());
                    continue;
                }

                // 2 自动控制
                Boolean result = handleAutoControlModeDoor(room, lockAccess, expectedOpen);
                if (result == null) {continue;}   // 不用进行操作
                operated = true;
                if (!result) {                    // 失败返回为false 👈
                    recordFail(room, expectedOpen);
                    return false;
                }
            } catch (Exception e) {
                // todo: 抛出异常
                logger.warn("【自动开门】异常:room=" + room.getName()  + ", doorId=" + lockAccess.getId() + ", msg=" + e.getMessage());
                recordFail(room, expectedOpen);     // 异常返回为false 👈
                return false;
            }
        }
        if (operated) {                         // 成功调用 recordSuccess 👈
            recordSuccess(room, expectedOpen);
        }
        return operated ? true : null;
    }

修改后的自动开关门的时序图如下:

sequenceDiagram
    participant Task as 定时任务
    participant Handler as 门处理器
    participant Retry as 重试状态
    participant Cache as 消息缓存

    loop 每2分钟
        Task->>Handler: 处理所有教室
        
        loop 每个教室
            Handler->>Retry: 获取重试状态
            
            alt 已熔断(失败≥5次)
                Handler-->>Task: 跳过该教室
            else 未熔断
                Handler->>Handler: 操作门
                
                alt 操作失败
                    Handler->>Retry: 失败次数+1
                    
                    alt 第5次失败
                        Retry->>Cache: 发送消息
                        Cache->>Cache: 去重检查
                        Note over Cache: 消息不变则跳过
                        Retry->>Retry: 开启熔断
                    end
                else 操作成功
                    Handler->>Retry: 重置失败计数
                    Note over Retry: 关闭熔断
                end
            end
        end
    end
            

测试

修改完代码之后,最重要的就是测试。

但是呢,我遇到一个棘手的问题。当前我的电脑没办法连接上第三方的服务端(提供门锁控制的)

之前系统考虑到了这种情况,按道理来说启用的 mock 模式,是不需要真实的连接上第三方的微耕服务的。
但是,我们的系统并没有完全实现 mock,在系统启动之初,startup 中的 V005SyncDoorStatus 会真实的调用 wgWebApi 这个API,也就是说需要真实的与第三方的微耕服务建立正确的连接

control-door:
    type: mock

❓我们该如何测试呢?

🔑 单元测试!!!!

我们当前只需要测试,我们所实现的功能是否正常;因为我们不需要管是否能正常开门,我们需要管的是:如果存在多条相同的错误提示,是否会2min间隔重复发送

所以,单元测试是一个正确的且不二选择

这里只展示2个稍微重要一点的单元测试:

@ExtendWith(MockitoExtension.class)
public class AutoControllerDoorScheduleTest {
    @InjectMocks
    private AutoControlDoorSchedule autoControlDoorSchedule;

    @Mock
    private DingDingService dingDingService;

    private Room room;



    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(autoControlDoorSchedule, "messageCacheMap", new HashMap<>());
        room = new Room();
        room.setId(1L);
        room.setName("A322");
    }

    /**
     * 测试首次开门成功是否发送钉钉消息
     */
    @Test
    void infoMessage_shouldSendDirectly() {
        autoControlDoorSchedule.sendDingMessage(
                DoorMessageType.INFO,
                "开门成功");

        verify(dingDingService).postToDingDingRobotOfText("开门成功");
    }

    /**
     * 测试多次不同信息的开门失败,是否会发送多条钉钉信息
     */
    @Test
    void errorMessage_differentMessage_shouldSendThreeTimes() {
        List<String> messages = List.of(
                "A开门失败",
                "B开门失败",
                "C开门失败");

        messages.forEach(message -> {
            autoControlDoorSchedule.sendDingMessage(DoorMessageType.ERROR, message);
        });

        verify(dingDingService, times(3)).postToDingDingRobotOfText(anyString());
    }

    /**
     * 测试多次相同的开门失败信息是否只发送一次钉钉消息
     */
    @Test
    void errorMessage_sameMessage_shouldSendOnlyOnce() {
        String message = "A315开门失败";
        autoControlDoorSchedule.sendDingMessage(DoorMessageType.ERROR, message);
        autoControlDoorSchedule.sendDingMessage(DoorMessageType.ERROR, message);
        autoControlDoorSchedule.sendDingMessage(DoorMessageType.ERROR, message);

        verify(dingDingService, times(1)).postToDingDingRobotOfText(message);
    }

    /**
     * 测试连续失败5次,是否会发送包含“连续失败5次”的钉钉信息(用于提醒用户,系统不在进行尝试)
     */
    @Test
    void recordFail_shouldSendAlarWhenFailFiveTimes() {
        for (int i = 0; i < 5; i ++) {
            autoControlDoorSchedule.recordFail(room, true);
        }
        verify(dingDingService, times(1)).postToDingDingRobotOfText(contains("连续失败5次"));
    }

    /**
     * 测试前3次失败,第4次成功时,是否会发送包含“恢复成功”的钉钉信息(且只调用1次钉钉消息发送的API)
     */
    @Test
    void recordSuccess_shouldSendRecoverMessageAfterFailures() {
        autoControlDoorSchedule.recordFail(room, true);
        autoControlDoorSchedule.recordFail(room, true);
        autoControlDoorSchedule.recordFail(room, true);

        autoControlDoorSchedule.recordSuccess(room, true);
        verify(dingDingService, times(1)).postToDingDingRobotOfText(contains("恢复成功"));
    }
}

测试通过截图(只展示部分)

image.png

结语

通过这次需求开发,我最大的收获并不是实现了一个自动开关门的优化功能,而是体会到了系统设计与业务思考的重要性。

很多时候,解决问题的第一反应往往只是让功能能够运行起来,但一个更合理的方案,往往需要结合业务场景、用户体验以及系统行为进行综合考虑。这些思考并不是短时间内就能形成的,而是需要不断学习和积累。

同时,也很感谢潘老师在这个过程中给予的指导。相比直接给出答案,老师更多的是引导我从不同角度分析问题,让我对“状态变化”“告警设计”“单元测试”等内容有了更深入的理解。这样的实践机会十分宝贵,也让我对软件开发有了更多思考。


vuxuan
71 声望9 粉丝