package agent import ( "context" "crypto/sha256" "fmt" "io" "log" "net/http" "strings" "sync" "time" "github.com/gooseek/backend/pkg/storage" ) const ( photoCachePrefix = "poi-photos" maxPhotoSize = 5 * 1024 * 1024 // 5MB photoDownloadTimeout = 8 * time.Second ) type PhotoCacheService struct { storage *storage.MinioStorage client *http.Client mu sync.RWMutex memCache map[string]string // sourceURL -> publicURL (in-memory for current session) } func NewPhotoCacheService(s *storage.MinioStorage) *PhotoCacheService { return &PhotoCacheService{ storage: s, client: &http.Client{ Timeout: photoDownloadTimeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return http.ErrUseLastResponse } return nil }, }, memCache: make(map[string]string, 128), } } func (pc *PhotoCacheService) CachePhoto(ctx context.Context, citySlug, sourceURL string) (string, error) { pc.mu.RLock() if cached, ok := pc.memCache[sourceURL]; ok { pc.mu.RUnlock() return cached, nil } pc.mu.RUnlock() key := pc.buildKey(citySlug, sourceURL) exists, err := pc.storage.ObjectExists(ctx, key) if err == nil && exists { publicURL := pc.storage.GetPublicURL(key) if publicURL != "" { pc.mu.Lock() pc.memCache[sourceURL] = publicURL pc.mu.Unlock() return publicURL, nil } } body, contentType, err := pc.downloadImage(ctx, sourceURL) if err != nil { return "", fmt.Errorf("download failed: %w", err) } defer body.Close() limitedReader := io.LimitReader(body, maxPhotoSize) result, err := pc.storage.UploadWithKey(ctx, key, limitedReader, -1, contentType) if err != nil { return "", fmt.Errorf("upload to minio failed: %w", err) } publicURL := pc.storage.GetPublicURL(result.Key) if publicURL == "" { return "", fmt.Errorf("no public URL configured for storage") } pc.mu.Lock() pc.memCache[sourceURL] = publicURL pc.mu.Unlock() return publicURL, nil } func (pc *PhotoCacheService) CachePhotoBatch(ctx context.Context, citySlug string, sourceURLs []string) []string { results := make([]string, len(sourceURLs)) var wg sync.WaitGroup for i, url := range sourceURLs { wg.Add(1) go func(idx int, srcURL string) { defer wg.Done() cacheCtx, cancel := context.WithTimeout(ctx, photoDownloadTimeout+2*time.Second) defer cancel() cached, err := pc.CachePhoto(cacheCtx, citySlug, srcURL) if err != nil { log.Printf("[photo-cache] failed to cache %s: %v", truncateURL(srcURL), err) results[idx] = srcURL return } results[idx] = cached }(i, url) } wg.Wait() return results } func (pc *PhotoCacheService) buildKey(citySlug, sourceURL string) string { hash := sha256.Sum256([]byte(sourceURL)) hashStr := fmt.Sprintf("%x", hash[:12]) ext := ".jpg" lower := strings.ToLower(sourceURL) switch { case strings.Contains(lower, ".png"): ext = ".png" case strings.Contains(lower, ".webp"): ext = ".webp" case strings.Contains(lower, ".gif"): ext = ".gif" } slug := sanitizeSlug(citySlug) return fmt.Sprintf("%s/%s/%s%s", photoCachePrefix, slug, hashStr, ext) } func (pc *PhotoCacheService) downloadImage(ctx context.Context, url string) (io.ReadCloser, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, "", err } req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; GooSeek/1.0)") req.Header.Set("Accept", "image/*") resp, err := pc.client.Do(req) if err != nil { return nil, "", err } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode) } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "image/jpeg" } if !strings.HasPrefix(contentType, "image/") { resp.Body.Close() return nil, "", fmt.Errorf("not an image: %s", contentType) } return resp.Body, contentType, nil } func sanitizeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) s = strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { return r } if (r >= 0x0400 && r <= 0x04FF) || r == '_' { return r } if r == ' ' { return '-' } return -1 }, s) for strings.Contains(s, "--") { s = strings.ReplaceAll(s, "--", "-") } return strings.Trim(s, "-") } func truncateURL(u string) string { if len(u) > 80 { return u[:80] + "..." } return u }