feat(tickets): 实现冻结水票自动释放功能

- 在 GltUserTicketMapper 中新增 releaseFrozenQty 方法用于释放冻结水票
- 新增 GltUserTicketReleaseMapper 处理释放记录的查询和状态更新
- 添加 H2 数据库依赖支持单元测试
- 创建 GltUserTicketAutoReleaseService 接口及其实现类
- 实现自动释放服务的核心逻辑包括加锁查询、数量更新和流水记录
- 新建定时任务 GltUserTicketAutoReleaseTask 定期执行释放操作
- 添加完整的单元测试覆盖正常释放、未到期和冻结不足等场景
- 创建测试专用数据库表结构文件
This commit is contained in:
2026-02-07 00:25:24 +08:00
parent 46de27611d
commit a20d1dd465
9 changed files with 569 additions and 0 deletions

View File

@@ -5,8 +5,10 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.glt.param.GltUserTicketParam;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -63,4 +65,28 @@ public interface GltUserTicketMapper extends BaseMapper<GltUserTicket> {
@Param("userId") Integer userId,
@Param("tenantId") Integer tenantId);
/**
* 释放冻结水票(冻结 -> 可用;并累加已释放数量)
* <p>
* 返回值为受影响行数1 表示释放成功0 表示记录不存在/状态不符/冻结不足。
*/
@Update("""
UPDATE glt_user_ticket
SET available_qty = COALESCE(available_qty, 0) + #{qty},
frozen_qty = COALESCE(frozen_qty, 0) - #{qty},
released_qty = COALESCE(released_qty, 0) + #{qty},
update_time = #{now}
WHERE id = #{id}
AND user_id = #{userId}
AND tenant_id = #{tenantId}
AND status = 0
AND deleted = 0
AND COALESCE(frozen_qty, 0) >= #{qty}
""")
int releaseFrozenQty(@Param("id") Integer id,
@Param("userId") Integer userId,
@Param("tenantId") Integer tenantId,
@Param("qty") Integer qty,
@Param("now") LocalDateTime now);
}

View File

@@ -4,8 +4,11 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.glt.entity.GltUserTicketRelease;
import com.gxwebsoft.glt.param.GltUserTicketReleaseParam;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -34,4 +37,37 @@ public interface GltUserTicketReleaseMapper extends BaseMapper<GltUserTicketRele
*/
List<GltUserTicketRelease> selectListRel(@Param("param") GltUserTicketReleaseParam param);
/**
* 查询待释放且到期的记录(加行锁,防止多实例重复处理)
*
* status: 0=待释放, 1=已释放, 2=释放失败(数据异常)
*/
@Select("""
SELECT *
FROM glt_user_ticket_release
WHERE status = 0
AND deleted = 0
AND release_time <= #{now}
ORDER BY release_time ASC, id ASC
LIMIT #{limit}
FOR UPDATE
""")
List<GltUserTicketRelease> selectDueForUpdate(@Param("now") LocalDateTime now,
@Param("limit") int limit);
/**
* 更新释放记录状态
*/
@Update("""
UPDATE glt_user_ticket_release
SET status = #{status},
update_time = #{now}
WHERE id = #{id}
AND deleted = 0
AND status = 0
""")
int updateStatus(@Param("id") Long id,
@Param("status") Integer status,
@Param("now") LocalDateTime now);
}

View File

@@ -0,0 +1,26 @@
package com.gxwebsoft.glt.service;
import java.time.LocalDateTime;
/**
* 冻结水票自动释放(到达 release_time 后执行frozen -> available
*/
public interface GltUserTicketAutoReleaseService {
/**
* 释放到期的冻结水票
*
* @param now 当前时间(用于测试/可控时钟)
* @param batchSize 单次处理上限
* @return 成功释放的条数
*/
int releaseDue(LocalDateTime now, int batchSize);
/**
* 释放到期的冻结水票(使用系统当前时间)
*/
default int releaseDue(int batchSize) {
return releaseDue(LocalDateTime.now(), batchSize);
}
}

View File

