- 买家端不再强制记主站(原 buyerSiteID=0),卖家端不再记拆分方站点,两端统一取 orderSiteID - orderSiteID = 订单所属站点(>0 时);订单未记站点但存在分销拆分时回退到拆分方站点,避免主站替分站订单买单 - 该站点同时用于取返利配置(GetRebateConfig)、返利流水与钱包流水的 distribution_site_id,分站后台由此能看到本站返利支出 - 基数口径未改动,非分销订单行为与改前一致;历史 9 笔数据不订正,规则自此生效 - 测试:更新跨站用例断言;新增订单站点归因用例(校验站点专属比例 0.5 与钱包流水站点维度)
1029 lines
35 KiB
Go
1029 lines
35 KiB
Go
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.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"`
|
||
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)
|
||
}
|