package invitation import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "math" "strconv" "strings" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/wallet" "gorm.io/gorm" "gorm.io/gorm/clause" ) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidInviteCode = errors.New("invalid invite code") ErrSelfInviteNotAllowed = errors.New("self invite not allowed") ErrAlreadyInvited = errors.New("already invited") ErrInviterNotVerified = errors.New("inviter not realname verified") ErrConfigNotFound = errors.New("invitation rebate config not found") ) const ( StatusPending = "pending" StatusActive = "active" StatusInvalid = "invalid" CycleFirstMonth = "first_month" CycleSecondMonth = "second_month" CycleSubsequent = "subsequent" DefaultFirstMonthRate = 0.6000 DefaultSecondMonthRate = 0.3000 DefaultSubsequentRate = 0.1500 // 返利来源单据类型:source=biz_type 用于区分返利由哪类单据产生。 RebateSourceRentalOrder = "rental_order" RebateSourcePickup = "pickup" ) type Repository struct { db *gorm.DB } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } // GenerateUniqueInviteCode 生成 7 位小写字母数字邀请码(防碰撞重试) func (r *Repository) GenerateUniqueInviteCode(ctx context.Context) (string, error) { if r == nil || r.db == nil { return "", ErrDependencyUnavailable } for i := 0; i < 10; i++ { bytes := make([]byte, 4) if _, err := rand.Read(bytes); err != nil { return "", err } code := hex.EncodeToString(bytes)[:7] // 7 位如 0d76234 var count int64 if err := r.db.WithContext(ctx).Model(&model.User{}).Where("invite_code = ?", code).Count(&count).Error; err != nil { return "", err } if count == 0 { return code, nil } } return "", errors.New("failed to generate unique invite code") } // EnsureUserInviteCode 为已实名认证的用户确保拥有邀请码 func (r *Repository) EnsureUserInviteCode(ctx context.Context, userID uint64) (string, error) { if r == nil || r.db == nil { return "", ErrDependencyUnavailable } var user model.User if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil { return "", err } if user.InviteCode != nil && *user.InviteCode != "" { return *user.InviteCode, nil } code, err := r.GenerateUniqueInviteCode(ctx) if err != nil { return "", err } if err := r.db.WithContext(ctx).Model(&user).Update("invite_code", code).Error; err != nil { return "", err } return code, nil } // OnUserRealnameVerified 用户实名认证通过时的事务处理: // 1. 若自身无邀请码,自动生成专属邀请码; // 2. 若作为被邀请人,将待生效绑定关系置为 active,以当前时刻记录 verified_at(首月计时起点)。 func (r *Repository) OnUserRealnameVerified(ctx context.Context, userID uint64) error { if r == nil || r.db == nil { return ErrDependencyUnavailable } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var user model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { return err } now := time.Now() // 1. 生成自身邀请码 if user.InviteCode == nil || *user.InviteCode == "" { for i := 0; i < 10; i++ { bytes := make([]byte, 4) if _, err := rand.Read(bytes); err != nil { return err } code := hex.EncodeToString(bytes)[:7] var count int64 if err := tx.Model(&model.User{}).Where("invite_code = ?", code).Count(&count).Error; err != nil { return err } if count == 0 { user.InviteCode = &code break } } } // 2. 检查是否有作为被邀请人的挂起记录 var invitation model.UserInvitation err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("invitee_id = ?", userID). First(&invitation).Error if err == nil { if invitation.Status == StatusPending { invitation.Status = StatusActive invitation.VerifiedAt = &now if err := tx.Save(&invitation).Error; err != nil { return err } user.InviteVerifiedAt = &now } } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } return tx.Save(&user).Error }) } // GetInviteCodeInfo 查询邀请码对应邀请人简要信息(用于邀请注册落地页) func (r *Repository) GetInviteCodeInfo(ctx context.Context, code string) (*InviteCodeInfoDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } cleanCode := strings.TrimSpace(code) if cleanCode == "" { return &InviteCodeInfoDTO{Valid: false}, nil } var user model.User if err := r.db.WithContext(ctx).Where("invite_code = ?", cleanCode).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return &InviteCodeInfoDTO{Valid: false, InviteCode: cleanCode}, nil } return nil, err } if user.RealnameStatus != "verified" || user.Status != "active" { return &InviteCodeInfoDTO{Valid: false, InviteCode: cleanCode}, nil } nickname := user.Nickname if nickname == "" && len(user.Phone) >= 11 { nickname = "用户" + user.Phone[7:] } return &InviteCodeInfoDTO{ Valid: true, InviteCode: cleanCode, InviterNickname: nickname, InviterAvatar: user.AvatarURL, }, nil } // BindInvitation 新用户注册时绑定邀请关系 func (r *Repository) BindInvitation(ctx context.Context, inviteeID uint64, code string, siteID uint64, siteCode ...string) error { if r == nil || r.db == nil { return ErrDependencyUnavailable } cleanCode := strings.TrimSpace(code) if cleanCode == "" { return nil } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if siteID == 0 && len(siteCode) > 0 && strings.TrimSpace(siteCode[0]) != "" { var ds model.DistributionSite if err := tx.Where("site_code = ?", strings.TrimSpace(siteCode[0])).First(&ds).Error; err == nil { siteID = ds.ID } } var inviter model.User if err := tx.Where("invite_code = ?", cleanCode).First(&inviter).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrInvalidInviteCode } return err } if inviter.ID == inviteeID { return ErrSelfInviteNotAllowed } if inviter.RealnameStatus != "verified" { return ErrInviterNotVerified } var count int64 if err := tx.Model(&model.UserInvitation{}).Where("invitee_id = ?", inviteeID).Count(&count).Error; err != nil { return err } if count > 0 { return ErrAlreadyInvited } var invitee model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&invitee, inviteeID).Error; err != nil { return err } status := StatusPending var verifiedAt *time.Time if invitee.RealnameStatus == "verified" { status = StatusActive now := time.Now() verifiedAt = &now invitee.InviteVerifiedAt = &now } invitee.InvitedByID = inviter.ID if err := tx.Save(&invitee).Error; err != nil { return err } invitation := model.UserInvitation{ InviterID: inviter.ID, InviteeID: inviteeID, DistributionSiteID: siteID, InviteCode: cleanCode, Status: status, VerifiedAt: verifiedAt, } return tx.Create(&invitation).Error }) } // GetRebateConfig 获取指定站点的返利比例配置(优先站点,回退至主站默认) func (r *Repository) GetRebateConfig(ctx context.Context, siteID uint64) (*model.InvitationRebateConfig, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } var cfg model.InvitationRebateConfig if siteID > 0 { err := r.db.WithContext(ctx).Where("distribution_site_id = ?", siteID).First(&cfg).Error if err == nil { return &cfg, nil } } // 回退主站配置 (distribution_site_id = 0) err := r.db.WithContext(ctx).Where("distribution_site_id = 0").First(&cfg).Error if err == nil { return &cfg, nil } if errors.Is(err, gorm.ErrRecordNotFound) { // 返回默认硬编码 return &model.InvitationRebateConfig{ DistributionSiteID: 0, FirstMonthRate: DefaultFirstMonthRate, SecondMonthRate: DefaultSecondMonthRate, SubsequentRate: DefaultSubsequentRate, Enabled: true, }, nil } return nil, err } // UpdateRebateConfig 更新站点返利比例配置 func (r *Repository) UpdateRebateConfig(ctx context.Context, siteID uint64, req UpdateConfigRequest) (*model.InvitationRebateConfig, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } var cfg model.InvitationRebateConfig err := r.db.WithContext(ctx).Where("distribution_site_id = ?", siteID).First(&cfg).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } if errors.Is(err, gorm.ErrRecordNotFound) { cfg = model.InvitationRebateConfig{ DistributionSiteID: siteID, FirstMonthRate: req.FirstMonthRate, SecondMonthRate: req.SecondMonthRate, SubsequentRate: req.SubsequentRate, Enabled: true, } if req.Enabled != nil { cfg.Enabled = *req.Enabled } if err := r.db.WithContext(ctx).Select("DistributionSiteID", "FirstMonthRate", "SecondMonthRate", "SubsequentRate", "Enabled").Create(&cfg).Error; err != nil { return nil, err } return &cfg, nil } cfg.FirstMonthRate = req.FirstMonthRate cfg.SecondMonthRate = req.SecondMonthRate cfg.SubsequentRate = req.SubsequentRate if req.Enabled != nil { cfg.Enabled = *req.Enabled } if err := r.db.WithContext(ctx).Select("FirstMonthRate", "SecondMonthRate", "SubsequentRate", "Enabled").Save(&cfg).Error; err != nil { return nil, err } return &cfg, nil } // CalculateRebateRate 根据被邀请人实名生效时间和配置计算适用的返利比例与阶段 func CalculateRebateRate(verifiedAt time.Time, now time.Time, cfg *model.InvitationRebateConfig) (float64, string, int) { days := int(now.Sub(verifiedAt).Hours() / 24) if days < 0 { days = 0 } firstRate := DefaultFirstMonthRate secondRate := DefaultSecondMonthRate subsequentRate := DefaultSubsequentRate if cfg != nil { firstRate = cfg.FirstMonthRate secondRate = cfg.SecondMonthRate subsequentRate = cfg.SubsequentRate } if days <= 30 { return firstRate, CycleFirstMonth, days } else if days <= 60 { return secondRate, CycleSecondMonth, days } return subsequentRate, CycleSubsequent, days } // ProcessOrderInvitationRebate 在订单终态结算时计算并分发买卖双边邀请返利。 func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.DB, orderID uint64) error { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { return err } if order.Status != "completed" && order.SettlementStatus != "settled" { return nil } orderGrossProfitCent := order.RentAmountCent - order.OwnerRentAmountCent if orderGrossProfitCent <= 0 { return nil } // 判定跨站或分销拆分:查 distribution_order_parties var parties []model.DistributionOrderParty _ = tx.Where("order_id = ?", order.ID).Find(&parties).Error var distributorReceiveCent int64 var platformSellCent int64 var distributorSiteID uint64 for _, p := range parties { amount := p.ProfitAmountCent if amount <= 0 { amount = p.IncomeAmountCent } if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" { distributorReceiveCent += amount if p.PartyType == "site" && p.PartyID > 0 { distributorSiteID = p.PartyID } else if p.SiteID > 0 { distributorSiteID = p.SiteID } } else if p.PartyRole == "platform_sell" || p.PartyRole == "service_fee" || p.PartyRole == "platform_receive" || p.PartyRole == "distributor_sell" { platformSellCent += amount } } now := time.Now() // 返利归因站点(统一规则):成本一律由「订单所属站点」承担,并使用该站点自己的返利配置。 // 订单未记录站点但存在分销拆分时,回退到拆分方站点(钱实际归它), // 避免再次出现「主站替分站订单买单」的倒贴。 orderSiteID := distributorSiteID if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { orderSiteID = *order.DistributionSiteID } // 1. 买家端处理(租客 RenterID) var buyerInvitation model.UserInvitation if err := tx.Where("invitee_id = ? AND status = ?", order.RenterID, StatusActive).First(&buyerInvitation).Error; err == nil { if buyerInvitation.VerifiedAt != nil { var inviter model.User if err := tx.First(&inviter, buyerInvitation.InviterID).Error; err == nil && inviter.RealnameStatus == "verified" && inviter.ID != order.RenterID { // 买家端基数:有分销售卖拆分按 platformSellCent,否则按整单毛利对半拆;归因站点统一取 orderSiteID var buyerBasisCent int64 if platformSellCent > 0 { buyerBasisCent = int64(math.Floor(float64(platformSellCent) / 2.0)) } else { buyerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0)) } if buyerBasisCent > 0 { cfg, _ := r.GetRebateConfig(ctx, orderSiteID) if cfg == nil || cfg.Enabled { rate, cycle, days := CalculateRebateRate(*buyerInvitation.VerifiedAt, now, cfg) rebateCent := int64(math.Round(float64(buyerBasisCent) * rate)) if rebateCent > 0 { ledgerNo := fmt.Sprintf("REB-B-%s-%d", order.OrderNo, buyerInvitation.InviterID) ledger := model.InvitationRebateLedger{ OrderID: order.ID, OrderNo: order.OrderNo, DistributionSiteID: orderSiteID, InviterID: buyerInvitation.InviterID, InviteeID: order.RenterID, TradeRole: "buyer", OrderProfitBasisCent: orderGrossProfitCent, SingleSideBasisCent: buyerBasisCent, RateApplied: rate, DaysSinceVerified: days, CycleStage: cycle, RebateAmountCent: rebateCent, WalletLedgerNo: ledgerNo, Status: "settled", SettledAt: now, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 { _ = wallet.AppendEntries(tx, wallet.Entry{ UserID: buyerInvitation.InviterID, DistributionSiteID: orderSiteID, Direction: "in", AmountCent: rebateCent, BalanceType: "available", BizType: "invite_rebate", BizNo: order.OrderNo, Remark: fmt.Sprintf("邀请买家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle, rate)), }) } } } } } } } // 2. 卖家端处理(号主 OwnerID) var sellerInvitation model.UserInvitation if err := tx.Where("invitee_id = ? AND status = ?", order.OwnerID, StatusActive).First(&sellerInvitation).Error; err == nil { if sellerInvitation.VerifiedAt != nil { var inviter model.User if err := tx.First(&inviter, sellerInvitation.InviterID).Error; err == nil && inviter.RealnameStatus == "verified" && inviter.ID != order.OwnerID { // 卖家端基数:有分站收号拆分按 distributorReceiveCent,否则按整单毛利对半拆;归因站点统一取 orderSiteID var sellerBasisCent int64 if distributorReceiveCent > 0 { sellerBasisCent = int64(math.Floor(float64(distributorReceiveCent) / 2.0)) } else { sellerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0)) } if sellerBasisCent > 0 { cfg, _ := r.GetRebateConfig(ctx, orderSiteID) if cfg == nil || cfg.Enabled { rate, cycle, days := CalculateRebateRate(*sellerInvitation.VerifiedAt, now, cfg) rebateCent := int64(math.Round(float64(sellerBasisCent) * rate)) if rebateCent > 0 { ledgerNo := fmt.Sprintf("REB-S-%s-%d", order.OrderNo, sellerInvitation.InviterID) ledger := model.InvitationRebateLedger{ OrderID: order.ID, OrderNo: order.OrderNo, DistributionSiteID: orderSiteID, InviterID: sellerInvitation.InviterID, InviteeID: order.OwnerID, TradeRole: "seller", OrderProfitBasisCent: orderGrossProfitCent, SingleSideBasisCent: sellerBasisCent, RateApplied: rate, DaysSinceVerified: days, CycleStage: cycle, RebateAmountCent: rebateCent, WalletLedgerNo: ledgerNo, Status: "settled", SettledAt: now, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 { _ = wallet.AppendEntries(tx, wallet.Entry{ UserID: sellerInvitation.InviterID, DistributionSiteID: orderSiteID, Direction: "in", AmountCent: rebateCent, BalanceType: "available", BizType: "invite_rebate", BizNo: order.OrderNo, Remark: fmt.Sprintf("邀请卖家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle, rate)), }) } } } } } } } return nil } // cycleLabel 生成返利周期的展示文案。 // 比例必须取本次实际生效的 rate(后台/分销站可改配置), // 不能写死 60%/30%/15%,否则钱包流水备注会和实际比例不一致。 func cycleLabel(cycle string, rate float64) string { percent := formatRebateRate(rate) switch cycle { case CycleFirstMonth: return "首月奖励 " + percent case CycleSecondMonth: return "次月奖励 " + percent case CycleSubsequent: return "长期奖励 " + percent default: return "返利 " + percent } } // formatRebateRate 把比例(0.6)格式化为展示文案(60%)。 // 先按配置列精度 decimal(6,4) 规整,避免浮点尾差显示成 59.999999%。 func formatRebateRate(rate float64) string { percent := math.Round(rate*10000) / 100 return strconv.FormatFloat(percent, 'f', -1, 64) + "%" } // SellerSideRebateInput 单边(卖家/服务提供方)返利结算入参。 // // 租赁订单与线下提号共用同一套规则:按被邀请人实名生效时刻计算比例阶梯、 // 用 (source, order_id, trade_role) 唯一键保证幂等、入账到邀请人钱包可用余额。 // 差异只在来源标识与收益基数,因此统一走这里,避免两处各写一遍算钱逻辑。 type SellerSideRebateInput struct { Source string // 来源单据类型:RebateSourceRentalOrder / RebateSourcePickup SourceID uint64 // 来源单据 ID SourceNo string // 来源单据编号(写入返利流水与钱包流水备注) SellerID uint64 // 交易人(被邀请人本人) GrossBasisCent int64 // 来源单据整体收益基数(分) BasisCent int64 // 单边收益基数(分),通常为 GrossBasisCent 对半 SiteID uint64 // 成本分担站点(0=主站) RateReferenceAt time.Time // 比例阶梯的基准时刻(单据完成时刻);零值取当前时间 SettledAt time.Time // 实际入账时刻;零值取当前时间 RemarkPrefix string // 钱包流水备注前缀 } // AwardSellerSideRebateTx 在事务内为「交易人的邀请人」发放单边返利(幂等)。 // 未命中生效邀请关系、邀请人未实名、返利配置关闭或金额为 0 时不写任何记录并返回 0。 func AwardSellerSideRebateTx(tx *gorm.DB, in SellerSideRebateInput) (int64, error) { if tx == nil || in.SellerID == 0 || in.SourceID == 0 || in.BasisCent <= 0 { return 0, nil } ctx := context.Background() var invitation model.UserInvitation if err := tx.WithContext(ctx). Where("invitee_id = ? AND status = ?", in.SellerID, StatusActive). First(&invitation).Error; err != nil { return 0, nil } if invitation.VerifiedAt == nil { return 0, nil } var inviter model.User if err := tx.WithContext(ctx).First(&inviter, invitation.InviterID).Error; err != nil { return 0, nil } if inviter.RealnameStatus != "verified" || inviter.ID == in.SellerID { return 0, nil } repo := NewRepository(tx) cfg, _ := repo.GetRebateConfig(ctx, in.SiteID) if cfg != nil && !cfg.Enabled { return 0, nil } referenceAt := in.RateReferenceAt if referenceAt.IsZero() { referenceAt = time.Now() } settledAt := in.SettledAt if settledAt.IsZero() { settledAt = time.Now() } rate, cycle, days := CalculateRebateRate(*invitation.VerifiedAt, referenceAt, cfg) rebateCent := int64(math.Round(float64(in.BasisCent) * rate)) if rebateCent <= 0 { return 0, nil } grossBasis := in.GrossBasisCent if grossBasis <= 0 { grossBasis = in.BasisCent } ledger := model.InvitationRebateLedger{ OrderID: in.SourceID, OrderNo: in.SourceNo, Source: in.Source, DistributionSiteID: in.SiteID, InviterID: invitation.InviterID, InviteeID: in.SellerID, TradeRole: "seller", OrderProfitBasisCent: grossBasis, SingleSideBasisCent: in.BasisCent, RateApplied: rate, DaysSinceVerified: days, CycleStage: cycle, RebateAmountCent: rebateCent, WalletLedgerNo: fmt.Sprintf("REB-S-%s-%d", in.SourceNo, invitation.InviterID), Status: "settled", SettledAt: settledAt, } if err := tx.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err != nil { return 0, err } if ledger.ID == 0 { // 同来源同角色已有返利记录:幂等跳过(重复调用、补发脚本重跑均安全)。 return 0, nil } if err := wallet.AppendEntries(tx, wallet.Entry{ UserID: invitation.InviterID, DistributionSiteID: in.SiteID, Direction: "in", AmountCent: rebateCent, BalanceType: "available", BizType: "invite_rebate", BizNo: in.SourceNo, Remark: fmt.Sprintf("%s(%s,%s)", in.RemarkPrefix, in.SourceNo, cycleLabel(cycle, rate)), }); err != nil { return 0, err } return rebateCent, nil } // GetUserSummary 获取用户个人中心的邀请返利数据看板 func (r *Repository) GetUserSummary(ctx context.Context, userID uint64, siteID uint64) (*UserInvitationSummaryDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } var user model.User if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil { return nil, err } cfg, _ := r.GetRebateConfig(ctx, siteID) firstRate := DefaultFirstMonthRate secondRate := DefaultSecondMonthRate subsequentRate := DefaultSubsequentRate if cfg != nil { firstRate = cfg.FirstMonthRate secondRate = cfg.SecondMonthRate subsequentRate = cfg.SubsequentRate } code := "" hasCode := false if user.InviteCode != nil && *user.InviteCode != "" { code = *user.InviteCode hasCode = true } else if user.RealnameStatus == "verified" { c, err := r.EnsureUserInviteCode(ctx, userID) if err == nil { code = c hasCode = true } } var totalCount int64 var activeCount int64 var pendingCount int64 _ = r.db.WithContext(ctx).Model(&model.UserInvitation{}).Where("inviter_id = ?", userID).Count(&totalCount).Error _ = r.db.WithContext(ctx).Model(&model.UserInvitation{}).Where("inviter_id = ? AND status = ?", userID, StatusActive).Count(&activeCount).Error _ = r.db.WithContext(ctx).Model(&model.UserInvitation{}).Where("inviter_id = ? AND status = ?", userID, StatusPending).Count(&pendingCount).Error var totalRebateCent int64 _ = r.db.WithContext(ctx).Model(&model.InvitationRebateLedger{}). Where("inviter_id = ? AND status = 'settled'", userID). Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(&totalRebateCent).Error now := time.Now() monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) var currentMonthEstCent int64 _ = r.db.WithContext(ctx).Model(&model.InvitationRebateLedger{}). Where("inviter_id = ? AND status = 'settled' AND settled_at >= ?", userID, monthStart). Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(¤tMonthEstCent).Error return &UserInvitationSummaryDTO{ HasInviteCode: hasCode, InviteCode: code, InviteLink: fmt.Sprintf("/i/%s", code), IsRealnameVerified: user.RealnameStatus == "verified", TotalInvitedCount: totalCount, ActiveInvitedCount: activeCount, PendingInvitedCount: pendingCount, TotalRebateCent: totalRebateCent, CurrentMonthEstCent: currentMonthEstCent, FirstMonthRate: firstRate, SecondMonthRate: secondRate, SubsequentRate: subsequentRate, }, nil } // ListMyInvitedUsers 获取我邀请的好友列表 func (r *Repository) ListMyInvitedUsers(ctx context.Context, userID uint64, page, pageSize int) ([]InvitedUserRecordDTO, int64, error) { if r == nil || r.db == nil { return nil, 0, ErrDependencyUnavailable } if page < 1 { page = 1 } if pageSize < 1 || pageSize > 50 { pageSize = 20 } var total int64 db := r.db.WithContext(ctx).Model(&model.UserInvitation{}).Where("inviter_id = ?", userID) if err := db.Count(&total).Error; err != nil { return nil, 0, err } var rows []model.UserInvitation offset := (page - 1) * pageSize if err := db.Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { return nil, 0, err } result := make([]InvitedUserRecordDTO, 0, len(rows)) now := time.Now() for _, row := range rows { var user model.User _ = r.db.WithContext(ctx).First(&user, row.InviteeID).Error maskedPhone := "" if len(user.Phone) >= 11 { maskedPhone = user.Phone[:3] + "****" + user.Phone[7:] } else if user.Phone != "" { maskedPhone = user.Phone } stage := "unverified" days := 0 if row.VerifiedAt != nil { days = int(now.Sub(*row.VerifiedAt).Hours() / 24) if days <= 30 { stage = CycleFirstMonth } else if days <= 60 { stage = CycleSecondMonth } else { stage = CycleSubsequent } } var earnedCent int64 _ = r.db.WithContext(ctx).Model(&model.InvitationRebateLedger{}). Where("inviter_id = ? AND invitee_id = ? AND status = 'settled'", userID, row.InviteeID). Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(&earnedCent).Error result = append(result, InvitedUserRecordDTO{ ID: row.ID, InviteeID: row.InviteeID, MaskedPhone: maskedPhone, Nickname: user.Nickname, AvatarURL: user.AvatarURL, Status: row.Status, CycleStage: stage, DaysSinceVerified: days, VerifiedAt: row.VerifiedAt, RegisteredAt: row.CreatedAt, TotalEarnedFromCent: earnedCent, }) } return result, total, nil } // ListMyRebates 获取我的返利流水列表 func (r *Repository) ListMyRebates(ctx context.Context, userID uint64, page, pageSize int) ([]RebateLedgerDTO, int64, error) { if r == nil || r.db == nil { return nil, 0, ErrDependencyUnavailable } if page < 1 { page = 1 } if pageSize < 1 || pageSize > 50 { pageSize = 20 } var total int64 db := r.db.WithContext(ctx).Model(&model.InvitationRebateLedger{}).Where("inviter_id = ?", userID) if err := db.Count(&total).Error; err != nil { return nil, 0, err } var rows []model.InvitationRebateLedger offset := (page - 1) * pageSize if err := db.Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { return nil, 0, err } result := make([]RebateLedgerDTO, 0, len(rows)) for _, row := range rows { var user model.User _ = r.db.WithContext(ctx).First(&user, row.InviteeID).Error maskedPhone := "" if len(user.Phone) >= 11 { maskedPhone = user.Phone[:3] + "****" + user.Phone[7:] } result = append(result, RebateLedgerDTO{ ID: row.ID, OrderID: row.OrderID, OrderNo: row.OrderNo, InviteeMaskedPhone: maskedPhone, InviteeNickname: user.Nickname, TradeRole: row.TradeRole, SingleSideBasisCent: row.SingleSideBasisCent, RateApplied: row.RateApplied, CycleStage: row.CycleStage, RebateAmountCent: row.RebateAmountCent, SettledAt: row.SettledAt, }) } return result, total, nil } // ListAdminInvitations 管理后台查询邀请关系 func (r *Repository) ListAdminInvitations(ctx context.Context, query AdminInvitationQuery) ([]map[string]any, int64, error) { if r == nil || r.db == nil { return nil, 0, ErrDependencyUnavailable } if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 || query.PageSize > 100 { query.PageSize = 20 } db := r.db.WithContext(ctx).Table("user_invitations ui"). Joins("LEFT JOIN users inviter ON inviter.id = ui.inviter_id"). Joins("LEFT JOIN users invitee ON invitee.id = ui.invitee_id") if p := strings.TrimSpace(query.Phone); p != "" { like := "%" + p + "%" db = db.Where("(inviter.phone LIKE ? OR invitee.phone LIKE ?)", like, like) } if p := strings.TrimSpace(query.InviterPhone); p != "" { db = db.Where("inviter.phone LIKE ?", "%"+p+"%") } if p := strings.TrimSpace(query.InviteePhone); p != "" { db = db.Where("invitee.phone LIKE ?", "%"+p+"%") } if code := strings.TrimSpace(query.InviteCode); code != "" { db = db.Where("ui.invite_code LIKE ?", "%"+code+"%") } if s := strings.TrimSpace(query.Status); s != "" { db = db.Where("ui.status = ?", s) } if query.DistributionSiteID != nil { db = db.Where("ui.distribution_site_id = ?", *query.DistributionSiteID) } if query.InviterID > 0 { db = db.Where("ui.inviter_id = ?", query.InviterID) } if query.InviteeID > 0 { db = db.Where("ui.invitee_id = ?", query.InviteeID) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } type rawRow struct { ID uint64 `gorm:"column:id"` InviterID uint64 `gorm:"column:inviter_id"` InviterPhone string `gorm:"column:inviter_phone"` InviterNickname string `gorm:"column:inviter_nickname"` InviteeID uint64 `gorm:"column:invitee_id"` InviteePhone string `gorm:"column:invitee_phone"` InviteeNickname string `gorm:"column:invitee_nickname"` DistributionSiteID uint64 `gorm:"column:distribution_site_id"` InviteCode string `gorm:"column:invite_code"` Status string `gorm:"column:status"` VerifiedAt *time.Time `gorm:"column:verified_at"` CreatedAt time.Time `gorm:"column:created_at"` } var rows []rawRow offset := (query.Page - 1) * query.PageSize err := db.Select("ui.id, ui.inviter_id, inviter.phone as inviter_phone, inviter.nickname as inviter_nickname, ui.invitee_id, invitee.phone as invitee_phone, invitee.nickname as invitee_nickname, ui.distribution_site_id, ui.invite_code, ui.status, ui.verified_at, ui.created_at"). Order("ui.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error if err != nil { return nil, 0, err } list := make([]map[string]any, 0, len(rows)) for _, row := range rows { list = append(list, map[string]any{ "id": row.ID, "inviter_id": row.InviterID, "inviter_phone": row.InviterPhone, "inviter_nickname": row.InviterNickname, "invitee_id": row.InviteeID, "invitee_phone": row.InviteePhone, "invitee_nickname": row.InviteeNickname, "distribution_site_id": row.DistributionSiteID, "invite_code": row.InviteCode, "status": row.Status, "verified_at": row.VerifiedAt, "created_at": row.CreatedAt, }) } return list, total, nil } // ListAdminRebateLedgers 管理后台查询返利流水 func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) { if r == nil || r.db == nil { return nil, 0, ErrDependencyUnavailable } if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 || query.PageSize > 100 { query.PageSize = 20 } db := r.db.WithContext(ctx).Table("invitation_rebate_ledgers irl"). Joins("LEFT JOIN users inviter ON inviter.id = irl.inviter_id"). Joins("LEFT JOIN users invitee ON invitee.id = irl.invitee_id") if o := strings.TrimSpace(query.OrderNo); o != "" { db = db.Where("irl.order_no LIKE ?", "%"+o+"%") } if p := strings.TrimSpace(query.InviterPhone); p != "" { db = db.Where("inviter.phone LIKE ?", "%"+p+"%") } if p := strings.TrimSpace(query.InviteePhone); p != "" { db = db.Where("invitee.phone LIKE ?", "%"+p+"%") } if t := strings.TrimSpace(query.TradeRole); t != "" { db = db.Where("irl.trade_role = ?", t) } if query.DistributionSiteID != nil { db = db.Where("irl.distribution_site_id = ?", *query.DistributionSiteID) } if query.InviterID > 0 { db = db.Where("irl.inviter_id = ?", query.InviterID) } if query.InviteeID > 0 { db = db.Where("irl.invitee_id = ?", query.InviteeID) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } type rawLedger struct { ID uint64 `gorm:"column:id"` OrderID uint64 `gorm:"column:order_id"` OrderNo string `gorm:"column:order_no"` Source string `gorm:"column:source"` DistributionSiteID uint64 `gorm:"column:distribution_site_id"` InviterID uint64 `gorm:"column:inviter_id"` InviterPhone string `gorm:"column:inviter_phone"` InviteeID uint64 `gorm:"column:invitee_id"` InviteePhone string `gorm:"column:invitee_phone"` TradeRole string `gorm:"column:trade_role"` OrderProfitBasisCent int64 `gorm:"column:order_profit_basis_cent"` SingleSideBasisCent int64 `gorm:"column:single_side_basis_cent"` RateApplied float64 `gorm:"column:rate_applied"` DaysSinceVerified int `gorm:"column:days_since_verified"` CycleStage string `gorm:"column:cycle_stage"` RebateAmountCent int64 `gorm:"column:rebate_amount_cent"` Status string `gorm:"column:status"` SettledAt time.Time `gorm:"column:settled_at"` } var rows []rawLedger offset := (query.Page - 1) * query.PageSize err := db.Select("irl.id, irl.order_id, irl.order_no, irl.source, irl.distribution_site_id, irl.inviter_id, inviter.phone as inviter_phone, irl.invitee_id, invitee.phone as invitee_phone, irl.trade_role, irl.order_profit_basis_cent, irl.single_side_basis_cent, irl.rate_applied, irl.days_since_verified, irl.cycle_stage, irl.rebate_amount_cent, irl.status, irl.settled_at"). Order("irl.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error if err != nil { return nil, 0, err } list := make([]map[string]any, 0, len(rows)) for _, row := range rows { list = append(list, map[string]any{ "id": row.ID, "order_id": row.OrderID, "order_no": row.OrderNo, "source": row.Source, "distribution_site_id": row.DistributionSiteID, "inviter_id": row.InviterID, "inviter_phone": row.InviterPhone, "invitee_id": row.InviteeID, "invitee_phone": row.InviteePhone, "trade_role": row.TradeRole, "order_profit_basis_cent": row.OrderProfitBasisCent, "single_side_basis_cent": row.SingleSideBasisCent, "rate_applied": row.RateApplied, "days_since_verified": row.DaysSinceVerified, "cycle_stage": row.CycleStage, "rebate_amount_cent": row.RebateAmountCent, "status": row.Status, "settled_at": row.SettledAt, }) } return list, total, nil } // ProcessOrderInvitationRebateTx 便捷事务入口,供订单结账完成终态时调用 func ProcessOrderInvitationRebateTx(tx *gorm.DB, orderID uint64) error { if tx == nil || orderID == 0 { return nil } repo := NewRepository(tx) return repo.ProcessOrderInvitationRebate(context.Background(), tx, orderID) } // OnUserRealnameVerifiedTx 便捷事务入口,供实名认证通过时调用 func OnUserRealnameVerifiedTx(tx *gorm.DB, userID uint64) error { if tx == nil || userID == 0 { return nil } repo := NewRepository(tx) return repo.OnUserRealnameVerified(context.Background(), userID) }