package service import ( "context" "errors" "math" "strings" "time" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) var ( ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found") ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code") ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use") ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound") ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer") ) const ( affiliateInviteesLimit = 100 // AffiliateCodeMinLength / AffiliateCodeMaxLength bound both system-generated // 12-char codes and admin-customized codes (e.g. "VIP2026"). AffiliateCodeMinLength = 4 AffiliateCodeMaxLength = 32 ) // affiliateCodeValidChar accepts uppercase letters, digits, underscore and dash. // All input passes through strings.ToUpper before validation, so lowercase from // users is normalized — admins may supply mixed case in their UI. var affiliateCodeValidChar = func() [256]bool { var tbl [256]bool for c := byte('A'); c <= 'Z'; c++ { tbl[c] = true } for c := byte('0'); c <= '9'; c++ { tbl[c] = true } tbl['_'] = true tbl['-'] = true return tbl }() // isValidAffiliateCodeFormat validates code format for both binding (user input) // and admin updates. Caller is expected to upper-case the input first. func isValidAffiliateCodeFormat(code string) bool { if len(code) < AffiliateCodeMinLength || len(code) > AffiliateCodeMaxLength { return false } for i := 0; i < len(code); i++ { if !affiliateCodeValidChar[code[i]] { return false } } return true } type AffiliateSummary struct { UserID int64 `json:"user_id"` AffCode string `json:"aff_code"` AffCodeCustom bool `json:"aff_code_custom"` AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"` InviterID *int64 `json:"inviter_id,omitempty"` AffCount int `json:"aff_count"` AffQuota float64 `json:"aff_quota"` AffHistoryQuota float64 `json:"aff_history_quota"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type AffiliateInvitee struct { UserID int64 `json:"user_id"` Email string `json:"email"` Username string `json:"username"` CreatedAt *time.Time `json:"created_at,omitempty"` } type AffiliateDetail struct { 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"` AffHistoryQuota float64 `json:"aff_history_quota"` // EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例: // 优先用户自己的专属比例(aff_rebate_rate_percent),否则回退到全局比例。 // 用于在用户的 /affiliate 页面直观展示「分享后能拿到多少」。 EffectiveRebateRatePercent float64 `json:"effective_rebate_rate_percent"` Invitees []AffiliateInvitee `json:"invitees"` } 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) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) // 管理端:用户级专属配置 UpdateUserAffCode(ctx context.Context, userID int64, newCode string) error ResetUserAffCode(ctx context.Context, userID int64) (string, error) SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error ListUsersWithCustomSettings(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) } // AffiliateAdminFilter 列表筛选条件 type AffiliateAdminFilter struct { Search string Page int PageSize int } // AffiliateAdminEntry 专属用户列表条目 type AffiliateAdminEntry struct { UserID int64 `json:"user_id"` Email string `json:"email"` Username string `json:"username"` AffCode string `json:"aff_code"` AffCodeCustom bool `json:"aff_code_custom"` AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"` AffCount int `json:"aff_count"` } type AffiliateService struct { repo AffiliateRepository settingService *SettingService authCacheInvalidator APIKeyAuthCacheInvalidator billingCacheService *BillingCacheService } func NewAffiliateService(repo AffiliateRepository, settingService *SettingService, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService { return &AffiliateService{ repo: repo, settingService: settingService, authCacheInvalidator: authCacheInvalidator, billingCacheService: billingCacheService, } } // IsEnabled reports whether the affiliate (邀请返利) feature is turned on. func (s *AffiliateService) IsEnabled(ctx context.Context) bool { if s == nil || s.settingService == nil { return AffiliateEnabledDefault } return s.settingService.IsAffiliateEnabled(ctx) } func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) { if userID <= 0 { return nil, infraerrors.BadRequest("INVALID_USER", "invalid user") } 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) { 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{ UserID: summary.UserID, AffCode: summary.AffCode, InviterID: summary.InviterID, AffCount: summary.AffCount, AffQuota: summary.AffQuota, AffHistoryQuota: summary.AffHistoryQuota, EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary), Invitees: invitees, }, 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") } // 总开关关闭时,注册阶段静默忽略 aff 参数(不报错,避免阻断注册流程) if !s.IsEnabled(ctx) { return nil } if !isValidAffiliateCodeFormat(code) { return ErrAffiliateCodeInvalid } 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 } // 总开关关闭时,新充值不再产生返利 if !s.IsEnabled(ctx) { return 0, nil } inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID) if err != nil { return 0, err } if inviteeSummary.InviterID == nil || *inviteeSummary.InviterID <= 0 { return 0, nil } // 加载邀请人 profile,优先使用专属比例(覆盖全局) inviterSummary, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID) if err != nil { return 0, err } rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary) rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8) if rebate <= 0 { return 0, nil } applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate) if err != nil { return 0, err } if !applied { return 0, 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) { 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 { 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) } } } // ========================= // 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) }