303 lines
8.6 KiB
Go
303 lines
8.6 KiB
Go
package invite
|
|
|
|
import (
|
|
"chatapp3-golang/internal/model"
|
|
"chatapp3-golang/internal/utils"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// HandleRechargeSuccess 处理被邀请人的充值成功事件,并更新进度与触发奖励。
|
|
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())
|
|
}
|
|
periodKey, _, _ := thirtyDayPeriodInfoAt(payTime, normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime)
|
|
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 := utils.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, periodKey, 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: periodKey,
|
|
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
|
|
}
|
|
|
|
// crossedInviteeRechargeRules 找出本次充值跨过的被邀请人充值奖励规则。
|
|
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
|
|
}
|
|
|
|
// parseRewardedRuleIDs 解析已奖励规则 ID 字符串。
|
|
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
|
|
}
|
|
|
|
// formatRewardedRuleIDs 把规则 ID 集合格式化成稳定字符串。
|
|
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, ",")
|
|
}
|
|
|
|
// parseFlexibleTime 宽松解析回调里的时间字段。
|
|
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")
|
|
}
|
|
}
|
|
|
|
// fromUnixNumber 把秒或毫秒级时间戳转换成时间。
|
|
func fromUnixNumber(value int64) time.Time {
|
|
if value > 1_000_000_000_000 {
|
|
return time.UnixMilli(value)
|
|
}
|
|
return time.Unix(value, 0)
|
|
}
|