package cn.lihu.jh.module.ecg.dal.mysql.queuesequence;
|
|
import java.util.*;
|
|
import cn.lihu.jh.framework.common.pojo.PageResult;
|
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.*;
|
|
/**
|
* 当天序号 Mapper
|
*
|
* @author 金华医院
|
*/
|
@Mapper
|
public interface QueueSequenceMapper extends BaseMapperX<QueueSequenceDO> {
|
|
default PageResult<QueueSequenceDO> selectPage(QueueSequencePageReqVO reqVO) {
|
return selectPage(reqVO, new LambdaQueryWrapperX<QueueSequenceDO>()
|
.eqIfPresent(QueueSequenceDO::getCheckType, reqVO.getCheckType())
|
.eqIfPresent(QueueSequenceDO::getTimeSlot, reqVO.getTimeSlot())
|
.eqIfPresent(QueueSequenceDO::getQueueNo, reqVO.getQueueNo())
|
.eqIfPresent(QueueSequenceDO::getQueueVipNo, reqVO.getQueueVipNo())
|
.eqIfPresent(QueueSequenceDO::getQueueFull, reqVO.getQueueFull())
|
.eqIfPresent(QueueSequenceDO::getQueueVipFull, reqVO.getQueueVipFull())
|
.betweenIfPresent(QueueSequenceDO::getCreateTime, reqVO.getCreateTime())
|
// 【预约时段维护界面】按「检查类型 → 时段」升序排列,
|
// 便于在同一项目下顺序查看/编辑各时段(原实现按 id 倒序,维护时难以阅读)
|
.orderByAsc(QueueSequenceDO::getCheckType)
|
.orderByAsc(QueueSequenceDO::getTimeSlot));
|
}
|
|
@Select(" select * from lihu.queue_sequence where check_type=#{checkType}; ")
|
List<QueueSequenceDO> selectTimeslotByCheckType(Integer checkType);
|
|
@Select( "select count(1) from lihu.queue_sequence")
|
Integer getQueueSequenceTableRowCount();
|
|
@Delete("delete from lihu.queue_sequence where TO_DAYS(create_time) != TO_DAYS(NOW())")
|
void clearQueueSequenceTableNotCurrent();
|
|
@Update("truncate table lihu.queue_sequence")
|
void clearQueueSequenceTable();
|
|
@Select("select queue_no from lihu.queue_sequence where check_type=#{checkType} and time_slot=#{timeslot} and queue_no < queue_full for update; ")
|
Integer selectQueueNoForUpdate(@Param("checkType") Integer checkType, @Param("timeslot") Integer timeslot);
|
|
@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_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);
|
}
|