feat(affiliate): add feature toggle and per-user custom invite settings

- 在系统设置「功能开关」中新增邀请返利总开关,默认关闭;
  关闭态:菜单隐藏、注册忽略 aff、新充值不返利,但已有 quota 仍可转余额
- 支持管理员为指定用户设置专属邀请码(覆盖随机码,全局唯一)
- 支持管理员为指定用户设置专属返利比例(覆盖全局比例,可单条/批量调整)
- 在系统设置邀请返利卡片内嵌入专属用户管理表格(搜索/编辑/批量/删除),
  删除采用项目通用 ConfirmDialog,会同时清除专属比例并把邀请码重置为系统随机码
- /affiliate 用户页新增「我的返利比例」卡片与动态使用说明,让用户直观看到
  分享后能拿到多少(同源 resolveRebateRatePercent 计算,与实际充值一致)
- 新增数据库迁移 132 添加 aff_rebate_rate_percent 与 aff_code_custom 列
- 新增 admin 路由组 /api/v1/admin/affiliates/users/* 共 5 个端点
- AffiliateService 改为只依赖 *SettingService,去除冗余的 SettingRepository
- 邀请码格式校验放宽到 [A-Z0-9_-]{4,32},兼容旧 12 位系统码与新自定义码
- 补充单元测试与集成测试覆盖新方法、冲突路径与边界值
This commit is contained in:
shaw
2026-04-25 19:14:34 +08:00
parent 9d1751ec57
commit 4e1bb2b445
28 changed files with 2010 additions and 141 deletions

View File

@@ -70,7 +70,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
affiliateRepository := repository.NewAffiliateRepository(client, db) affiliateRepository := repository.NewAffiliateRepository(client, db)
affiliateService := service.NewAffiliateService(affiliateRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCacheService) affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService) authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient) redeemCache := repository.NewRedeemCache(redisClient)
@@ -231,7 +231,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository) channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService) channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler) affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler, affiliateHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)

View File

@@ -0,0 +1,183 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// AffiliateHandler handles admin affiliate (邀请返利) management:
// listing users with custom settings, updating per-user invite codes
// and exclusive rebate rates, and batch operations.
type AffiliateHandler struct {
affiliateService *service.AffiliateService
adminService service.AdminService
}
// NewAffiliateHandler creates a new admin affiliate handler.
func NewAffiliateHandler(affiliateService *service.AffiliateService, adminService service.AdminService) *AffiliateHandler {
return &AffiliateHandler{
affiliateService: affiliateService,
adminService: adminService,
}
}
// ListUsers returns paginated users with custom affiliate settings.
// GET /api/v1/admin/affiliates/users
func (h *AffiliateHandler) ListUsers(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
search := c.Query("search")
entries, total, err := h.affiliateService.AdminListCustomUsers(c.Request.Context(), service.AffiliateAdminFilter{
Search: search,
Page: page,
PageSize: pageSize,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, entries, total, page, pageSize)
}
// UpdateUserSettings updates a user's affiliate settings.
// PUT /api/v1/admin/affiliates/users/:user_id
//
// Both fields are optional and applied independently.
type UpdateAffiliateUserRequest struct {
AffCode *string `json:"aff_code"`
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
// ClearRebateRate explicitly clears the per-user rate (sets it to NULL).
// Used to disambiguate from "field not provided".
ClearRebateRate bool `json:"clear_rebate_rate"`
}
func (h *AffiliateHandler) UpdateUserSettings(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
var req UpdateAffiliateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if req.AffCode != nil {
if err := h.affiliateService.AdminUpdateUserAffCode(c.Request.Context(), userID, *req.AffCode); err != nil {
response.ErrorFrom(c, err)
return
}
}
if req.ClearRebateRate {
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
response.ErrorFrom(c, err)
return
}
} else if req.AffRebateRatePercent != nil {
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, req.AffRebateRatePercent); err != nil {
response.ErrorFrom(c, err)
return
}
}
response.Success(c, gin.H{"user_id": userID})
}
// ClearUserSettings removes ALL of a user's custom affiliate settings — clears
// the exclusive rebate rate AND regenerates the invite code as a new system
// random one. Conceptually this "removes the user from the custom list".
//
// Both writes happen in this handler; failure of one leaves the other applied,
// but the operation is idempotent so the admin can re-run it safely.
// DELETE /api/v1/admin/affiliates/users/:user_id
func (h *AffiliateHandler) ClearUserSettings(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
response.ErrorFrom(c, err)
return
}
if _, err := h.affiliateService.AdminResetUserAffCode(c.Request.Context(), userID); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"user_id": userID})
}
// BatchSetRate applies the same rebate rate (or clears it) to multiple users.
//
// Protocol: pass `clear: true` to clear rates (aff_rebate_rate_percent is
// ignored). Otherwise aff_rebate_rate_percent is required and applied to
// every user_id. The explicit `clear` flag exists because Go's JSON unmarshal
// can't distinguish a missing field from `null`, and a silent clear from a
// frontend that forgot to include the rate would be a footgun.
//
// POST /api/v1/admin/affiliates/users/batch-rate
type BatchSetRateRequest struct {
UserIDs []int64 `json:"user_ids" binding:"required"`
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
Clear bool `json:"clear"`
}
func (h *AffiliateHandler) BatchSetRate(c *gin.Context) {
var req BatchSetRateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.UserIDs) == 0 {
response.BadRequest(c, "user_ids cannot be empty")
return
}
if !req.Clear && req.AffRebateRatePercent == nil {
response.BadRequest(c, "aff_rebate_rate_percent is required unless clear=true")
return
}
rate := req.AffRebateRatePercent
if req.Clear {
rate = nil
}
if err := h.affiliateService.AdminBatchSetUserRebateRate(c.Request.Context(), req.UserIDs, rate); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"affected": len(req.UserIDs)})
}
// AffiliateUserSummary is the minimal user shape returned by LookupUsers,
// shared with the frontend's add-custom-user picker.
type AffiliateUserSummary struct {
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
}
// LookupUsers searches users by email/username for the "add custom user" modal.
// GET /api/v1/admin/affiliates/users/lookup?q=
func (h *AffiliateHandler) LookupUsers(c *gin.Context) {
keyword := c.Query("q")
if keyword == "" {
response.Success(c, []AffiliateUserSummary{})
return
}
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 20, service.UserListFilters{Search: keyword}, "email", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
}
result := make([]AffiliateUserSummary, len(users))
for i, u := range users {
result[i] = AffiliateUserSummary{ID: u.ID, Email: u.Email, Username: u.Username}
}
response.Success(c, result)
}

View File

@@ -242,6 +242,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled, AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
} }
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults)) response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
} }
@@ -441,6 +443,9 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing) // Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"` AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled *bool `json:"affiliate_enabled"`
} }
// UpdateSettings 更新系统设置 // UpdateSettings 更新系统设置
@@ -1265,6 +1270,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
} }
return previousSettings.AvailableChannelsEnabled return previousSettings.AvailableChannelsEnabled
}(), }(),
AffiliateEnabled: func() bool {
if req.AffiliateEnabled != nil {
return *req.AffiliateEnabled
}
return previousSettings.AffiliateEnabled
}(),
} }
authSourceDefaults := &service.AuthSourceDefaultSettings{ authSourceDefaults := &service.AuthSourceDefaultSettings{
@@ -1502,6 +1513,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
} }
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults)) response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
} }
@@ -1870,6 +1883,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled { if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled") changed = append(changed, "available_channels_enabled")
} }
if before.AffiliateEnabled != after.AffiliateEnabled {
changed = append(changed, "affiliate_enabled")
}
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults) changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
return changed return changed
} }

View File

@@ -192,6 +192,9 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view) // Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"` AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled bool `json:"affiliate_enabled"`
} }
type DefaultSubscriptionSetting struct { type DefaultSubscriptionSetting struct {
@@ -244,6 +247,8 @@ type PublicSettings struct {
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"` AvailableChannelsEnabled bool `json:"available_channels_enabled"`
AffiliateEnabled bool `json:"affiliate_enabled"`
} }
// OverloadCooldownSettings 529过载冷却配置 DTO // OverloadCooldownSettings 529过载冷却配置 DTO

View File

@@ -34,6 +34,7 @@ type AdminHandlers struct {
ChannelMonitor *admin.ChannelMonitorHandler ChannelMonitor *admin.ChannelMonitorHandler
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
Payment *admin.PaymentHandler Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
} }
// Handlers contains all HTTP handlers // Handlers contains all HTTP handlers

View File

@@ -75,5 +75,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled, AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
}) })
} }

View File

@@ -37,6 +37,7 @@ func ProvideAdminHandlers(
channelMonitorHandler *admin.ChannelMonitorHandler, channelMonitorHandler *admin.ChannelMonitorHandler,
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler, channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
paymentHandler *admin.PaymentHandler, paymentHandler *admin.PaymentHandler,
affiliateHandler *admin.AffiliateHandler,
) *AdminHandlers { ) *AdminHandlers {
return &AdminHandlers{ return &AdminHandlers{
Dashboard: dashboardHandler, Dashboard: dashboardHandler,
@@ -67,6 +68,7 @@ func ProvideAdminHandlers(
ChannelMonitor: channelMonitorHandler, ChannelMonitor: channelMonitorHandler,
ChannelMonitorTemplate: channelMonitorTemplateHandler, ChannelMonitorTemplate: channelMonitorTemplateHandler,
Payment: paymentHandler, Payment: paymentHandler,
Affiliate: affiliateHandler,
} }
} }
@@ -169,6 +171,7 @@ var ProviderSet = wire.NewSet(
admin.NewChannelMonitorHandler, admin.NewChannelMonitorHandler,
admin.NewChannelMonitorRequestTemplateHandler, admin.NewChannelMonitorRequestTemplateHandler,
admin.NewPaymentHandler, admin.NewPaymentHandler,
admin.NewAffiliateHandler,
// AdminHandlers and Handlers constructors // AdminHandlers and Handlers constructors
ProvideAdminHandlers, ProvideAdminHandlers,

View File

@@ -294,6 +294,8 @@ func queryAffiliateByUserID(ctx context.Context, client affiliateQueryExecer, us
rows, err := client.QueryContext(ctx, ` rows, err := client.QueryContext(ctx, `
SELECT user_id, SELECT user_id,
aff_code, aff_code,
aff_code_custom,
aff_rebate_rate_percent,
inviter_id, inviter_id,
aff_count, aff_count,
aff_quota::double precision, aff_quota::double precision,
@@ -315,9 +317,12 @@ WHERE user_id = $1`, userID)
var out service.AffiliateSummary var out service.AffiliateSummary
var inviterID sql.NullInt64 var inviterID sql.NullInt64
var rebateRate sql.NullFloat64
if err := rows.Scan( if err := rows.Scan(
&out.UserID, &out.UserID,
&out.AffCode, &out.AffCode,
&out.AffCodeCustom,
&rebateRate,
&inviterID, &inviterID,
&out.AffCount, &out.AffCount,
&out.AffQuota, &out.AffQuota,
@@ -330,6 +335,10 @@ WHERE user_id = $1`, userID)
if inviterID.Valid { if inviterID.Valid {
out.InviterID = &inviterID.Int64 out.InviterID = &inviterID.Int64
} }
if rebateRate.Valid {
v := rebateRate.Float64
out.AffRebateRatePercent = &v
}
return &out, nil return &out, nil
} }
@@ -337,6 +346,8 @@ func queryAffiliateByCode(ctx context.Context, client affiliateQueryExecer, code
rows, err := client.QueryContext(ctx, ` rows, err := client.QueryContext(ctx, `
SELECT user_id, SELECT user_id,
aff_code, aff_code,
aff_code_custom,
aff_rebate_rate_percent,
inviter_id, inviter_id,
aff_count, aff_count,
aff_quota::double precision, aff_quota::double precision,
@@ -360,9 +371,12 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
var out service.AffiliateSummary var out service.AffiliateSummary
var inviterID sql.NullInt64 var inviterID sql.NullInt64
var rebateRate sql.NullFloat64
if err := rows.Scan( if err := rows.Scan(
&out.UserID, &out.UserID,
&out.AffCode, &out.AffCode,
&out.AffCodeCustom,
&rebateRate,
&inviterID, &inviterID,
&out.AffCount, &out.AffCount,
&out.AffQuota, &out.AffQuota,
@@ -375,6 +389,10 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
if inviterID.Valid { if inviterID.Valid {
out.InviterID = &inviterID.Int64 out.InviterID = &inviterID.Int64
} }
if rebateRate.Valid {
v := rebateRate.Float64
out.AffRebateRatePercent = &v
}
return &out, nil return &out, nil
} }
@@ -418,3 +436,229 @@ func isAffiliateUniqueViolation(err error) bool {
} }
return false return false
} }
// UpdateUserAffCode 改写用户的邀请码(自定义专属邀请码)。
// 唯一性冲突返回 ErrAffiliateCodeTaken。
func (r *affiliateRepository) UpdateUserAffCode(ctx context.Context, userID int64, newCode string) error {
if userID <= 0 {
return service.ErrUserNotFound
}
code := strings.ToUpper(strings.TrimSpace(newCode))
if code == "" {
return service.ErrAffiliateCodeInvalid
}
return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
return err
}
res, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliates
SET aff_code = $1,
aff_code_custom = true,
updated_at = NOW()
WHERE user_id = $2`, code, userID)
if err != nil {
if isAffiliateUniqueViolation(err) {
return service.ErrAffiliateCodeTaken
}
return fmt.Errorf("update aff_code: %w", err)
}
affected, _ := res.RowsAffected()
if affected == 0 {
return service.ErrUserNotFound
}
return nil
})
}
// ResetUserAffCode 把 aff_code 还原为系统随机码,并清除 aff_code_custom 标记。
func (r *affiliateRepository) ResetUserAffCode(ctx context.Context, userID int64) (string, error) {
if userID <= 0 {
return "", service.ErrUserNotFound
}
var newCode string
err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
return err
}
for i := 0; i < affiliateCodeMaxAttempts; i++ {
candidate, codeErr := generateAffiliateCode()
if codeErr != nil {
return codeErr
}
res, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliates
SET aff_code = $1,
aff_code_custom = false,
updated_at = NOW()
WHERE user_id = $2`, candidate, userID)
if err != nil {
if isAffiliateUniqueViolation(err) {
continue
}
return fmt.Errorf("reset aff_code: %w", err)
}
affected, _ := res.RowsAffected()
if affected == 0 {
return service.ErrUserNotFound
}
newCode = candidate
return nil
}
return fmt.Errorf("reset aff_code: exhausted attempts")
})
if err != nil {
return "", err
}
return newCode, nil
}
// SetUserRebateRate 设置或清除用户专属返利比例。ratePercent==nil 表示清除(沿用全局)。
func (r *affiliateRepository) SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error {
if userID <= 0 {
return service.ErrUserNotFound
}
return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
return err
}
// nullableArg lets us use a single UPDATE for both "set value" and
// "clear" cases — database/sql converts nil interface{} to SQL NULL.
res, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliates
SET aff_rebate_rate_percent = $1,
updated_at = NOW()
WHERE user_id = $2`, nullableArg(ratePercent), userID)
if err != nil {
return fmt.Errorf("set aff_rebate_rate_percent: %w", err)
}
affected, _ := res.RowsAffected()
if affected == 0 {
return service.ErrUserNotFound
}
return nil
})
}
// BatchSetUserRebateRate 批量为多个用户设置专属比例nil 清除)。
func (r *affiliateRepository) BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error {
if len(userIDs) == 0 {
return nil
}
return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
for _, uid := range userIDs {
if uid <= 0 {
continue
}
if _, err := ensureUserAffiliateWithClient(txCtx, txClient, uid); err != nil {
return err
}
}
_, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliates
SET aff_rebate_rate_percent = $1,
updated_at = NOW()
WHERE user_id = ANY($2)`, nullableArg(ratePercent), pq.Array(userIDs))
if err != nil {
return fmt.Errorf("batch set aff_rebate_rate_percent: %w", err)
}
return nil
})
}
// nullableArg unwraps a *float64 into an interface{} suitable for SQL parameter
// binding: nil pointer → SQL NULL, non-nil → the float value.
func nullableArg(v *float64) any {
if v == nil {
return nil
}
return *v
}
// ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。
//
// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索"
// 空 search 时拼接出的 LIKE 模式为 "%%",匹配所有行;非空时按 ILIKE 子串匹配。
// 这避免了为两种情况维护两份 SQL 模板。
func (r *affiliateRepository) ListUsersWithCustomSettings(ctx context.Context, filter service.AffiliateAdminFilter) ([]service.AffiliateAdminEntry, int64, error) {
page := filter.Page
if page < 1 {
page = 1
}
pageSize := filter.PageSize
if pageSize <= 0 || pageSize > 200 {
pageSize = 20
}
offset := (page - 1) * pageSize
likePattern := "%" + strings.TrimSpace(filter.Search) + "%"
const baseFrom = `
FROM user_affiliates ua
JOIN users u ON u.id = ua.user_id
WHERE (ua.aff_code_custom = true OR ua.aff_rebate_rate_percent IS NOT NULL)
AND (u.email ILIKE $1 OR u.username ILIKE $1)`
client := clientFromContext(ctx, r.client)
total, err := scanInt64(ctx, client, "SELECT COUNT(*)"+baseFrom, likePattern)
if err != nil {
return nil, 0, fmt.Errorf("count affiliate admin entries: %w", err)
}
listQuery := `
SELECT ua.user_id,
COALESCE(u.email, ''),
COALESCE(u.username, ''),
ua.aff_code,
ua.aff_code_custom,
ua.aff_rebate_rate_percent,
ua.aff_count` + baseFrom + `
ORDER BY ua.updated_at DESC
LIMIT $2 OFFSET $3`
rows, err := client.QueryContext(ctx, listQuery, likePattern, pageSize, offset)
if err != nil {
return nil, 0, fmt.Errorf("list affiliate admin entries: %w", err)
}
defer func() { _ = rows.Close() }()
entries := make([]service.AffiliateAdminEntry, 0)
for rows.Next() {
var e service.AffiliateAdminEntry
var rebate sql.NullFloat64
if err := rows.Scan(&e.UserID, &e.Email, &e.Username, &e.AffCode,
&e.AffCodeCustom, &rebate, &e.AffCount); err != nil {
return nil, 0, err
}
if rebate.Valid {
v := rebate.Float64
e.AffRebateRatePercent = &v
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return entries, total, nil
}
// scanInt64 runs a query expected to return a single int64 column (e.g. COUNT).
func scanInt64(ctx context.Context, client affiliateQueryExecer, query string, args ...any) (int64, error) {
rows, err := client.QueryContext(ctx, query, args...)
if err != nil {
return 0, err
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
if err := rows.Err(); err != nil {
return 0, err
}
return 0, nil
}
var v int64
if err := rows.Scan(&v); err != nil {
return 0, err
}
return v, nil
}

View File

@@ -182,3 +182,218 @@ VALUES ($1, $2, 0, 0, NOW(), NOW())`, u.ID, affCode)
"SELECT balance::double precision FROM users WHERE id = $1", u.ID) "SELECT balance::double precision FROM users WHERE id = $1", u.ID)
require.InDelta(t, 3.21, persistedBalance, 1e-9) require.InDelta(t, 3.21, persistedBalance, 1e-9)
} }
// TestAffiliateRepository_AdminCustomCode covers the success path of admin
// invite-code rewrite + reset within a shared test transaction:
// - UpdateUserAffCode replaces aff_code, sets aff_code_custom=true, lookup works
// - the old code can no longer be found
// - ResetUserAffCode reverts aff_code_custom and assigns a new system-format code
//
// The conflict path (duplicate code → ErrAffiliateCodeTaken) lives in its own
// test because a unique-violation aborts the surrounding Postgres tx, which
// would poison subsequent assertions in the same transaction.
func TestAffiliateRepository_AdminCustomCode(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-custom-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
})
original, err := repo.EnsureUserAffiliate(txCtx, u.ID)
require.NoError(t, err)
require.False(t, original.AffCodeCustom, "system-generated codes start as non-custom")
originalCode := original.AffCode
// Rewrite to a custom code
customCode := fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)
require.NoError(t, repo.UpdateUserAffCode(txCtx, u.ID, customCode))
updated, err := repo.EnsureUserAffiliate(txCtx, u.ID)
require.NoError(t, err)
require.Equal(t, customCode, updated.AffCode)
require.True(t, updated.AffCodeCustom)
// Lookup by new custom code finds the user
byCode, err := repo.GetAffiliateByCode(txCtx, customCode)
require.NoError(t, err)
require.Equal(t, u.ID, byCode.UserID)
// Old system code should no longer match
_, err = repo.GetAffiliateByCode(txCtx, originalCode)
require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
// Reset back to a fresh system code, clears custom flag
newSysCode, err := repo.ResetUserAffCode(txCtx, u.ID)
require.NoError(t, err)
require.NotEqual(t, customCode, newSysCode)
reset, err := repo.EnsureUserAffiliate(txCtx, u.ID)
require.NoError(t, err)
require.Equal(t, newSysCode, reset.AffCode)
require.False(t, reset.AffCodeCustom)
// The old custom code is now free again
_, err = repo.GetAffiliateByCode(txCtx, customCode)
require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
}
// TestAffiliateRepository_AdminCustomCode_Conflict isolates the unique-violation
// path. PostgreSQL aborts the enclosing tx when a unique constraint fires, so
// this test must be the only assertion and run in its own tx — production
// callers each have their own outer tx, so this matches real behavior.
func TestAffiliateRepository_AdminCustomCode_Conflict(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
taker := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-conflict-taker-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser, Status: service.StatusActive,
})
requester := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-conflict-req-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser, Status: service.StatusActive,
})
takenCode := fmt.Sprintf("HOT%09d", time.Now().UnixNano()%1_000_000_000)
require.NoError(t, repo.UpdateUserAffCode(txCtx, taker.ID, takenCode))
// Now requester tries to grab the same code → conflict.
err := repo.UpdateUserAffCode(txCtx, requester.ID, takenCode)
require.ErrorIs(t, err, service.ErrAffiliateCodeTaken)
}
// TestAffiliateRepository_AdminRebateRate covers per-user exclusive rate
// set/clear and the Batch variant including NULL semantics.
func TestAffiliateRepository_AdminRebateRate(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u1 := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-rate-%d-a@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
})
u2 := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-rate-%d-b@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
})
// Set exclusive rate for u1
rate := 42.5
require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, &rate))
got, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
require.NoError(t, err)
require.NotNil(t, got.AffRebateRatePercent)
require.InDelta(t, 42.5, *got.AffRebateRatePercent, 1e-9)
// Clear exclusive rate
require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, nil))
cleared, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
require.NoError(t, err)
require.Nil(t, cleared.AffRebateRatePercent)
// Batch set both users
batchRate := 15.0
require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, &batchRate))
for _, uid := range []int64{u1.ID, u2.ID} {
v, err := repo.EnsureUserAffiliate(txCtx, uid)
require.NoError(t, err)
require.NotNil(t, v.AffRebateRatePercent)
require.InDelta(t, 15.0, *v.AffRebateRatePercent, 1e-9)
}
// Batch clear
require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, nil))
for _, uid := range []int64{u1.ID, u2.ID} {
v, err := repo.EnsureUserAffiliate(txCtx, uid)
require.NoError(t, err)
require.Nil(t, v.AffRebateRatePercent)
}
}
// TestAffiliateRepository_ListUsersWithCustomSettings verifies the admin list
// only includes users with at least one override applied.
func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
// User without any custom config — should NOT appear in the list.
plainEmail := fmt.Sprintf("affiliate-plain-%d@example.com", time.Now().UnixNano())
uPlain := mustCreateUser(t, client, &service.User{
Email: plainEmail, PasswordHash: "hash",
Role: service.RoleUser, Status: service.StatusActive,
})
_, err := repo.EnsureUserAffiliate(txCtx, uPlain.ID)
require.NoError(t, err)
// User with a custom code — should appear.
uCode := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-codeonly-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser, Status: service.StatusActive,
})
require.NoError(t, repo.UpdateUserAffCode(txCtx, uCode.ID, fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)))
// User with only an exclusive rate — should appear.
uRate := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-rateonly-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser, Status: service.StatusActive,
})
r := 33.3
require.NoError(t, repo.SetUserRebateRate(txCtx, uRate.ID, &r))
entries, total, err := repo.ListUsersWithCustomSettings(txCtx, service.AffiliateAdminFilter{
Page: 1, PageSize: 100,
})
require.NoError(t, err)
// Build a quick lookup to assert per-user attributes (other tests may have
// inserted custom rows in the same DB; we only care about our 3).
byUserID := make(map[int64]service.AffiliateAdminEntry, len(entries))
for _, e := range entries {
byUserID[e.UserID] = e
}
require.NotContains(t, byUserID, uPlain.ID, "users without overrides must not appear")
codeEntry, ok := byUserID[uCode.ID]
require.True(t, ok, "custom-code user missing from list")
require.True(t, codeEntry.AffCodeCustom)
require.Nil(t, codeEntry.AffRebateRatePercent)
rateEntry, ok := byUserID[uRate.ID]
require.True(t, ok, "custom-rate user missing from list")
require.False(t, rateEntry.AffCodeCustom)
require.NotNil(t, rateEntry.AffRebateRatePercent)
require.InDelta(t, 33.3, *rateEntry.AffRebateRatePercent, 1e-9)
require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows")
}

View File

@@ -775,6 +775,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true, "channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60, "channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false, "available_channels_enabled": false,
"affiliate_enabled": false,
"wechat_connect_enabled": false, "wechat_connect_enabled": false,
"wechat_connect_app_id": "", "wechat_connect_app_id": "",
"wechat_connect_app_secret_configured": false, "wechat_connect_app_secret_configured": false,
@@ -951,6 +952,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true, "channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60, "channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false, "available_channels_enabled": false,
"affiliate_enabled": false,
"wechat_connect_enabled": true, "wechat_connect_enabled": true,
"wechat_connect_app_id": "wx-open-config", "wechat_connect_app_id": "wx-open-config",
"wechat_connect_app_secret_configured": true, "wechat_connect_app_secret_configured": true,

View File

@@ -91,6 +91,9 @@ func RegisterAdminRoutes(
// 渠道监控 // 渠道监控
registerChannelMonitorRoutes(admin, h) registerChannelMonitorRoutes(admin, h)
// 邀请返利(专属用户管理)
registerAffiliateRoutes(admin, h)
} }
} }
@@ -594,3 +597,18 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply) templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply)
} }
} }
// registerAffiliateRoutes 注册邀请返利的管理端路由(专属用户配置)
func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
affiliates := admin.Group("/affiliates")
{
users := affiliates.Group("/users")
{
users.GET("", h.Admin.Affiliate.ListUsers)
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
}
}
}

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"math" "math"
"strconv"
"strings" "strings"
"time" "time"
@@ -15,28 +14,39 @@ import (
var ( var (
ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found") ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found")
ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code") ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code")
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound") ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer") ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
) )
const ( const (
affiliateInviteesLimit = 100 affiliateInviteesLimit = 100
// affiliateCodeFormatLength must stay in sync with repository.affiliateCodeLength. // AffiliateCodeMinLength / AffiliateCodeMaxLength bound both system-generated
affiliateCodeFormatLength = 12 // 12-char codes and admin-customized codes (e.g. "VIP2026").
AffiliateCodeMinLength = 4
AffiliateCodeMaxLength = 32
) )
// affiliateCodeValidChar is a 256-entry lookup table mirroring the charset used // affiliateCodeValidChar accepts uppercase letters, digits, underscore and dash.
// by the repository's generateAffiliateCode (A-Z minus I/O, digits 2-9). // All input passes through strings.ToUpper before validation, so lowercase from
// users is normalized — admins may supply mixed case in their UI.
var affiliateCodeValidChar = func() [256]bool { var affiliateCodeValidChar = func() [256]bool {
var tbl [256]bool var tbl [256]bool
for _, c := range []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") { for c := byte('A'); c <= 'Z'; c++ {
tbl[c] = true tbl[c] = true
} }
for c := byte('0'); c <= '9'; c++ {
tbl[c] = true
}
tbl['_'] = true
tbl['-'] = true
return tbl return tbl
}() }()
// isValidAffiliateCodeFormat validates code format for both binding (user input)
// and admin updates. Caller is expected to upper-case the input first.
func isValidAffiliateCodeFormat(code string) bool { func isValidAffiliateCodeFormat(code string) bool {
if len(code) != affiliateCodeFormatLength { if len(code) < AffiliateCodeMinLength || len(code) > AffiliateCodeMaxLength {
return false return false
} }
for i := 0; i < len(code); i++ { for i := 0; i < len(code); i++ {
@@ -50,6 +60,8 @@ func isValidAffiliateCodeFormat(code string) bool {
type AffiliateSummary struct { type AffiliateSummary struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
AffCode string `json:"aff_code"` AffCode string `json:"aff_code"`
AffCodeCustom bool `json:"aff_code_custom"`
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
InviterID *int64 `json:"inviter_id,omitempty"` InviterID *int64 `json:"inviter_id,omitempty"`
AffCount int `json:"aff_count"` AffCount int `json:"aff_count"`
AffQuota float64 `json:"aff_quota"` AffQuota float64 `json:"aff_quota"`
@@ -72,6 +84,10 @@ type AffiliateDetail struct {
AffCount int `json:"aff_count"` AffCount int `json:"aff_count"`
AffQuota float64 `json:"aff_quota"` AffQuota float64 `json:"aff_quota"`
AffHistoryQuota float64 `json:"aff_history_quota"` AffHistoryQuota float64 `json:"aff_history_quota"`
// EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例:
// 优先用户自己的专属比例aff_rebate_rate_percent否则回退到全局比例。
// 用于在用户的 /affiliate 页面直观展示「分享后能拿到多少」。
EffectiveRebateRatePercent float64 `json:"effective_rebate_rate_percent"`
Invitees []AffiliateInvitee `json:"invitees"` Invitees []AffiliateInvitee `json:"invitees"`
} }
@@ -82,24 +98,57 @@ type AffiliateRepository interface {
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error)
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
// 管理端:用户级专属配置
UpdateUserAffCode(ctx context.Context, userID int64, newCode string) error
ResetUserAffCode(ctx context.Context, userID int64) (string, error)
SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error
BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error
ListUsersWithCustomSettings(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error)
}
// AffiliateAdminFilter 列表筛选条件
type AffiliateAdminFilter struct {
Search string
Page int
PageSize int
}
// AffiliateAdminEntry 专属用户列表条目
type AffiliateAdminEntry struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
AffCode string `json:"aff_code"`
AffCodeCustom bool `json:"aff_code_custom"`
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
AffCount int `json:"aff_count"`
} }
type AffiliateService struct { type AffiliateService struct {
repo AffiliateRepository repo AffiliateRepository
settingRepo SettingRepository settingService *SettingService
authCacheInvalidator APIKeyAuthCacheInvalidator authCacheInvalidator APIKeyAuthCacheInvalidator
billingCacheService *BillingCacheService billingCacheService *BillingCacheService
} }
func NewAffiliateService(repo AffiliateRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService { func NewAffiliateService(repo AffiliateRepository, settingService *SettingService, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService {
return &AffiliateService{ return &AffiliateService{
repo: repo, repo: repo,
settingRepo: settingRepo, settingService: settingService,
authCacheInvalidator: authCacheInvalidator, authCacheInvalidator: authCacheInvalidator,
billingCacheService: billingCacheService, billingCacheService: billingCacheService,
} }
} }
// IsEnabled reports whether the affiliate (邀请返利) feature is turned on.
func (s *AffiliateService) IsEnabled(ctx context.Context) bool {
if s == nil || s.settingService == nil {
return AffiliateEnabledDefault
}
return s.settingService.IsAffiliateEnabled(ctx)
}
func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) { func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) {
if userID <= 0 { if userID <= 0 {
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user") return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
@@ -126,6 +175,7 @@ func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64)
AffCount: summary.AffCount, AffCount: summary.AffCount,
AffQuota: summary.AffQuota, AffQuota: summary.AffQuota,
AffHistoryQuota: summary.AffHistoryQuota, AffHistoryQuota: summary.AffHistoryQuota,
EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary),
Invitees: invitees, Invitees: invitees,
}, nil }, nil
} }
@@ -135,12 +185,16 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
if code == "" { if code == "" {
return nil return nil
} }
if !isValidAffiliateCodeFormat(code) {
return ErrAffiliateCodeInvalid
}
if s == nil || s.repo == nil { if s == nil || s.repo == nil {
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable") return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
} }
// 总开关关闭时,注册阶段静默忽略 aff 参数(不报错,避免阻断注册流程)
if !s.IsEnabled(ctx) {
return nil
}
if !isValidAffiliateCodeFormat(code) {
return ErrAffiliateCodeInvalid
}
selfSummary, err := s.repo.EnsureUserAffiliate(ctx, userID) selfSummary, err := s.repo.EnsureUserAffiliate(ctx, userID)
if err != nil { if err != nil {
@@ -178,6 +232,10 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) { if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) {
return 0, nil return 0, nil
} }
// 总开关关闭时,新充值不再产生返利
if !s.IsEnabled(ctx) {
return 0, nil
}
inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID) inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
if err != nil { if err != nil {
@@ -187,16 +245,17 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
return 0, nil return 0, nil
} }
rebateRatePercent := s.loadAffiliateRebateRatePercent(ctx) // 加载邀请人 profile优先使用专属比例覆盖全局
inviterSummary, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID)
if err != nil {
return 0, err
}
rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary)
rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8) rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
if rebate <= 0 { if rebate <= 0 {
return 0, nil return 0, nil
} }
if _, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID); err != nil {
return 0, err
}
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate) applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -207,6 +266,28 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
return rebate, nil return rebate, nil
} }
// resolveRebateRatePercent returns the inviter's exclusive rate when set,
// otherwise the global setting value (clamped to [Min, Max]).
func (s *AffiliateService) resolveRebateRatePercent(ctx context.Context, inviter *AffiliateSummary) float64 {
if inviter != nil && inviter.AffRebateRatePercent != nil {
v := *inviter.AffRebateRatePercent
if math.IsNaN(v) || math.IsInf(v, 0) {
return s.globalRebateRatePercent(ctx)
}
return clampAffiliateRebateRate(v)
}
return s.globalRebateRatePercent(ctx)
}
// globalRebateRatePercent reads the system-wide rebate rate via SettingService,
// returning the documented default when SettingService is unavailable.
func (s *AffiliateService) globalRebateRatePercent(ctx context.Context) float64 {
if s == nil || s.settingService == nil {
return AffiliateRebateRateDefault
}
return s.settingService.GetAffiliateRebateRatePercent(ctx)
}
func (s *AffiliateService) TransferAffiliateQuota(ctx context.Context, userID int64) (float64, float64, error) { func (s *AffiliateService) TransferAffiliateQuota(ctx context.Context, userID int64) (float64, float64, error) {
if s == nil || s.repo == nil { if s == nil || s.repo == nil {
return 0, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable") return 0, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
@@ -236,32 +317,6 @@ func (s *AffiliateService) listInvitees(ctx context.Context, inviterID int64) ([
return invitees, nil return invitees, nil
} }
func (s *AffiliateService) loadAffiliateRebateRatePercent(ctx context.Context) float64 {
if s == nil || s.settingRepo == nil {
return AffiliateRebateRateDefault
}
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
if err != nil {
return AffiliateRebateRateDefault
}
rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return AffiliateRebateRateDefault
}
if math.IsNaN(rate) || math.IsInf(rate, 0) {
return AffiliateRebateRateDefault
}
if rate < AffiliateRebateRateMin {
return AffiliateRebateRateMin
}
if rate > AffiliateRebateRateMax {
return AffiliateRebateRateMax
}
return rate
}
func roundTo(v float64, scale int) float64 { func roundTo(v float64, scale int) float64 {
factor := math.Pow10(scale) factor := math.Pow10(scale)
return math.Round(v*factor) / factor return math.Round(v*factor) / factor
@@ -312,3 +367,82 @@ func (s *AffiliateService) invalidateAffiliateCaches(ctx context.Context, userID
} }
} }
} }
// =========================
// Admin: 专属配置管理
// =========================
// validateExclusiveRate ensures a per-user override is finite and within
// [Min, Max]. nil is always valid (means "clear / fall back to global").
func validateExclusiveRate(ratePercent *float64) error {
if ratePercent == nil {
return nil
}
v := *ratePercent
if math.IsNaN(v) || math.IsInf(v, 0) {
return infraerrors.BadRequest("INVALID_RATE", "invalid rebate rate")
}
if v < AffiliateRebateRateMin || v > AffiliateRebateRateMax {
return infraerrors.BadRequest("INVALID_RATE", "rebate rate out of range")
}
return nil
}
// AdminUpdateUserAffCode 管理员改写用户的邀请码(专属邀请码)。
func (s *AffiliateService) AdminUpdateUserAffCode(ctx context.Context, userID int64, rawCode string) error {
if s == nil || s.repo == nil {
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
code := strings.ToUpper(strings.TrimSpace(rawCode))
if !isValidAffiliateCodeFormat(code) {
return ErrAffiliateCodeInvalid
}
return s.repo.UpdateUserAffCode(ctx, userID, code)
}
// AdminResetUserAffCode 重置用户邀请码为系统随机码。
func (s *AffiliateService) AdminResetUserAffCode(ctx context.Context, userID int64) (string, error) {
if s == nil || s.repo == nil {
return "", infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
return s.repo.ResetUserAffCode(ctx, userID)
}
// AdminSetUserRebateRate 设置/清除用户专属返利比例。ratePercent==nil 表示清除。
func (s *AffiliateService) AdminSetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error {
if s == nil || s.repo == nil {
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
if err := validateExclusiveRate(ratePercent); err != nil {
return err
}
return s.repo.SetUserRebateRate(ctx, userID, ratePercent)
}
// AdminBatchSetUserRebateRate 批量设置/清除用户专属返利比例。
func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error {
if s == nil || s.repo == nil {
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
if err := validateExclusiveRate(ratePercent); err != nil {
return err
}
cleaned := make([]int64, 0, len(userIDs))
for _, uid := range userIDs {
if uid > 0 {
cleaned = append(cleaned, uid)
}
}
if len(cleaned) == 0 {
return nil
}
return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
}
// AdminListCustomUsers 列出有专属配置的用户。
func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
if s == nil || s.repo == nil {
return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
return s.repo.ListUsersWithCustomSettings(ctx, filter)
}

View File

@@ -4,51 +4,82 @@ package service
import ( import (
"context" "context"
"math"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type affiliateSettingRepoStub struct { // TestResolveRebateRatePercent_PerUserOverride verifies that per-inviter
value string // AffRebateRatePercent overrides the global rate, that NULL falls back to the
err error // global rate, and that out-of-range exclusive rates are clamped silently.
} //
// SettingService is left nil here so globalRebateRatePercent returns the
func (s *affiliateSettingRepoStub) Get(context.Context, string) (*Setting, error) { return nil, s.err } // documented default (AffiliateRebateRateDefault = 20%) — this exercises the
func (s *affiliateSettingRepoStub) GetValue(context.Context, string) (string, error) { // fallback path without spinning up a settings stub.
if s.err != nil { func TestResolveRebateRatePercent_PerUserOverride(t *testing.T) {
return "", s.err
}
return s.value, nil
}
func (s *affiliateSettingRepoStub) Set(context.Context, string, string) error { return s.err }
func (s *affiliateSettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
if s.err != nil {
return nil, s.err
}
return map[string]string{}, nil
}
func (s *affiliateSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
return s.err
}
func (s *affiliateSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
if s.err != nil {
return nil, s.err
}
return map[string]string{}, nil
}
func (s *affiliateSettingRepoStub) Delete(context.Context, string) error { return s.err }
func TestAffiliateRebateRatePercentSemantics(t *testing.T) {
t.Parallel() t.Parallel()
svc := &AffiliateService{}
svc := &AffiliateService{settingRepo: &affiliateSettingRepoStub{value: "1"}} // nil exclusive rate → falls back to global default (20%)
rate := svc.loadAffiliateRebateRatePercent(context.Background()) require.InDelta(t, AffiliateRebateRateDefault,
require.Equal(t, 1.0, rate) svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{}), 1e-9)
svc.settingRepo = &affiliateSettingRepoStub{value: "0.2"} // exclusive rate set → overrides global
rate = svc.loadAffiliateRebateRatePercent(context.Background()) rate := 50.0
require.Equal(t, 0.2, rate) require.InDelta(t, 50.0,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &rate}), 1e-9)
// exclusive rate 0 → returns 0 (no rebate, intentional)
zero := 0.0
require.InDelta(t, 0.0,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &zero}), 1e-9)
// exclusive rate above max → clamped to Max
tooHigh := 250.0
require.InDelta(t, AffiliateRebateRateMax,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &tooHigh}), 1e-9)
// exclusive rate below min → clamped to Min
tooLow := -5.0
require.InDelta(t, AffiliateRebateRateMin,
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &tooLow}), 1e-9)
}
// TestIsEnabled_NilSettingServiceReturnsDefault verifies that IsEnabled
// safely handles a nil settingService dependency by returning the default
// (off). This protects callers from nil-pointer crashes in misconfigured
// environments.
func TestIsEnabled_NilSettingServiceReturnsDefault(t *testing.T) {
t.Parallel()
svc := &AffiliateService{}
require.False(t, svc.IsEnabled(context.Background()))
require.Equal(t, AffiliateEnabledDefault, svc.IsEnabled(context.Background()))
}
// TestValidateExclusiveRate_BoundaryAndInvalid covers the validator used by
// admin-facing rate setters: nil is always valid (clear), in-range values
// are accepted, NaN/Inf and out-of-range values produce a typed BadRequest.
func TestValidateExclusiveRate_BoundaryAndInvalid(t *testing.T) {
t.Parallel()
require.NoError(t, validateExclusiveRate(nil))
for _, v := range []float64{0, 0.01, 50, 99.99, 100} {
v := v
require.NoError(t, validateExclusiveRate(&v), "value %v should be valid", v)
}
for _, v := range []float64{-0.01, 100.01, -100, 200} {
v := v
require.Error(t, validateExclusiveRate(&v), "value %v should be rejected", v)
}
nan := math.NaN()
require.Error(t, validateExclusiveRate(&nan))
posInf := math.Inf(1)
require.Error(t, validateExclusiveRate(&posInf))
negInf := math.Inf(-1)
require.Error(t, validateExclusiveRate(&negInf))
} }
func TestMaskEmail(t *testing.T) { func TestMaskEmail(t *testing.T) {
@@ -61,24 +92,33 @@ func TestMaskEmail(t *testing.T) {
func TestIsValidAffiliateCodeFormat(t *testing.T) { func TestIsValidAffiliateCodeFormat(t *testing.T) {
t.Parallel() t.Parallel()
// 邀请码格式校验同时服务于:
// 1) 系统自动生成的 12 位随机码A-Z 去 I/O2-9 去 0/1
// 2) 管理员设置的自定义专属码(如 "VIP2026"、"NEW_USER-1"
// 因此校验放宽到 [A-Z0-9_-]{4,32}(要求调用方先 ToUpper
cases := []struct { cases := []struct {
name string name string
in string in string
want bool want bool
}{ }{
{"valid canonical", "ABCDEFGHJKLM", true}, {"valid canonical 12-char", "ABCDEFGHJKLM", true},
{"valid all digits 2-9", "234567892345", true}, {"valid all digits 2-9", "234567892345", true},
{"valid mixed", "A2B3C4D5E6F7", true}, {"valid mixed", "A2B3C4D5E6F7", true},
{"too short", "ABCDEFGHJKL", false}, {"valid admin custom short", "VIP1", true},
{"too long", "ABCDEFGHJKLMN", false}, {"valid admin custom with hyphen", "NEW-USER", true},
{"contains excluded letter I", "IBCDEFGHJKLM", false}, {"valid admin custom with underscore", "VIP_2026", true},
{"contains excluded letter O", "OBCDEFGHJKLM", false}, {"valid 32-char max", "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", true},
{"contains excluded digit 0", "0BCDEFGHJKLM", false}, // Previously-excluded chars (I/O/0/1) are now allowed since admins may use them.
{"contains excluded digit 1", "1BCDEFGHJKLM", false}, {"letter I now allowed", "IBCDEFGHJKLM", true},
{"letter O now allowed", "OBCDEFGHJKLM", true},
{"digit 0 now allowed", "0BCDEFGHJKLM", true},
{"digit 1 now allowed", "1BCDEFGHJKLM", true},
{"too short (3 chars)", "ABC", false},
{"too long (33 chars)", "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456", false},
{"lowercase rejected (caller must ToUpper first)", "abcdefghjklm", false}, {"lowercase rejected (caller must ToUpper first)", "abcdefghjklm", false},
{"empty", "", false}, {"empty", "", false},
{"12-byte utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // 6×2 bytes = 12 bytes, bytes out of charset {"utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // bytes out of charset
{"ascii punctuation", "ABCDEFGHJK.M", false}, {"ascii punctuation .", "ABCDEFGHJK.M", false},
{"whitespace", "ABCDEFGHJK M", false}, {"whitespace", "ABCDEFGHJK M", false},
} }
for _, tc := range cases { for _, tc := range cases {

View File

@@ -23,6 +23,7 @@ const (
AffiliateRebateRateDefault = 20.0 AffiliateRebateRateDefault = 20.0
AffiliateRebateRateMin = 0.0 AffiliateRebateRateMin = 0.0
AffiliateRebateRateMax = 100.0 AffiliateRebateRateMax = 100.0
AffiliateEnabledDefault = false // 邀请返利总开关默认关闭
) )
// Platform constants // Platform constants
@@ -94,6 +95,7 @@ const (
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyFrontendURL = "frontend_url" // 前端基础URL用于生成邮件中的重置密码链接 SettingKeyFrontendURL = "frontend_url" // 前端基础URL用于生成邮件中的重置密码链接
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关
SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例百分比0-100 SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例百分比0-100
// 邮件服务设置 // 邮件服务设置

View File

@@ -454,6 +454,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyChannelMonitorEnabled, SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorDefaultIntervalSeconds,
SettingKeyAvailableChannelsEnabled, SettingKeyAvailableChannelsEnabled,
SettingKeyAffiliateEnabled,
} }
settings, err := s.settingRepo.GetMultiple(ctx, keys) settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -541,6 +542,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
}, nil }, nil
} }
@@ -687,6 +690,7 @@ type PublicSettingsInjectionPayload struct {
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"` AvailableChannelsEnabled bool `json:"available_channels_enabled"`
AffiliateEnabled bool `json:"affiliate_enabled"`
} }
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
@@ -739,6 +743,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled, AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
}, nil }, nil
} }
@@ -1205,6 +1210,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// Available channels feature switch // Available channels feature switch
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
// Affiliate (邀请返利) feature switch
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
// Claude Code version check // Claude Code version check
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
@@ -1480,6 +1488,30 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
return value == "true" return value == "true"
} }
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。
// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错,
// 调用方只关心一个可用的数值。
func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
if err != nil {
return AffiliateRebateRateDefault
}
rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) {
return AffiliateRebateRateDefault
}
return clampAffiliateRebateRate(rate)
}
// IsPasswordResetEnabled 检查是否启用密码重置功能 // IsPasswordResetEnabled 检查是否启用密码重置功能
// 要求:必须同时开启邮件验证 // 要求:必须同时开启邮件验证
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
@@ -1771,6 +1803,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// Available channels feature (default disabled; opt-in) // Available channels feature (default disabled; opt-in)
SettingKeyAvailableChannelsEnabled: "false", SettingKeyAvailableChannelsEnabled: "false",
// Affiliate (邀请返利) feature (default disabled; opt-in)
SettingKeyAffiliateEnabled: "false",
// Claude Code version check (default: empty = disabled) // Claude Code version check (default: empty = disabled)
SettingKeyMinClaudeCodeVersion: "", SettingKeyMinClaudeCodeVersion: "",
SettingKeyMaxClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "",
@@ -2091,6 +2126,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Available channels feature (default: disabled; strict true) // Available channels feature (default: disabled; strict true)
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
// Affiliate (邀请返利) feature (default: disabled; strict true)
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
// Claude Code version check // Claude Code version check
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]

View File

@@ -106,6 +106,7 @@ type SystemSettings struct {
DefaultConcurrency int DefaultConcurrency int
DefaultBalance float64 DefaultBalance float64
AffiliateEnabled bool
AffiliateRebateRate float64 AffiliateRebateRate float64
DefaultUserRPMLimit int DefaultUserRPMLimit int
DefaultSubscriptions []DefaultSubscriptionSetting DefaultSubscriptions []DefaultSubscriptionSetting
@@ -225,6 +226,9 @@ type PublicSettings struct {
// Available Channels feature (user-facing aggregate view) // Available Channels feature (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"` AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Affiliate (邀请返利) feature toggle
AffiliateEnabled bool `json:"affiliate_enabled"`
} }
type WeChatConnectOAuthConfig struct { type WeChatConnectOAuthConfig struct {

View File

@@ -0,0 +1,16 @@
-- 邀请返利:用户专属配置增强
-- 1) aff_rebate_rate_percent: 用户作为邀请人时的专属返利比例百分比NULL 表示沿用全局比例)
-- 2) aff_code_custom: 标记当前 aff_code 是否被管理员手动改写过(用于"专属用户"列表筛选)
ALTER TABLE user_affiliates
ADD COLUMN IF NOT EXISTS aff_rebate_rate_percent DECIMAL(5,2);
ALTER TABLE user_affiliates
ADD COLUMN IF NOT EXISTS aff_code_custom BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_user_affiliates_admin_settings
ON user_affiliates (updated_at)
WHERE aff_code_custom = true OR aff_rebate_rate_percent IS NOT NULL;
COMMENT ON COLUMN user_affiliates.aff_rebate_rate_percent IS '专属返利比例(百分比 0-100NULL 表示沿用全局)';
COMMENT ON COLUMN user_affiliates.aff_code_custom IS '邀请码是否由管理员改写过(用于专属用户筛选)';

View File

@@ -0,0 +1,108 @@
/**
* Admin Affiliate API endpoints
* Manage per-user affiliate (邀请返利) configurations:
* exclusive invite codes (overrides aff_code) and exclusive rebate rates.
*/
import { apiClient } from '../client'
import type { PaginatedResponse } from '@/types'
export interface AffiliateAdminEntry {
user_id: number
email: string
username: string
aff_code: string
aff_code_custom: boolean
aff_rebate_rate_percent?: number | null
aff_count: number
}
export interface ListAffiliateUsersParams {
page?: number
page_size?: number
search?: string
}
export interface UpdateAffiliateUserRequest {
aff_code?: string
aff_rebate_rate_percent?: number | null
/** Set true to explicitly clear the per-user rate (sets it to NULL). */
clear_rebate_rate?: boolean
}
export interface BatchSetRateRequest {
user_ids: number[]
aff_rebate_rate_percent?: number | null
/** Set true to clear rates instead of setting. */
clear?: boolean
}
export interface SimpleUser {
id: number
email: string
username: string
}
export async function listUsers(
params: ListAffiliateUsersParams = {},
): Promise<PaginatedResponse<AffiliateAdminEntry>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateAdminEntry>>(
'/admin/affiliates/users',
{
params: {
page: params.page ?? 1,
page_size: params.page_size ?? 20,
search: params.search ?? '',
},
},
)
return data
}
export async function lookupUsers(q: string): Promise<SimpleUser[]> {
const { data } = await apiClient.get<SimpleUser[]>(
'/admin/affiliates/users/lookup',
{ params: { q } },
)
return data
}
export async function updateUserSettings(
userId: number,
payload: UpdateAffiliateUserRequest,
): Promise<{ user_id: number }> {
const { data } = await apiClient.put<{ user_id: number }>(
`/admin/affiliates/users/${userId}`,
payload,
)
return data
}
export async function clearUserSettings(
userId: number,
): Promise<{ user_id: number }> {
const { data } = await apiClient.delete<{ user_id: number }>(
`/admin/affiliates/users/${userId}`,
)
return data
}
export async function batchSetRate(
payload: BatchSetRateRequest,
): Promise<{ affected: number }> {
const { data } = await apiClient.post<{ affected: number }>(
'/admin/affiliates/users/batch-rate',
payload,
)
return data
}
export const affiliatesAPI = {
listUsers,
lookupUsers,
updateUserSettings,
clearUserSettings,
batchSetRate,
}
export default affiliatesAPI

