diff --git a/docs/yumi_gift_challenge_api.md b/docs/yumi_gift_challenge_api.md index 6c30a96..86b06f2 100644 --- a/docs/yumi_gift_challenge_api.md +++ b/docs/yumi_gift_challenge_api.md @@ -36,7 +36,7 @@ Yumi 的 Java `sysOrigin` 固定为 `LIKEI`。网关外部地址在以下路径 | POST | `/settlement/:id` | JSON `{ "periodType":"DAILY\|OVERALL", "statDate":"yyyy-MM-dd" }`;OVERALL 不传日期 | `...:settle` | | 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` 门闩按新配置重建。 日榜、总榜结算延迟均允许 `0-1440` 分钟,且 `overallSettlementDelayMinutes` 必须大于或等于 `dailySettlementDelayMinutes`,保证最终日榜不会晚于总榜关门。 diff --git a/internal/model/yumi_gift_challenge_models.go b/internal/model/yumi_gift_challenge_models.go index a8fb068..874f4aa 100644 --- a/internal/model/yumi_gift_challenge_models.go +++ b/internal/model/yumi_gift_challenge_models.go @@ -5,23 +5,24 @@ import "time" // YumiGiftChallengeActivity 保存一次独立活动周期;总榜结算开始前允许调整活动配置, // 已产生的用户任务、日榜结算和奖励项由各自快照保持历史语义。 type YumiGiftChallengeActivity struct { - ID int64 `gorm:"column:id;primaryKey"` - ActivityCode string `gorm:"column:activity_code"` - ActivityName string `gorm:"column:activity_name"` - ActivityDesc string `gorm:"column:activity_desc"` - SysOrigin string `gorm:"column:sys_origin"` - Timezone string `gorm:"column:time_zone"` - StartTime time.Time `gorm:"column:start_time"` - EndTime time.Time `gorm:"column:end_time"` - DailySettlementDelayMinutes int `gorm:"column:daily_settlement_delay_minutes"` - OverallSettlementDelayMinutes int `gorm:"column:overall_settlement_delay_minutes"` - OverallSettlementTime time.Time `gorm:"column:overall_settlement_time"` - DisplayTopN int `gorm:"column:display_top_n"` - Enabled bool `gorm:"column:enabled"` - OverallSettlementStatus string `gorm:"column:overall_settlement_status"` - Version int `gorm:"column:version"` - CreateTime time.Time `gorm:"column:create_time"` - UpdateTime time.Time `gorm:"column:update_time"` + ID int64 `gorm:"column:id;primaryKey"` + ActivityCode string `gorm:"column:activity_code"` + ActivityName string `gorm:"column:activity_name"` + ActivityDesc string `gorm:"column:activity_desc"` + SysOrigin string `gorm:"column:sys_origin"` + Timezone string `gorm:"column:time_zone"` + StartTime time.Time `gorm:"column:start_time"` + EndTime time.Time `gorm:"column:end_time"` + DailySettlementDelayMinutes int `gorm:"column:daily_settlement_delay_minutes"` + OverallSettlementDelayMinutes int `gorm:"column:overall_settlement_delay_minutes"` + OverallSettlementTime time.Time `gorm:"column:overall_settlement_time"` + DisplayTopN int `gorm:"column:display_top_n"` + Enabled bool `gorm:"column:enabled"` + EnabledTime *time.Time `gorm:"column:enabled_time"` + OverallSettlementStatus string `gorm:"column:overall_settlement_status"` + Version int `gorm:"column:version"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` } func (YumiGiftChallengeActivity) TableName() string { return "yumi_gift_challenge_activity" } diff --git a/internal/service/yumigiftchallenge/config.go b/internal/service/yumigiftchallenge/config.go index e5f0154..c701308 100644 --- a/internal/service/yumigiftchallenge/config.go +++ b/internal/service/yumigiftchallenge/config.go @@ -90,9 +90,12 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailRes preserveLiveState := false if isNew { if req.Enabled { - if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) { - return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future") + enabledTime := time.Now() + 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 { 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") } txNow := time.Now() + enabledTime := current.EnabledTime + if !req.Enabled { + enabledTime = nil + } else if !current.Enabled { + // 停用草稿中途开启时从本次提交时刻计分,不使用已经过去的配置开始时间。 + enabledTime = &txNow + } startedActive := current.Enabled && !txNow.Before(current.StartTime) preserveLiveState = startedActive 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") } if req.Enabled { - if !current.Enabled && (!proposed.StartTime.After(txNow) || !proposed.EndTime.After(txNow)) { - return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must move its full time window to the future before enabling") + if !current.Enabled && !proposed.EndTime.After(txNow) { + return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must end in the future before enabling") } if err := ensureNoEnabledOverlap(tx, proposed); err != nil { return err @@ -147,7 +157,7 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailRes "daily_settlement_delay_minutes": proposed.DailySettlementDelayMinutes, "overall_settlement_delay_minutes": proposed.OverallSettlementDelayMinutes, "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, }) 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") } now := time.Now() - if enabled && (!activity.StartTime.After(now) || !activity.EndTime.After(now)) { - return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must move its full time window to the future before enabling") + if enabled && !activity.EndTime.After(now) { + 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") } if !enabled { @@ -457,7 +470,10 @@ func (s *Service) SetEnabled(ctx context.Context, activityID int64, enabled bool return err } 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) { return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be enabled") } - if !locked.StartTime.After(now) || !locked.EndTime.After(now) { - return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must move its full time window to the future before enabling") + if !locked.EndTime.After(now) { + return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must end in the future before enabling") } if locked.Version != activity.Version { 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 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 }) } diff --git a/internal/service/yumigiftchallenge/event.go b/internal/service/yumigiftchallenge/event.go index ffbc21c..e1b2b8a 100644 --- a/internal/service/yumigiftchallenge/event.go +++ b/internal/service/yumigiftchallenge/event.go @@ -71,8 +71,8 @@ func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent) error { // 新版本,不会拿旧 timezone 算 statDate 后撞上新门闩而静默丢失。 var activity model.YumiGiftChallengeActivity queryErr := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where( - "sys_origin = ? AND enabled = ? AND start_time <= ? AND end_time > ?", - origin, true, eventTime, eventTime, + "sys_origin = ? AND enabled = ? AND enabled_time IS NOT NULL AND enabled_time <= ? AND start_time <= ? AND end_time > ?", + origin, true, eventTime, eventTime, eventTime, ).Order("start_time DESC").First(&activity).Error if errors.Is(queryErr, gorm.ErrRecordNotFound) { return nil diff --git a/migrations/061_yumi_gift_challenge_late_enable.sql b/migrations/061_yumi_gift_challenge_late_enable.sql new file mode 100644 index 0000000..f1a60cd --- /dev/null +++ b/migrations/061_yumi_gift_challenge_late_enable.sql @@ -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;