feat(ticket): 添加套票发放定时任务和核心服务

- 实现 GltTicketIssue10584Task 定时任务,每分钟扫描今日订单并生成套票账户
- 创建 GltTicketIssueService 服务,处理从订单生成用户套票和释放计划的完整流程
- 支持幂等处理,防止重复发放套票
- 实现月度释放计划生成功能,支持首期立即释放或次月释放模式
- 添加多租户支持和并发控制,确保任务执行安全
- 集成订单状态检查、套票模板验证和发放流水记录功能
This commit is contained in:
2026-02-03 21:25:13 +08:00
parent 27baa6ecf7
commit d393de816f
2 changed files with 380 additions and 0 deletions

View File

@@ -0,0 +1,334 @@
package com.gxwebsoft.glt.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.glt.entity.GltTicketTemplate;
import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.glt.entity.GltUserTicketLog;
import com.gxwebsoft.glt.entity.GltUserTicketRelease;
import com.gxwebsoft.shop.entity.ShopOrder;
import com.gxwebsoft.shop.entity.ShopOrderGoods;
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
import com.gxwebsoft.shop.service.ShopOrderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* 套票发放(从订单生成用户套票 + 释放计划)的业务逻辑。
*
* 说明:
* - 定时任务无登录态时MyBatis-Plus 多租户插件可能拿不到 tenantId
* 外层任务方法会通过 @IgnoreTenant 禁用租户拦截,本服务内部强制用 tenantId 过滤。
* - 幂等:以 (tenantId, templateId, orderNo, orderGoodsId) 判断是否已发放。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GltTicketIssueService {
public static final int CHANGE_TYPE_ISSUE = 10;
private final ShopOrderService shopOrderService;
private final ShopOrderGoodsService shopOrderGoodsService;
private final GltTicketTemplateService gltTicketTemplateService;
private final GltUserTicketService gltUserTicketService;
private final GltUserTicketReleaseService gltUserTicketReleaseService;
private final GltUserTicketLogService gltUserTicketLogService;
private final TransactionTemplate transactionTemplate;
/**
* 扫描“今日订单”,执行套票发放。
*/
public void issueTodayOrders(Integer tenantId, Integer formId) {
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime tomorrowStart = todayStart.plusDays(1);
List<ShopOrder> orders = shopOrderService.list(
new LambdaQueryWrapper<ShopOrder>()
.eq(ShopOrder::getTenantId, tenantId)
.eq(ShopOrder::getFormId, formId)
.eq(ShopOrder::getPayStatus, true)
.eq(ShopOrder::getOrderStatus, 0)
// 今日订单(兼容:以 create_time 或 pay_time 任一落在今日即可)
.and(w -> w
.ge(ShopOrder::getCreateTime, todayStart).lt(ShopOrder::getCreateTime, tomorrowStart)
.or()
.ge(ShopOrder::getPayTime, todayStart).lt(ShopOrder::getPayTime, tomorrowStart)
)
.orderByAsc(ShopOrder::getPayTime)
.orderByAsc(ShopOrder::getOrderId)
);
if (orders.isEmpty()) {
log.debug("套票发放扫描:今日无符合条件的订单 tenantId={}, formId={}", tenantId, formId);
return;
}
int success = 0;
int skipped = 0;
int failed = 0;
for (ShopOrder order : orders) {
try {
int issuedCount = issueForOrder(tenantId, formId, order);
if (issuedCount > 0) {
success += issuedCount;
} else {
skipped++;
}
} catch (Exception e) {
failed++;
log.error("套票发放失败 - tenantId={}, orderNo={}, orderId={}",
tenantId, order.getOrderNo(), order.getOrderId(), e);
}
}
log.info("套票发放扫描完成 - tenantId={}, formId={}, 订单数={}, 发放成功={}, 跳过={}, 失败={}",
tenantId, formId, orders.size(), success, skipped, failed);
}
private int issueForOrder(Integer tenantId, Integer formId, ShopOrder order) {
List<ShopOrderGoods> goodsList = shopOrderGoodsService.getListByOrderIdIgnoreTenant(order.getOrderId());
if (goodsList == null || goodsList.isEmpty()) {
return 0;
}
int issuedCount = 0;
for (ShopOrderGoods og : goodsList) {
if (!Objects.equals(og.getGoodsId(), formId)) {
continue;
}
boolean issued = Boolean.TRUE.equals(transactionTemplate.execute(status -> doIssueOne(tenantId, order, og)));
if (issued) {
issuedCount++;
}
}
return issuedCount;
}
private boolean doIssueOne(Integer tenantId, ShopOrder order, ShopOrderGoods og) {
if (order.getUserId() == null) {
log.warn("套票发放跳过:订单缺少 userId - tenantId={}, orderNo={}", tenantId, order.getOrderNo());
return false;
}
if (og.getId() == null) {
log.warn("套票发放跳过:订单商品缺少 id - tenantId={}, orderNo={}", tenantId, order.getOrderNo());
return false;
}
GltTicketTemplate template = gltTicketTemplateService.getOne(
new LambdaQueryWrapper<GltTicketTemplate>()
.eq(GltTicketTemplate::getTenantId, tenantId)
.eq(GltTicketTemplate::getGoodsId, og.getGoodsId())
.eq(GltTicketTemplate::getDeleted, 0)
.last("limit 1")
);
if (template == null) {
log.warn("套票发放跳过:未配置套票模板 - tenantId={}, goodsId={}, orderNo={}",
tenantId, og.getGoodsId(), order.getOrderNo());
return false;
}
if (!Boolean.TRUE.equals(template.getEnabled())) {
log.info("套票发放跳过:套票模板未启用 - tenantId={}, templateId={}, goodsId={}",
tenantId, template.getId(), template.getGoodsId());
return false;
}
// 幂等:同一 orderNo + orderGoodsId 只发放一次
GltUserTicket existing = gltUserTicketService.getOne(
new LambdaQueryWrapper<GltUserTicket>()
.eq(GltUserTicket::getTenantId, tenantId)
.eq(GltUserTicket::getTemplateId, template.getId())
.eq(GltUserTicket::getOrderNo, order.getOrderNo())
.eq(GltUserTicket::getOrderGoodsId, og.getId())
.eq(GltUserTicket::getDeleted, 0)
.last("limit 1")
);
if (existing != null) {
log.debug("套票已发放,跳过 - tenantId={}, orderNo={}, orderGoodsId={}, userTicketId={}",
tenantId, order.getOrderNo(), og.getId(), existing.getId());
return false;
}
int buyQty = og.getTotalNum() != null ? og.getTotalNum() : 0;
if (buyQty <= 0) {
log.warn("套票发放跳过:购买数量无效 - tenantId={}, orderNo={}, orderGoodsId={}, totalNum={}",
tenantId, order.getOrderNo(), og.getId(), og.getTotalNum());
return false;
}
Integer minBuyQty = template.getMinBuyQty();
if (minBuyQty != null && minBuyQty > 0 && buyQty < minBuyQty) {
log.info("套票发放跳过:未达到最小购买数量 - tenantId={}, orderNo={}, buyQty={}, minBuyQty={}",
tenantId, order.getOrderNo(), buyQty, minBuyQty);
return false;
}
int giftMultiplier = template.getGiftMultiplier() != null ? template.getGiftMultiplier() : 0;
int giftQty = buyQty * Math.max(giftMultiplier, 0);
int totalQty = giftQty;
if (Boolean.TRUE.equals(template.getIncludeBuyQty())) {
totalQty += buyQty;
}
if (totalQty <= 0) {
log.info("套票发放跳过计算结果为0 - tenantId={}, orderNo={}, buyQty={}, giftMultiplier={}, includeBuyQty={}",
tenantId, order.getOrderNo(), buyQty, giftMultiplier, template.getIncludeBuyQty());
return false;
}
LocalDateTime now = LocalDateTime.now();
GltUserTicket userTicket = new GltUserTicket();
userTicket.setTemplateId(template.getId());
userTicket.setGoodsId(og.getGoodsId());
userTicket.setOrderId(order.getOrderId());
userTicket.setOrderNo(order.getOrderNo());
userTicket.setOrderGoodsId(og.getId());
userTicket.setTotalQty(totalQty);
userTicket.setAvailableQty(0);
userTicket.setFrozenQty(totalQty);
userTicket.setUsedQty(0);
userTicket.setReleasedQty(0);
userTicket.setUserId(order.getUserId());
userTicket.setSortNumber(0);
userTicket.setComments("订单发放套票");
userTicket.setStatus(0);
userTicket.setDeleted(0);
userTicket.setTenantId(tenantId);
userTicket.setCreateTime(now);
userTicket.setUpdateTime(now);
gltUserTicketService.save(userTicket);
// 生成释放计划(按月)
LocalDateTime baseTime = order.getPayTime() != null ? order.getPayTime() : order.getCreateTime();
if (baseTime == null) {
baseTime = now;
}
List<GltUserTicketRelease> releases = buildReleasePlan(template, userTicket, baseTime, totalQty, now);
if (!releases.isEmpty()) {
gltUserTicketReleaseService.saveBatch(releases);
}
// 发放流水
GltUserTicketLog issueLog = new GltUserTicketLog();
issueLog.setUserTicketId(userTicket.getId());
issueLog.setChangeType(CHANGE_TYPE_ISSUE);
issueLog.setChangeAvailable(0);
issueLog.setChangeFrozen(totalQty);
issueLog.setChangeUsed(0);
issueLog.setAvailableAfter(0);
issueLog.setFrozenAfter(totalQty);
issueLog.setUsedAfter(0);
issueLog.setOrderId(order.getOrderId());
issueLog.setOrderNo(order.getOrderNo());
issueLog.setUserId(order.getUserId());
issueLog.setSortNumber(0);
issueLog.setComments("套票发放");
issueLog.setStatus(0);
issueLog.setDeleted(0);
issueLog.setTenantId(tenantId);
issueLog.setCreateTime(now);
issueLog.setUpdateTime(now);
gltUserTicketLogService.save(issueLog);
log.info("套票发放成功 - tenantId={}, orderNo={}, orderGoodsId={}, templateId={}, userTicketId={}, totalQty={}",
tenantId, order.getOrderNo(), og.getId(), template.getId(), userTicket.getId(), totalQty);
return true;
}
private List<GltUserTicketRelease> buildReleasePlan(GltTicketTemplate template,
GltUserTicket userTicket,
LocalDateTime baseTime,
int totalQty,
LocalDateTime now) {
List<GltUserTicketRelease> list = new ArrayList<>();
if (totalQty <= 0) {
return list;
}
// 首期释放时间
LocalDateTime firstReleaseTime;
if (Objects.equals(template.getFirstReleaseMode(), 1)) {
firstReleaseTime = nextMonthSameDay(baseTime);
} else {
firstReleaseTime = baseTime;
}
// 每期释放数量计算
Integer releasePeriods = template.getReleasePeriods();
if (releasePeriods != null && releasePeriods > 0) {
int base = totalQty / releasePeriods;
int remainder = totalQty % releasePeriods;
for (int i = 1; i <= releasePeriods; i++) {
int qty = base + (i <= remainder ? 1 : 0);
if (qty <= 0) {
continue;
}
list.add(buildRelease(userTicket, i, qty, firstReleaseTime.plusMonths(i - 1), now));
}
return list;
}
int monthlyReleaseQty = template.getMonthlyReleaseQty() != null && template.getMonthlyReleaseQty() > 0
? template.getMonthlyReleaseQty()
: 10;
int periods = (totalQty + monthlyReleaseQty - 1) / monthlyReleaseQty;
int remaining = totalQty;
for (int i = 1; i <= periods; i++) {
int qty = Math.min(monthlyReleaseQty, remaining);
if (qty <= 0) {
break;
}
remaining -= qty;
list.add(buildRelease(userTicket, i, qty, firstReleaseTime.plusMonths(i - 1), now));
}
return list;
}
private GltUserTicketRelease buildRelease(GltUserTicket userTicket,
int periodNo,
int releaseQty,
LocalDateTime releaseTime,
LocalDateTime now) {
GltUserTicketRelease r = new GltUserTicketRelease();
r.setUserTicketId(userTicket.getId() != null ? userTicket.getId().longValue() : null);
r.setUserId(userTicket.getUserId());
r.setPeriodNo(periodNo);
r.setReleaseQty(releaseQty);
r.setReleaseTime(releaseTime);
r.setStatus(0);
r.setDeleted(0);
r.setTenantId(userTicket.getTenantId());
r.setCreateTime(now);
r.setUpdateTime(now);
return r;
}
private static LocalDateTime nextMonthSameDay(LocalDateTime baseTime) {
LocalDate baseDate = baseTime.toLocalDate();
LocalTime time = baseTime.toLocalTime();
LocalDate nextMonth = baseDate.plusMonths(1);
int day = Math.min(baseDate.getDayOfMonth(), nextMonth.lengthOfMonth());
LocalDate adjusted = nextMonth.withDayOfMonth(day);
return LocalDateTime.of(adjusted, time);
}
}

