2026-04-27 19:12:00 +08:00

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