mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 05:30:44 +08:00
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:
@@ -70,7 +70,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
|
||||
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)
|
||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
@@ -231,7 +231,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
|
||||
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
|
||||
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)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
|
||||
183
backend/internal/handler/admin/affiliate_handler.go
Normal file
183
backend/internal/handler/admin/affiliate_handler.go
Normal 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)
|
||||
}
|
||||
@@ -242,6 +242,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
}
|
||||
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
|
||||
}
|
||||
@@ -441,6 +443,9 @@ type UpdateSettingsRequest struct {
|
||||
|
||||
// Available Channels feature switch (user-facing)
|
||||
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||||
}
|
||||
|
||||
// UpdateSettings 更新系统设置
|
||||
@@ -1265,6 +1270,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.AvailableChannelsEnabled
|
||||
}(),
|
||||
AffiliateEnabled: func() bool {
|
||||
if req.AffiliateEnabled != nil {
|
||||
return *req.AffiliateEnabled
|
||||
}
|
||||
return previousSettings.AffiliateEnabled
|
||||
}(),
|
||||
}
|
||||
|
||||
authSourceDefaults := &service.AuthSourceDefaultSettings{
|
||||
@@ -1502,6 +1513,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
|
||||
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||||
}
|
||||
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
|
||||
}
|
||||
@@ -1870,6 +1883,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
|
||||
changed = append(changed, "available_channels_enabled")
|
||||
}
|
||||
if before.AffiliateEnabled != after.AffiliateEnabled {
|
||||
changed = append(changed, "affiliate_enabled")
|
||||
}
|
||||
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -192,6 +192,9 @@ type SystemSettings struct {
|
||||
|
||||
// Available Channels feature switch (user-facing aggregate view)
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
}
|
||||
|
||||
type DefaultSubscriptionSetting struct {
|
||||
@@ -244,6 +247,8 @@ type PublicSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
}
|
||||
|
||||
// OverloadCooldownSettings 529过载冷却配置 DTO
|
||||
|
||||
@@ -34,6 +34,7 @@ type AdminHandlers struct {
|
||||
ChannelMonitor *admin.ChannelMonitorHandler
|
||||
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
|
||||
Payment *admin.PaymentHandler
|
||||
Affiliate *admin.AffiliateHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@@ -75,5 +75,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func ProvideAdminHandlers(
|
||||
channelMonitorHandler *admin.ChannelMonitorHandler,
|
||||
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
|
||||
paymentHandler *admin.PaymentHandler,
|
||||
affiliateHandler *admin.AffiliateHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
@@ -67,6 +68,7 @@ func ProvideAdminHandlers(
|
||||
ChannelMonitor: channelMonitorHandler,
|
||||
ChannelMonitorTemplate: channelMonitorTemplateHandler,
|
||||
Payment: paymentHandler,
|
||||
Affiliate: affiliateHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +171,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewChannelMonitorHandler,
|
||||
admin.NewChannelMonitorRequestTemplateHandler,
|
||||
admin.NewPaymentHandler,
|
||||
admin.NewAffiliateHandler,
|
||||
|
||||
// AdminHandlers and Handlers constructors
|
||||
ProvideAdminHandlers,
|
||||
|
||||
@@ -294,6 +294,8 @@ func queryAffiliateByUserID(ctx context.Context, client affiliateQueryExecer, us
|
||||
rows, err := client.QueryContext(ctx, `
|
||||
SELECT user_id,
|
||||
aff_code,
|
||||
aff_code_custom,
|
||||
aff_rebate_rate_percent,
|
||||
inviter_id,
|
||||
aff_count,
|
||||
aff_quota::double precision,
|
||||
@@ -315,9 +317,12 @@ WHERE user_id = $1`, userID)
|
||||
|
||||
var out service.AffiliateSummary
|
||||
var inviterID sql.NullInt64
|
||||
var rebateRate sql.NullFloat64
|
||||
if err := rows.Scan(
|
||||
&out.UserID,
|
||||
&out.AffCode,
|
||||
&out.AffCodeCustom,
|
||||
&rebateRate,
|
||||
&inviterID,
|
||||
&out.AffCount,
|
||||
&out.AffQuota,
|
||||
@@ -330,6 +335,10 @@ WHERE user_id = $1`, userID)
|
||||
if inviterID.Valid {
|
||||
out.InviterID = &inviterID.Int64
|
||||
}
|
||||
if rebateRate.Valid {
|
||||
v := rebateRate.Float64
|
||||
out.AffRebateRatePercent = &v
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
@@ -337,6 +346,8 @@ func queryAffiliateByCode(ctx context.Context, client affiliateQueryExecer, code
|
||||
rows, err := client.QueryContext(ctx, `
|
||||
SELECT user_id,
|
||||
aff_code,
|
||||
aff_code_custom,
|
||||
aff_rebate_rate_percent,
|
||||
inviter_id,
|
||||
aff_count,
|
||||
aff_quota::double precision,
|
||||
@@ -360,9 +371,12 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
|
||||
|
||||
var out service.AffiliateSummary
|
||||
var inviterID sql.NullInt64
|
||||
var rebateRate sql.NullFloat64
|
||||
if err := rows.Scan(
|
||||
&out.UserID,
|
||||
&out.AffCode,
|
||||
&out.AffCodeCustom,
|
||||
&rebateRate,
|
||||
&inviterID,
|
||||
&out.AffCount,
|
||||
&out.AffQuota,
|
||||
@@ -375,6 +389,10 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
|
||||
if inviterID.Valid {
|
||||
out.InviterID = &inviterID.Int64
|
||||
}
|
||||
if rebateRate.Valid {
|
||||
v := rebateRate.Float64
|
||||
out.AffRebateRatePercent = &v
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
@@ -418,3 +436,229 @@ func isAffiliateUniqueViolation(err error) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -775,6 +775,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"channel_monitor_enabled": true,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"affiliate_enabled": false,
|
||||
"wechat_connect_enabled": false,
|
||||
"wechat_connect_app_id": "",
|
||||
"wechat_connect_app_secret_configured": false,
|
||||
@@ -951,6 +952,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"channel_monitor_enabled": true,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"affiliate_enabled": false,
|
||||
"wechat_connect_enabled": true,
|
||||
"wechat_connect_app_id": "wx-open-config",
|
||||
"wechat_connect_app_secret_configured": true,
|
||||
|
||||
@@ -91,6 +91,9 @@ func RegisterAdminRoutes(
|
||||
|
||||
// 渠道监控
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,28 +14,39 @@ import (
|
||||
var (
|
||||
ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found")
|
||||
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")
|
||||
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
|
||||
)
|
||||
|
||||
const (
|
||||
affiliateInviteesLimit = 100
|
||||
// affiliateCodeFormatLength must stay in sync with repository.affiliateCodeLength.
|
||||
affiliateCodeFormatLength = 12
|
||||
// AffiliateCodeMinLength / AffiliateCodeMaxLength bound both system-generated
|
||||
// 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
|
||||
// by the repository's generateAffiliateCode (A-Z minus I/O, digits 2-9).
|
||||
// affiliateCodeValidChar accepts uppercase letters, digits, underscore and dash.
|
||||
// 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 tbl [256]bool
|
||||
for _, c := range []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") {
|
||||
for c := byte('A'); c <= 'Z'; c++ {
|
||||
tbl[c] = true
|
||||
}
|
||||
for c := byte('0'); c <= '9'; c++ {
|
||||
tbl[c] = true
|
||||
}
|
||||
tbl['_'] = true
|
||||
tbl['-'] = true
|
||||
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 {
|
||||
if len(code) != affiliateCodeFormatLength {
|
||||
if len(code) < AffiliateCodeMinLength || len(code) > AffiliateCodeMaxLength {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(code); i++ {
|
||||
@@ -50,6 +60,8 @@ func isValidAffiliateCodeFormat(code string) bool {
|
||||
type AffiliateSummary struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
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"`
|
||||
AffCount int `json:"aff_count"`
|
||||
AffQuota float64 `json:"aff_quota"`
|
||||
@@ -72,6 +84,10 @@ type AffiliateDetail struct {
|
||||
AffCount int `json:"aff_count"`
|
||||
AffQuota float64 `json:"aff_quota"`
|
||||
AffHistoryQuota float64 `json:"aff_history_quota"`
|
||||
// EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例:
|
||||
// 优先用户自己的专属比例(aff_rebate_rate_percent),否则回退到全局比例。
|
||||
// 用于在用户的 /affiliate 页面直观展示「分享后能拿到多少」。
|
||||
EffectiveRebateRatePercent float64 `json:"effective_rebate_rate_percent"`
|
||||
Invitees []AffiliateInvitee `json:"invitees"`
|
||||
}
|
||||
|
||||
@@ -82,24 +98,57 @@ type AffiliateRepository interface {
|
||||
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error)
|
||||
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, 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 {
|
||||
repo AffiliateRepository
|
||||
settingRepo SettingRepository
|
||||
settingService *SettingService
|
||||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||||
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{
|
||||
repo: repo,
|
||||
settingRepo: settingRepo,
|
||||
settingService: settingService,
|
||||
authCacheInvalidator: authCacheInvalidator,
|
||||
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) {
|
||||
if userID <= 0 {
|
||||
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
|
||||
@@ -126,6 +175,7 @@ func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64)
|
||||
AffCount: summary.AffCount,
|
||||
AffQuota: summary.AffQuota,
|
||||
AffHistoryQuota: summary.AffHistoryQuota,
|
||||
EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary),
|
||||
Invitees: invitees,
|
||||
}, nil
|
||||
}
|
||||
@@ -135,12 +185,16 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
if !isValidAffiliateCodeFormat(code) {
|
||||
return ErrAffiliateCodeInvalid
|
||||
}
|
||||
if s == nil || s.repo == nil {
|
||||
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)
|
||||
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) {
|
||||
return 0, nil
|
||||
}
|
||||
// 总开关关闭时,新充值不再产生返利
|
||||
if !s.IsEnabled(ctx) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
|
||||
if err != nil {
|
||||
@@ -187,16 +245,17 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
|
||||
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)
|
||||
if rebate <= 0 {
|
||||
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)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -207,6 +266,28 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
|
||||
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) {
|
||||
if s == nil || s.repo == nil {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
factor := math.Pow10(scale)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,51 +4,82 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type affiliateSettingRepoStub struct {
|
||||
value string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *affiliateSettingRepoStub) Get(context.Context, string) (*Setting, error) { return nil, s.err }
|
||||
func (s *affiliateSettingRepoStub) GetValue(context.Context, string) (string, error) {
|
||||
if s.err != nil {
|
||||
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) {
|
||||
// TestResolveRebateRatePercent_PerUserOverride verifies that per-inviter
|
||||
// AffRebateRatePercent overrides the global rate, that NULL falls back to the
|
||||
// global rate, and that out-of-range exclusive rates are clamped silently.
|
||||
//
|
||||
// SettingService is left nil here so globalRebateRatePercent returns the
|
||||
// documented default (AffiliateRebateRateDefault = 20%) — this exercises the
|
||||
// fallback path without spinning up a settings stub.
|
||||
func TestResolveRebateRatePercent_PerUserOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &AffiliateService{}
|
||||
|
||||
svc := &AffiliateService{settingRepo: &affiliateSettingRepoStub{value: "1"}}
|
||||
rate := svc.loadAffiliateRebateRatePercent(context.Background())
|
||||
require.Equal(t, 1.0, rate)
|
||||
// nil exclusive rate → falls back to global default (20%)
|
||||
require.InDelta(t, AffiliateRebateRateDefault,
|
||||
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{}), 1e-9)
|
||||
|
||||
svc.settingRepo = &affiliateSettingRepoStub{value: "0.2"}
|
||||
rate = svc.loadAffiliateRebateRatePercent(context.Background())
|
||||
require.Equal(t, 0.2, rate)
|
||||
// exclusive rate set → overrides global
|
||||
rate := 50.0
|
||||
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) {
|
||||
@@ -61,24 +92,33 @@ func TestMaskEmail(t *testing.T) {
|
||||
func TestIsValidAffiliateCodeFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 邀请码格式校验同时服务于:
|
||||
// 1) 系统自动生成的 12 位随机码(A-Z 去 I/O,2-9 去 0/1)
|
||||
// 2) 管理员设置的自定义专属码(如 "VIP2026"、"NEW_USER-1")
|
||||
// 因此校验放宽到 [A-Z0-9_-]{4,32}(要求调用方先 ToUpper)。
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"valid canonical", "ABCDEFGHJKLM", true},
|
||||
{"valid canonical 12-char", "ABCDEFGHJKLM", true},
|
||||
{"valid all digits 2-9", "234567892345", true},
|
||||
{"valid mixed", "A2B3C4D5E6F7", true},
|
||||
{"too short", "ABCDEFGHJKL", false},
|
||||
{"too long", "ABCDEFGHJKLMN", false},
|
||||
{"contains excluded letter I", "IBCDEFGHJKLM", false},
|
||||
{"contains excluded letter O", "OBCDEFGHJKLM", false},
|
||||
{"contains excluded digit 0", "0BCDEFGHJKLM", false},
|
||||
{"contains excluded digit 1", "1BCDEFGHJKLM", false},
|
||||
{"valid admin custom short", "VIP1", true},
|
||||
{"valid admin custom with hyphen", "NEW-USER", true},
|
||||
{"valid admin custom with underscore", "VIP_2026", true},
|
||||
{"valid 32-char max", "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", true},
|
||||
// Previously-excluded chars (I/O/0/1) are now allowed since admins may use them.
|
||||
{"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},
|
||||
{"empty", "", false},
|
||||
{"12-byte utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // 6×2 bytes = 12 bytes, bytes out of charset
|
||||
{"ascii punctuation", "ABCDEFGHJK.M", false},
|
||||
{"utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // bytes out of charset
|
||||
{"ascii punctuation .", "ABCDEFGHJK.M", false},
|
||||
{"whitespace", "ABCDEFGHJK M", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
AffiliateRebateRateDefault = 20.0
|
||||
AffiliateRebateRateMin = 0.0
|
||||
AffiliateRebateRateMax = 100.0
|
||||
AffiliateEnabledDefault = false // 邀请返利总开关默认关闭
|
||||
)
|
||||
|
||||
// Platform constants
|
||||
@@ -94,6 +95,7 @@ const (
|
||||
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
||||
SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
|
||||
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
||||
SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关
|
||||
SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100)
|
||||
|
||||
// 邮件服务设置
|
||||
|
||||
@@ -454,6 +454,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyChannelMonitorEnabled,
|
||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||
SettingKeyAvailableChannelsEnabled,
|
||||
SettingKeyAffiliateEnabled,
|
||||
}
|
||||
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
@@ -541,6 +542,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||||
|
||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
|
||||
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -687,6 +690,7 @@ type PublicSettingsInjectionPayload struct {
|
||||
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1205,6 +1210,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
// Available channels feature switch
|
||||
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
||||
|
||||
// Claude Code version check
|
||||
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
||||
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
||||
@@ -1480,6 +1488,30 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
||||
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 检查是否启用密码重置功能
|
||||
// 要求:必须同时开启邮件验证
|
||||
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)
|
||||
SettingKeyAvailableChannelsEnabled: "false",
|
||||
|
||||
// Affiliate (邀请返利) feature (default disabled; opt-in)
|
||||
SettingKeyAffiliateEnabled: "false",
|
||||
|
||||
// Claude Code version check (default: empty = disabled)
|
||||
SettingKeyMinClaudeCodeVersion: "",
|
||||
SettingKeyMaxClaudeCodeVersion: "",
|
||||
@@ -2091,6 +2126,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
// Available channels feature (default: disabled; strict true)
|
||||
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
|
||||
|
||||
// Affiliate (邀请返利) feature (default: disabled; strict true)
|
||||
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
|
||||
|
||||
// Claude Code version check
|
||||
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
||||
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
|
||||
|
||||
@@ -106,6 +106,7 @@ type SystemSettings struct {
|
||||
|
||||
DefaultConcurrency int
|
||||
DefaultBalance float64
|
||||
AffiliateEnabled bool
|
||||
AffiliateRebateRate float64
|
||||
DefaultUserRPMLimit int
|
||||
DefaultSubscriptions []DefaultSubscriptionSetting
|
||||
@@ -225,6 +226,9 @@ type PublicSettings struct {
|
||||
|
||||
// Available Channels feature (user-facing aggregate view)
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature toggle
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
}
|
||||
|
||||
type WeChatConnectOAuthConfig struct {
|
||||
|
||||
16
backend/migrations/132_affiliate_custom_settings.sql
Normal file
16
backend/migrations/132_affiliate_custom_settings.sql
Normal 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-100,NULL 表示沿用全局)';
|
||||
COMMENT ON COLUMN user_affiliates.aff_code_custom IS '邀请码是否由管理员改写过(用于专属用户筛选)';
|
||||
108
frontend/src/api/admin/affiliates.ts
Normal file
108
frontend/src/api/admin/affiliates.ts
Normal 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
|
||||
@@ -29,6 +29,7 @@ import channelsAPI from './channels'
|
||||
import channelMonitorAPI from './channelMonitor'
|
||||
import channelMonitorTemplateAPI from './channelMonitorTemplate'
|
||||
import adminPaymentAPI from './payment'
|
||||
import affiliatesAPI from './affiliates'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -59,7 +60,8 @@ export const adminAPI = {
|
||||
channels: channelsAPI,
|
||||
channelMonitor: channelMonitorAPI,
|
||||
channelMonitorTemplate: channelMonitorTemplateAPI,
|
||||
payment: adminPaymentAPI
|
||||
payment: adminPaymentAPI,
|
||||
affiliates: affiliatesAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -88,7 +90,8 @@ export {
|
||||
channelsAPI,
|
||||
channelMonitorAPI,
|
||||
channelMonitorTemplateAPI,
|
||||
adminPaymentAPI
|
||||
adminPaymentAPI,
|
||||
affiliatesAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
@@ -478,6 +478,9 @@ export interface SystemSettings {
|
||||
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: boolean;
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@@ -636,6 +639,9 @@ export interface UpdateSettingsRequest {
|
||||
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled?: boolean;
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -634,6 +634,7 @@ const ChevronDownIcon = {
|
||||
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
|
||||
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
||||
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
||||
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
|
||||
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
||||
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: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
||||
{ 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 },
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: `/custom/${item.id}`,
|
||||
|
||||
@@ -985,6 +985,8 @@ export default {
|
||||
loadFailed: 'Failed to load affiliate data',
|
||||
transferFailed: 'Failed to transfer affiliate quota',
|
||||
stats: {
|
||||
rebateRate: 'My Rebate Rate',
|
||||
rebateRateHint: 'What you earn each time an invitee recharges',
|
||||
invitedUsers: 'Invited Users',
|
||||
availableQuota: 'Available Rebate Quota',
|
||||
totalQuota: 'Historical Rebate Quota'
|
||||
@@ -1009,7 +1011,7 @@ export default {
|
||||
tips: {
|
||||
title: 'How It Works',
|
||||
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.'
|
||||
}
|
||||
},
|
||||
@@ -4779,6 +4781,55 @@ export default {
|
||||
enabled: 'Enable Available Channels',
|
||||
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 invitee’s 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',
|
||||
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
|
||||
|
||||
@@ -989,6 +989,8 @@ export default {
|
||||
loadFailed: '加载邀请返利数据失败',
|
||||
transferFailed: '转入余额失败',
|
||||
stats: {
|
||||
rebateRate: '我的返利比例',
|
||||
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
|
||||
invitedUsers: '邀请人数',
|
||||
availableQuota: '可转返利额度',
|
||||
totalQuota: '历史返利额度'
|
||||
@@ -1013,7 +1015,7 @@ export default {
|
||||
tips: {
|
||||
title: '使用说明',
|
||||
line1: '将邀请码或邀请链接分享给新用户。',
|
||||
line2: '被邀请用户充值后,你可获得对应比例的返利额度。',
|
||||
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
|
||||
line3: '返利额度可随时转入账户余额。'
|
||||
}
|
||||
},
|
||||
@@ -4942,6 +4944,55 @@ export default {
|
||||
enabled: '启用可用渠道',
|
||||
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: '邮箱验证未启用',
|
||||
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
|
||||
|
||||
@@ -355,6 +355,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
channel_monitor_enabled: true,
|
||||
channel_monitor_default_interval_seconds: 60,
|
||||
available_channels_enabled: false,
|
||||
affiliate_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ export interface UserAffiliateDetail {
|
||||
aff_count: number
|
||||
aff_quota: number
|
||||
aff_history_quota: number
|
||||
/** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */
|
||||
effective_rebate_rate_percent: number
|
||||
invitees: AffiliateInvitee[]
|
||||
}
|
||||
|
||||
@@ -212,6 +214,7 @@ export interface PublicSettings {
|
||||
channel_monitor_enabled: boolean
|
||||
channel_monitor_default_interval_seconds: number
|
||||
available_channels_enabled: boolean
|
||||
affiliate_enabled: boolean
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
|
||||
@@ -109,6 +109,11 @@ export const FeatureFlags = {
|
||||
mode: 'opt-out',
|
||||
label: 'Payment',
|
||||
}),
|
||||
affiliate: defineFlag({
|
||||
key: 'affiliate_enabled',
|
||||
mode: 'opt-in',
|
||||
label: 'Affiliate',
|
||||
}),
|
||||
} as const
|
||||
|
||||
export type RegisteredFeatureFlag = keyof typeof FeatureFlags
|
||||
|
||||
@@ -2153,31 +2153,6 @@
|
||||
{{ t("admin.settings.defaults.defaultBalanceHint") }}
|
||||
</p>
|
||||
</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>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
@@ -3878,6 +3853,356 @@
|
||||
</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 -->
|
||||
|
||||
<!-- Tab: Email -->
|
||||
@@ -4793,12 +5118,21 @@
|
||||
@confirm="handleDeleteProvider"
|
||||
@cancel="showDeleteProviderDialog = false"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
:show="affiliateConfirmDialog.show"
|
||||
:title="affiliateConfirmDialog.title"
|
||||
:message="affiliateConfirmDialog.message"
|
||||
:confirm-text="affiliateConfirmDialog.confirmText"
|
||||
danger
|
||||
@confirm="handleAffiliateConfirm"
|
||||
@cancel="cancelAffiliateConfirm"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<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 { adminAPI } from "@/api";
|
||||
import {
|
||||
@@ -4835,6 +5169,7 @@ import ProxySelector from "@/components/common/ProxySelector.vue";
|
||||
import ImageUpload from "@/components/common/ImageUpload.vue";
|
||||
import BackupSettings from "@/views/admin/BackupView.vue";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
||||
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { useAdminSettingsStore } from "@/stores/adminSettings";
|
||||
@@ -5145,6 +5480,8 @@ const form = reactive<SettingsForm>({
|
||||
channel_monitor_default_interval_seconds: 60,
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: false,
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: false,
|
||||
});
|
||||
|
||||
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
|
||||
@@ -6063,6 +6400,8 @@ async function saveSettings() {
|
||||
Number(form.channel_monitor_default_interval_seconds) || 60,
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: form.available_channels_enabled,
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: form.affiliate_enabled,
|
||||
};
|
||||
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
|
||||
@@ -6844,6 +7183,359 @@ onMounted(() => {
|
||||
loadBetaPolicySettings();
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,7 +8,23 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
@@ -61,7 +77,7 @@
|
||||
<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">
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -149,6 +165,14 @@ const inviteLink = computed(() => {
|
||||
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 {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user