From f46785818b6fd929be5b8edaa2272f6a6394260c Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 20 Jul 2026 16:48:19 +0800 Subject: [PATCH] feat: keep gift challenge editable before settlement --- .../docs/Aslan礼物挑战活动接口.md | 17 +- .../aslan/AslanGiftChallengeServiceImpl.java | 425 +++++++++++++----- .../aslan/AslanGiftChallengeMapper.java | 84 ++++ 3 files changed, 415 insertions(+), 111 deletions(-) diff --git a/rc-service/rc-service-other/docs/Aslan礼物挑战活动接口.md b/rc-service/rc-service-other/docs/Aslan礼物挑战活动接口.md index 58b23ac2..ebbc1b61 100644 --- a/rc-service/rc-service-other/docs/Aslan礼物挑战活动接口.md +++ b/rc-service/rc-service-other/docs/Aslan礼物挑战活动接口.md @@ -10,8 +10,10 @@ - 单个活动最多覆盖 366 个活动时区自然日;首次启用会批量幂等预建全部 DAILY 结算门闩, 因此没有任何参与者的日期也会按时冻结为空榜。 - 启用会冻结任务、排名区间所引用资源组的完整奖励配置(发放字段和 H5 展示字段),最多 - 5000 个奖励项。活动开始前修改任务、排名奖励或活动时间会先清理旧的 `NOT_STARTED` - 门闩和旧奖励快照再整体重建;停用也会清理这些预建数据。 + 5000 个奖励项。总榜结算开始前,即使活动已经进入或走完时间窗,WebConsole 仍可修改 + 时间、时区、任务、榜单和奖励;新配置只影响之后的事件和尚未物化/冻结的数据。 +- 已生成用户任务、已冻结日榜、榜奖 parent 和 delivery item 永不回写;这些记录引用的 + 奖励组快照始终保留。同一已引用资源组不会刷新,修改奖励内容必须选择新的资源组。 - `dailySettlementDelayMinutes` 与 `overallSettlementDelayMinutes` 是有界迟到窗口,不是无限 追溯。结算门闩开始后对应榜单不再接受迟到写入;执行自动或人工结算前必须监控 `ASLAN_GIFT_CHALLENGE_GIFT` consumer group lag,确认落后量已进入可接受范围。 @@ -92,6 +94,17 @@ 同一周期排名区间不得重叠。启用后的 H5 奖励展示、每日任务发奖和榜单结算发奖均只读取 该活动自己的冻结快照;共享资源组之后改名、改奖励或下架都不会改变本期用户所得。 +| 活动状态 | 核心配置 | 启停约束 | +| --- | --- | --- | +| 未启用 + `NOT_STARTED`(包括旧开始时间已过) | 时间、任务、榜单和奖励全部可编辑 | 启用前必须改为未来开始时间 | +| 已启用 + `NOT_STARTED`(开始前、进行中或已过结束时间) | 全部可编辑 | 历史事实保留,新配置向后生效;已有业务事实后禁止停用 | +| 任意已进入结算的状态 | 仅活动名称和说明可编辑 | 核心配置始终锁定,不受时间字段影响 | + +三个任务的 `taskCode/taskType/sortOrder` 固定为 `daily_enter/ENTER_PAGE/1`、 +`daily_gift_1/SEND_GIFT_GOLD/2`、`daily_gift_2/SEND_GIFT_GOLD/3`;活动中仅可修改文案、 +门槛和奖励组。修改时区或时间窗后,旧日期已完成但未成功发放的任务仍可通过原 taskCode +继续领取,旧日榜也以实际保留的 period header 为准继续查询和结算。 + ## 礼物事件与发奖 活动使用独立 consumer group `ASLAN_GIFT_CHALLENGE_GIFT` 直接订阅原始 diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/service/activity/aslan/AslanGiftChallengeServiceImpl.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/service/activity/aslan/AslanGiftChallengeServiceImpl.java index 21cd7ff9..db875f8e 100644 --- a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/service/activity/aslan/AslanGiftChallengeServiceImpl.java +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/service/activity/aslan/AslanGiftChallengeServiceImpl.java @@ -66,6 +66,7 @@ import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -94,16 +95,24 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService private static final String DEFAULT_TIME_ZONE = "Asia/Riyadh"; private static final String OWNER_TASK = "TASK"; private static final String OWNER_SETTLEMENT = "SETTLEMENT"; + private static final String TASK_CODE_ENTER = "daily_enter"; + private static final String TASK_CODE_GIFT_1 = "daily_gift_1"; + private static final String TASK_CODE_GIFT_2 = "daily_gift_2"; private static final int MAX_RANK_LIMIT = 500; private static final int MAX_SETTLEMENT_LIMIT = 500; /** 启用时会为每个活动日预建结算门闩,限制日期数避免误配多年活动造成突发批量写入。 */ private static final int MAX_ACTIVITY_DAILY_HEADERS = 366; private static final int MAX_ACTIVITY_REWARD_SNAPSHOT_ITEMS = 5000; private static final int REWARD_SNAPSHOT_BATCH_SIZE = 500; + + private record TaskClaimContext(TaskDailyRow task, ActivityRow activity, Date statDate) { + } private static final int MAX_FAILURE_REASON_LENGTH = 500; private static final long DELIVERY_PROCESSING_LEASE_SECONDS = 600L; private static final Set DELIVERY_STATUSES = Set.of( "PENDING", "PROCESSING", "SUCCESS", "UNKNOWN"); + private static final Set TASK_PROCESSABLE_DELIVERY_STATUSES = Set.of( + "NOT_CLAIMED", "PENDING", "PROCESSING"); private final AslanGiftChallengeMapper mapper; private final TransactionTemplate transactionTemplate; @@ -122,28 +131,33 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService @Override public UserState enter(AppExtCommand cmd, Long activityId) { ActivityRow activity = requireVisibleActivity(cmd, activityId); - Timestamp now = Timestamp.from(Instant.now()); - requireActive(activity, now); Long userId = cmd.requiredReqUserId(); - Date statDate = activityDate(activity, now); - - transactionTemplate.executeWithoutResult(status -> { - ensureDailyPeriod(activity, statDate); - PeriodSettlementRow gate = mapper.lockPeriodSettlementForGiftGate(activity.getId(), + UserState state = transactionTemplate.execute(status -> { + // 与配置保存统一按 activity -> DAILY gate 加锁;活动中改时间/时区时,本次进入 + // 要么完整使用旧配置,要么在保存提交后重新读取新配置,不能混出旧日期的新任务。 + ActivityRow lockedActivity = mapper.lockActivityForGiftGate(activity.getId()); + ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, lockedActivity); + Timestamp now = Timestamp.from(Instant.now()); + requireActive(lockedActivity, now); + Date statDate = activityDate(lockedActivity, now); + ensureDailyPeriod(lockedActivity, statDate); + PeriodSettlementRow gate = mapper.lockPeriodSettlementForGiftGate(lockedActivity.getId(), PERIOD_DAILY, statDate.toString()); ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, gate); ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, "NOT_STARTED".equals(gate.getStatus())); - ensureTaskSnapshots(activity, statDate, userId); + ensureTaskSnapshots(lockedActivity, statDate, userId); // 页面进入是写操作:同一用户同一活动日反复进入只把 ENTER_PAGE 快照置为完成, // 不累加次数,也不会改变 SEND_GIFT_GOLD 进度。 - for (TaskDailyRow task : mapper.listUserTaskDaily(activity.getId(), statDate, userId)) { + for (TaskDailyRow task : mapper.listUserTaskDaily(lockedActivity.getId(), statDate, userId)) { if (TASK_ENTER_PAGE.equals(task.getTaskType())) { ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE, mapper.completeTask(task.getId(), now) == 1); } } + return buildUserState(lockedActivity, userId, statDate); }); - return buildUserState(activity, userId, statDate); + ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, state); + return state; } @Override @@ -157,27 +171,73 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService public UserState claimTask(AppExtCommand cmd, Long activityId, String taskCode, String requestId) { ActivityRow activity = requireVisibleActivity(cmd, activityId); - Timestamp now = Timestamp.from(Instant.now()); - requireActive(activity, now); String normalizedRequestId = requestId == null ? null : requestId.trim(); ResponseAssert.isTrue(CommonErrorCode.REQUIRED_FIELDS_CANNOT_BE_IGNORED, taskCode != null && taskCode.trim().matches(TASK_CODE_PATTERN) && StringUtils.isNotBlank(normalizedRequestId) && normalizedRequestId.length() <= 128); Long userId = cmd.requiredReqUserId(); - Date statDate = activityDate(activity, now); - - TaskDailyRow claimed = transactionTemplate.execute(status -> { - ensureTaskSnapshots(activity, statDate, userId); - TaskDailyRow task = mapper.getTaskDaily(activity.getId(), statDate, userId, - taskCode.trim()); + TaskClaimContext claim = transactionTemplate.execute(status -> { + // 先共享锁活动,再锁日门闩和任务;配置保存持有 activity 独占锁,因此不会在 + // 读取旧任务后又使用新奖励快照,结算也会等待本次领取完成日期门闩检查。 + ActivityRow lockedActivity = mapper.lockActivityForGiftGate(activity.getId()); + ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, lockedActivity); + Timestamp now = Timestamp.from(Instant.now()); + boolean ongoing = Boolean.TRUE.equals(lockedActivity.getEnabled()) + && !lockedActivity.getStartTime().after(now) && lockedActivity.getEndTime().after(now) + && "NOT_STARTED".equals(lockedActivity.getOverallSettlementStatus()); + Date statDate = null; + // 先按 requestId 恢复原 owner;否则同一请求跨过时区/日期边界重试时,可能误领 + // 新一天同 taskCode 的另一份奖励。 + TaskDailyRow task = mapper.findClaimRequestTask(lockedActivity.getId(), userId, + taskCode.trim(), normalizedRequestId); + if (task != null) { + statDate = task.getStatDate(); + } else { + TaskDailyRow terminalCurrent = null; + if (ongoing) { + statDate = activityDate(lockedActivity, now); + ensureDailyPeriod(lockedActivity, statDate); + PeriodSettlementRow gate = mapper.lockPeriodSettlementForGiftGate( + lockedActivity.getId(), PERIOD_DAILY, statDate.toString()); + ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, gate); + if ("NOT_STARTED".equals(gate.getStatus())) { + ensureTaskSnapshots(lockedActivity, statDate, userId); + TaskDailyRow current = mapper.getTaskDaily(lockedActivity.getId(), statDate, userId, + taskCode.trim()); + if (current != null && current.getCompletedTime() != null) { + if (TASK_PROCESSABLE_DELIVERY_STATUSES.contains(current.getDeliveryStatus())) { + task = current; + } else if ("UNKNOWN".equals(current.getDeliveryStatus())) { + terminalCurrent = current; + } + } + } + } + if (task == null) { + // 改时区、缩短窗口或活动自然结束后,当前日期不再指向旧任务。SUCCESS/UNKNOWN + // 的当前行不能挡住另一日期尚可处理的独立 owner。 + task = mapper.findLatestClaimableTask(lockedActivity.getId(), userId, + taskCode.trim()); + } + if (task == null) { + // UNKNOWN 不允许自动补发,但当没有其他可处理 owner 时仍返回该行,让后续状态 + // 分支明确要求人工核账,不能伪装成“任务未完成”。 + task = terminalCurrent != null ? terminalCurrent + : mapper.findLatestUnknownTask(lockedActivity.getId(), userId, taskCode.trim()); + } + if (task != null) { + statDate = task.getStatDate(); + } + } ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, task); TaskDailyRow locked = mapper.lockTaskDaily(task.getId()); ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, locked); + statDate = locked.getStatDate(); ResponseAssert.notNull(CommonErrorCode.STATE_ERROR, locked.getCompletedTime()); ResponseAssert.notNull(CommonErrorCode.CONFIGURATION_ERROR, locked.getResourceGroupId()); if ("SUCCESS".equals(locked.getDeliveryStatus())) { - return locked; + return new TaskClaimContext(locked, lockedActivity, statDate); } // UNKNOWN 可能已经在下游到账,H5 不具备核账能力;只有 webconsole 明确确认后才能补发。 ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, @@ -185,16 +245,17 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService if ("NOT_CLAIMED".equals(locked.getDeliveryStatus())) { ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE, mapper.claimTaskReward(locked.getId(), normalizedRequestId) == 1); - createDeliveryItems(OWNER_TASK, locked.getId(), activity.getId(), userId, + createDeliveryItems(OWNER_TASK, locked.getId(), lockedActivity.getId(), userId, locked.getResourceGroupId()); } - return mapper.lockTaskDaily(locked.getId()); + return new TaskClaimContext(mapper.lockTaskDaily(locked.getId()), lockedActivity, statDate); }); - ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, claimed); - if (!"SUCCESS".equals(claimed.getDeliveryStatus())) { - deliverTask(claimed); + ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, claim); + ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, claim.task()); + if (!"SUCCESS".equals(claim.task().getDeliveryStatus())) { + deliverTask(claim.task()); } - return buildUserState(activity, userId, statDate); + return buildUserState(claim.activity(), userId, claim.statDate()); } @Override @@ -214,7 +275,6 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService private boolean recordGift(ActivityRow activity, GiveAwayGiftBatchEvent event, BigDecimal amount) { Timestamp eventTime = event.getCreateTime(); - Date statDate = activityDate(activity, eventTime); Boolean inserted = transactionTemplate.execute(status -> { ActivityRow overallGate = mapper.lockActivityForGiftGate(activity.getId()); if (overallGate == null || !Boolean.TRUE.equals(overallGate.getEnabled()) @@ -224,6 +284,9 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService return false; } + // 候选活动来自事务前查询;配置可能在两次读取间改过时区或窗口,必须在共享锁后 + // 用同一份 locked row 重算活动日,确保 ledger、日榜门闩和任务使用同一口径。 + Date statDate = activityDate(overallGate, eventTime); ensureDailyPeriod(overallGate, statDate); PeriodSettlementRow dailyGate = mapper.lockPeriodSettlementForGiftGate(activity.getId(), PERIOD_DAILY, statDate.toString()); @@ -255,7 +318,7 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE, mapper.incrementUserDailyScore(activity.getId(), statDate, event.getSendUserId(), amount, eventTime) == 1); - ensureTaskSnapshots(activity, statDate, event.getSendUserId()); + ensureTaskSnapshots(overallGate, statDate, event.getSendUserId()); for (TaskDailyRow task : mapper.listUserTaskDaily(activity.getId(), statDate, event.getSendUserId())) { if (TASK_SEND_GIFT_GOLD.equals(task.getTaskType())) { @@ -311,17 +374,27 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService mapper.countActivityCode(cmd.getActivityCode().trim(), cmd.getId()) == 0); ActivityRow existing = cmd.getId() == null ? null : mapper.lockActivity(cmd.getId()); Timestamp saveNow = Timestamp.from(Instant.now()); - boolean existingStarted = existing != null && !existing.getStartTime().after(saveNow); + boolean existingConfigurationLocked = existing != null + && !isConfigurationEditable(existing); if (cmd.getId() != null) { ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, existing); ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, Objects.equals(cmd.getVersion(), existing.getVersion())); - validateStartedLocks(existing, cmd, saveNow); + validateStartedLocks(existing, cmd); + if (Boolean.TRUE.equals(existing.getEnabled()) + && !Boolean.TRUE.equals(cmd.getEnabled())) { + // enabled 是事件入口,不与普通配置一起追溯切换。只要已有计分、用户任务或 + // 冻结日榜,就不能通过先改 startTime 到未来再停用来绕过历史保护。 + ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, + existing.getStartTime().after(saveNow) + && !hasMaterializedActivityState(existing.getId())); + } } - if (Boolean.TRUE.equals(cmd.getEnabled()) - && !existingStarted) { - // 尚未开始的已启用活动也不能通过编辑把新开始时间推进到当前/过去;否则本次保存 - // 会跳过完整 artifacts 重建,监听器将使用旧日期门闩处理新时间窗口。 + boolean enabling = Boolean.TRUE.equals(cmd.getEnabled()) + && (existing == null || !Boolean.TRUE.equals(existing.getEnabled())); + if (enabling) { + // 首次/重新启用仍要求未来开始,确保完整快照和所有日期门闩先于第一笔礼物提交; + // 已经运行中的活动保存不受此限制,可以按运营要求实时调整配置。 ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, new Timestamp(cmd.getStartTime()).after(saveNow)); } @@ -345,21 +418,25 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService if (cmd.getRankRewards() != null) { replaceRankRewards(row, cmd.getRankRewards(), existing == null); } - if (Boolean.TRUE.equals(row.getEnabled())) { - validateReadyToEnable(row); - if (!existingStarted) { - // 未开始活动允许改时区/起止时间;先清掉旧门闩和奖励模板再按当前配置完整重建。 - // rebuild 内再次检查 startTime,若事务执行期间已越过开始边界则整体回滚。 - rebuildPreStartArtifacts(row); + if (!existingConfigurationLocked) { + if (Boolean.TRUE.equals(row.getEnabled())) { + validateReadyToEnable(row); + // 活动中编辑只替换未被 task_daily/settlement parent 引用的奖励组,并仅同步 + // NOT_STARTED 日期门闩;已领取任务、已冻结日榜和逐项发奖历史均保持不变。 + synchronizeRewardSnapshots(row); + synchronizeDailyPeriods(row); + } else if (hasMaterializedActivityState(row.getId())) { + pruneDisabledActivityArtifacts(row.getId()); + } else { + clearPreStartArtifacts(row.getId()); } - } else if (existing != null - && existing.getStartTime().after(Timestamp.from(Instant.now()))) { - clearPreStartArtifacts(row.getId()); } - if (!existingStarted && existing != null && Boolean.TRUE.equals(existing.getEnabled())) { - // 已启用待开始活动即使本次改为停用,也必须在提交前仍未开始;否则事务执行期间 - // 到达 startTime 会造成已发生礼物使用旧可见状态、提交后活动却被追溯停用。 - assertActivityStillPreStart(row); + if (!existingConfigurationLocked && existing != null + && Boolean.TRUE.equals(existing.getEnabled())) { + // 待开始活动允许停用,但提交前必须仍未开始;运行中在上面已强制保持 enabled。 + if (!Boolean.TRUE.equals(row.getEnabled())) { + assertActivityStillPreStart(existing); + } } return id; }); @@ -375,20 +452,30 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService ResponseAssert.isTrue(CommonErrorCode.INOPERABLE_WRONG_ATTRIBUTION, ORIGIN_ATYOU.equals(activity.getSysOrigin())); Timestamp now = Timestamp.from(Instant.now()); + boolean materializedState = hasMaterializedActivityState(activityId); if (enabled) { ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, activity.getStartTime().after(now) && activity.getEndTime().after(now) && "NOT_STARTED".equals(activity.getOverallSettlementStatus())); assertNoEnabledOverlap(activity); validateReadyToEnable(activity); - rebuildPreStartArtifacts(activity); - } else if (activity.getStartTime().after(now)) { - clearPreStartArtifacts(activityId); + synchronizeRewardSnapshots(activity); + synchronizeDailyPeriods(activity); + } else { + ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, + Objects.equals(activity.getEnabled(), false) + || (activity.getStartTime().after(now) && !materializedState)); + if (materializedState) { + pruneDisabledActivityArtifacts(activityId); + } else { + clearPreStartArtifacts(activityId); + } } // 运行中下架会让同一活动窗口前后产生两套计分口径,因此开始后只允许保持原状态。 ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, Objects.equals(activity.getEnabled(), enabled) - || activity.getStartTime().after(Timestamp.from(Instant.now()))); + || (activity.getStartTime().after(Timestamp.from(Instant.now())) + && !materializedState)); ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE, mapper.updateEnabled(activityId, enabled) == 1); }); @@ -410,7 +497,7 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService ResponseAssert.isTrue(CommonErrorCode.INOPERABLE_WRONG_ATTRIBUTION, ORIGIN_ATYOU.equals(activity.getSysOrigin())); replaceTasks(activity, cmd.getTasks(), false); - refreshPreStartRewardSnapshots(activity); + refreshEditableRewardSnapshots(activity); }); } @@ -430,7 +517,7 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService ResponseAssert.isTrue(CommonErrorCode.INOPERABLE_WRONG_ATTRIBUTION, ORIGIN_ATYOU.equals(activity.getSysOrigin())); replaceRankRewards(activity, cmd.getRankRewards(), false); - refreshPreStartRewardSnapshots(activity); + refreshEditableRewardSnapshots(activity); }); } @@ -468,11 +555,6 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService Date settlementDate = PERIOD_DAILY.equals(normalizedPeriod) ? requireActivityDate(activity, statDate) : null; String periodKey = settlementDate == null ? PERIOD_OVERALL : settlementDate.toString(); - Timestamp dueTime = PERIOD_DAILY.equals(normalizedPeriod) - ? dailySettlementDueTime(activity, settlementDate) : activity.getOverallSettlementTime(); - ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, - !dueTime.after(Timestamp.from(Instant.now()))); - ensurePeriod(activity, normalizedPeriod, periodKey, settlementDate, dueTime); // 第一阶段只冻结名次和关闭写门闩,并在继续物化奖励前独立提交。奖励组即使被其他 // 后台下架或改坏,也只能让对应 parent 保持 PENDING,不能让榜单重新开放。 @@ -507,15 +589,30 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService private void freezeSettlementSnapshot(Long activityId, String normalizedPeriod, String periodKey, Date settlementDate) { transactionTemplate.executeWithoutResult(status -> { - ActivityRow lockedActivity = PERIOD_OVERALL.equals(normalizedPeriod) - ? mapper.lockActivity(activityId) : mapper.getActivity(activityId); + // DAILY/OVERALL 都按 activity -> period header 加锁。这样在线配置保存、礼物事件和 + // 结算只会完整看到同一版本的时间窗、排行奖励与截止时间,不会交叉冻结新旧配置。 + ActivityRow lockedActivity = mapper.lockActivity(activityId); ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, lockedActivity); PeriodSettlementRow header = mapper.lockPeriodSettlement(activityId, normalizedPeriod, periodKey); + if (header == null) { + if (PERIOD_DAILY.equals(normalizedPeriod)) { + // requireActivityDate 在 activity 锁内重新确认:若在线保存刚删除了无事实的旧空 + // 门闩,结算不能用事务前读到的旧窗口把它重新创建回来。 + requireActivityDate(lockedActivity, periodKey); + } + Timestamp dueTime = PERIOD_DAILY.equals(normalizedPeriod) + ? dailySettlementDueTime(lockedActivity, settlementDate) + : lockedActivity.getOverallSettlementTime(); + ensurePeriod(lockedActivity, normalizedPeriod, periodKey, settlementDate, dueTime); + header = mapper.lockPeriodSettlement(activityId, normalizedPeriod, periodKey); + } ResponseAssert.notNull(CommonErrorCode.OPERATING_FAILURE, header); if (!"NOT_STARTED".equals(header.getStatus())) { return; } + ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, + !header.getSnapshotDueTime().after(Timestamp.from(Instant.now()))); // header 独占锁是日榜门闩,activity 独占锁是总榜门闩;拿到锁时所有已进入的礼物 // 共享事务均已提交,因此本次 Top-N 是可重复读取的冻结快照。 @@ -834,11 +931,10 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService transactionTemplate.executeWithoutResult(transactionStatus -> { // 多个 parent 同时完成或核账时,header 行锁保证最后一次汇总读取全部已提交父状态, // 不会被较早事务计算出的 RECONCILIATION_REQUIRED/PROCESSING 结果反向覆盖。 - // OVERALL 与快照阶段保持 activity -> header 的固定锁序,避免刷新和重复 settle 互锁。 - if (PERIOD_OVERALL.equals(period)) { - ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, - mapper.lockActivity(activityId)); - } + // DAILY/OVERALL 都与快照阶段保持 activity -> header 的固定锁序,避免在线编辑、 + // 刷新和重复 settle 之间形成相反锁序或读到半套配置。 + ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, + mapper.lockActivity(activityId)); PeriodSettlementRow header = mapper.lockPeriodSettlement(activityId, period, periodKey); if (header == null) { return; @@ -896,16 +992,6 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService return ensurePeriod(activity, PERIOD_DAILY, statDate.toString(), statDate, due); } - private void rebuildPreStartArtifacts(ActivityRow activity) { - assertActivityStillPreStart(activity); - clearPreStartArtifacts(activity.getId()); - replaceRewardSnapshots(activity); - precreateDailyPeriods(activity); - // 奖励详情读取和最多 5000 项/366 日写入可能耗时;提交前再次校验,越过活动开始时间 - // 就让整个配置事务回滚,避免活动已开始但新配置仍不可见的漏计窗口。 - assertActivityStillPreStart(activity); - } - private void clearPreStartArtifacts(Long activityId) { // 只删除尚未关闭写门闩的 DAILY 行;COMPLETED/PROCESSING 等冻结记录永远不会被配置 // 编辑清理。调用方同时持有 activity 行锁且已验证活动未开始,因此不存在合法积分快照。 @@ -913,27 +999,18 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService mapper.deleteRewardSnapshots(activityId); } - private void refreshPreStartRewardSnapshots(ActivityRow activity) { - ResponseAssert.isTrue(CommonErrorCode.INOPERABLE_WRONG_ATTRIBUTION, - ORIGIN_ATYOU.equals(activity.getSysOrigin())); - if (!Boolean.TRUE.equals(activity.getEnabled())) { - mapper.deleteRewardSnapshots(activity.getId()); - return; - } - validateReadyToEnable(activity); - replaceRewardSnapshots(activity); - // 单独修改任务/排名奖励同样可能跨过开始边界,必须与活动保存走相同提交门槛。 - assertActivityStillPreStart(activity); - } - - private void replaceRewardSnapshots(ActivityRow activity) { - assertActivityStillPreStart(activity); + private List buildRewardSnapshots(ActivityRow activity, + Set frozenGroups) { Set groupIds = new HashSet<>(); mapper.listTasks(activity.getId()).stream().map(Task::getResourceGroupId) .filter(Objects::nonNull).forEach(groupIds::add); mapper.listRankRewards(activity.getId()).stream().map(RankReward::getResourceGroupId) .filter(Objects::nonNull).forEach(groupIds::add); ResponseAssert.notEmpty(CommonErrorCode.CONFIGURATION_ERROR, groupIds); + groupIds.removeAll(frozenGroups); + if (groupIds.isEmpty()) { + return Collections.emptyList(); + } // 展示字段获取失败不影响核心发放快照;type/content/quantity 等实际发放字段始终直接 // 来自同一事务读取的本地奖励配置,确保同一期所有用户收到完全一致的资源版本。 @@ -983,17 +1060,87 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService snapshots.size() <= MAX_ACTIVITY_REWARD_SNAPSHOT_ITEMS); } } + return snapshots; + } - mapper.deleteRewardSnapshots(activity.getId()); + private void insertRewardSnapshotBatches(List snapshots) { for (int offset = 0; offset < snapshots.size(); offset += REWARD_SNAPSHOT_BATCH_SIZE) { int end = Math.min(offset + REWARD_SNAPSHOT_BATCH_SIZE, snapshots.size()); mapper.insertRewardSnapshots(snapshots.subList(offset, end)); } - ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE, - mapper.countRewardSnapshots(activity.getId()) == snapshots.size()); } - private void precreateDailyPeriods(ActivityRow activity) { + /** + * 在线编辑只刷新尚未被任务或榜奖 parent 引用的组。已引用组仍可能在稍后领取、失败 + * 恢复或人工核账,因此即使当前配置不再选择它,也必须保留原始完整快照。 + */ + private void synchronizeRewardSnapshots(ActivityRow activity) { + Set existingGroups = new HashSet<>( + mapper.listRewardSnapshotGroupIds(activity.getId())); + // 一旦活动有任意业务事实,保守冻结此前所有活动级快照组。组数最多受 5000 条 + // 快照上限约束,避免在 activity X 锁内扫描百万级 user_task_daily 去逐组去重。 + Set protectedGroups = mapper.hasMaterializedActivityState(activity.getId()) + ? new HashSet<>(existingGroups) : Collections.emptySet(); + + Set frozenProtectedGroups = existingGroups.stream() + .filter(protectedGroups::contains).collect(Collectors.toSet()); + List desired = buildRewardSnapshots(activity, frozenProtectedGroups); + + existingGroups.stream().filter(groupId -> !protectedGroups.contains(groupId)) + .forEach(groupId -> mapper.deleteRewardSnapshotsByGroup(activity.getId(), groupId)); + + // 受保护且已有快照的组整体保持旧版本,不能把资源中心后来新增的奖励项混入旧组; + // 若要修改其奖励内容,运营必须选择新的资源组。 + insertRewardSnapshotBatches(desired); + + ResponseAssert.isTrue(CommonErrorCode.CONFIGURATION_ERROR, + mapper.countRewardSnapshots(activity.getId()) <= MAX_ACTIVITY_REWARD_SNAPSHOT_ITEMS); + Set configuredGroups = new HashSet<>(); + mapper.listTasks(activity.getId()).stream().map(Task::getResourceGroupId) + .filter(Objects::nonNull).forEach(configuredGroups::add); + mapper.listRankRewards(activity.getId()).stream().map(RankReward::getResourceGroupId) + .filter(Objects::nonNull).forEach(configuredGroups::add); + for (Long groupId : configuredGroups) { + ResponseAssert.notEmpty(CommonErrorCode.UPDATE_FAILURE, + mapper.listRewardSnapshotsByGroup(activity.getId(), groupId)); + } + } + + private void refreshEditableRewardSnapshots(ActivityRow activity) { + ResponseAssert.isTrue(CommonErrorCode.INOPERABLE_WRONG_ATTRIBUTION, + ORIGIN_ATYOU.equals(activity.getSysOrigin())); + ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, + isConfigurationEditable(activity)); + if (!Boolean.TRUE.equals(activity.getEnabled())) { + if (hasMaterializedActivityState(activity.getId())) { + pruneDisabledActivityArtifacts(activity.getId()); + } else { + clearPreStartArtifacts(activity.getId()); + } + return; + } + validateReadyToEnable(activity); + synchronizeRewardSnapshots(activity); + } + + private boolean hasMaterializedActivityState(Long activityId) { + return mapper.hasMaterializedActivityState(activityId); + } + + private void pruneDisabledActivityArtifacts(Long activityId) { + Set protectedGroups = new HashSet<>(mapper.listRewardSnapshotGroupIds(activityId)); + + Set materializedDates = new HashSet<>(mapper.listMaterializedDailyDates(activityId)); + mapper.listDailyPeriodSettlements(activityId).stream() + .filter(header -> "NOT_STARTED".equals(header.getStatus())) + .filter(header -> !materializedDates.contains(header.getStatDate())) + .forEach(header -> mapper.deleteNotStartedPeriodById(header.getId())); + ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE, + protectedGroups.stream().allMatch(groupId -> + !mapper.listRewardSnapshotsByGroup(activityId, groupId).isEmpty())); + } + + private List buildDailyPeriodHeaders(ActivityRow activity) { ZoneId zone = requireZoneId(activity.getTimeZone()); LocalDate first = activity.getStartTime().toInstant().atZone(zone).toLocalDate(); LocalDate last = activity.getEndTime().toInstant().minusMillis(1).atZone(zone).toLocalDate(); @@ -1013,8 +1160,39 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService .setSnapshotDueTime(dailySettlementDueTime(activity, sqlDate)) .setStatus("NOT_STARTED")); } - // 一次批量 INSERT IGNORE 完成最多 366 个日期;重复启用或恢复调用不会重写已有状态。 - mapper.ensurePeriodSettlements(headers); + return headers; + } + + private void synchronizeDailyPeriods(ActivityRow activity) { + List desired = buildDailyPeriodHeaders(activity); + Map desiredByDate = new HashMap<>(); + desired.forEach(header -> desiredByDate.put(header.getStatDate(), header)); + Set materializedDates = new HashSet<>( + mapper.listMaterializedDailyDates(activity.getId())); + + for (PeriodSettlementRow existing : mapper.listDailyPeriodSettlements(activity.getId())) { + PeriodSettlementRow replacement = desiredByDate.remove(existing.getStatDate()); + if (!"NOT_STARTED".equals(existing.getStatus())) { + // PROCESSING/COMPLETED/RECONCILIATION_REQUIRED 已冻结,原行与截止时间都不可改写。 + continue; + } + if (replacement != null) { + if (!materializedDates.contains(existing.getStatDate())) { + // 同一日期只要已有积分或任务,也沿用原截止时间;否则把 end/delay 调早会让 + // 历史日事实突然立即冻结,不符合“新配置只影响尚未物化日期”的规则。 + ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE, + mapper.updateNotStartedPeriodDueTime(existing.getId(), + replacement.getSnapshotDueTime()) == 1); + } + } else if (!materializedDates.contains(existing.getStatDate())) { + // 新时间窗之外且没有积分/任务的预建空门闩可以清理;已有事实的旧日期保留, + // 供历史榜单查询和之后的人工/自动结算继续使用。 + mapper.deleteNotStartedPeriodById(existing.getId()); + } + } + if (!desiredByDate.isEmpty()) { + mapper.ensurePeriodSettlements(new ArrayList<>(desiredByDate.values())); + } } private PeriodSettlementRow ensurePeriod(ActivityRow activity, String period, String periodKey, @@ -1240,19 +1418,19 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService dailyDelay >= 0 && dailyDelay <= 1440 && overallDelay >= 0 && overallDelay <= 1440 && topN >= 1 && topN <= MAX_RANK_LIMIT); if (cmd.getTasks() != null) { - validateTasks(cmd.getTasks(), true, ORIGIN_ATYOU); + validateTasks(cmd.getTasks(), false, ORIGIN_ATYOU); } if (cmd.getRankRewards() != null) { - validateRankRewards(cmd.getRankRewards(), topN, true, ORIGIN_ATYOU); + validateRankRewards(cmd.getRankRewards(), topN, false, ORIGIN_ATYOU); } } - private void validateStartedLocks(ActivityRow existing, SaveCommand cmd, Timestamp saveNow) { - if (existing.getStartTime().after(saveNow)) { + private void validateStartedLocks(ActivityRow existing, SaveCommand cmd) { + if (isConfigurationEditable(existing)) { return; } - // 计分后只允许改展示名称/描述;任何影响活动归属、时间边界、榜单容量或结算窗口的 - // 字段都必须保持原值,否则同一活动中的早晚礼物会使用不同规则。 + // 总榜获奖 parent 开始冻结后只允许改展示名称/描述;此时任何统计、榜单或奖励字段 + // 都必须保持原值,不能让已生成的最终发奖批次失去对应配置版本。 ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, Objects.equals(existing.getActivityCode(), cmd.getActivityCode().trim()) && Objects.equals(existing.getSysOrigin(), cmd.getSysOrigin()) @@ -1270,9 +1448,11 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService } private void validateReadyToEnable(ActivityRow activity) { - validateTasks(mapper.listTasks(activity.getId()), true, activity.getSysOrigin()); - validateRankRewards(mapper.listRankRewards(activity.getId()), activity.getDisplayTopN(), true, - activity.getSysOrigin()); + // 先校验结构;资源组的上架/归属/奖励项由 synchronizeRewardSnapshots 仅针对需要 + // 新建快照的组验证。已被历史 parent 引用的组继续使用旧快照,不受资源中心后改影响。 + validateTasks(mapper.listTasks(activity.getId()), false, activity.getSysOrigin()); + validateRankRewards(mapper.listRankRewards(activity.getId()), activity.getDisplayTopN(), + false, activity.getSysOrigin()); } private void validateTasks(List tasks, boolean verifyResourceGroups, String sysOrigin) { @@ -1316,6 +1496,14 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService // 出现重复领取的同义任务;两档送礼的门槛和奖励资源组仍由 console 独立配置。 ResponseAssert.isTrue(CommonErrorCode.CONFIGURATION_ERROR, enterTasks == 1 && giftTasks == 2); + Map tasksBySort = tasks.stream() + .collect(Collectors.toMap(Task::getSortOrder, task -> task)); + // taskCode 是 user_task_daily 的幂等键,type/sort 决定进度入口与页面槽位;活动中 + // 允许改阈值和奖励,但这三项必须永久固定,否则同一用户同一天会新增另一套任务。 + ResponseAssert.isTrue(CommonErrorCode.CONFIGURATION_ERROR, + fixedTaskIdentity(tasksBySort.get(1), TASK_CODE_ENTER, TASK_ENTER_PAGE) + && fixedTaskIdentity(tasksBySort.get(2), TASK_CODE_GIFT_1, TASK_SEND_GIFT_GOLD) + && fixedTaskIdentity(tasksBySort.get(3), TASK_CODE_GIFT_2, TASK_SEND_GIFT_GOLD)); List orderedGiftTasks = giftTaskConfigs.stream() .sorted(Comparator.comparingInt(Task::getSortOrder)).toList(); // 两档累计送礼按页面槽位必须严格递增;相同或倒序门槛会形成重复档位并误导领取状态。 @@ -1324,6 +1512,11 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService .compareTo(orderedGiftTasks.get(1).getTargetValue()) < 0); } + private boolean fixedTaskIdentity(Task task, String code, String type) { + return task != null && code.equals(StringUtils.trim(task.getTaskCode())) + && type.equals(task.getTaskType() == null ? "" : task.getTaskType().trim().toUpperCase()); + } + private void validateRankRewards(List rewards, int displayTopN, boolean verifyResourceGroups, String sysOrigin) { ResponseAssert.isTrue(CommonErrorCode.CONFIGURATION_ERROR, @@ -1367,8 +1560,9 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService private void replaceTasks(ActivityRow activity, List tasks, boolean initialConfiguration) { ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, - initialConfiguration || activity.getStartTime().after(Timestamp.from(Instant.now()))); - validateTasks(tasks, true, activity.getSysOrigin()); + initialConfiguration + || isConfigurationEditable(activity)); + validateTasks(tasks, false, activity.getSysOrigin()); mapper.deleteTasks(activity.getId()); tasks.stream().sorted(Comparator.comparingInt(Task::getSortOrder)).forEach(task -> mapper.insertTask(new Task() @@ -1387,8 +1581,9 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService private void replaceRankRewards(ActivityRow activity, List rewards, boolean initialConfiguration) { ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, - initialConfiguration || activity.getStartTime().after(Timestamp.from(Instant.now()))); - validateRankRewards(rewards, activity.getDisplayTopN(), true, activity.getSysOrigin()); + initialConfiguration + || isConfigurationEditable(activity)); + validateRankRewards(rewards, activity.getDisplayTopN(), false, activity.getSysOrigin()); mapper.deleteRankRewards(activity.getId()); rewards.stream().sorted(Comparator.comparing(RankReward::getPeriodType) .thenComparingInt(RankReward::getStartRank)) @@ -1603,8 +1798,14 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService // 活动窗口之外而让整个活动页报错;客户端显式请求未来日期仍会被拒绝。 date = date.isBefore(first) ? first : (date.isAfter(last) ? last : date); } + boolean inCurrentWindow = !date.isBefore(first) && !date.isAfter(last); + boolean historicalPeriodExists = explicit && mapper.getPeriodSettlement(activity.getId(), + PERIOD_DAILY, date.toString()) != null; + // 在线编辑可能缩短窗口或改变时区;显式查询已保留的旧 period header 时仍应可查榜、 + // 可结算。不存在真实门闩的任意越界日期继续拒绝,避免放开无限日期扫描。 ResponseAssert.isTrue(CommonErrorCode.TYPE_IS_NOT_IN_SCOPE, - !date.isBefore(first) && !date.isAfter(last) && (!explicit || !date.isAfter(today))); + (inCurrentWindow || historicalPeriodExists) + && (!explicit || historicalPeriodExists || !date.isAfter(today))); return Date.valueOf(date); } @@ -1694,4 +1895,10 @@ public class AslanGiftChallengeServiceImpl implements AslanGiftChallengeService ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, activity.getStartTime().after(Timestamp.from(Instant.now()))); } + + private boolean isConfigurationEditable(ActivityRow activity) { + // 开始时间只是事件归属边界,不再兼任配置写门闩。历史积分、任务和日榜由独立 + // parent/snapshot 保持旧语义;只有总榜获奖人开始冻结后才禁止改变核心配置。 + return activity != null && "NOT_STARTED".equals(activity.getOverallSettlementStatus()); + } } diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/activity/aslan/AslanGiftChallengeMapper.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/activity/aslan/AslanGiftChallengeMapper.java index 9ba26758..a112d81a 100644 --- a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/activity/aslan/AslanGiftChallengeMapper.java +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/activity/aslan/AslanGiftChallengeMapper.java @@ -179,6 +179,25 @@ public interface AslanGiftChallengeMapper { + " ORDER BY resource_group_id ASC, sort_order ASC, reward_config_id ASC") List listRewardSnapshots(@Param("activityId") Long activityId); + @Select("SELECT DISTINCT resource_group_id FROM aslan_gift_challenge_reward_snapshot" + + " WHERE activity_id = #{activityId}") + List listRewardSnapshotGroupIds(@Param("activityId") Long activityId); + + /** 各 EXISTS 都只命中 activity_id 前缀并在首行停止,避免保存时扫描整场任务明细。 */ + @Select("SELECT CASE WHEN" + + " EXISTS (SELECT 1 FROM aslan_gift_challenge_user_task_daily" + + " WHERE activity_id = #{activityId} LIMIT 1)" + + " OR EXISTS (SELECT 1 FROM aslan_gift_challenge_user_daily_score" + + " WHERE activity_id = #{activityId} LIMIT 1)" + + " OR EXISTS (SELECT 1 FROM aslan_gift_challenge_gift_ledger" + + " WHERE activity_id = #{activityId} LIMIT 1)" + + " OR EXISTS (SELECT 1 FROM aslan_gift_challenge_settlement" + + " WHERE activity_id = #{activityId} LIMIT 1)" + + " OR EXISTS (SELECT 1 FROM aslan_gift_challenge_period_settlement" + + " WHERE activity_id = #{activityId} AND status <> 'NOT_STARTED' LIMIT 1)" + + " THEN 1 ELSE 0 END") + boolean hasMaterializedActivityState(@Param("activityId") Long activityId); + @Select("SELECT " + REWARD_SNAPSHOT_COLUMNS + " FROM aslan_gift_challenge_reward_snapshot WHERE activity_id = #{activityId}" + " AND resource_group_id = #{resourceGroupId}" @@ -189,6 +208,11 @@ public interface AslanGiftChallengeMapper { @Delete("DELETE FROM aslan_gift_challenge_reward_snapshot WHERE activity_id = #{activityId}") int deleteRewardSnapshots(@Param("activityId") Long activityId); + @Delete("DELETE FROM aslan_gift_challenge_reward_snapshot WHERE activity_id = #{activityId}" + + " AND resource_group_id = #{resourceGroupId}") + int deleteRewardSnapshotsByGroup(@Param("activityId") Long activityId, + @Param("resourceGroupId") Long resourceGroupId); + @Insert({ "