feat: keep gift challenge editable before settlement
This commit is contained in:
parent
88b20f9ca3
commit
f46785818b
@ -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` 直接订阅原始
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user