This commit is contained in:
hy001 2026-04-27 19:12:00 +08:00
parent 86c8e0c9ec
commit b7c0225dc3
18 changed files with 678 additions and 138 deletions

View File

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

View File

@ -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"` // 创建时间

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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())
}
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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 是内部使用的轻量用户资料视图。

View File

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

View File

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