diff --git a/internal/config/config.go b/internal/config/config.go index 7fd2aa2..2f04161 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,7 @@ type RegisterRewardWorkerConfig struct { // InviteConfig 保存邀请活动相关配置。 type InviteConfig struct { PublicBaseURL string + PublicH5Path string } // WeekStarConfig 保存周榜模块配置。 @@ -175,6 +176,7 @@ func Load() Config { }, Invite: InviteConfig{ PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"), + PublicH5Path: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_H5_PATH", "INVITE_PUBLIC_H5_PATH"}, "/h5/app-invite/landing.html"), }, WeekStar: WeekStarConfig{ DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")), diff --git a/internal/model/invite_models.go b/internal/model/invite_models.go index 521e797..6fba8ad 100644 --- a/internal/model/invite_models.go +++ b/internal/model/invite_models.go @@ -79,12 +79,12 @@ type InviteCampaignInviteeProgress struct { // TableName 返回被邀请人进度表名。 func (InviteCampaignInviteeProgress) TableName() string { return "invite_campaign_invitee_progress" } -// InviteCampaignMonthlyProgress 汇总邀请人月度邀请进展。 +// InviteCampaignMonthlyProgress 汇总邀请人当前任务周期邀请进展。 type InviteCampaignMonthlyProgress struct { ID int64 `gorm:"column:id;primaryKey"` // 统计主键 SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_monthly_progress,priority:1"` // 系统标识 InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_monthly_progress,priority:2"` // 邀请人用户 ID - MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_monthly_progress,priority:3"` // 月份标识 + MonthKey string `gorm:"column:month_key;size:16;uniqueIndex:uk_invite_monthly_progress,priority:3"` // 任务周期标识 InviteCount int64 `gorm:"column:invite_count"` // 邀请人数 ValidUserCount int64 `gorm:"column:valid_user_count"` // 有效用户数 TotalRecharge int64 `gorm:"column:total_recharge_coins"` // 累计充值金币 @@ -93,7 +93,7 @@ type InviteCampaignMonthlyProgress struct { UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 } -// TableName 返回邀请月度进度表名。 +// TableName 返回邀请任务周期进度表名。 func (InviteCampaignMonthlyProgress) TableName() string { return "invite_campaign_monthly_progress" } // InviteCampaignTaskClaim 记录任务奖励领取行为,防止重复领取。 @@ -101,7 +101,7 @@ type InviteCampaignTaskClaim struct { ID int64 `gorm:"column:id;primaryKey"` // 领取记录主键 SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_task_claim,priority:1"` // 系统标识 InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_task_claim,priority:2"` // 邀请人用户 ID - MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_task_claim,priority:3"` // 月份标识 + MonthKey string `gorm:"column:month_key;size:16;uniqueIndex:uk_invite_task_claim,priority:3"` // 任务周期标识 RuleID int64 `gorm:"column:rule_id;uniqueIndex:uk_invite_task_claim,priority:4"` // 规则 ID ClaimTime time.Time `gorm:"column:claim_time"` // 领取时间 CreateTime time.Time `gorm:"column:create_time"` // 创建时间 diff --git a/internal/router/invite_routes.go b/internal/router/invite_routes.go index 920f924..b5fcae3 100644 --- a/internal/router/invite_routes.go +++ b/internal/router/invite_routes.go @@ -1,6 +1,8 @@ package router import ( + "errors" + "io" "net/http" "strconv" @@ -72,17 +74,10 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ writeOK(c, resp) }) appGroup.POST("/bind-code", func(c *gin.Context) { - var req invite.BindCodeRequest - if err := c.ShouldBindJSON(&req); err != nil { - writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) - return - } - resp, err := inviteService.BindCode(c.Request.Context(), mustAuthUser(c), resolveClientIP(c), req) - if err != nil { - writeError(c, err) - return - } - writeOK(c, resp) + bindInviteCode(c, inviteService) + }) + appGroup.POST("/bind-invite-code", func(c *gin.Context) { + bindInviteCode(c, inviteService) }) appGroup.POST("/tasks/claim", func(c *gin.Context) { var req invite.TaskClaimRequest @@ -98,3 +93,23 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ writeOK(c, resp) }) } + +func bindInviteCode(c *gin.Context, inviteService *invite.InviteService) { + var req invite.BindCodeRequest + if err := c.ShouldBind(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + if req.NormalizedInviteCode() == "" { + req.InviteCodeSnake = c.Query("invite_code") + req.InviteCode = c.Query("inviteCode") + req.InvitationCode = c.Query("invitationCode") + req.InvitationCodeSnake = c.Query("invitation_code") + } + resp, err := inviteService.BindCode(c.Request.Context(), mustAuthUser(c), resolveClientIP(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) +} diff --git a/internal/router/week_star_routes.go b/internal/router/week_star_routes.go index 4c26255..cd54def 100644 --- a/internal/router/week_star_routes.go +++ b/internal/router/week_star_routes.go @@ -98,7 +98,7 @@ func registerSpecifiedGiftWeeklyRankRoutes(group *gin.RouterGroup, javaClient au writeError(c, weekstar.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) return } - resp, err := weekStarService.RetryRewardRecord(c.Request.Context(), req.ID) + resp, err := weekStarService.RetryRewardRecord(c.Request.Context(), req.ID.Int64()) if err != nil { writeError(c, err) return diff --git a/internal/service/invite/bind.go b/internal/service/invite/bind.go index 08e18a1..d9938dc 100644 --- a/internal/service/invite/bind.go +++ b/internal/service/invite/bind.go @@ -30,11 +30,31 @@ func (s *InviteService) BindRegisteredCode(ctx context.Context, req RegisterBind return s.bindCode(ctx, AuthUser{ UserID: req.InviteeUserID, SysOrigin: sysOrigin, - }, req.ClientIP, BindCodeRequest{InviteCode: req.InviteCode}, req.DeviceFingerprint) + }, req.ClientIP, BindCodeRequest{InviteCode: req.NormalizedInviteCode()}, req.DeviceFingerprint) +} + +// NormalizedInviteCode 返回兼容 camelCase、snake_case 和 Java invitationCode 的邀请码字段。 +func (req BindCodeRequest) NormalizedInviteCode() string { + for _, value := range []string{req.InviteCode, req.InviteCodeSnake, req.InvitationCode, req.InvitationCodeSnake} { + if normalized := strings.TrimSpace(value); normalized != "" { + return normalized + } + } + return "" +} + +// NormalizedInviteCode 返回注册链路里兼容多种命名的邀请码字段。 +func (req RegisterBindCodeRequest) NormalizedInviteCode() string { + for _, value := range []string{req.InviteCode, req.InviteCodeSnake, req.InvitationCode, req.InvitationCodeSnake} { + if normalized := strings.TrimSpace(value); normalized != "" { + return normalized + } + } + return "" } func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest, deviceFingerprint string) (*BindCodeResponse, error) { - inviteCode := strings.TrimSpace(req.InviteCode) + inviteCode := req.NormalizedInviteCode() if inviteCode == "" { return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") } @@ -46,6 +66,15 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st return nil, NewAppError(400, "campaign_disabled", "invite campaign is disabled") } + if existing, existingErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); existingErr == nil { + if existing.InviteCode == inviteCode { + return s.buildAlreadyBoundResponse(bundle, existing, inviteCode), nil + } + return nil, NewAppError(400, "already_bound", "invite code already bound") + } else if !errors.Is(existingErr, gorm.ErrRecordNotFound) { + return nil, existingErr + } + mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -86,10 +115,18 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st relationID int64 eligibleStatus = relationStatusEligible blockReason string - monthKey string + periodKey string + alreadyBound bool ) err = s.withBindLocks(ctx, lockKeys, func() error { - if _, lockErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); lockErr == nil { + if existing, lockErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); lockErr == nil { + if existing.InviteCode == inviteCode { + relationID = existing.ID + eligibleStatus = existing.EligibleStatus + blockReason = existing.BlockReason + alreadyBound = true + return nil + } return NewAppError(400, "already_bound", "invite code already bound") } else if !errors.Is(lockErr, gorm.ErrRecordNotFound) { return lockErr @@ -97,7 +134,7 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st // 在锁内判定风控资格并写入关系、进度两张表。 bindTime := time.Now() - monthKey = monthKeyAt(bindTime, normalizeTimezone(bundle.Config.Timezone)) + periodKey, _, _ = thirtyDayPeriodInfoAt(bindTime, normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime) if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" { exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP) if existsErr != nil { @@ -166,7 +203,7 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st return err } if eligibleStatus == relationStatusEligible { - if err := s.incrementMonthlyProgressTx(tx, user.SysOrigin, mapping.UserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { + if err := s.incrementMonthlyProgressTx(tx, user.SysOrigin, mapping.UserID, periodKey, func(item *model.InviteCampaignMonthlyProgress) { item.InviteCount += 1 }); err != nil { return err @@ -182,12 +219,12 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind) inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) // 只有资格正常的绑定关系才会触发双方即时奖励。 - if eligibleStatus == relationStatusEligible { + if eligibleStatus == relationStatusEligible && !alreadyBound { inviterUserID := mapping.UserID inviteeUserID := user.UserID _, err = s.dispatchReward(ctx, rewardDispatchInput{ SysOrigin: user.SysOrigin, - MonthKey: monthKey, + MonthKey: periodKey, RelationID: refInt64(relationID), InviterUserID: &inviterUserID, InviteeUserID: &inviteeUserID, @@ -225,6 +262,7 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st return &BindCodeResponse{ RelationID: relationID, Eligible: eligibleStatus == relationStatusEligible, + AlreadyBound: alreadyBound, BlockReason: blockReason, InviterUserID: mapping.UserID, InviteCode: inviteCode, @@ -233,6 +271,19 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st }, nil } +func (s *InviteService) buildAlreadyBoundResponse(bundle *campaignBundle, relation *model.InviteCampaignRelation, inviteCode string) *BindCodeResponse { + return &BindCodeResponse{ + RelationID: relation.ID, + Eligible: relation.EligibleStatus == relationStatusEligible, + AlreadyBound: true, + BlockReason: relation.BlockReason, + InviterUserID: relation.InviterUserID, + InviteCode: inviteCode, + InviterReward: rewardFromRule(firstRule(bundle.Rules, ruleTypeInviterBind)), + InviteeReward: rewardFromRule(firstRule(bundle.Rules, ruleTypeInviteeBind)), + } +} + // withBindLocks 为绑码流程批量加锁,确保同用户/同邀请码并发安全。 func (s *InviteService) withBindLocks(ctx context.Context, keys []string, fn func() error) error { keys = uniqueSortedStrings(keys) diff --git a/internal/service/invite/home.go b/internal/service/invite/home.go index 84366ab..5464f6a 100644 --- a/internal/service/invite/home.go +++ b/internal/service/invite/home.go @@ -5,10 +5,11 @@ import ( "context" "net/url" "sort" + "strings" "time" ) -// GetHome 返回邀请活动首页所需的配置、任务、月度进度和累计统计。 +// GetHome 返回邀请活动首页所需的配置、任务、当前周期进度和累计统计。 func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) { bundle, err := s.loadBundle(ctx, user.SysOrigin) if err != nil { @@ -19,12 +20,11 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon return nil, err } - // 首页需要同时返回当前月进度、累计统计、邀请码和任务配置。 + // 首页需要同时返回当前 30 天周期进度、累计统计、邀请码和任务配置。 now := time.Now() timezone := normalizeTimezone(bundle.Config.Timezone) - monthKey := monthKeyAt(now, timezone) - periodEndAt, secondsToReset := monthResetInfoAt(now, timezone) - monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) + periodKey, periodEndAt, secondsToReset := thirtyDayPeriodInfoAt(now, timezone, bundle.Config.CreateTime) + monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, periodKey) if err != nil { return nil, err } @@ -32,7 +32,7 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon if err != nil { return nil, err } - claims, err := s.loadClaimedRuleIDs(ctx, user.SysOrigin, user.UserID, monthKey) + claims, err := s.loadClaimedRuleIDs(ctx, user.SysOrigin, user.UserID, periodKey) if err != nil { return nil, err } @@ -47,16 +47,18 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon CampaignEnabled: bundle.Config.Enabled, SysOrigin: user.SysOrigin, InviteCode: inviteCode, - InviteLink: s.cfg.Invite.PublicBaseURL + "/public/h5/invite-campaign/landing?code=" + url.QueryEscape(inviteCode), + InviteLink: s.buildInviteLink(inviteCode), ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), DownloadURL: bundle.Config.DownloadURL, ServiceContact: bundle.Config.ServiceContact, Timezone: timezone, - MonthKey: monthKey, + PeriodKey: periodKey, + MonthKey: periodKey, ServerTimeMs: now.UnixMilli(), PeriodEndAtMs: periodEndAt.UnixMilli(), SecondsToReset: secondsToReset, + ResetPeriodDays: inviteTaskPeriodDays, ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules), InviterBindReward: rewardFromRule(inviterBindRule), InviteeBindReward: rewardFromRule(inviteeBindRule), @@ -67,11 +69,35 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge resp.MonthlyProgress.RewardGoldCoins = monthly.RewardGoldCoins - resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.InviteCount, claims) + resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.ValidUserCount, claims) resp.TotalRechargeTasks = buildTaskViews(totalRechargeRules, monthly.TotalRecharge, claims) return resp, nil } +// buildInviteLink 返回用户分享出去的 H5 落地页链接;公开 landing 接口仍只用于 H5 拉数据。 +func (s *InviteService) buildInviteLink(inviteCode string) string { + baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.Invite.PublicBaseURL), "/") + if baseURL == "" { + baseURL = "http://localhost:2900" + } + + publicH5Path := strings.TrimSpace(s.cfg.Invite.PublicH5Path) + if publicH5Path == "" { + publicH5Path = "/h5/app-invite/landing.html" + } + + rawURL := baseURL + "/" + strings.TrimLeft(publicH5Path, "/") + parsedURL, err := url.Parse(rawURL) + if err != nil { + return rawURL + "?code=" + url.QueryEscape(inviteCode) + } + + query := parsedURL.Query() + query.Set("code", inviteCode) + parsedURL.RawQuery = query.Encode() + return parsedURL.String() +} + // buildTaskViews 根据规则、进度和领取状态生成任务展示列表。 func buildTaskViews(rules []model.InviteCampaignRewardRule, progress int64, claimed map[int64]bool) []TaskView { result := make([]TaskView, 0, len(rules)) @@ -158,19 +184,7 @@ func rewardsFromRules(rules []model.InviteCampaignRewardRule) []RewardView { return result } -// effectiveValidThreshold 计算“有效用户”判定使用的最低充值门槛。 -func effectiveValidThreshold(rules []model.InviteCampaignRewardRule) int64 { - var threshold int64 - for _, rule := range rules { - if !rule.Enabled || rule.ThresholdValue <= 0 { - continue - } - if threshold == 0 || rule.ThresholdValue < threshold { - threshold = rule.ThresholdValue - } - } - if threshold == 0 { - return defaultValidRechargeCoins - } - return threshold +// effectiveValidThreshold 返回“有效用户”判定使用的固定充值门槛。 +func effectiveValidThreshold(_ []model.InviteCampaignRewardRule) int64 { + return defaultValidRechargeCoins } diff --git a/internal/service/invite/invite_test.go b/internal/service/invite/invite_test.go index 509563c..0c4cf8d 100644 --- a/internal/service/invite/invite_test.go +++ b/internal/service/invite/invite_test.go @@ -80,7 +80,25 @@ func newTestInviteService(t *testing.T, gateway *inviteTestGateway) (*InviteServ } } -func seedInviteCampaign(t *testing.T, db *gorm.DB) { +func TestBuildInviteLinkUsesAppInviteH5Page(t *testing.T) { + service, _, cleanup := newTestInviteService(t, nil) + defer cleanup() + + got := service.buildInviteLink("INV1001") + want := "https://invite.example.com/h5/app-invite/landing.html?code=INV1001" + if got != want { + t.Fatalf("invite link = %s, want %s", got, want) + } + + service.cfg.Invite.PublicH5Path = "/custom/landing.html?view=gift" + got = service.buildInviteLink("INV1001") + want = "https://invite.example.com/custom/landing.html?code=INV1001&view=gift" + if got != want { + t.Fatalf("custom invite link = %s, want %s", got, want) + } +} + +func seedInviteCampaign(t *testing.T, db *gorm.DB) time.Time { t.Helper() now := time.Now() if err := db.Create(&model.InviteCampaignConfig{ @@ -93,12 +111,17 @@ func seedInviteCampaign(t *testing.T, db *gorm.DB) { }).Error; err != nil { t.Fatalf("seed config: %v", err) } + return now } -func TestMonthResetInfoAtReturnsNextMonthStart(t *testing.T) { +func TestThirtyDayPeriodInfoAtReturnsNextPeriodStart(t *testing.T) { + anchor := time.Date(2026, 4, 1, 9, 30, 0, 0, time.FixedZone("CST", 8*3600)) now := time.Date(2026, 4, 30, 23, 59, 30, 0, time.FixedZone("CST", 8*3600)) - periodEnd, seconds := monthResetInfoAt(now, "Asia/Shanghai") + periodKey, periodEnd, seconds := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", anchor) + if periodKey != "20260401" { + t.Fatalf("periodKey = %s, want 20260401", 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) } @@ -107,13 +130,13 @@ func TestMonthResetInfoAtReturnsNextMonthStart(t *testing.T) { } } -func TestGetHomeUsesInviteCountForInviteCountTasks(t *testing.T) { +func TestGetHomeUsesValidUserCountForValidCountTasks(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() - seedInviteCampaign(t, db) + configTime := seedInviteCampaign(t, db) now := time.Now() - monthKey := monthKeyAt(now, "Asia/Shanghai") + periodKey, _, _ := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", configTime) if err := db.Create(&model.InviteCodeMapping{UserID: 1001, InviteCode: "INV1001", Account: "u1001"}).Error; err != nil { t.Fatalf("seed invite code: %v", err) } @@ -134,9 +157,9 @@ func TestGetHomeUsesInviteCountForInviteCountTasks(t *testing.T) { ID: 3001, SysOrigin: "LIKEI", InviterUserID: 1001, - MonthKey: monthKey, + MonthKey: periodKey, InviteCount: 7, - ValidUserCount: 0, + ValidUserCount: 4, CreateTime: now, UpdateTime: now, }).Error; err != nil { @@ -151,22 +174,22 @@ func TestGetHomeUsesInviteCountForInviteCountTasks(t *testing.T) { t.Fatalf("ValidCountTasks length = %d, want 1", len(resp.ValidCountTasks)) } task := resp.ValidCountTasks[0] - if task.Progress != 7 || !task.Achieved { - t.Fatalf("task progress = %d achieved = %v, want progress 7 achieved true", task.Progress, task.Achieved) + if task.Progress != 4 || task.Achieved { + t.Fatalf("task progress = %d achieved = %v, want progress 4 achieved false", task.Progress, task.Achieved) } if resp.SecondsToReset <= 0 || resp.PeriodEndAtMs <= resp.ServerTimeMs { t.Fatalf("invalid countdown fields: server=%d end=%d seconds=%d", resp.ServerTimeMs, resp.PeriodEndAtMs, resp.SecondsToReset) } } -func TestClaimTaskUsesInviteCountForInviteCountTasks(t *testing.T) { +func TestClaimTaskUsesValidUserCountForValidCountTasks(t *testing.T) { gateway := &inviteTestGateway{} service, db, cleanup := newTestInviteService(t, gateway) defer cleanup() - seedInviteCampaign(t, db) + configTime := seedInviteCampaign(t, db) now := time.Now() - monthKey := monthKeyAt(now, "Asia/Shanghai") + periodKey, _, _ := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", configTime) if err := db.Create(&model.InviteCampaignRewardRule{ ID: 2002, SysOrigin: "LIKEI", @@ -184,18 +207,34 @@ func TestClaimTaskUsesInviteCountForInviteCountTasks(t *testing.T) { ID: 3002, SysOrigin: "LIKEI", InviterUserID: 1002, - MonthKey: monthKey, + MonthKey: periodKey, InviteCount: 5, - ValidUserCount: 0, + ValidUserCount: 4, CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed monthly progress: %v", err) } + _, err := service.ClaimTask(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, TaskClaimRequest{RuleID: 2002}) + if err == nil { + t.Fatalf("ClaimTask() error = nil, want task_not_reached") + } + if appErr, ok := err.(*AppError); !ok || appErr.Code != "task_not_reached" { + t.Fatalf("ClaimTask() error = %v, want task_not_reached", err) + } + if gateway.grantGoldCalls != 0 { + t.Fatalf("grantGoldCalls = %d, want 0", gateway.grantGoldCalls) + } + + if err := db.Model(&model.InviteCampaignMonthlyProgress{}). + Where("id = ?", 3002). + Update("valid_user_count", 5).Error; err != nil { + t.Fatalf("update valid user count: %v", err) + } resp, err := service.ClaimTask(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, TaskClaimRequest{RuleID: 2002}) if err != nil { - t.Fatalf("ClaimTask() error = %v", err) + t.Fatalf("ClaimTask() after valid count reached error = %v", err) } if !resp.Claimed || resp.Progress != 5 { t.Fatalf("claim response = %+v, want claimed progress 5", resp) @@ -266,7 +305,67 @@ func TestBindRegisteredCodeUsesRequestDeviceFingerprint(t *testing.T) { } } -func TestGetRankUsesLifetimeInviteAndRewardStats(t *testing.T) { +func TestBindCodeAcceptsSnakeInviteCodeAndIsIdempotent(t *testing.T) { + service, db, cleanup := newTestInviteService(t, nil) + defer cleanup() + + now := time.Now() + if err := db.Create(&model.InviteCampaignConfig{ + ID: 201, + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Shanghai", + AntiCheatSameIPOnce: false, + AntiCheatSameDeviceOnce: false, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed config: %v", err) + } + if err := db.Create(&model.InviteCodeMapping{UserID: 9201, InviteCode: "INV9201", Account: "u9201"}).Error; err != nil { + t.Fatalf("seed invite code: %v", err) + } + if err := db.Create(&model.UserBaseInfo{ID: 9201, Account: "u9201", UserNickname: "Inviter", OriginSys: "LIKEI", CreateTime: now}).Error; err != nil { + t.Fatalf("seed inviter profile: %v", err) + } + + user := AuthUser{UserID: 9301, SysOrigin: "LIKEI"} + resp, err := service.BindCode(context.Background(), user, "10.0.0.3", BindCodeRequest{InviteCodeSnake: "INV9201"}) + if err != nil { + t.Fatalf("BindCode() error = %v", err) + } + if resp.AlreadyBound || !resp.Eligible || resp.InviterUserID != 9201 { + t.Fatalf("BindCode() response = %+v, want first bind eligible inviter 9201", resp) + } + + again, err := service.BindCode(context.Background(), user, "10.0.0.3", BindCodeRequest{InviteCodeSnake: "INV9201"}) + if err != nil { + t.Fatalf("BindCode() idempotent error = %v", err) + } + if !again.AlreadyBound || again.RelationID != resp.RelationID { + t.Fatalf("idempotent response = %+v, want same relation alreadyBound", again) + } + + var relationCount int64 + if err := db.Model(&model.InviteCampaignRelation{}). + Where("sys_origin = ? AND invitee_user_id = ?", "LIKEI", 9301). + Count(&relationCount).Error; err != nil { + t.Fatalf("count relations: %v", err) + } + if relationCount != 1 { + t.Fatalf("relation count = %d, want 1", relationCount) + } + + var progress model.InviteCampaignMonthlyProgress + if err := db.Where("sys_origin = ? AND inviter_user_id = ?", "LIKEI", 9201).First(&progress).Error; err != nil { + t.Fatalf("load progress: %v", err) + } + if progress.InviteCount != 1 { + t.Fatalf("invite count = %d, want 1", progress.InviteCount) + } +} + +func TestGetRankUsesLifetimeValidUserAndRewardStats(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() @@ -274,8 +373,14 @@ func TestGetRankUsesLifetimeInviteAndRewardStats(t *testing.T) { rows := []any{ &model.InviteCampaignRelation{ID: 1, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 201, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRelation{ID: 2, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 202, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, - &model.InviteCampaignRelation{ID: 3, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 101, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, - &model.InviteCampaignRelation{ID: 4, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 102, EligibleStatus: relationStatusBlocked, BindTime: now, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRelation{ID: 3, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 203, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRelation{ID: 4, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 101, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignRelation{ID: 5, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 102, EligibleStatus: relationStatusBlocked, BindTime: now, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignInviteeProgress{ID: 101, RelationID: 1, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 201, TotalRecharge: 80000, ValidUser: true, ValidTime: refTime(now), CreateTime: now, UpdateTime: now}, + &model.InviteCampaignInviteeProgress{ID: 102, RelationID: 2, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 202, TotalRecharge: 79999, ValidUser: false, CreateTime: now, UpdateTime: now}, + &model.InviteCampaignInviteeProgress{ID: 103, RelationID: 3, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 203, TotalRecharge: 80000, ValidUser: true, ValidTime: refTime(now), CreateTime: now, UpdateTime: now}, + &model.InviteCampaignInviteeProgress{ID: 104, RelationID: 4, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 101, TotalRecharge: 80000, ValidUser: true, ValidTime: refTime(now), CreateTime: now, UpdateTime: now}, + &model.InviteCampaignInviteeProgress{ID: 105, RelationID: 5, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 102, TotalRecharge: 80000, ValidUser: true, ValidTime: refTime(now), CreateTime: now, UpdateTime: now}, &model.UserBaseInfo{ID: 20, Account: "user20", UserNickname: "Mina", UserAvatar: "avatar-20", OriginSys: "LIKEI", CreateTime: now}, &model.UserBaseInfo{ID: 10, Account: "user10", UserNickname: "Lina", UserAvatar: "avatar-10", OriginSys: "LIKEI", CreateTime: now}, &model.InviteCampaignRewardLog{ID: 11, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 20, BusinessKey: "r20-1", GoldAmount: 100, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, @@ -305,6 +410,96 @@ func TestGetRankUsesLifetimeInviteAndRewardStats(t *testing.T) { } } +func TestRechargeMarksValidUserOnlyAfterEightyThousandCoins(t *testing.T) { + service, db, cleanup := newTestInviteService(t, nil) + defer cleanup() + configTime := seedInviteCampaign(t, db) + + payTime := configTime.Add(time.Hour) + periodKey, _, _ := thirtyDayPeriodInfoAt(payTime, "Asia/Shanghai", configTime) + rows := []any{ + &model.InviteCampaignRewardRule{ + ID: 2101, + SysOrigin: "LIKEI", + RuleType: ruleTypeInviteeRecharge, + ThresholdValue: 10000, + GoldAmount: 0, + Enabled: true, + Sort: 1, + CreateTime: configTime, + UpdateTime: configTime, + }, + &model.InviteCampaignRelation{ + ID: 4101, + SysOrigin: "LIKEI", + InviterUserID: 5101, + InviteeUserID: 5201, + EligibleStatus: relationStatusEligible, + BindTime: configTime, + CreateTime: configTime, + UpdateTime: configTime, + }, + &model.InviteCampaignInviteeProgress{ + ID: 4201, + RelationID: 4101, + SysOrigin: "LIKEI", + InviterUserID: 5101, + InviteeUserID: 5201, + CreateTime: configTime, + UpdateTime: configTime, + }, + } + for _, row := range rows { + if err := db.Create(row).Error; err != nil { + t.Fatalf("seed row %T: %v", row, err) + } + } + + resp, err := service.HandleRechargeSuccess(context.Background(), RechargeSuccessRequest{ + OrderID: "order-79999", + UserID: 5201, + RechargeCoins: 79999, + PayTime: payTime.UnixMilli(), + SysOrigin: "LIKEI", + }) + if err != nil { + t.Fatalf("HandleRechargeSuccess() 79999 error = %v", err) + } + if resp.ValidUser { + t.Fatalf("ValidUser = true after 79999 recharge, want false") + } + + monthly, err := service.findMonthlyProgress(context.Background(), "LIKEI", 5101, periodKey) + if err != nil { + t.Fatalf("find monthly progress: %v", err) + } + if monthly.ValidUserCount != 0 || monthly.TotalRecharge != 79999 { + t.Fatalf("monthly after 79999 = %+v, want valid 0 total 79999", monthly) + } + + resp, err = service.HandleRechargeSuccess(context.Background(), RechargeSuccessRequest{ + OrderID: "order-80000", + UserID: 5201, + RechargeCoins: 1, + PayTime: payTime.Add(time.Minute).UnixMilli(), + SysOrigin: "LIKEI", + }) + if err != nil { + t.Fatalf("HandleRechargeSuccess() 80000 error = %v", err) + } + if !resp.ValidUser { + t.Fatalf("ValidUser = false after 80000 recharge, want true") + } + + monthly, err = service.findMonthlyProgress(context.Background(), "LIKEI", 5101, periodKey) + if err != nil { + t.Fatalf("find monthly progress after 80000: %v", err) + } + if monthly.ValidUserCount != 1 || monthly.TotalRecharge != 80000 { + t.Fatalf("monthly after 80000 = %+v, want valid 1 total 80000", monthly) + } +} + func TestDispatchGoldReturnsGrantError(t *testing.T) { grantErr := errors.New("wallet unavailable") service, db, cleanup := newTestInviteService(t, &inviteTestGateway{grantGoldErr: grantErr}) diff --git a/internal/service/invite/normalize.go b/internal/service/invite/normalize.go index 16677d3..4a530ae 100644 --- a/internal/service/invite/normalize.go +++ b/internal/service/invite/normalize.go @@ -27,28 +27,34 @@ func normalizeTimezone(value string) string { return value } -// monthKeyAt 计算某个时间点在指定时区下的月份标识。 -func monthKeyAt(t time.Time, timezone string) string { +func inviteLocation(timezone string) *time.Location { location, err := time.LoadLocation(normalizeTimezone(timezone)) if err != nil { location = time.FixedZone(defaultTimezone, 3*3600) } - return t.In(location).Format("200601") + return location } -// monthResetInfoAt 返回指定时间所在活动月的结束时间和剩余秒数。 -func monthResetInfoAt(t time.Time, timezone string) (time.Time, int64) { - location, err := time.LoadLocation(normalizeTimezone(timezone)) - if err != nil { - location = time.FixedZone(defaultTimezone, 3*3600) - } +// thirtyDayPeriodInfoAt 返回指定时间所在 30 天任务周期的 key、结束时间和剩余秒数。 +func thirtyDayPeriodInfoAt(t time.Time, timezone string, anchor time.Time) (string, time.Time, int64) { + location := inviteLocation(timezone) localTime := t.In(location) - periodEnd := time.Date(localTime.Year(), localTime.Month()+1, 1, 0, 0, 0, 0, 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) seconds := int64(periodEnd.Sub(localTime).Seconds()) if seconds < 0 { seconds = 0 } - return periodEnd, seconds + return periodStart.Format("20060102"), periodEnd, seconds } // digitsOnly 判断字符串是否只包含数字。 diff --git a/internal/service/invite/rank.go b/internal/service/invite/rank.go index 526093d..b613058 100644 --- a/internal/service/invite/rank.go +++ b/internal/service/invite/rank.go @@ -25,10 +25,11 @@ func (s *InviteService) GetRank(ctx context.Context, user AuthUser, limit int) ( invite_rank.invite_count AS invite_count, COALESCE(reward_rank.reward_gold_coins, 0) AS reward_gold_coins FROM ( - SELECT inviter_user_id AS user_id, COUNT(1) AS invite_count - FROM invite_campaign_relation - WHERE sys_origin = ? AND eligible_status = ? - GROUP BY inviter_user_id + SELECT relation.inviter_user_id AS user_id, COUNT(1) AS invite_count + FROM invite_campaign_invitee_progress AS progress + INNER JOIN invite_campaign_relation AS relation ON relation.id = progress.relation_id + WHERE relation.sys_origin = ? AND relation.eligible_status = ? AND progress.valid_user = 1 + GROUP BY relation.inviter_user_id ) invite_rank LEFT JOIN ( SELECT target_user_id AS user_id, COALESCE(SUM(gold_amount), 0) AS reward_gold_coins diff --git a/internal/service/invite/recharge.go b/internal/service/invite/recharge.go index 63786c4..61e3281 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()) } - monthKey := monthKeyAt(payTime, normalizeTimezone(bundle.Config.Timezone)) + periodKey, _, _ := thirtyDayPeriodInfoAt(payTime, normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime) inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) validThreshold := effectiveValidThreshold(inviteeRechargeRules) @@ -58,7 +58,7 @@ func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeS newTotal int64 ) - // 在事务里完成充值事件幂等、关系查询、进度更新和月度统计累加。 + // 在事务里完成充值事件幂等、关系查询、进度更新和当前周期统计累加。 err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing model.InviteCampaignRechargeEvent err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). @@ -126,7 +126,7 @@ func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeS return err } if eligible { - if err := s.incrementMonthlyProgressTx(tx, req.SysOrigin, relation.InviterUserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { + if err := s.incrementMonthlyProgressTx(tx, req.SysOrigin, relation.InviterUserID, periodKey, func(item *model.InviteCampaignMonthlyProgress) { item.TotalRecharge += req.RechargeCoins if becameValid { item.ValidUserCount += 1 @@ -162,7 +162,7 @@ func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeS for _, rule := range crossedRules { _, err := s.dispatchReward(ctx, rewardDispatchInput{ SysOrigin: req.SysOrigin, - MonthKey: monthKey, + MonthKey: periodKey, RelationID: refInt64(relationID), InviterUserID: refInt64(inviterUserID), InviteeUserID: refInt64(req.UserID), diff --git a/internal/service/invite/store.go b/internal/service/invite/store.go index 9238930..fac50cf 100644 --- a/internal/service/invite/store.go +++ b/internal/service/invite/store.go @@ -137,7 +137,7 @@ func (s *InviteService) findRelationByInvitee(ctx context.Context, sysOrigin str return &relation, nil } -// findMonthlyProgress 查询邀请人的月度进度。 +// findMonthlyProgress 查询邀请人的当前任务周期进度;底层字段沿用 month_key 以兼容历史表结构。 func (s *InviteService) findMonthlyProgress(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (*model.InviteCampaignMonthlyProgress, error) { var progress model.InviteCampaignMonthlyProgress err := s.repo.DB.WithContext(ctx). @@ -191,7 +191,7 @@ func (s *InviteService) loadStats(ctx context.Context, sysOrigin string, inviter return stats, nil } -// loadClaimedRuleIDs 读取某月已领取的任务规则集合。 +// loadClaimedRuleIDs 读取某个任务周期已领取的任务规则集合。 func (s *InviteService) loadClaimedRuleIDs(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (map[int64]bool, error) { var claims []model.InviteCampaignTaskClaim if err := s.repo.DB.WithContext(ctx). diff --git a/internal/service/invite/task.go b/internal/service/invite/task.go index 1d28e29..4a61cc1 100644 --- a/internal/service/invite/task.go +++ b/internal/service/invite/task.go @@ -29,18 +29,18 @@ func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskCl return nil, NewAppError(400, "invalid_rule_type", "rule cannot be claimed manually") } - monthKey := monthKeyAt(time.Now(), normalizeTimezone(bundle.Config.Timezone)) - claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, monthKey, rule.ID) + periodKey, _, _ := thirtyDayPeriodInfoAt(time.Now(), normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime) + claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, periodKey, rule.ID) if err != nil { return nil, err } - monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) + monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, periodKey) if err != nil { return nil, err } progressValue := monthly.TotalRecharge if rule.RuleType == ruleTypeInviterValidCount { - progressValue = monthly.InviteCount + progressValue = monthly.ValidUserCount } if claimed { return &TaskClaimResponse{ @@ -60,16 +60,16 @@ func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskCl // 先发奖,再落领取记录;发奖侧通过 businessKey 兜底幂等。 _, err = s.dispatchReward(ctx, rewardDispatchInput{ SysOrigin: user.SysOrigin, - MonthKey: monthKey, + MonthKey: periodKey, InviterUserID: refInt64(user.UserID), TargetUserID: user.UserID, Reward: rewardFromRule(rule), Scene: rule.RuleType, RuleID: refInt64(rule.ID), - BusinessKey: fmt.Sprintf("claim:%d:%s:%d", user.UserID, monthKey, rule.ID), + BusinessKey: fmt.Sprintf("claim:%d:%s:%d", user.UserID, periodKey, rule.ID), GoldOrigin: mapGoldOrigin(rule.RuleType), PropsOrigin: mapPropsOrigin(rule.RuleType), - Remark: "resident invite monthly task reward", + Remark: "resident invite 30-day task reward", }) if err != nil { return nil, err @@ -77,7 +77,7 @@ func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskCl now := time.Now() err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := s.createTaskClaimIfAbsentTx(tx, user.SysOrigin, user.UserID, monthKey, rule.ID, now); err != nil { + if err := s.createTaskClaimIfAbsentTx(tx, user.SysOrigin, user.UserID, periodKey, rule.ID, now); err != nil { return err } return nil @@ -107,7 +107,7 @@ func (s *InviteService) hasTaskClaim(ctx context.Context, sysOrigin string, invi return count > 0, err } -// incrementMonthlyProgressTx 在事务里更新月度进度。 +// incrementMonthlyProgressTx 在事务里更新当前任务周期进度;函数名沿用月度命名以减少表结构迁移影响。 func (s *InviteService) incrementMonthlyProgressTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, mutate func(item *model.InviteCampaignMonthlyProgress)) error { var progress model.InviteCampaignMonthlyProgress err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). diff --git a/internal/service/invite/types.go b/internal/service/invite/types.go index 1462e60..c9c4f21 100644 --- a/internal/service/invite/types.go +++ b/internal/service/invite/types.go @@ -31,6 +31,7 @@ const ( ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE" defaultInviteRankLimit = 50 maxInviteRankLimit = 100 + inviteTaskPeriodDays = 30 ) var errAlreadyProcessed = errors.New("invite recharge already processed") @@ -81,10 +82,12 @@ type HomeResponse struct { DownloadURL string `json:"downloadUrl"` ServiceContact string `json:"serviceContact"` Timezone string `json:"timezone"` + PeriodKey string `json:"periodKey"` MonthKey string `json:"monthKey"` ServerTimeMs int64 `json:"serverTimeMs"` PeriodEndAtMs int64 `json:"periodEndAtMs"` SecondsToReset int64 `json:"secondsToReset"` + ResetPeriodDays int `json:"resetPeriodDays"` ValidUserThreshold int64 `json:"validUserThreshold"` InviterBindReward RewardView `json:"inviterBindReward"` InviteeBindReward RewardView `json:"inviteeBindReward"` @@ -133,22 +136,29 @@ type LandingResponse struct { // BindCodeRequest 是绑定邀请码入参。 type BindCodeRequest struct { - InviteCode string `json:"inviteCode"` + InviteCode string `json:"inviteCode" form:"inviteCode"` + InviteCodeSnake string `json:"invite_code" form:"invite_code"` + InvitationCode string `json:"invitationCode" form:"invitationCode"` + InvitationCodeSnake string `json:"invitation_code" form:"invitation_code"` } // RegisterBindCodeRequest 是 Java 注册成功后同步邀请活动关系的内部入参。 type RegisterBindCodeRequest struct { - InviteCode string `json:"inviteCode"` - SysOrigin string `json:"sysOrigin"` - InviteeUserID int64 `json:"inviteeUserId"` - ClientIP string `json:"clientIp"` - DeviceFingerprint string `json:"deviceFingerprint"` + InviteCode string `json:"inviteCode"` + InviteCodeSnake string `json:"invite_code"` + InvitationCode string `json:"invitationCode"` + InvitationCodeSnake string `json:"invitation_code"` + SysOrigin string `json:"sysOrigin"` + InviteeUserID int64 `json:"inviteeUserId"` + ClientIP string `json:"clientIp"` + DeviceFingerprint string `json:"deviceFingerprint"` } // BindCodeResponse 是绑定邀请码后的返回结果。 type BindCodeResponse struct { RelationID int64 `json:"relationId"` Eligible bool `json:"eligible"` + AlreadyBound bool `json:"alreadyBound"` BlockReason string `json:"blockReason,omitempty"` InviterUserID int64 `json:"inviterUserId"` InviteCode string `json:"inviteCode"` diff --git a/internal/service/weekstar/config.go b/internal/service/weekstar/config.go index 81b6182..153a105 100644 --- a/internal/service/weekstar/config.go +++ b/internal/service/weekstar/config.go @@ -92,6 +92,7 @@ func (s *WeekStarService) PageConfigs(ctx context.Context, sysOrigin string, cur // SaveConfig 创建或更新未来周配置,并校验礼物、奖励和启用配置时间窗不重叠。 func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfigRequest) (*WeekStarConfigResponse, error) { + requestID := req.ID.Int64() sysOrigin := s.normalizeSysOrigin(req.SysOrigin) timezone := strings.TrimSpace(req.Timezone) if timezone == "" { @@ -121,28 +122,39 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig return nil, NewAppError(http.StatusBadRequest, "invalid_time_range", "endAt must be later than startAt") } now := time.Now().In(location) - if !startAt.After(now) { - return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", "only future-week configs can be created or updated") + if requestID > 0 { + if !startAt.After(now) { + return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", "only future-week configs can be updated") + } + } else if !startAt.After(now) { + currentCycleStart, _ := s.cycleBounds(now.In(s.location)) + if startAt.In(s.location).Before(currentCycleStart) { + return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", "current-week config cannot start before this week") + } + if !endAt.After(now) { + return nil, NewAppError(http.StatusBadRequest, "invalid_time_range", "current-week config must end in the future") + } } giftIDs := make(map[int64]struct{}, len(req.GiftConfigs)) // 先把礼物和奖励配置标准化,避免后续事务里还掺杂输入清洗逻辑。 normalizedGifts := make([]model.WeekStarActivityGiftConfig, 0, len(req.GiftConfigs)) for index, item := range req.GiftConfigs { - if item.GiftID <= 0 { + giftID := item.GiftID.Int64() + if giftID <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_gift_id", "giftId is required") } - if _, exists := giftIDs[item.GiftID]; exists { + if _, exists := giftIDs[giftID]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_gift_id", "giftConfigs must not contain duplicate gifts") } - giftIDs[item.GiftID] = struct{}{} + giftIDs[giftID] = struct{}{} id, idErr := utils.NextID() if idErr != nil { return nil, idErr } normalizedGifts = append(normalizedGifts, model.WeekStarActivityGiftConfig{ ID: id, - GiftID: item.GiftID, + GiftID: giftID, GiftName: strings.TrimSpace(item.GiftName), GiftPhoto: strings.TrimSpace(item.GiftPhoto), GiftCandy: item.GiftCandy, @@ -154,25 +166,33 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig seenRanks := make(map[int]struct{}, len(req.RewardConfigs)) normalizedRewards := make([]model.WeekStarActivityRewardConfig, 0, len(req.RewardConfigs)) for _, item := range req.RewardConfigs { + rewardGroupID := item.RewardGroupID.Int64() if _, ok := rewardRanks[item.Rank]; !ok { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_rank", "reward rank must be 1, 2, or 3") } - if item.RewardGroupID <= 0 { + if rewardGroupID <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group_id", "rewardGroupId is required") } if _, exists := seenRanks[item.Rank]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_rank", "rewardConfigs must not contain duplicate ranks") } + rewardItems, rewardGroupName, err := s.loadRewardPreviewItems(ctx, rewardGroupID) + if err != nil || len(rewardItems) == 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group_id", "reward group detail is empty or unavailable") + } seenRanks[item.Rank] = struct{}{} id, idErr := utils.NextID() if idErr != nil { return nil, idErr } + if strings.TrimSpace(rewardGroupName) == "" { + rewardGroupName = item.RewardGroupName + } normalizedRewards = append(normalizedRewards, model.WeekStarActivityRewardConfig{ ID: id, Rank: item.Rank, - RewardGroupID: item.RewardGroupID, - RewardGroupName: strings.TrimSpace(item.RewardGroupName), + RewardGroupID: rewardGroupID, + RewardGroupName: strings.TrimSpace(rewardGroupName), }) } @@ -187,8 +207,8 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig storedStartAt := s.toStorageWallClock(startAt) storedEndAt := s.toStorageWallClock(endAt) var configRow model.WeekStarActivityConfig - if req.ID > 0 { - if err := tx.Where("id = ?", req.ID).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { + if requestID > 0 { + if err := tx.Where("id = ?", requestID).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "week_star_config_not_found", "config not found") } else if err != nil { return err @@ -229,7 +249,7 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig configRow.Timezone = timezone configRow.UpdateTime = now - if req.ID > 0 { + if requestID > 0 { if err := tx.Model(&model.WeekStarActivityConfig{}). Where("id = ?", configRow.ID). Updates(map[string]any{ diff --git a/internal/service/weekstar/config_test.go b/internal/service/weekstar/config_test.go new file mode 100644 index 0000000..43cc997 --- /dev/null +++ b/internal/service/weekstar/config_test.go @@ -0,0 +1,194 @@ +package weekstar + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type weekStarTestGateway struct{} + +func (g weekStarTestGateway) GetRewardGroupDetail(_ context.Context, groupID int64) (integration.RewardGroupDetail, error) { + if groupID < 2004471533988167125 || groupID > 2004471533988167127 { + return integration.RewardGroupDetail{ID: integration.Int64Value(groupID)}, nil + } + return integration.RewardGroupDetail{ + ID: integration.Int64Value(groupID), + Name: "Top Reward", + RewardConfigList: []integration.RewardGroupItem{ + { + ID: 2004471533988167001, + Type: "PROPS", + Name: "Frame", + Content: "1001", + Quantity: 7, + Cover: "https://example.com/frame.png", + }, + }, + }, nil +} + +func (g weekStarTestGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) { + return integration.UserProfile{}, nil +} + +func newTestWeekStarService(t *testing.T) (*WeekStarService, *gorm.DB) { + t.Helper() + + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.WeekStarActivityConfig{}, + &model.WeekStarActivityGiftConfig{}, + &model.WeekStarActivityRewardConfig{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + service := NewWeekStarService(config.Config{ + WeekStar: config.WeekStarConfig{ + DefaultSysOrigin: "LIKEI", + Timezone: "Asia/Riyadh", + }, + }, db, nil, weekStarTestGateway{}) + return service, db +} + +func TestSaveWeekStarConfigRequestPreservesStringIDs(t *testing.T) { + const longID int64 = 2004471533988167125 + + var req SaveWeekStarConfigRequest + if err := json.Unmarshal([]byte(`{ + "id": "2004471533988167125", + "giftConfigs": [{"giftId": "2004471533988167125"}], + "rewardConfigs": [{"rank": 1, "rewardGroupId": "2004471533988167125"}] + }`), &req); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if req.ID.Int64() != longID { + t.Fatalf("req.ID = %d, want %d", req.ID.Int64(), longID) + } + if req.GiftConfigs[0].GiftID.Int64() != longID { + t.Fatalf("giftId = %d, want %d", req.GiftConfigs[0].GiftID.Int64(), longID) + } + if req.RewardConfigs[0].RewardGroupID.Int64() != longID { + t.Fatalf("rewardGroupId = %d, want %d", req.RewardConfigs[0].RewardGroupID.Int64(), longID) + } +} + +func TestWeekStarConfigResponseSerializesLongIDsAsStrings(t *testing.T) { + const longID int64 = 2004471533988167125 + + data, err := json.Marshal(WeekStarConfigResponse{ + ID: longID, + GiftConfigs: []WeekStarGiftConfigPayload{ + {GiftID: longID}, + }, + RewardConfigs: []WeekStarRewardConfigPayload{ + {Rank: 1, RewardGroupID: longID}, + }, + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + jsonText := string(data) + for _, want := range []string{ + `"id":"2004471533988167125"`, + `"giftId":"2004471533988167125"`, + `"rewardGroupId":"2004471533988167125"`, + } { + if !strings.Contains(jsonText, want) { + t.Fatalf("json = %s, want contains %s", jsonText, want) + } + } +} + +func TestSaveConfigAllowsCurrentWeekWhenNoActiveConfigExists(t *testing.T) { + service, _ := newTestWeekStarService(t) + now := time.Now().In(service.location) + cycleStart, cycleEnd := service.cycleBounds(now) + + resp, err := service.SaveConfig(context.Background(), SaveWeekStarConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + StartAt: formatDateTime(cycleStart), + EndAt: formatDateTime(cycleEnd), + Timezone: "Asia/Riyadh", + GiftConfigs: []WeekStarGiftConfigInput{ + {GiftID: flexibleInt64(101), GiftName: "Gift 1", Sort: 1}, + {GiftID: flexibleInt64(102), GiftName: "Gift 2", Sort: 2}, + {GiftID: flexibleInt64(103), GiftName: "Gift 3", Sort: 3}, + }, + RewardConfigs: []WeekStarRewardConfigInput{ + {Rank: 1, RewardGroupID: flexibleInt64(2004471533988167125), RewardGroupName: "Top1"}, + {Rank: 2, RewardGroupID: flexibleInt64(2004471533988167126), RewardGroupName: "Top2"}, + {Rank: 3, RewardGroupID: flexibleInt64(2004471533988167127), RewardGroupName: "Top3"}, + }, + }) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + if resp == nil || !resp.Configured || resp.Status != weekStarActivityStatusOngoing { + t.Fatalf("SaveConfig() response = %#v, want ongoing configured response", resp) + } + if got := resp.RewardConfigs[0].RewardGroupID; got != 2004471533988167125 { + t.Fatalf("rewardGroupId = %d, want exact long id", got) + } + if len(resp.RewardConfigs[0].Items) != 1 { + t.Fatalf("reward items length = %d, want 1", len(resp.RewardConfigs[0].Items)) + } +} + +func TestSaveConfigRejectsCurrentWeekWhenActiveConfigExists(t *testing.T) { + service, db := newTestWeekStarService(t) + now := time.Now().In(service.location) + cycleStart, cycleEnd := service.cycleBounds(now) + + if err := db.Create(&model.WeekStarActivityConfig{ + ID: 1, + SysOrigin: "LIKEI", + Enabled: true, + StartAt: service.toStorageWallClock(cycleStart), + EndAt: service.toStorageWallClock(cycleEnd), + Timezone: "Asia/Riyadh", + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed active config: %v", err) + } + + _, err := service.SaveConfig(context.Background(), SaveWeekStarConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + StartAt: formatDateTime(cycleStart), + EndAt: formatDateTime(cycleEnd), + Timezone: "Asia/Riyadh", + GiftConfigs: []WeekStarGiftConfigInput{ + {GiftID: flexibleInt64(101), GiftName: "Gift 1", Sort: 1}, + {GiftID: flexibleInt64(102), GiftName: "Gift 2", Sort: 2}, + {GiftID: flexibleInt64(103), GiftName: "Gift 3", Sort: 3}, + }, + RewardConfigs: []WeekStarRewardConfigInput{ + {Rank: 1, RewardGroupID: flexibleInt64(2004471533988167125), RewardGroupName: "Top1"}, + {Rank: 2, RewardGroupID: flexibleInt64(2004471533988167126), RewardGroupName: "Top2"}, + {Rank: 3, RewardGroupID: flexibleInt64(2004471533988167127), RewardGroupName: "Top3"}, + }, + }) + if err == nil { + t.Fatalf("SaveConfig() error = nil, want overlap error") + } +} diff --git a/internal/service/weekstar/types.go b/internal/service/weekstar/types.go index ea31381..437ed40 100644 --- a/internal/service/weekstar/types.go +++ b/internal/service/weekstar/types.go @@ -2,6 +2,7 @@ package weekstar import ( "context" + "encoding/json" "math" "strconv" "strings" @@ -60,6 +61,16 @@ func (v *flexibleInt64) UnmarshalJSON(data []byte) error { return nil } +// Int64 返回标准 int64,便于写入数据库和调用 Java 接口。 +func (v flexibleInt64) Int64() int64 { + return int64(v) +} + +// MarshalJSON 把长整型输出为字符串,避免后台页面解析 Snowflake ID 时丢精度。 +func (v flexibleInt64) MarshalJSON() ([]byte, error) { + return json.Marshal(strconv.FormatInt(int64(v), 10)) +} + // weekStarGiftEvent 表示送礼 MQ 事件的核心字段。 type weekStarGiftEvent struct { TrackID string `json:"trackId"` @@ -92,7 +103,7 @@ type weekStarGiftEventValue struct { // WeekStarGiftConfigPayload 是周榜礼物配置的对外载体。 type WeekStarGiftConfigPayload struct { - GiftID int64 `json:"giftId"` + GiftID int64 `json:"giftId,string"` GiftName string `json:"giftName"` GiftPhoto string `json:"giftPhoto"` GiftCandy int64 `json:"giftCandy"` @@ -101,7 +112,7 @@ type WeekStarGiftConfigPayload struct { // WeekStarRewardPreviewItem 表示奖励预览中的单个物品。 type WeekStarRewardPreviewItem struct { - ID int64 `json:"id"` + ID int64 `json:"id,string"` Type string `json:"type"` Name string `json:"name"` Content string `json:"content"` @@ -113,15 +124,31 @@ type WeekStarRewardPreviewItem struct { // WeekStarRewardConfigPayload 是单个名次奖励配置的对外载体。 type WeekStarRewardConfigPayload struct { Rank int `json:"rank"` - RewardGroupID int64 `json:"rewardGroupId"` + RewardGroupID int64 `json:"rewardGroupId,string"` RewardGroupName string `json:"rewardGroupName"` Items []WeekStarRewardPreviewItem `json:"items,omitempty"` } +// WeekStarGiftConfigInput 是后台保存周榜礼物配置的入参,兼容字符串形式的长整型 ID。 +type WeekStarGiftConfigInput struct { + GiftID flexibleInt64 `json:"giftId"` + GiftName string `json:"giftName"` + GiftPhoto string `json:"giftPhoto"` + GiftCandy int64 `json:"giftCandy"` + Sort int `json:"sort,omitempty"` +} + +// WeekStarRewardConfigInput 是后台保存周榜奖励配置的入参,兼容字符串形式的长整型 ID。 +type WeekStarRewardConfigInput struct { + Rank int `json:"rank"` + RewardGroupID flexibleInt64 `json:"rewardGroupId"` + RewardGroupName string `json:"rewardGroupName"` +} + // WeekStarConfigResponse 是后台单个活动配置的读模型,同时复用于配置列表项。 type WeekStarConfigResponse struct { Configured bool `json:"configured"` - ID int64 `json:"id"` + ID int64 `json:"id,string"` SysOrigin string `json:"sysOrigin"` Enabled bool `json:"enabled"` Status string `json:"status"` @@ -136,19 +163,19 @@ type WeekStarConfigResponse struct { // SaveWeekStarConfigRequest 是周榜配置保存入参;带 ID 表示更新,不带 ID 表示创建未来周配置。 type SaveWeekStarConfigRequest struct { - ID int64 `json:"id"` - SysOrigin string `json:"sysOrigin"` - Enabled bool `json:"enabled"` - StartAt string `json:"startAt"` - EndAt string `json:"endAt"` - Timezone string `json:"timezone"` - GiftConfigs []WeekStarGiftConfigPayload `json:"giftConfigs"` - RewardConfigs []WeekStarRewardConfigPayload `json:"rewardConfigs"` + ID flexibleInt64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + Timezone string `json:"timezone"` + GiftConfigs []WeekStarGiftConfigInput `json:"giftConfigs"` + RewardConfigs []WeekStarRewardConfigInput `json:"rewardConfigs"` } // WeekStarRankingUserView 是榜单用户展示对象。 type WeekStarRankingUserView struct { - UserID int64 `json:"userId"` + UserID int64 `json:"userId,string"` UserAvatar string `json:"userAvatar"` UserNickname string `json:"userNickname"` Account string `json:"account"` @@ -172,12 +199,12 @@ type WeekStarPageResponse struct { // WeekStarSnapshotView 是后台快照列表的展示对象。 type WeekStarSnapshotView struct { - ID int64 `json:"id"` - ConfigID int64 `json:"configId"` + ID int64 `json:"id,string"` + ConfigID int64 `json:"configId,string"` CycleKey string `json:"cycleKey"` PeriodStartAt string `json:"periodStartAt"` PeriodEndAt string `json:"periodEndAt"` - UserID int64 `json:"userId"` + UserID int64 `json:"userId,string"` UserAvatar string `json:"userAvatar"` UserNickname string `json:"userNickname"` Account string `json:"account"` @@ -189,17 +216,17 @@ type WeekStarSnapshotView struct { // WeekStarRewardRecordView 是后台发奖记录列表的展示对象。 type WeekStarRewardRecordView struct { - ID int64 `json:"id"` - ConfigID int64 `json:"configId"` + ID int64 `json:"id,string"` + ConfigID int64 `json:"configId,string"` CycleKey string `json:"cycleKey"` - UserID int64 `json:"userId"` + UserID int64 `json:"userId,string"` UserAvatar string `json:"userAvatar"` UserNickname string `json:"userNickname"` Account string `json:"account"` CountryCode string `json:"countryCode"` CountryName string `json:"countryName"` Rank int `json:"rank"` - RewardGroupID int64 `json:"rewardGroupId"` + RewardGroupID int64 `json:"rewardGroupId,string"` RewardGroupName string `json:"rewardGroupName"` ScoreGold int64 `json:"scoreGold"` Status string `json:"status"` @@ -211,7 +238,7 @@ type WeekStarRewardRecordView struct { // WeekStarRetryRewardRequest 是后台补发请求入参。 type WeekStarRetryRewardRequest struct { - ID int64 `json:"id"` + ID flexibleInt64 `json:"id"` } // weekStarUserProfile 是内部使用的轻量用户资料视图。 diff --git a/migrations/001_invite_campaign.sql b/migrations/001_invite_campaign.sql index 934752d..89c7b49 100644 --- a/migrations/001_invite_campaign.sql +++ b/migrations/001_invite_campaign.sql @@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS invite_campaign_monthly_progress ( id BIGINT NOT NULL PRIMARY KEY, sys_origin VARCHAR(32) NOT NULL, inviter_user_id BIGINT NOT NULL, - month_key VARCHAR(6) NOT NULL, + month_key VARCHAR(16) NOT NULL, invite_count BIGINT NOT NULL DEFAULT 0, valid_user_count BIGINT NOT NULL DEFAULT 0, total_recharge_coins BIGINT NOT NULL DEFAULT 0, @@ -86,7 +86,7 @@ CREATE TABLE IF NOT EXISTS invite_campaign_task_claim ( id BIGINT NOT NULL PRIMARY KEY, sys_origin VARCHAR(32) NOT NULL, inviter_user_id BIGINT NOT NULL, - month_key VARCHAR(6) NOT NULL, + month_key VARCHAR(16) NOT NULL, rule_id BIGINT NOT NULL, claim_time DATETIME NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/migrations/015_invite_campaign_30_day_period.sql b/migrations/015_invite_campaign_30_day_period.sql new file mode 100644 index 0000000..e91c3fb --- /dev/null +++ b/migrations/015_invite_campaign_30_day_period.sql @@ -0,0 +1,5 @@ +ALTER TABLE invite_campaign_monthly_progress + MODIFY COLUMN month_key VARCHAR(16) NOT NULL; + +ALTER TABLE invite_campaign_task_claim + MODIFY COLUMN month_key VARCHAR(16) NOT NULL;