2026-04-24 21:41:26 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"math"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
2026-04-25 08:44:18 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
2026-04-24 21:41:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found")
|
|
|
|
|
|
ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code")
|
2026-04-25 19:14:34 +08:00
|
|
|
|
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
|
2026-04-24 21:41:26 +08:00
|
|
|
|
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
|
|
|
|
|
|
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
affiliateInviteesLimit = 100
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// AffiliateCodeMinLength / AffiliateCodeMaxLength bound both system-generated
|
|
|
|
|
|
// 12-char codes and admin-customized codes (e.g. "VIP2026").
|
|
|
|
|
|
AffiliateCodeMinLength = 4
|
|
|
|
|
|
AffiliateCodeMaxLength = 32
|
2026-04-24 21:41:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// affiliateCodeValidChar accepts uppercase letters, digits, underscore and dash.
|
|
|
|
|
|
// All input passes through strings.ToUpper before validation, so lowercase from
|
|
|
|
|
|
// users is normalized — admins may supply mixed case in their UI.
|
2026-04-25 08:44:18 +08:00
|
|
|
|
var affiliateCodeValidChar = func() [256]bool {
|
|
|
|
|
|
var tbl [256]bool
|
2026-04-25 19:14:34 +08:00
|
|
|
|
for c := byte('A'); c <= 'Z'; c++ {
|
2026-04-25 08:44:18 +08:00
|
|
|
|
tbl[c] = true
|
|
|
|
|
|
}
|
2026-04-25 19:14:34 +08:00
|
|
|
|
for c := byte('0'); c <= '9'; c++ {
|
|
|
|
|
|
tbl[c] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
tbl['_'] = true
|
|
|
|
|
|
tbl['-'] = true
|
2026-04-25 08:44:18 +08:00
|
|
|
|
return tbl
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// isValidAffiliateCodeFormat validates code format for both binding (user input)
|
|
|
|
|
|
// and admin updates. Caller is expected to upper-case the input first.
|
2026-04-25 08:44:18 +08:00
|
|
|
|
func isValidAffiliateCodeFormat(code string) bool {
|
2026-04-25 19:14:34 +08:00
|
|
|
|
if len(code) < AffiliateCodeMinLength || len(code) > AffiliateCodeMaxLength {
|
2026-04-25 08:44:18 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := 0; i < len(code); i++ {
|
|
|
|
|
|
if !affiliateCodeValidChar[code[i]] {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 21:41:26 +08:00
|
|
|
|
type AffiliateSummary struct {
|
2026-04-25 19:14:34 +08:00
|
|
|
|
UserID int64 `json:"user_id"`
|
|
|
|
|
|
AffCode string `json:"aff_code"`
|
|
|
|
|
|
AffCodeCustom bool `json:"aff_code_custom"`
|
|
|
|
|
|
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
|
|
|
|
|
|
InviterID *int64 `json:"inviter_id,omitempty"`
|
|
|
|
|
|
AffCount int `json:"aff_count"`
|
|
|
|
|
|
AffQuota float64 `json:"aff_quota"`
|
2026-04-26 12:31:52 +08:00
|
|
|
|
AffFrozenQuota float64 `json:"aff_frozen_quota"`
|
2026-04-25 19:14:34 +08:00
|
|
|
|
AffHistoryQuota float64 `json:"aff_history_quota"`
|
|
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
2026-04-24 21:41:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AffiliateInvitee struct {
|
2026-04-26 12:31:52 +08:00
|
|
|
|
UserID int64 `json:"user_id"`
|
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
|
Username string `json:"username"`
|
|
|
|
|
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
|
|
|
|
|
TotalRebate float64 `json:"total_rebate"`
|
2026-04-24 21:41:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AffiliateDetail struct {
|
2026-04-25 20:37:42 +08:00
|
|
|
|
UserID int64 `json:"user_id"`
|
|
|
|
|
|
AffCode string `json:"aff_code"`
|
|
|
|
|
|
InviterID *int64 `json:"inviter_id,omitempty"`
|
|
|
|
|
|
AffCount int `json:"aff_count"`
|
|
|
|
|
|
AffQuota float64 `json:"aff_quota"`
|
2026-04-26 12:31:52 +08:00
|
|
|
|
AffFrozenQuota float64 `json:"aff_frozen_quota"`
|
2026-04-25 20:37:42 +08:00
|
|
|
|
AffHistoryQuota float64 `json:"aff_history_quota"`
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例:
|
|
|
|
|
|
// 优先用户自己的专属比例(aff_rebate_rate_percent),否则回退到全局比例。
|
|
|
|
|
|
// 用于在用户的 /affiliate 页面直观展示「分享后能拿到多少」。
|
|
|
|
|
|
EffectiveRebateRatePercent float64 `json:"effective_rebate_rate_percent"`
|
|
|
|
|
|
Invitees []AffiliateInvitee `json:"invitees"`
|
2026-04-24 21:41:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AffiliateRepository interface {
|
|
|
|
|
|
EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
|
|
|
|
|
|
GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
|
|
|
|
|
|
BindInviter(ctx context.Context, userID, inviterID int64) (bool, error)
|
2026-04-26 12:31:52 +08:00
|
|
|
|
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error)
|
|
|
|
|
|
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
|
|
|
|
|
|
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
|
2026-04-24 21:41:26 +08:00
|
|
|
|
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
|
|
|
|
|
|
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
|
2026-04-25 19:14:34 +08:00
|
|
|
|
|
|
|
|
|
|
// 管理端:用户级专属配置
|
|
|
|
|
|
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"`
|
2026-04-24 21:41:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AffiliateService struct {
|
|
|
|
|
|
repo AffiliateRepository
|
2026-04-25 19:14:34 +08:00
|
|
|
|
settingService *SettingService
|
2026-04-24 21:41:26 +08:00
|
|
|
|
authCacheInvalidator APIKeyAuthCacheInvalidator
|
|
|
|
|
|
billingCacheService *BillingCacheService
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 19:14:34 +08:00
|
|
|
|
func NewAffiliateService(repo AffiliateRepository, settingService *SettingService, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService {
|
2026-04-24 21:41:26 +08:00
|
|
|
|
return &AffiliateService{
|
|
|
|
|
|
repo: repo,
|
2026-04-25 19:14:34 +08:00
|
|
|
|
settingService: settingService,
|
2026-04-24 21:41:26 +08:00
|
|
|
|
authCacheInvalidator: authCacheInvalidator,
|
|
|
|
|
|
billingCacheService: billingCacheService,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 21:41:26 +08:00
|
|
|
|
func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) {
|
|
|
|
|
|
if userID <= 0 {
|
|
|
|
|
|
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
|
|
|
|
|
|
}
|
|
|
|
|
|
if s == nil || s.repo == nil {
|
|
|
|
|
|
return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.repo.EnsureUserAffiliate(ctx, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) (*AffiliateDetail, error) {
|
2026-04-26 12:31:52 +08:00
|
|
|
|
// Lazy thaw: move any matured frozen quota to available before reading.
|
|
|
|
|
|
if s != nil && s.repo != nil {
|
|
|
|
|
|
// best-effort: thaw failure is non-fatal
|
|
|
|
|
|
_, _ = s.repo.ThawFrozenQuota(ctx, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 21:41:26 +08:00
|
|
|
|
summary, err := s.EnsureUserAffiliate(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
invitees, err := s.listInvitees(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return &AffiliateDetail{
|
2026-04-25 19:14:34 +08:00
|
|
|
|
UserID: summary.UserID,
|
|
|
|
|
|
AffCode: summary.AffCode,
|
|
|
|
|
|
InviterID: summary.InviterID,
|
|
|
|
|
|
AffCount: summary.AffCount,
|
|
|
|
|
|
AffQuota: summary.AffQuota,
|
2026-04-26 12:31:52 +08:00
|
|
|
|
AffFrozenQuota: summary.AffFrozenQuota,
|
2026-04-25 19:14:34 +08:00
|
|
|
|
AffHistoryQuota: summary.AffHistoryQuota,
|
|
|
|
|
|
EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary),
|
|
|
|
|
|
Invitees: invitees,
|
2026-04-24 21:41:26 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64, rawCode string) error {
|
|
|
|
|
|
code := strings.ToUpper(strings.TrimSpace(rawCode))
|
|
|
|
|
|
if code == "" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if s == nil || s.repo == nil {
|
|
|
|
|
|
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
|
|
|
|
|
}
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// 总开关关闭时,注册阶段静默忽略 aff 参数(不报错,避免阻断注册流程)
|
|
|
|
|
|
if !s.IsEnabled(ctx) {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if !isValidAffiliateCodeFormat(code) {
|
|
|
|
|
|
return ErrAffiliateCodeInvalid
|
|
|
|
|
|
}
|
2026-04-24 21:41:26 +08:00
|
|
|
|
|
|
|
|
|
|
selfSummary, err := s.repo.EnsureUserAffiliate(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if selfSummary.InviterID != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inviterSummary, err := s.repo.GetAffiliateByCode(ctx, code)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, ErrAffiliateProfileNotFound) {
|
|
|
|
|
|
return ErrAffiliateCodeInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if inviterSummary == nil || inviterSummary.UserID <= 0 || inviterSummary.UserID == userID {
|
|
|
|
|
|
return ErrAffiliateCodeInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bound, err := s.repo.BindInviter(ctx, userID, inviterSummary.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if !bound {
|
|
|
|
|
|
return ErrAffiliateAlreadyBound
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64) (float64, error) {
|
|
|
|
|
|
if s == nil || s.repo == nil {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// 总开关关闭时,新充值不再产生返利
|
|
|
|
|
|
if !s.IsEnabled(ctx) {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
2026-04-24 21:41:26 +08:00
|
|
|
|
|
|
|
|
|
|
inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if inviteeSummary.InviterID == nil || *inviteeSummary.InviterID <= 0 {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// 加载邀请人 profile,优先使用专属比例(覆盖全局)
|
|
|
|
|
|
inviterSummary, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
2026-04-26 12:31:52 +08:00
|
|
|
|
// 有效期检查:超过返利有效期后不再产生返利
|
|
|
|
|
|
if s.settingService != nil {
|
|
|
|
|
|
if durationDays := s.settingService.GetAffiliateRebateDurationDays(ctx); durationDays > 0 {
|
|
|
|
|
|
if time.Now().After(inviteeSummary.CreatedAt.AddDate(0, 0, durationDays)) {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 19:14:34 +08:00
|
|
|
|
rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary)
|
2026-04-24 21:41:26 +08:00
|
|
|
|
rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
|
|
|
|
|
|
if rebate <= 0 {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 12:31:52 +08:00
|
|
|
|
// 单人上限检查:精确截断到剩余额度
|
|
|
|
|
|
if s.settingService != nil {
|
|
|
|
|
|
if perInviteeCap := s.settingService.GetAffiliateRebatePerInviteeCap(ctx); perInviteeCap > 0 {
|
|
|
|
|
|
existing, err := s.repo.GetAccruedRebateFromInvitee(ctx, *inviteeSummary.InviterID, inviteeUserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if existing >= perInviteeCap {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if remaining := perInviteeCap - existing; rebate > remaining {
|
|
|
|
|
|
rebate = roundTo(remaining, 8)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var freezeHours int
|
|
|
|
|
|
if s.settingService != nil {
|
|
|
|
|
|
freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours)
|
2026-04-24 21:41:26 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if !applied {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return rebate, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 19:14:34 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 21:41:26 +08:00
|
|
|
|
func (s *AffiliateService) TransferAffiliateQuota(ctx context.Context, userID int64) (float64, float64, error) {
|
|
|
|
|
|
if s == nil || s.repo == nil {
|
|
|
|
|
|
return 0, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
transferred, balance, err := s.repo.TransferQuotaToBalance(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if transferred > 0 {
|
|
|
|
|
|
s.invalidateAffiliateCaches(ctx, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return transferred, balance, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AffiliateService) listInvitees(ctx context.Context, inviterID int64) ([]AffiliateInvitee, error) {
|
|
|
|
|
|
if s == nil || s.repo == nil {
|
|
|
|
|
|
return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
|
|
|
|
|
}
|
|
|
|
|
|
invitees, err := s.repo.ListInvitees(ctx, inviterID, affiliateInviteesLimit)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := range invitees {
|
|
|
|
|
|
invitees[i].Email = maskEmail(invitees[i].Email)
|
|
|
|
|
|
}
|
|
|
|
|
|
return invitees, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func roundTo(v float64, scale int) float64 {
|
|
|
|
|
|
factor := math.Pow10(scale)
|
|
|
|
|
|
return math.Round(v*factor) / factor
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func maskEmail(email string) string {
|
|
|
|
|
|
email = strings.TrimSpace(email)
|
|
|
|
|
|
if email == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
at := strings.Index(email, "@")
|
|
|
|
|
|
if at <= 0 || at >= len(email)-1 {
|
|
|
|
|
|
return "***"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
local := email[:at]
|
|
|
|
|
|
domain := email[at+1:]
|
|
|
|
|
|
dot := strings.LastIndex(domain, ".")
|
|
|
|
|
|
|
|
|
|
|
|
maskedLocal := maskSegment(local)
|
|
|
|
|
|
if dot <= 0 || dot >= len(domain)-1 {
|
|
|
|
|
|
return maskedLocal + "@" + maskSegment(domain)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
domainName := domain[:dot]
|
|
|
|
|
|
tld := domain[dot:]
|
|
|
|
|
|
return maskedLocal + "@" + maskSegment(domainName) + tld
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func maskSegment(s string) string {
|
|
|
|
|
|
r := []rune(s)
|
|
|
|
|
|
if len(r) == 0 {
|
|
|
|
|
|
return "***"
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(r) == 1 {
|
|
|
|
|
|
return string(r[0]) + "***"
|
|
|
|
|
|
}
|
|
|
|
|
|
return string(r[0]) + "***"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AffiliateService) invalidateAffiliateCaches(ctx context.Context, userID int64) {
|
|
|
|
|
|
if s.authCacheInvalidator != nil {
|
|
|
|
|
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.billingCacheService != nil {
|
2026-04-25 08:44:18 +08:00
|
|
|
|
if err := s.billingCacheService.InvalidateUserBalance(ctx, userID); err != nil {
|
|
|
|
|
|
logger.LegacyPrintf("service.affiliate", "[Affiliate] Failed to invalidate billing cache for user %d: %v", userID, err)
|
|
|
|
|
|
}
|
2026-04-24 21:41:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-25 19:14:34 +08:00
|
|
|
|
|
|
|
|
|
|
// =========================
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|