| | |
| | | import cn.lihu.jh.framework.mybatis.core.query.LambdaQueryWrapperX; |
| | | import cn.lihu.jh.framework.mybatis.core.mapper.BaseMapperX; |
| | | import cn.lihu.jh.module.ecg.dal.dataobject.queuesequence.QueueSequenceDO; |
| | | import cn.lihu.jh.module.ecg.dal.dataobject.queuesequence.SeqCounterDO; |
| | | import com.baomidou.mybatisplus.annotation.InterceptorIgnore; |
| | | import org.apache.ibatis.annotations.*; |
| | | import cn.lihu.jh.module.ecg.controller.admin.queuesequence.vo.*; |
| | | |
| | |
| | | .eqIfPresent(QueueSequenceDO::getQueueFull, reqVO.getQueueFull()) |
| | | .eqIfPresent(QueueSequenceDO::getQueueVipFull, reqVO.getQueueVipFull()) |
| | | .betweenIfPresent(QueueSequenceDO::getCreateTime, reqVO.getCreateTime()) |
| | | .orderByDesc(QueueSequenceDO::getId)); |
| | | // 【预约时段维护界面】按「检查类型 → 时段」升序排列, |
| | | // 便于在同一项目下顺序查看/编辑各时段(原实现按 id 倒序,维护时难以阅读) |
| | | .orderByAsc(QueueSequenceDO::getCheckType) |
| | | .orderByAsc(QueueSequenceDO::getTimeSlot)); |
| | | } |
| | | |
| | | @Select(" select * from lihu.queue_sequence where check_type=#{checkType}; ") |
| | |
| | | @Update("update lihu.queue_sequence set queue_no = queue_no + 1 where check_type=#{checkType} and time_slot=#{timeslot} and queue_no=#{curQueueNo} and queue_no < queue_full; ") |
| | | Integer updateGivenCheckTypeTimeslotSeqNo(@Param("checkType") Integer checkType, @Param("timeslot") Integer timeslot, @Param("curQueueNo") Integer curQueueNo); |
| | | |
| | | @Select("select queue_vip_no from lihu.queue_sequence where check_type=#{checkType} and time_slot=#{timeslot} and queue_no < queue_full for update; ") |
| | | @Select("select queue_vip_no from lihu.queue_sequence where check_type=#{checkType} and time_slot=#{timeslot} and queue_vip_no < queue_vip_full for update; ") |
| | | Integer selectQueueVipNoForUpdate(@Param("checkType") Integer checkType, @Param("timeslot") Integer timeslot); |
| | | |
| | | @Update("update lihu.queue_sequence set queue_vip_no = queue_vip_no + 1 where check_type=#{checkType} and time_slot=#{timeslot} and queue_vip_no=#{curQueueVipNo} and queue_vip_no < queue_vip_full; ") |
| | | Integer updateGivenCheckTypeTimeslotVipSeqNo(@Param("checkType") Integer checkType, @Param("timeslot") Integer timeslot, @Param("curQueueVipNo") Integer curQueueVipNo); |
| | | |
| | | @Update("UPDATE lihu.queue_sequence SET queue_no=queue_set,queue_vip_no=queue_vip_set WHERE deleted=0; ") |
| | | Integer initNumber(); |
| | | |
| | | /** |
| | | * 【修复「时段不全/已满导致没法签到」】判断指定 (检查类型, 时段) 是否**还有可取号**。 |
| | | * <p> |
| | | * 注意:与 {@link #selectQueueNoForUpdate} 不同,本方法**不加 for update**、 |
| | | * 也不返回具体号值 —— 它只用于「向后寻找一个可用时段」时的探测,不参与并发取号, |
| | | * 真正取号仍由 {@code selectQueueNoForUpdate} 在事务内完成。 |
| | | * |
| | | * @param includeVip true 时同时要求 VIP 号也有余量(VIP 患者用) |
| | | * @return 1 = 有余量;0 = 该时段不存在或已满 |
| | | */ |
| | | @Select("select count(1) from lihu.queue_sequence " |
| | | + " where check_type = #{checkType} and time_slot = #{timeslot} " |
| | | + " and queue_no < queue_full " |
| | | + " and (#{includeVip} = 0 or queue_vip_no < queue_vip_full)") |
| | | Integer countAvailableTimeslot(@Param("checkType") Integer checkType, |
| | | @Param("timeslot") Integer timeslot, |
| | | @Param("includeVip") Integer includeVip); |
| | | |
| | | // ========================================================================= |
| | | // 【需求:排队序号规则 = 签到序号】计数器(seq_counter 表) |
| | | // |
| | | // 设计要点(两次实测踩坑后确定,勿改回): |
| | | // ① 普通号与预留号用**两个独立计数器** —— 单个计数器"运算后跳过"必然重号: |
| | | // 普通患者 candidate=10 → 跳过预留得 11;下一个 candidate=11 → 也得 11。 |
| | | // ② 推进用单条 INSERT..ON DUPLICATE KEY UPDATE(原子); |
| | | // 读回必须与推进处于**同一事务**,否则 REPEATABLE READ 会读到旧快照。 |
| | | // 实测:autoCommit=true 时 160 次取号仅 75 个唯一值;包事务后 160/160。 |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * 原子推进【普通号】计数器,并返回分配到的普通号。 |
| | | * |
| | | * <h4>计数器语义(关键,勿改)</h4> |
| | | * {@code normal_next} 存的是**上一次已分配出去的普通号**(首次为 0)。 |
| | | * 本次分配 {@code = normal_next + 1};若该值恰是预留号(10 的倍数), |
| | | * 则本次分配实际上要跳到 {@code +2},并把计数器一并推进到 {@code +2}。 |
| | | * |
| | | * <pre> |
| | | * normal_next: 0 → 分配1 → 1 → 分配2 → … → 9 → 分配11(跳过10) → 11 → 分配12 → … |
| | | * ↑ 计数器 +2,因此不会在下一次再产出 11 |
| | | * </pre> |
| | | * |
| | | * <h4>为什么不能只 +1</h4> |
| | | * 若计数器只 +1:{@code normal_next=10} 时分配 {@code nextNormalSeq(11)=11}, |
| | | * 而 {@code normal_next=11} 时又分配 11 —— **同一个号发两次**。 |
| | | * 单元测试 {@code SeqRuleEnumTest#testNextNormalSeq_shouldSkipMultiplesOfTen} 曾捕获此问题。 |
| | | * <p> |
| | | * 本 SQL 中的 {@code IF} 正是"跳过预留号时额外 +1",等价于 Java 的 |
| | | * {@code nextNormalSeq(normal_next + 1)},且**在一条语句内原子完成**。 |
| | | * |
| | | * @return 本次分配到的普通号 |
| | | */ |
| | | // ========================================================================== |
| | | // ⚠️ 必须加 @InterceptorIgnore(dataPermission = "true"),否则【预约确认直接 500】 |
| | | // |
| | | // 实测故障(2026-10-01 线上日志): |
| | | // org.mybatis.spring.MyBatisSystemException: ... UnsupportedOperationException |
| | | // at JsqlParserSupport.processInsert(JsqlParserSupport.java:107) |
| | | // at DataPermissionInterceptor.beforePrepare(DataPermissionInterceptor.java:86) |
| | | // ### The error may involve QueueSequenceMapper.advanceNormalSeq |
| | | // (调用链 appointment/confirm → distributeSeqNoByRule → nextCheckInSeqNo → advanceNormalSeq) |
| | | // |
| | | // 原因:MyBatis-Plus 的 DataPermissionInterceptor 会用 JSqlParser 解析每条 SQL, |
| | | // 而 JSqlParser 的 processInsert **不支持 MySQL 的 `INSERT ... ON DUPLICATE KEY UPDATE`** |
| | | // 语法,直接抛 UnsupportedOperationException;该异常穿透到业务层,导致签到失败。 |
| | | // —— 这是「框架拦截器」与「MySQL 专有语法」的冲突,与 SQL 本身是否正确无关。 |
| | | // |
| | | // 为什么可以、也应该忽略数据权限: |
| | | // 1) seq_counter 表**既没有 dept_id 也没有 user_id**,数据权限规则对它本就无意义; |
| | | // 2) 本语句是纯粹的原子计数器推进,不涉及任何需要按部门/用户隔离的数据。 |
| | | // ========================================================================== |
| | | @InterceptorIgnore(dataPermission = "true") |
| | | @Update("INSERT INTO lihu.seq_counter (check_type, seq_date, normal_next, reserved_next) " |
| | | + "VALUES (#{checkType}, #{seqDate}, 1, 10) " |
| | | + "ON DUPLICATE KEY UPDATE normal_next = normal_next + 1 " |
| | | + " + IF((normal_next + 1) % 10 = 0, 1, 0)") |
| | | int advanceNormalSeq(@Param("checkType") Integer checkType, |
| | | @Param("seqDate") java.time.LocalDate seqDate); |
| | | |
| | | /** |
| | | * 原子推进【预留号】计数器,并返回分配到的预留号(加急患者用)。 |
| | | * <p> |
| | | * 预留号序列:10、20、30…(固定为 10 的倍数)。 |
| | | * <p> |
| | | * 同样需要 {@code @InterceptorIgnore(dataPermission = "true")},原因见 |
| | | * {@link #advanceNormalSeq} 上方的详细说明(JSqlParser 不支持本语法)。 |
| | | * |
| | | * @return 本次分配到的预留号 |
| | | */ |
| | | @InterceptorIgnore(dataPermission = "true") |
| | | @Update("INSERT INTO lihu.seq_counter (check_type, seq_date, normal_next, reserved_next) " |
| | | + "VALUES (#{checkType}, #{seqDate}, 1, 10) " |
| | | + "ON DUPLICATE KEY UPDATE reserved_next = reserved_next + 10") |
| | | int advanceReservedSeq(@Param("checkType") Integer checkType, |
| | | @Param("seqDate") java.time.LocalDate seqDate); |
| | | |
| | | /** |
| | | * 读取两个计数器的当前值(即本次要分配的号)。 |
| | | * |
| | | * <h4>⚠️ 必须与 advance* 处于【同一事务】内</h4> |
| | | * MySQL 默认 REPEATABLE READ:若 SELECT 在独立事务中,会读到事务开始时的快照旧值, |
| | | * 从而把已被别人用掉的号重复发出去。 |
| | | * <p> |
| | | * 实测对比(8 线程 × 20 次 = 160 次取号): |
| | | * <table border="1"> |
| | | * <tr><th>方式</th><th>唯一值</th><th>结论</th></tr> |
| | | * <tr><td>autoCommit=true(推进与读回各自独立事务)</td><td>75 / 160</td><td>🔴 大量重号</td></tr> |
| | | * <tr><td>显式事务(BEGIN → 推进 → 读回 → COMMIT)</td><td><b>160 / 160</b></td><td>✅ 号值 1~160 连续无重</td></tr> |
| | | * </table> |
| | | * 调用方 {@code QueueSequenceServiceImpl#distributeSeqNoByRule} 已标注 {@code @Transactional}, |
| | | * 这是**必须保持**的前提:请勿把本方法挪到无事务的上下文中单独调用。 |
| | | * |
| | | * @return {@code [0]=normal_next, [1]=reserved_next} |
| | | */ |
| | | @Select("SELECT normal_next, reserved_next FROM lihu.seq_counter " |
| | | + " WHERE check_type = #{checkType} AND seq_date = #{seqDate}") |
| | | @Results({ |
| | | @Result(property = "normalNext", column = "normal_next"), |
| | | @Result(property = "reservedNext", column = "reserved_next") |
| | | }) |
| | | SeqCounterDO getSeqCounter(@Param("checkType") Integer checkType, |
| | | @Param("seqDate") java.time.LocalDate seqDate); |
| | | } |