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:
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¤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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,544 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Setting, User, Coin, Refresh, Search, Check } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchAdminRebateConfig,
|
||||
updateAdminRebateConfig,
|
||||
type InvitationRebateConfig,
|
||||
} from '@/features/invitation/api/invitation'
|
||||
import {
|
||||
fetchDistributionSites,
|
||||
type DistributionSite,
|
||||
} from '@/features/admin/api/adminDistribution'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { formatCent } from '@/shared/utils/money'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const activeTab = ref<'config' | 'invitations' | 'rebates'>('config')
|
||||
|
||||
// 站点列表
|
||||
const sites = ref<DistributionSite[]>([])
|
||||
const selectedSiteId = ref(0)
|
||||
const loadingConfig = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
|
||||
const configForm = reactive({
|
||||
firstMonthRate: 60,
|
||||
secondMonthRate: 30,
|
||||
subsequentRate: 15,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// 邀请关系列表
|
||||
const loadingInvitations = ref(false)
|
||||
const invitationQuery = reactive({
|
||||
phone: '',
|
||||
invite_code: '',
|
||||
distribution_site_id: undefined as number | undefined,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
const invitations = ref<any[]>([])
|
||||
const invitationsTotal = ref(0)
|
||||
|
||||
// 返利流水列表
|
||||
const loadingRebates = ref(false)
|
||||
const rebateQuery = reactive({
|
||||
order_no: '',
|
||||
inviter_phone: '',
|
||||
invitee_phone: '',
|
||||
distribution_site_id: undefined as number | undefined,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
const rebates = ref<any[]>([])
|
||||
const rebatesTotal = ref(0)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites()
|
||||
await loadConfig()
|
||||
})
|
||||
|
||||
async function loadSites() {
|
||||
try {
|
||||
const list = await fetchDistributionSites()
|
||||
sites.value = list
|
||||
} catch {
|
||||
// 忽略加载分站失败
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loadingConfig.value = true
|
||||
try {
|
||||
const cfg: InvitationRebateConfig = await fetchAdminRebateConfig(selectedSiteId.value)
|
||||
configForm.firstMonthRate = Math.round((cfg.first_month_rate || 0.6) * 100)
|
||||
configForm.secondMonthRate = Math.round((cfg.second_month_rate || 0.3) * 100)
|
||||
configForm.subsequentRate = Math.round((cfg.subsequent_rate || 0.15) * 100)
|
||||
configForm.enabled = cfg.enabled !== false
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '获取返利配置失败'))
|
||||
} finally {
|
||||
loadingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveConfig() {
|
||||
if (
|
||||
configForm.firstMonthRate < 0 ||
|
||||
configForm.firstMonthRate > 100 ||
|
||||
configForm.secondMonthRate < 0 ||
|
||||
configForm.secondMonthRate > 100 ||
|
||||
configForm.subsequentRate < 0 ||
|
||||
configForm.subsequentRate > 100
|
||||
) {
|
||||
ElMessage.warning('分成比例必须在 0% 到 100% 之间')
|
||||
return
|
||||
}
|
||||
|
||||
savingConfig.value = true
|
||||
try {
|
||||
await updateAdminRebateConfig(selectedSiteId.value, {
|
||||
first_month_rate: Number((configForm.firstMonthRate / 100).toFixed(4)),
|
||||
second_month_rate: Number((configForm.secondMonthRate / 100).toFixed(4)),
|
||||
subsequent_rate: Number((configForm.subsequentRate / 100).toFixed(4)),
|
||||
enabled: configForm.enabled,
|
||||
})
|
||||
ElMessage.success('返利比例配置已保存')
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '保存配置失败'))
|
||||
} finally {
|
||||
savingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInvitations() {
|
||||
loadingInvitations.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: invitationQuery.page,
|
||||
page_size: invitationQuery.pageSize,
|
||||
}
|
||||
if (invitationQuery.phone.trim()) params.phone = invitationQuery.phone.trim()
|
||||
if (invitationQuery.invite_code.trim()) params.invite_code = invitationQuery.invite_code.trim()
|
||||
if (invitationQuery.distribution_site_id !== undefined) {
|
||||
params.distribution_site_id = invitationQuery.distribution_site_id
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get('/invitations', { params })
|
||||
invitations.value = data.data.items || []
|
||||
invitationsTotal.value = data.data.total || 0
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '获取邀请关系失败'))
|
||||
} finally {
|
||||
loadingInvitations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRebates() {
|
||||
loadingRebates.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: rebateQuery.page,
|
||||
page_size: rebateQuery.pageSize,
|
||||
}
|
||||
if (rebateQuery.order_no.trim()) params.order_no = rebateQuery.order_no.trim()
|
||||
if (rebateQuery.inviter_phone.trim()) params.inviter_phone = rebateQuery.inviter_phone.trim()
|
||||
if (rebateQuery.invitee_phone.trim()) params.invitee_phone = rebateQuery.invitee_phone.trim()
|
||||
if (rebateQuery.distribution_site_id !== undefined) {
|
||||
params.distribution_site_id = rebateQuery.distribution_site_id
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get('/invitations/rebates', { params })
|
||||
rebates.value = data.data.items || []
|
||||
rebatesTotal.value = data.data.total || 0
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '获取返利流水失败'))
|
||||
} finally {
|
||||
loadingRebates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(tab: 'config' | 'invitations' | 'rebates') {
|
||||
activeTab.value = tab
|
||||
if (tab === 'invitations') {
|
||||
void loadInvitations()
|
||||
} else if (tab === 'rebates') {
|
||||
void loadRebates()
|
||||
}
|
||||
}
|
||||
|
||||
function cycleLabel(cycle: string) {
|
||||
switch (cycle) {
|
||||
case 'first_month':
|
||||
return '首月高额'
|
||||
case 'second_month':
|
||||
return '次月奖励'
|
||||
default:
|
||||
return '长期分成'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-invitations-page">
|
||||
<div class="page-header">
|
||||
<h2>邀请返利与分成管理</h2>
|
||||
<p class="page-desc">
|
||||
配置主站及各分销站点的返利阶梯比例(首月、次月、往后永久),监控全网邀请关系与返利流水。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 选项卡导航 -->
|
||||
<div class="nav-tabs">
|
||||
<button
|
||||
class="nav-tab-btn"
|
||||
:class="{ active: activeTab === 'config' }"
|
||||
@click="handleTabChange('config')"
|
||||
>
|
||||
<el-icon><Setting /></el-icon> 返利比例配置
|
||||
</button>
|
||||
<button
|
||||
class="nav-tab-btn"
|
||||
:class="{ active: activeTab === 'invitations' }"
|
||||
@click="handleTabChange('invitations')"
|
||||
>
|
||||
<el-icon><User /></el-icon> 全网邀请关系 ({{ invitationsTotal }})
|
||||
</button>
|
||||
<button
|
||||
class="nav-tab-btn"
|
||||
:class="{ active: activeTab === 'rebates' }"
|
||||
@click="handleTabChange('rebates')"
|
||||
>
|
||||
<el-icon><Coin /></el-icon> 返利结算流水 ({{ rebatesTotal }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 配置面板 -->
|
||||
<div v-if="activeTab === 'config'" class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div class="site-selector-row">
|
||||
<label>选择配置生效目标:</label>
|
||||
<el-select v-model="selectedSiteId" style="width: 260px" @change="loadConfig">
|
||||
<el-option :value="0" label="主站(默认基础配置)" />
|
||||
<el-option
|
||||
v-for="s in sites"
|
||||
:key="s.id"
|
||||
:value="s.id"
|
||||
:label="`分销站: ${s.site_name} (${s.site_code})`"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" circle @click="loadConfig" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loadingConfig" class="config-form-body">
|
||||
<el-form label-position="top" style="max-width: 600px">
|
||||
<el-form-item label="功能开关">
|
||||
<el-switch
|
||||
v-model="configForm.enabled"
|
||||
active-text="启用邀请返利"
|
||||
inactive-text="暂停该站点返利"
|
||||
/>
|
||||
<div class="field-hint">关闭后该站点产生的新订单将不计算并发放邀请返利</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="首月分成比例(实名生效 1 ~ 30 天)">
|
||||
<div class="rate-input-box">
|
||||
<el-input-number v-model="configForm.firstMonthRate" :min="0" :max="100" :step="5" />
|
||||
<span class="rate-unit">%</span>
|
||||
</div>
|
||||
<div class="field-hint">默认 60%,按平台每笔订单单边毛利计算分成基数</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="次月分成比例(实名生效 31 ~ 60 天)">
|
||||
<div class="rate-input-box">
|
||||
<el-input-number v-model="configForm.secondMonthRate" :min="0" :max="100" :step="5" />
|
||||
<span class="rate-unit">%</span>
|
||||
</div>
|
||||
<div class="field-hint">默认 30%</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="往后长期分成比例(实名生效 60 天后永久)">
|
||||
<div class="rate-input-box">
|
||||
<el-input-number v-model="configForm.subsequentRate" :min="0" :max="100" :step="1" />
|
||||
<span class="rate-unit">%</span>
|
||||
</div>
|
||||
<div class="field-hint">默认 15%</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Check"
|
||||
:loading="savingConfig"
|
||||
@click="handleSaveConfig"
|
||||
>
|
||||
保存比例配置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请关系面板 -->
|
||||
<div v-if="activeTab === 'invitations'" class="panel-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="invitationQuery.phone"
|
||||
placeholder="搜索手机号"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadInvitations"
|
||||
/>
|
||||
<el-input
|
||||
v-model="invitationQuery.invite_code"
|
||||
placeholder="搜索邀请码"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="loadInvitations"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="loadInvitations">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="loadInvitations">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loadingInvitations"
|
||||
:data="invitations"
|
||||
border
|
||||
style="width: 100%; margin-top: 16px"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="邀请人" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.inviter_nickname || '-' }}</div>
|
||||
<code style="font-size: 12px; color: #64748b">{{ row.inviter_phone }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="被邀请人" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.invitee_nickname || '-' }}</div>
|
||||
<code style="font-size: 12px; color: #64748b">{{ row.invitee_phone }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="invite_code" label="使用邀请码" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.invite_code }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="认证生效状态" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'warning'" size="small">
|
||||
{{ row.status === 'active' ? '已实名 (生效中)' : '待实名 (未生效)' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="verified_at" label="实名生效时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.verified_at ? row.verified_at.slice(0, 19).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="绑定时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.created_at ? row.created_at.slice(0, 19).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-box">
|
||||
<el-pagination
|
||||
v-model:current-page="invitationQuery.page"
|
||||
v-model:page-size="invitationQuery.pageSize"
|
||||
:total="invitationsTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadInvitations"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返利流水面板 -->
|
||||
<div v-if="activeTab === 'rebates'" class="panel-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="rebateQuery.order_no"
|
||||
placeholder="订单号"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadRebates"
|
||||
/>
|
||||
<el-input
|
||||
v-model="rebateQuery.inviter_phone"
|
||||
placeholder="邀请人手机号"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="loadRebates"
|
||||
/>
|
||||
<el-input
|
||||
v-model="rebateQuery.invitee_phone"
|
||||
placeholder="被邀请人手机号"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="loadRebates"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="loadRebates">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="loadRebates">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loadingRebates"
|
||||
:data="rebates"
|
||||
border
|
||||
style="width: 100%; margin-top: 16px"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="关联订单号" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.order_no }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="inviter_phone" label="收益人(邀请人)" width="130" />
|
||||
<el-table-column prop="invitee_phone" label="交易人(被邀请人)" width="130" />
|
||||
<el-table-column prop="trade_role" label="交易端" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.trade_role === 'buyer' ? 'primary' : 'danger'" size="small">
|
||||
{{ row.trade_role === 'buyer' ? '买家端' : '卖家端' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="收益基数" width="110">
|
||||
<template #default="{ row }">
|
||||
¥{{ formatCent(row.single_side_basis_cent || 0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提成比例" width="90">
|
||||
<template #default="{ row }"> {{ Math.round((row.rate_applied || 0) * 100) }}% </template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cycle_stage" label="周期阶段" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ cycleLabel(row.cycle_stage) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="返利金额" width="110">
|
||||
<template #default="{ row }">
|
||||
<strong style="color: #dc2626">+¥{{ formatCent(row.rebate_amount_cent || 0) }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settled_at" label="入账时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.settled_at ? row.settled_at.slice(0, 19).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-box">
|
||||
<el-pagination
|
||||
v-model:current-page="rebateQuery.page"
|
||||
v-model:page-size="rebateQuery.pageSize"
|
||||
:total="rebatesTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadRebates"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-invitations-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 6px 0 18px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nav-tab-btn {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-tab-btn:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.nav-tab-btn.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.site-selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rate-input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rate-unit {
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination-box {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, unknown> = { phone, code, password }
|
||||
if (trackCode) {
|
||||
payload.track_code = trackCode
|
||||
}
|
||||
if (inviteCode) {
|
||||
payload.invite_code = inviteCode
|
||||
}
|
||||
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/password/register', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -467,6 +467,13 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
</span>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell
|
||||
title="邀请返利"
|
||||
icon="gold-coin-o"
|
||||
is-link
|
||||
to="/m/invitations"
|
||||
value="首月60%收益"
|
||||
/>
|
||||
<van-cell title="租后须知" icon="info-o" is-link @click="openPostRentalNotice" />
|
||||
<van-cell title="系统设置" icon="setting-o" is-link @click="showSettings = true" />
|
||||
</van-cell-group>
|
||||
|
||||
@@ -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<T> {
|
||||
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<InviteCodeInfo> {
|
||||
const { data } = await apiClient.get<ApiResponse<InviteCodeInfo>>(
|
||||
`/invitations/code-info/${encodeURIComponent(code)}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 获取当前用户的邀请看板汇总数据 */
|
||||
export async function fetchUserInvitationSummary(): Promise<UserInvitationSummary> {
|
||||
const { data } = await apiClient.get<ApiResponse<UserInvitationSummary>>(
|
||||
'/user/invitations/summary'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 获取当前用户邀请的好友列表 */
|
||||
export async function fetchMyInvitedUsers(
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
): Promise<PaginatedResult<InvitedUserRecord>> {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<InvitedUserRecord>>>(
|
||||
'/user/invitations/records',
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 获取当前用户的返利流水明细 */
|
||||
export async function fetchMyRebates(
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
): Promise<PaginatedResult<RebateLedger>> {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<RebateLedger>>>(
|
||||
'/user/invitations/rebates',
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 管理端获取返利比例配置(siteId 为 0 时为主站) */
|
||||
export async function fetchAdminRebateConfig(siteId = 0): Promise<InvitationRebateConfig> {
|
||||
const { data } = await apiClient.get<ApiResponse<InvitationRebateConfig>>(
|
||||
'/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<InvitationRebateConfig> {
|
||||
const { data } = await apiClient.put<ApiResponse<InvitationRebateConfig>>(
|
||||
'/invitations/configs',
|
||||
config,
|
||||
{
|
||||
params: { distribution_site_id: siteId },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Lock,
|
||||
Iphone,
|
||||
Key,
|
||||
View,
|
||||
Hide,
|
||||
Present,
|
||||
WarningFilled,
|
||||
Check,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { fetchAuthCaptcha, sendSmsCode } from '@/features/auth/api/auth'
|
||||
import { fetchInviteCodeInfo, type InviteCodeInfo } from '@/features/invitation/api/invitation'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useDistributionSiteStore } from '@/stores/distributionSite'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const siteStore = useDistributionSiteStore()
|
||||
|
||||
const inviteCode = computed(() => String(route.params.code || '').trim())
|
||||
const loadingInfo = ref(true)
|
||||
const codeInfo = ref<InviteCodeInfo | null>(null)
|
||||
|
||||
// 注册表单状态
|
||||
const form = reactive({
|
||||
phone: '',
|
||||
captchaCode: '',
|
||||
smsCode: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const showPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 图形验证码状态
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
const loadingCaptcha = ref(false)
|
||||
|
||||
// 短信倒计时
|
||||
const smsCountdown = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCodeInfo(), refreshCaptcha()])
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
async function loadCodeInfo() {
|
||||
if (!inviteCode.value) {
|
||||
loadingInfo.value = false
|
||||
return
|
||||
}
|
||||
loadingInfo.value = true
|
||||
try {
|
||||
const res = await fetchInviteCodeInfo(inviteCode.value)
|
||||
codeInfo.value = res
|
||||
} catch (_err) {
|
||||
ElMessage.warning('邀请码查询失败,请核对邀请链接')
|
||||
} finally {
|
||||
loadingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCaptcha() {
|
||||
loadingCaptcha.value = true
|
||||
try {
|
||||
const res = await fetchAuthCaptcha()
|
||||
captchaId.value = res.captcha_id
|
||||
captchaImage.value = res.image
|
||||
} catch (_err) {
|
||||
ElMessage.error('获取图形验证码失败,请点击刷新')
|
||||
} finally {
|
||||
loadingCaptcha.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendSms() {
|
||||
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
|
||||
ElMessage.warning('请输入正确的11位手机号码')
|
||||
return
|
||||
}
|
||||
if (!form.captchaCode) {
|
||||
ElMessage.warning('请先输入图形验证码')
|
||||
return
|
||||
}
|
||||
if (smsCountdown.value > 0) return
|
||||
|
||||
try {
|
||||
await sendSmsCode(form.phone, captchaId.value, form.captchaCode)
|
||||
ElMessage.success('短信验证码已发送')
|
||||
smsCountdown.value = 60
|
||||
timer = setInterval(() => {
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '发送验证码失败'))
|
||||
await refreshCaptcha()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
|
||||
ElMessage.warning('请输入正确的11位手机号码')
|
||||
return
|
||||
}
|
||||
if (!form.smsCode || form.smsCode.length !== 6) {
|
||||
ElMessage.warning('请输入6位短信验证码')
|
||||
return
|
||||
}
|
||||
if (!form.password || form.password.length < 8 || form.password.length > 20) {
|
||||
ElMessage.warning('密码长度必须为8-20位')
|
||||
return
|
||||
}
|
||||
if (form.password !== form.confirmPassword) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await session.register(form.phone, form.smsCode, form.password, inviteCode.value)
|
||||
|
||||
ElMessageBox.confirm(
|
||||
'账号注册成功并已绑定邀请人!根据平台规定,完成实名认证后方可正式激活会员特权与邀请返利资格。是否立即前往实名认证?',
|
||||
'注册成功',
|
||||
{
|
||||
confirmButtonText: '去实名认证',
|
||||
cancelButtonText: '稍后再说',
|
||||
type: 'success',
|
||||
distinguishCancelAndClose: true,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
router.replace('/realname')
|
||||
})
|
||||
.catch(() => {
|
||||
router.replace('/')
|
||||
})
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '注册失败'))
|
||||
await refreshCaptcha()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="invite-register-page">
|
||||
<div class="invite-card-container">
|
||||
<!-- 顶部邀请人特权卡片 -->
|
||||
<div class="invite-header-banner">
|
||||
<div class="banner-badge">
|
||||
<el-icon><Present /></el-icon>
|
||||
<span>专属邀请专享特权</span>
|
||||
</div>
|
||||
<h1 class="banner-title">加入 {{ siteStore.siteName || '哈夫币' }}</h1>
|
||||
<p class="banner-sub">安全便捷的游戏资产租赁与寄售平台 · 租客号主双边返利</p>
|
||||
|
||||
<div v-if="loadingInfo" class="inviter-box loading">
|
||||
<span>正在核对邀请信息...</span>
|
||||
</div>
|
||||
<div v-else-if="codeInfo && codeInfo.valid" class="inviter-box valid">
|
||||
<div class="inviter-avatar">
|
||||
<img v-if="codeInfo.inviter_avatar" :src="codeInfo.inviter_avatar" alt="avatar" />
|
||||
<span v-else>{{ (codeInfo.inviter_nickname || '友').slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="inviter-meta">
|
||||
<div class="inviter-name">
|
||||
来自 <strong>{{ codeInfo.inviter_nickname || '平台认证号主' }}</strong> 的邀请
|
||||
</div>
|
||||
<div class="inviter-desc">
|
||||
专属邀请码:<code class="code-pill">{{ inviteCode }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="inviter-box invalid">
|
||||
<el-icon color="#e6a23c"><WarningFilled /></el-icon>
|
||||
<span
|
||||
>邀请码
|
||||
<strong>{{ inviteCode }}</strong> 暂未生效或邀请人尚未实名,注册仍可正常完成</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返利权益亮点条 -->
|
||||
<div class="rebate-perks-row">
|
||||
<div class="perk-item">
|
||||
<span class="perk-rate">60%</span>
|
||||
<span class="perk-label">首月高额分成</span>
|
||||
</div>
|
||||
<div class="perk-divider"></div>
|
||||
<div class="perk-item">
|
||||
<span class="perk-rate">30%</span>
|
||||
<span class="perk-label">次月奖励分成</span>
|
||||
</div>
|
||||
<div class="perk-divider"></div>
|
||||
<div class="perk-item">
|
||||
<span class="perk-rate">15%</span>
|
||||
<span class="perk-label">往后终身永久</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单卡片 -->
|
||||
<div class="register-card-body">
|
||||
<div class="form-title">创建新账号</div>
|
||||
|
||||
<form class="register-form" @submit.prevent="handleSubmit">
|
||||
<!-- 手机号 -->
|
||||
<div class="form-group">
|
||||
<label>手机号码</label>
|
||||
<div class="input-wrapper">
|
||||
<el-icon class="input-icon"><Iphone /></el-icon>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
maxlength="11"
|
||||
placeholder="请输入11位手机号码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图形验证码 -->
|
||||
<div class="form-group">
|
||||
<label>图形验证码</label>
|
||||
<div class="input-wrapper captcha-wrapper">
|
||||
<el-icon class="input-icon"><Key /></el-icon>
|
||||
<input
|
||||
v-model="form.captchaCode"
|
||||
type="text"
|
||||
maxlength="4"
|
||||
placeholder="请输入图形验证码"
|
||||
required
|
||||
/>
|
||||
<div class="captcha-img-box" @click="refreshCaptcha">
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="captcha" />
|
||||
<span v-else>点击获取</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 短信验证码 -->
|
||||
<div class="form-group">
|
||||
<label>短信验证码</label>
|
||||
<div class="input-wrapper sms-wrapper">
|
||||
<el-icon class="input-icon"><Key /></el-icon>
|
||||
<input
|
||||
v-model="form.smsCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="6位短信验证码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sms-btn"
|
||||
:disabled="smsCountdown > 0"
|
||||
@click="handleSendSms"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}秒后重试` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div class="form-group">
|
||||
<label>登录密码</label>
|
||||
<div class="input-wrapper">
|
||||
<el-icon class="input-icon"><Lock /></el-icon>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="设置登录密码(8-20位)"
|
||||
required
|
||||
/>
|
||||
<button type="button" class="pwd-toggle" @click="showPassword = !showPassword">
|
||||
<el-icon><View v-if="showPassword" /><Hide v-else /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<div class="form-group">
|
||||
<label>确认密码</label>
|
||||
<div class="input-wrapper">
|
||||
<el-icon class="input-icon"><Lock /></el-icon>
|
||||
<input
|
||||
v-model="form.confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
placeholder="再次输入登录密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="pwd-toggle"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<el-icon><View v-if="showConfirmPassword" /><Hide v-else /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请码锁定提示 -->
|
||||
<div class="invite-bind-notice">
|
||||
<div class="bind-badge">
|
||||
<el-icon color="#67c23a"><Check /></el-icon>
|
||||
<span
|
||||
>已绑定邀请码:<strong>{{ inviteCode }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<p class="bind-tip">注册成功后将自动建立推荐返利关联</p>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<button type="submit" class="submit-btn" :disabled="submitting">
|
||||
{{ submitting ? '正在注册并绑定...' : '立即注册并领取返利特权' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-redirect">
|
||||
已有账号?
|
||||
<RouterLink to="/login" class="login-link">直接登录</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.invite-register-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8edf9 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.invite-card-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 12px 40px rgba(30, 58, 138, 0.08);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
}
|
||||
|
||||
.invite-header-banner {
|
||||
background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%);
|
||||
color: #ffffff;
|
||||
padding: 28px 24px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.banner-sub {
|
||||
margin: 8px 0 16px;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.inviter-box {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.inviter-box.invalid {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inviter-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inviter-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.inviter-meta {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inviter-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.inviter-desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.code-pill {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rebate-perks-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
background: #f8fafc;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.perk-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.perk-rate {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #dc2626;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.perk-label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.perk-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.register-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: #2563eb;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.captcha-img-box {
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e2e8f0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.captcha-img-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sms-btn {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.sms-btn:disabled {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pwd-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.invite-bind-notice {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bind-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.bind-tip {
|
||||
margin: 4px 0 0 22px;
|
||||
font-size: 11px;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-redirect {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,12 @@ const allNavGroups: NavGroup[] = [
|
||||
icon: Coin,
|
||||
permission: 'distribution:settlement_view',
|
||||
},
|
||||
{
|
||||
label: '邀请返利管理',
|
||||
to: adminPath('invitations'),
|
||||
icon: Present,
|
||||
permission: 'distribution:site_view',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
class="dropdown-basic-item"
|
||||
to="/invitations"
|
||||
@click="showUserDropdown = false"
|
||||
>
|
||||
<el-icon><Coin /></el-icon>
|
||||
<span>邀请返利</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user