From 7c1ecb804e3e6d39bb8c27ccb62d4287ab37a56c Mon Sep 17 00:00:00 2001 From: hy001 Date: Mon, 27 Apr 2026 21:10:27 +0800 Subject: [PATCH] fix invite calendar month reset --- internal/service/invite/bind.go | 2 +- internal/service/invite/home.go | 6 +- internal/service/invite/invite_test.go | 22 +++-- internal/service/invite/normalize.go | 20 ++-- internal/service/invite/recharge.go | 2 +- internal/service/invite/task.go | 2 +- internal/service/invite/types.go | 1 - ..._invite_campaign_calendar_month_period.sql | 94 +++++++++++++++++++ 8 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 migrations/016_invite_campaign_calendar_month_period.sql diff --git a/internal/service/invite/bind.go b/internal/service/invite/bind.go index d9938dc..8f64438 100644 --- a/internal/service/invite/bind.go +++ b/internal/service/invite/bind.go @@ -134,7 +134,7 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st // 在锁内判定风控资格并写入关系、进度两张表。 bindTime := time.Now() - periodKey, _, _ = thirtyDayPeriodInfoAt(bindTime, normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime) + periodKey, _, _, _ = calendarMonthPeriodInfoAt(bindTime, normalizeTimezone(bundle.Config.Timezone)) if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" { exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP) if existsErr != nil { diff --git a/internal/service/invite/home.go b/internal/service/invite/home.go index 5464f6a..c54155c 100644 --- a/internal/service/invite/home.go +++ b/internal/service/invite/home.go @@ -20,10 +20,10 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon return nil, err } - // 首页需要同时返回当前 30 天周期进度、累计统计、邀请码和任务配置。 + // 首页需要同时返回当前自然月周期进度、累计统计、邀请码和任务配置。 now := time.Now() timezone := normalizeTimezone(bundle.Config.Timezone) - periodKey, periodEndAt, secondsToReset := thirtyDayPeriodInfoAt(now, timezone, bundle.Config.CreateTime) + periodKey, periodEndAt, secondsToReset, resetPeriodDays := calendarMonthPeriodInfoAt(now, timezone) monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, periodKey) if err != nil { return nil, err @@ -58,7 +58,7 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon ServerTimeMs: now.UnixMilli(), PeriodEndAtMs: periodEndAt.UnixMilli(), SecondsToReset: secondsToReset, - ResetPeriodDays: inviteTaskPeriodDays, + ResetPeriodDays: resetPeriodDays, ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules), InviterBindReward: rewardFromRule(inviterBindRule), InviteeBindReward: rewardFromRule(inviteeBindRule), diff --git a/internal/service/invite/invite_test.go b/internal/service/invite/invite_test.go index d8ee8de..206f094 100644 --- a/internal/service/invite/invite_test.go +++ b/internal/service/invite/invite_test.go @@ -115,13 +115,12 @@ func seedInviteCampaign(t *testing.T, db *gorm.DB) time.Time { return now } -func TestThirtyDayPeriodInfoAtReturnsNextPeriodStart(t *testing.T) { - anchor := time.Date(2026, 4, 1, 9, 30, 0, 0, time.FixedZone("CST", 8*3600)) +func TestCalendarMonthPeriodInfoAtReturnsNextMonthStart(t *testing.T) { now := time.Date(2026, 4, 30, 23, 59, 30, 0, time.FixedZone("CST", 8*3600)) - periodKey, periodEnd, seconds := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", anchor) + periodKey, periodEnd, seconds, periodDays := calendarMonthPeriodInfoAt(now, "Asia/Shanghai") - if periodKey != "20260401" { - t.Fatalf("periodKey = %s, want 20260401", periodKey) + if periodKey != "202604" { + t.Fatalf("periodKey = %s, want 202604", periodKey) } if got := periodEnd.In(time.FixedZone("CST", 8*3600)).Format(time.RFC3339); got != "2026-05-01T00:00:00+08:00" { t.Fatalf("periodEnd = %s, want 2026-05-01T00:00:00+08:00", got) @@ -129,15 +128,18 @@ func TestThirtyDayPeriodInfoAtReturnsNextPeriodStart(t *testing.T) { if seconds != 30 { t.Fatalf("seconds = %d, want 30", seconds) } + if periodDays != 30 { + t.Fatalf("periodDays = %d, want 30", periodDays) + } } func TestGetHomeUsesValidUserCountForValidCountTasks(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() - configTime := seedInviteCampaign(t, db) + seedInviteCampaign(t, db) now := time.Now() - periodKey, _, _ := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", configTime) + periodKey, _, _, _ := calendarMonthPeriodInfoAt(now, "Asia/Shanghai") if err := db.Create(&model.InviteCodeMapping{UserID: 1001, InviteCode: "INV1001", Account: "u1001"}).Error; err != nil { t.Fatalf("seed invite code: %v", err) } @@ -187,10 +189,10 @@ func TestClaimTaskUsesValidUserCountForValidCountTasks(t *testing.T) { gateway := &inviteTestGateway{} service, db, cleanup := newTestInviteService(t, gateway) defer cleanup() - configTime := seedInviteCampaign(t, db) + seedInviteCampaign(t, db) now := time.Now() - periodKey, _, _ := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", configTime) + periodKey, _, _, _ := calendarMonthPeriodInfoAt(now, "Asia/Shanghai") if err := db.Create(&model.InviteCampaignRewardRule{ ID: 2002, SysOrigin: "LIKEI", @@ -417,7 +419,7 @@ func TestRechargeMarksValidUserOnlyAfterEightyThousandCoins(t *testing.T) { configTime := seedInviteCampaign(t, db) payTime := configTime.Add(time.Hour) - periodKey, _, _ := thirtyDayPeriodInfoAt(payTime, "Asia/Shanghai", configTime) + periodKey, _, _, _ := calendarMonthPeriodInfoAt(payTime, "Asia/Shanghai") rows := []any{ &model.InviteCampaignRewardRule{ ID: 2101, diff --git a/internal/service/invite/normalize.go b/internal/service/invite/normalize.go index 4a530ae..3286e05 100644 --- a/internal/service/invite/normalize.go +++ b/internal/service/invite/normalize.go @@ -35,26 +35,18 @@ func inviteLocation(timezone string) *time.Location { return location } -// thirtyDayPeriodInfoAt 返回指定时间所在 30 天任务周期的 key、结束时间和剩余秒数。 -func thirtyDayPeriodInfoAt(t time.Time, timezone string, anchor time.Time) (string, time.Time, int64) { +// calendarMonthPeriodInfoAt 返回指定时间所在自然月任务周期的 key、结束时间、剩余秒数和周期天数。 +func calendarMonthPeriodInfoAt(t time.Time, timezone string) (string, time.Time, int64, int) { location := inviteLocation(timezone) localTime := t.In(location) - anchorLocal := anchor.In(location) - if anchor.IsZero() { - anchorLocal = time.Date(1970, 1, 1, 0, 0, 0, 0, location) - } - anchorStart := time.Date(anchorLocal.Year(), anchorLocal.Month(), anchorLocal.Day(), 0, 0, 0, 0, location) - elapsedDays := int(localTime.Sub(anchorStart) / (24 * time.Hour)) - if elapsedDays < 0 { - elapsedDays = 0 - } - periodStart := anchorStart.AddDate(0, 0, elapsedDays/inviteTaskPeriodDays*inviteTaskPeriodDays) - periodEnd := periodStart.AddDate(0, 0, inviteTaskPeriodDays) + periodStart := time.Date(localTime.Year(), localTime.Month(), 1, 0, 0, 0, 0, location) + periodEnd := periodStart.AddDate(0, 1, 0) seconds := int64(periodEnd.Sub(localTime).Seconds()) if seconds < 0 { seconds = 0 } - return periodStart.Format("20060102"), periodEnd, seconds + periodDays := int(periodEnd.Sub(periodStart) / (24 * time.Hour)) + return periodStart.Format("200601"), periodEnd, seconds, periodDays } // digitsOnly 判断字符串是否只包含数字。 diff --git a/internal/service/invite/recharge.go b/internal/service/invite/recharge.go index 61e3281..c03f287 100644 --- a/internal/service/invite/recharge.go +++ b/internal/service/invite/recharge.go @@ -41,7 +41,7 @@ func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeS if err != nil { return nil, NewAppError(400, "invalid_pay_time", err.Error()) } - periodKey, _, _ := thirtyDayPeriodInfoAt(payTime, normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime) + periodKey, _, _, _ := calendarMonthPeriodInfoAt(payTime, normalizeTimezone(bundle.Config.Timezone)) inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) validThreshold := effectiveValidThreshold(inviteeRechargeRules) diff --git a/internal/service/invite/task.go b/internal/service/invite/task.go index 4a61cc1..50f65c5 100644 --- a/internal/service/invite/task.go +++ b/internal/service/invite/task.go @@ -29,7 +29,7 @@ func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskCl return nil, NewAppError(400, "invalid_rule_type", "rule cannot be claimed manually") } - periodKey, _, _ := thirtyDayPeriodInfoAt(time.Now(), normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime) + periodKey, _, _, _ := calendarMonthPeriodInfoAt(time.Now(), normalizeTimezone(bundle.Config.Timezone)) claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, periodKey, rule.ID) if err != nil { return nil, err diff --git a/internal/service/invite/types.go b/internal/service/invite/types.go index cedc700..bca2cb5 100644 --- a/internal/service/invite/types.go +++ b/internal/service/invite/types.go @@ -36,7 +36,6 @@ const ( ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE" defaultInviteRankLimit = 50 maxInviteRankLimit = 100 - inviteTaskPeriodDays = 30 ) var errAlreadyProcessed = errors.New("invite recharge already processed") diff --git a/migrations/016_invite_campaign_calendar_month_period.sql b/migrations/016_invite_campaign_calendar_month_period.sql new file mode 100644 index 0000000..ca5e21d --- /dev/null +++ b/migrations/016_invite_campaign_calendar_month_period.sql @@ -0,0 +1,94 @@ +CREATE TEMPORARY TABLE tmp_invite_campaign_monthly_progress_merge AS +SELECT + MIN(id) AS id, + sys_origin, + inviter_user_id, + LEFT(month_key, 6) AS month_key, + SUM(invite_count) AS invite_count, + SUM(valid_user_count) AS valid_user_count, + SUM(total_recharge_coins) AS total_recharge_coins, + SUM(reward_gold_coins) AS reward_gold_coins, + MIN(create_time) AS create_time, + MAX(update_time) AS update_time +FROM invite_campaign_monthly_progress +WHERE CHAR_LENGTH(month_key) = 8 +GROUP BY sys_origin, inviter_user_id, LEFT(month_key, 6); + +DELETE FROM invite_campaign_monthly_progress +WHERE CHAR_LENGTH(month_key) = 8; + +INSERT INTO invite_campaign_monthly_progress ( + id, + sys_origin, + inviter_user_id, + month_key, + invite_count, + valid_user_count, + total_recharge_coins, + reward_gold_coins, + create_time, + update_time +) +SELECT + id, + sys_origin, + inviter_user_id, + month_key, + invite_count, + valid_user_count, + total_recharge_coins, + reward_gold_coins, + create_time, + update_time +FROM tmp_invite_campaign_monthly_progress_merge +ON DUPLICATE KEY UPDATE + invite_count = invite_campaign_monthly_progress.invite_count + VALUES(invite_count), + valid_user_count = invite_campaign_monthly_progress.valid_user_count + VALUES(valid_user_count), + total_recharge_coins = invite_campaign_monthly_progress.total_recharge_coins + VALUES(total_recharge_coins), + reward_gold_coins = invite_campaign_monthly_progress.reward_gold_coins + VALUES(reward_gold_coins), + update_time = GREATEST(invite_campaign_monthly_progress.update_time, VALUES(update_time)); + +DROP TEMPORARY TABLE tmp_invite_campaign_monthly_progress_merge; + +CREATE TEMPORARY TABLE tmp_invite_campaign_task_claim_merge AS +SELECT + MIN(id) AS id, + sys_origin, + inviter_user_id, + LEFT(month_key, 6) AS month_key, + rule_id, + MIN(claim_time) AS claim_time, + MIN(create_time) AS create_time, + MAX(update_time) AS update_time +FROM invite_campaign_task_claim +WHERE CHAR_LENGTH(month_key) = 8 +GROUP BY sys_origin, inviter_user_id, LEFT(month_key, 6), rule_id; + +DELETE FROM invite_campaign_task_claim +WHERE CHAR_LENGTH(month_key) = 8; + +INSERT INTO invite_campaign_task_claim ( + id, + sys_origin, + inviter_user_id, + month_key, + rule_id, + claim_time, + create_time, + update_time +) +SELECT + id, + sys_origin, + inviter_user_id, + month_key, + rule_id, + claim_time, + create_time, + update_time +FROM tmp_invite_campaign_task_claim_merge +ON DUPLICATE KEY UPDATE + claim_time = LEAST(invite_campaign_task_claim.claim_time, VALUES(claim_time)), + update_time = GREATEST(invite_campaign_task_claim.update_time, VALUES(update_time)); + +DROP TEMPORARY TABLE tmp_invite_campaign_task_claim_merge;