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

@@ -38,6 +38,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- spring-boot-web -->
<dependency>

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);
}
}
}

View File

@@ -0,0 +1,22 @@
package com.gxwebsoft.glt;
import com.gxwebsoft.glt.mapper.GltUserTicketLogMapper;
import com.gxwebsoft.glt.mapper.GltUserTicketMapper;
import com.gxwebsoft.glt.mapper.GltUserTicketReleaseMapper;
import com.gxwebsoft.glt.service.impl.GltUserTicketAutoReleaseServiceImpl;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Import;
@SpringBootConfiguration
@EnableAutoConfiguration
@MapperScan(basePackageClasses = {
GltUserTicketMapper.class,
GltUserTicketReleaseMapper.class,
GltUserTicketLogMapper.class
})
@Import(GltUserTicketAutoReleaseServiceImpl.class)
public class AutoReleaseTestApplication {
}

View File

@@ -0,0 +1,209 @@
package com.gxwebsoft.glt.service;
import com.gxwebsoft.glt.AutoReleaseTestApplication;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(
classes = AutoReleaseTestApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:auto_release;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.sql.init.mode=always",
"spring.sql.init.schema-locations=classpath:schema-auto-release-h2.sql",
"mybatis-plus.configuration.map-underscore-to-camel-case=true",
"mybatis-plus.global-config.db-config.logic-delete-value=1",
"mybatis-plus.global-config.db-config.logic-not-delete-value=0"
}
)
class GltUserTicketAutoReleaseServiceTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private GltUserTicketMapper userTicketMapper;
@Autowired
private GltUserTicketReleaseMapper releaseMapper;
@Autowired
private GltUserTicketLogMapper logMapper;
@Autowired
private GltUserTicketAutoReleaseService autoReleaseService;
@BeforeEach
void clean() {
jdbcTemplate.execute("DELETE FROM glt_user_ticket_log");
jdbcTemplate.execute("DELETE FROM glt_user_ticket_release");
jdbcTemplate.execute("DELETE FROM glt_user_ticket");
}
@Test
void releaseDue_success_updatesTicketReleaseAndWritesLog() {
LocalDateTime now = LocalDateTime.of(2026, 2, 6, 12, 0, 0);
GltUserTicket ticket = new GltUserTicket();
ticket.setTemplateId(1);
ticket.setGoodsId(1);
ticket.setOrderId(1);
ticket.setOrderNo("T-001");
ticket.setOrderGoodsId(1);
ticket.setTotalQty(10);
ticket.setAvailableQty(0);
ticket.setFrozenQty(10);
ticket.setUsedQty(0);
ticket.setReleasedQty(0);
ticket.setUserId(100);
ticket.setSortNumber(0);
ticket.setComments("test");
ticket.setStatus(0);
ticket.setDeleted(0);
ticket.setTenantId(10584);
ticket.setCreateTime(now);
ticket.setUpdateTime(now);
assertEquals(1, userTicketMapper.insert(ticket));
assertNotNull(ticket.getId());
GltUserTicketRelease rel = new GltUserTicketRelease();
rel.setUserTicketId(ticket.getId().longValue());
rel.setUserId(100);
rel.setPeriodNo(1);
rel.setReleaseQty(3);
rel.setReleaseTime(now.minusSeconds(1));
rel.setStatus(0);
rel.setDeleted(0);
rel.setTenantId(10584);
rel.setCreateTime(now);
rel.setUpdateTime(now);
assertEquals(1, releaseMapper.insert(rel));
assertNotNull(rel.getId());
int releasedCount = autoReleaseService.releaseDue(now, 100);
assertEquals(1, releasedCount);
GltUserTicket after = userTicketMapper.selectById(ticket.getId());
assertNotNull(after);
assertEquals(3, after.getAvailableQty());
assertEquals(7, after.getFrozenQty());
assertEquals(3, after.getReleasedQty());
GltUserTicketRelease relAfter = releaseMapper.selectById(rel.getId());
assertNotNull(relAfter);
assertEquals(1, relAfter.getStatus());
List<GltUserTicketLog> logs = logMapper.selectList(null);
assertEquals(1, logs.size());
assertEquals(ticket.getId(), logs.get(0).getUserTicketId());
assertEquals(3, logs.get(0).getChangeAvailable());
assertEquals(-3, logs.get(0).getChangeFrozen());
}
@Test
void releaseDue_notDue_noop() {
LocalDateTime now = LocalDateTime.of(2026, 2, 6, 12, 0, 0);
GltUserTicket ticket = new GltUserTicket();
ticket.setTotalQty(10);
ticket.setAvailableQty(0);
ticket.setFrozenQty(10);
ticket.setUsedQty(0);
ticket.setReleasedQty(0);
ticket.setUserId(100);
ticket.setStatus(0);
ticket.setDeleted(0);
ticket.setTenantId(10584);
ticket.setCreateTime(now);
ticket.setUpdateTime(now);
userTicketMapper.insert(ticket);
GltUserTicketRelease rel = new GltUserTicketRelease();
rel.setUserTicketId(ticket.getId().longValue());
rel.setUserId(100);
rel.setPeriodNo(1);
rel.setReleaseQty(3);
rel.setReleaseTime(now.plusDays(1));
rel.setStatus(0);
rel.setDeleted(0);
rel.setTenantId(10584);
rel.setCreateTime(now);
rel.setUpdateTime(now);
releaseMapper.insert(rel);
int releasedCount = autoReleaseService.releaseDue(now, 100);
assertEquals(0, releasedCount);
GltUserTicket after = userTicketMapper.selectById(ticket.getId());
assertNotNull(after);
assertEquals(0, after.getAvailableQty());
assertEquals(10, after.getFrozenQty());
GltUserTicketRelease relAfter = releaseMapper.selectById(rel.getId());
assertNotNull(relAfter);
assertEquals(0, relAfter.getStatus());
assertEquals(0, logMapper.selectCount(null));
}
@Test
void releaseDue_frozenInsufficient_marksFailed() {
LocalDateTime now = LocalDateTime.of(2026, 2, 6, 12, 0, 0);
GltUserTicket ticket = new GltUserTicket();
ticket.setTotalQty(2);
ticket.setAvailableQty(0);
ticket.setFrozenQty(2);
ticket.setUsedQty(0);
ticket.setReleasedQty(0);
ticket.setUserId(100);
ticket.setStatus(0);
ticket.setDeleted(0);
ticket.setTenantId(10584);
ticket.setCreateTime(now);
ticket.setUpdateTime(now);
userTicketMapper.insert(ticket);
GltUserTicketRelease rel = new GltUserTicketRelease();
rel.setUserTicketId(ticket.getId().longValue());
rel.setUserId(100);
rel.setPeriodNo(1);
rel.setReleaseQty(3);
rel.setReleaseTime(now.minusSeconds(1));
rel.setStatus(0);
rel.setDeleted(0);
rel.setTenantId(10584);
rel.setCreateTime(now);
rel.setUpdateTime(now);
releaseMapper.insert(rel);
int releasedCount = autoReleaseService.releaseDue(now, 100);
assertEquals(0, releasedCount);
GltUserTicket after = userTicketMapper.selectById(ticket.getId());
assertNotNull(after);
assertEquals(0, after.getAvailableQty());
assertEquals(2, after.getFrozenQty());
GltUserTicketRelease relAfter = releaseMapper.selectById(rel.getId());
assertNotNull(relAfter);
assertEquals(2, relAfter.getStatus());
assertEquals(0, logMapper.selectCount(null));
}
}

