fix: allow late enabling Yumi gift challenge
This commit is contained in:
parent
48242f0ade
commit
489aa40a50
@ -36,7 +36,7 @@ Yumi 的 Java `sysOrigin` 固定为 `LIKEI`。网关外部地址在以下路径
|
|||||||
| POST | `/settlement/:id` | JSON `{ "periodType":"DAILY\|OVERALL", "statDate":"yyyy-MM-dd" }`;OVERALL 不传日期 | `...:settle` |
|
| POST | `/settlement/:id` | JSON `{ "periodType":"DAILY\|OVERALL", "statDate":"yyyy-MM-dd" }`;OVERALL 不传日期 | `...:settle` |
|
||||||
| POST | `/delivery-item/resolve/:itemId` | JSON `{ "delivered": true\|false }`;仅人工核账 UNKNOWN | `...:reconcile` |
|
| POST | `/delivery-item/resolve/:itemId` | JSON `{ "delivered": true\|false }`;仅人工核账 UNKNOWN | `...:reconcile` |
|
||||||
|
|
||||||
新建并启用活动、或重新启用已停用活动时,`startTime`、`endTime` 均须晚于服务端当前时间;同时校验奖励组已上架且属于 LIKEI,并冻结完整奖励项。活动即使已经进入时间窗,只要 `overallSettlementStatus=NOT_STARTED`,仍可通过 `/save`、`/tasks/:id`、`/rank-rewards/:id` 修改完整配置。只有总榜结算进入 `PROCESSING/COMPLETED/RECONCILIATION_REQUIRED` 后,才仅允许修改活动名称和说明。
|
新建并启用活动、或重新启用已停用活动时,只要求 `endTime` 晚于服务端当前时间;`startTime` 已经过期但活动尚未结束时仍可中途启用。服务端把本次实际启用时间写入 `enabled_time`,只接收事件时间不早于该时刻的礼物,因此不会补算启用前的历史消息。同时校验奖励组已上架且属于 LIKEI,并冻结完整奖励项。活动即使已经进入时间窗,只要 `overallSettlementStatus=NOT_STARTED`,仍可通过 `/save`、`/tasks/:id`、`/rank-rewards/:id` 修改完整配置。只有总榜结算进入 `PROCESSING/COMPLETED/RECONCILIATION_REQUIRED` 后,才仅允许修改活动名称和说明。
|
||||||
|
|
||||||
进行中编辑按提交时点向后生效,不回算已经入账的积分。已物化的用户日任务、已冻结日榜、榜奖 parent 和 delivery item 保留原目标及奖励;尚未物化的用户/日期和未冻结榜奖使用新配置。旧任务或榜奖引用的资源组快照始终保留;同一已引用资源组不会刷新,若要更换奖励内容应选择新的资源组。修改时间窗或时区后,事件事务会在活动共享锁内重新读取窗口和时区;已产生数据的历史日期门闩保留,其余 `NOT_STARTED` 门闩按新配置重建。
|
进行中编辑按提交时点向后生效,不回算已经入账的积分。已物化的用户日任务、已冻结日榜、榜奖 parent 和 delivery item 保留原目标及奖励;尚未物化的用户/日期和未冻结榜奖使用新配置。旧任务或榜奖引用的资源组快照始终保留;同一已引用资源组不会刷新,若要更换奖励内容应选择新的资源组。修改时间窗或时区后,事件事务会在活动共享锁内重新读取窗口和时区;已产生数据的历史日期门闩保留,其余 `NOT_STARTED` 门闩按新配置重建。
|
||||||
日榜、总榜结算延迟均允许 `0-1440` 分钟,且 `overallSettlementDelayMinutes` 必须大于或等于 `dailySettlementDelayMinutes`,保证最终日榜不会晚于总榜关门。
|
日榜、总榜结算延迟均允许 `0-1440` 分钟,且 `overallSettlementDelayMinutes` 必须大于或等于 `dailySettlementDelayMinutes`,保证最终日榜不会晚于总榜关门。
|
||||||
|
|||||||
@ -5,23 +5,24 @@ import "time"
|
|||||||
// YumiGiftChallengeActivity 保存一次独立活动周期;总榜结算开始前允许调整活动配置,
|
// YumiGiftChallengeActivity 保存一次独立活动周期;总榜结算开始前允许调整活动配置,
|
||||||
// 已产生的用户任务、日榜结算和奖励项由各自快照保持历史语义。
|
// 已产生的用户任务、日榜结算和奖励项由各自快照保持历史语义。
|
||||||
type YumiGiftChallengeActivity struct {
|
type YumiGiftChallengeActivity struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
ActivityCode string `gorm:"column:activity_code"`
|
ActivityCode string `gorm:"column:activity_code"`
|
||||||
ActivityName string `gorm:"column:activity_name"`
|
ActivityName string `gorm:"column:activity_name"`
|
||||||
ActivityDesc string `gorm:"column:activity_desc"`
|
ActivityDesc string `gorm:"column:activity_desc"`
|
||||||
SysOrigin string `gorm:"column:sys_origin"`
|
SysOrigin string `gorm:"column:sys_origin"`
|
||||||
Timezone string `gorm:"column:time_zone"`
|
Timezone string `gorm:"column:time_zone"`
|
||||||
StartTime time.Time `gorm:"column:start_time"`
|
StartTime time.Time `gorm:"column:start_time"`
|
||||||
EndTime time.Time `gorm:"column:end_time"`
|
EndTime time.Time `gorm:"column:end_time"`
|
||||||
DailySettlementDelayMinutes int `gorm:"column:daily_settlement_delay_minutes"`
|
DailySettlementDelayMinutes int `gorm:"column:daily_settlement_delay_minutes"`
|
||||||
OverallSettlementDelayMinutes int `gorm:"column:overall_settlement_delay_minutes"`
|
OverallSettlementDelayMinutes int `gorm:"column:overall_settlement_delay_minutes"`
|
||||||
OverallSettlementTime time.Time `gorm:"column:overall_settlement_time"`
|
OverallSettlementTime time.Time `gorm:"column:overall_settlement_time"`
|
||||||
DisplayTopN int `gorm:"column:display_top_n"`
|
DisplayTopN int `gorm:"column:display_top_n"`
|
||||||
Enabled bool `gorm:"column:enabled"`
|
Enabled bool `gorm:"column:enabled"`
|
||||||
OverallSettlementStatus string `gorm:"column:overall_settlement_status"`
|
EnabledTime *time.Time `gorm:"column:enabled_time"`
|
||||||
Version int `gorm:"column:version"`
|
OverallSettlementStatus string `gorm:"column:overall_settlement_status"`
|
||||||
CreateTime time.Time `gorm:"column:create_time"`
|
Version int `gorm:"column:version"`
|
||||||
UpdateTime time.Time `gorm:"column:update_time"`
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (YumiGiftChallengeActivity) TableName() string { return "yumi_gift_challenge_activity" }
|
func (YumiGiftChallengeActivity) TableName() string { return "yumi_gift_challenge_activity" }
|
||||||
|
|||||||
@ -90,9 +90,12 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailRes
|
|||||||
preserveLiveState := false
|
preserveLiveState := false
|
||||||
if isNew {
|
if isNew {
|
||||||
if req.Enabled {
|
if req.Enabled {
|
||||||
if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) {
|
enabledTime := time.Now()
|
||||||
return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future")
|
if !proposed.EndTime.After(enabledTime) {
|
||||||
|
return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must end in the future")
|
||||||
}
|
}
|
||||||
|
// 创建并立即启用也以事务内的真实启用时刻为统计下界,不能把构建奖励快照期间的消息补进来。
|
||||||
|
proposed.EnabledTime = &enabledTime
|
||||||
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
|
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -120,6 +123,13 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailRes
|
|||||||
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
|
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
|
||||||
}
|
}
|
||||||
txNow := time.Now()
|
txNow := time.Now()
|
||||||
|
enabledTime := current.EnabledTime
|
||||||
|
if !req.Enabled {
|
||||||
|
enabledTime = nil
|
||||||
|
} else if !current.Enabled {
|
||||||
|
// 停用草稿中途开启时从本次提交时刻计分,不使用已经过去的配置开始时间。
|
||||||
|
enabledTime = &txNow
|
||||||
|
}
|
||||||
startedActive := current.Enabled && !txNow.Before(current.StartTime)
|
startedActive := current.Enabled && !txNow.Before(current.StartTime)
|
||||||
preserveLiveState = startedActive
|
preserveLiveState = startedActive
|
||||||
if !preserveLiveState {
|
if !preserveLiveState {
|
||||||
@ -132,8 +142,8 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailRes
|
|||||||
return NewAppError(http.StatusConflict, "activity_status_readonly", "started activity cannot be disabled")
|
return NewAppError(http.StatusConflict, "activity_status_readonly", "started activity cannot be disabled")
|
||||||
}
|
}
|
||||||
if req.Enabled {
|
if req.Enabled {
|
||||||
if !current.Enabled && (!proposed.StartTime.After(txNow) || !proposed.EndTime.After(txNow)) {
|
if !current.Enabled && !proposed.EndTime.After(txNow) {
|
||||||
return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must move its full time window to the future before enabling")
|
return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must end in the future before enabling")
|
||||||
}
|
}
|
||||||
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
|
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -147,7 +157,7 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailRes
|
|||||||
"daily_settlement_delay_minutes": proposed.DailySettlementDelayMinutes,
|
"daily_settlement_delay_minutes": proposed.DailySettlementDelayMinutes,
|
||||||
"overall_settlement_delay_minutes": proposed.OverallSettlementDelayMinutes,
|
"overall_settlement_delay_minutes": proposed.OverallSettlementDelayMinutes,
|
||||||
"overall_settlement_time": proposed.OverallSettlementTime, "display_top_n": proposed.DisplayTopN,
|
"overall_settlement_time": proposed.OverallSettlementTime, "display_top_n": proposed.DisplayTopN,
|
||||||
"enabled": proposed.Enabled, "overall_settlement_status": StatusNotStarted,
|
"enabled": proposed.Enabled, "enabled_time": enabledTime, "overall_settlement_status": StatusNotStarted,
|
||||||
"version": gorm.Expr("version + 1"), "update_time": now,
|
"version": gorm.Expr("version + 1"), "update_time": now,
|
||||||
})
|
})
|
||||||
if result.Error != nil || result.RowsAffected != 1 {
|
if result.Error != nil || result.RowsAffected != 1 {
|
||||||
@ -423,10 +433,13 @@ func (s *Service) SetEnabled(ctx context.Context, activityID int64, enabled bool
|
|||||||
return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be enabled or disabled")
|
return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be enabled or disabled")
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if enabled && (!activity.StartTime.After(now) || !activity.EndTime.After(now)) {
|
if enabled && !activity.EndTime.After(now) {
|
||||||
return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must move its full time window to the future before enabling")
|
return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must end in the future before enabling")
|
||||||
}
|
}
|
||||||
if activity.Enabled && !now.Before(activity.StartTime) {
|
if enabled && activity.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !enabled && activity.Enabled && !now.Before(activity.StartTime) {
|
||||||
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be disabled")
|
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be disabled")
|
||||||
}
|
}
|
||||||
if !enabled {
|
if !enabled {
|
||||||
@ -457,7 +470,10 @@ func (s *Service) SetEnabled(ctx context.Context, activityID int64, enabled bool
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
||||||
Updates(map[string]any{"enabled": false, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": time.Now()}).Error
|
Updates(map[string]any{
|
||||||
|
"enabled": false, "enabled_time": nil, "overall_settlement_status": StatusNotStarted,
|
||||||
|
"version": gorm.Expr("version + 1"), "update_time": time.Now(),
|
||||||
|
}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,8 +506,8 @@ func (s *Service) SetEnabled(ctx context.Context, activityID int64, enabled bool
|
|||||||
if !activityCoreConfigEditable(locked) {
|
if !activityCoreConfigEditable(locked) {
|
||||||
return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be enabled")
|
return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be enabled")
|
||||||
}
|
}
|
||||||
if !locked.StartTime.After(now) || !locked.EndTime.After(now) {
|
if !locked.EndTime.After(now) {
|
||||||
return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must move its full time window to the future before enabling")
|
return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must end in the future before enabling")
|
||||||
}
|
}
|
||||||
if locked.Version != activity.Version {
|
if locked.Version != activity.Version {
|
||||||
return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading")
|
return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading")
|
||||||
@ -515,7 +531,10 @@ func (s *Service) SetEnabled(ctx context.Context, activityID int64, enabled bool
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
||||||
Updates(map[string]any{"enabled": true, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": time.Now()}).Error
|
Updates(map[string]any{
|
||||||
|
"enabled": true, "enabled_time": now, "overall_settlement_status": StatusNotStarted,
|
||||||
|
"version": gorm.Expr("version + 1"), "update_time": now,
|
||||||
|
}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,8 +71,8 @@ func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent) error {
|
|||||||
// 新版本,不会拿旧 timezone 算 statDate 后撞上新门闩而静默丢失。
|
// 新版本,不会拿旧 timezone 算 statDate 后撞上新门闩而静默丢失。
|
||||||
var activity model.YumiGiftChallengeActivity
|
var activity model.YumiGiftChallengeActivity
|
||||||
queryErr := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
queryErr := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
||||||
"sys_origin = ? AND enabled = ? AND start_time <= ? AND end_time > ?",
|
"sys_origin = ? AND enabled = ? AND enabled_time IS NOT NULL AND enabled_time <= ? AND start_time <= ? AND end_time > ?",
|
||||||
origin, true, eventTime, eventTime,
|
origin, true, eventTime, eventTime, eventTime,
|
||||||
).Order("start_time DESC").First(&activity).Error
|
).Order("start_time DESC").First(&activity).Error
|
||||||
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
31
migrations/061_yumi_gift_challenge_late_enable.sql
Normal file
31
migrations/061_yumi_gift_challenge_late_enable.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- Yumi Gift Challenge late enable cutoff.
|
||||||
|
--
|
||||||
|
-- Performance review:
|
||||||
|
-- 1. The activity table contains only operator-created activity cycles, not gift events; the
|
||||||
|
-- one-column ALTER is forced to INSTANT so it cannot fall back to a table copy. MySQL rejects an
|
||||||
|
-- explicit LOCK clause together with INSTANT; metadata locking is therefore left to INSTANT semantics.
|
||||||
|
-- 2. The backfill touches only already-enabled activity rows. It preserves the historical behavior
|
||||||
|
-- for activities that were live before this migration, while every later enable writes NOW(3).
|
||||||
|
-- 3. Event matching still enters through idx_yumi_gc_activity_window
|
||||||
|
-- (sys_origin, enabled, start_time, end_time). enabled_time is a residual cutoff over the tiny
|
||||||
|
-- enabled set, so adding it to that index would only increase write cost without improving lookup.
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
SET @add_enabled_time := IF(
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'yumi_gift_challenge_activity'
|
||||||
|
AND COLUMN_NAME = 'enabled_time') = 0,
|
||||||
|
'ALTER TABLE `yumi_gift_challenge_activity` ADD COLUMN `enabled_time` DATETIME(3) NULL COMMENT ''首次实际启用时间,早于该时间的礼物不回放'', ALGORITHM=INSTANT',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE add_enabled_time_stmt FROM @add_enabled_time;
|
||||||
|
EXECUTE add_enabled_time_stmt;
|
||||||
|
DEALLOCATE PREPARE add_enabled_time_stmt;
|
||||||
|
|
||||||
|
UPDATE `yumi_gift_challenge_activity`
|
||||||
|
SET `enabled_time` = `start_time`
|
||||||
|
WHERE `enabled` = 1
|
||||||
|
AND `enabled_time` IS NULL;
|
||||||
Loading…
x
Reference in New Issue
Block a user