fix invite calendar month reset

This commit is contained in:
hy001 2026-04-27 21:10:27 +08:00
parent 362069e2d4
commit 7c1ecb804e
8 changed files with 118 additions and 31 deletions

View File

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

View File

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

View File

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

View File

@ -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 判断字符串是否只包含数字。

View File

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

View File

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

View File

@ -36,7 +36,6 @@ const (
ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE"
defaultInviteRankLimit = 50
maxInviteRankLimit = 100
inviteTaskPeriodDays = 30
)
var errAlreadyProcessed = errors.New("invite recharge already processed")

View File

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