feat: keep gift challenge editable before settlement

This commit is contained in:
zhx 2026-07-20 16:48:19 +08:00
parent 88b20f9ca3
commit f46785818b
3 changed files with 415 additions and 111 deletions

View File

@ -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` 直接订阅原始

View File

@ -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<String> DELIVERY_STATUSES = Set.of(
"PENDING", "PROCESSING", "SUCCESS", "UNKNOWN");
private static final Set<String> 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<RewardSnapshotRow> buildRewardSnapshots(ActivityRow activity,
Set<Long> frozenGroups) {
Set<Long> 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<RewardSnapshotRow> 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<Long> existingGroups = new HashSet<>(
mapper.listRewardSnapshotGroupIds(activity.getId()));
// 一旦活动有任意业务事实保守冻结此前所有活动级快照组组数最多受 5000
// 快照上限约束避免在 activity X 锁内扫描百万级 user_task_daily 去逐组去重
Set<Long> protectedGroups = mapper.hasMaterializedActivityState(activity.getId())
? new HashSet<>(existingGroups) : Collections.emptySet();
Set<Long> frozenProtectedGroups = existingGroups.stream()
.filter(protectedGroups::contains).collect(Collectors.toSet());
List<RewardSnapshotRow> 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<Long> 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<Long> protectedGroups = new HashSet<>(mapper.listRewardSnapshotGroupIds(activityId));
Set<Date> 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<PeriodSettlementRow> 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<PeriodSettlementRow> desired = buildDailyPeriodHeaders(activity);
Map<Date, PeriodSettlementRow> desiredByDate = new HashMap<>();
desired.forEach(header -> desiredByDate.put(header.getStatDate(), header));
Set<Date> 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<Task> 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<Integer, Task> 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<Task> 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<RankReward> 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<Task> 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<RankReward> 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());
}
}

View File

@ -179,6 +179,25 @@ public interface AslanGiftChallengeMapper {
+ " ORDER BY resource_group_id ASC, sort_order ASC, reward_config_id ASC")
List<RewardSnapshotRow> listRewardSnapshots(@Param("activityId") Long activityId);
@Select("SELECT DISTINCT resource_group_id FROM aslan_gift_challenge_reward_snapshot"
+ " WHERE activity_id = #{activityId}")
List<Long> 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({
"<script>",
"INSERT INTO aslan_gift_challenge_reward_snapshot",
@ -312,6 +336,34 @@ public interface AslanGiftChallengeMapper {
@Param("statDate") Date statDate, @Param("userId") Long userId,
@Param("taskCode") String taskCode);
@Select("SELECT " + TASK_DAILY_COLUMNS
+ " FROM aslan_gift_challenge_user_task_daily WHERE activity_id = #{activityId}"
+ " AND user_id = #{userId} AND task_code = #{taskCode}"
+ " AND claim_request_id = #{requestId} AND completed_time IS NOT NULL"
+ " ORDER BY stat_date DESC, id DESC LIMIT 1")
TaskDailyRow findClaimRequestTask(@Param("activityId") Long activityId,
@Param("userId") Long userId, @Param("taskCode") String taskCode,
@Param("requestId") String requestId);
/**
* 在线改时区/窗口后旧日期已完成但未最终成功的任务仍必须可领取或继续恢复
* 查询由 (activity_id, stat_date, user_id, task_code) 唯一索引的 activity 前缀收敛
*/
@Select("SELECT " + TASK_DAILY_COLUMNS
+ " FROM aslan_gift_challenge_user_task_daily WHERE activity_id = #{activityId}"
+ " AND user_id = #{userId} AND task_code = #{taskCode} AND completed_time IS NOT NULL"
+ " AND delivery_status IN ('NOT_CLAIMED','PENDING','PROCESSING')"
+ " ORDER BY stat_date DESC, id DESC LIMIT 1")
TaskDailyRow findLatestClaimableTask(@Param("activityId") Long activityId,
@Param("userId") Long userId, @Param("taskCode") String taskCode);
@Select("SELECT " + TASK_DAILY_COLUMNS
+ " FROM aslan_gift_challenge_user_task_daily WHERE activity_id = #{activityId}"
+ " AND user_id = #{userId} AND task_code = #{taskCode} AND completed_time IS NOT NULL"
+ " AND delivery_status = 'UNKNOWN' ORDER BY stat_date DESC, id DESC LIMIT 1")
TaskDailyRow findLatestUnknownTask(@Param("activityId") Long activityId,
@Param("userId") Long userId, @Param("taskCode") String taskCode);
@Select("SELECT " + TASK_DAILY_COLUMNS
+ " FROM aslan_gift_challenge_user_task_daily WHERE id = #{id} LIMIT 1 FOR UPDATE")
TaskDailyRow lockTaskDaily(@Param("id") Long id);
@ -380,6 +432,38 @@ public interface AslanGiftChallengeMapper {
+ " AND status = 'NOT_STARTED'")
int deleteNotStartedDailyPeriods(@Param("activityId") Long activityId);
@Select("SELECT " + PERIOD_COLUMNS + " FROM aslan_gift_challenge_period_settlement"
+ " WHERE activity_id = #{activityId} AND period_type = 'DAILY'"
+ " ORDER BY stat_date ASC, id ASC")
List<PeriodSettlementRow> listDailyPeriodSettlements(@Param("activityId") Long activityId);
/**
* 以最多 366 period header 驱动 EXISTS事实表均按 (activity_id, stat_date) 点查
* 避免持有 activity 排他锁时 DISTINCT 扫描整场用户任务或积分明细
*/
@Select("SELECT period.stat_date FROM aslan_gift_challenge_period_settlement period"
+ " WHERE period.activity_id = #{activityId} AND period.period_type = 'DAILY'"
+ " AND (period.status <> 'NOT_STARTED'"
+ " OR EXISTS (SELECT 1 FROM aslan_gift_challenge_user_daily_score score"
+ " WHERE score.activity_id = period.activity_id AND score.stat_date = period.stat_date)"
+ " OR EXISTS (SELECT 1 FROM aslan_gift_challenge_user_task_daily task"
+ " WHERE task.activity_id = period.activity_id AND task.stat_date = period.stat_date)"
+ " OR EXISTS (SELECT 1 FROM aslan_gift_challenge_gift_ledger ledger"
+ " WHERE ledger.activity_id = period.activity_id AND ledger.stat_date = period.stat_date)"
+ " OR EXISTS (SELECT 1 FROM aslan_gift_challenge_settlement settled"
+ " WHERE settled.activity_id = period.activity_id AND settled.period_type = 'DAILY'"
+ " AND settled.period_key = period.period_key))")
List<Date> listMaterializedDailyDates(@Param("activityId") Long activityId);
@Update("UPDATE aslan_gift_challenge_period_settlement SET snapshot_due_time = #{dueTime},"
+ " update_time = NOW() WHERE id = #{id} AND status = 'NOT_STARTED'")
int updateNotStartedPeriodDueTime(@Param("id") Long id,
@Param("dueTime") Timestamp dueTime);
@Delete("DELETE FROM aslan_gift_challenge_period_settlement"
+ " WHERE id = #{id} AND status = 'NOT_STARTED'")
int deleteNotStartedPeriodById(@Param("id") Long id);
@Select("SELECT " + PERIOD_COLUMNS + " FROM aslan_gift_challenge_period_settlement"
+ " WHERE activity_id = #{activityId} AND period_type = #{periodType}"
+ " AND period_key = #{periodKey} LIMIT 1")