package common import ( "context" "encoding/json" "fmt" "net/url" "strings" "sync" "time" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/pkg/kutt" ) const inviteShortLinkCachePrefix = "cache:invite:short_link:" type inviteLinkCustomData struct { ShareURL string `json:"shareUrl"` Domain string `json:"domain"` } type InviteLinkResolver struct { ctx context.Context svcCtx *svc.ServiceContext createShortLink func(ctx context.Context, targetURL, domain string) (string, error) } func NewInviteLinkResolver(ctx context.Context, svcCtx *svc.ServiceContext) *InviteLinkResolver { resolver := &InviteLinkResolver{ ctx: ctx, svcCtx: svcCtx, } resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) { client := kutt.NewClient(svcCtx.Config.Kutt.ApiURL, svcCtx.Config.Kutt.ApiKey) link, err := client.CreateShortLink(ctx, &kutt.CreateLinkRequest{ Target: targetURL, Reuse: true, Domain: domain, }) if err != nil { return "", err } shortLink := strings.TrimSpace(link.Link) if strings.HasPrefix(shortLink, "http://") { shortLink = strings.Replace(shortLink, "http://", "https://", 1) } return shortLink, nil } return resolver } func (r *InviteLinkResolver) ResolveInviteLink(referCode string) string { normalizedCode := strings.TrimSpace(referCode) if normalizedCode == "" { return "" } longLink := r.buildLongInviteLink(normalizedCode) if !r.canUseKutt() || longLink == "" { return longLink } if cached := r.getCachedShortLink(normalizedCode); cached != "" { return cached } shortLink, err := r.generateShortLinkWithTimeout(normalizedCode, 1500*time.Millisecond) if err != nil || strings.TrimSpace(shortLink) == "" { return longLink } r.cacheShortLink(normalizedCode, shortLink) return shortLink } func (r *InviteLinkResolver) ResolveInviteLinksBatch(referCodes []string, maxGenerate, maxConcurrency int, timeout time.Duration) map[string]string { result := make(map[string]string) uniqueCodes := uniqueReferCodes(referCodes) if len(uniqueCodes) == 0 { return result } for _, referCode := range uniqueCodes { result[referCode] = r.buildLongInviteLink(referCode) } if !r.canUseKutt() { return result } toGenerate := make([]string, 0, len(uniqueCodes)) for _, referCode := range uniqueCodes { if cached := r.getCachedShortLink(referCode); cached != "" { result[referCode] = cached continue } toGenerate = append(toGenerate, referCode) } if maxGenerate > 0 && len(toGenerate) > maxGenerate { toGenerate = toGenerate[:maxGenerate] } if len(toGenerate) == 0 { return result } if maxConcurrency <= 0 { maxConcurrency = 1 } if timeout <= 0 { timeout = 1500 * time.Millisecond } limiter := make(chan struct{}, maxConcurrency) var waitGroup sync.WaitGroup var mutex sync.Mutex for _, referCode := range toGenerate { waitGroup.Add(1) currentCode := referCode go func() { defer waitGroup.Done() limiter <- struct{}{} defer func() { <-limiter }() shortLink, err := r.generateShortLinkWithTimeout(currentCode, timeout) if err != nil || strings.TrimSpace(shortLink) == "" { return } mutex.Lock() result[currentCode] = shortLink mutex.Unlock() r.cacheShortLink(currentCode, shortLink) }() } waitGroup.Wait() return result } func (r *InviteLinkResolver) canUseKutt() bool { if r == nil || r.svcCtx == nil { return false } if !r.svcCtx.Config.Kutt.Enable { return false } if strings.TrimSpace(r.svcCtx.Config.Kutt.ApiURL) == "" || strings.TrimSpace(r.svcCtx.Config.Kutt.ApiKey) == "" { return false } return r.createShortLink != nil } func (r *InviteLinkResolver) resolveShareURLAndDomain() (string, string) { if r == nil || r.svcCtx == nil { return "", "" } shareURL := strings.TrimSpace(r.svcCtx.Config.Kutt.TargetURL) domain := strings.TrimSpace(r.svcCtx.Config.Kutt.Domain) customData := strings.TrimSpace(r.svcCtx.Config.Site.CustomData) if customData == "" { return shareURL, domain } var parsedData inviteLinkCustomData if err := json.Unmarshal([]byte(customData), &parsedData); err != nil { return shareURL, domain } if strings.TrimSpace(parsedData.ShareURL) != "" { shareURL = strings.TrimSpace(parsedData.ShareURL) } if strings.TrimSpace(parsedData.Domain) != "" { domain = strings.TrimSpace(parsedData.Domain) } return shareURL, domain } func (r *InviteLinkResolver) buildLongInviteLink(referCode string) string { normalizedCode := strings.TrimSpace(referCode) if normalizedCode == "" { return "" } shareURL, _ := r.resolveShareURLAndDomain() if shareURL == "" { return "" } parsedURL, err := url.Parse(shareURL) if err != nil { return fallbackLongInviteLink(shareURL, normalizedCode) } queryValues := parsedURL.Query() queryValues.Set("ic", normalizedCode) parsedURL.RawQuery = queryValues.Encode() return parsedURL.String() } func (r *InviteLinkResolver) generateShortLinkWithTimeout(referCode string, timeout time.Duration) (string, error) { longLink := r.buildLongInviteLink(referCode) if longLink == "" { return "", nil } _, domain := r.resolveShareURLAndDomain() const maxRetries = 3 var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { requestCtx := r.ctx var cancel context.CancelFunc if timeout > 0 { requestCtx, cancel = context.WithTimeout(r.ctx, timeout) } shortLink, err := r.createShortLink(requestCtx, longLink, domain) if cancel != nil { cancel() } if err == nil && strings.TrimSpace(shortLink) != "" { return strings.TrimSpace(shortLink), nil } lastErr = err } return "", lastErr } func (r *InviteLinkResolver) getCachedShortLink(referCode string) string { if r == nil || r.svcCtx == nil || r.svcCtx.Redis == nil { return "" } cacheKey := inviteShortLinkCachePrefix + referCode shortLink, err := r.svcCtx.Redis.Get(r.ctx, cacheKey).Result() if err != nil { return "" } return strings.TrimSpace(shortLink) } func (r *InviteLinkResolver) cacheShortLink(referCode, shortLink string) { if r == nil || r.svcCtx == nil || r.svcCtx.Redis == nil { return } if strings.TrimSpace(referCode) == "" || strings.TrimSpace(shortLink) == "" { return } cacheKey := inviteShortLinkCachePrefix + referCode _ = r.svcCtx.Redis.Set(r.ctx, cacheKey, shortLink, 7*24*time.Hour).Err() } func uniqueReferCodes(referCodes []string) []string { uniqueCodes := make([]string, 0, len(referCodes)) seen := make(map[string]struct{}, len(referCodes)) for _, referCode := range referCodes { normalizedCode := strings.TrimSpace(referCode) if normalizedCode == "" { continue } if _, exists := seen[normalizedCode]; exists { continue } seen[normalizedCode] = struct{}{} uniqueCodes = append(uniqueCodes, normalizedCode) } return uniqueCodes } func fallbackLongInviteLink(baseURL, referCode string) string { normalizedBase := strings.TrimSpace(baseURL) normalizedCode := strings.TrimSpace(referCode) if normalizedBase == "" || normalizedCode == "" { return "" } separator := "?" if strings.Contains(normalizedBase, "?") { separator = "&" } trimmedBase := strings.TrimRight(normalizedBase, "?&") return fmt.Sprintf("%s%sic=%s", trimmedBase, separator, url.QueryEscape(normalizedCode)) }