Files
hfb_sys/backend/internal/modules/invitation/repository.go
T

1120 lines
39 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"
"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
}
// OrderRebateOptions 允许调用方覆盖订单毛利基数与返利计算时刻。
// 仲裁按实际消耗结算后,订单快照金额仍是原预收金额,必须传入实际平台毛利。
type OrderRebateOptions struct {
GrossProfitCent *int64
RateReferenceAt time.Time
SettledAt time.Time
}
// ProcessOrderInvitationRebate 在订单终态结算时计算并分发买卖双边邀请返利。
func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.DB, orderID uint64) error {
return r.processOrderInvitationRebate(ctx, tx, orderID, OrderRebateOptions{})
}
func (r *Repository) processOrderInvitationRebate(ctx context.Context, tx *gorm.DB, orderID uint64, opts OrderRebateOptions) 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 opts.GrossProfitCent != nil {
orderGrossProfitCent = *opts.GrossProfitCent
} else {
// 正常结账、仲裁等终态都已落定实际结账单;未显式传基数时优先按实际平台利润,
// 避免再次用订单创建时的预收毛利快照给零消耗结账发返利。
var committedCheckout model.OrderCheckout
if err := tx.Where("order_id = ? AND status IN ?", order.ID, []string{"accepted", "settled"}).
Order("id DESC").First(&committedCheckout).Error; err == nil {
orderGrossProfitCent = committedCheckout.PlatformFeeCent
}
}
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()
rateReferenceAt := now
if !opts.RateReferenceAt.IsZero() {
rateReferenceAt = opts.RateReferenceAt
}
settledAt := now
if !opts.SettledAt.IsZero() {
settledAt = opts.SettledAt
}
// 返利归因站点(统一规则):成本一律由「订单所属站点」承担,并使用该站点自己的返利配置。
// 订单未记录站点但存在分销拆分时,回退到拆分方站点(钱实际归它),
// 避免再次出现「主站替分站订单买单」的倒贴。
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, rateReferenceAt, 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: settledAt,
}
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, rateReferenceAt, 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: settledAt,
}
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(&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.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
}
// 汇总本页邀请人名下的累计邀请人数(一次 IN 查询,避免逐行统计)。
inviterIDs := make([]uint64, 0, len(rows))
seenInviter := make(map[uint64]struct{}, len(rows))
for _, row := range rows {
if row.InviterID == 0 {
continue
}
if _, ok := seenInviter[row.InviterID]; ok {
continue
}
seenInviter[row.InviterID] = struct{}{}
inviterIDs = append(inviterIDs, row.InviterID)
}
type inviterStatRow struct {
InviterID uint64 `gorm:"column:inviter_id"`
TotalInvited int64 `gorm:"column:total_invited"`
ActiveInvited int64 `gorm:"column:active_invited"`
PendingInvited int64 `gorm:"column:pending_invited"`
InvalidInvited int64 `gorm:"column:invalid_invited"`
}
inviterStats := make(map[uint64]inviterStatRow, len(inviterIDs))
if len(inviterIDs) > 0 {
var statRows []inviterStatRow
statDB := r.db.WithContext(ctx).Model(&model.UserInvitation{}).
Select("inviter_id, COUNT(*) AS total_invited, "+
"SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_invited, "+
"SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_invited, "+
"SUM(CASE WHEN status = 'invalid' THEN 1 ELSE 0 END) AS invalid_invited").
Where("inviter_id IN ?", inviterIDs).
Group("inviter_id")
// 分站后台只能看到本站范围内的累计数据,避免跨站泄露。
if query.DistributionSiteID != nil {
statDB = statDB.Where("distribution_site_id = ?", *query.DistributionSiteID)
}
if err := statDB.Scan(&statRows).Error; err != nil {
return nil, 0, err
}
for _, stat := range statRows {
inviterStats[stat.InviterID] = stat
}
}
list := make([]map[string]any, 0, len(rows))
for _, row := range rows {
stat := inviterStats[row.InviterID]
list = append(list, map[string]any{
"id": row.ID,
"inviter_id": row.InviterID,
"inviter_phone": row.InviterPhone,
"inviter_nickname": row.InviterNickname,
"inviter_total_invited": stat.TotalInvited,
"inviter_active_invited": stat.ActiveInvited,
"inviter_pending_invited": stat.PendingInvited,
"inviter_invalid_invited": stat.InvalidInvited,
"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 {
return ProcessOrderInvitationRebateWithOptionsTx(tx, orderID, OrderRebateOptions{})
}
// ProcessOrderInvitationRebateWithOptionsTx 支持覆盖实际结算基数与计算时刻。
func ProcessOrderInvitationRebateWithOptionsTx(tx *gorm.DB, orderID uint64, opts OrderRebateOptions) error {
if tx == nil || orderID == 0 {
return nil
}
repo := NewRepository(tx)
return repo.processOrderInvitationRebate(context.Background(), tx, orderID, opts)
}
// 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)
}