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