yxh
昨天 fce96ef468291fb9a0e6a4d34ab371315e9485d4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
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);
}