View File

@@ -0,0 +1,46 @@
package com.gxwebsoft.glt.task;
import com.gxwebsoft.common.core.annotation.IgnoreTenant;
import com.gxwebsoft.glt.service.GltTicketIssueService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* GLT 套票发放任务:
* - 每分钟扫描一次今日订单tenantId=10584, formId=10074, payStatus=1, orderStatus=0
* - 为订单生成用户套票账户 + 释放计划(幂等)
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "glt.ticket.issue10584", name = "enabled", havingValue = "true", matchIfMissing = true)
public class GltTicketIssue10584Task {
private static final int TENANT_ID = 10584;
private static final int FORM_ID = 10074;
private final GltTicketIssueService gltTicketIssueService;
private final AtomicBoolean running = new AtomicBoolean(false);
@Scheduled(cron = "${glt.ticket.issue10584.cron:0 */1 * * * ?}")
@IgnoreTenant("定时任务无登录态,需忽略租户隔离;内部使用 tenantId=10584 精确过滤")
public void run() {
if (!running.compareAndSet(false, true)) {
log.warn("套票发放任务仍在执行中,本轮跳过 - tenantId={}, formId={}", TENANT_ID, FORM_ID);
return;
}
try {
gltTicketIssueService.issueTodayOrders(TENANT_ID, FORM_ID);
} finally {
running.set(false);
}
}
}