1528 lines
47 KiB
Go

package service
import (
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/util"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
defaultTimezone = "Asia/Riyadh"
defaultShareTitle = "Invite Friends, Earn Big Rewards!"
defaultShareDesc = "Invite friends to join Party App and unlock exclusive rewards."
defaultValidRechargeCoins = int64(80000)
rewardLogStatusPending = "PENDING"
rewardLogStatusSuccess = "SUCCESS"
rewardLogStatusFailed = "FAILED"
relationStatusEligible = "ELIGIBLE"
relationStatusBlocked = "BLOCKED"
blockReasonDuplicateIP = "DUPLICATE_IP"
blockReasonDuplicateDevice = "DUPLICATE_DEVICE"
ruleTypeInviterBind = "INVITER_BIND"
ruleTypeInviteeBind = "INVITEE_BIND"
ruleTypeInviteeRecharge = "INVITEE_RECHARGE"
ruleTypeInviterValidCount = "INVITER_VALID_COUNT"
ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE"
)
var errAlreadyProcessed = errors.New("invite recharge already processed")
type AppError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *AppError) Error() string {
return e.Message
}
func NewAppError(status int, code, message string) *AppError {
return &AppError{Status: status, Code: code, Message: message}
}
type AuthUser struct {
UserID int64
SysOrigin string
Token string
Authorization string
}
type RewardView struct {
RuleID int64 `json:"ruleId,omitempty"`
RuleType string `json:"ruleType,omitempty"`
Threshold int64 `json:"threshold,omitempty"`
GoldAmount int64 `json:"goldAmount"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
ValidThreshold int64 `json:"validThreshold,omitempty"`
}
type TaskView struct {
RuleID int64 `json:"ruleId"`
RuleType string `json:"ruleType"`
Threshold int64 `json:"threshold"`
Progress int64 `json:"progress"`
GoldAmount int64 `json:"goldAmount"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
Achieved bool `json:"achieved"`
Claimed bool `json:"claimed"`
}
type InviteStats struct {
TotalInvitedUsers int64 `json:"totalInvitedUsers"`
TotalValidUsers int64 `json:"totalValidUsers"`
TotalRechargeCoins int64 `json:"totalRechargeCoins"`
RewardGoldCoins int64 `json:"rewardGoldCoins"`
}
type HomeResponse struct {
CampaignEnabled bool `json:"campaignEnabled"`
SysOrigin string `json:"sysOrigin"`
InviteCode string `json:"inviteCode"`
InviteLink string `json:"inviteLink"`
ShareTitle string `json:"shareTitle"`
ShareDesc string `json:"shareDesc"`
DownloadURL string `json:"downloadUrl"`
ServiceContact string `json:"serviceContact"`
Timezone string `json:"timezone"`
MonthKey string `json:"monthKey"`
ValidUserThreshold int64 `json:"validUserThreshold"`
InviterBindReward RewardView `json:"inviterBindReward"`
InviteeBindReward RewardView `json:"inviteeBindReward"`
InviteeRecharge []RewardView `json:"inviteeRechargeRules"`
ValidCountTasks []TaskView `json:"inviterValidCountTasks"`
TotalRechargeTasks []TaskView `json:"inviterTotalRechargeTasks"`
MonthlyProgress struct {
InviteCount int64 `json:"inviteCount"`
ValidUserCount int64 `json:"validUserCount"`
TotalRecharge int64 `json:"totalRechargeCoins"`
RewardGoldCoins int64 `json:"rewardGoldCoins"`
} `json:"monthlyProgress"`
Stats InviteStats `json:"stats"`
}
type LandingResponse struct {
CampaignEnabled bool `json:"campaignEnabled"`
SysOrigin string `json:"sysOrigin"`
InviterUserID int64 `json:"inviterUserId"`
InviterNickname string `json:"inviterNickname"`
InviterAvatar string `json:"inviterAvatar"`
InviteCode string `json:"inviteCode"`
DownloadURL string `json:"downloadUrl"`
ShareTitle string `json:"shareTitle"`
ShareDesc string `json:"shareDesc"`
InviteeReward RewardView `json:"inviteeReward"`
}
type BindCodeRequest struct {
InviteCode string `json:"inviteCode"`
}
type BindCodeResponse struct {
RelationID int64 `json:"relationId"`
Eligible bool `json:"eligible"`
BlockReason string `json:"blockReason,omitempty"`
InviterUserID int64 `json:"inviterUserId"`
InviteCode string `json:"inviteCode"`
InviterReward RewardView `json:"inviterReward"`
InviteeReward RewardView `json:"inviteeReward"`
}
type TaskClaimRequest struct {
RuleID int64 `json:"ruleId"`
}
type TaskClaimResponse struct {
Claimed bool `json:"claimed"`
AlreadyClaimed bool `json:"alreadyClaimed"`
RuleID int64 `json:"ruleId"`
RuleType string `json:"ruleType"`
Progress int64 `json:"progress"`
Threshold int64 `json:"threshold"`
Reward RewardView `json:"reward"`
}
type RechargeSuccessRequest struct {
OrderID string `json:"orderId"`
UserID int64 `json:"userId"`
RechargeCoins int64 `json:"rechargeCoins"`
PayTime any `json:"payTime"`
SysOrigin string `json:"sysOrigin"`
}
type RechargeSuccessResponse struct {
Processed bool `json:"processed"`
AlreadyProcessed bool `json:"alreadyProcessed"`
HasInviteRelation bool `json:"hasInviteRelation"`
Eligible bool `json:"eligible"`
RelationID int64 `json:"relationId,omitempty"`
InviterUserID int64 `json:"inviterUserId,omitempty"`
TotalRechargeCoins int64 `json:"totalRechargeCoins,omitempty"`
ValidUser bool `json:"validUser"`
NewlyRewardedRuleIDs []int64 `json:"newlyRewardedRuleIds,omitempty"`
}
type rewardDispatchInput struct {
SysOrigin string
MonthKey string
RelationID *int64
InviterUserID *int64
InviteeUserID *int64
TargetUserID int64
Reward RewardView
Scene string
RuleID *int64
BusinessKey string
GoldOrigin string
PropsOrigin string
Remark string
}
type rewardDispatchResult struct {
Success bool
}
type campaignBundle struct {
Config model.InviteCampaignConfig `json:"config"`
Rules []model.InviteCampaignRewardRule `json:"rules"`
}
type InviteService struct {
cfg config.Config
repo *repo.Repository
java *integration.Client
}
func NewInviteService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *InviteService {
return &InviteService{
cfg: cfg,
repo: repository,
java: javaClient,
}
}
func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) {
bundle, err := s.loadBundle(ctx, user.SysOrigin)
if err != nil {
return nil, err
}
inviteCode, err := s.ensureInviteCode(ctx, user)
if err != nil {
return nil, err
}
now := time.Now()
timezone := normalizeTimezone(bundle.Config.Timezone)
monthKey := monthKeyAt(now, timezone)
monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey)
if err != nil {
return nil, err
}
stats, err := s.loadStats(ctx, user.SysOrigin, user.UserID)
if err != nil {
return nil, err
}
claims, err := s.loadClaimedRuleIDs(ctx, user.SysOrigin, user.UserID, monthKey)
if err != nil {
return nil, err
}
inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind)
inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind)
inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge)
validCountRules := rulesByType(bundle.Rules, ruleTypeInviterValidCount)
totalRechargeRules := rulesByType(bundle.Rules, ruleTypeInviterTotalRecharge)
resp := &HomeResponse{
CampaignEnabled: bundle.Config.Enabled,
SysOrigin: user.SysOrigin,
InviteCode: inviteCode,
InviteLink: s.cfg.PublicBaseURL + "/public/h5/invite-campaign/landing?code=" + url.QueryEscape(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,
ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules),
InviterBindReward: rewardFromRule(inviterBindRule),
InviteeBindReward: rewardFromRule(inviteeBindRule),
InviteeRecharge: rewardsFromRules(inviteeRechargeRules),
Stats: stats,
}
resp.MonthlyProgress.InviteCount = monthly.InviteCount
resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount
resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge
resp.MonthlyProgress.RewardGoldCoins = monthly.RewardGoldCoins
resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.ValidUserCount, claims)
resp.TotalRechargeTasks = buildTaskViews(totalRechargeRules, monthly.TotalRecharge, claims)
return resp, nil
}
func (s *InviteService) GetLanding(ctx context.Context, inviteCode string) (*LandingResponse, error) {
inviteCode = strings.TrimSpace(inviteCode)
if inviteCode == "" {
return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required")
}
mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewAppError(404, "invite_code_not_found", "invite code not found")
}
return nil, err
}
inviter, err := s.findUserBaseInfo(ctx, mapping.UserID)
if err != nil {
return nil, err
}
bundle, err := s.loadBundle(ctx, inviter.OriginSys)
if err != nil {
return nil, err
}
inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind)
return &LandingResponse{
CampaignEnabled: bundle.Config.Enabled,
SysOrigin: inviter.OriginSys,
InviterUserID: inviter.ID,
InviterNickname: inviter.UserNickname,
InviterAvatar: inviter.UserAvatar,
InviteCode: mapping.InviteCode,
DownloadURL: bundle.Config.DownloadURL,
ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle),
ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc),
InviteeReward: rewardFromRule(inviteeBindRule),
}, nil
}
func (s *InviteService) BindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest) (*BindCodeResponse, error) {
inviteCode := strings.TrimSpace(req.InviteCode)
if inviteCode == "" {
return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required")
}
bundle, err := s.loadBundle(ctx, user.SysOrigin)
if err != nil {
return nil, err
}
if !bundle.Config.Enabled {
return nil, NewAppError(400, "campaign_disabled", "invite campaign is disabled")
}
mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewAppError(404, "invite_code_not_found", "invite code not found")
}
return nil, err
}
if mapping.UserID == user.UserID {
return nil, NewAppError(400, "self_bind_not_allowed", "cannot bind your own invite code")
}
inviterProfile, err := s.findUserBaseInfo(ctx, mapping.UserID)
if err != nil {
return nil, err
}
if inviterProfile.OriginSys != "" && inviterProfile.OriginSys != user.SysOrigin {
return nil, NewAppError(400, "sys_origin_mismatch", "invite code does not belong to the current system")
}
fingerprint := ""
if value, fpErr := s.java.GetDeviceFingerprint(ctx, user.UserID); fpErr == nil {
fingerprint = strings.TrimSpace(value)
}
lockKeys := []string{
fmt.Sprintf("invite:bind:lock:user:%s:%d", user.SysOrigin, user.UserID),
}
if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" {
lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:ip:%s:%s", user.SysOrigin, strings.TrimSpace(clientIP)))
}
if bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" {
lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:device:%s:%s", user.SysOrigin, fingerprint))
}
var (
relationID int64
eligibleStatus = relationStatusEligible
blockReason string
monthKey string
)
err = s.withBindLocks(ctx, lockKeys, func() error {
if _, lockErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); lockErr == nil {
return NewAppError(400, "already_bound", "invite code already bound")
} else if !errors.Is(lockErr, gorm.ErrRecordNotFound) {
return lockErr
}
bindTime := time.Now()
monthKey = monthKeyAt(bindTime, normalizeTimezone(bundle.Config.Timezone))
if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" {
exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP)
if existsErr != nil {
return existsErr
}
if exists {
eligibleStatus = relationStatusBlocked
blockReason = blockReasonDuplicateIP
}
}
if eligibleStatus == relationStatusEligible && bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" {
exists, existsErr := s.existsEligibleRelationByDevice(ctx, user.SysOrigin, fingerprint)
if existsErr != nil {
return existsErr
}
if exists {
eligibleStatus = relationStatusBlocked
blockReason = blockReasonDuplicateDevice
}
}
nextRelationID, idErr := util.NextID()
if idErr != nil {
return idErr
}
progressID, idErr := util.NextID()
if idErr != nil {
return idErr
}
relationID = nextRelationID
relation := model.InviteCampaignRelation{
ID: relationID,
SysOrigin: user.SysOrigin,
InviterUserID: mapping.UserID,
InviteeUserID: user.UserID,
InviteCode: inviteCode,
BindIP: strings.TrimSpace(clientIP),
DeviceFingerprint: fingerprint,
EligibleStatus: eligibleStatus,
BlockReason: blockReason,
BindTime: bindTime,
CreateTime: bindTime,
UpdateTime: bindTime,
}
progress := model.InviteCampaignInviteeProgress{
ID: progressID,
RelationID: relationID,
SysOrigin: user.SysOrigin,
InviterUserID: mapping.UserID,
InviteeUserID: user.UserID,
TotalRecharge: 0,
ValidUser: false,
RewardedRuleIDs: "",
CreateTime: bindTime,
UpdateTime: bindTime,
}
return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&relation).Error; err != nil {
if isInviteRelationDuplicateErr(err) {
return NewAppError(400, "already_bound", "invite code already bound")
}
return err
}
if err := tx.Create(&progress).Error; err != nil {
return err
}
if eligibleStatus == relationStatusEligible {
if err := s.incrementMonthlyProgressTx(tx, user.SysOrigin, mapping.UserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) {
item.InviteCount += 1
}); err != nil {
return err
}
}
return nil
})
})
if err != nil {
return nil, err
}
inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind)
inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind)
if eligibleStatus == relationStatusEligible {
inviterUserID := mapping.UserID
inviteeUserID := user.UserID
_, err = s.dispatchReward(ctx, rewardDispatchInput{
SysOrigin: user.SysOrigin,
MonthKey: monthKey,
RelationID: refInt64(relationID),
InviterUserID: &inviterUserID,
InviteeUserID: &inviteeUserID,
TargetUserID: mapping.UserID,
Reward: rewardFromRule(inviterBindRule),
Scene: ruleTypeInviterBind,
RuleID: optionalRuleID(inviterBindRule),
BusinessKey: fmt.Sprintf("bind:inviter:%d", relationID),
GoldOrigin: "INVITED_NEW_USER_REWARD",
PropsOrigin: "INVITE_USER_REWARDS",
Remark: "resident invite inviter bind reward",
})
if err != nil {
return nil, err
}
_, err = s.dispatchReward(ctx, rewardDispatchInput{
SysOrigin: user.SysOrigin,
RelationID: refInt64(relationID),
InviterUserID: &inviterUserID,
InviteeUserID: &inviteeUserID,
TargetUserID: user.UserID,
Reward: rewardFromRule(inviteeBindRule),
Scene: ruleTypeInviteeBind,
RuleID: optionalRuleID(inviteeBindRule),
BusinessKey: fmt.Sprintf("bind:invitee:%d", relationID),
GoldOrigin: "INVITE_USER_REWARDS",
PropsOrigin: "INVITE_USER_REWARDS",
Remark: "resident invite invitee bind reward",
})
if err != nil {
return nil, err
}
}
return &BindCodeResponse{
RelationID: relationID,
Eligible: eligibleStatus == relationStatusEligible,
BlockReason: blockReason,
InviterUserID: mapping.UserID,
InviteCode: inviteCode,
InviterReward: rewardFromRule(inviterBindRule),
InviteeReward: rewardFromRule(inviteeBindRule),
}, nil
}
func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskClaimRequest) (*TaskClaimResponse, error) {
if req.RuleID == 0 {
return nil, NewAppError(400, "invalid_rule_id", "ruleId is required")
}
bundle, err := s.loadBundle(ctx, user.SysOrigin)
if err != nil {
return nil, err
}
rule := findRuleByID(bundle.Rules, req.RuleID)
if rule == nil || !rule.Enabled {
return nil, NewAppError(404, "rule_not_found", "rule not found")
}
if rule.RuleType != ruleTypeInviterValidCount && rule.RuleType != ruleTypeInviterTotalRecharge {
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)
if err != nil {
return nil, err
}
monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey)
if err != nil {
return nil, err
}
progressValue := monthly.TotalRecharge
if rule.RuleType == ruleTypeInviterValidCount {
progressValue = monthly.ValidUserCount
}
if claimed {
return &TaskClaimResponse{
Claimed: true,
AlreadyClaimed: true,
RuleID: rule.ID,
RuleType: rule.RuleType,
Progress: progressValue,
Threshold: rule.ThresholdValue,
Reward: rewardFromRule(rule),
}, nil
}
if progressValue < rule.ThresholdValue {
return nil, NewAppError(400, "task_not_reached", "task threshold not reached")
}
_, err = s.dispatchReward(ctx, rewardDispatchInput{
SysOrigin: user.SysOrigin,
MonthKey: monthKey,
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),
GoldOrigin: mapGoldOrigin(rule.RuleType),
PropsOrigin: mapPropsOrigin(rule.RuleType),
Remark: "resident invite monthly task reward",
})
if err != nil {
return nil, err
}
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 {
return err
}
return nil
})
if err != nil {
return nil, err
}
return &TaskClaimResponse{
Claimed: true,
AlreadyClaimed: false,
RuleID: rule.ID,
RuleType: rule.RuleType,
Progress: progressValue,
Threshold: rule.ThresholdValue,
Reward: rewardFromRule(rule),
}, nil
}
func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeSuccessRequest) (*RechargeSuccessResponse, error) {
req.OrderID = strings.TrimSpace(req.OrderID)
req.SysOrigin = strings.TrimSpace(req.SysOrigin)
if req.OrderID == "" {
return nil, NewAppError(400, "invalid_order_id", "orderId is required")
}
if req.UserID == 0 {
return nil, NewAppError(400, "invalid_user_id", "userId is required")
}
if req.RechargeCoins <= 0 {
return nil, NewAppError(400, "invalid_recharge", "rechargeCoins must be greater than 0")
}
if req.SysOrigin == "" {
return nil, NewAppError(400, "invalid_sys_origin", "sysOrigin is required")
}
bundle, err := s.loadBundle(ctx, req.SysOrigin)
if err != nil {
return nil, err
}
payTime, err := parseFlexibleTime(req.PayTime)
if err != nil {
return nil, NewAppError(400, "invalid_pay_time", err.Error())
}
monthKey := monthKeyAt(payTime, normalizeTimezone(bundle.Config.Timezone))
inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge)
validThreshold := effectiveValidThreshold(inviteeRechargeRules)
var (
relation model.InviteCampaignRelation
progress model.InviteCampaignInviteeProgress
crossedRules []model.InviteCampaignRewardRule
oldRewardedIDs map[int64]struct{}
hasRelation bool
eligible bool
becameValid bool
inviterUserID int64
relationID int64
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"}).
Where("order_id = ?", req.OrderID).
First(&existing).Error
if err == nil {
return errAlreadyProcessed
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sys_origin = ? AND invitee_user_id = ?", req.SysOrigin, req.UserID).
First(&relation).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
hasRelation = err == nil
eligible = hasRelation && relation.EligibleStatus == relationStatusEligible
eventID, idErr := util.NextID()
if idErr != nil {
return idErr
}
rechargeEvent := model.InviteCampaignRechargeEvent{
ID: eventID,
SysOrigin: req.SysOrigin,
OrderID: req.OrderID,
InviteeUserID: req.UserID,
RechargeCoins: req.RechargeCoins,
PayTime: payTime,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if hasRelation {
rechargeEvent.InviterUserID = refInt64(relation.InviterUserID)
}
if err := tx.Create(&rechargeEvent).Error; err != nil {
return err
}
if !hasRelation {
return nil
}
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("relation_id = ?", relation.ID).
First(&progress).Error
if err != nil {
return err
}
oldTotal := progress.TotalRecharge
newTotal = oldTotal + req.RechargeCoins
oldRewardedIDs = parseRewardedRuleIDs(progress.RewardedRuleIDs)
crossedRules = crossedInviteeRechargeRules(inviteeRechargeRules, oldTotal, newTotal, oldRewardedIDs)
progress.TotalRecharge = newTotal
progress.UpdateTime = time.Now()
if eligible && !progress.ValidUser && newTotal >= validThreshold {
progress.ValidUser = true
progress.ValidTime = refTime(payTime)
becameValid = true
}
if err := tx.Save(&progress).Error; err != nil {
return err
}
if eligible {
if err := s.incrementMonthlyProgressTx(tx, req.SysOrigin, relation.InviterUserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) {
item.TotalRecharge += req.RechargeCoins
if becameValid {
item.ValidUserCount += 1
}
}); err != nil {
return err
}
}
inviterUserID = relation.InviterUserID
relationID = relation.ID
return nil
})
if err != nil {
if errors.Is(err, errAlreadyProcessed) {
return &RechargeSuccessResponse{
Processed: true,
AlreadyProcessed: true,
}, nil
}
return nil, err
}
if !hasRelation {
return &RechargeSuccessResponse{
Processed: true,
HasInviteRelation: false,
}, nil
}
successfulRuleIDs := make([]int64, 0, len(crossedRules))
if eligible {
for _, rule := range crossedRules {
_, err := s.dispatchReward(ctx, rewardDispatchInput{
SysOrigin: req.SysOrigin,
MonthKey: monthKey,
RelationID: refInt64(relationID),
InviterUserID: refInt64(inviterUserID),
InviteeUserID: refInt64(req.UserID),
TargetUserID: inviterUserID,
Reward: rewardFromRule(&rule),
Scene: ruleTypeInviteeRecharge,
RuleID: refInt64(rule.ID),
BusinessKey: fmt.Sprintf("recharge:%d:%d", relationID, rule.ID),
GoldOrigin: mapGoldOrigin(ruleTypeInviteeRecharge),
PropsOrigin: mapPropsOrigin(ruleTypeInviteeRecharge),
Remark: "resident invite recharge reward",
})
if err != nil {
return nil, err
}
successfulRuleIDs = append(successfulRuleIDs, rule.ID)
}
if len(successfulRuleIDs) > 0 {
if err := s.mergeRewardedRuleIDs(ctx, relationID, successfulRuleIDs); err != nil {
return nil, err
}
}
}
return &RechargeSuccessResponse{
Processed: true,
AlreadyProcessed: false,
HasInviteRelation: true,
Eligible: eligible,
RelationID: relationID,
InviterUserID: inviterUserID,
TotalRechargeCoins: newTotal,
ValidUser: progress.ValidUser,
NewlyRewardedRuleIDs: successfulRuleIDs,
}, nil
}
func (s *InviteService) loadBundle(ctx context.Context, sysOrigin string) (*campaignBundle, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
cacheKey := "invite:campaign:config:" + sysOrigin
if raw, err := s.repo.Redis.Get(ctx, cacheKey).Result(); err == nil && raw != "" {
var bundle campaignBundle
if jsonErr := json.Unmarshal([]byte(raw), &bundle); jsonErr == nil {
return &bundle, nil
}
}
bundle := &campaignBundle{
Config: model.InviteCampaignConfig{
SysOrigin: sysOrigin,
Enabled: false,
Timezone: defaultTimezone,
AntiCheatSameIPOnce: true,
AntiCheatSameDeviceOnce: true,
},
}
var configEntity model.InviteCampaignConfig
err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
First(&configEntity).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err == nil {
bundle.Config = configEntity
if strings.TrimSpace(bundle.Config.Timezone) == "" {
bundle.Config.Timezone = defaultTimezone
}
}
if err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
Order("sort asc, threshold_value asc, id asc").
Find(&bundle.Rules).Error; err != nil {
return nil, err
}
payload, _ := json.Marshal(bundle)
s.repo.Redis.Set(ctx, cacheKey, payload, 0)
return bundle, nil
}
func (s *InviteService) ensureInviteCode(ctx context.Context, user AuthUser) (string, error) {
if mapping, err := s.findInviteCodeMappingByUserID(ctx, user.UserID); err == nil {
return mapping.InviteCode, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
if user.Authorization == "" {
return "", NewAppError(401, "missing_authorization", "authorization is required to generate invite code")
}
resp, err := s.java.EnsureInviteCode(ctx, user.Authorization)
if err != nil {
return "", err
}
info, infoErr := s.findUserBaseInfo(ctx, user.UserID)
if infoErr != nil {
return "", infoErr
}
mapping := model.InviteCodeMapping{
UserID: user.UserID,
InviteCode: resp.InviteCode,
Account: info.Account,
}
if err := s.repo.DB.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"invite_code", "account"}),
}).
Create(&mapping).Error; err != nil {
return "", err
}
return resp.InviteCode, nil
}
func (s *InviteService) findInviteCodeMappingByUserID(ctx context.Context, userID int64) (*model.InviteCodeMapping, error) {
var mapping model.InviteCodeMapping
err := s.repo.DB.WithContext(ctx).
Where("user_id = ?", userID).
First(&mapping).Error
if err != nil {
return nil, err
}
return &mapping, nil
}
func (s *InviteService) findInviteCodeMappingByCode(ctx context.Context, inviteCode string) (*model.InviteCodeMapping, error) {
var mapping model.InviteCodeMapping
err := s.repo.DB.WithContext(ctx).
Where("invite_code = ?", inviteCode).
First(&mapping).Error
if err != nil {
return nil, err
}
return &mapping, nil
}
func (s *InviteService) findUserBaseInfo(ctx context.Context, userID int64) (*model.UserBaseInfo, error) {
var info model.UserBaseInfo
err := s.repo.DB.WithContext(ctx).
Where("id = ?", userID).
First(&info).Error
if err != nil {
return nil, err
}
return &info, nil
}
func (s *InviteService) findRelationByInvitee(ctx context.Context, sysOrigin string, inviteeUserID int64) (*model.InviteCampaignRelation, error) {
var relation model.InviteCampaignRelation
err := s.repo.DB.WithContext(ctx).
Where("sys_origin = ? AND invitee_user_id = ?", sysOrigin, inviteeUserID).
First(&relation).Error
if err != nil {
return nil, err
}
return &relation, nil
}
func (s *InviteService) withBindLocks(ctx context.Context, keys []string, fn func() error) error {
keys = uniqueSortedStrings(keys)
released := make([]string, 0, len(keys))
for _, key := range keys {
acquired, err := s.acquireBindLock(ctx, key, 3*time.Second)
if err != nil {
s.releaseBindLocks(ctx, released)
return err
}
if !acquired {
s.releaseBindLocks(ctx, released)
return NewAppError(409, "bind_in_progress", "invite bind is in progress")
}
released = append(released, key)
}
defer s.releaseBindLocks(ctx, released)
return fn()
}
func (s *InviteService) acquireBindLock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
deadline := time.Now().Add(2 * time.Second)
for {
acquired, err := s.repo.Redis.SetNX(ctx, key, "1", ttl).Result()
if err != nil {
return false, err
}
if acquired {
return true, nil
}
if time.Now().After(deadline) {
return false, nil
}
select {
case <-ctx.Done():
return false, ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
}
func (s *InviteService) releaseBindLocks(ctx context.Context, keys []string) {
if len(keys) == 0 {
return
}
_ = s.repo.Redis.Del(ctx, keys...).Err()
}
func uniqueSortedStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Strings(result)
return result
}
func isInviteRelationDuplicateErr(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(message, "duplicate entry") && strings.Contains(message, "invite_campaign_relation")
}
func (s *InviteService) existsEligibleRelationByIP(ctx context.Context, sysOrigin, bindIP string) (bool, error) {
var count int64
err := s.repo.DB.WithContext(ctx).
Model(&model.InviteCampaignRelation{}).
Where("sys_origin = ? AND bind_ip = ? AND eligible_status = ?", sysOrigin, bindIP, relationStatusEligible).
Count(&count).Error
return count > 0, err
}
func (s *InviteService) existsEligibleRelationByDevice(ctx context.Context, sysOrigin, fingerprint string) (bool, error) {
var count int64
err := s.repo.DB.WithContext(ctx).
Model(&model.InviteCampaignRelation{}).
Where("sys_origin = ? AND device_fingerprint = ? AND eligible_status = ?", sysOrigin, fingerprint, relationStatusEligible).
Count(&count).Error
return count > 0, err
}
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).
Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey).
First(&progress).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &model.InviteCampaignMonthlyProgress{
SysOrigin: sysOrigin,
InviterUserID: inviterUserID,
MonthKey: monthKey,
}, nil
}
if err != nil {
return nil, err
}
return &progress, nil
}
func (s *InviteService) loadStats(ctx context.Context, sysOrigin string, inviterUserID int64) (InviteStats, error) {
stats := InviteStats{}
if err := s.repo.DB.WithContext(ctx).
Model(&model.InviteCampaignRelation{}).
Where("sys_origin = ? AND inviter_user_id = ? AND eligible_status = ?", sysOrigin, inviterUserID, relationStatusEligible).
Count(&stats.TotalInvitedUsers).Error; err != nil {
return stats, err
}
if err := s.repo.DB.WithContext(ctx).Raw(`
SELECT COALESCE(COUNT(1), 0)
FROM invite_campaign_invitee_progress ip
INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id
WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? AND ip.valid_user = 1
`, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalValidUsers).Error; err != nil {
return stats, err
}
if err := s.repo.DB.WithContext(ctx).Raw(`
SELECT COALESCE(SUM(ip.total_recharge_coins), 0)
FROM invite_campaign_invitee_progress ip
INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id
WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ?
`, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalRechargeCoins).Error; err != nil {
return stats, err
}
if err := s.repo.DB.WithContext(ctx).Raw(`
SELECT COALESCE(SUM(gold_amount), 0)
FROM invite_campaign_reward_log
WHERE sys_origin = ? AND inviter_user_id = ? AND target_user_id = ? AND status = ?
`, sysOrigin, inviterUserID, inviterUserID, rewardLogStatusSuccess).Scan(&stats.RewardGoldCoins).Error; err != nil {
return stats, err
}
return stats, nil
}
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).
Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey).
Find(&claims).Error; err != nil {
return nil, err
}
result := make(map[int64]bool, len(claims))
for _, item := range claims {
result[item.RuleID] = true
}
return result, nil
}
func (s *InviteService) hasTaskClaim(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64) (bool, error) {
var count int64
err := s.repo.DB.WithContext(ctx).
Model(&model.InviteCampaignTaskClaim{}).
Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID).
Count(&count).Error
return count > 0, err
}
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"}).
Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey).
First(&progress).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, idErr := util.NextID()
if idErr != nil {
return idErr
}
now := time.Now()
progress = model.InviteCampaignMonthlyProgress{
ID: id,
SysOrigin: sysOrigin,
InviterUserID: inviterUserID,
MonthKey: monthKey,
CreateTime: now,
UpdateTime: now,
}
mutate(&progress)
return tx.Create(&progress).Error
}
if err != nil {
return err
}
mutate(&progress)
progress.UpdateTime = time.Now()
return tx.Save(&progress).Error
}
func (s *InviteService) createTaskClaimIfAbsentTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64, claimTime time.Time) error {
var existing model.InviteCampaignTaskClaim
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID).
First(&existing).Error
if err == nil {
return nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
id, idErr := util.NextID()
if idErr != nil {
return idErr
}
return tx.Create(&model.InviteCampaignTaskClaim{
ID: id,
SysOrigin: sysOrigin,
InviterUserID: inviterUserID,
MonthKey: monthKey,
RuleID: ruleID,
ClaimTime: claimTime,
CreateTime: claimTime,
UpdateTime: claimTime,
}).Error
}
func (s *InviteService) dispatchReward(ctx context.Context, input rewardDispatchInput) (*rewardDispatchResult, error) {
if input.Reward.GoldAmount <= 0 && input.Reward.RewardGroupID == nil {
return &rewardDispatchResult{Success: true}, nil
}
if input.Reward.GoldAmount > 0 {
if err := s.dispatchGold(ctx, input); err != nil {
return &rewardDispatchResult{Success: false}, err
}
}
if input.Reward.RewardGroupID != nil {
if err := s.dispatchProps(ctx, input); err != nil {
return &rewardDispatchResult{Success: false}, err
}
}
return &rewardDispatchResult{Success: true}, nil
}
func (s *InviteService) dispatchGold(ctx context.Context, input rewardDispatchInput) error {
businessKey := input.BusinessKey + ":gold"
logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, input.Reward.GoldAmount, nil)
if err != nil {
return err
}
if logRecord.Status == rewardLogStatusSuccess {
return nil
}
err = s.java.GrantGold(ctx, integration.GrantGoldRequest{
UserID: input.TargetUserID,
SysOrigin: input.SysOrigin,
EventID: businessKey,
Origin: input.GoldOrigin,
GoldAmount: input.Reward.GoldAmount,
Remark: input.Remark,
})
if err != nil {
return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error())
}
if err := s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, ""); err != nil {
return err
}
if input.MonthKey != "" && input.InviterUserID != nil && *input.InviterUserID == input.TargetUserID {
return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.incrementMonthlyProgressTx(tx, input.SysOrigin, input.TargetUserID, input.MonthKey, func(item *model.InviteCampaignMonthlyProgress) {
item.RewardGoldCoins += input.Reward.GoldAmount
})
})
}
return nil
}
func (s *InviteService) dispatchProps(ctx context.Context, input rewardDispatchInput) error {
if input.Reward.RewardGroupID == nil {
return nil
}
businessKey := input.BusinessKey + ":props"
logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, 0, input.Reward.RewardGroupID)
if err != nil {
return err
}
if logRecord.Status == rewardLogStatusSuccess {
return nil
}
err = s.java.GrantProps(ctx, integration.GrantPropsRequest{
TrackID: logRecord.ID,
AcceptUserID: input.TargetUserID,
SysOrigin: input.SysOrigin,
Origin: input.PropsOrigin,
SourceGroupID: *input.Reward.RewardGroupID,
})
if err != nil {
return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error())
}
return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, "")
}
func (s *InviteService) getOrCreateRewardLog(ctx context.Context, businessKey string, input rewardDispatchInput, goldAmount int64, rewardGroupID *int64) (*model.InviteCampaignRewardLog, error) {
var record model.InviteCampaignRewardLog
err := s.repo.DB.WithContext(ctx).
Where("business_key = ?", businessKey).
First(&record).Error
if err == nil {
return &record, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
id, idErr := util.NextID()
if idErr != nil {
return nil, idErr
}
now := time.Now()
record = model.InviteCampaignRewardLog{
ID: id,
SysOrigin: input.SysOrigin,
RelationID: input.RelationID,
InviterUserID: input.InviterUserID,
InviteeUserID: input.InviteeUserID,
TargetUserID: input.TargetUserID,
RuleID: input.RuleID,
RewardScene: input.Scene,
BusinessKey: businessKey,
GoldAmount: goldAmount,
RewardGroupID: rewardGroupID,
Status: rewardLogStatusPending,
CreateTime: now,
UpdateTime: now,
}
if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") {
return nil, err
}
if err := s.repo.DB.WithContext(ctx).Where("business_key = ?", businessKey).First(&record).Error; err != nil {
return nil, err
}
}
return &record, nil
}
func (s *InviteService) updateRewardLogStatus(ctx context.Context, id int64, status, message string) error {
return s.repo.DB.WithContext(ctx).
Model(&model.InviteCampaignRewardLog{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"error_message": truncate(message, 255),
"update_time": time.Now(),
}).Error
}
func (s *InviteService) mergeRewardedRuleIDs(ctx context.Context, relationID int64, ruleIDs []int64) error {
return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var progress model.InviteCampaignInviteeProgress
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("relation_id = ?", relationID).
First(&progress).Error; err != nil {
return err
}
current := parseRewardedRuleIDs(progress.RewardedRuleIDs)
for _, ruleID := range ruleIDs {
current[ruleID] = struct{}{}
}
progress.RewardedRuleIDs = formatRewardedRuleIDs(current)
progress.UpdateTime = time.Now()
return tx.Save(&progress).Error
})
}
func buildTaskViews(rules []model.InviteCampaignRewardRule, progress int64, claimed map[int64]bool) []TaskView {
result := make([]TaskView, 0, len(rules))
for _, rule := range rules {
if !rule.Enabled {
continue
}
result = append(result, TaskView{
RuleID: rule.ID,
RuleType: rule.RuleType,
Threshold: rule.ThresholdValue,
Progress: progress,
GoldAmount: rule.GoldAmount,
RewardGroupID: rule.RewardGroupID,
Achieved: progress >= rule.ThresholdValue,
Claimed: claimed[rule.ID],
})
}
return result
}
func firstRule(rules []model.InviteCampaignRewardRule, ruleType string) *model.InviteCampaignRewardRule {
for _, item := range rules {
if item.RuleType == ruleType && item.Enabled {
rule := item
return &rule
}
}
return nil
}
func rulesByType(rules []model.InviteCampaignRewardRule, ruleType string) []model.InviteCampaignRewardRule {
result := make([]model.InviteCampaignRewardRule, 0)
for _, item := range rules {
if item.RuleType == ruleType && item.Enabled {
result = append(result, item)
}
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].Sort == result[j].Sort {
if result[i].ThresholdValue == result[j].ThresholdValue {
return result[i].ID < result[j].ID
}
return result[i].ThresholdValue < result[j].ThresholdValue
}
return result[i].Sort < result[j].Sort
})
return result
}
func findRuleByID(rules []model.InviteCampaignRewardRule, ruleID int64) *model.InviteCampaignRewardRule {
for _, item := range rules {
if item.ID == ruleID {
rule := item
return &rule
}
}
return nil
}
func crossedInviteeRechargeRules(rules []model.InviteCampaignRewardRule, oldTotal, newTotal int64, rewarded map[int64]struct{}) []model.InviteCampaignRewardRule {
result := make([]model.InviteCampaignRewardRule, 0)
for _, rule := range rules {
if !rule.Enabled {
continue
}
if _, exists := rewarded[rule.ID]; exists {
continue
}
if oldTotal < rule.ThresholdValue && newTotal >= rule.ThresholdValue {
result = append(result, rule)
}
}
return result
}
func rewardFromRule(rule *model.InviteCampaignRewardRule) RewardView {
if rule == nil {
return RewardView{}
}
return RewardView{
RuleID: rule.ID,
RuleType: rule.RuleType,
Threshold: rule.ThresholdValue,
GoldAmount: rule.GoldAmount,
RewardGroupID: rule.RewardGroupID,
}
}
func rewardsFromRules(rules []model.InviteCampaignRewardRule) []RewardView {
result := make([]RewardView, 0, len(rules))
for _, item := range rules {
result = append(result, rewardFromRule(&item))
}
return result
}
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
}
func parseRewardedRuleIDs(raw string) map[int64]struct{} {
result := make(map[int64]struct{})
for _, item := range strings.Split(raw, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
value, err := strconv.ParseInt(item, 10, 64)
if err == nil && value > 0 {
result[value] = struct{}{}
}
}
return result
}
func formatRewardedRuleIDs(values map[int64]struct{}) string {
items := make([]int64, 0, len(values))
for value := range values {
items = append(items, value)
}
sort.Slice(items, func(i, j int) bool { return items[i] < items[j] })
parts := make([]string, 0, len(items))
for _, value := range items {
parts = append(parts, strconv.FormatInt(value, 10))
}
return strings.Join(parts, ",")
}
func parseFlexibleTime(value any) (time.Time, error) {
switch v := value.(type) {
case nil:
return time.Now(), nil
case float64:
return fromUnixNumber(int64(v)), nil
case int64:
return fromUnixNumber(v), nil
case int:
return fromUnixNumber(int64(v)), nil
case json.Number:
num, err := v.Int64()
if err != nil {
return time.Time{}, err
}
return fromUnixNumber(num), nil
case string:
v = strings.TrimSpace(v)
if v == "" {
return time.Now(), nil
}
if digitsOnly(v) {
num, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return time.Time{}, err
}
return fromUnixNumber(num), nil
}
layouts := []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, v); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("unsupported payTime format")
default:
return time.Time{}, fmt.Errorf("unsupported payTime type")
}
}
func fromUnixNumber(value int64) time.Time {
if value > 1_000_000_000_000 {
return time.UnixMilli(value)
}
return time.Unix(value, 0)
}
func normalizeSysOrigin(value string) string {
return strings.TrimSpace(value)
}
func normalizeTimezone(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return defaultTimezone
}
return value
}
func monthKeyAt(t time.Time, timezone string) string {
location, err := time.LoadLocation(normalizeTimezone(timezone))
if err != nil {
location = time.FixedZone(defaultTimezone, 3*3600)
}
return t.In(location).Format("200601")
}
func digitsOnly(value string) bool {
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func truncate(value string, size int) string {
if len(value) <= size {
return value
}
return value[:size]
}
func optionalRuleID(rule *model.InviteCampaignRewardRule) *int64 {
if rule == nil || rule.ID == 0 {
return nil
}
return refInt64(rule.ID)
}
func refInt64(value int64) *int64 {
return &value
}
func refTime(value time.Time) *time.Time {
return &value
}
func mapGoldOrigin(ruleType string) string {
switch ruleType {
case ruleTypeInviterBind:
return "INVITED_NEW_USER_REWARD"
case ruleTypeInviteeRecharge:
return "INVITED_USER_FIRST_RECHARGE_REWARD"
case ruleTypeInviterTotalRecharge:
return "CUMULATIVE_RECHARGE_REWARDS"
default:
return "INVITE_USER_REWARDS"
}
}
func mapPropsOrigin(ruleType string) string {
switch ruleType {
case ruleTypeInviterTotalRecharge:
return "CUMULATIVE_RECHARGE_REWARDS"
default:
return "INVITE_USER_REWARDS"
}
}