@@ -0,0 +1,132 @@
package com.gxwebsoft.glt.service.impl;
import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.glt.entity.GltUserTicketLog;
import com.gxwebsoft.glt.entity.GltUserTicketRelease;
import com.gxwebsoft.glt.mapper.GltUserTicketLogMapper;
import com.gxwebsoft.glt.mapper.GltUserTicketMapper;
import com.gxwebsoft.glt.mapper.GltUserTicketReleaseMapper;
import com.gxwebsoft.glt.service.GltUserTicketAutoReleaseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 冻结水票自动释放实现:
* - 读取到期且待释放的 release 记录FOR UPDATE 加锁,防止重复处理)
* - 释放成功:更新 user_ticket 数量 & 将 release.status 置为 1并写入流水
* - 释放失败:将 release.status 置为 2数据异常/冻结不足等)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GltUserTicketAutoReleaseServiceImpl implements GltUserTicketAutoReleaseService {
/**
* 变更类型:冻结释放
* <p>
* 现有发放类型为 10见 GltTicketIssueService.CHANGE_TYPE_ISSUE这里取 11。
*/
private static final int CHANGE_TYPE_RELEASE = 11;
/** release.status待释放 */
private static final int RELEASE_STATUS_PENDING = 0;
/** release.status已释放 */
private static final int RELEASE_STATUS_DONE = 1;
/** release.status释放失败数据异常/冻结不足等,需人工处理) */
private static final int RELEASE_STATUS_FAILED = 2;
private final GltUserTicketReleaseMapper releaseMapper;
private final GltUserTicketMapper userTicketMapper;
private final GltUserTicketLogMapper userTicketLogMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public int releaseDue(LocalDateTime now, int batchSize) {
int limit = Math.max(batchSize, 1);
List<GltUserTicketRelease> dueList = releaseMapper.selectDueForUpdate(now, limit);
if (dueList == null || dueList.isEmpty()) {
return 0;
}
int success = 0;
for (GltUserTicketRelease rel : dueList) {
if (rel.getId() == null || rel.getStatus() == null || rel.getStatus() != RELEASE_STATUS_PENDING) {
continue;
}
Integer qtyObj = rel.getReleaseQty();
if (qtyObj == null || qtyObj <= 0) {
markFailed(rel.getId(), now, "releaseQty无效");
continue;
}
int qty = qtyObj;
if (rel.getUserTicketId() == null || rel.getUserId() == null || rel.getTenantId() == null) {
markFailed(rel.getId(), now, "缺少userTicketId/userId/tenantId");
continue;
}
long userTicketIdLong = rel.getUserTicketId();
if (userTicketIdLong > Integer.MAX_VALUE || userTicketIdLong < 1) {
markFailed(rel.getId(), now, "userTicketId超范围");
continue;
}
Integer userTicketId = (int) userTicketIdLong;
// 先释放冻结数量(条件更新,确保 frozen_qty >= qty
int updated = userTicketMapper.releaseFrozenQty(
userTicketId,
rel.getUserId(),
rel.getTenantId(),
qty,
now
);
if (updated <= 0) {
markFailed(rel.getId(), now, "冻结不足/水票不存在/状态不符");
continue;
}
// 写入流水(可用 +qty冻结 -qty
GltUserTicket ticket = userTicketMapper.selectById(userTicketId);
GltUserTicketLog logRow = new GltUserTicketLog();
logRow.setUserTicketId(userTicketId);
logRow.setChangeType(CHANGE_TYPE_RELEASE);
logRow.setChangeAvailable(qty);
logRow.setChangeFrozen(-qty);
logRow.setChangeUsed(0);
if (ticket != null) {
logRow.setAvailableAfter(ticket.getAvailableQty());
logRow.setFrozenAfter(ticket.getFrozenQty());
logRow.setUsedAfter(ticket.getUsedQty());
}
logRow.setOrderId(null);
logRow.setOrderNo(null);
logRow.setUserId(rel.getUserId());
logRow.setSortNumber(0);
logRow.setComments("冻结水票到期释放");
logRow.setStatus(0);
logRow.setDeleted(0);
logRow.setTenantId(rel.getTenantId());
logRow.setCreateTime(now);
logRow.setUpdateTime(now);
userTicketLogMapper.insert(logRow);
// 标记释放记录已完成(放在最后:若流水失败则回滚)
releaseMapper.updateStatus(rel.getId(), RELEASE_STATUS_DONE, now);
success++;
}
return success;
}
private void markFailed(Long releaseId, LocalDateTime now, String reason) {
releaseMapper.updateStatus(releaseId, RELEASE_STATUS_FAILED, now);
log.warn("冻结水票释放标记失败 - releaseId={}, reason={}", releaseId, reason);
}
}

View File

@@ -0,0 +1,51 @@
package com.gxwebsoft.glt.task;
import com.gxwebsoft.common.core.annotation.IgnoreTenant;
import com.gxwebsoft.glt.service.GltUserTicketAutoReleaseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 冻结水票自动释放任务:
* - 扫描 glt_user_ticket_release 中到期且待释放status=0的记录
* - 释放成功frozen -> available并将 release.status 置为 1
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "glt.ticket.auto-release", name = "enabled", havingValue = "true", matchIfMissing = true)
public class GltUserTicketAutoReleaseTask {
private final GltUserTicketAutoReleaseService autoReleaseService;
@Value("${glt.ticket.auto-release.batch-size:200}")
private int batchSize;
private final AtomicBoolean running = new AtomicBoolean(false);
@Scheduled(cron = "${glt.ticket.auto-release.cron:0 */1 * * * ?}")
@IgnoreTenant("定时任务无登录态,需忽略租户隔离;释放记录自带 tenantId更新时会校验 tenantId")
public void run() {
if (!running.compareAndSet(false, true)) {
log.warn("冻结水票自动释放任务仍在执行中,本轮跳过");
return;
}
try {
LocalDateTime now = LocalDateTime.now();
int released = autoReleaseService.releaseDue(now, Math.max(batchSize, 1));
if (released > 0) {
log.info("冻结水票自动释放完成 - released={}, now={}", released, now);
}
} finally {
running.set(false);
}
}
}