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