View File

@@ -0,0 +1,62 @@
DROP TABLE IF EXISTS glt_user_ticket_log;
DROP TABLE IF EXISTS glt_user_ticket_release;
DROP TABLE IF EXISTS glt_user_ticket;
CREATE TABLE glt_user_ticket (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT,
goods_id INT,
order_id INT,
order_no VARCHAR(64),
order_goods_id INT,
total_qty INT,
available_qty INT,
frozen_qty INT,
used_qty INT,
released_qty INT,
user_id INT,
sort_number INT,
comments VARCHAR(255),
status INT,
deleted INT DEFAULT 0,
tenant_id INT,
create_time TIMESTAMP,
update_time TIMESTAMP
);
CREATE TABLE glt_user_ticket_release (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_ticket_id BIGINT,
user_id INT,
period_no INT,
release_qty INT,
release_time TIMESTAMP,
status INT,
deleted INT DEFAULT 0,
tenant_id INT,
create_time TIMESTAMP,
update_time TIMESTAMP
);
CREATE TABLE glt_user_ticket_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_ticket_id INT,
change_type INT,
change_available INT,
change_frozen INT,
change_used INT,
available_after INT,
frozen_after INT,
used_after INT,
order_id INT,
order_no VARCHAR(64),
user_id INT,
sort_number INT,
comments VARCHAR(255),
status INT,
deleted INT DEFAULT 0,
tenant_id INT,
create_time TIMESTAMP,
update_time TIMESTAMP
);