feat(invitation): 实现邀请返利系统、专属注册落地页与分成管理

- 数据库与数据模型:新增 migration 000081,支持用户专属邀请码、被邀请人绑定与实名时间戳;新增邀请返利配置表与结算明细表
- 业务与结算逻辑:实现首月 60%、次月 30%、往后 15% 阶梯返利算法;买卖双向独立寻源并根据订单平台利润 50% 单边基准核算;与终态订单结算和钱包可用余额强幂等联动
- 分销站点支持:支持主站与加盟分站独立配置返利比例;跨站交易按照收号端与售卖端独立出资,资金互不侵蚀
- 前端交互与入口:
  - 新增专属短链注册落地页 /i/:code,自动带入并锁定邀请码,引导实名认证
  - 个人中心与移动端新增邀请返利看板(专属码、推广链接、统计面板、好友记录与返利流水明细)
  - 管理后台新增邀请返利管理(比例配置、邀请关系与返利流水审计)
- 验证方式:
  - 通过 repository_test.go 单元测试(阶梯区间、实名激活、跨站多方分流验证)
  - 通过 npm run format、npm run check 与 scripts/check.sh 全量门禁校验
This commit is contained in:
yml2213
2026-10-10 12:48:59 +08:00
parent e92668689b
commit 984433f866
26 changed files with 4292 additions and 16 deletions
@@ -10,6 +10,7 @@ import (
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/invitation"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/crypto"
@@ -363,6 +364,8 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID
return err
}
_ = invitation.OnUserRealnameVerifiedTx(tx, user.ID)
return appendAuditLog(tx, adminID, "admin_user.manual_realname", user.ID, meta, map[string]any{
"user_id": user.ID,
"reason": reason,
+26 -8
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"errors"
"net/http"
"strings"
@@ -11,8 +12,17 @@ import (
"github.com/gin-gonic/gin"
)
type InvitationBinder interface {
BindInvitation(ctx context.Context, inviteeID uint64, code string, siteID uint64, siteCode ...string) error
}
type Handler struct {
service *Service
service *Service
invitationBinder InvitationBinder
}
func (h *Handler) SetInvitationBinder(binder InvitationBinder) {
h.invitationBinder = binder
}
type SendSMSRequest struct {
@@ -23,9 +33,10 @@ type SendSMSRequest struct {
}
type LoginRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
TrackCode string `json:"track_code"`
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
TrackCode string `json:"track_code"`
InviteCode string `json:"invite_code"`
}
type RefreshRequest struct {
@@ -43,10 +54,11 @@ type SetPasswordRequest struct {
}
type RegisterRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
Password string `json:"password" binding:"required,min=8,max=20"`
TrackCode string `json:"track_code"`
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required,len=6"`
Password string `json:"password" binding:"required,min=8,max=20"`
TrackCode string `json:"track_code"`
InviteCode string `json:"invite_code"`
}
type ResetPasswordRequest struct {
@@ -141,6 +153,9 @@ func (h *Handler) Login(c *gin.Context) {
writeAuthError(c, err)
return
}
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, sitecode.FromRequest(c))
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
@@ -224,6 +239,9 @@ func (h *Handler) Register(c *gin.Context) {
writeAuthError(c, err)
return
}
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, sitecode.FromRequest(c))
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
@@ -0,0 +1,95 @@
package invitation
import "time"
// InviteCodeInfoDTO 用于邀请注册落地页公开展示邀请人信息
type InviteCodeInfoDTO struct {
Valid bool `json:"valid"`
InviteCode string `json:"invite_code"`
InviterNickname string `json:"inviter_nickname"`
InviterAvatar string `json:"inviter_avatar"`
}
// UserInvitationSummaryDTO 个人中心邀请返利概览
type UserInvitationSummaryDTO struct {
HasInviteCode bool `json:"has_invite_code"`
InviteCode string `json:"invite_code"`
InviteLink string `json:"invite_link"`
IsRealnameVerified bool `json:"is_realname_verified"`
TotalInvitedCount int64 `json:"total_invited_count"`
ActiveInvitedCount int64 `json:"active_invited_count"`
PendingInvitedCount int64 `json:"pending_invited_count"`
TotalRebateCent int64 `json:"total_rebate_cent"`
CurrentMonthEstCent int64 `json:"current_month_est_cent"`
FirstMonthRate float64 `json:"first_month_rate"`
SecondMonthRate float64 `json:"second_month_rate"`
SubsequentRate float64 `json:"subsequent_rate"`
}
// InvitedUserRecordDTO 邀请好友记录
type InvitedUserRecordDTO struct {
ID uint64 `json:"id"`
InviteeID uint64 `json:"invitee_id"`
MaskedPhone string `json:"masked_phone"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
Status string `json:"status"` // pending=待实名, active=已实名生效
CycleStage string `json:"cycle_stage"` // first_month, second_month, subsequent, unverified
DaysSinceVerified int `json:"days_since_verified"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
TotalEarnedFromCent int64 `json:"total_earned_from_cent"`
}
// RebateLedgerDTO 返利明细记录
type RebateLedgerDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
InviteeMaskedPhone string `json:"invitee_masked_phone"`
InviteeNickname string `json:"invitee_nickname"`
TradeRole string `json:"trade_role"` // buyer / seller
SingleSideBasisCent int64 `json:"single_side_basis_cent"`
RateApplied float64 `json:"rate_applied"`
CycleStage string `json:"cycle_stage"`
RebateAmountCent int64 `json:"rebate_amount_cent"`
SettledAt time.Time `json:"settled_at"`
}
// ConfigDTO 返利比例配置 DTO
type ConfigDTO struct {
DistributionSiteID uint64 `json:"distribution_site_id"`
FirstMonthRate float64 `json:"first_month_rate"`
SecondMonthRate float64 `json:"second_month_rate"`
SubsequentRate float64 `json:"subsequent_rate"`
Enabled bool `json:"enabled"`
UpdatedAt time.Time `json:"updated_at"`
}
// UpdateConfigRequest 更新返利比例请求
type UpdateConfigRequest struct {
FirstMonthRate float64 `json:"first_month_rate" binding:"min=0,max=1"`
SecondMonthRate float64 `json:"second_month_rate" binding:"min=0,max=1"`
SubsequentRate float64 `json:"subsequent_rate" binding:"min=0,max=1"`
Enabled *bool `json:"enabled"`
}
// AdminInvitationQuery 后台查询邀请关系
type AdminInvitationQuery struct {
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
InviterPhone string `form:"inviter_phone"`
InviteePhone string `form:"invitee_phone"`
Status string `form:"status"`
DistributionSiteID *uint64 `form:"distribution_site_id"`
}
// AdminRebateLedgerQuery 后台查询返利明细
type AdminRebateLedgerQuery struct {
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
OrderNo string `form:"order_no"`
InviterPhone string `form:"inviter_phone"`
TradeRole string `form:"trade_role"`
DistributionSiteID *uint64 `form:"distribution_site_id"`
}
@@ -0,0 +1,227 @@
package invitation
import (
"errors"
"net/http"
"strconv"
"strings"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/sitecode"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// GetInviteInfo 公开接口:根据邀请码查询邀请人简要信息(用于落地注册页)
func (h *Handler) GetInviteInfo(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if code == "" {
code = strings.TrimSpace(c.Query("code"))
}
info, err := h.service.GetInviteCodeInfo(c.Request.Context(), code)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, info)
}
// UserSummary 个人中心邀请返利概览
func (h *Handler) UserSummary(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
siteID := parseSiteID(c)
summary, err := h.service.GetUserSummary(c.Request.Context(), userID, siteID)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, summary)
}
// ListMyInvitedUsers 我的邀请好友列表
func (h *Handler) ListMyInvitedUsers(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
page, pageSize := parsePagination(c)
items, total, err := h.service.ListMyInvitedUsers(c.Request.Context(), userID, page, pageSize)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// ListMyRebates 我的返利流水
func (h *Handler) ListMyRebates(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
page, pageSize := parsePagination(c)
items, total, err := h.service.ListMyRebates(c.Request.Context(), userID, page, pageSize)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// AdminGetConfig 管理员获取返利比例配置
func (h *Handler) AdminGetConfig(c *gin.Context) {
siteID := parseAdminSiteID(c)
cfg, err := h.service.GetRebateConfig(c.Request.Context(), siteID)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, cfg)
}
// AdminUpdateConfig 管理员更新返利比例配置
func (h *Handler) AdminUpdateConfig(c *gin.Context) {
siteID := parseAdminSiteID(c)
var req UpdateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "比例参数格式不正确,必须在0到1之间")
return
}
cfg, err := h.service.UpdateRebateConfig(c.Request.Context(), siteID, req)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, cfg)
}
// AdminInvitations 管理员查询全量邀请关系
func (h *Handler) AdminInvitations(c *gin.Context) {
var query AdminInvitationQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.BadRequest(c, "查询参数不正确")
return
}
items, total, err := h.service.ListAdminInvitations(c.Request.Context(), query)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, gin.H{
"items": items,
"total": total,
"page": query.Page,
"page_size": query.PageSize,
})
}
// AdminRebates 管理员查询全量返利流水
func (h *Handler) AdminRebates(c *gin.Context) {
var query AdminRebateLedgerQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.BadRequest(c, "查询参数不正确")
return
}
items, total, err := h.service.ListAdminRebateLedgers(c.Request.Context(), query)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, gin.H{
"items": items,
"total": total,
"page": query.Page,
"page_size": query.PageSize,
})
}
func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID)
if !ok {
return 0, false
}
userID, ok := value.(uint64)
return userID, ok
}
func parsePagination(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
func parseSiteID(c *gin.Context) uint64 {
siteCode := sitecode.FromRequest(c)
if strings.TrimSpace(siteCode) == "" {
return 0
}
// 可从上下文或按 siteCode 获取 siteID
return 0
}
func parseAdminSiteID(c *gin.Context) uint64 {
raw := c.Query("distribution_site_id")
if raw == "" {
raw = c.Param("id")
}
if raw == "" {
raw = c.Param("site_id")
}
if raw != "" {
id, _ := strconv.ParseUint(raw, 10, 64)
return id
}
return 0
}
func writeInvitationError(c *gin.Context, err error) {
response.RecordError(c, err)
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库服务不可用")
case errors.Is(err, ErrInvalidInviteCode):
response.BadRequest(c, "邀请码不存在或无效")
case errors.Is(err, ErrSelfInviteNotAllowed):
response.BadRequest(c, "不能自己邀请自己")
case errors.Is(err, ErrAlreadyInvited):
response.BadRequest(c, "用户已绑定过邀请人")
case errors.Is(err, ErrInviterNotVerified):
response.BadRequest(c, "邀请人尚未完成实名认证")
default:
response.Error(c, http.StatusInternalServerError, "invitation_error", err.Error())
}
}
@@ -0,0 +1,888 @@
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)
}
@@ -0,0 +1,291 @@
package invitation
import (
"context"
"fmt"
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func setupTestDB(t *testing.T) *gorm.DB {
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db,
&model.User{},
&model.UserInvitation{},
&model.InvitationRebateConfig{},
&model.InvitationRebateLedger{},
&model.RentalOrder{},
&model.DistributionSite{},
&model.DistributionOrderParty{},
&model.WalletAccount{},
&model.WalletLedger{},
); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
}
return db
}
func TestCalculateRebateRate(t *testing.T) {
now := time.Now()
cfg := &model.InvitationRebateConfig{
FirstMonthRate: 0.60,
SecondMonthRate: 0.30,
SubsequentRate: 0.15,
Enabled: true,
}
// 10 天(首月)
rate, cycle, days := CalculateRebateRate(now.AddDate(0, 0, -10), now, cfg)
if cycle != CycleFirstMonth || rate != 0.60 || days != 10 {
t.Fatalf("首月比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
}
// 30 天(首月边界)
rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -30), now, cfg)
if cycle != CycleFirstMonth || rate != 0.60 || days != 30 {
t.Fatalf("首月30天边界计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
}
// 45 天(次月)
rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -45), now, cfg)
if cycle != CycleSecondMonth || rate != 0.30 || days != 45 {
t.Fatalf("次月比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
}
// 60 天(次月边界)
rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -60), now, cfg)
if cycle != CycleSecondMonth || rate != 0.30 || days != 60 {
t.Fatalf("次月60天边界计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
}
// 90 天(往后长期)
rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -90), now, cfg)
if cycle != CycleSubsequent || rate != 0.15 || days != 90 {
t.Fatalf("长期比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days)
}
}
func TestInvitationFlowAndOrderRebate(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
// 1. 创建邀请人 Inviter (已实名)
inviterCode := "inv0001"
inviter := model.User{
Phone: "13800000001",
Nickname: "邀请人老张",
RealnameStatus: "verified",
Status: "active",
InviteCode: &inviterCode,
}
if err := db.Create(&inviter).Error; err != nil {
t.Fatalf("创建邀请人失败: %v", err)
}
// 2. 创建被邀请人 Invitee (未实名)
invitee := model.User{
Phone: "13800000002",
Nickname: "新人小李",
RealnameStatus: "unverified",
Status: "active",
}
if err := db.Create(&invitee).Error; err != nil {
t.Fatalf("创建被邀请人失败: %v", err)
}
// 3. 绑定邀请码
if err := repo.BindInvitation(ctx, invitee.ID, inviterCode, 0); err != nil {
t.Fatalf("绑定邀请码失败: %v", err)
}
// 校验处于 pending 状态(未实名尚未正式生效)
var invRecord model.UserInvitation
if err := db.Where("invitee_id = ?", invitee.ID).First(&invRecord).Error; err != nil {
t.Fatalf("查询邀请记录失败: %v", err)
}
if invRecord.Status != StatusPending || invRecord.VerifiedAt != nil {
t.Fatalf("邀请状态应为 pending,实际: %s", invRecord.Status)
}
// 4. 被邀请人完成实名认证
if err := db.Model(&model.User{}).Where("id = ?", invitee.ID).Update("realname_status", "verified").Error; err != nil {
t.Fatalf("更新实名失败: %v", err)
}
if err := repo.OnUserRealnameVerified(ctx, invitee.ID); err != nil {
t.Fatalf("触发实名生效钩子失败: %v", err)
}
if err := db.Where("invitee_id = ?", invitee.ID).First(&invRecord).Error; err != nil {
t.Fatalf("查询生效后邀请记录失败: %v", err)
}
if invRecord.Status != StatusActive || invRecord.VerifiedAt == nil {
t.Fatalf("邀请状态应为 active 且包含 verified_at,实际: %s", invRecord.Status)
}
// 检查被邀请人自身是否自动生成了邀请码
var updatedInvitee model.User
if err := db.First(&updatedInvitee, invitee.ID).Error; err != nil {
t.Fatalf("查询被邀请人失败: %v", err)
}
if updatedInvitee.InviteCode == nil || *updatedInvitee.InviteCode == "" {
t.Fatalf("实名通过后自身应自动生成邀请码")
}
// 5. 模拟订单完成终态结算
orderNo := fmt.Sprintf("ORD-%d", time.Now().UnixNano())
order := model.RentalOrder{
OrderNo: orderNo,
RenterID: invitee.ID, // 被邀请人作为买家
OwnerID: 99999, // 独立卖家(无邀请人)
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 20000, // 200元
OwnerRentAmountCent: 10000, // 100元号主成本
// 平台毛利: 10000 (100元)
// 单边基准: 5000 (50元)
// 首月 60% 返利: 5000 * 0.6 = 3000 (30元)
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建测试订单失败: %v", err)
}
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("订单返利处理失败: %v", err)
}
// 验证流水表
var ledgers []model.InvitationRebateLedger
if err := db.Where("order_id = ?", order.ID).Find(&ledgers).Error; err != nil {
t.Fatalf("查询返利记录失败: %v", err)
}
if len(ledgers) != 1 {
t.Fatalf("应生成 1 条买家端返利流水,实际: %d", len(ledgers))
}
l := ledgers[0]
if l.InviterID != inviter.ID || l.TradeRole != "buyer" || l.RebateAmountCent != 3000 {
t.Fatalf("返利金额错误: expected=3000, got=%d, role=%s", l.RebateAmountCent, l.TradeRole)
}
// 验证邀请人钱包余额
var walletAcct model.WalletAccount
if err := db.Where("user_id = ? AND distribution_site_id = 0", inviter.ID).First(&walletAcct).Error; err != nil {
t.Fatalf("查询邀请人钱包失败: %v", err)
}
if walletAcct.AvailableBalanceCent != 3000 {
t.Fatalf("邀请人可用余额错误: expected=3000, got=%d", walletAcct.AvailableBalanceCent)
}
// 幂等性测试:再次触发不应重复生成
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("再次调用不应报错: %v", err)
}
var count int64
db.Model(&model.InvitationRebateLedger{}).Where("order_id = ?", order.ID).Count(&count)
if count != 1 {
t.Fatalf("流水重复写入! count=%d", count)
}
}
func TestCrossSiteDistributionRebate(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
// 邀请人 1 (邀请了买家)
c1 := "code001"
buyerInviter := model.User{Phone: "13900000001", RealnameStatus: "verified", Status: "active", InviteCode: &c1}
db.Create(&buyerInviter)
// 邀请人 2 (邀请了卖家)
c2 := "code002"
sellerInviter := model.User{Phone: "13900000002", RealnameStatus: "verified", Status: "active", InviteCode: &c2}
db.Create(&sellerInviter)
// 买家
now := time.Now()
buyer := model.User{Phone: "13900000003", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&model.UserInvitation{InviterID: buyerInviter.ID, InviteeID: buyer.ID, Status: StatusActive, VerifiedAt: &now})
// 卖家
seller := model.User{Phone: "13900000004", RealnameStatus: "verified", Status: "active"}
db.Create(&seller)
db.Create(&model.UserInvitation{InviterID: sellerInviter.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &now})
// 分销站点收号拆分:
// 主站卖出分站收的账号 (site)
// platformSellCent = 4000 (主站利润 40 元)
// distributorReceiveCent = 6000 (分站利润 60 元)
distSite := model.DistributionSite{
SiteName: "加盟分站88",
SiteCode: "site88",
Domain: "sub88.example.com",
Status: "active",
}
if err := db.Create(&distSite).Error; err != nil {
t.Fatalf("创建测试分销站点失败: %v", err)
}
distributorSiteID := distSite.ID
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-CROSS-%d", time.Now().UnixNano()),
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 30000,
OwnerRentAmountCent: 20000, // 总毛利 10000
}
db.Create(&order)
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID,
SiteID: distributorSiteID,
PartyRole: "platform_sell",
PartyType: "platform",
PartyID: 0,
ProfitAmountCent: 4000,
IncomeAmountCent: 4000,
}).Error; err != nil {
t.Fatalf("创建平台分成方失败: %v", err)
}
if err := db.Create(&model.DistributionOrderParty{
OrderID: order.ID,
SiteID: distributorSiteID,
PartyRole: "distributor_receive",
PartyType: "site",
PartyID: distributorSiteID,
ProfitAmountCent: 6000,
IncomeAmountCent: 6000,
}).Error; err != nil {
t.Fatalf("创建分销分成方失败: %v", err)
}
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("跨站分销返利结算失败: %v", err)
}
// 验证买家端流水:基数 4000 / 2 = 2000,首月 60% = 1200,站点为 0 (主站)
var buyerLedger model.InvitationRebateLedger
if err := db.Where("order_id = ? AND trade_role = 'buyer'", order.ID).First(&buyerLedger).Error; err != nil {
t.Fatalf("未找到买家端流水: %v", err)
}
if buyerLedger.DistributionSiteID != 0 || buyerLedger.RebateAmountCent != 1200 {
t.Fatalf("买家端跨站返利错误: siteID=%d, amount=%d", buyerLedger.DistributionSiteID, buyerLedger.RebateAmountCent)
}
// 验证卖家端流水:基数 6000 / 2 = 3000,首月 60% = 1800,站点为 88 (分站)
var sellerLedger model.InvitationRebateLedger
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&sellerLedger).Error; err != nil {
t.Fatalf("未找到卖家端流水: %v", err)
}
if sellerLedger.DistributionSiteID != distributorSiteID || sellerLedger.RebateAmountCent != 1800 {
t.Fatalf("卖家端跨站返利错误: siteID=%d, amount=%d", sellerLedger.DistributionSiteID, sellerLedger.RebateAmountCent)
}
}
@@ -0,0 +1,109 @@
package invitation
import (
"context"
)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Repo() *Repository {
return s.repo
}
func (s *Service) GetInviteCodeInfo(ctx context.Context, code string) (*InviteCodeInfoDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.GetInviteCodeInfo(ctx, code)
}
func (s *Service) BindInvitation(ctx context.Context, inviteeID uint64, code string, siteID uint64, siteCode ...string) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.BindInvitation(ctx, inviteeID, code, siteID, siteCode...)
}
func (s *Service) OnUserRealnameVerified(ctx context.Context, userID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.OnUserRealnameVerified(ctx, userID)
}
func (s *Service) GetUserSummary(ctx context.Context, userID uint64, siteID uint64) (*UserInvitationSummaryDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.GetUserSummary(ctx, userID, siteID)
}
func (s *Service) ListMyInvitedUsers(ctx context.Context, userID uint64, page, pageSize int) ([]InvitedUserRecordDTO, int64, error) {
if s.repo == nil {
return nil, 0, ErrDependencyUnavailable
}
return s.repo.ListMyInvitedUsers(ctx, userID, page, pageSize)
}
func (s *Service) ListMyRebates(ctx context.Context, userID uint64, page, pageSize int) ([]RebateLedgerDTO, int64, error) {
if s.repo == nil {
return nil, 0, ErrDependencyUnavailable
}
return s.repo.ListMyRebates(ctx, userID, page, pageSize)
}
func (s *Service) GetRebateConfig(ctx context.Context, siteID uint64) (*ConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
cfg, err := s.repo.GetRebateConfig(ctx, siteID)
if err != nil {
return nil, err
}
return &ConfigDTO{
DistributionSiteID: cfg.DistributionSiteID,
FirstMonthRate: cfg.FirstMonthRate,
SecondMonthRate: cfg.SecondMonthRate,
SubsequentRate: cfg.SubsequentRate,
Enabled: cfg.Enabled,
UpdatedAt: cfg.UpdatedAt,
}, nil
}
func (s *Service) UpdateRebateConfig(ctx context.Context, siteID uint64, req UpdateConfigRequest) (*ConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
cfg, err := s.repo.UpdateRebateConfig(ctx, siteID, req)
if err != nil {
return nil, err
}
return &ConfigDTO{
DistributionSiteID: cfg.DistributionSiteID,
FirstMonthRate: cfg.FirstMonthRate,
SecondMonthRate: cfg.SecondMonthRate,
SubsequentRate: cfg.SubsequentRate,
Enabled: cfg.Enabled,
UpdatedAt: cfg.UpdatedAt,
}, nil
}
func (s *Service) ListAdminInvitations(ctx context.Context, query AdminInvitationQuery) ([]map[string]any, int64, error) {
if s.repo == nil {
return nil, 0, ErrDependencyUnavailable
}
return s.repo.ListAdminInvitations(ctx, query)
}
func (s *Service) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) {
if s.repo == nil {
return nil, 0, ErrDependencyUnavailable
}
return s.repo.ListAdminRebateLedgers(ctx, query)
}
@@ -5,6 +5,7 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/distribution"
"hfb_sys/backend/internal/modules/invitation"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet"
@@ -61,6 +62,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
return nil, err
}
}
_ = invitation.ProcessOrderInvitationRebateTx(tx, order.ID)
return refund, nil
}
@@ -5,6 +5,7 @@ import (
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/invitation"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -66,7 +67,13 @@ func (r *Repository) SaveResult(ctx context.Context, userID uint64, provider str
if result.Status == StatusPending {
userStatus = "pending"
}
return tx.Model(&model.User{}).Where("id = ?", userID).Update("realname_status", userStatus).Error
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("realname_status", userStatus).Error; err != nil {
return err
}
if userStatus == "verified" {
_ = invitation.OnUserRealnameVerifiedTx(tx, userID)
}
return nil
})
if err != nil {
return nil, err