- 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突 - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定 - 前端 OAuth 注册页面传递 aff_code 参数 - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻) - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利 - 新增单人返利上限:超出上限部分精确截断 - 增强返利流程 slog 结构化日志,便于排查问题 - 已邀请用户列表增加返利明细列
491 lines
17 KiB
Go
491 lines
17 KiB
Go
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"`
|
||
AffFrozenQuota float64 `json:"aff_frozen_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"`
|
||
TotalRebate float64 `json:"total_rebate"`
|
||
}
|
||
|
||
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"`
|
||
AffFrozenQuota float64 `json:"aff_frozen_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, freezeHours int) (bool, error)
|
||
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
|
||
ThawFrozenQuota(ctx context.Context, userID int64) (float64, 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) {
|
||
// 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)
|
||
}
|
||
|
||
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,
|
||
AffFrozenQuota: summary.AffFrozenQuota,
|
||
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
|
||
}
|
||
// 有效期检查:超过返利有效期后不再产生返利
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary)
|
||
rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
|
||
if rebate <= 0 {
|
||
return 0, nil
|
||
}
|
||
|
||
// 单人上限检查:精确截断到剩余额度
|
||
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)
|
||
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)
|
||
}
|