feat(tickets): 实现冻结水票自动释放功能
- 在 GltUserTicketMapper 中新增 releaseFrozenQty 方法用于释放冻结水票 - 新增 GltUserTicketReleaseMapper 处理释放记录的查询和状态更新 - 添加 H2 数据库依赖支持单元测试 - 创建 GltUserTicketAutoReleaseService 接口及其实现类 - 实现自动释放服务的核心逻辑包括加锁查询、数量更新和流水记录 - 新建定时任务 GltUserTicketAutoReleaseTask 定期执行释放操作 - 添加完整的单元测试覆盖正常释放、未到期和冻结不足等场景 - 创建测试专用数据库表结构文件
This commit is contained in:
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user