View File

@@ -29,6 +29,7 @@ import channelsAPI from './channels'
import channelMonitorAPI from './channelMonitor' import channelMonitorAPI from './channelMonitor'
import channelMonitorTemplateAPI from './channelMonitorTemplate' import channelMonitorTemplateAPI from './channelMonitorTemplate'
import adminPaymentAPI from './payment' import adminPaymentAPI from './payment'
import affiliatesAPI from './affiliates'
/** /**
* Unified admin API object for convenient access * Unified admin API object for convenient access
@@ -59,7 +60,8 @@ export const adminAPI = {
channels: channelsAPI, channels: channelsAPI,
channelMonitor: channelMonitorAPI, channelMonitor: channelMonitorAPI,
channelMonitorTemplate: channelMonitorTemplateAPI, channelMonitorTemplate: channelMonitorTemplateAPI,
payment: adminPaymentAPI payment: adminPaymentAPI,
affiliates: affiliatesAPI
} }
export { export {
@@ -88,7 +90,8 @@ export {
channelsAPI, channelsAPI,
channelMonitorAPI, channelMonitorAPI,
channelMonitorTemplateAPI, channelMonitorTemplateAPI,
adminPaymentAPI adminPaymentAPI,
affiliatesAPI
} }
export default adminAPI export default adminAPI

View File

@@ -478,6 +478,9 @@ export interface SystemSettings {
// Available Channels feature switch // Available Channels feature switch
available_channels_enabled: boolean; available_channels_enabled: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled: boolean;
} }
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
@@ -636,6 +639,9 @@ export interface UpdateSettingsRequest {
// Available Channels feature switch // Available Channels feature switch
available_channels_enabled?: boolean; available_channels_enabled?: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled?: boolean;
} }
/** /**

View File

@@ -634,6 +634,7 @@ const ChevronDownIcon = {
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor) const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment) const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels) const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled const flagAdminPayment = () => adminSettingsStore.paymentEnabled
@@ -656,7 +657,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment }, { path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment }, { path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true }, { path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }, { path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({ ...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`, path: `/custom/${item.id}`,

View File

@@ -985,6 +985,8 @@ export default {
loadFailed: 'Failed to load affiliate data', loadFailed: 'Failed to load affiliate data',
transferFailed: 'Failed to transfer affiliate quota', transferFailed: 'Failed to transfer affiliate quota',
stats: { stats: {
rebateRate: 'My Rebate Rate',
rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users', invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota', availableQuota: 'Available Rebate Quota',
totalQuota: 'Historical Rebate Quota' totalQuota: 'Historical Rebate Quota'
@@ -1009,7 +1011,7 @@ export default {
tips: { tips: {
title: 'How It Works', title: 'How It Works',
line1: 'Share your affiliate code or invite link with new users.', line1: 'Share your affiliate code or invite link with new users.',
line2: 'When invitees recharge, you receive rebate quota based on the configured rate.', line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.' line3: 'Transfer rebate quota to balance at any time.'
} }
}, },
@@ -4779,6 +4781,55 @@ export default {
enabled: 'Enable Available Channels', enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.', enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
}, },
affiliate: {
title: 'Affiliate (Invite Rebate)',
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitees recharges. Disabled by default.',
enabled: 'Enable Affiliate',
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
rebateRate: 'Global Rebate Rate',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
customUsers: {
title: 'Per-User Overrides',
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
addButton: 'Add Custom User',
searchPlaceholder: 'Search by email or username',
batchButton: 'Batch Set Rate ({count} selected)',
empty: 'No users with custom affiliate settings yet',
customBadge: 'custom',
useGlobal: 'use global',
resetTitle: 'Reset Custom Settings',
resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)',
totalLabel: '{total} total',
col: {
email: 'Email',
username: 'Username',
code: 'Invite Code',
rate: 'Custom Rate',
actions: 'Actions',
},
},
modal: {
addTitle: 'Add Custom User',
editTitle: 'Edit Custom Settings',
userLabel: 'User',
userPlaceholder: 'Search by email or username',
changeUser: 'Change user',
codeLabel: 'Custom Invite Code (optional)',
codePlaceholder: 'e.g. VIP2026',
codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.',
rateLabel: 'Exclusive Rebate Rate (optional)',
ratePlaceholder: 'e.g. 30',
rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.',
errorBadRate: 'Please enter a number between 0 and 100',
errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate',
},
batchModal: {
title: 'Batch Set Rate ({count} users selected)',
hint: 'Apply the same exclusive rebate rate to all selected users.',
placeholder: 'e.g. 30',
clearHint: 'Submitting empty will clear the exclusive rate for selected users.',
},
},
}, },
emailTabDisabledTitle: 'Email Verification Not Enabled', emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.', emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',

View File

@@ -989,6 +989,8 @@ export default {
loadFailed: '加载邀请返利数据失败', loadFailed: '加载邀请返利数据失败',
transferFailed: '转入余额失败', transferFailed: '转入余额失败',
stats: { stats: {
rebateRate: '我的返利比例',
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数', invitedUsers: '邀请人数',
availableQuota: '可转返利额度', availableQuota: '可转返利额度',
totalQuota: '历史返利额度' totalQuota: '历史返利额度'
@@ -1013,7 +1015,7 @@ export default {
tips: { tips: {
title: '使用说明', title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。', line1: '将邀请码或邀请链接分享给新用户。',
line2: '被邀请用户充值后,你可获得对应比例的返利额度。', line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。' line3: '返利额度可随时转入账户余额。'
} }
}, },
@@ -4942,6 +4944,55 @@ export default {
enabled: '启用可用渠道', enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。', enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
}, },
affiliate: {
title: '邀请返利',
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
enabled: '启用邀请返利',
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
rebateRate: '全局返利比例',
rebateRateHint: '充值后返给邀请人的默认比例0-100%,例如填写 10 表示返利 10%)。',
customUsers: {
title: '专属用户配置',
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
addButton: '添加专属用户',
searchPlaceholder: '搜索邮箱或用户名',
batchButton: '批量设置比例(已选 {count}',
empty: '暂无专属配置用户',
customBadge: '自定义',
useGlobal: '沿用全局',
resetTitle: '重置该用户的专属配置',
resetMessage: '确认将 {email} 的专属配置全部重置为默认?\n• 专属返利比例将清除(沿用全局)\n• 邀请码将重新生成为系统随机码(已分发的旧邀请链接将失效)',
totalLabel: '共 {total} 条',
col: {
email: '邮箱',
username: '用户名',
code: '邀请码',
rate: '专属比例',
actions: '操作',
},
},
modal: {
addTitle: '添加专属用户',
editTitle: '编辑专属配置',
userLabel: '用户',
userPlaceholder: '搜索邮箱或用户名',
changeUser: '更换用户',
codeLabel: '专属邀请码(可选)',
codePlaceholder: '例如 VIP2026',
codeHint: '4-32 位,仅支持大写字母、数字、下划线、连字符;留空表示不修改;输入将自动转大写。',
rateLabel: '专属返利比例(可选)',
ratePlaceholder: '例如 30',
rateHint: '0-100%;留空(编辑模式下)表示清除专属比例并沿用全局。',
errorBadRate: '请输入 0-100 之间的比例',
errorEmpty: '至少填写一项:专属邀请码或专属返利比例',
},
batchModal: {
title: '批量设置专属比例(已选 {count} 个用户)',
hint: '为所选用户统一设置专属返利比例。',
placeholder: '例如 30',
clearHint: '留空提交将清除所选用户的专属比例。',
},
},
}, },
emailTabDisabledTitle: '邮箱验证未启用', emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。', emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',

View File

@@ -355,6 +355,7 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_enabled: true, channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60, channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false, available_channels_enabled: false,
affiliate_enabled: false,
} }
} }

View File

@@ -139,6 +139,8 @@ export interface UserAffiliateDetail {
aff_count: number aff_count: number
aff_quota: number aff_quota: number
aff_history_quota: number aff_history_quota: number
/** 当前用户作为邀请人时实际生效的返利比例专属覆盖全局。0-100。 */
effective_rebate_rate_percent: number
invitees: AffiliateInvitee[] invitees: AffiliateInvitee[]
} }
@@ -212,6 +214,7 @@ export interface PublicSettings {
channel_monitor_enabled: boolean channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean available_channels_enabled: boolean
affiliate_enabled: boolean
} }
export interface AuthResponse { export interface AuthResponse {

View File

@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-out', mode: 'opt-out',
label: 'Payment', label: 'Payment',
}), }),
affiliate: defineFlag({
key: 'affiliate_enabled',
mode: 'opt-in',
label: 'Affiliate',
}),
} as const } as const
export type RegisteredFeatureFlag = keyof typeof FeatureFlags export type RegisteredFeatureFlag = keyof typeof FeatureFlags

