package invitation import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "math" "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 ) 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() // 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,否则按整单毛利对半拆 var buyerBasisCent int64 var buyerSiteID uint64 if platformSellCent > 0 { buyerBasisCent = int64(math.Floor(float64(platformSellCent) / 2.0)) buyerSiteID = 0 } else { buyerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0)) if order.DistributionSiteID != nil { buyerSiteID = *order.DistributionSiteID } } if buyerBasisCent > 0 { cfg, _ := r.GetRebateConfig(ctx, buyerSiteID) 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: buyerSiteID, 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: buyerSiteID, Direction: "in", AmountCent: rebateCent, BalanceType: "available", BizType: "invite_rebate", BizNo: order.OrderNo, Remark: fmt.Sprintf("邀请买家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle)), }) } } } } } } } // 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(从分站出),否则按整单毛利对半拆 var sellerBasisCent int64 var sellerSiteID uint64 if distributorReceiveCent > 0 { sellerBasisCent = int64(math.Floor(float64(distributorReceiveCent) / 2.0)) sellerSiteID = distributorSiteID } else { sellerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0)) if order.DistributionSiteID != nil { sellerSiteID = *order.DistributionSiteID } } if sellerBasisCent > 0 { cfg, _ := r.GetRebateConfig(ctx, sellerSiteID) 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: sellerSiteID, 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: sellerSiteID, Direction: "in", AmountCent: rebateCent, BalanceType: "available", BizType: "invite_rebate", BizNo: order.OrderNo, Remark: fmt.Sprintf("邀请卖家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle)), }) } } } } } } } return nil } func cycleLabel(cycle string) string { switch cycle { case CycleFirstMonth: return "首月高额奖励 60%" case CycleSecondMonth: return "次月奖励 30%" default: return "长期奖励 15%" } } // 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.InviterPhone); p != "" { db = db.Where("inviter.phone = ?", p) } if p := strings.TrimSpace(query.InviteePhone); p != "" { db = db.Where("invitee.phone = ?", p) } 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 = ?", o) } if p := strings.TrimSpace(query.InviterPhone); p != "" { db = db.Where("inviter.phone = ?", p) } if p := strings.TrimSpace(query.InviteePhone); p != "" { db = db.Where("invitee.phone = ?", 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"` 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.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, "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) }