refactor(affiliate): tighten DI and harden inviter code validation

- Drop SetAffiliateService setters and ProvideAuthService /
  ProvidePaymentService / ProvideUserHandler wrappers in favor of direct
  Wire constructor injection. AffiliateService has no back-edge to
  Auth/Payment/User, so the indirection was never required.
- Change RegisterWithVerification's variadic affiliateCode to a fixed
  parameter; adjust all call sites.
- Validate aff_code length and charset in BindInviterByCode before any
  DB lookup, eliminating timing-side-channel and useless DB roundtrips
  on malformed input.
- Make affiliate cache invalidation synchronous; surface Redis errors
  via the project logger instead of swallowing them in a detached
  goroutine.
- Add an integration test guarding cross-layer tx propagation in
  AccrueQuota and a unit test pinning the aff_code format rules.
This commit is contained in:
shaw
2026-04-25 08:44:18 +08:00
parent 5b5db88550
commit aa8ee33b0a
22 changed files with 188 additions and 157 deletions

View File

@@ -9,6 +9,7 @@ import (
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
var (
@@ -20,8 +21,32 @@ var (
const (
affiliateInviteesLimit = 100
// affiliateCodeFormatLength must stay in sync with repository.affiliateCodeLength.
affiliateCodeFormatLength = 12
)
// affiliateCodeValidChar is a 256-entry lookup table mirroring the charset used
// by the repository's generateAffiliateCode (A-Z minus I/O, digits 2-9).
var affiliateCodeValidChar = func() [256]bool {
var tbl [256]bool
for _, c := range []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") {
tbl[c] = true
}
return tbl
}()
func isValidAffiliateCodeFormat(code string) bool {
if len(code) != affiliateCodeFormatLength {
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"`
@@ -110,6 +135,9 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
if code == "" {
return nil
}
if !isValidAffiliateCodeFormat(code) {
return ErrAffiliateCodeInvalid
}
if s == nil || s.repo == nil {
return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
@@ -279,10 +307,8 @@ func (s *AffiliateService) invalidateAffiliateCaches(ctx context.Context, userID
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
if s.billingCacheService != nil {
go func() {
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
}()
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)
}
}
}