View File

@@ -2153,31 +2153,6 @@
{{ t("admin.settings.defaults.defaultBalanceHint") }} {{ t("admin.settings.defaults.defaultBalanceHint") }}
</p> </p>
</div> </div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.defaults.affiliateRebateRate") }}
</label>
<div class="relative">
<input
v-model.number="form.affiliate_rebate_rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
placeholder="20"
/>
<span
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
>%</span
>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.defaults.affiliateRebateRateHint") }}
</p>
</div>
<div> <div>
<label <label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
@@ -3878,6 +3853,356 @@
</div> </div>
</div> </div>
<!-- Affiliate (邀请返利) feature card -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.affiliate.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.affiliate.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.enabledHint') }}
</p>
</div>
<Toggle v-model="form.affiliate_enabled" />
</div>
<div v-if="form.affiliate_enabled" class="space-y-6">
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.rebateRate') }}
</label>
<div class="relative">
<input
v-model.number="form.affiliate_rebate_rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
placeholder="20"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.rebateRateHint') }}
</p>
</div>
<!-- 专属用户管理 -->
<div class="border-t border-gray-100 pt-6 dark:border-dark-700">
<div class="mb-3 flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.affiliate.customUsers.title') }}
</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.customUsers.description') }}
</p>
</div>
<button
type="button"
class="btn btn-primary btn-sm"
@click="openAffiliateModal(null)"
>
+ {{ t('admin.settings.features.affiliate.customUsers.addButton') }}
</button>
</div>
<div class="mb-3 flex items-center gap-2">
<input
v-model="affiliateState.search"
type="text"
class="input flex-1"
:placeholder="t('admin.settings.features.affiliate.customUsers.searchPlaceholder')"
@input="onAffiliateSearchInput"
/>
<button
v-if="affiliateState.selected.length > 0"
type="button"
class="btn btn-secondary btn-sm"
@click="openAffiliateBatchModal"
>
{{ t('admin.settings.features.affiliate.customUsers.batchButton', { count: affiliateState.selected.length }) }}
</button>
</div>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-800">
<tr>
<th class="px-3 py-2 text-left">
<input
type="checkbox"
:checked="affiliateState.entries.length > 0 && affiliateState.selected.length === affiliateState.entries.length"
@change="toggleAffiliateSelectAll"
/>
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.email') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.username') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.code') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.rate') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr v-if="affiliateState.loading">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</td>
</tr>
<tr v-else-if="affiliateState.entries.length === 0">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('admin.settings.features.affiliate.customUsers.empty') }}
</td>
</tr>
<tr v-for="entry in affiliateState.entries" :key="entry.user_id">
<td class="px-3 py-2">
<input
type="checkbox"
:checked="affiliateState.selected.includes(entry.user_id)"
@change="toggleAffiliateSelect(entry.user_id)"
/>
</td>
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white">{{ entry.email }}</td>
<td class="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">{{ entry.username }}</td>
<td class="px-3 py-2 text-sm font-mono">
{{ entry.aff_code }}
<span
v-if="entry.aff_code_custom"
class="ml-1 inline-block rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>{{ t('admin.settings.features.affiliate.customUsers.customBadge') }}</span>
</td>
<td class="px-3 py-2 text-sm">
<span v-if="entry.aff_rebate_rate_percent != null">{{ entry.aff_rebate_rate_percent }}%</span>
<span v-else class="text-gray-400">{{ t('admin.settings.features.affiliate.customUsers.useGlobal') }}</span>
</td>
<td class="px-3 py-2 text-sm">
<div class="flex items-center gap-2">
<button type="button" class="text-primary-600 hover:underline" @click="openAffiliateModal(entry)">
{{ t('common.edit') }}
</button>
<button
type="button"
class="text-red-600 hover:underline"
@click="askResetAffiliateUser(entry)"
>
{{ t('common.delete') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="affiliateState.total > affiliateState.pageSize" class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-500">
{{ t('admin.settings.features.affiliate.customUsers.totalLabel', { total: affiliateState.total }) }}
</span>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="affiliateState.page <= 1"
@click="changeAffiliatePage(affiliateState.page - 1)"
>
{{ t('pagination.previous') }}
</button>
<span class="text-gray-500">{{ affiliateState.page }} / {{ Math.max(1, Math.ceil(affiliateState.total / affiliateState.pageSize)) }}</span>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="affiliateState.page >= Math.ceil(affiliateState.total / affiliateState.pageSize)"
@click="changeAffiliatePage(affiliateState.page + 1)"
>
{{ t('pagination.next') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Affiliate add/edit modal -->
<div
v-if="affiliateModal.open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closeAffiliateModal"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dark-900">
<h3 class="mb-4 text-lg font-semibold">
{{ affiliateModal.mode === 'add' ? t('admin.settings.features.affiliate.modal.addTitle') : t('admin.settings.features.affiliate.modal.editTitle') }}
</h3>
<div class="space-y-4">
<div v-if="affiliateModal.mode === 'add'">
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.userLabel') }}</label>
<!-- Chip showing the picked user; clicking it re-opens the search -->
<div
v-if="affiliateModal.selectedUser"
class="flex items-center justify-between rounded-md border border-primary-200 bg-primary-50 px-3 py-2 dark:border-primary-700/50 dark:bg-primary-900/20"
>
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ affiliateModal.selectedUser.email }}</span>
<span class="ml-1 text-xs text-gray-500">({{ affiliateModal.selectedUser.username }})</span>
</div>
<button
type="button"
class="text-lg leading-none text-gray-400 hover:text-red-600"
:title="t('admin.settings.features.affiliate.modal.changeUser')"
@click="clearSelectedAffiliateUser"
>
×
</button>
</div>
<!-- Search input + result dropdown — hidden once a selection is made -->
<template v-else>
<input
v-model="affiliateModal.userQuery"
type="text"
class="input"
:placeholder="t('admin.settings.features.affiliate.modal.userPlaceholder')"
@input="onAffiliateUserSearchInput"
/>
<div
v-if="affiliateModal.userResults.length > 0"
class="mt-1 max-h-40 overflow-y-auto rounded border border-gray-200 dark:border-dark-700"
>
<button
v-for="u in affiliateModal.userResults"
:key="u.id"
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-800"
@click="selectAffiliateUser(u)"
>
{{ u.email }} <span class="text-xs text-gray-500">({{ u.username }})</span>
</button>
</div>
</template>
</div>
<div v-else>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.userLabel') }}</label>
<input
type="text"
class="input"
:value="affiliateModal.editingEntry ? affiliateModal.editingEntry.email : ''"
disabled
/>
</div>
<div>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.codeLabel') }}</label>
<input
v-model="affiliateModal.code"
type="text"
class="input font-mono"
:placeholder="t('admin.settings.features.affiliate.modal.codePlaceholder')"
maxlength="32"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.modal.codeHint') }}
</p>
</div>
<div>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.rateLabel') }}</label>
<div class="relative">
<input
v-model="affiliateModal.rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
:placeholder="t('admin.settings.features.affiliate.modal.ratePlaceholder')"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.modal.rateHint') }}
</p>
</div>
</div>
<div class="mt-6 flex items-center justify-between gap-3">
<p
v-if="!affiliateModalCanSubmit"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.features.affiliate.modal.errorEmpty') }}
</p>
<span v-else></span>
<div class="flex gap-2">
<button type="button" class="btn btn-secondary" @click="closeAffiliateModal">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="affiliateModal.saving || !affiliateModalCanSubmit"
@click="submitAffiliateModal"
>
{{ affiliateModal.saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
</div>
<!-- Affiliate batch rate modal -->
<div
v-if="affiliateBatchModal.open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="affiliateBatchModal.open = false"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dark-900">
<h3 class="mb-4 text-lg font-semibold">
{{ t('admin.settings.features.affiliate.batchModal.title', { count: affiliateState.selected.length }) }}
</h3>
<p class="mb-4 text-sm text-gray-500">
{{ t('admin.settings.features.affiliate.batchModal.hint') }}
</p>
<div class="relative">
<input
v-model="affiliateBatchModal.rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
:placeholder="t('admin.settings.features.affiliate.batchModal.placeholder')"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
<p class="mt-2 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.batchModal.clearHint') }}
</p>
<div class="mt-6 flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="affiliateBatchModal.open = false">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="affiliateBatchModal.saving"
@click="submitAffiliateBatchModal"
>
{{ affiliateBatchModal.saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
</div><!-- /Tab: Features --> </div><!-- /Tab: Features -->
<!-- Tab: Email --> <!-- Tab: Email -->
@@ -4793,12 +5118,21 @@
@confirm="handleDeleteProvider" @confirm="handleDeleteProvider"
@cancel="showDeleteProviderDialog = false" @cancel="showDeleteProviderDialog = false"
/> />
<ConfirmDialog
:show="affiliateConfirmDialog.show"
:title="affiliateConfirmDialog.title"
:message="affiliateConfirmDialog.message"
:confirm-text="affiliateConfirmDialog.confirmText"
danger
@confirm="handleAffiliateConfirm"
@cancel="cancelAffiliateConfirm"
/>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"; import { ref, reactive, computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { adminAPI } from "@/api"; import { adminAPI } from "@/api";
import { import {
@@ -4835,6 +5169,7 @@ import ProxySelector from "@/components/common/ProxySelector.vue";
import ImageUpload from "@/components/common/ImageUpload.vue"; import ImageUpload from "@/components/common/ImageUpload.vue";
import BackupSettings from "@/views/admin/BackupView.vue"; import BackupSettings from "@/views/admin/BackupView.vue";
import { useClipboard } from "@/composables/useClipboard"; import { useClipboard } from "@/composables/useClipboard";
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError"; import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
import { useAppStore } from "@/stores"; import { useAppStore } from "@/stores";
import { useAdminSettingsStore } from "@/stores/adminSettings"; import { useAdminSettingsStore } from "@/stores/adminSettings";
@@ -5145,6 +5480,8 @@ const form = reactive<SettingsForm>({
channel_monitor_default_interval_seconds: 60, channel_monitor_default_interval_seconds: 60,
// Available Channels feature switch // Available Channels feature switch
available_channels_enabled: false, available_channels_enabled: false,
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
}); });
const authSourceDefaults = reactive<AuthSourceDefaultsState>( const authSourceDefaults = reactive<AuthSourceDefaultsState>(
@@ -6063,6 +6400,8 @@ async function saveSettings() {
Number(form.channel_monitor_default_interval_seconds) || 60, Number(form.channel_monitor_default_interval_seconds) || 60,
// Available Channels feature switch // Available Channels feature switch
available_channels_enabled: form.available_channels_enabled, available_channels_enabled: form.available_channels_enabled,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
}; };
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults); appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
@@ -6844,6 +7183,359 @@ onMounted(() => {
loadBetaPolicySettings(); loadBetaPolicySettings();
loadProviders(); loadProviders();
}); });
// =========================
// Affiliate (邀请返利) 专属用户管理
// =========================
interface AffiliateState {
loading: boolean;
entries: AffiliateAdminEntry[];
total: number;
page: number;
pageSize: number;
search: string;
selected: number[];
searchTimer: number | null;
}
const affiliateState = reactive<AffiliateState>({
loading: false,
entries: [],
total: 0,
page: 1,
pageSize: 20,
search: "",
selected: [],
searchTimer: null,
});
// `rate` is typed as string|number because <input type="number"> makes Vue's
// v-model auto-cast the bound value to a Number on every keystroke. We keep
// both shapes and normalize at read time.
interface AffiliateModalState {
open: boolean;
mode: "add" | "edit";
saving: boolean;
userQuery: string;
userResults: AffiliateSimpleUser[];
selectedUser: AffiliateSimpleUser | null;
editingEntry: AffiliateAdminEntry | null;
code: string;
rate: string | number;
searchTimer: number | null;
}
const affiliateModal = reactive<AffiliateModalState>({
open: false,
mode: "add",
saving: false,
userQuery: "",
userResults: [],
selectedUser: null,
editingEntry: null,
code: "",
rate: "",
searchTimer: null,
});
const affiliateBatchModal = reactive<{
open: boolean;
saving: boolean;
rate: string | number;
}>({
open: false,
saving: false,
rate: "",
});
// affiliateConfirmDialog drives the project-standard <ConfirmDialog>. We can't
// `await` the user's response from the dialog component, so the confirm action
// runs from the @confirm callback once the user clicks the dialog's confirm
// button.
const affiliateConfirmDialog = reactive<{
show: boolean;
title: string;
message: string;
confirmText: string;
pending: (() => Promise<unknown>) | null;
}>({
show: false,
title: "",
message: "",
confirmText: "",
pending: null,
});
function openAffiliateConfirm(
title: string,
message: string,
confirmText: string,
fn: () => Promise<unknown>,
) {
affiliateConfirmDialog.title = title;
affiliateConfirmDialog.message = message;
affiliateConfirmDialog.confirmText = confirmText;
affiliateConfirmDialog.pending = fn;
affiliateConfirmDialog.show = true;
}
async function handleAffiliateConfirm() {
const fn = affiliateConfirmDialog.pending;
affiliateConfirmDialog.show = false;
affiliateConfirmDialog.pending = null;
if (!fn) return;
try {
await fn();
appStore.showSuccess(t("common.saved"));
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
}
}
function cancelAffiliateConfirm() {
affiliateConfirmDialog.show = false;
affiliateConfirmDialog.pending = null;
}
// debounceTimer wires a single timer slot to a callback with a delay,
// canceling any pending invocation. Used for type-as-you-go search inputs.
function debounceTimer(slot: { searchTimer: number | null }, delayMs: number, run: () => void) {
if (slot.searchTimer != null) window.clearTimeout(slot.searchTimer);
slot.searchTimer = window.setTimeout(run, delayMs);
}
// parseRebateRate validates 0-100 numeric input. Returns the parsed number on
// success, null when the field is empty (caller decides empty semantics), or
// undefined on invalid input (after surfacing a toast).
//
// Accepts unknown because <input type="number"> makes Vue's v-model coerce
// the value to Number on each keystroke (e.g. typing "30" lands a `30: number`
// in state, not a `"30": string`). String("") and (30).trim() would crash, so
// we normalize here instead of forcing every caller to remember.
function parseRebateRate(raw: unknown): number | null | undefined {
const s = String(raw ?? "").trim();
if (s === "") return null;
const parsed = Number(s);
if (Number.isNaN(parsed) || parsed < 0 || parsed > 100) {
appStore.showError(t("admin.settings.features.affiliate.modal.errorBadRate"));
return undefined;
}
return parsed;
}
async function loadAffiliateUsers() {
affiliateState.loading = true;
try {
const res = await affiliatesAPI.listUsers({
page: affiliateState.page,
page_size: affiliateState.pageSize,
search: affiliateState.search,
});
affiliateState.entries = res.items ?? [];
affiliateState.total = res.total ?? 0;
// Drop selections that are no longer visible.
const visibleIds = new Set(affiliateState.entries.map((e) => e.user_id));
affiliateState.selected = affiliateState.selected.filter((id) => visibleIds.has(id));
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateState.loading = false;
}
}
function onAffiliateSearchInput() {
debounceTimer(affiliateState, 300, () => {
affiliateState.page = 1;
loadAffiliateUsers();
});
}
function changeAffiliatePage(page: number) {
if (page < 1) return;
affiliateState.page = page;
loadAffiliateUsers();
}
function toggleAffiliateSelectAll(e: Event) {
const checked = (e.target as HTMLInputElement).checked;
affiliateState.selected = checked ? affiliateState.entries.map((entry) => entry.user_id) : [];
}
function toggleAffiliateSelect(userId: number) {
const idx = affiliateState.selected.indexOf(userId);
if (idx >= 0) affiliateState.selected.splice(idx, 1);
else affiliateState.selected.push(userId);
}
// openAffiliateModal opens the add/edit modal, prefilling fields from the
// edited entry when present and resetting them otherwise.
function openAffiliateModal(entry: AffiliateAdminEntry | null) {
affiliateModal.open = true;
affiliateModal.mode = entry ? "edit" : "add";
affiliateModal.userQuery = "";
affiliateModal.userResults = [];
affiliateModal.selectedUser = null;
affiliateModal.editingEntry = entry;
affiliateModal.code = entry?.aff_code_custom ? entry.aff_code : "";
affiliateModal.rate =
entry?.aff_rebate_rate_percent != null ? String(entry.aff_rebate_rate_percent) : "";
}
function closeAffiliateModal() {
affiliateModal.open = false;
if (affiliateModal.searchTimer != null) {
window.clearTimeout(affiliateModal.searchTimer);
affiliateModal.searchTimer = null;
}
}
function onAffiliateUserSearchInput() {
const q = affiliateModal.userQuery.trim();
if (!q) {
affiliateModal.userResults = [];
return;
}
debounceTimer(affiliateModal, 300, async () => {
try {
affiliateModal.userResults = await affiliatesAPI.lookupUsers(q);
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
}
});
}
// selectAffiliateUser picks a user from the dropdown and collapses the search
// UI. Clearing the result list also clears the visual dropdown.
function selectAffiliateUser(user: AffiliateSimpleUser) {
affiliateModal.selectedUser = user;
affiliateModal.userQuery = "";
affiliateModal.userResults = [];
}
function clearSelectedAffiliateUser() {
affiliateModal.selectedUser = null;
}
// affiliateModalCanSubmit guards the Save button: must have a user picked AND
// produce at least one field change. Without this the admin could "save" an
// empty payload that silently does nothing — the user reported exactly that
// confusion.
const affiliateModalCanSubmit = computed(() => {
if (affiliateModal.mode === "add") {
if (!affiliateModal.selectedUser) return false;
} else if (!affiliateModal.editingEntry) {
return false;
}
const codeFilled = affiliateModal.code.trim() !== "";
const rateFilled = String(affiliateModal.rate ?? "").trim() !== "";
if (codeFilled || rateFilled) return true;
// Edit mode + empty rate input is a meaningful "clear" only if the user
// currently has an exclusive rate to clear.
return (
affiliateModal.mode === "edit" &&
affiliateModal.editingEntry?.aff_rebate_rate_percent != null
);
});
async function submitAffiliateModal() {
if (!affiliateModalCanSubmit.value) {
// Should be unreachable because the button is disabled, but keep a guard.
appStore.showError(t("admin.settings.features.affiliate.modal.errorEmpty"));
return;
}
let userId: number;
if (affiliateModal.mode === "add") {
userId = affiliateModal.selectedUser!.id;
} else {
userId = affiliateModal.editingEntry!.user_id;
}
const payload: Parameters<typeof affiliatesAPI.updateUserSettings>[1] = {};
const codeRaw = affiliateModal.code.trim();
if (codeRaw) payload.aff_code = codeRaw.toUpperCase();
const rateInput = parseRebateRate(affiliateModal.rate);
if (rateInput === undefined) return; // toast already shown
if (rateInput === null) {
if (affiliateModal.mode === "edit" && affiliateModal.editingEntry?.aff_rebate_rate_percent != null) {
payload.clear_rebate_rate = true;
}
} else {
payload.aff_rebate_rate_percent = rateInput;
}
affiliateModal.saving = true;
try {
await affiliatesAPI.updateUserSettings(userId, payload);
appStore.showSuccess(t("common.saved"));
closeAffiliateModal();
affiliateState.page = 1;
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateModal.saving = false;
}
}
// askResetAffiliateUser prompts via the project ConfirmDialog, then on confirm
// calls the backend "reset all" endpoint that clears both the exclusive rate
// AND regenerates the invite code as a system random one.
function askResetAffiliateUser(entry: AffiliateAdminEntry) {
openAffiliateConfirm(
t("admin.settings.features.affiliate.customUsers.resetTitle"),
t("admin.settings.features.affiliate.customUsers.resetMessage", {
email: entry.email || `#${entry.user_id}`,
}),
t("common.delete"),
() => affiliatesAPI.clearUserSettings(entry.user_id),
);
}
function openAffiliateBatchModal() {
if (affiliateState.selected.length === 0) return;
affiliateBatchModal.open = true;
affiliateBatchModal.rate = "";
}
async function submitAffiliateBatchModal() {
const rateInput = parseRebateRate(affiliateBatchModal.rate);
if (rateInput === undefined) return;
const userIDs = [...affiliateState.selected];
const payload: Parameters<typeof affiliatesAPI.batchSetRate>[0] =
rateInput === null
? { user_ids: userIDs, clear: true }
: { user_ids: userIDs, aff_rebate_rate_percent: rateInput };
affiliateBatchModal.saving = true;
try {
await affiliatesAPI.batchSetRate(payload);
appStore.showSuccess(t("common.saved"));
affiliateBatchModal.open = false;
affiliateState.selected = [];
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateBatchModal.saving = false;
}
}
// Load the per-user table the first time the affiliate switch is observed
// as enabled. The form starts disabled and is updated to the server's value
// after the settings load — so this fires either when the saved value is
// truthy on first paint, or when the admin manually toggles it on.
watch(
() => form.affiliate_enabled,
(enabled, prev) => {
if (enabled && !prev) {
loadAffiliateUsers();
}
},
);
</script> </script>
<style scoped> <style scoped>

View File

@@ -8,7 +8,23 @@
</div> </div>
<template v-else-if="detail"> <template v-else-if="detail">
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- 返利比例用主色突出让用户一眼看到能拿多少 -->
<div class="card relative overflow-hidden p-5">
<div class="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-primary-500/10"></div>
<div class="relative">
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
<Icon name="dollar" size="sm" class="text-primary-500" />
{{ t('affiliate.stats.rebateRate') }}
</p>
<p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">
{{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span>
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
{{ t('affiliate.stats.rebateRateHint') }}
</p>
</div>
</div>
<div class="card p-5"> <div class="card p-5">
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p> <p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white"> <p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
@@ -61,7 +77,7 @@
<p class="text-sm font-medium text-primary-800 dark:text-primary-200">{{ t('affiliate.tips.title') }}</p> <p class="text-sm font-medium text-primary-800 dark:text-primary-200">{{ t('affiliate.tips.title') }}</p>
<ul class="mt-2 space-y-1 text-sm text-primary-700 dark:text-primary-300"> <ul class="mt-2 space-y-1 text-sm text-primary-700 dark:text-primary-300">
<li>1. {{ t('affiliate.tips.line1') }}</li> <li>1. {{ t('affiliate.tips.line1') }}</li>
<li>2. {{ t('affiliate.tips.line2') }}</li> <li>2. {{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li>
<li>3. {{ t('affiliate.tips.line3') }}</li> <li>3. {{ t('affiliate.tips.line3') }}</li>
</ul> </ul>
</div> </div>
@@ -149,6 +165,14 @@ const inviteLink = computed(() => {
return `${window.location.origin}/register?aff=${encodeURIComponent(detail.value.aff_code)}` return `${window.location.origin}/register?aff=${encodeURIComponent(detail.value.aff_code)}`
}) })
// Rebate rate is a percentage in the range [0, 100]; backend already clamps it.
// We trim trailing zeros (e.g. 20.00 → "20", 12.50 → "12.5") for a cleaner UI.
const formattedRebateRate = computed(() => {
const v = detail.value?.effective_rebate_rate_percent ?? 0
const rounded = Math.round(v * 100) / 100
return Number.isInteger(rounded) ? String(rounded) : rounded.toString()
})
function formatCount(value: number): string { function formatCount(value: number): string {
return value.toLocaleString() return value.toLocaleString()
} }