mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
feat(affiliate): add feature toggle and per-user custom invite settings
- 在系统设置「功能开关」中新增邀请返利总开关,默认关闭;
关闭态:菜单隐藏、注册忽略 aff、新充值不返利,但已有 quota 仍可转余额
- 支持管理员为指定用户设置专属邀请码(覆盖随机码,全局唯一)
- 支持管理员为指定用户设置专属返利比例(覆盖全局比例,可单条/批量调整)
- 在系统设置邀请返利卡片内嵌入专属用户管理表格(搜索/编辑/批量/删除),
删除采用项目通用 ConfirmDialog,会同时清除专属比例并把邀请码重置为系统随机码
- /affiliate 用户页新增「我的返利比例」卡片与动态使用说明,让用户直观看到
分享后能拿到多少(同源 resolveRebateRatePercent 计算,与实际充值一致)
- 新增数据库迁移 132 添加 aff_rebate_rate_percent 与 aff_code_custom 列
- 新增 admin 路由组 /api/v1/admin/affiliates/users/* 共 5 个端点
- AffiliateService 改为只依赖 *SettingService,去除冗余的 SettingRepository
- 邀请码格式校验放宽到 [A-Z0-9_-]{4,32},兼容旧 12 位系统码与新自定义码
- 补充单元测试与集成测试覆盖新方法、冲突路径与边界值
This commit is contained in:
@@ -70,7 +70,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
|
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
|
||||||
affiliateRepository := repository.NewAffiliateRepository(client, db)
|
affiliateRepository := repository.NewAffiliateRepository(client, db)
|
||||||
affiliateService := service.NewAffiliateService(affiliateRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCacheService)
|
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
|
||||||
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
|
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
|
||||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||||
redeemCache := repository.NewRedeemCache(redisClient)
|
redeemCache := repository.NewRedeemCache(redisClient)
|
||||||
@@ -231,7 +231,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
|
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
|
||||||
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
|
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
|
||||||
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
||||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler)
|
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
|
||||||
|
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler, affiliateHandler)
|
||||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||||
|
|||||||
183
backend/internal/handler/admin/affiliate_handler.go
Normal file
183
backend/internal/handler/admin/affiliate_handler.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AffiliateHandler handles admin affiliate (邀请返利) management:
|
||||||
|
// listing users with custom settings, updating per-user invite codes
|
||||||
|
// and exclusive rebate rates, and batch operations.
|
||||||
|
type AffiliateHandler struct {
|
||||||
|
affiliateService *service.AffiliateService
|
||||||
|
adminService service.AdminService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAffiliateHandler creates a new admin affiliate handler.
|
||||||
|
func NewAffiliateHandler(affiliateService *service.AffiliateService, adminService service.AdminService) *AffiliateHandler {
|
||||||
|
return &AffiliateHandler{
|
||||||
|
affiliateService: affiliateService,
|
||||||
|
adminService: adminService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns paginated users with custom affiliate settings.
|
||||||
|
// GET /api/v1/admin/affiliates/users
|
||||||
|
func (h *AffiliateHandler) ListUsers(c *gin.Context) {
|
||||||
|
page, pageSize := response.ParsePagination(c)
|
||||||
|
search := c.Query("search")
|
||||||
|
|
||||||
|
entries, total, err := h.affiliateService.AdminListCustomUsers(c.Request.Context(), service.AffiliateAdminFilter{
|
||||||
|
Search: search,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Paginated(c, entries, total, page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserSettings updates a user's affiliate settings.
|
||||||
|
// PUT /api/v1/admin/affiliates/users/:user_id
|
||||||
|
//
|
||||||
|
// Both fields are optional and applied independently.
|
||||||
|
type UpdateAffiliateUserRequest struct {
|
||||||
|
AffCode *string `json:"aff_code"`
|
||||||
|
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
|
||||||
|
// ClearRebateRate explicitly clears the per-user rate (sets it to NULL).
|
||||||
|
// Used to disambiguate from "field not provided".
|
||||||
|
ClearRebateRate bool `json:"clear_rebate_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AffiliateHandler) UpdateUserSettings(c *gin.Context) {
|
||||||
|
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
response.BadRequest(c, "Invalid user_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateAffiliateUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AffCode != nil {
|
||||||
|
if err := h.affiliateService.AdminUpdateUserAffCode(c.Request.Context(), userID, *req.AffCode); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ClearRebateRate {
|
||||||
|
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if req.AffRebateRatePercent != nil {
|
||||||
|
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, req.AffRebateRatePercent); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{"user_id": userID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearUserSettings removes ALL of a user's custom affiliate settings — clears
|
||||||
|
// the exclusive rebate rate AND regenerates the invite code as a new system
|
||||||
|
// random one. Conceptually this "removes the user from the custom list".
|
||||||
|
//
|
||||||
|
// Both writes happen in this handler; failure of one leaves the other applied,
|
||||||
|
// but the operation is idempotent so the admin can re-run it safely.
|
||||||
|
// DELETE /api/v1/admin/affiliates/users/:user_id
|
||||||
|
func (h *AffiliateHandler) ClearUserSettings(c *gin.Context) {
|
||||||
|
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
response.BadRequest(c, "Invalid user_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.affiliateService.AdminResetUserAffCode(c.Request.Context(), userID); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, gin.H{"user_id": userID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchSetRate applies the same rebate rate (or clears it) to multiple users.
|
||||||
|
//
|
||||||
|
// Protocol: pass `clear: true` to clear rates (aff_rebate_rate_percent is
|
||||||
|
// ignored). Otherwise aff_rebate_rate_percent is required and applied to
|
||||||
|
// every user_id. The explicit `clear` flag exists because Go's JSON unmarshal
|
||||||
|
// can't distinguish a missing field from `null`, and a silent clear from a
|
||||||
|
// frontend that forgot to include the rate would be a footgun.
|
||||||
|
//
|
||||||
|
// POST /api/v1/admin/affiliates/users/batch-rate
|
||||||
|
type BatchSetRateRequest struct {
|
||||||
|
UserIDs []int64 `json:"user_ids" binding:"required"`
|
||||||
|
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
|
||||||
|
Clear bool `json:"clear"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AffiliateHandler) BatchSetRate(c *gin.Context) {
|
||||||
|
var req BatchSetRateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.UserIDs) == 0 {
|
||||||
|
response.BadRequest(c, "user_ids cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !req.Clear && req.AffRebateRatePercent == nil {
|
||||||
|
response.BadRequest(c, "aff_rebate_rate_percent is required unless clear=true")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rate := req.AffRebateRatePercent
|
||||||
|
if req.Clear {
|
||||||
|
rate = nil
|
||||||
|
}
|
||||||
|
if err := h.affiliateService.AdminBatchSetUserRebateRate(c.Request.Context(), req.UserIDs, rate); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, gin.H{"affected": len(req.UserIDs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AffiliateUserSummary is the minimal user shape returned by LookupUsers,
|
||||||
|
// shared with the frontend's add-custom-user picker.
|
||||||
|
type AffiliateUserSummary struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupUsers searches users by email/username for the "add custom user" modal.
|
||||||
|
// GET /api/v1/admin/affiliates/users/lookup?q=
|
||||||
|
func (h *AffiliateHandler) LookupUsers(c *gin.Context) {
|
||||||
|
keyword := c.Query("q")
|
||||||
|
if keyword == "" {
|
||||||
|
response.Success(c, []AffiliateUserSummary{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 20, service.UserListFilters{Search: keyword}, "email", "asc")
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := make([]AffiliateUserSummary, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
result[i] = AffiliateUserSummary{ID: u.ID, Email: u.Email, Username: u.Username}
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
@@ -242,6 +242,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||||
|
|
||||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||||
|
|
||||||
|
AffiliateEnabled: settings.AffiliateEnabled,
|
||||||
}
|
}
|
||||||
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
|
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
|
||||||
}
|
}
|
||||||
@@ -441,6 +443,9 @@ type UpdateSettingsRequest struct {
|
|||||||
|
|
||||||
// Available Channels feature switch (user-facing)
|
// Available Channels feature switch (user-facing)
|
||||||
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
|
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
|
||||||
|
|
||||||
|
// Affiliate (邀请返利) feature switch
|
||||||
|
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings 更新系统设置
|
// UpdateSettings 更新系统设置
|
||||||
@@ -1265,6 +1270,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return previousSettings.AvailableChannelsEnabled
|
return previousSettings.AvailableChannelsEnabled
|
||||||
}(),
|
}(),
|
||||||
|
AffiliateEnabled: func() bool {
|
||||||
|
if req.AffiliateEnabled != nil {
|
||||||
|
return *req.AffiliateEnabled
|
||||||
|
}
|
||||||
|
return previousSettings.AffiliateEnabled
|
||||||
|
}(),
|
||||||
}
|
}
|
||||||
|
|
||||||
authSourceDefaults := &service.AuthSourceDefaultSettings{
|
authSourceDefaults := &service.AuthSourceDefaultSettings{
|
||||||
@@ -1502,6 +1513,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
||||||
|
|
||||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||||
|
|
||||||
|
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||||||
}
|
}
|
||||||
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
|
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
|
||||||
}
|
}
|
||||||
@@ -1870,6 +1883,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
|
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
|
||||||
changed = append(changed, "available_channels_enabled")
|
changed = append(changed, "available_channels_enabled")
|
||||||
}
|
}
|
||||||
|
if before.AffiliateEnabled != after.AffiliateEnabled {
|
||||||
|
changed = append(changed, "affiliate_enabled")
|
||||||
|
}
|
||||||
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
|
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ type SystemSettings struct {
|
|||||||
|
|
||||||
// Available Channels feature switch (user-facing aggregate view)
|
// Available Channels feature switch (user-facing aggregate view)
|
||||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||||
|
|
||||||
|
// Affiliate (邀请返利) feature switch
|
||||||
|
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultSubscriptionSetting struct {
|
type DefaultSubscriptionSetting struct {
|
||||||
@@ -244,6 +247,8 @@ type PublicSettings struct {
|
|||||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||||
|
|
||||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||||
|
|
||||||
|
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OverloadCooldownSettings 529过载冷却配置 DTO
|
// OverloadCooldownSettings 529过载冷却配置 DTO
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type AdminHandlers struct {
|
|||||||
ChannelMonitor *admin.ChannelMonitorHandler
|
ChannelMonitor *admin.ChannelMonitorHandler
|
||||||
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
|
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
|
||||||
Payment *admin.PaymentHandler
|
Payment *admin.PaymentHandler
|
||||||
|
Affiliate *admin.AffiliateHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers contains all HTTP handlers
|
// Handlers contains all HTTP handlers
|
||||||
|
|||||||
@@ -75,5 +75,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||||
|
|
||||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||||
|
|
||||||
|
AffiliateEnabled: settings.AffiliateEnabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func ProvideAdminHandlers(
|
|||||||
channelMonitorHandler *admin.ChannelMonitorHandler,
|
channelMonitorHandler *admin.ChannelMonitorHandler,
|
||||||
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
|
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
|
||||||
paymentHandler *admin.PaymentHandler,
|
paymentHandler *admin.PaymentHandler,
|
||||||
|
affiliateHandler *admin.AffiliateHandler,
|
||||||
) *AdminHandlers {
|
) *AdminHandlers {
|
||||||
return &AdminHandlers{
|
return &AdminHandlers{
|
||||||
Dashboard: dashboardHandler,
|
Dashboard: dashboardHandler,
|
||||||
@@ -67,6 +68,7 @@ func ProvideAdminHandlers(
|
|||||||
ChannelMonitor: channelMonitorHandler,
|
ChannelMonitor: channelMonitorHandler,
|
||||||
ChannelMonitorTemplate: channelMonitorTemplateHandler,
|
ChannelMonitorTemplate: channelMonitorTemplateHandler,
|
||||||
Payment: paymentHandler,
|
Payment: paymentHandler,
|
||||||
|
Affiliate: affiliateHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +171,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
admin.NewChannelMonitorHandler,
|
admin.NewChannelMonitorHandler,
|
||||||
admin.NewChannelMonitorRequestTemplateHandler,
|
admin.NewChannelMonitorRequestTemplateHandler,
|
||||||
admin.NewPaymentHandler,
|
admin.NewPaymentHandler,
|
||||||
|
admin.NewAffiliateHandler,
|
||||||
|
|
||||||
// AdminHandlers and Handlers constructors
|
// AdminHandlers and Handlers constructors
|
||||||
ProvideAdminHandlers,
|
ProvideAdminHandlers,
|
||||||
|
|||||||
@@ -294,6 +294,8 @@ func queryAffiliateByUserID(ctx context.Context, client affiliateQueryExecer, us
|
|||||||
rows, err := client.QueryContext(ctx, `
|
rows, err := client.QueryContext(ctx, `
|
||||||
SELECT user_id,
|
SELECT user_id,
|
||||||
aff_code,
|
aff_code,
|
||||||
|
aff_code_custom,
|
||||||
|
aff_rebate_rate_percent,
|
||||||
inviter_id,
|
inviter_id,
|
||||||
aff_count,
|
aff_count,
|
||||||
aff_quota::double precision,
|
aff_quota::double precision,
|
||||||
@@ -315,9 +317,12 @@ WHERE user_id = $1`, userID)
|
|||||||
|
|
||||||
var out service.AffiliateSummary
|
var out service.AffiliateSummary
|
||||||
var inviterID sql.NullInt64
|
var inviterID sql.NullInt64
|
||||||
|
var rebateRate sql.NullFloat64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&out.UserID,
|
&out.UserID,
|
||||||
&out.AffCode,
|
&out.AffCode,
|
||||||
|
&out.AffCodeCustom,
|
||||||
|
&rebateRate,
|
||||||
&inviterID,
|
&inviterID,
|
||||||
&out.AffCount,
|
&out.AffCount,
|
||||||
&out.AffQuota,
|
&out.AffQuota,
|
||||||
@@ -330,6 +335,10 @@ WHERE user_id = $1`, userID)
|
|||||||
if inviterID.Valid {
|
if inviterID.Valid {
|
||||||
out.InviterID = &inviterID.Int64
|
out.InviterID = &inviterID.Int64
|
||||||
}
|
}
|
||||||
|
if rebateRate.Valid {
|
||||||
|
v := rebateRate.Float64
|
||||||
|
out.AffRebateRatePercent = &v
|
||||||
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +346,8 @@ func queryAffiliateByCode(ctx context.Context, client affiliateQueryExecer, code
|
|||||||
rows, err := client.QueryContext(ctx, `
|
rows, err := client.QueryContext(ctx, `
|
||||||
SELECT user_id,
|
SELECT user_id,
|
||||||
aff_code,
|
aff_code,
|
||||||
|
aff_code_custom,
|
||||||
|
aff_rebate_rate_percent,
|
||||||
inviter_id,
|
inviter_id,
|
||||||
aff_count,
|
aff_count,
|
||||||
aff_quota::double precision,
|
aff_quota::double precision,
|
||||||
@@ -360,9 +371,12 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
|
|||||||
|
|
||||||
var out service.AffiliateSummary
|
var out service.AffiliateSummary
|
||||||
var inviterID sql.NullInt64
|
var inviterID sql.NullInt64
|
||||||
|
var rebateRate sql.NullFloat64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&out.UserID,
|
&out.UserID,
|
||||||
&out.AffCode,
|
&out.AffCode,
|
||||||
|
&out.AffCodeCustom,
|
||||||
|
&rebateRate,
|
||||||
&inviterID,
|
&inviterID,
|
||||||
&out.AffCount,
|
&out.AffCount,
|
||||||
&out.AffQuota,
|
&out.AffQuota,
|
||||||
@@ -375,6 +389,10 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
|
|||||||
if inviterID.Valid {
|
if inviterID.Valid {
|
||||||
out.InviterID = &inviterID.Int64
|
out.InviterID = &inviterID.Int64
|
||||||
}
|
}
|
||||||
|
if rebateRate.Valid {
|
||||||
|
v := rebateRate.Float64
|
||||||
|
out.AffRebateRatePercent = &v
|
||||||
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,3 +436,229 @@ func isAffiliateUniqueViolation(err error) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateUserAffCode 改写用户的邀请码(自定义专属邀请码)。
|
||||||
|
// 唯一性冲突返回 ErrAffiliateCodeTaken。
|
||||||
|
func (r *affiliateRepository) UpdateUserAffCode(ctx context.Context, userID int64, newCode string) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(newCode))
|
||||||
|
if code == "" {
|
||||||
|
return service.ErrAffiliateCodeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
|
||||||
|
if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := txClient.ExecContext(txCtx, `
|
||||||
|
UPDATE user_affiliates
|
||||||
|
SET aff_code = $1,
|
||||||
|
aff_code_custom = true,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $2`, code, userID)
|
||||||
|
if err != nil {
|
||||||
|
if isAffiliateUniqueViolation(err) {
|
||||||
|
return service.ErrAffiliateCodeTaken
|
||||||
|
}
|
||||||
|
return fmt.Errorf("update aff_code: %w", err)
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetUserAffCode 把 aff_code 还原为系统随机码,并清除 aff_code_custom 标记。
|
||||||
|
func (r *affiliateRepository) ResetUserAffCode(ctx context.Context, userID int64) (string, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return "", service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
var newCode string
|
||||||
|
err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
|
||||||
|
if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := 0; i < affiliateCodeMaxAttempts; i++ {
|
||||||
|
candidate, codeErr := generateAffiliateCode()
|
||||||
|
if codeErr != nil {
|
||||||
|
return codeErr
|
||||||
|
}
|
||||||
|
res, err := txClient.ExecContext(txCtx, `
|
||||||
|
UPDATE user_affiliates
|
||||||
|
SET aff_code = $1,
|
||||||
|
aff_code_custom = false,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $2`, candidate, userID)
|
||||||
|
if err != nil {
|
||||||
|
if isAffiliateUniqueViolation(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("reset aff_code: %w", err)
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
newCode = candidate
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("reset aff_code: exhausted attempts")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return newCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserRebateRate 设置或清除用户专属返利比例。ratePercent==nil 表示清除(沿用全局)。
|
||||||
|
func (r *affiliateRepository) SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
|
||||||
|
if _, err := ensureUserAffiliateWithClient(txCtx, txClient, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// nullableArg lets us use a single UPDATE for both "set value" and
|
||||||
|
// "clear" cases — database/sql converts nil interface{} to SQL NULL.
|
||||||
|
res, err := txClient.ExecContext(txCtx, `
|
||||||
|
UPDATE user_affiliates
|
||||||
|
SET aff_rebate_rate_percent = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $2`, nullableArg(ratePercent), userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set aff_rebate_rate_percent: %w", err)
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchSetUserRebateRate 批量为多个用户设置专属比例(nil 清除)。
|
||||||
|
func (r *affiliateRepository) BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if uid <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := ensureUserAffiliateWithClient(txCtx, txClient, uid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := txClient.ExecContext(txCtx, `
|
||||||
|
UPDATE user_affiliates
|
||||||
|
SET aff_rebate_rate_percent = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = ANY($2)`, nullableArg(ratePercent), pq.Array(userIDs))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("batch set aff_rebate_rate_percent: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableArg unwraps a *float64 into an interface{} suitable for SQL parameter
|
||||||
|
// binding: nil pointer → SQL NULL, non-nil → the float value.
|
||||||
|
func nullableArg(v *float64) any {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。
|
||||||
|
//
|
||||||
|
// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索":
|
||||||
|
// 空 search 时拼接出的 LIKE 模式为 "%%",匹配所有行;非空时按 ILIKE 子串匹配。
|
||||||
|
// 这避免了为两种情况维护两份 SQL 模板。
|
||||||
|
func (r *affiliateRepository) ListUsersWithCustomSettings(ctx context.Context, filter service.AffiliateAdminFilter) ([]service.AffiliateAdminEntry, int64, error) {
|
||||||
|
page := filter.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize := filter.PageSize
|
||||||
|
if pageSize <= 0 || pageSize > 200 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
likePattern := "%" + strings.TrimSpace(filter.Search) + "%"
|
||||||
|
|
||||||
|
const baseFrom = `
|
||||||
|
FROM user_affiliates ua
|
||||||
|
JOIN users u ON u.id = ua.user_id
|
||||||
|
WHERE (ua.aff_code_custom = true OR ua.aff_rebate_rate_percent IS NOT NULL)
|
||||||
|
AND (u.email ILIKE $1 OR u.username ILIKE $1)`
|
||||||
|
|
||||||
|
client := clientFromContext(ctx, r.client)
|
||||||
|
|
||||||
|
total, err := scanInt64(ctx, client, "SELECT COUNT(*)"+baseFrom, likePattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count affiliate admin entries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listQuery := `
|
||||||
|
SELECT ua.user_id,
|
||||||
|
COALESCE(u.email, ''),
|
||||||
|
COALESCE(u.username, ''),
|
||||||
|
ua.aff_code,
|
||||||
|
ua.aff_code_custom,
|
||||||
|
ua.aff_rebate_rate_percent,
|
||||||
|
ua.aff_count` + baseFrom + `
|
||||||
|
ORDER BY ua.updated_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
|
||||||
|
rows, err := client.QueryContext(ctx, listQuery, likePattern, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list affiliate admin entries: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
entries := make([]service.AffiliateAdminEntry, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var e service.AffiliateAdminEntry
|
||||||
|
var rebate sql.NullFloat64
|
||||||
|
if err := rows.Scan(&e.UserID, &e.Email, &e.Username, &e.AffCode,
|
||||||
|
&e.AffCodeCustom, &rebate, &e.AffCount); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if rebate.Valid {
|
||||||
|
v := rebate.Float64
|
||||||
|
e.AffRebateRatePercent = &v
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return entries, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanInt64 runs a query expected to return a single int64 column (e.g. COUNT).
|
||||||
|
func scanInt64(ctx context.Context, client affiliateQueryExecer, query string, args ...any) (int64, error) {
|
||||||
|
rows, err := client.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
if !rows.Next() {
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var v int64
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -182,3 +182,218 @@ VALUES ($1, $2, 0, 0, NOW(), NOW())`, u.ID, affCode)
|
|||||||
"SELECT balance::double precision FROM users WHERE id = $1", u.ID)
|
"SELECT balance::double precision FROM users WHERE id = $1", u.ID)
|
||||||
require.InDelta(t, 3.21, persistedBalance, 1e-9)
|
require.InDelta(t, 3.21, persistedBalance, 1e-9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAffiliateRepository_AdminCustomCode covers the success path of admin
|
||||||
|
// invite-code rewrite + reset within a shared test transaction:
|
||||||
|
// - UpdateUserAffCode replaces aff_code, sets aff_code_custom=true, lookup works
|
||||||
|
// - the old code can no longer be found
|
||||||
|
// - ResetUserAffCode reverts aff_code_custom and assigns a new system-format code
|
||||||
|
//
|
||||||
|
// The conflict path (duplicate code → ErrAffiliateCodeTaken) lives in its own
|
||||||
|
// test because a unique-violation aborts the surrounding Postgres tx, which
|
||||||
|
// would poison subsequent assertions in the same transaction.
|
||||||
|
func TestAffiliateRepository_AdminCustomCode(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx := testEntTx(t)
|
||||||
|
txCtx := dbent.NewTxContext(ctx, tx)
|
||||||
|
client := tx.Client()
|
||||||
|
|
||||||
|
repo := NewAffiliateRepository(client, integrationDB)
|
||||||
|
|
||||||
|
u := mustCreateUser(t, client, &service.User{
|
||||||
|
Email: fmt.Sprintf("affiliate-custom-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
|
||||||
|
original, err := repo.EnsureUserAffiliate(txCtx, u.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, original.AffCodeCustom, "system-generated codes start as non-custom")
|
||||||
|
originalCode := original.AffCode
|
||||||
|
|
||||||
|
// Rewrite to a custom code
|
||||||
|
customCode := fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)
|
||||||
|
require.NoError(t, repo.UpdateUserAffCode(txCtx, u.ID, customCode))
|
||||||
|
|
||||||
|
updated, err := repo.EnsureUserAffiliate(txCtx, u.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, customCode, updated.AffCode)
|
||||||
|
require.True(t, updated.AffCodeCustom)
|
||||||
|
|
||||||
|
// Lookup by new custom code finds the user
|
||||||
|
byCode, err := repo.GetAffiliateByCode(txCtx, customCode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, u.ID, byCode.UserID)
|
||||||
|
|
||||||
|
// Old system code should no longer match
|
||||||
|
_, err = repo.GetAffiliateByCode(txCtx, originalCode)
|
||||||
|
require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
|
||||||
|
|
||||||
|
// Reset back to a fresh system code, clears custom flag
|
||||||
|
newSysCode, err := repo.ResetUserAffCode(txCtx, u.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, customCode, newSysCode)
|
||||||
|
|
||||||
|
reset, err := repo.EnsureUserAffiliate(txCtx, u.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, newSysCode, reset.AffCode)
|
||||||
|
require.False(t, reset.AffCodeCustom)
|
||||||
|
|
||||||
|
// The old custom code is now free again
|
||||||
|
_, err = repo.GetAffiliateByCode(txCtx, customCode)
|
||||||
|
require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAffiliateRepository_AdminCustomCode_Conflict isolates the unique-violation
|
||||||
|
// path. PostgreSQL aborts the enclosing tx when a unique constraint fires, so
|
||||||
|
// this test must be the only assertion and run in its own tx — production
|
||||||
|
// callers each have their own outer tx, so this matches real behavior.
|
||||||
|
func TestAffiliateRepository_AdminCustomCode_Conflict(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx := testEntTx(t)
|
||||||
|
txCtx := dbent.NewTxContext(ctx, tx)
|
||||||
|
client := tx.Client()
|
||||||
|
|
||||||
|
repo := NewAffiliateRepository(client, integrationDB)
|
||||||
|
|
||||||
|
taker := mustCreateUser(t, client, &service.User{
|
||||||
|
Email: fmt.Sprintf("affiliate-conflict-taker-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser, Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
requester := mustCreateUser(t, client, &service.User{
|
||||||
|
Email: fmt.Sprintf("affiliate-conflict-req-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser, Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
|
||||||
|
takenCode := fmt.Sprintf("HOT%09d", time.Now().UnixNano()%1_000_000_000)
|
||||||
|
require.NoError(t, repo.UpdateUserAffCode(txCtx, taker.ID, takenCode))
|
||||||
|
|
||||||
|
// Now requester tries to grab the same code → conflict.
|
||||||
|
err := repo.UpdateUserAffCode(txCtx, requester.ID, takenCode)
|
||||||
|
require.ErrorIs(t, err, service.ErrAffiliateCodeTaken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAffiliateRepository_AdminRebateRate covers per-user exclusive rate
|
||||||
|
// set/clear and the Batch variant including NULL semantics.
|
||||||
|
func TestAffiliateRepository_AdminRebateRate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx := testEntTx(t)
|
||||||
|
txCtx := dbent.NewTxContext(ctx, tx)
|
||||||
|
client := tx.Client()
|
||||||
|
|
||||||
|
repo := NewAffiliateRepository(client, integrationDB)
|
||||||
|
|
||||||
|
u1 := mustCreateUser(t, client, &service.User{
|
||||||
|
Email: fmt.Sprintf("affiliate-rate-%d-a@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
u2 := mustCreateUser(t, client, &service.User{
|
||||||
|
Email: fmt.Sprintf("affiliate-rate-%d-b@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set exclusive rate for u1
|
||||||
|
rate := 42.5
|
||||||
|
require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, &rate))
|
||||||
|
|
||||||
|
got, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.AffRebateRatePercent)
|
||||||
|
require.InDelta(t, 42.5, *got.AffRebateRatePercent, 1e-9)
|
||||||
|
|
||||||
|
// Clear exclusive rate
|
||||||
|
require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, nil))
|
||||||
|
cleared, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, cleared.AffRebateRatePercent)
|
||||||
|
|
||||||
|
// Batch set both users
|
||||||
|
batchRate := 15.0
|
||||||
|
require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, &batchRate))
|
||||||
|
|
||||||
|
for _, uid := range []int64{u1.ID, u2.ID} {
|
||||||
|
v, err := repo.EnsureUserAffiliate(txCtx, uid)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, v.AffRebateRatePercent)
|
||||||
|
require.InDelta(t, 15.0, *v.AffRebateRatePercent, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch clear
|
||||||
|
require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, nil))
|
||||||
|
for _, uid := range []int64{u1.ID, u2.ID} {
|
||||||
|
v, err := repo.EnsureUserAffiliate(txCtx, uid)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, v.AffRebateRatePercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAffiliateRepository_ListUsersWithCustomSettings verifies the admin list
|
||||||
|
// only includes users with at least one override applied.
|
||||||
|
func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx := testEntTx(t)
|
||||||
|
txCtx := dbent.NewTxContext(ctx, tx)
|
||||||
|
client := tx.Client()
|
||||||
|
|
||||||
|
repo := NewAffiliateRepository(client, integrationDB)
|
||||||
|
|
||||||
|
// User without any custom config — should NOT appear in the list.
|
||||||
|
plainEmail := fmt.Sprintf("affiliate-plain-%d@example.com", time.Now().UnixNano())
|
||||||
|
uPlain := mustCreateUser(t, client, &service.User{
|
||||||
|
Email: plainEmail, PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser, Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
_, err := repo.EnsureUserAffiliate(txCtx, uPlain.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// User with a custom code — should appear.
|
||||||
|
uCode := mustCreateUser(t, client, &service.User{
|
||||||
|
Email: fmt.Sprintf("affiliate-codeonly-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser, Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
require.NoError(t, repo.UpdateUserAffCode(txCtx, uCode.ID, fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)))
|
||||||
|
|
||||||
|
// User with only an exclusive rate — should appear.
|
||||||
|
uRate := mustCreateUser(t, client, &service.User{
|
||||||
|
Email: fmt.Sprintf("affiliate-rateonly-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser, Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
r := 33.3
|
||||||
|
require.NoError(t, repo.SetUserRebateRate(txCtx, uRate.ID, &r))
|
||||||
|
|
||||||
|
entries, total, err := repo.ListUsersWithCustomSettings(txCtx, service.AffiliateAdminFilter{
|
||||||
|
Page: 1, PageSize: 100,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Build a quick lookup to assert per-user attributes (other tests may have
|
||||||
|
// inserted custom rows in the same DB; we only care about our 3).
|
||||||
|
byUserID := make(map[int64]service.AffiliateAdminEntry, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
byUserID[e.UserID] = e
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotContains(t, byUserID, uPlain.ID, "users without overrides must not appear")
|
||||||
|
|
||||||
|
codeEntry, ok := byUserID[uCode.ID]
|
||||||
|
require.True(t, ok, "custom-code user missing from list")
|
||||||
|
require.True(t, codeEntry.AffCodeCustom)
|
||||||
|
require.Nil(t, codeEntry.AffRebateRatePercent)
|
||||||
|
|
||||||
|
rateEntry, ok := byUserID[uRate.ID]
|
||||||
|
require.True(t, ok, "custom-rate user missing from list")
|
||||||
|
require.False(t, rateEntry.AffCodeCustom)
|
||||||
|
require.NotNil(t, rateEntry.AffRebateRatePercent)
|
||||||
|
require.InDelta(t, 33.3, *rateEntry.AffRebateRatePercent, 1e-9)
|
||||||
|
|
||||||
|
require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows")
|
||||||
|
}
|
||||||
|
|||||||
@@ -775,6 +775,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"channel_monitor_enabled": true,
|
"channel_monitor_enabled": true,
|
||||||
"channel_monitor_default_interval_seconds": 60,
|
"channel_monitor_default_interval_seconds": 60,
|
||||||
"available_channels_enabled": false,
|
"available_channels_enabled": false,
|
||||||
|
"affiliate_enabled": false,
|
||||||
"wechat_connect_enabled": false,
|
"wechat_connect_enabled": false,
|
||||||
"wechat_connect_app_id": "",
|
"wechat_connect_app_id": "",
|
||||||
"wechat_connect_app_secret_configured": false,
|
"wechat_connect_app_secret_configured": false,
|
||||||
@@ -951,6 +952,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"channel_monitor_enabled": true,
|
"channel_monitor_enabled": true,
|
||||||
"channel_monitor_default_interval_seconds": 60,
|
"channel_monitor_default_interval_seconds": 60,
|
||||||
"available_channels_enabled": false,
|
"available_channels_enabled": false,
|
||||||
|
"affiliate_enabled": false,
|
||||||
"wechat_connect_enabled": true,
|
"wechat_connect_enabled": true,
|
||||||
"wechat_connect_app_id": "wx-open-config",
|
"wechat_connect_app_id": "wx-open-config",
|
||||||
"wechat_connect_app_secret_configured": true,
|
"wechat_connect_app_secret_configured": true,
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ func RegisterAdminRoutes(
|
|||||||
|
|
||||||
// 渠道监控
|
// 渠道监控
|
||||||
registerChannelMonitorRoutes(admin, h)
|
registerChannelMonitorRoutes(admin, h)
|
||||||
|
|
||||||
|
// 邀请返利(专属用户管理)
|
||||||
|
registerAffiliateRoutes(admin, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,3 +597,18 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply)
|
templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerAffiliateRoutes 注册邀请返利的管理端路由(专属用户配置)
|
||||||
|
func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||||
|
affiliates := admin.Group("/affiliates")
|
||||||
|
{
|
||||||
|
users := affiliates.Group("/users")
|
||||||
|
{
|
||||||
|
users.GET("", h.Admin.Affiliate.ListUsers)
|
||||||
|
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
|
||||||
|
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
|
||||||
|
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
|
||||||
|
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,28 +14,39 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found")
|
ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found")
|
||||||
ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code")
|
ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code")
|
||||||
|
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
|
||||||
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
|
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
|
||||||
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
|
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
affiliateInviteesLimit = 100
|
affiliateInviteesLimit = 100
|
||||||
// affiliateCodeFormatLength must stay in sync with repository.affiliateCodeLength.
|
// AffiliateCodeMinLength / AffiliateCodeMaxLength bound both system-generated
|
||||||
affiliateCodeFormatLength = 12
|
// 12-char codes and admin-customized codes (e.g. "VIP2026").
|
||||||
|
AffiliateCodeMinLength = 4
|
||||||
|
AffiliateCodeMaxLength = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
// affiliateCodeValidChar is a 256-entry lookup table mirroring the charset used
|
// affiliateCodeValidChar accepts uppercase letters, digits, underscore and dash.
|
||||||
// by the repository's generateAffiliateCode (A-Z minus I/O, digits 2-9).
|
// All input passes through strings.ToUpper before validation, so lowercase from
|
||||||
|
// users is normalized — admins may supply mixed case in their UI.
|
||||||
var affiliateCodeValidChar = func() [256]bool {
|
var affiliateCodeValidChar = func() [256]bool {
|
||||||
var tbl [256]bool
|
var tbl [256]bool
|
||||||
for _, c := range []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") {
|
for c := byte('A'); c <= 'Z'; c++ {
|
||||||
tbl[c] = true
|
tbl[c] = true
|
||||||
}
|
}
|
||||||
|
for c := byte('0'); c <= '9'; c++ {
|
||||||
|
tbl[c] = true
|
||||||
|
}
|
||||||
|
tbl['_'] = true
|
||||||
|
tbl['-'] = true
|
||||||
return tbl
|
return tbl
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// isValidAffiliateCodeFormat validates code format for both binding (user input)
|
||||||
|
// and admin updates. Caller is expected to upper-case the input first.
|
||||||
func isValidAffiliateCodeFormat(code string) bool {
|
func isValidAffiliateCodeFormat(code string) bool {
|
||||||
if len(code) != affiliateCodeFormatLength {
|
if len(code) < AffiliateCodeMinLength || len(code) > AffiliateCodeMaxLength {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for i := 0; i < len(code); i++ {
|
for i := 0; i < len(code); i++ {
|
||||||
@@ -48,14 +58,16 @@ func isValidAffiliateCodeFormat(code string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AffiliateSummary struct {
|
type AffiliateSummary struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
AffCode string `json:"aff_code"`
|
AffCode string `json:"aff_code"`
|
||||||
InviterID *int64 `json:"inviter_id,omitempty"`
|
AffCodeCustom bool `json:"aff_code_custom"`
|
||||||
AffCount int `json:"aff_count"`
|
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
|
||||||
AffQuota float64 `json:"aff_quota"`
|
InviterID *int64 `json:"inviter_id,omitempty"`
|
||||||
AffHistoryQuota float64 `json:"aff_history_quota"`
|
AffCount int `json:"aff_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
AffQuota float64 `json:"aff_quota"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
AffHistoryQuota float64 `json:"aff_history_quota"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AffiliateInvitee struct {
|
type AffiliateInvitee struct {
|
||||||
@@ -72,7 +84,11 @@ type AffiliateDetail struct {
|
|||||||
AffCount int `json:"aff_count"`
|
AffCount int `json:"aff_count"`
|
||||||
AffQuota float64 `json:"aff_quota"`
|
AffQuota float64 `json:"aff_quota"`
|
||||||
AffHistoryQuota float64 `json:"aff_history_quota"`
|
AffHistoryQuota float64 `json:"aff_history_quota"`
|
||||||
Invitees []AffiliateInvitee `json:"invitees"`
|
// EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例:
|
||||||
|
// 优先用户自己的专属比例(aff_rebate_rate_percent),否则回退到全局比例。
|
||||||
|
// 用于在用户的 /affiliate 页面直观展示「分享后能拿到多少」。
|
||||||
|
EffectiveRebateRatePercent float64 `json:"effective_rebate_rate_percent"`
|
||||||
|
Invitees []AffiliateInvitee `json:"invitees"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AffiliateRepository interface {
|
type AffiliateRepository interface {
|
||||||
@@ -82,24 +98,57 @@ type AffiliateRepository interface {
|
|||||||
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error)
|
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error)
|
||||||
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
|
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
|
||||||
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
|
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
|
||||||
|
|
||||||
|
// 管理端:用户级专属配置
|
||||||
|
UpdateUserAffCode(ctx context.Context, userID int64, newCode string) error
|
||||||
|
ResetUserAffCode(ctx context.Context, userID int64) (string, error)
|
||||||
|
SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error
|
||||||
|
BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error
|
||||||
|
ListUsersWithCustomSettings(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AffiliateAdminFilter 列表筛选条件
|
||||||
|
type AffiliateAdminFilter struct {
|
||||||
|
Search string
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AffiliateAdminEntry 专属用户列表条目
|
||||||
|
type AffiliateAdminEntry struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
AffCode string `json:"aff_code"`
|
||||||
|
AffCodeCustom bool `json:"aff_code_custom"`
|
||||||
|
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
|
||||||
|
AffCount int `json:"aff_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AffiliateService struct {
|
type AffiliateService struct {
|
||||||
repo AffiliateRepository
|
repo AffiliateRepository
|
||||||
settingRepo SettingRepository
|
settingService *SettingService
|
||||||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||||||
billingCacheService *BillingCacheService
|
billingCacheService *BillingCacheService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAffiliateService(repo AffiliateRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService {
|
func NewAffiliateService(repo AffiliateRepository, settingService *SettingService, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService {
|
||||||
return &AffiliateService{
|
return &AffiliateService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
settingRepo: settingRepo,
|
settingService: settingService,
|
||||||
authCacheInvalidator: authCacheInvalidator,
|
authCacheInvalidator: authCacheInvalidator,
|
||||||
billingCacheService: billingCacheService,
|
billingCacheService: billingCacheService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsEnabled reports whether the affiliate (邀请返利) feature is turned on.
|
||||||
|
func (s *AffiliateService) IsEnabled(ctx context.Context) bool {
|
||||||
|
if s == nil || s.settingService == nil {
|
||||||
|
return AffiliateEnabledDefault
|
||||||
|
}
|
||||||
|
return s.settingService.IsAffiliateEnabled(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) {
|
func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) {
|
||||||
if userID <= 0 {
|
if userID <= 0 {
|
||||||
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
|
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
|
||||||
@@ -120,13 +169,14 @@ func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &AffiliateDetail{
|
return &AffiliateDetail{
|
||||||
UserID: summary.UserID,
|
UserID: summary.UserID,
|
||||||
AffCode: summary.AffCode,
|
AffCode: summary.AffCode,
|
||||||
InviterID: summary.InviterID,
|
InviterID: summary.InviterID,
|
||||||
AffCount: summary.AffCount,
|
AffCount: summary.AffCount,
|
||||||
AffQuota: summary.AffQuota,
|
AffQuota: summary.AffQuota,
|
||||||
AffHistoryQuota: summary.AffHistoryQuota,
|
AffHistoryQuota: summary.AffHistoryQuota,
|
||||||
Invitees: invitees,
|
EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary),
|
||||||
|
Invitees: invitees,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,12 +185,16 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
|
|||||||
if code == "" {
|
if code == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !isValidAffiliateCodeFormat(code) {
|
|
||||||
return ErrAffiliateCodeInvalid
|
|
||||||
}
|
|
||||||
if s == nil || s.repo == nil {
|
if s == nil || s.repo == nil {
|
||||||
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
||||||
}
|
}
|
||||||
|
// 总开关关闭时,注册阶段静默忽略 aff 参数(不报错,避免阻断注册流程)
|
||||||
|
if !s.IsEnabled(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isValidAffiliateCodeFormat(code) {
|
||||||
|
return ErrAffiliateCodeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
selfSummary, err := s.repo.EnsureUserAffiliate(ctx, userID)
|
selfSummary, err := s.repo.EnsureUserAffiliate(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,6 +232,10 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
|
|||||||
if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) {
|
if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
// 总开关关闭时,新充值不再产生返利
|
||||||
|
if !s.IsEnabled(ctx) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
|
inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,16 +245,17 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rebateRatePercent := s.loadAffiliateRebateRatePercent(ctx)
|
// 加载邀请人 profile,优先使用专属比例(覆盖全局)
|
||||||
|
inviterSummary, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary)
|
||||||
rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
|
rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
|
||||||
if rebate <= 0 {
|
if rebate <= 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate)
|
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -207,6 +266,28 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
|
|||||||
return rebate, nil
|
return rebate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveRebateRatePercent returns the inviter's exclusive rate when set,
|
||||||
|
// otherwise the global setting value (clamped to [Min, Max]).
|
||||||
|
func (s *AffiliateService) resolveRebateRatePercent(ctx context.Context, inviter *AffiliateSummary) float64 {
|
||||||
|
if inviter != nil && inviter.AffRebateRatePercent != nil {
|
||||||
|
v := *inviter.AffRebateRatePercent
|
||||||
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||||
|
return s.globalRebateRatePercent(ctx)
|
||||||
|
}
|
||||||
|
return clampAffiliateRebateRate(v)
|
||||||
|
}
|
||||||
|
return s.globalRebateRatePercent(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalRebateRatePercent reads the system-wide rebate rate via SettingService,
|
||||||
|
// returning the documented default when SettingService is unavailable.
|
||||||
|
func (s *AffiliateService) globalRebateRatePercent(ctx context.Context) float64 {
|
||||||
|
if s == nil || s.settingService == nil {
|
||||||
|
return AffiliateRebateRateDefault
|
||||||
|
}
|
||||||
|
return s.settingService.GetAffiliateRebateRatePercent(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AffiliateService) TransferAffiliateQuota(ctx context.Context, userID int64) (float64, float64, error) {
|
func (s *AffiliateService) TransferAffiliateQuota(ctx context.Context, userID int64) (float64, float64, error) {
|
||||||
if s == nil || s.repo == nil {
|
if s == nil || s.repo == nil {
|
||||||
return 0, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
return 0, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
||||||
@@ -236,32 +317,6 @@ func (s *AffiliateService) listInvitees(ctx context.Context, inviterID int64) ([
|
|||||||
return invitees, nil
|
return invitees, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AffiliateService) loadAffiliateRebateRatePercent(ctx context.Context) float64 {
|
|
||||||
if s == nil || s.settingRepo == nil {
|
|
||||||
return AffiliateRebateRateDefault
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
|
|
||||||
if err != nil {
|
|
||||||
return AffiliateRebateRateDefault
|
|
||||||
}
|
|
||||||
|
|
||||||
rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
|
||||||
if err != nil {
|
|
||||||
return AffiliateRebateRateDefault
|
|
||||||
}
|
|
||||||
if math.IsNaN(rate) || math.IsInf(rate, 0) {
|
|
||||||
return AffiliateRebateRateDefault
|
|
||||||
}
|
|
||||||
if rate < AffiliateRebateRateMin {
|
|
||||||
return AffiliateRebateRateMin
|
|
||||||
}
|
|
||||||
if rate > AffiliateRebateRateMax {
|
|
||||||
return AffiliateRebateRateMax
|
|
||||||
}
|
|
||||||
return rate
|
|
||||||
}
|
|
||||||
|
|
||||||
func roundTo(v float64, scale int) float64 {
|
func roundTo(v float64, scale int) float64 {
|
||||||
factor := math.Pow10(scale)
|
factor := math.Pow10(scale)
|
||||||
return math.Round(v*factor) / factor
|
return math.Round(v*factor) / factor
|
||||||
@@ -312,3 +367,82 @@ func (s *AffiliateService) invalidateAffiliateCaches(ctx context.Context, userID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Admin: 专属配置管理
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
// validateExclusiveRate ensures a per-user override is finite and within
|
||||||
|
// [Min, Max]. nil is always valid (means "clear / fall back to global").
|
||||||
|
func validateExclusiveRate(ratePercent *float64) error {
|
||||||
|
if ratePercent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := *ratePercent
|
||||||
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||||
|
return infraerrors.BadRequest("INVALID_RATE", "invalid rebate rate")
|
||||||
|
}
|
||||||
|
if v < AffiliateRebateRateMin || v > AffiliateRebateRateMax {
|
||||||
|
return infraerrors.BadRequest("INVALID_RATE", "rebate rate out of range")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateUserAffCode 管理员改写用户的邀请码(专属邀请码)。
|
||||||
|
func (s *AffiliateService) AdminUpdateUserAffCode(ctx context.Context, userID int64, rawCode string) error {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
||||||
|
}
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(rawCode))
|
||||||
|
if !isValidAffiliateCodeFormat(code) {
|
||||||
|
return ErrAffiliateCodeInvalid
|
||||||
|
}
|
||||||
|
return s.repo.UpdateUserAffCode(ctx, userID, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminResetUserAffCode 重置用户邀请码为系统随机码。
|
||||||
|
func (s *AffiliateService) AdminResetUserAffCode(ctx context.Context, userID int64) (string, error) {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return "", infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
||||||
|
}
|
||||||
|
return s.repo.ResetUserAffCode(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetUserRebateRate 设置/清除用户专属返利比例。ratePercent==nil 表示清除。
|
||||||
|
func (s *AffiliateService) AdminSetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
||||||
|
}
|
||||||
|
if err := validateExclusiveRate(ratePercent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.SetUserRebateRate(ctx, userID, ratePercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBatchSetUserRebateRate 批量设置/清除用户专属返利比例。
|
||||||
|
func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
||||||
|
}
|
||||||
|
if err := validateExclusiveRate(ratePercent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cleaned := make([]int64, 0, len(userIDs))
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if uid > 0 {
|
||||||
|
cleaned = append(cleaned, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListCustomUsers 列出有专属配置的用户。
|
||||||
|
func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
||||||
|
}
|
||||||
|
return s.repo.ListUsersWithCustomSettings(ctx, filter)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,51 +4,82 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type affiliateSettingRepoStub struct {
|
// TestResolveRebateRatePercent_PerUserOverride verifies that per-inviter
|
||||||
value string
|
// AffRebateRatePercent overrides the global rate, that NULL falls back to the
|
||||||
err error
|
// global rate, and that out-of-range exclusive rates are clamped silently.
|
||||||
}
|
//
|
||||||
|
// SettingService is left nil here so globalRebateRatePercent returns the
|
||||||
func (s *affiliateSettingRepoStub) Get(context.Context, string) (*Setting, error) { return nil, s.err }
|
// documented default (AffiliateRebateRateDefault = 20%) — this exercises the
|
||||||
func (s *affiliateSettingRepoStub) GetValue(context.Context, string) (string, error) {
|
// fallback path without spinning up a settings stub.
|
||||||
if s.err != nil {
|
func TestResolveRebateRatePercent_PerUserOverride(t *testing.T) {
|
||||||
return "", s.err
|
|
||||||
}
|
|
||||||
return s.value, nil
|
|
||||||
}
|
|
||||||
func (s *affiliateSettingRepoStub) Set(context.Context, string, string) error { return s.err }
|
|
||||||
func (s *affiliateSettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
|
|
||||||
if s.err != nil {
|
|
||||||
return nil, s.err
|
|
||||||
}
|
|
||||||
return map[string]string{}, nil
|
|
||||||
}
|
|
||||||
func (s *affiliateSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
|
|
||||||
return s.err
|
|
||||||
}
|
|
||||||
func (s *affiliateSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
|
||||||
if s.err != nil {
|
|
||||||
return nil, s.err
|
|
||||||
}
|
|
||||||
return map[string]string{}, nil
|
|
||||||
}
|
|
||||||
func (s *affiliateSettingRepoStub) Delete(context.Context, string) error { return s.err }
|
|
||||||
|
|
||||||
func TestAffiliateRebateRatePercentSemantics(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
svc := &AffiliateService{}
|
||||||
|
|
||||||
svc := &AffiliateService{settingRepo: &affiliateSettingRepoStub{value: "1"}}
|
// nil exclusive rate → falls back to global default (20%)
|
||||||
rate := svc.loadAffiliateRebateRatePercent(context.Background())
|
require.InDelta(t, AffiliateRebateRateDefault,
|
||||||
require.Equal(t, 1.0, rate)
|
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{}), 1e-9)
|
||||||
|
|
||||||
svc.settingRepo = &affiliateSettingRepoStub{value: "0.2"}
|
// exclusive rate set → overrides global
|
||||||
rate = svc.loadAffiliateRebateRatePercent(context.Background())
|
rate := 50.0
|
||||||
require.Equal(t, 0.2, rate)
|
require.InDelta(t, 50.0,
|
||||||
|
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &rate}), 1e-9)
|
||||||
|
|
||||||
|
// exclusive rate 0 → returns 0 (no rebate, intentional)
|
||||||
|
zero := 0.0
|
||||||
|
require.InDelta(t, 0.0,
|
||||||
|
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &zero}), 1e-9)
|
||||||
|
|
||||||
|
// exclusive rate above max → clamped to Max
|
||||||
|
tooHigh := 250.0
|
||||||
|
require.InDelta(t, AffiliateRebateRateMax,
|
||||||
|
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &tooHigh}), 1e-9)
|
||||||
|
|
||||||
|
// exclusive rate below min → clamped to Min
|
||||||
|
tooLow := -5.0
|
||||||
|
require.InDelta(t, AffiliateRebateRateMin,
|
||||||
|
svc.resolveRebateRatePercent(context.Background(), &AffiliateSummary{AffRebateRatePercent: &tooLow}), 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsEnabled_NilSettingServiceReturnsDefault verifies that IsEnabled
|
||||||
|
// safely handles a nil settingService dependency by returning the default
|
||||||
|
// (off). This protects callers from nil-pointer crashes in misconfigured
|
||||||
|
// environments.
|
||||||
|
func TestIsEnabled_NilSettingServiceReturnsDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc := &AffiliateService{}
|
||||||
|
require.False(t, svc.IsEnabled(context.Background()))
|
||||||
|
require.Equal(t, AffiliateEnabledDefault, svc.IsEnabled(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateExclusiveRate_BoundaryAndInvalid covers the validator used by
|
||||||
|
// admin-facing rate setters: nil is always valid (clear), in-range values
|
||||||
|
// are accepted, NaN/Inf and out-of-range values produce a typed BadRequest.
|
||||||
|
func TestValidateExclusiveRate_BoundaryAndInvalid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.NoError(t, validateExclusiveRate(nil))
|
||||||
|
|
||||||
|
for _, v := range []float64{0, 0.01, 50, 99.99, 100} {
|
||||||
|
v := v
|
||||||
|
require.NoError(t, validateExclusiveRate(&v), "value %v should be valid", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range []float64{-0.01, 100.01, -100, 200} {
|
||||||
|
v := v
|
||||||
|
require.Error(t, validateExclusiveRate(&v), "value %v should be rejected", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
nan := math.NaN()
|
||||||
|
require.Error(t, validateExclusiveRate(&nan))
|
||||||
|
posInf := math.Inf(1)
|
||||||
|
require.Error(t, validateExclusiveRate(&posInf))
|
||||||
|
negInf := math.Inf(-1)
|
||||||
|
require.Error(t, validateExclusiveRate(&negInf))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMaskEmail(t *testing.T) {
|
func TestMaskEmail(t *testing.T) {
|
||||||
@@ -61,24 +92,33 @@ func TestMaskEmail(t *testing.T) {
|
|||||||
func TestIsValidAffiliateCodeFormat(t *testing.T) {
|
func TestIsValidAffiliateCodeFormat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// 邀请码格式校验同时服务于:
|
||||||
|
// 1) 系统自动生成的 12 位随机码(A-Z 去 I/O,2-9 去 0/1)
|
||||||
|
// 2) 管理员设置的自定义专属码(如 "VIP2026"、"NEW_USER-1")
|
||||||
|
// 因此校验放宽到 [A-Z0-9_-]{4,32}(要求调用方先 ToUpper)。
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
in string
|
in string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"valid canonical", "ABCDEFGHJKLM", true},
|
{"valid canonical 12-char", "ABCDEFGHJKLM", true},
|
||||||
{"valid all digits 2-9", "234567892345", true},
|
{"valid all digits 2-9", "234567892345", true},
|
||||||
{"valid mixed", "A2B3C4D5E6F7", true},
|
{"valid mixed", "A2B3C4D5E6F7", true},
|
||||||
{"too short", "ABCDEFGHJKL", false},
|
{"valid admin custom short", "VIP1", true},
|
||||||
{"too long", "ABCDEFGHJKLMN", false},
|
{"valid admin custom with hyphen", "NEW-USER", true},
|
||||||
{"contains excluded letter I", "IBCDEFGHJKLM", false},
|
{"valid admin custom with underscore", "VIP_2026", true},
|
||||||
{"contains excluded letter O", "OBCDEFGHJKLM", false},
|
{"valid 32-char max", "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", true},
|
||||||
{"contains excluded digit 0", "0BCDEFGHJKLM", false},
|
// Previously-excluded chars (I/O/0/1) are now allowed since admins may use them.
|
||||||
{"contains excluded digit 1", "1BCDEFGHJKLM", false},
|
{"letter I now allowed", "IBCDEFGHJKLM", true},
|
||||||
|
{"letter O now allowed", "OBCDEFGHJKLM", true},
|
||||||
|
{"digit 0 now allowed", "0BCDEFGHJKLM", true},
|
||||||
|
{"digit 1 now allowed", "1BCDEFGHJKLM", true},
|
||||||
|
{"too short (3 chars)", "ABC", false},
|
||||||
|
{"too long (33 chars)", "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456", false},
|
||||||
{"lowercase rejected (caller must ToUpper first)", "abcdefghjklm", false},
|
{"lowercase rejected (caller must ToUpper first)", "abcdefghjklm", false},
|
||||||
{"empty", "", false},
|
{"empty", "", false},
|
||||||
{"12-byte utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // 6×2 bytes = 12 bytes, bytes out of charset
|
{"utf8 non-ascii", "ÄÄÄÄÄÄ", false}, // bytes out of charset
|
||||||
{"ascii punctuation", "ABCDEFGHJK.M", false},
|
{"ascii punctuation .", "ABCDEFGHJK.M", false},
|
||||||
{"whitespace", "ABCDEFGHJK M", false},
|
{"whitespace", "ABCDEFGHJK M", false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
AffiliateRebateRateDefault = 20.0
|
AffiliateRebateRateDefault = 20.0
|
||||||
AffiliateRebateRateMin = 0.0
|
AffiliateRebateRateMin = 0.0
|
||||||
AffiliateRebateRateMax = 100.0
|
AffiliateRebateRateMax = 100.0
|
||||||
|
AffiliateEnabledDefault = false // 邀请返利总开关默认关闭
|
||||||
)
|
)
|
||||||
|
|
||||||
// Platform constants
|
// Platform constants
|
||||||
@@ -94,6 +95,7 @@ const (
|
|||||||
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
||||||
SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
|
SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
|
||||||
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
||||||
|
SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关
|
||||||
SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100)
|
SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100)
|
||||||
|
|
||||||
// 邮件服务设置
|
// 邮件服务设置
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyChannelMonitorEnabled,
|
SettingKeyChannelMonitorEnabled,
|
||||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||||
SettingKeyAvailableChannelsEnabled,
|
SettingKeyAvailableChannelsEnabled,
|
||||||
|
SettingKeyAffiliateEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||||
@@ -541,6 +542,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||||||
|
|
||||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||||
|
|
||||||
|
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +690,7 @@ type PublicSettingsInjectionPayload struct {
|
|||||||
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||||
|
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
||||||
@@ -739,6 +743,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
|
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
|
||||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||||
|
AffiliateEnabled: settings.AffiliateEnabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,6 +1210,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
|||||||
// Available channels feature switch
|
// Available channels feature switch
|
||||||
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
||||||
|
|
||||||
|
// Affiliate (邀请返利) feature switch
|
||||||
|
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
||||||
|
|
||||||
// Claude Code version check
|
// Claude Code version check
|
||||||
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
||||||
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
||||||
@@ -1480,6 +1488,30 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
|||||||
return value == "true"
|
return value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
|
||||||
|
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
|
||||||
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return false // 默认关闭
|
||||||
|
}
|
||||||
|
return value == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。
|
||||||
|
// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错,
|
||||||
|
// 调用方只关心一个可用的数值。
|
||||||
|
func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 {
|
||||||
|
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
|
||||||
|
if err != nil {
|
||||||
|
return AffiliateRebateRateDefault
|
||||||
|
}
|
||||||
|
rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||||
|
if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) {
|
||||||
|
return AffiliateRebateRateDefault
|
||||||
|
}
|
||||||
|
return clampAffiliateRebateRate(rate)
|
||||||
|
}
|
||||||
|
|
||||||
// IsPasswordResetEnabled 检查是否启用密码重置功能
|
// IsPasswordResetEnabled 检查是否启用密码重置功能
|
||||||
// 要求:必须同时开启邮件验证
|
// 要求:必须同时开启邮件验证
|
||||||
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
|
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
|
||||||
@@ -1771,6 +1803,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
// Available channels feature (default disabled; opt-in)
|
// Available channels feature (default disabled; opt-in)
|
||||||
SettingKeyAvailableChannelsEnabled: "false",
|
SettingKeyAvailableChannelsEnabled: "false",
|
||||||
|
|
||||||
|
// Affiliate (邀请返利) feature (default disabled; opt-in)
|
||||||
|
SettingKeyAffiliateEnabled: "false",
|
||||||
|
|
||||||
// Claude Code version check (default: empty = disabled)
|
// Claude Code version check (default: empty = disabled)
|
||||||
SettingKeyMinClaudeCodeVersion: "",
|
SettingKeyMinClaudeCodeVersion: "",
|
||||||
SettingKeyMaxClaudeCodeVersion: "",
|
SettingKeyMaxClaudeCodeVersion: "",
|
||||||
@@ -2091,6 +2126,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
// Available channels feature (default: disabled; strict true)
|
// Available channels feature (default: disabled; strict true)
|
||||||
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
|
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
|
||||||
|
|
||||||
|
// Affiliate (邀请返利) feature (default: disabled; strict true)
|
||||||
|
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
|
||||||
|
|
||||||
// Claude Code version check
|
// Claude Code version check
|
||||||
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
||||||
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
|
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ type SystemSettings struct {
|
|||||||
|
|
||||||
DefaultConcurrency int
|
DefaultConcurrency int
|
||||||
DefaultBalance float64
|
DefaultBalance float64
|
||||||
|
AffiliateEnabled bool
|
||||||
AffiliateRebateRate float64
|
AffiliateRebateRate float64
|
||||||
DefaultUserRPMLimit int
|
DefaultUserRPMLimit int
|
||||||
DefaultSubscriptions []DefaultSubscriptionSetting
|
DefaultSubscriptions []DefaultSubscriptionSetting
|
||||||
@@ -225,6 +226,9 @@ type PublicSettings struct {
|
|||||||
|
|
||||||
// Available Channels feature (user-facing aggregate view)
|
// Available Channels feature (user-facing aggregate view)
|
||||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||||
|
|
||||||
|
// Affiliate (邀请返利) feature toggle
|
||||||
|
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeChatConnectOAuthConfig struct {
|
type WeChatConnectOAuthConfig struct {
|
||||||
|
|||||||
16
backend/migrations/132_affiliate_custom_settings.sql
Normal file
16
backend/migrations/132_affiliate_custom_settings.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- 邀请返利:用户专属配置增强
|
||||||
|
-- 1) aff_rebate_rate_percent: 用户作为邀请人时的专属返利比例(百分比,NULL 表示沿用全局比例)
|
||||||
|
-- 2) aff_code_custom: 标记当前 aff_code 是否被管理员手动改写过(用于"专属用户"列表筛选)
|
||||||
|
|
||||||
|
ALTER TABLE user_affiliates
|
||||||
|
ADD COLUMN IF NOT EXISTS aff_rebate_rate_percent DECIMAL(5,2);
|
||||||
|
|
||||||
|
ALTER TABLE user_affiliates
|
||||||
|
ADD COLUMN IF NOT EXISTS aff_code_custom BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_affiliates_admin_settings
|
||||||
|
ON user_affiliates (updated_at)
|
||||||
|
WHERE aff_code_custom = true OR aff_rebate_rate_percent IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN user_affiliates.aff_rebate_rate_percent IS '专属返利比例(百分比 0-100,NULL 表示沿用全局)';
|
||||||
|
COMMENT ON COLUMN user_affiliates.aff_code_custom IS '邀请码是否由管理员改写过(用于专属用户筛选)';
|
||||||
108
frontend/src/api/admin/affiliates.ts
Normal file
108
frontend/src/api/admin/affiliates.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Admin Affiliate API endpoints
|
||||||
|
* Manage per-user affiliate (邀请返利) configurations:
|
||||||
|
* exclusive invite codes (overrides aff_code) and exclusive rebate rates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from '../client'
|
||||||
|
import type { PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export interface AffiliateAdminEntry {
|
||||||
|
user_id: number
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
aff_code: string
|
||||||
|
aff_code_custom: boolean
|
||||||
|
aff_rebate_rate_percent?: number | null
|
||||||
|
aff_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAffiliateUsersParams {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAffiliateUserRequest {
|
||||||
|
aff_code?: string
|
||||||
|
aff_rebate_rate_percent?: number | null
|
||||||
|
/** Set true to explicitly clear the per-user rate (sets it to NULL). */
|
||||||
|
clear_rebate_rate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchSetRateRequest {
|
||||||
|
user_ids: number[]
|
||||||
|
aff_rebate_rate_percent?: number | null
|
||||||
|
/** Set true to clear rates instead of setting. */
|
||||||
|
clear?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleUser {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUsers(
|
||||||
|
params: ListAffiliateUsersParams = {},
|
||||||
|
): Promise<PaginatedResponse<AffiliateAdminEntry>> {
|
||||||
|
const { data } = await apiClient.get<PaginatedResponse<AffiliateAdminEntry>>(
|
||||||
|
'/admin/affiliates/users',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
page: params.page ?? 1,
|
||||||
|
page_size: params.page_size ?? 20,
|
||||||
|
search: params.search ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupUsers(q: string): Promise<SimpleUser[]> {
|
||||||
|
const { data } = await apiClient.get<SimpleUser[]>(
|
||||||
|
'/admin/affiliates/users/lookup',
|
||||||
|
{ params: { q } },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserSettings(
|
||||||
|
userId: number,
|
||||||
|
payload: UpdateAffiliateUserRequest,
|
||||||
|
): Promise<{ user_id: number }> {
|
||||||
|
const { data } = await apiClient.put<{ user_id: number }>(
|
||||||
|
`/admin/affiliates/users/${userId}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearUserSettings(
|
||||||
|
userId: number,
|
||||||
|
): Promise<{ user_id: number }> {
|
||||||
|
const { data } = await apiClient.delete<{ user_id: number }>(
|
||||||
|
`/admin/affiliates/users/${userId}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchSetRate(
|
||||||
|
payload: BatchSetRateRequest,
|
||||||
|
): Promise<{ affected: number }> {
|
||||||
|
const { data } = await apiClient.post<{ affected: number }>(
|
||||||
|
'/admin/affiliates/users/batch-rate',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const affiliatesAPI = {
|
||||||
|
listUsers,
|
||||||
|
lookupUsers,
|
||||||
|
updateUserSettings,
|
||||||
|
clearUserSettings,
|
||||||
|
batchSetRate,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default affiliatesAPI
|
||||||
@@ -29,6 +29,7 @@ import channelsAPI from './channels'
|
|||||||
import channelMonitorAPI from './channelMonitor'
|
import channelMonitorAPI from './channelMonitor'
|
||||||
import channelMonitorTemplateAPI from './channelMonitorTemplate'
|
import channelMonitorTemplateAPI from './channelMonitorTemplate'
|
||||||
import adminPaymentAPI from './payment'
|
import adminPaymentAPI from './payment'
|
||||||
|
import affiliatesAPI from './affiliates'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified admin API object for convenient access
|
* Unified admin API object for convenient access
|
||||||
@@ -59,7 +60,8 @@ export const adminAPI = {
|
|||||||
channels: channelsAPI,
|
channels: channelsAPI,
|
||||||
channelMonitor: channelMonitorAPI,
|
channelMonitor: channelMonitorAPI,
|
||||||
channelMonitorTemplate: channelMonitorTemplateAPI,
|
channelMonitorTemplate: channelMonitorTemplateAPI,
|
||||||
payment: adminPaymentAPI
|
payment: adminPaymentAPI,
|
||||||
|
affiliates: affiliatesAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -88,7 +90,8 @@ export {
|
|||||||
channelsAPI,
|
channelsAPI,
|
||||||
channelMonitorAPI,
|
channelMonitorAPI,
|
||||||
channelMonitorTemplateAPI,
|
channelMonitorTemplateAPI,
|
||||||
adminPaymentAPI
|
adminPaymentAPI,
|
||||||
|
affiliatesAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
export default adminAPI
|
export default adminAPI
|
||||||
|
|||||||
@@ -478,6 +478,9 @@ export interface SystemSettings {
|
|||||||
|
|
||||||
// Available Channels feature switch
|
// Available Channels feature switch
|
||||||
available_channels_enabled: boolean;
|
available_channels_enabled: boolean;
|
||||||
|
|
||||||
|
// Affiliate (邀请返利) feature switch
|
||||||
|
affiliate_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSettingsRequest {
|
export interface UpdateSettingsRequest {
|
||||||
@@ -636,6 +639,9 @@ export interface UpdateSettingsRequest {
|
|||||||
|
|
||||||
// Available Channels feature switch
|
// Available Channels feature switch
|
||||||
available_channels_enabled?: boolean;
|
available_channels_enabled?: boolean;
|
||||||
|
|
||||||
|
// Affiliate (邀请返利) feature switch
|
||||||
|
affiliate_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -634,6 +634,7 @@ const ChevronDownIcon = {
|
|||||||
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
|
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
|
||||||
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
||||||
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
||||||
|
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
|
||||||
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
||||||
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
|
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
|
||||||
|
|
||||||
@@ -656,7 +657,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
|
|||||||
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
||||||
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
||||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||||
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true },
|
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
|
||||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||||
path: `/custom/${item.id}`,
|
path: `/custom/${item.id}`,
|
||||||
|
|||||||
@@ -985,6 +985,8 @@ export default {
|
|||||||
loadFailed: 'Failed to load affiliate data',
|
loadFailed: 'Failed to load affiliate data',
|
||||||
transferFailed: 'Failed to transfer affiliate quota',
|
transferFailed: 'Failed to transfer affiliate quota',
|
||||||
stats: {
|
stats: {
|
||||||
|
rebateRate: 'My Rebate Rate',
|
||||||
|
rebateRateHint: 'What you earn each time an invitee recharges',
|
||||||
invitedUsers: 'Invited Users',
|
invitedUsers: 'Invited Users',
|
||||||
availableQuota: 'Available Rebate Quota',
|
availableQuota: 'Available Rebate Quota',
|
||||||
totalQuota: 'Historical Rebate Quota'
|
totalQuota: 'Historical Rebate Quota'
|
||||||
@@ -1009,7 +1011,7 @@ export default {
|
|||||||
tips: {
|
tips: {
|
||||||
title: 'How It Works',
|
title: 'How It Works',
|
||||||
line1: 'Share your affiliate code or invite link with new users.',
|
line1: 'Share your affiliate code or invite link with new users.',
|
||||||
line2: 'When invitees recharge, you receive rebate quota based on the configured rate.',
|
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
|
||||||
line3: 'Transfer rebate quota to balance at any time.'
|
line3: 'Transfer rebate quota to balance at any time.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4779,6 +4781,55 @@ export default {
|
|||||||
enabled: 'Enable Available Channels',
|
enabled: 'Enable Available Channels',
|
||||||
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
|
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
|
||||||
},
|
},
|
||||||
|
affiliate: {
|
||||||
|
title: 'Affiliate (Invite Rebate)',
|
||||||
|
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.',
|
||||||
|
enabled: 'Enable Affiliate',
|
||||||
|
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
|
||||||
|
rebateRate: 'Global Rebate Rate',
|
||||||
|
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
|
||||||
|
customUsers: {
|
||||||
|
title: 'Per-User Overrides',
|
||||||
|
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
|
||||||
|
addButton: 'Add Custom User',
|
||||||
|
searchPlaceholder: 'Search by email or username',
|
||||||
|
batchButton: 'Batch Set Rate ({count} selected)',
|
||||||
|
empty: 'No users with custom affiliate settings yet',
|
||||||
|
customBadge: 'custom',
|
||||||
|
useGlobal: 'use global',
|
||||||
|
resetTitle: 'Reset Custom Settings',
|
||||||
|
resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)',
|
||||||
|
totalLabel: '{total} total',
|
||||||
|
col: {
|
||||||
|
email: 'Email',
|
||||||
|
username: 'Username',
|
||||||
|
code: 'Invite Code',
|
||||||
|
rate: 'Custom Rate',
|
||||||
|
actions: 'Actions',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
addTitle: 'Add Custom User',
|
||||||
|
editTitle: 'Edit Custom Settings',
|
||||||
|
userLabel: 'User',
|
||||||
|
userPlaceholder: 'Search by email or username',
|
||||||
|
changeUser: 'Change user',
|
||||||
|
codeLabel: 'Custom Invite Code (optional)',
|
||||||
|
codePlaceholder: 'e.g. VIP2026',
|
||||||
|
codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.',
|
||||||
|
rateLabel: 'Exclusive Rebate Rate (optional)',
|
||||||
|
ratePlaceholder: 'e.g. 30',
|
||||||
|
rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.',
|
||||||
|
errorBadRate: 'Please enter a number between 0 and 100',
|
||||||
|
errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate',
|
||||||
|
},
|
||||||
|
batchModal: {
|
||||||
|
title: 'Batch Set Rate ({count} users selected)',
|
||||||
|
hint: 'Apply the same exclusive rebate rate to all selected users.',
|
||||||
|
placeholder: 'e.g. 30',
|
||||||
|
clearHint: 'Submitting empty will clear the exclusive rate for selected users.',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emailTabDisabledTitle: 'Email Verification Not Enabled',
|
emailTabDisabledTitle: 'Email Verification Not Enabled',
|
||||||
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
|
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
|
||||||
|
|||||||
@@ -989,6 +989,8 @@ export default {
|
|||||||
loadFailed: '加载邀请返利数据失败',
|
loadFailed: '加载邀请返利数据失败',
|
||||||
transferFailed: '转入余额失败',
|
transferFailed: '转入余额失败',
|
||||||
stats: {
|
stats: {
|
||||||
|
rebateRate: '我的返利比例',
|
||||||
|
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
|
||||||
invitedUsers: '邀请人数',
|
invitedUsers: '邀请人数',
|
||||||
availableQuota: '可转返利额度',
|
availableQuota: '可转返利额度',
|
||||||
totalQuota: '历史返利额度'
|
totalQuota: '历史返利额度'
|
||||||
@@ -1013,7 +1015,7 @@ export default {
|
|||||||
tips: {
|
tips: {
|
||||||
title: '使用说明',
|
title: '使用说明',
|
||||||
line1: '将邀请码或邀请链接分享给新用户。',
|
line1: '将邀请码或邀请链接分享给新用户。',
|
||||||
line2: '被邀请用户充值后,你可获得对应比例的返利额度。',
|
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
|
||||||
line3: '返利额度可随时转入账户余额。'
|
line3: '返利额度可随时转入账户余额。'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4942,6 +4944,55 @@ export default {
|
|||||||
enabled: '启用可用渠道',
|
enabled: '启用可用渠道',
|
||||||
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
|
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
|
||||||
},
|
},
|
||||||
|
affiliate: {
|
||||||
|
title: '邀请返利',
|
||||||
|
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
|
||||||
|
enabled: '启用邀请返利',
|
||||||
|
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
|
||||||
|
rebateRate: '全局返利比例',
|
||||||
|
rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。',
|
||||||
|
customUsers: {
|
||||||
|
title: '专属用户配置',
|
||||||
|
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
|
||||||
|
addButton: '添加专属用户',
|
||||||
|
searchPlaceholder: '搜索邮箱或用户名',
|
||||||
|
batchButton: '批量设置比例(已选 {count})',
|
||||||
|
empty: '暂无专属配置用户',
|
||||||
|
customBadge: '自定义',
|
||||||
|
useGlobal: '沿用全局',
|
||||||
|
resetTitle: '重置该用户的专属配置',
|
||||||
|
resetMessage: '确认将 {email} 的专属配置全部重置为默认?\n• 专属返利比例将清除(沿用全局)\n• 邀请码将重新生成为系统随机码(已分发的旧邀请链接将失效)',
|
||||||
|
totalLabel: '共 {total} 条',
|
||||||
|
col: {
|
||||||
|
email: '邮箱',
|
||||||
|
username: '用户名',
|
||||||
|
code: '邀请码',
|
||||||
|
rate: '专属比例',
|
||||||
|
actions: '操作',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
addTitle: '添加专属用户',
|
||||||
|
editTitle: '编辑专属配置',
|
||||||
|
userLabel: '用户',
|
||||||
|
userPlaceholder: '搜索邮箱或用户名',
|
||||||
|
changeUser: '更换用户',
|
||||||
|
codeLabel: '专属邀请码(可选)',
|
||||||
|
codePlaceholder: '例如 VIP2026',
|
||||||
|
codeHint: '4-32 位,仅支持大写字母、数字、下划线、连字符;留空表示不修改;输入将自动转大写。',
|
||||||
|
rateLabel: '专属返利比例(可选)',
|
||||||
|
ratePlaceholder: '例如 30',
|
||||||
|
rateHint: '0-100%;留空(编辑模式下)表示清除专属比例并沿用全局。',
|
||||||
|
errorBadRate: '请输入 0-100 之间的比例',
|
||||||
|
errorEmpty: '至少填写一项:专属邀请码或专属返利比例',
|
||||||
|
},
|
||||||
|
batchModal: {
|
||||||
|
title: '批量设置专属比例(已选 {count} 个用户)',
|
||||||
|
hint: '为所选用户统一设置专属返利比例。',
|
||||||
|
placeholder: '例如 30',
|
||||||
|
clearHint: '留空提交将清除所选用户的专属比例。',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emailTabDisabledTitle: '邮箱验证未启用',
|
emailTabDisabledTitle: '邮箱验证未启用',
|
||||||
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
|
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
channel_monitor_enabled: true,
|
channel_monitor_enabled: true,
|
||||||
channel_monitor_default_interval_seconds: 60,
|
channel_monitor_default_interval_seconds: 60,
|
||||||
available_channels_enabled: false,
|
available_channels_enabled: false,
|
||||||
|
affiliate_enabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export interface UserAffiliateDetail {
|
|||||||
aff_count: number
|
aff_count: number
|
||||||
aff_quota: number
|
aff_quota: number
|
||||||
aff_history_quota: number
|
aff_history_quota: number
|
||||||
|
/** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */
|
||||||
|
effective_rebate_rate_percent: number
|
||||||
invitees: AffiliateInvitee[]
|
invitees: AffiliateInvitee[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +214,7 @@ export interface PublicSettings {
|
|||||||
channel_monitor_enabled: boolean
|
channel_monitor_enabled: boolean
|
||||||
channel_monitor_default_interval_seconds: number
|
channel_monitor_default_interval_seconds: number
|
||||||
available_channels_enabled: boolean
|
available_channels_enabled: boolean
|
||||||
|
affiliate_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export const FeatureFlags = {
|
|||||||
mode: 'opt-out',
|
mode: 'opt-out',
|
||||||
label: 'Payment',
|
label: 'Payment',
|
||||||
}),
|
}),
|
||||||
|
affiliate: defineFlag({
|
||||||
|
key: 'affiliate_enabled',
|
||||||
|
mode: 'opt-in',
|
||||||
|
label: 'Affiliate',
|
||||||
|
}),
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type RegisteredFeatureFlag = keyof typeof FeatureFlags
|
export type RegisteredFeatureFlag = keyof typeof FeatureFlags
|
||||||
|
|||||||
@@ -2153,31 +2153,6 @@
|
|||||||
{{ t("admin.settings.defaults.defaultBalanceHint") }}
|
{{ t("admin.settings.defaults.defaultBalanceHint") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
{{ t("admin.settings.defaults.affiliateRebateRate") }}
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
v-model.number="form.affiliate_rebate_rate"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
class="input pr-8"
|
|
||||||
placeholder="20"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
|
|
||||||
>%</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ t("admin.settings.defaults.affiliateRebateRateHint") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
@@ -3878,6 +3853,356 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Affiliate (邀请返利) feature card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.features.affiliate.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.features.affiliate.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.features.affiliate.enabled') }}
|
||||||
|
</label>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.features.affiliate.enabledHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.affiliate_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.affiliate_enabled" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">
|
||||||
|
{{ t('admin.settings.features.affiliate.rebateRate') }}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model.number="form.affiliate_rebate_rate"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="input pr-8"
|
||||||
|
placeholder="20"
|
||||||
|
/>
|
||||||
|
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
{{ t('admin.settings.features.affiliate.rebateRateHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 专属用户管理 -->
|
||||||
|
<div class="border-t border-gray-100 pt-6 dark:border-dark-700">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.features.affiliate.customUsers.title') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.features.affiliate.customUsers.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
@click="openAffiliateModal(null)"
|
||||||
|
>
|
||||||
|
+ {{ t('admin.settings.features.affiliate.customUsers.addButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="affiliateState.search"
|
||||||
|
type="text"
|
||||||
|
class="input flex-1"
|
||||||
|
:placeholder="t('admin.settings.features.affiliate.customUsers.searchPlaceholder')"
|
||||||
|
@input="onAffiliateSearchInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="affiliateState.selected.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
@click="openAffiliateBatchModal"
|
||||||
|
>
|
||||||
|
{{ t('admin.settings.features.affiliate.customUsers.batchButton', { count: affiliateState.selected.length }) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-dark-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="affiliateState.entries.length > 0 && affiliateState.selected.length === affiliateState.entries.length"
|
||||||
|
@change="toggleAffiliateSelectAll"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.email') }}</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.username') }}</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.code') }}</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.rate') }}</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||||
|
<tr v-if="affiliateState.loading">
|
||||||
|
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="affiliateState.entries.length === 0">
|
||||||
|
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
|
||||||
|
{{ t('admin.settings.features.affiliate.customUsers.empty') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="entry in affiliateState.entries" :key="entry.user_id">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="affiliateState.selected.includes(entry.user_id)"
|
||||||
|
@change="toggleAffiliateSelect(entry.user_id)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white">{{ entry.email }}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">{{ entry.username }}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono">
|
||||||
|
{{ entry.aff_code }}
|
||||||
|
<span
|
||||||
|
v-if="entry.aff_code_custom"
|
||||||
|
class="ml-1 inline-block rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||||
|
>{{ t('admin.settings.features.affiliate.customUsers.customBadge') }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-sm">
|
||||||
|
<span v-if="entry.aff_rebate_rate_percent != null">{{ entry.aff_rebate_rate_percent }}%</span>
|
||||||
|
<span v-else class="text-gray-400">{{ t('admin.settings.features.affiliate.customUsers.useGlobal') }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" class="text-primary-600 hover:underline" @click="openAffiliateModal(entry)">
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-600 hover:underline"
|
||||||
|
@click="askResetAffiliateUser(entry)"
|
||||||
|
>
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="affiliateState.total > affiliateState.pageSize" class="mt-3 flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-500">
|
||||||
|
{{ t('admin.settings.features.affiliate.customUsers.totalLabel', { total: affiliateState.total }) }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="affiliateState.page <= 1"
|
||||||
|
@click="changeAffiliatePage(affiliateState.page - 1)"
|
||||||
|
>
|
||||||
|
{{ t('pagination.previous') }}
|
||||||
|
</button>
|
||||||
|
<span class="text-gray-500">{{ affiliateState.page }} / {{ Math.max(1, Math.ceil(affiliateState.total / affiliateState.pageSize)) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="affiliateState.page >= Math.ceil(affiliateState.total / affiliateState.pageSize)"
|
||||||
|
@click="changeAffiliatePage(affiliateState.page + 1)"
|
||||||
|
>
|
||||||
|
{{ t('pagination.next') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Affiliate add/edit modal -->
|
||||||
|
<div
|
||||||
|
v-if="affiliateModal.open"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
@click.self="closeAffiliateModal"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dark-900">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold">
|
||||||
|
{{ affiliateModal.mode === 'add' ? t('admin.settings.features.affiliate.modal.addTitle') : t('admin.settings.features.affiliate.modal.editTitle') }}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="affiliateModal.mode === 'add'">
|
||||||
|
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.userLabel') }}</label>
|
||||||
|
<!-- Chip showing the picked user; clicking it re-opens the search -->
|
||||||
|
<div
|
||||||
|
v-if="affiliateModal.selectedUser"
|
||||||
|
class="flex items-center justify-between rounded-md border border-primary-200 bg-primary-50 px-3 py-2 dark:border-primary-700/50 dark:bg-primary-900/20"
|
||||||
|
>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ affiliateModal.selectedUser.email }}</span>
|
||||||
|
<span class="ml-1 text-xs text-gray-500">({{ affiliateModal.selectedUser.username }})</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-lg leading-none text-gray-400 hover:text-red-600"
|
||||||
|
:title="t('admin.settings.features.affiliate.modal.changeUser')"
|
||||||
|
@click="clearSelectedAffiliateUser"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Search input + result dropdown — hidden once a selection is made -->
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="affiliateModal.userQuery"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.settings.features.affiliate.modal.userPlaceholder')"
|
||||||
|
@input="onAffiliateUserSearchInput"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="affiliateModal.userResults.length > 0"
|
||||||
|
class="mt-1 max-h-40 overflow-y-auto rounded border border-gray-200 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="u in affiliateModal.userResults"
|
||||||
|
:key="u.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-800"
|
||||||
|
@click="selectAffiliateUser(u)"
|
||||||
|
>
|
||||||
|
{{ u.email }} <span class="text-xs text-gray-500">({{ u.username }})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.userLabel') }}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:value="affiliateModal.editingEntry ? affiliateModal.editingEntry.email : ''"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.codeLabel') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="affiliateModal.code"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono"
|
||||||
|
:placeholder="t('admin.settings.features.affiliate.modal.codePlaceholder')"
|
||||||
|
maxlength="32"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
{{ t('admin.settings.features.affiliate.modal.codeHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.rateLabel') }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="affiliateModal.rate"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="input pr-8"
|
||||||
|
:placeholder="t('admin.settings.features.affiliate.modal.ratePlaceholder')"
|
||||||
|
/>
|
||||||
|
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
{{ t('admin.settings.features.affiliate.modal.rateHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-between gap-3">
|
||||||
|
<p
|
||||||
|
v-if="!affiliateModalCanSubmit"
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ t('admin.settings.features.affiliate.modal.errorEmpty') }}
|
||||||
|
</p>
|
||||||
|
<span v-else></span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="closeAffiliateModal">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="affiliateModal.saving || !affiliateModalCanSubmit"
|
||||||
|
@click="submitAffiliateModal"
|
||||||
|
>
|
||||||
|
{{ affiliateModal.saving ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Affiliate batch rate modal -->
|
||||||
|
<div
|
||||||
|
v-if="affiliateBatchModal.open"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
@click.self="affiliateBatchModal.open = false"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dark-900">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold">
|
||||||
|
{{ t('admin.settings.features.affiliate.batchModal.title', { count: affiliateState.selected.length }) }}
|
||||||
|
</h3>
|
||||||
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
|
{{ t('admin.settings.features.affiliate.batchModal.hint') }}
|
||||||
|
</p>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="affiliateBatchModal.rate"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="input pr-8"
|
||||||
|
:placeholder="t('admin.settings.features.affiliate.batchModal.placeholder')"
|
||||||
|
/>
|
||||||
|
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-400">
|
||||||
|
{{ t('admin.settings.features.affiliate.batchModal.clearHint') }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="affiliateBatchModal.open = false">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="affiliateBatchModal.saving"
|
||||||
|
@click="submitAffiliateBatchModal"
|
||||||
|
>
|
||||||
|
{{ affiliateBatchModal.saving ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /Tab: Features -->
|
</div><!-- /Tab: Features -->
|
||||||
|
|
||||||
<!-- Tab: Email -->
|
<!-- Tab: Email -->
|
||||||
@@ -4793,12 +5118,21 @@
|
|||||||
@confirm="handleDeleteProvider"
|
@confirm="handleDeleteProvider"
|
||||||
@cancel="showDeleteProviderDialog = false"
|
@cancel="showDeleteProviderDialog = false"
|
||||||
/>
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="affiliateConfirmDialog.show"
|
||||||
|
:title="affiliateConfirmDialog.title"
|
||||||
|
:message="affiliateConfirmDialog.message"
|
||||||
|
:confirm-text="affiliateConfirmDialog.confirmText"
|
||||||
|
danger
|
||||||
|
@confirm="handleAffiliateConfirm"
|
||||||
|
@cancel="cancelAffiliateConfirm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from "vue";
|
import { ref, reactive, computed, onMounted, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { adminAPI } from "@/api";
|
import { adminAPI } from "@/api";
|
||||||
import {
|
import {
|
||||||
@@ -4835,6 +5169,7 @@ import ProxySelector from "@/components/common/ProxySelector.vue";
|
|||||||
import ImageUpload from "@/components/common/ImageUpload.vue";
|
import ImageUpload from "@/components/common/ImageUpload.vue";
|
||||||
import BackupSettings from "@/views/admin/BackupView.vue";
|
import BackupSettings from "@/views/admin/BackupView.vue";
|
||||||
import { useClipboard } from "@/composables/useClipboard";
|
import { useClipboard } from "@/composables/useClipboard";
|
||||||
|
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
||||||
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
||||||
import { useAppStore } from "@/stores";
|
import { useAppStore } from "@/stores";
|
||||||
import { useAdminSettingsStore } from "@/stores/adminSettings";
|
import { useAdminSettingsStore } from "@/stores/adminSettings";
|
||||||
@@ -5145,6 +5480,8 @@ const form = reactive<SettingsForm>({
|
|||||||
channel_monitor_default_interval_seconds: 60,
|
channel_monitor_default_interval_seconds: 60,
|
||||||
// Available Channels feature switch
|
// Available Channels feature switch
|
||||||
available_channels_enabled: false,
|
available_channels_enabled: false,
|
||||||
|
// Affiliate (邀请返利) feature switch
|
||||||
|
affiliate_enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
|
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
|
||||||
@@ -6063,6 +6400,8 @@ async function saveSettings() {
|
|||||||
Number(form.channel_monitor_default_interval_seconds) || 60,
|
Number(form.channel_monitor_default_interval_seconds) || 60,
|
||||||
// Available Channels feature switch
|
// Available Channels feature switch
|
||||||
available_channels_enabled: form.available_channels_enabled,
|
available_channels_enabled: form.available_channels_enabled,
|
||||||
|
// Affiliate (邀请返利) feature switch
|
||||||
|
affiliate_enabled: form.affiliate_enabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
|
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
|
||||||
@@ -6844,6 +7183,359 @@ onMounted(() => {
|
|||||||
loadBetaPolicySettings();
|
loadBetaPolicySettings();
|
||||||
loadProviders();
|
loadProviders();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Affiliate (邀请返利) 专属用户管理
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
interface AffiliateState {
|
||||||
|
loading: boolean;
|
||||||
|
entries: AffiliateAdminEntry[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search: string;
|
||||||
|
selected: number[];
|
||||||
|
searchTimer: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const affiliateState = reactive<AffiliateState>({
|
||||||
|
loading: false,
|
||||||
|
entries: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
search: "",
|
||||||
|
selected: [],
|
||||||
|
searchTimer: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// `rate` is typed as string|number because <input type="number"> makes Vue's
|
||||||
|
// v-model auto-cast the bound value to a Number on every keystroke. We keep
|
||||||
|
// both shapes and normalize at read time.
|
||||||
|
interface AffiliateModalState {
|
||||||
|
open: boolean;
|
||||||
|
mode: "add" | "edit";
|
||||||
|
saving: boolean;
|
||||||
|
userQuery: string;
|
||||||
|
userResults: AffiliateSimpleUser[];
|
||||||
|
selectedUser: AffiliateSimpleUser | null;
|
||||||
|
editingEntry: AffiliateAdminEntry | null;
|
||||||
|
code: string;
|
||||||
|
rate: string | number;
|
||||||
|
searchTimer: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const affiliateModal = reactive<AffiliateModalState>({
|
||||||
|
open: false,
|
||||||
|
mode: "add",
|
||||||
|
saving: false,
|
||||||
|
userQuery: "",
|
||||||
|
userResults: [],
|
||||||
|
selectedUser: null,
|
||||||
|
editingEntry: null,
|
||||||
|
code: "",
|
||||||
|
rate: "",
|
||||||
|
searchTimer: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const affiliateBatchModal = reactive<{
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
rate: string | number;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
saving: false,
|
||||||
|
rate: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// affiliateConfirmDialog drives the project-standard <ConfirmDialog>. We can't
|
||||||
|
// `await` the user's response from the dialog component, so the confirm action
|
||||||
|
// runs from the @confirm callback once the user clicks the dialog's confirm
|
||||||
|
// button.
|
||||||
|
const affiliateConfirmDialog = reactive<{
|
||||||
|
show: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText: string;
|
||||||
|
pending: (() => Promise<unknown>) | null;
|
||||||
|
}>({
|
||||||
|
show: false,
|
||||||
|
title: "",
|
||||||
|
message: "",
|
||||||
|
confirmText: "",
|
||||||
|
pending: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function openAffiliateConfirm(
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
confirmText: string,
|
||||||
|
fn: () => Promise<unknown>,
|
||||||
|
) {
|
||||||
|
affiliateConfirmDialog.title = title;
|
||||||
|
affiliateConfirmDialog.message = message;
|
||||||
|
affiliateConfirmDialog.confirmText = confirmText;
|
||||||
|
affiliateConfirmDialog.pending = fn;
|
||||||
|
affiliateConfirmDialog.show = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAffiliateConfirm() {
|
||||||
|
const fn = affiliateConfirmDialog.pending;
|
||||||
|
affiliateConfirmDialog.show = false;
|
||||||
|
affiliateConfirmDialog.pending = null;
|
||||||
|
if (!fn) return;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
appStore.showSuccess(t("common.saved"));
|
||||||
|
await loadAffiliateUsers();
|
||||||
|
} catch (err) {
|
||||||
|
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAffiliateConfirm() {
|
||||||
|
affiliateConfirmDialog.show = false;
|
||||||
|
affiliateConfirmDialog.pending = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounceTimer wires a single timer slot to a callback with a delay,
|
||||||
|
// canceling any pending invocation. Used for type-as-you-go search inputs.
|
||||||
|
function debounceTimer(slot: { searchTimer: number | null }, delayMs: number, run: () => void) {
|
||||||
|
if (slot.searchTimer != null) window.clearTimeout(slot.searchTimer);
|
||||||
|
slot.searchTimer = window.setTimeout(run, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRebateRate validates 0-100 numeric input. Returns the parsed number on
|
||||||
|
// success, null when the field is empty (caller decides empty semantics), or
|
||||||
|
// undefined on invalid input (after surfacing a toast).
|
||||||
|
//
|
||||||
|
// Accepts unknown because <input type="number"> makes Vue's v-model coerce
|
||||||
|
// the value to Number on each keystroke (e.g. typing "30" lands a `30: number`
|
||||||
|
// in state, not a `"30": string`). String("") and (30).trim() would crash, so
|
||||||
|
// we normalize here instead of forcing every caller to remember.
|
||||||
|
function parseRebateRate(raw: unknown): number | null | undefined {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (s === "") return null;
|
||||||
|
const parsed = Number(s);
|
||||||
|
if (Number.isNaN(parsed) || parsed < 0 || parsed > 100) {
|
||||||
|
appStore.showError(t("admin.settings.features.affiliate.modal.errorBadRate"));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAffiliateUsers() {
|
||||||
|
affiliateState.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await affiliatesAPI.listUsers({
|
||||||
|
page: affiliateState.page,
|
||||||
|
page_size: affiliateState.pageSize,
|
||||||
|
search: affiliateState.search,
|
||||||
|
});
|
||||||
|
affiliateState.entries = res.items ?? [];
|
||||||
|
affiliateState.total = res.total ?? 0;
|
||||||
|
// Drop selections that are no longer visible.
|
||||||
|
const visibleIds = new Set(affiliateState.entries.map((e) => e.user_id));
|
||||||
|
affiliateState.selected = affiliateState.selected.filter((id) => visibleIds.has(id));
|
||||||
|
} catch (err) {
|
||||||
|
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||||
|
} finally {
|
||||||
|
affiliateState.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAffiliateSearchInput() {
|
||||||
|
debounceTimer(affiliateState, 300, () => {
|
||||||
|
affiliateState.page = 1;
|
||||||
|
loadAffiliateUsers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeAffiliatePage(page: number) {
|
||||||
|
if (page < 1) return;
|
||||||
|
affiliateState.page = page;
|
||||||
|
loadAffiliateUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAffiliateSelectAll(e: Event) {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
affiliateState.selected = checked ? affiliateState.entries.map((entry) => entry.user_id) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAffiliateSelect(userId: number) {
|
||||||
|
const idx = affiliateState.selected.indexOf(userId);
|
||||||
|
if (idx >= 0) affiliateState.selected.splice(idx, 1);
|
||||||
|
else affiliateState.selected.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAffiliateModal opens the add/edit modal, prefilling fields from the
|
||||||
|
// edited entry when present and resetting them otherwise.
|
||||||
|
function openAffiliateModal(entry: AffiliateAdminEntry | null) {
|
||||||
|
affiliateModal.open = true;
|
||||||
|
affiliateModal.mode = entry ? "edit" : "add";
|
||||||
|
affiliateModal.userQuery = "";
|
||||||
|
affiliateModal.userResults = [];
|
||||||
|
affiliateModal.selectedUser = null;
|
||||||
|
affiliateModal.editingEntry = entry;
|
||||||
|
affiliateModal.code = entry?.aff_code_custom ? entry.aff_code : "";
|
||||||
|
affiliateModal.rate =
|
||||||
|
entry?.aff_rebate_rate_percent != null ? String(entry.aff_rebate_rate_percent) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAffiliateModal() {
|
||||||
|
affiliateModal.open = false;
|
||||||
|
if (affiliateModal.searchTimer != null) {
|
||||||
|
window.clearTimeout(affiliateModal.searchTimer);
|
||||||
|
affiliateModal.searchTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAffiliateUserSearchInput() {
|
||||||
|
const q = affiliateModal.userQuery.trim();
|
||||||
|
if (!q) {
|
||||||
|
affiliateModal.userResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceTimer(affiliateModal, 300, async () => {
|
||||||
|
try {
|
||||||
|
affiliateModal.userResults = await affiliatesAPI.lookupUsers(q);
|
||||||
|
} catch (err) {
|
||||||
|
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectAffiliateUser picks a user from the dropdown and collapses the search
|
||||||
|
// UI. Clearing the result list also clears the visual dropdown.
|
||||||
|
function selectAffiliateUser(user: AffiliateSimpleUser) {
|
||||||
|
affiliateModal.selectedUser = user;
|
||||||
|
affiliateModal.userQuery = "";
|
||||||
|
affiliateModal.userResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedAffiliateUser() {
|
||||||
|
affiliateModal.selectedUser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// affiliateModalCanSubmit guards the Save button: must have a user picked AND
|
||||||
|
// produce at least one field change. Without this the admin could "save" an
|
||||||
|
// empty payload that silently does nothing — the user reported exactly that
|
||||||
|
// confusion.
|
||||||
|
const affiliateModalCanSubmit = computed(() => {
|
||||||
|
if (affiliateModal.mode === "add") {
|
||||||
|
if (!affiliateModal.selectedUser) return false;
|
||||||
|
} else if (!affiliateModal.editingEntry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const codeFilled = affiliateModal.code.trim() !== "";
|
||||||
|
const rateFilled = String(affiliateModal.rate ?? "").trim() !== "";
|
||||||
|
if (codeFilled || rateFilled) return true;
|
||||||
|
// Edit mode + empty rate input is a meaningful "clear" only if the user
|
||||||
|
// currently has an exclusive rate to clear.
|
||||||
|
return (
|
||||||
|
affiliateModal.mode === "edit" &&
|
||||||
|
affiliateModal.editingEntry?.aff_rebate_rate_percent != null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitAffiliateModal() {
|
||||||
|
if (!affiliateModalCanSubmit.value) {
|
||||||
|
// Should be unreachable because the button is disabled, but keep a guard.
|
||||||
|
appStore.showError(t("admin.settings.features.affiliate.modal.errorEmpty"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId: number;
|
||||||
|
if (affiliateModal.mode === "add") {
|
||||||
|
userId = affiliateModal.selectedUser!.id;
|
||||||
|
} else {
|
||||||
|
userId = affiliateModal.editingEntry!.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Parameters<typeof affiliatesAPI.updateUserSettings>[1] = {};
|
||||||
|
const codeRaw = affiliateModal.code.trim();
|
||||||
|
if (codeRaw) payload.aff_code = codeRaw.toUpperCase();
|
||||||
|
|
||||||
|
const rateInput = parseRebateRate(affiliateModal.rate);
|
||||||
|
if (rateInput === undefined) return; // toast already shown
|
||||||
|
if (rateInput === null) {
|
||||||
|
if (affiliateModal.mode === "edit" && affiliateModal.editingEntry?.aff_rebate_rate_percent != null) {
|
||||||
|
payload.clear_rebate_rate = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload.aff_rebate_rate_percent = rateInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
affiliateModal.saving = true;
|
||||||
|
try {
|
||||||
|
await affiliatesAPI.updateUserSettings(userId, payload);
|
||||||
|
appStore.showSuccess(t("common.saved"));
|
||||||
|
closeAffiliateModal();
|
||||||
|
affiliateState.page = 1;
|
||||||
|
await loadAffiliateUsers();
|
||||||
|
} catch (err) {
|
||||||
|
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||||
|
} finally {
|
||||||
|
affiliateModal.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// askResetAffiliateUser prompts via the project ConfirmDialog, then on confirm
|
||||||
|
// calls the backend "reset all" endpoint that clears both the exclusive rate
|
||||||
|
// AND regenerates the invite code as a system random one.
|
||||||
|
function askResetAffiliateUser(entry: AffiliateAdminEntry) {
|
||||||
|
openAffiliateConfirm(
|
||||||
|
t("admin.settings.features.affiliate.customUsers.resetTitle"),
|
||||||
|
t("admin.settings.features.affiliate.customUsers.resetMessage", {
|
||||||
|
email: entry.email || `#${entry.user_id}`,
|
||||||
|
}),
|
||||||
|
t("common.delete"),
|
||||||
|
() => affiliatesAPI.clearUserSettings(entry.user_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAffiliateBatchModal() {
|
||||||
|
if (affiliateState.selected.length === 0) return;
|
||||||
|
affiliateBatchModal.open = true;
|
||||||
|
affiliateBatchModal.rate = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAffiliateBatchModal() {
|
||||||
|
const rateInput = parseRebateRate(affiliateBatchModal.rate);
|
||||||
|
if (rateInput === undefined) return;
|
||||||
|
const userIDs = [...affiliateState.selected];
|
||||||
|
const payload: Parameters<typeof affiliatesAPI.batchSetRate>[0] =
|
||||||
|
rateInput === null
|
||||||
|
? { user_ids: userIDs, clear: true }
|
||||||
|
: { user_ids: userIDs, aff_rebate_rate_percent: rateInput };
|
||||||
|
|
||||||
|
affiliateBatchModal.saving = true;
|
||||||
|
try {
|
||||||
|
await affiliatesAPI.batchSetRate(payload);
|
||||||
|
appStore.showSuccess(t("common.saved"));
|
||||||
|
affiliateBatchModal.open = false;
|
||||||
|
affiliateState.selected = [];
|
||||||
|
await loadAffiliateUsers();
|
||||||
|
} catch (err) {
|
||||||
|
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||||
|
} finally {
|
||||||
|
affiliateBatchModal.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the per-user table the first time the affiliate switch is observed
|
||||||
|
// as enabled. The form starts disabled and is updated to the server's value
|
||||||
|
// after the settings load — so this fires either when the saved value is
|
||||||
|
// truthy on first paint, or when the admin manually toggles it on.
|
||||||
|
watch(
|
||||||
|
() => form.affiliate_enabled,
|
||||||
|
(enabled, prev) => {
|
||||||
|
if (enabled && !prev) {
|
||||||
|
loadAffiliateUsers();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -8,7 +8,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- 返利比例:用主色突出,让用户一眼看到「能拿多少」 -->
|
||||||
|
<div class="card relative overflow-hidden p-5">
|
||||||
|
<div class="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-primary-500/10"></div>
|
||||||
|
<div class="relative">
|
||||||
|
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
<Icon name="dollar" size="sm" class="text-primary-500" />
|
||||||
|
{{ t('affiliate.stats.rebateRate') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
|
||||||
|
{{ t('affiliate.stats.rebateRateHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card p-5">
|
<div class="card p-5">
|
||||||
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
|
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
|
||||||
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
@@ -61,7 +77,7 @@
|
|||||||
<p class="text-sm font-medium text-primary-800 dark:text-primary-200">{{ t('affiliate.tips.title') }}</p>
|
<p class="text-sm font-medium text-primary-800 dark:text-primary-200">{{ t('affiliate.tips.title') }}</p>
|
||||||
<ul class="mt-2 space-y-1 text-sm text-primary-700 dark:text-primary-300">
|
<ul class="mt-2 space-y-1 text-sm text-primary-700 dark:text-primary-300">
|
||||||
<li>1. {{ t('affiliate.tips.line1') }}</li>
|
<li>1. {{ t('affiliate.tips.line1') }}</li>
|
||||||
<li>2. {{ t('affiliate.tips.line2') }}</li>
|
<li>2. {{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li>
|
||||||
<li>3. {{ t('affiliate.tips.line3') }}</li>
|
<li>3. {{ t('affiliate.tips.line3') }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +165,14 @@ const inviteLink = computed(() => {
|
|||||||
return `${window.location.origin}/register?aff=${encodeURIComponent(detail.value.aff_code)}`
|
return `${window.location.origin}/register?aff=${encodeURIComponent(detail.value.aff_code)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Rebate rate is a percentage in the range [0, 100]; backend already clamps it.
|
||||||
|
// We trim trailing zeros (e.g. 20.00 → "20", 12.50 → "12.5") for a cleaner UI.
|
||||||
|
const formattedRebateRate = computed(() => {
|
||||||
|
const v = detail.value?.effective_rebate_rate_percent ?? 0
|
||||||
|
const rounded = Math.round(v * 100) / 100
|
||||||
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toString()
|
||||||
|
})
|
||||||
|
|
||||||
function formatCount(value: number): string {
|
function formatCount(value: number): string {
|
||||||
return value.toLocaleString()
|
return value.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user