fix: allow late enabling Yumi gift challenge

This commit is contained in:
hy001 2026-07-20 20:53:10 +08:00
parent 48242f0ade
commit 489aa40a50
5 changed files with 83 additions and 32 deletions

View File

@ -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`,保证最终日榜不会晚于总榜关门。

View File

@ -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" }

View File

@ -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
}) })
} }

View File

@ -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

View 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;