From d393de816fdf60d1bcfcb34bbbb88c27ff8a9a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Tue, 3 Feb 2026 21:25:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(ticket):=20=E6=B7=BB=E5=8A=A0=E5=A5=97?= =?UTF-8?q?=E7=A5=A8=E5=8F=91=E6=94=BE=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=92=8C=E6=A0=B8=E5=BF=83=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 GltTicketIssue10584Task 定时任务,每分钟扫描今日订单并生成套票账户 - 创建 GltTicketIssueService 服务,处理从订单生成用户套票和释放计划的完整流程 - 支持幂等处理,防止重复发放套票 - 实现月度释放计划生成功能,支持首期立即释放或次月释放模式 - 添加多租户支持和并发控制,确保任务执行安全 - 集成订单状态检查、套票模板验证和发放流水记录功能 --- .../glt/service/GltTicketIssueService.java | 334 ++++++++++++++++++ .../glt/task/GltTicketIssue10584Task.java | 46 +++ 2 files changed, 380 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/glt/service/GltTicketIssueService.java create mode 100644 src/main/java/com/gxwebsoft/glt/task/GltTicketIssue10584Task.java diff --git a/src/main/java/com/gxwebsoft/glt/service/GltTicketIssueService.java b/src/main/java/com/gxwebsoft/glt/service/GltTicketIssueService.java new file mode 100644 index 0000000..8b5b14d --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/service/GltTicketIssueService.java @@ -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 orders = shopOrderService.list( + new LambdaQueryWrapper() + .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 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() + .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() + .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 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 buildReleasePlan(GltTicketTemplate template, + GltUserTicket userTicket, + LocalDateTime baseTime, + int totalQty, + LocalDateTime now) { + List 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); + } +} diff --git a/src/main/java/com/gxwebsoft/glt/task/GltTicketIssue10584Task.java b/src/main/java/com/gxwebsoft/glt/task/GltTicketIssue10584Task.java new file mode 100644 index 0000000..292acb2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/task/GltTicketIssue10584Task.java @@ -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); + } + } +} +