邀请
This commit is contained in:
parent
86c8e0c9ec
commit
b7c0225dc3
@ -68,6 +68,7 @@ type RegisterRewardWorkerConfig struct {
|
|||||||
// InviteConfig 保存邀请活动相关配置。
|
// InviteConfig 保存邀请活动相关配置。
|
||||||
type InviteConfig struct {
|
type InviteConfig struct {
|
||||||
PublicBaseURL string
|
PublicBaseURL string
|
||||||
|
PublicH5Path string
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeekStarConfig 保存周榜模块配置。
|
// WeekStarConfig 保存周榜模块配置。
|
||||||
@ -175,6 +176,7 @@ func Load() Config {
|
|||||||
},
|
},
|
||||||
Invite: InviteConfig{
|
Invite: InviteConfig{
|
||||||
PublicBaseURL: getEnvAny([]string{"CHATAPP_INVITE_PUBLIC_BASE_URL", "INVITE_PUBLIC_BASE_URL"}, "http://localhost:2900"),
|
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{
|
WeekStar: WeekStarConfig{
|
||||||
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
DefaultSysOrigin: strings.ToUpper(getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI")),
|
||||||
|
|||||||
@ -79,12 +79,12 @@ type InviteCampaignInviteeProgress struct {
|
|||||||
// TableName 返回被邀请人进度表名。
|
// TableName 返回被邀请人进度表名。
|
||||||
func (InviteCampaignInviteeProgress) TableName() string { return "invite_campaign_invitee_progress" }
|
func (InviteCampaignInviteeProgress) TableName() string { return "invite_campaign_invitee_progress" }
|
||||||
|
|
||||||
// InviteCampaignMonthlyProgress 汇总邀请人月度邀请进展。
|
// InviteCampaignMonthlyProgress 汇总邀请人当前任务周期邀请进展。
|
||||||
type InviteCampaignMonthlyProgress struct {
|
type InviteCampaignMonthlyProgress struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey"` // 统计主键
|
ID int64 `gorm:"column:id;primaryKey"` // 统计主键
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_monthly_progress,priority:1"` // 系统标识
|
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
|
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"` // 邀请人数
|
InviteCount int64 `gorm:"column:invite_count"` // 邀请人数
|
||||||
ValidUserCount int64 `gorm:"column:valid_user_count"` // 有效用户数
|
ValidUserCount int64 `gorm:"column:valid_user_count"` // 有效用户数
|
||||||
TotalRecharge int64 `gorm:"column:total_recharge_coins"` // 累计充值金币
|
TotalRecharge int64 `gorm:"column:total_recharge_coins"` // 累计充值金币
|
||||||
@ -93,7 +93,7 @@ type InviteCampaignMonthlyProgress struct {
|
|||||||
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
|
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 返回邀请月度进度表名。
|
// TableName 返回邀请任务周期进度表名。
|
||||||
func (InviteCampaignMonthlyProgress) TableName() string { return "invite_campaign_monthly_progress" }
|
func (InviteCampaignMonthlyProgress) TableName() string { return "invite_campaign_monthly_progress" }
|
||||||
|
|
||||||
// InviteCampaignTaskClaim 记录任务奖励领取行为,防止重复领取。
|
// InviteCampaignTaskClaim 记录任务奖励领取行为,防止重复领取。
|
||||||
@ -101,7 +101,7 @@ type InviteCampaignTaskClaim struct {
|
|||||||
ID int64 `gorm:"column:id;primaryKey"` // 领取记录主键
|
ID int64 `gorm:"column:id;primaryKey"` // 领取记录主键
|
||||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_task_claim,priority:1"` // 系统标识
|
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
|
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
|
RuleID int64 `gorm:"column:rule_id;uniqueIndex:uk_invite_task_claim,priority:4"` // 规则 ID
|
||||||
ClaimTime time.Time `gorm:"column:claim_time"` // 领取时间
|
ClaimTime time.Time `gorm:"column:claim_time"` // 领取时间
|
||||||
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@ -72,17 +74,10 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ
|
|||||||
writeOK(c, resp)
|
writeOK(c, resp)
|
||||||
})
|
})
|
||||||
appGroup.POST("/bind-code", func(c *gin.Context) {
|
appGroup.POST("/bind-code", func(c *gin.Context) {
|
||||||
var req invite.BindCodeRequest
|
bindInviteCode(c, inviteService)
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
})
|
||||||
writeError(c, invite.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
appGroup.POST("/bind-invite-code", func(c *gin.Context) {
|
||||||
return
|
bindInviteCode(c, inviteService)
|
||||||
}
|
|
||||||
resp, err := inviteService.BindCode(c.Request.Context(), mustAuthUser(c), resolveClientIP(c), req)
|
|
||||||
if err != nil {
|
|
||||||
writeError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeOK(c, resp)
|
|
||||||
})
|
})
|
||||||
appGroup.POST("/tasks/claim", func(c *gin.Context) {
|
appGroup.POST("/tasks/claim", func(c *gin.Context) {
|
||||||
var req invite.TaskClaimRequest
|
var req invite.TaskClaimRequest
|
||||||
@ -98,3 +93,23 @@ func registerInviteRoutes(engine *gin.Engine, javaClient authGateway, inviteServ
|
|||||||
writeOK(c, resp)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ func registerSpecifiedGiftWeeklyRankRoutes(group *gin.RouterGroup, javaClient au
|
|||||||
writeError(c, weekstar.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
writeError(c, weekstar.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := weekStarService.RetryRewardRecord(c.Request.Context(), req.ID)
|
resp, err := weekStarService.RetryRewardRecord(c.Request.Context(), req.ID.Int64())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -30,11 +30,31 @@ func (s *InviteService) BindRegisteredCode(ctx context.Context, req RegisterBind
|
|||||||
return s.bindCode(ctx, AuthUser{
|
return s.bindCode(ctx, AuthUser{
|
||||||
UserID: req.InviteeUserID,
|
UserID: req.InviteeUserID,
|
||||||
SysOrigin: sysOrigin,
|
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) {
|
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 == "" {
|
if inviteCode == "" {
|
||||||
return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required")
|
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")
|
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)
|
mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@ -86,10 +115,18 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st
|
|||||||
relationID int64
|
relationID int64
|
||||||
eligibleStatus = relationStatusEligible
|
eligibleStatus = relationStatusEligible
|
||||||
blockReason string
|
blockReason string
|
||||||
monthKey string
|
periodKey string
|
||||||
|
alreadyBound bool
|
||||||
)
|
)
|
||||||
err = s.withBindLocks(ctx, lockKeys, func() error {
|
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")
|
return NewAppError(400, "already_bound", "invite code already bound")
|
||||||
} else if !errors.Is(lockErr, gorm.ErrRecordNotFound) {
|
} else if !errors.Is(lockErr, gorm.ErrRecordNotFound) {
|
||||||
return lockErr
|
return lockErr
|
||||||
@ -97,7 +134,7 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st
|
|||||||
|
|
||||||
// 在锁内判定风控资格并写入关系、进度两张表。
|
// 在锁内判定风控资格并写入关系、进度两张表。
|
||||||
bindTime := time.Now()
|
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) != "" {
|
if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" {
|
||||||
exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP)
|
exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP)
|
||||||
if existsErr != nil {
|
if existsErr != nil {
|
||||||
@ -166,7 +203,7 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if eligibleStatus == relationStatusEligible {
|
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
|
item.InviteCount += 1
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -182,12 +219,12 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st
|
|||||||
inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind)
|
inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind)
|
||||||
inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind)
|
inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind)
|
||||||
// 只有资格正常的绑定关系才会触发双方即时奖励。
|
// 只有资格正常的绑定关系才会触发双方即时奖励。
|
||||||
if eligibleStatus == relationStatusEligible {
|
if eligibleStatus == relationStatusEligible && !alreadyBound {
|
||||||
inviterUserID := mapping.UserID
|
inviterUserID := mapping.UserID
|
||||||
inviteeUserID := user.UserID
|
inviteeUserID := user.UserID
|
||||||
_, err = s.dispatchReward(ctx, rewardDispatchInput{
|
_, err = s.dispatchReward(ctx, rewardDispatchInput{
|
||||||
SysOrigin: user.SysOrigin,
|
SysOrigin: user.SysOrigin,
|
||||||
MonthKey: monthKey,
|
MonthKey: periodKey,
|
||||||
RelationID: refInt64(relationID),
|
RelationID: refInt64(relationID),
|
||||||
InviterUserID: &inviterUserID,
|
InviterUserID: &inviterUserID,
|
||||||
InviteeUserID: &inviteeUserID,
|
InviteeUserID: &inviteeUserID,
|
||||||
@ -225,6 +262,7 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st
|
|||||||
return &BindCodeResponse{
|
return &BindCodeResponse{
|
||||||
RelationID: relationID,
|
RelationID: relationID,
|
||||||
Eligible: eligibleStatus == relationStatusEligible,
|
Eligible: eligibleStatus == relationStatusEligible,
|
||||||
|
AlreadyBound: alreadyBound,
|
||||||
BlockReason: blockReason,
|
BlockReason: blockReason,
|
||||||
InviterUserID: mapping.UserID,
|
InviterUserID: mapping.UserID,
|
||||||
InviteCode: inviteCode,
|
InviteCode: inviteCode,
|
||||||
@ -233,6 +271,19 @@ func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP st
|
|||||||
}, nil
|
}, 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 为绑码流程批量加锁,确保同用户/同邀请码并发安全。
|
// withBindLocks 为绑码流程批量加锁,确保同用户/同邀请码并发安全。
|
||||||
func (s *InviteService) withBindLocks(ctx context.Context, keys []string, fn func() error) error {
|
func (s *InviteService) withBindLocks(ctx context.Context, keys []string, fn func() error) error {
|
||||||
keys = uniqueSortedStrings(keys)
|
keys = uniqueSortedStrings(keys)
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetHome 返回邀请活动首页所需的配置、任务、月度进度和累计统计。
|
// GetHome 返回邀请活动首页所需的配置、任务、当前周期进度和累计统计。
|
||||||
func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) {
|
func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) {
|
||||||
bundle, err := s.loadBundle(ctx, user.SysOrigin)
|
bundle, err := s.loadBundle(ctx, user.SysOrigin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -19,12 +20,11 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首页需要同时返回当前月进度、累计统计、邀请码和任务配置。
|
// 首页需要同时返回当前 30 天周期进度、累计统计、邀请码和任务配置。
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
timezone := normalizeTimezone(bundle.Config.Timezone)
|
timezone := normalizeTimezone(bundle.Config.Timezone)
|
||||||
monthKey := monthKeyAt(now, timezone)
|
periodKey, periodEndAt, secondsToReset := thirtyDayPeriodInfoAt(now, timezone, bundle.Config.CreateTime)
|
||||||
periodEndAt, secondsToReset := monthResetInfoAt(now, timezone)
|
monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, periodKey)
|
||||||
monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -32,7 +32,7 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -47,16 +47,18 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon
|
|||||||
CampaignEnabled: bundle.Config.Enabled,
|
CampaignEnabled: bundle.Config.Enabled,
|
||||||
SysOrigin: user.SysOrigin,
|
SysOrigin: user.SysOrigin,
|
||||||
InviteCode: inviteCode,
|
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),
|
ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle),
|
||||||
ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc),
|
ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc),
|
||||||
DownloadURL: bundle.Config.DownloadURL,
|
DownloadURL: bundle.Config.DownloadURL,
|
||||||
ServiceContact: bundle.Config.ServiceContact,
|
ServiceContact: bundle.Config.ServiceContact,
|
||||||
Timezone: timezone,
|
Timezone: timezone,
|
||||||
MonthKey: monthKey,
|
PeriodKey: periodKey,
|
||||||
|
MonthKey: periodKey,
|
||||||
ServerTimeMs: now.UnixMilli(),
|
ServerTimeMs: now.UnixMilli(),
|
||||||
PeriodEndAtMs: periodEndAt.UnixMilli(),
|
PeriodEndAtMs: periodEndAt.UnixMilli(),
|
||||||
SecondsToReset: secondsToReset,
|
SecondsToReset: secondsToReset,
|
||||||
|
ResetPeriodDays: inviteTaskPeriodDays,
|
||||||
ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules),
|
ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules),
|
||||||
InviterBindReward: rewardFromRule(inviterBindRule),
|
InviterBindReward: rewardFromRule(inviterBindRule),
|
||||||
InviteeBindReward: rewardFromRule(inviteeBindRule),
|
InviteeBindReward: rewardFromRule(inviteeBindRule),
|
||||||
@ -67,11 +69,35 @@ func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeRespon
|
|||||||
resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount
|
resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount
|
||||||
resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge
|
resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge
|
||||||
resp.MonthlyProgress.RewardGoldCoins = monthly.RewardGoldCoins
|
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)
|
resp.TotalRechargeTasks = buildTaskViews(totalRechargeRules, monthly.TotalRecharge, claims)
|
||||||
return resp, nil
|
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 根据规则、进度和领取状态生成任务展示列表。
|
// buildTaskViews 根据规则、进度和领取状态生成任务展示列表。
|
||||||
func buildTaskViews(rules []model.InviteCampaignRewardRule, progress int64, claimed map[int64]bool) []TaskView {
|
func buildTaskViews(rules []model.InviteCampaignRewardRule, progress int64, claimed map[int64]bool) []TaskView {
|
||||||
result := make([]TaskView, 0, len(rules))
|
result := make([]TaskView, 0, len(rules))
|
||||||
@ -158,19 +184,7 @@ func rewardsFromRules(rules []model.InviteCampaignRewardRule) []RewardView {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectiveValidThreshold 计算“有效用户”判定使用的最低充值门槛。
|
// effectiveValidThreshold 返回“有效用户”判定使用的固定充值门槛。
|
||||||
func effectiveValidThreshold(rules []model.InviteCampaignRewardRule) int64 {
|
func effectiveValidThreshold(_ []model.InviteCampaignRewardRule) int64 {
|
||||||
var threshold int64
|
return defaultValidRechargeCoins
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
t.Helper()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := db.Create(&model.InviteCampaignConfig{
|
if err := db.Create(&model.InviteCampaignConfig{
|
||||||
@ -93,12 +111,17 @@ func seedInviteCampaign(t *testing.T, db *gorm.DB) {
|
|||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
t.Fatalf("seed config: %v", err)
|
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))
|
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" {
|
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)
|
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)
|
service, db, cleanup := newTestInviteService(t, nil)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
seedInviteCampaign(t, db)
|
configTime := seedInviteCampaign(t, db)
|
||||||
|
|
||||||
now := time.Now()
|
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 {
|
if err := db.Create(&model.InviteCodeMapping{UserID: 1001, InviteCode: "INV1001", Account: "u1001"}).Error; err != nil {
|
||||||
t.Fatalf("seed invite code: %v", err)
|
t.Fatalf("seed invite code: %v", err)
|
||||||
}
|
}
|
||||||
@ -134,9 +157,9 @@ func TestGetHomeUsesInviteCountForInviteCountTasks(t *testing.T) {
|
|||||||
ID: 3001,
|
ID: 3001,
|
||||||
SysOrigin: "LIKEI",
|
SysOrigin: "LIKEI",
|
||||||
InviterUserID: 1001,
|
InviterUserID: 1001,
|
||||||
MonthKey: monthKey,
|
MonthKey: periodKey,
|
||||||
InviteCount: 7,
|
InviteCount: 7,
|
||||||
ValidUserCount: 0,
|
ValidUserCount: 4,
|
||||||
CreateTime: now,
|
CreateTime: now,
|
||||||
UpdateTime: now,
|
UpdateTime: now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
@ -151,22 +174,22 @@ func TestGetHomeUsesInviteCountForInviteCountTasks(t *testing.T) {
|
|||||||
t.Fatalf("ValidCountTasks length = %d, want 1", len(resp.ValidCountTasks))
|
t.Fatalf("ValidCountTasks length = %d, want 1", len(resp.ValidCountTasks))
|
||||||
}
|
}
|
||||||
task := resp.ValidCountTasks[0]
|
task := resp.ValidCountTasks[0]
|
||||||
if task.Progress != 7 || !task.Achieved {
|
if task.Progress != 4 || task.Achieved {
|
||||||
t.Fatalf("task progress = %d achieved = %v, want progress 7 achieved true", task.Progress, 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 {
|
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)
|
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{}
|
gateway := &inviteTestGateway{}
|
||||||
service, db, cleanup := newTestInviteService(t, gateway)
|
service, db, cleanup := newTestInviteService(t, gateway)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
seedInviteCampaign(t, db)
|
configTime := seedInviteCampaign(t, db)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
monthKey := monthKeyAt(now, "Asia/Shanghai")
|
periodKey, _, _ := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", configTime)
|
||||||
if err := db.Create(&model.InviteCampaignRewardRule{
|
if err := db.Create(&model.InviteCampaignRewardRule{
|
||||||
ID: 2002,
|
ID: 2002,
|
||||||
SysOrigin: "LIKEI",
|
SysOrigin: "LIKEI",
|
||||||
@ -184,18 +207,34 @@ func TestClaimTaskUsesInviteCountForInviteCountTasks(t *testing.T) {
|
|||||||
ID: 3002,
|
ID: 3002,
|
||||||
SysOrigin: "LIKEI",
|
SysOrigin: "LIKEI",
|
||||||
InviterUserID: 1002,
|
InviterUserID: 1002,
|
||||||
MonthKey: monthKey,
|
MonthKey: periodKey,
|
||||||
InviteCount: 5,
|
InviteCount: 5,
|
||||||
ValidUserCount: 0,
|
ValidUserCount: 4,
|
||||||
CreateTime: now,
|
CreateTime: now,
|
||||||
UpdateTime: now,
|
UpdateTime: now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
t.Fatalf("seed monthly progress: %v", err)
|
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})
|
resp, err := service.ClaimTask(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, TaskClaimRequest{RuleID: 2002})
|
||||||
if err != nil {
|
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 {
|
if !resp.Claimed || resp.Progress != 5 {
|
||||||
t.Fatalf("claim response = %+v, want claimed progress 5", resp)
|
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)
|
service, db, cleanup := newTestInviteService(t, nil)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@ -274,8 +373,14 @@ func TestGetRankUsesLifetimeInviteAndRewardStats(t *testing.T) {
|
|||||||
rows := []any{
|
rows := []any{
|
||||||
&model.InviteCampaignRelation{ID: 1, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 201, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now},
|
&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: 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: 3, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 203, 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: 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: 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.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},
|
&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) {
|
func TestDispatchGoldReturnsGrantError(t *testing.T) {
|
||||||
grantErr := errors.New("wallet unavailable")
|
grantErr := errors.New("wallet unavailable")
|
||||||
service, db, cleanup := newTestInviteService(t, &inviteTestGateway{grantGoldErr: grantErr})
|
service, db, cleanup := newTestInviteService(t, &inviteTestGateway{grantGoldErr: grantErr})
|
||||||
|
|||||||
@ -27,28 +27,34 @@ func normalizeTimezone(value string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// monthKeyAt 计算某个时间点在指定时区下的月份标识。
|
func inviteLocation(timezone string) *time.Location {
|
||||||
func monthKeyAt(t time.Time, timezone string) string {
|
|
||||||
location, err := time.LoadLocation(normalizeTimezone(timezone))
|
location, err := time.LoadLocation(normalizeTimezone(timezone))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
location = time.FixedZone(defaultTimezone, 3*3600)
|
location = time.FixedZone(defaultTimezone, 3*3600)
|
||||||
}
|
}
|
||||||
return t.In(location).Format("200601")
|
return location
|
||||||
}
|
}
|
||||||
|
|
||||||
// monthResetInfoAt 返回指定时间所在活动月的结束时间和剩余秒数。
|
// thirtyDayPeriodInfoAt 返回指定时间所在 30 天任务周期的 key、结束时间和剩余秒数。
|
||||||
func monthResetInfoAt(t time.Time, timezone string) (time.Time, int64) {
|
func thirtyDayPeriodInfoAt(t time.Time, timezone string, anchor time.Time) (string, time.Time, int64) {
|
||||||
location, err := time.LoadLocation(normalizeTimezone(timezone))
|
location := inviteLocation(timezone)
|
||||||
if err != nil {
|
|
||||||
location = time.FixedZone(defaultTimezone, 3*3600)
|
|
||||||
}
|
|
||||||
localTime := t.In(location)
|
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())
|
seconds := int64(periodEnd.Sub(localTime).Seconds())
|
||||||
if seconds < 0 {
|
if seconds < 0 {
|
||||||
seconds = 0
|
seconds = 0
|
||||||
}
|
}
|
||||||
return periodEnd, seconds
|
return periodStart.Format("20060102"), periodEnd, seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// digitsOnly 判断字符串是否只包含数字。
|
// digitsOnly 判断字符串是否只包含数字。
|
||||||
|
|||||||
@ -25,10 +25,11 @@ func (s *InviteService) GetRank(ctx context.Context, user AuthUser, limit int) (
|
|||||||
invite_rank.invite_count AS invite_count,
|
invite_rank.invite_count AS invite_count,
|
||||||
COALESCE(reward_rank.reward_gold_coins, 0) AS reward_gold_coins
|
COALESCE(reward_rank.reward_gold_coins, 0) AS reward_gold_coins
|
||||||
FROM (
|
FROM (
|
||||||
SELECT inviter_user_id AS user_id, COUNT(1) AS invite_count
|
SELECT relation.inviter_user_id AS user_id, COUNT(1) AS invite_count
|
||||||
FROM invite_campaign_relation
|
FROM invite_campaign_invitee_progress AS progress
|
||||||
WHERE sys_origin = ? AND eligible_status = ?
|
INNER JOIN invite_campaign_relation AS relation ON relation.id = progress.relation_id
|
||||||
GROUP BY inviter_user_id
|
WHERE relation.sys_origin = ? AND relation.eligible_status = ? AND progress.valid_user = 1
|
||||||
|
GROUP BY relation.inviter_user_id
|
||||||
) invite_rank
|
) invite_rank
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT target_user_id AS user_id, COALESCE(SUM(gold_amount), 0) AS reward_gold_coins
|
SELECT target_user_id AS user_id, COALESCE(SUM(gold_amount), 0) AS reward_gold_coins
|
||||||
|
|||||||
@ -41,7 +41,7 @@ func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, NewAppError(400, "invalid_pay_time", err.Error())
|
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)
|
inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge)
|
||||||
validThreshold := effectiveValidThreshold(inviteeRechargeRules)
|
validThreshold := effectiveValidThreshold(inviteeRechargeRules)
|
||||||
|
|
||||||
@ -58,7 +58,7 @@ func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeS
|
|||||||
newTotal int64
|
newTotal int64
|
||||||
)
|
)
|
||||||
|
|
||||||
// 在事务里完成充值事件幂等、关系查询、进度更新和月度统计累加。
|
// 在事务里完成充值事件幂等、关系查询、进度更新和当前周期统计累加。
|
||||||
err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var existing model.InviteCampaignRechargeEvent
|
var existing model.InviteCampaignRechargeEvent
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
@ -126,7 +126,7 @@ func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeS
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if eligible {
|
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
|
item.TotalRecharge += req.RechargeCoins
|
||||||
if becameValid {
|
if becameValid {
|
||||||
item.ValidUserCount += 1
|
item.ValidUserCount += 1
|
||||||
@ -162,7 +162,7 @@ func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeS
|
|||||||
for _, rule := range crossedRules {
|
for _, rule := range crossedRules {
|
||||||
_, err := s.dispatchReward(ctx, rewardDispatchInput{
|
_, err := s.dispatchReward(ctx, rewardDispatchInput{
|
||||||
SysOrigin: req.SysOrigin,
|
SysOrigin: req.SysOrigin,
|
||||||
MonthKey: monthKey,
|
MonthKey: periodKey,
|
||||||
RelationID: refInt64(relationID),
|
RelationID: refInt64(relationID),
|
||||||
InviterUserID: refInt64(inviterUserID),
|
InviterUserID: refInt64(inviterUserID),
|
||||||
InviteeUserID: refInt64(req.UserID),
|
InviteeUserID: refInt64(req.UserID),
|
||||||
|
|||||||
@ -137,7 +137,7 @@ func (s *InviteService) findRelationByInvitee(ctx context.Context, sysOrigin str
|
|||||||
return &relation, nil
|
return &relation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findMonthlyProgress 查询邀请人的月度进度。
|
// findMonthlyProgress 查询邀请人的当前任务周期进度;底层字段沿用 month_key 以兼容历史表结构。
|
||||||
func (s *InviteService) findMonthlyProgress(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (*model.InviteCampaignMonthlyProgress, error) {
|
func (s *InviteService) findMonthlyProgress(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (*model.InviteCampaignMonthlyProgress, error) {
|
||||||
var progress model.InviteCampaignMonthlyProgress
|
var progress model.InviteCampaignMonthlyProgress
|
||||||
err := s.repo.DB.WithContext(ctx).
|
err := s.repo.DB.WithContext(ctx).
|
||||||
@ -191,7 +191,7 @@ func (s *InviteService) loadStats(ctx context.Context, sysOrigin string, inviter
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadClaimedRuleIDs 读取某月已领取的任务规则集合。
|
// loadClaimedRuleIDs 读取某个任务周期已领取的任务规则集合。
|
||||||
func (s *InviteService) loadClaimedRuleIDs(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (map[int64]bool, error) {
|
func (s *InviteService) loadClaimedRuleIDs(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (map[int64]bool, error) {
|
||||||
var claims []model.InviteCampaignTaskClaim
|
var claims []model.InviteCampaignTaskClaim
|
||||||
if err := s.repo.DB.WithContext(ctx).
|
if err := s.repo.DB.WithContext(ctx).
|
||||||
|
|||||||
@ -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")
|
return nil, NewAppError(400, "invalid_rule_type", "rule cannot be claimed manually")
|
||||||
}
|
}
|
||||||
|
|
||||||
monthKey := monthKeyAt(time.Now(), normalizeTimezone(bundle.Config.Timezone))
|
periodKey, _, _ := thirtyDayPeriodInfoAt(time.Now(), normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime)
|
||||||
claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, monthKey, rule.ID)
|
claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, periodKey, rule.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
progressValue := monthly.TotalRecharge
|
progressValue := monthly.TotalRecharge
|
||||||
if rule.RuleType == ruleTypeInviterValidCount {
|
if rule.RuleType == ruleTypeInviterValidCount {
|
||||||
progressValue = monthly.InviteCount
|
progressValue = monthly.ValidUserCount
|
||||||
}
|
}
|
||||||
if claimed {
|
if claimed {
|
||||||
return &TaskClaimResponse{
|
return &TaskClaimResponse{
|
||||||
@ -60,16 +60,16 @@ func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskCl
|
|||||||
// 先发奖,再落领取记录;发奖侧通过 businessKey 兜底幂等。
|
// 先发奖,再落领取记录;发奖侧通过 businessKey 兜底幂等。
|
||||||
_, err = s.dispatchReward(ctx, rewardDispatchInput{
|
_, err = s.dispatchReward(ctx, rewardDispatchInput{
|
||||||
SysOrigin: user.SysOrigin,
|
SysOrigin: user.SysOrigin,
|
||||||
MonthKey: monthKey,
|
MonthKey: periodKey,
|
||||||
InviterUserID: refInt64(user.UserID),
|
InviterUserID: refInt64(user.UserID),
|
||||||
TargetUserID: user.UserID,
|
TargetUserID: user.UserID,
|
||||||
Reward: rewardFromRule(rule),
|
Reward: rewardFromRule(rule),
|
||||||
Scene: rule.RuleType,
|
Scene: rule.RuleType,
|
||||||
RuleID: refInt64(rule.ID),
|
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),
|
GoldOrigin: mapGoldOrigin(rule.RuleType),
|
||||||
PropsOrigin: mapPropsOrigin(rule.RuleType),
|
PropsOrigin: mapPropsOrigin(rule.RuleType),
|
||||||
Remark: "resident invite monthly task reward",
|
Remark: "resident invite 30-day task reward",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -77,7 +77,7 @@ func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskCl
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -107,7 +107,7 @@ func (s *InviteService) hasTaskClaim(ctx context.Context, sysOrigin string, invi
|
|||||||
return count > 0, err
|
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 {
|
func (s *InviteService) incrementMonthlyProgressTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, mutate func(item *model.InviteCampaignMonthlyProgress)) error {
|
||||||
var progress model.InviteCampaignMonthlyProgress
|
var progress model.InviteCampaignMonthlyProgress
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
|||||||
@ -31,6 +31,7 @@ const (
|
|||||||
ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE"
|
ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE"
|
||||||
defaultInviteRankLimit = 50
|
defaultInviteRankLimit = 50
|
||||||
maxInviteRankLimit = 100
|
maxInviteRankLimit = 100
|
||||||
|
inviteTaskPeriodDays = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
var errAlreadyProcessed = errors.New("invite recharge already processed")
|
var errAlreadyProcessed = errors.New("invite recharge already processed")
|
||||||
@ -81,10 +82,12 @@ type HomeResponse struct {
|
|||||||
DownloadURL string `json:"downloadUrl"`
|
DownloadURL string `json:"downloadUrl"`
|
||||||
ServiceContact string `json:"serviceContact"`
|
ServiceContact string `json:"serviceContact"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
|
PeriodKey string `json:"periodKey"`
|
||||||
MonthKey string `json:"monthKey"`
|
MonthKey string `json:"monthKey"`
|
||||||
ServerTimeMs int64 `json:"serverTimeMs"`
|
ServerTimeMs int64 `json:"serverTimeMs"`
|
||||||
PeriodEndAtMs int64 `json:"periodEndAtMs"`
|
PeriodEndAtMs int64 `json:"periodEndAtMs"`
|
||||||
SecondsToReset int64 `json:"secondsToReset"`
|
SecondsToReset int64 `json:"secondsToReset"`
|
||||||
|
ResetPeriodDays int `json:"resetPeriodDays"`
|
||||||
ValidUserThreshold int64 `json:"validUserThreshold"`
|
ValidUserThreshold int64 `json:"validUserThreshold"`
|
||||||
InviterBindReward RewardView `json:"inviterBindReward"`
|
InviterBindReward RewardView `json:"inviterBindReward"`
|
||||||
InviteeBindReward RewardView `json:"inviteeBindReward"`
|
InviteeBindReward RewardView `json:"inviteeBindReward"`
|
||||||
@ -133,22 +136,29 @@ type LandingResponse struct {
|
|||||||
|
|
||||||
// BindCodeRequest 是绑定邀请码入参。
|
// BindCodeRequest 是绑定邀请码入参。
|
||||||
type BindCodeRequest struct {
|
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 注册成功后同步邀请活动关系的内部入参。
|
// RegisterBindCodeRequest 是 Java 注册成功后同步邀请活动关系的内部入参。
|
||||||
type RegisterBindCodeRequest struct {
|
type RegisterBindCodeRequest struct {
|
||||||
InviteCode string `json:"inviteCode"`
|
InviteCode string `json:"inviteCode"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
InviteCodeSnake string `json:"invite_code"`
|
||||||
InviteeUserID int64 `json:"inviteeUserId"`
|
InvitationCode string `json:"invitationCode"`
|
||||||
ClientIP string `json:"clientIp"`
|
InvitationCodeSnake string `json:"invitation_code"`
|
||||||
DeviceFingerprint string `json:"deviceFingerprint"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
InviteeUserID int64 `json:"inviteeUserId"`
|
||||||
|
ClientIP string `json:"clientIp"`
|
||||||
|
DeviceFingerprint string `json:"deviceFingerprint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindCodeResponse 是绑定邀请码后的返回结果。
|
// BindCodeResponse 是绑定邀请码后的返回结果。
|
||||||
type BindCodeResponse struct {
|
type BindCodeResponse struct {
|
||||||
RelationID int64 `json:"relationId"`
|
RelationID int64 `json:"relationId"`
|
||||||
Eligible bool `json:"eligible"`
|
Eligible bool `json:"eligible"`
|
||||||
|
AlreadyBound bool `json:"alreadyBound"`
|
||||||
BlockReason string `json:"blockReason,omitempty"`
|
BlockReason string `json:"blockReason,omitempty"`
|
||||||
InviterUserID int64 `json:"inviterUserId"`
|
InviterUserID int64 `json:"inviterUserId"`
|
||||||
InviteCode string `json:"inviteCode"`
|
InviteCode string `json:"inviteCode"`
|
||||||
|
|||||||
@ -92,6 +92,7 @@ func (s *WeekStarService) PageConfigs(ctx context.Context, sysOrigin string, cur
|
|||||||
|
|
||||||
// SaveConfig 创建或更新未来周配置,并校验礼物、奖励和启用配置时间窗不重叠。
|
// SaveConfig 创建或更新未来周配置,并校验礼物、奖励和启用配置时间窗不重叠。
|
||||||
func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfigRequest) (*WeekStarConfigResponse, error) {
|
func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfigRequest) (*WeekStarConfigResponse, error) {
|
||||||
|
requestID := req.ID.Int64()
|
||||||
sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
|
sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
|
||||||
timezone := strings.TrimSpace(req.Timezone)
|
timezone := strings.TrimSpace(req.Timezone)
|
||||||
if 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")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_time_range", "endAt must be later than startAt")
|
||||||
}
|
}
|
||||||
now := time.Now().In(location)
|
now := time.Now().In(location)
|
||||||
if !startAt.After(now) {
|
if requestID > 0 {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", "only future-week configs can be created or updated")
|
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))
|
giftIDs := make(map[int64]struct{}, len(req.GiftConfigs))
|
||||||
// 先把礼物和奖励配置标准化,避免后续事务里还掺杂输入清洗逻辑。
|
// 先把礼物和奖励配置标准化,避免后续事务里还掺杂输入清洗逻辑。
|
||||||
normalizedGifts := make([]model.WeekStarActivityGiftConfig, 0, len(req.GiftConfigs))
|
normalizedGifts := make([]model.WeekStarActivityGiftConfig, 0, len(req.GiftConfigs))
|
||||||
for index, item := range 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")
|
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")
|
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()
|
id, idErr := utils.NextID()
|
||||||
if idErr != nil {
|
if idErr != nil {
|
||||||
return nil, idErr
|
return nil, idErr
|
||||||
}
|
}
|
||||||
normalizedGifts = append(normalizedGifts, model.WeekStarActivityGiftConfig{
|
normalizedGifts = append(normalizedGifts, model.WeekStarActivityGiftConfig{
|
||||||
ID: id,
|
ID: id,
|
||||||
GiftID: item.GiftID,
|
GiftID: giftID,
|
||||||
GiftName: strings.TrimSpace(item.GiftName),
|
GiftName: strings.TrimSpace(item.GiftName),
|
||||||
GiftPhoto: strings.TrimSpace(item.GiftPhoto),
|
GiftPhoto: strings.TrimSpace(item.GiftPhoto),
|
||||||
GiftCandy: item.GiftCandy,
|
GiftCandy: item.GiftCandy,
|
||||||
@ -154,25 +166,33 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig
|
|||||||
seenRanks := make(map[int]struct{}, len(req.RewardConfigs))
|
seenRanks := make(map[int]struct{}, len(req.RewardConfigs))
|
||||||
normalizedRewards := make([]model.WeekStarActivityRewardConfig, 0, len(req.RewardConfigs))
|
normalizedRewards := make([]model.WeekStarActivityRewardConfig, 0, len(req.RewardConfigs))
|
||||||
for _, item := range req.RewardConfigs {
|
for _, item := range req.RewardConfigs {
|
||||||
|
rewardGroupID := item.RewardGroupID.Int64()
|
||||||
if _, ok := rewardRanks[item.Rank]; !ok {
|
if _, ok := rewardRanks[item.Rank]; !ok {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_rank", "reward rank must be 1, 2, or 3")
|
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")
|
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group_id", "rewardGroupId is required")
|
||||||
}
|
}
|
||||||
if _, exists := seenRanks[item.Rank]; exists {
|
if _, exists := seenRanks[item.Rank]; exists {
|
||||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_rank", "rewardConfigs must not contain duplicate ranks")
|
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{}{}
|
seenRanks[item.Rank] = struct{}{}
|
||||||
id, idErr := utils.NextID()
|
id, idErr := utils.NextID()
|
||||||
if idErr != nil {
|
if idErr != nil {
|
||||||
return nil, idErr
|
return nil, idErr
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(rewardGroupName) == "" {
|
||||||
|
rewardGroupName = item.RewardGroupName
|
||||||
|
}
|
||||||
normalizedRewards = append(normalizedRewards, model.WeekStarActivityRewardConfig{
|
normalizedRewards = append(normalizedRewards, model.WeekStarActivityRewardConfig{
|
||||||
ID: id,
|
ID: id,
|
||||||
Rank: item.Rank,
|
Rank: item.Rank,
|
||||||
RewardGroupID: item.RewardGroupID,
|
RewardGroupID: rewardGroupID,
|
||||||
RewardGroupName: strings.TrimSpace(item.RewardGroupName),
|
RewardGroupName: strings.TrimSpace(rewardGroupName),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,8 +207,8 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig
|
|||||||
storedStartAt := s.toStorageWallClock(startAt)
|
storedStartAt := s.toStorageWallClock(startAt)
|
||||||
storedEndAt := s.toStorageWallClock(endAt)
|
storedEndAt := s.toStorageWallClock(endAt)
|
||||||
var configRow model.WeekStarActivityConfig
|
var configRow model.WeekStarActivityConfig
|
||||||
if req.ID > 0 {
|
if requestID > 0 {
|
||||||
if err := tx.Where("id = ?", req.ID).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
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")
|
return NewAppError(http.StatusNotFound, "week_star_config_not_found", "config not found")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -229,7 +249,7 @@ func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfig
|
|||||||
configRow.Timezone = timezone
|
configRow.Timezone = timezone
|
||||||
configRow.UpdateTime = now
|
configRow.UpdateTime = now
|
||||||
|
|
||||||
if req.ID > 0 {
|
if requestID > 0 {
|
||||||
if err := tx.Model(&model.WeekStarActivityConfig{}).
|
if err := tx.Model(&model.WeekStarActivityConfig{}).
|
||||||
Where("id = ?", configRow.ID).
|
Where("id = ?", configRow.ID).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
|
|||||||
194
internal/service/weekstar/config_test.go
Normal file
194
internal/service/weekstar/config_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package weekstar
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -60,6 +61,16 @@ func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
|
|||||||
return nil
|
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 事件的核心字段。
|
// weekStarGiftEvent 表示送礼 MQ 事件的核心字段。
|
||||||
type weekStarGiftEvent struct {
|
type weekStarGiftEvent struct {
|
||||||
TrackID string `json:"trackId"`
|
TrackID string `json:"trackId"`
|
||||||
@ -92,7 +103,7 @@ type weekStarGiftEventValue struct {
|
|||||||
|
|
||||||
// WeekStarGiftConfigPayload 是周榜礼物配置的对外载体。
|
// WeekStarGiftConfigPayload 是周榜礼物配置的对外载体。
|
||||||
type WeekStarGiftConfigPayload struct {
|
type WeekStarGiftConfigPayload struct {
|
||||||
GiftID int64 `json:"giftId"`
|
GiftID int64 `json:"giftId,string"`
|
||||||
GiftName string `json:"giftName"`
|
GiftName string `json:"giftName"`
|
||||||
GiftPhoto string `json:"giftPhoto"`
|
GiftPhoto string `json:"giftPhoto"`
|
||||||
GiftCandy int64 `json:"giftCandy"`
|
GiftCandy int64 `json:"giftCandy"`
|
||||||
@ -101,7 +112,7 @@ type WeekStarGiftConfigPayload struct {
|
|||||||
|
|
||||||
// WeekStarRewardPreviewItem 表示奖励预览中的单个物品。
|
// WeekStarRewardPreviewItem 表示奖励预览中的单个物品。
|
||||||
type WeekStarRewardPreviewItem struct {
|
type WeekStarRewardPreviewItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id,string"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
@ -113,15 +124,31 @@ type WeekStarRewardPreviewItem struct {
|
|||||||
// WeekStarRewardConfigPayload 是单个名次奖励配置的对外载体。
|
// WeekStarRewardConfigPayload 是单个名次奖励配置的对外载体。
|
||||||
type WeekStarRewardConfigPayload struct {
|
type WeekStarRewardConfigPayload struct {
|
||||||
Rank int `json:"rank"`
|
Rank int `json:"rank"`
|
||||||
RewardGroupID int64 `json:"rewardGroupId"`
|
RewardGroupID int64 `json:"rewardGroupId,string"`
|
||||||
RewardGroupName string `json:"rewardGroupName"`
|
RewardGroupName string `json:"rewardGroupName"`
|
||||||
Items []WeekStarRewardPreviewItem `json:"items,omitempty"`
|
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 是后台单个活动配置的读模型,同时复用于配置列表项。
|
// WeekStarConfigResponse 是后台单个活动配置的读模型,同时复用于配置列表项。
|
||||||
type WeekStarConfigResponse struct {
|
type WeekStarConfigResponse struct {
|
||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id,string"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@ -136,19 +163,19 @@ type WeekStarConfigResponse struct {
|
|||||||
|
|
||||||
// SaveWeekStarConfigRequest 是周榜配置保存入参;带 ID 表示更新,不带 ID 表示创建未来周配置。
|
// SaveWeekStarConfigRequest 是周榜配置保存入参;带 ID 表示更新,不带 ID 表示创建未来周配置。
|
||||||
type SaveWeekStarConfigRequest struct {
|
type SaveWeekStarConfigRequest struct {
|
||||||
ID int64 `json:"id"`
|
ID flexibleInt64 `json:"id"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
StartAt string `json:"startAt"`
|
StartAt string `json:"startAt"`
|
||||||
EndAt string `json:"endAt"`
|
EndAt string `json:"endAt"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
GiftConfigs []WeekStarGiftConfigPayload `json:"giftConfigs"`
|
GiftConfigs []WeekStarGiftConfigInput `json:"giftConfigs"`
|
||||||
RewardConfigs []WeekStarRewardConfigPayload `json:"rewardConfigs"`
|
RewardConfigs []WeekStarRewardConfigInput `json:"rewardConfigs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeekStarRankingUserView 是榜单用户展示对象。
|
// WeekStarRankingUserView 是榜单用户展示对象。
|
||||||
type WeekStarRankingUserView struct {
|
type WeekStarRankingUserView struct {
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId,string"`
|
||||||
UserAvatar string `json:"userAvatar"`
|
UserAvatar string `json:"userAvatar"`
|
||||||
UserNickname string `json:"userNickname"`
|
UserNickname string `json:"userNickname"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
@ -172,12 +199,12 @@ type WeekStarPageResponse struct {
|
|||||||
|
|
||||||
// WeekStarSnapshotView 是后台快照列表的展示对象。
|
// WeekStarSnapshotView 是后台快照列表的展示对象。
|
||||||
type WeekStarSnapshotView struct {
|
type WeekStarSnapshotView struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id,string"`
|
||||||
ConfigID int64 `json:"configId"`
|
ConfigID int64 `json:"configId,string"`
|
||||||
CycleKey string `json:"cycleKey"`
|
CycleKey string `json:"cycleKey"`
|
||||||
PeriodStartAt string `json:"periodStartAt"`
|
PeriodStartAt string `json:"periodStartAt"`
|
||||||
PeriodEndAt string `json:"periodEndAt"`
|
PeriodEndAt string `json:"periodEndAt"`
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId,string"`
|
||||||
UserAvatar string `json:"userAvatar"`
|
UserAvatar string `json:"userAvatar"`
|
||||||
UserNickname string `json:"userNickname"`
|
UserNickname string `json:"userNickname"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
@ -189,17 +216,17 @@ type WeekStarSnapshotView struct {
|
|||||||
|
|
||||||
// WeekStarRewardRecordView 是后台发奖记录列表的展示对象。
|
// WeekStarRewardRecordView 是后台发奖记录列表的展示对象。
|
||||||
type WeekStarRewardRecordView struct {
|
type WeekStarRewardRecordView struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id,string"`
|
||||||
ConfigID int64 `json:"configId"`
|
ConfigID int64 `json:"configId,string"`
|
||||||
CycleKey string `json:"cycleKey"`
|
CycleKey string `json:"cycleKey"`
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId,string"`
|
||||||
UserAvatar string `json:"userAvatar"`
|
UserAvatar string `json:"userAvatar"`
|
||||||
UserNickname string `json:"userNickname"`
|
UserNickname string `json:"userNickname"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
CountryCode string `json:"countryCode"`
|
CountryCode string `json:"countryCode"`
|
||||||
CountryName string `json:"countryName"`
|
CountryName string `json:"countryName"`
|
||||||
Rank int `json:"rank"`
|
Rank int `json:"rank"`
|
||||||
RewardGroupID int64 `json:"rewardGroupId"`
|
RewardGroupID int64 `json:"rewardGroupId,string"`
|
||||||
RewardGroupName string `json:"rewardGroupName"`
|
RewardGroupName string `json:"rewardGroupName"`
|
||||||
ScoreGold int64 `json:"scoreGold"`
|
ScoreGold int64 `json:"scoreGold"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@ -211,7 +238,7 @@ type WeekStarRewardRecordView struct {
|
|||||||
|
|
||||||
// WeekStarRetryRewardRequest 是后台补发请求入参。
|
// WeekStarRetryRewardRequest 是后台补发请求入参。
|
||||||
type WeekStarRetryRewardRequest struct {
|
type WeekStarRetryRewardRequest struct {
|
||||||
ID int64 `json:"id"`
|
ID flexibleInt64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// weekStarUserProfile 是内部使用的轻量用户资料视图。
|
// weekStarUserProfile 是内部使用的轻量用户资料视图。
|
||||||
|
|||||||
@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS invite_campaign_monthly_progress (
|
|||||||
id BIGINT NOT NULL PRIMARY KEY,
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
sys_origin VARCHAR(32) NOT NULL,
|
sys_origin VARCHAR(32) NOT NULL,
|
||||||
inviter_user_id BIGINT 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,
|
invite_count BIGINT NOT NULL DEFAULT 0,
|
||||||
valid_user_count BIGINT NOT NULL DEFAULT 0,
|
valid_user_count BIGINT NOT NULL DEFAULT 0,
|
||||||
total_recharge_coins 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,
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
sys_origin VARCHAR(32) NOT NULL,
|
sys_origin VARCHAR(32) NOT NULL,
|
||||||
inviter_user_id BIGINT 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,
|
rule_id BIGINT NOT NULL,
|
||||||
claim_time DATETIME NOT NULL,
|
claim_time DATETIME NOT NULL,
|
||||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|||||||
5
migrations/015_invite_campaign_30_day_period.sql
Normal file
5
migrations/015_invite_campaign_30_day_period.sql
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user