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

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

View File

@@ -70,7 +70,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
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)

View File

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

View File

@@ -242,6 +242,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
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
}

View File

@@ -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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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
}

View File

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

View File

@@ -775,6 +775,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_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,

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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/O2-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 {

View File

@@ -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
// 邮件服务设置

View File

@@ -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]

View File

@@ -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 {

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ import channelsAPI from './channels'
import channelMonitorAPI from './channelMonitor'
import 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

View File

@@ -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;
}
/**

View File

@@ -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}`,

View File

@@ -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 invitees recharges. Disabled by default.',
enabled: 'Enable Affiliate',
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
rebateRate: 'Global Rebate Rate',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
customUsers: {
title: 'Per-User Overrides',
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
addButton: 'Add Custom User',
searchPlaceholder: 'Search by email or username',
batchButton: 'Batch Set Rate ({count} selected)',
empty: 'No users with custom affiliate settings yet',
customBadge: 'custom',
useGlobal: 'use global',
resetTitle: 'Reset Custom Settings',
resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)',
totalLabel: '{total} total',
col: {
email: 'Email',
username: 'Username',
code: 'Invite Code',
rate: 'Custom Rate',
actions: 'Actions',
},
},
modal: {
addTitle: 'Add Custom User',
editTitle: 'Edit Custom Settings',
userLabel: 'User',
userPlaceholder: 'Search by email or username',
changeUser: 'Change user',
codeLabel: 'Custom Invite Code (optional)',
codePlaceholder: 'e.g. VIP2026',
codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.',
rateLabel: 'Exclusive Rebate Rate (optional)',
ratePlaceholder: 'e.g. 30',
rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.',
errorBadRate: 'Please enter a number between 0 and 100',
errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate',
},
batchModal: {
title: 'Batch Set Rate ({count} users selected)',
hint: 'Apply the same exclusive rebate rate to all selected users.',
placeholder: 'e.g. 30',
clearHint: 'Submitting empty will clear the exclusive rate for selected users.',
},
},
},
emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',

View File

@@ -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 设置。',

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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>

View File

@@ -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()
}