Files
hfb_sys/backend/internal/modules/invitation/repository.go
T
yml2213 984433f866 feat(invitation): 实现邀请返利系统、专属注册落地页与分成管理
- 数据库与数据模型:新增 migration 000081,支持用户专属邀请码、被邀请人绑定与实名时间戳;新增邀请返利配置表与结算明细表
- 业务与结算逻辑:实现首月 60%、次月 30%、往后 15% 阶梯返利算法;买卖双向独立寻源并根据订单平台利润 50% 单边基准核算;与终态订单结算和钱包可用余额强幂等联动
- 分销站点支持:支持主站与加盟分站独立配置返利比例;跨站交易按照收号端与售卖端独立出资,资金互不侵蚀
- 前端交互与入口:
  - 新增专属短链注册落地页 /i/:code,自动带入并锁定邀请码,引导实名认证
  - 个人中心与移动端新增邀请返利看板(专属码、推广链接、统计面板、好友记录与返利流水明细)
  - 管理后台新增邀请返利管理(比例配置、邀请关系与返利流水审计)
- 验证方式:
  - 通过 repository_test.go 单元测试(阶梯区间、实名激活、跨站多方分流验证)
  - 通过 npm run format、npm run check 与 scripts/check.sh 全量门禁校验
2026-10-10 12:48:59 +08:00

889 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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).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).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(&currentMonthEstCent).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)
}
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 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)
}
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)
}