diff --git a/backend/internal/model/invitation.go b/backend/internal/model/invitation.go new file mode 100644 index 0000000..1421073 --- /dev/null +++ b/backend/internal/model/invitation.go @@ -0,0 +1,62 @@ +package model + +import "time" + +// UserInvitation 用户邀请绑定关系表 +type UserInvitation struct { + ID uint64 `gorm:"primaryKey" json:"id"` + InviterID uint64 `gorm:"not null;index:idx_user_invitations_inviter" json:"inviter_id"` + InviteeID uint64 `gorm:"not null;uniqueIndex:uk_user_invitations_invitee" json:"invitee_id"` + DistributionSiteID uint64 `gorm:"not null;default:0" json:"distribution_site_id"` + InviteCode string `gorm:"size:32;not null;default:''" json:"invite_code"` + Status string `gorm:"size:32;not null;default:'pending';index:idx_user_invitations_inviter" json:"status"` // pending/active/invalid + VerifiedAt *time.Time `json:"verified_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (UserInvitation) TableName() string { + return "user_invitations" +} + +// InvitationRebateConfig 邀请返利比例配置表(主站或分销站点) +type InvitationRebateConfig struct { + ID uint64 `gorm:"primaryKey" json:"id"` + DistributionSiteID uint64 `gorm:"not null;uniqueIndex:uk_invitation_rebate_configs_site" json:"distribution_site_id"` // 0=主站 + FirstMonthRate float64 `gorm:"type:decimal(6,4);not null;default:0.6000" json:"first_month_rate"` + SecondMonthRate float64 `gorm:"type:decimal(6,4);not null;default:0.3000" json:"second_month_rate"` + SubsequentRate float64 `gorm:"type:decimal(6,4);not null;default:0.1500" json:"subsequent_rate"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (InvitationRebateConfig) TableName() string { + return "invitation_rebate_configs" +} + +// InvitationRebateLedger 邀请返利结算记录明细表 +type InvitationRebateLedger struct { + ID uint64 `gorm:"primaryKey" json:"id"` + OrderID uint64 `gorm:"not null;uniqueIndex:uk_invitation_rebate_order_role,priority:1" json:"order_id"` + OrderNo string `gorm:"size:64;not null" json:"order_no"` + DistributionSiteID uint64 `gorm:"not null;default:0;index:idx_invitation_rebate_site" json:"distribution_site_id"` + InviterID uint64 `gorm:"not null;index:idx_invitation_rebate_inviter" json:"inviter_id"` + InviteeID uint64 `gorm:"not null" json:"invitee_id"` + TradeRole string `gorm:"size:16;not null;uniqueIndex:uk_invitation_rebate_order_role,priority:2" json:"trade_role"` // buyer / seller + OrderProfitBasisCent int64 `gorm:"not null;default:0" json:"order_profit_basis_cent"` + SingleSideBasisCent int64 `gorm:"not null;default:0" json:"single_side_basis_cent"` + RateApplied float64 `gorm:"type:decimal(6,4);not null;default:0" json:"rate_applied"` + DaysSinceVerified int `gorm:"not null;default:0" json:"days_since_verified"` + CycleStage string `gorm:"size:32;not null;default:''" json:"cycle_stage"` // first_month / second_month / subsequent + RebateAmountCent int64 `gorm:"not null;default:0" json:"rebate_amount_cent"` + WalletLedgerNo string `gorm:"size:64;not null;default:''" json:"wallet_ledger_no"` + Status string `gorm:"size:32;not null;default:'settled'" json:"status"` // settled / cancelled + SettledAt time.Time `gorm:"index:idx_invitation_rebate_inviter;index:idx_invitation_rebate_site" json:"settled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (InvitationRebateLedger) TableName() string { + return "invitation_rebate_ledgers" +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 2726695..8a48f7c 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -19,10 +19,13 @@ type User struct { // 默认值对齐迁移 000052 的 users.token_version DEFAULT 0。 TokenVersion int64 `gorm:"not null;default:0" json:"-"` // 注册归因:注册时由前端携带的推广短码。 - RegisterTrackCode string `gorm:"size:64;index:idx_users_register_track_code" json:"register_track_code,omitempty"` - LastLoginAt *time.Time `json:"last_login_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + RegisterTrackCode string `gorm:"size:64;index:idx_users_register_track_code" json:"register_track_code,omitempty"` + InviteCode *string `gorm:"size:32;uniqueIndex:uk_users_invite_code" json:"invite_code,omitempty"` + InvitedByID uint64 `gorm:"not null;default:0;index:idx_users_invited_by_id" json:"invited_by_id,omitempty"` + InviteVerifiedAt *time.Time `json:"invite_verified_at,omitempty"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (User) TableName() string { diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index cf578a2..1fb4461 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -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, diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index e324b7f..948826c 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -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) } diff --git a/backend/internal/modules/invitation/dto.go b/backend/internal/modules/invitation/dto.go new file mode 100644 index 0000000..c7b6829 --- /dev/null +++ b/backend/internal/modules/invitation/dto.go @@ -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"` +} diff --git a/backend/internal/modules/invitation/handler.go b/backend/internal/modules/invitation/handler.go new file mode 100644 index 0000000..5b2d911 --- /dev/null +++ b/backend/internal/modules/invitation/handler.go @@ -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()) + } +} diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go new file mode 100644 index 0000000..6ced5b7 --- /dev/null +++ b/backend/internal/modules/invitation/repository.go @@ -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(¤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) + } + + 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) +} diff --git a/backend/internal/modules/invitation/repository_test.go b/backend/internal/modules/invitation/repository_test.go new file mode 100644 index 0000000..31a5191 --- /dev/null +++ b/backend/internal/modules/invitation/repository_test.go @@ -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) + } +} diff --git a/backend/internal/modules/invitation/service.go b/backend/internal/modules/invitation/service.go new file mode 100644 index 0000000..f35713e --- /dev/null +++ b/backend/internal/modules/invitation/service.go @@ -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) +} diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 3ba5083..dca64dd 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -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 } diff --git a/backend/internal/modules/realname/repository.go b/backend/internal/modules/realname/repository.go index c11b3da..5ff8c7c 100644 --- a/backend/internal/modules/realname/repository.go +++ b/backend/internal/modules/realname/repository.go @@ -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 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 07ed5c8..881fb02 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -24,6 +24,7 @@ import ( "hfb_sys/backend/internal/modules/dispute" "hfb_sys/backend/internal/modules/distribution" filemodule "hfb_sys/backend/internal/modules/file" + "hfb_sys/backend/internal/modules/invitation" "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/mohong" "hfb_sys/backend/internal/modules/notification" @@ -239,6 +240,18 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { withdrawalService := withdrawal.NewService(withdrawalRepo) withdrawalHandler := withdrawal.NewHandler(withdrawalService) + var invitationRepo *invitation.Repository + var invitationService *invitation.Service + var invitationHandler *invitation.Handler + if deps.DB != nil { + invitationRepo = invitation.NewRepository(deps.DB) + invitationService = invitation.NewService(invitationRepo) + invitationHandler = invitation.NewHandler(invitationService) + if authHandler != nil { + authHandler.SetInvitationBinder(invitationService) + } + } + // 支付配置管理 var paymentConfigRepo *paymentconfig.Repository var paymentConfigService *paymentconfig.Service @@ -477,6 +490,18 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.PUT("/me", requireAuth, userHandler.UpdateMe) api.PUT("/password", requireAuth, authHandler.SetPassword) + if invitationHandler != nil { + api.GET("/invitations/code-info", invitationHandler.GetInviteInfo) + api.GET("/invitations/code-info/:code", invitationHandler.GetInviteInfo) + + userInvites := api.Group("/user/invitations", requireAuth) + { + userInvites.GET("/summary", invitationHandler.UserSummary) + userInvites.GET("/records", invitationHandler.ListMyInvitedUsers) + userInvites.GET("/rebates", invitationHandler.ListMyRebates) + } + } + listingRoutes := api.Group("/listings") { listingRoutes.GET("", listingHandler.ListPublic) @@ -687,6 +712,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal) adminRoutes.GET("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_view"), distributionHandler.ListSiteWithdrawals) adminRoutes.GET("/distribution/sites/:id/analytics", requirePerm("distribution:site_view"), distributionHandler.GetDashboardAnalytics) + if invitationHandler != nil { + adminRoutes.GET("/distribution/sites/:id/invitation-config", requirePerm("distribution:site_view"), invitationHandler.AdminGetConfig) + adminRoutes.PUT("/distribution/sites/:id/invitation-config", requirePerm("distribution:site_manage"), invitationHandler.AdminUpdateConfig) + } adminRoutes.GET("/distribution/sites/:id/qrcodes", requirePerm("qrcode:view"), chatHandler.ListSiteQrCodesHandler) adminRoutes.GET("/distribution/sites/:id/qrcodes/stats", requirePerm("qrcode:view"), chatHandler.GetSiteQrCodeStatsHandler) adminRoutes.POST("/distribution/sites/:id/qrcodes/batch", requirePerm("qrcode:manage"), chatHandler.BatchCreateSiteQrCodeHandler) @@ -870,6 +899,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.PUT("/tracking-links/:id", requirePerm("ad_tracking:manage"), adTrackingHandler.Update) adminRoutes.PUT("/tracking-links/:id/status", requirePerm("ad_tracking:manage"), adTrackingHandler.UpdateStatus) + if invitationHandler != nil { + adminRoutes.GET("/invitations/configs", requirePerm("distribution:site_view"), invitationHandler.AdminGetConfig) + adminRoutes.PUT("/invitations/configs", requirePerm("distribution:site_manage"), invitationHandler.AdminUpdateConfig) + adminRoutes.GET("/invitations", requirePerm("distribution:site_view"), invitationHandler.AdminInvitations) + adminRoutes.GET("/invitations/rebates", requirePerm("distribution:settlement_view"), invitationHandler.AdminRebates) + } + registerAdminManagementRoutes(adminRoutes, requirePerm, adminManagementHandlers{ chat: chatHandler, supportGroup: supportGroupHandler, role: adminRoleHandler, admin: adminMgrHandler, announcement: announcementHandler, diff --git a/backend/migrations/000081_user_invitations_and_rebates.sql b/backend/migrations/000081_user_invitations_and_rebates.sql new file mode 100644 index 0000000..c243c3f --- /dev/null +++ b/backend/migrations/000081_user_invitations_and_rebates.sql @@ -0,0 +1,76 @@ +-- +goose Up + +ALTER TABLE users + ADD COLUMN invite_code VARCHAR(32) NULL COMMENT '用户专属邀请码' AFTER register_track_code, + ADD COLUMN invited_by_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '邀请人用户ID' AFTER invite_code, + ADD COLUMN invite_verified_at DATETIME NULL COMMENT '被邀请人生效实名时间' AFTER invited_by_id, + ADD UNIQUE KEY uk_users_invite_code (invite_code), + ADD KEY idx_users_invited_by_id (invited_by_id); + +CREATE TABLE user_invitations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + inviter_id BIGINT UNSIGNED NOT NULL COMMENT '邀请人用户ID', + invitee_id BIGINT UNSIGNED NOT NULL COMMENT '被邀请人用户ID', + distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属分销站点ID (0=主站)', + invite_code VARCHAR(32) NOT NULL DEFAULT '' COMMENT '当时使用的邀请码', + status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '状态: pending=注册待实名, active=已实名生效, invalid=已失效', + verified_at DATETIME NULL COMMENT '被邀请人实名通过时间(首月计时起点)', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_user_invitations_invitee (invitee_id), + KEY idx_user_invitations_inviter (inviter_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户邀请关系表'; + +CREATE TABLE invitation_rebate_configs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属分销站点ID (0=主站全局默认)', + first_month_rate DECIMAL(6,4) NOT NULL DEFAULT 0.6000 COMMENT '首月返利比例 (默认0.60)', + second_month_rate DECIMAL(6,4) NOT NULL DEFAULT 0.3000 COMMENT '次月返利比例 (默认0.30)', + subsequent_rate DECIMAL(6,4) NOT NULL DEFAULT 0.1500 COMMENT '往后返利比例 (默认0.15)', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用返利', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_invitation_rebate_configs_site (distribution_site_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邀请返利比例配置表'; + +INSERT INTO invitation_rebate_configs (distribution_site_id, first_month_rate, second_month_rate, subsequent_rate, enabled, created_at, updated_at) +VALUES (0, 0.6000, 0.3000, 0.1500, 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE updated_at = NOW(); + +CREATE TABLE invitation_rebate_ledgers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL COMMENT '关联订单ID', + order_no VARCHAR(64) NOT NULL COMMENT '订单号', + distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '分担成本的站点ID (0=主站)', + inviter_id BIGINT UNSIGNED NOT NULL COMMENT '获得返利的用户ID', + invitee_id BIGINT UNSIGNED NOT NULL COMMENT '贡献交易的被邀请人ID', + trade_role VARCHAR(16) NOT NULL COMMENT '交易端角色: buyer=买家, seller=卖家', + order_profit_basis_cent BIGINT NOT NULL DEFAULT 0 COMMENT '订单收益基数(分)', + single_side_basis_cent BIGINT NOT NULL DEFAULT 0 COMMENT '单边收益基数(分)', + rate_applied DECIMAL(6,4) NOT NULL DEFAULT 0 COMMENT '计算时采用的返利比例', + days_since_verified INT NOT NULL DEFAULT 0 COMMENT '被邀请人实名至成交的天数', + cycle_stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '周期阶段: first_month/second_month/subsequent', + rebate_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '实际返利金额(分)', + wallet_ledger_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联钱包流水号', + status VARCHAR(32) NOT NULL DEFAULT 'settled' COMMENT '状态: settled=已发放, cancelled=已取消/冲正', + settled_at DATETIME NOT NULL COMMENT '结算发放时间', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_invitation_rebate_order_role (order_id, trade_role), + KEY idx_invitation_rebate_inviter (inviter_id, settled_at), + KEY idx_invitation_rebate_site (distribution_site_id, settled_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邀请返利结算明细表'; + +-- +goose Down + +DROP TABLE IF EXISTS invitation_rebate_ledgers; +DROP TABLE IF EXISTS invitation_rebate_configs; +DROP TABLE IF EXISTS user_invitations; + +ALTER TABLE users + DROP COLUMN invite_verified_at, + DROP COLUMN invited_by_id, + DROP COLUMN invite_code; diff --git a/frontend/src/features/admin/views/AdminInvitationsView.vue b/frontend/src/features/admin/views/AdminInvitationsView.vue new file mode 100644 index 0000000..d08ecf7 --- /dev/null +++ b/frontend/src/features/admin/views/AdminInvitationsView.vue @@ -0,0 +1,544 @@ + + + + + diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index dc99966..033c8c8 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -69,12 +69,20 @@ export async function loginWithPassword(phone: string, password: string) { return data.data } -export async function registerWithPassword(phone: string, code: string, password: string) { +export async function registerWithPassword( + phone: string, + code: string, + password: string, + inviteCode?: string +) { const trackCode = getTrackCode() const payload: Record = { phone, code, password } if (trackCode) { payload.track_code = trackCode } + if (inviteCode) { + payload.invite_code = inviteCode + } const { data } = await apiClient.post>('/auth/password/register', payload) return data.data } diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index e5f71f4..1b16946 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -467,6 +467,13 @@ function resolveAvatarURL(url: string | undefined | null) { + diff --git a/frontend/src/features/invitation/api/invitation.ts b/frontend/src/features/invitation/api/invitation.ts new file mode 100644 index 0000000..28fc134 --- /dev/null +++ b/frontend/src/features/invitation/api/invitation.ts @@ -0,0 +1,139 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' + +export interface InviteCodeInfo { + valid: boolean + invite_code: string + inviter_nickname?: string + inviter_avatar?: string +} + +export interface UserInvitationSummary { + user_id: number + has_invite_code: boolean + invite_code: string + is_realname_verified: boolean + total_rebate_cent: number + month_rebate_cent: number + total_invited_count: number + active_invited_count: number + first_month_rate: number + second_month_rate: number + subsequent_rate: number +} + +export interface InvitedUserRecord { + id: number + invitee_id: number + invitee_phone: string + invitee_nickname: string + status: string + verified_at?: string + created_at: string +} + +export interface RebateLedger { + id: number + order_id: number + order_no: string + trade_role: 'buyer' | 'seller' + order_profit_basis_cent: number + single_side_basis_cent: number + rate_applied: number + days_since_verified: number + cycle_stage: 'first_month' | 'second_month' | 'subsequent' + rebate_amount_cent: number + status: string + settled_at: string +} + +export interface PaginatedResult { + items: T[] + total: number + page: number + page_size: number +} + +export interface InvitationRebateConfig { + distribution_site_id: number + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled: boolean + updated_at?: string +} + +/** 公开获取邀请码信息(用于注册落地页) */ +export async function fetchInviteCodeInfo(code: string): Promise { + const { data } = await apiClient.get>( + `/invitations/code-info/${encodeURIComponent(code)}` + ) + return data.data +} + +/** 获取当前用户的邀请看板汇总数据 */ +export async function fetchUserInvitationSummary(): Promise { + const { data } = await apiClient.get>( + '/user/invitations/summary' + ) + return data.data +} + +/** 获取当前用户邀请的好友列表 */ +export async function fetchMyInvitedUsers( + page = 1, + pageSize = 20 +): Promise> { + const { data } = await apiClient.get>>( + '/user/invitations/records', + { + params: { page, page_size: pageSize }, + } + ) + return data.data +} + +/** 获取当前用户的返利流水明细 */ +export async function fetchMyRebates( + page = 1, + pageSize = 20 +): Promise> { + const { data } = await apiClient.get>>( + '/user/invitations/rebates', + { + params: { page, page_size: pageSize }, + } + ) + return data.data +} + +/** 管理端获取返利比例配置(siteId 为 0 时为主站) */ +export async function fetchAdminRebateConfig(siteId = 0): Promise { + const { data } = await apiClient.get>( + '/invitations/configs', + { + params: { distribution_site_id: siteId }, + } + ) + return data.data +} + +/** 管理端更新返利比例配置 */ +export async function updateAdminRebateConfig( + siteId: number, + config: { + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled?: boolean + } +): Promise { + const { data } = await apiClient.put>( + '/invitations/configs', + config, + { + params: { distribution_site_id: siteId }, + } + ) + return data.data +} diff --git a/frontend/src/features/invitation/views/InviteRegisterView.vue b/frontend/src/features/invitation/views/InviteRegisterView.vue new file mode 100644 index 0000000..1ca9af8 --- /dev/null +++ b/frontend/src/features/invitation/views/InviteRegisterView.vue @@ -0,0 +1,664 @@ + + + + + diff --git a/frontend/src/features/invitation/views/UserInvitationView.vue b/frontend/src/features/invitation/views/UserInvitationView.vue new file mode 100644 index 0000000..3f3efba --- /dev/null +++ b/frontend/src/features/invitation/views/UserInvitationView.vue @@ -0,0 +1,1057 @@ + + + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index df98a6e..773d3e8 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -169,6 +169,12 @@ const allNavGroups: NavGroup[] = [ icon: Coin, permission: 'distribution:settlement_view', }, + { + label: '邀请返利管理', + to: adminPath('invitations'), + icon: Present, + permission: 'distribution:site_view', + }, ], }, { diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 5108cfc..65a285b 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -40,6 +40,7 @@ const navItems = [ { label: '我的订单', to: '/orders', icon: Tickets }, { label: '消息', to: '/messages', icon: ChatDotRound }, { label: '钱包', to: '/wallet', icon: Wallet }, + { label: '邀请返利', to: '/invitations', icon: Coin }, { label: '公告', to: '/announcements', icon: Bell }, ] const buyerServiceItems = [ @@ -315,6 +316,14 @@ async function handleSupportClick() { {{ realnameBadge.text }} + + + 邀请返利 + diff --git a/frontend/src/router/accountRoutes.ts b/frontend/src/router/accountRoutes.ts index 8a5ebf6..80e7c83 100644 --- a/frontend/src/router/accountRoutes.ts +++ b/frontend/src/router/accountRoutes.ts @@ -60,6 +60,12 @@ export const accountRoutes: RouteRecordRaw[] = [ component: () => import('@/features/auth/views/RealnameView.vue'), meta: { requiresAuth: true }, }, + { + path: '/invitations', + name: 'user-invitations', + component: () => import('@/features/invitation/views/UserInvitationView.vue'), + meta: { requiresAuth: true }, + }, { path: '/post-rental-notice', name: 'post-rental-notice', diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 43ef5ee..9b27337 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -252,4 +252,10 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminTrackingLinksView.vue'), meta: adminMeta, }, + { + path: adminPath('invitations'), + name: 'admin-invitations', + component: () => import('@/features/admin/views/AdminInvitationsView.vue'), + meta: adminMeta, + }, ] diff --git a/frontend/src/router/mobileRoutes.ts b/frontend/src/router/mobileRoutes.ts index d1973ae..74962e6 100644 --- a/frontend/src/router/mobileRoutes.ts +++ b/frontend/src/router/mobileRoutes.ts @@ -73,6 +73,12 @@ export const mobileRoutes: RouteRecordRaw[] = [ component: () => import('@/features/auth/views/MobileRealnameView.vue'), meta: { layout: 'blank', requiresAuth: true }, }, + { + path: '/m/invitations', + name: 'mobile-invitations', + component: () => import('@/features/invitation/views/UserInvitationView.vue'), + meta: { layout: 'blank', requiresAuth: true }, + }, { path: '/m/profile', name: 'mobile-profile', diff --git a/frontend/src/router/publicRoutes.ts b/frontend/src/router/publicRoutes.ts index 4819ab3..42757b9 100644 --- a/frontend/src/router/publicRoutes.ts +++ b/frontend/src/router/publicRoutes.ts @@ -39,6 +39,13 @@ export const publicRoutes: RouteRecordRaw[] = [ name: 'crash-detail', component: () => import('@/features/mohong/views/MohongDetailView.vue'), }, + // 专属邀请注册落地页 + { + path: '/i/:code', + name: 'invite-register', + component: () => import('@/features/invitation/views/InviteRegisterView.vue'), + meta: { layout: 'blank' }, + }, // 旧路径兼容 { path: '/mohong', redirect: '/crash' }, { path: '/mohong/orders', redirect: '/crash/orders' }, diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index 0ed0099..cc917f3 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -56,8 +56,8 @@ export const useSessionStore = defineStore('session', { this.applySession(result.user, result.tokens.access_token, result.tokens.refresh_token) return result }, - async register(phone: string, code: string, password: string) { - const result = await registerWithPassword(phone, code, password) + async register(phone: string, code: string, password: string, inviteCode?: string) { + const result = await registerWithPassword(phone, code, password, inviteCode) this.applySession(result.user, result.tokens.access_token, result.tokens.refresh_token) return result },