1528 lines
47 KiB
Go
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"
|
|
}
|
|
}
|