package routes import ( "database/sql" "encoding/json" "fmt" "log/slog" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "github.com/google/uuid" "github.com/gorilla/mux" ) type ProductImageItem struct { ID int64 `json:"id"` FileName string `json:"file_name"` FileSize int64 `json:"file_size"` Storage string `json:"storage_path"` ContentURL string `json:"content_url"` UUID string `json:"uuid,omitempty"` ThumbURL string `json:"thumb_url,omitempty"` FullURL string `json:"full_url,omitempty"` StoredInDB bool `json:"stored_in_db,omitempty"` } type ProductImageBatchItem struct { Key string `json:"key"` Code string `json:"code"` Dim1 string `json:"dim1"` Dim3 string `json:"dim3"` Limit int `json:"limit,omitempty"` } type ProductImageBatchRequest struct { Items []ProductImageBatchItem `json:"items"` } type ProductImageBatchResponseItem struct { Key string `json:"key"` Images []ProductImageItem `json:"images"` } var uuidPattern = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) func normalizeDimParam(v string) string { s := strings.TrimSpace(v) if s == "" || s == "0" { return "" } return s } func uniqueNonEmpty(items ...string) []string { out := make([]string, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, it := range items { v := normalizeDimParam(it) if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) } return out } func buildNameLikePatterns(token string) []string { t := strings.ToUpper(strings.TrimSpace(token)) if t == "" { return nil } return []string{ "% " + t + " %", "%-" + t + "-%", "%-" + t + "_%", "%_" + t + "_%", "%(" + t + ")%", t + " %", } } func resolveDimvalFromFileNameToken(pg *sql.DB, column, token string) string { patterns := buildNameLikePatterns(token) if len(patterns) == 0 { return "" } query := fmt.Sprintf(` SELECT x.dimv FROM ( SELECT COALESCE(%s::text, '') AS dimv, COUNT(*) AS cnt FROM dfblob WHERE src_table='mmitem' AND typ='img' AND COALESCE(%s::text, '') <> '' AND ( UPPER(COALESCE(file_name,'')) LIKE $1 OR UPPER(COALESCE(file_name,'')) LIKE $2 OR UPPER(COALESCE(file_name,'')) LIKE $3 OR UPPER(COALESCE(file_name,'')) LIKE $4 OR UPPER(COALESCE(file_name,'')) LIKE $5 OR UPPER(COALESCE(file_name,'')) LIKE $6 ) GROUP BY COALESCE(%s::text, '') ) x ORDER BY x.cnt DESC, x.dimv LIMIT 1 `, column, column, column) var v string if err := pg.QueryRow(query, patterns[0], patterns[1], patterns[2], patterns[3], patterns[4], patterns[5], ).Scan(&v); err != nil { return "" } return normalizeDimParam(v) } func extractImageUUID(storagePath, fileName string) string { if m := uuidPattern.FindString(storagePath); m != "" { return strings.ToLower(m) } if m := uuidPattern.FindString(fileName); m != "" { return strings.ToLower(m) } return "" } func enrichProductImageItem(it *ProductImageItem) { if it == nil { return } if u := extractImageUUID(it.Storage, it.FileName); u != "" { it.UUID = u it.ThumbURL = "/uploads/image/t300/" + u + ".jpg" it.FullURL = "/uploads/image/" + u + ".jpg" } if it.StoredInDB { it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) return } if resolved, _ := resolveStoragePath(it.Storage); resolved != "" { it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) } } // POST /api/product-images/batch func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { reqID := strings.TrimSpace(r.Header.Get("X-Request-ID")) if reqID == "" { reqID = uuid.NewString() } w.Header().Set("X-Request-ID", reqID) w.Header().Set("Content-Type", "application/json; charset=utf-8") var req ProductImageBatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Gecersiz gorsel batch istegi: "+err.Error(), http.StatusBadRequest) return } if len(req.Items) > 300 { req.Items = req.Items[:300] } cleanItems := make([]ProductImageBatchItem, 0, len(req.Items)) seen := map[string]bool{} for _, item := range req.Items { item.Key = strings.TrimSpace(item.Key) item.Code = strings.TrimSpace(item.Code) item.Dim1 = normalizeDimParam(item.Dim1) item.Dim3 = normalizeDimParam(item.Dim3) if item.Key == "" { item.Key = item.Code + "|" + item.Dim1 + "|" + item.Dim3 } if item.Code == "" || seen[item.Key] { continue } seen[item.Key] = true cleanItems = append(cleanItems, item) } if len(cleanItems) == 0 { _ = json.NewEncoder(w).Encode([]ProductImageBatchResponseItem{}) return } payload, err := json.Marshal(cleanItems) if err != nil { http.Error(w, "Gorsel batch hazirlanamadi: "+err.Error(), http.StatusInternalServerError) return } rows, err := pg.Query(` WITH req AS ( SELECT * FROM jsonb_to_recordset($1::jsonb) AS x(key text, code text, dim1 text, dim3 text) ), matched_item AS ( SELECT DISTINCT ON (r.key) r.key, r.code, r.dim1, r.dim3, m.id AS mmitem_id FROM req r JOIN mmitem m ON UPPER(REPLACE(COALESCE(m.code,''), ' ', '')) = UPPER(REPLACE(COALESCE(r.code,''), ' ', '')) OR UPPER(REPLACE(REGEXP_REPLACE(COALESCE(m.code,''), '^.*-', ''), ' ', '')) = UPPER(REPLACE(REGEXP_REPLACE(COALESCE(r.code,''), '^.*-', ''), ' ', '')) ORDER BY r.key, m.id ), ranked AS ( SELECT mi.key, b.id, COALESCE(b.file_name,'') AS file_name, COALESCE(b.file_size,0) AS file_size, COALESCE(b.storage_path,'') AS storage_path, COALESCE(b.stored_in_db,false) AS stored_in_db, ROW_NUMBER() OVER ( PARTITION BY mi.key ORDER BY CASE WHEN mi.dim1 <> '' AND mi.dim3 <> '' AND COALESCE(b.dimval1::text,'') = mi.dim1 AND COALESCE(b.dimval3::text,'') = mi.dim3 THEN 0 WHEN mi.dim1 <> '' AND COALESCE(b.dimval1::text,'') = mi.dim1 AND COALESCE(b.dimval3::text,'') = '' THEN 1 WHEN COALESCE(b.dimval1::text,'') = '' AND COALESCE(b.dimval3::text,'') = '' THEN 2 ELSE 3 END, COALESCE(b.sort_order,999999), b.id ) AS rn FROM matched_item mi JOIN dfblob b ON b.src_table='mmitem' AND b.typ='img' AND b.src_id=mi.mmitem_id ) SELECT key, id, file_name, file_size, storage_path, stored_in_db FROM ranked WHERE rn <= 1 ORDER BY key, rn `, string(payload)) if err != nil { slog.Error("product_images.batch.query_failed", "req_id", reqID, "err", err.Error()) http.Error(w, "Gorsel batch sorgu hatasi: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() grouped := map[string][]ProductImageItem{} for rows.Next() { var key string var it ProductImageItem if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage, &it.StoredInDB); err != nil { continue } enrichProductImageItem(&it) grouped[key] = append(grouped[key], it) } if err := rows.Err(); err != nil { http.Error(w, "Gorsel batch okuma hatasi: "+err.Error(), http.StatusInternalServerError) return } out := make([]ProductImageBatchResponseItem, 0, len(cleanItems)) for _, item := range cleanItems { out = append(out, ProductImageBatchResponseItem{ Key: item.Key, Images: grouped[item.Key], }) } _ = json.NewEncoder(w).Encode(out) } } // GET /api/product-images?code=...&dim1=...&dim3=... func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { reqID := strings.TrimSpace(r.Header.Get("X-Request-ID")) if reqID == "" { reqID = uuid.NewString() } w.Header().Set("X-Request-ID", reqID) code := strings.TrimSpace(r.URL.Query().Get("code")) dim1 := strings.TrimSpace(r.URL.Query().Get("dim1")) if dim1 == "" { dim1 = strings.TrimSpace(r.URL.Query().Get("color")) } dim3 := strings.TrimSpace(r.URL.Query().Get("dim3")) if dim3 == "" { dim3 = strings.TrimSpace(r.URL.Query().Get("yaka")) } if dim3 == "" { dim3 = strings.TrimSpace(r.URL.Query().Get("renk2")) } dim1ID := strings.TrimSpace(r.URL.Query().Get("dim1_id")) if dim1ID == "" { dim1ID = strings.TrimSpace(r.URL.Query().Get("itemdim1")) } dim3ID := strings.TrimSpace(r.URL.Query().Get("dim3_id")) if dim3ID == "" { dim3ID = strings.TrimSpace(r.URL.Query().Get("itemdim3")) } if code == "" { http.Error(w, "Eksik parametre: code gerekli", http.StatusBadRequest) return } // Rule: code -> mmitem.id var mmItemID int64 err := pg.QueryRow(` SELECT id FROM mmitem WHERE UPPER(REPLACE(COALESCE(code,''), ' ', '')) = UPPER(REPLACE(COALESCE($1,''), ' ', '')) ORDER BY id LIMIT 1 `, code).Scan(&mmItemID) if err == sql.ErrNoRows { err = pg.QueryRow(` SELECT id FROM mmitem WHERE UPPER(REPLACE(REGEXP_REPLACE(COALESCE(code,''), '^.*-', ''), ' ', '')) = UPPER(REPLACE(REGEXP_REPLACE(COALESCE($1,''), '^.*-', ''), ' ', '')) ORDER BY id LIMIT 1 `, code).Scan(&mmItemID) } if err != nil { if err == sql.ErrNoRows { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode([]ProductImageItem{}) return } http.Error(w, "Gorsel sorgu hatasi: "+err.Error(), http.StatusInternalServerError) return } runQuery := func(dim1Filter, dim3Filter string) ([]ProductImageItem, error) { query := ` SELECT id, COALESCE(file_name,'') AS file_name, COALESCE(file_size,0) AS file_size, COALESCE(storage_path,'') AS storage_path, COALESCE(stored_in_db,false) AS stored_in_db FROM dfblob WHERE typ='img' AND src_table='mmitem' AND src_id=$1` args := []interface{}{mmItemID} argPos := 2 if dim1Filter != "" { query += fmt.Sprintf(" AND COALESCE(dimval1::text,'') = $%d", argPos) args = append(args, dim1Filter) argPos++ if dim3Filter != "" { query += fmt.Sprintf(" AND COALESCE(dimval3::text,'') = $%d", argPos) args = append(args, dim3Filter) argPos++ } } query += ` ORDER BY COALESCE(sort_order,999999), id` rows, err := pg.Query(query, args...) if err != nil { return nil, err } defer rows.Close() items := make([]ProductImageItem, 0, 16) for rows.Next() { var it ProductImageItem if err := rows.Scan(&it.ID, &it.FileName, &it.FileSize, &it.Storage, &it.StoredInDB); err != nil { continue } enrichProductImageItem(&it) items = append(items, it) } if err := rows.Err(); err != nil { return nil, err } return items, nil } // Rule: // dim1!=0 && dim3!=0 => dimval1=dim1 AND dimval3=dim3 // dim1!=0 && dim3==0 => dimval1=dim1 // dim1==0 && dim3==0 => generic photos // // Frontend'den yanlis dim id gelebildigi icin: // 1) once *_id ile deneriz // 2) sonuc yoksa kod degeriyle fallback deneriz. resolvedDim1ID := normalizeDimParam(dim1ID) if resolvedDim1ID == "" && normalizeDimParam(dim1) != "" { resolvedDim1ID = resolveDimvalFromFileNameToken(pg, "dimval1", dim1) } resolvedDim3ID := normalizeDimParam(dim3ID) if resolvedDim3ID == "" && normalizeDimParam(dim3) != "" { resolvedDim3ID = resolveDimvalFromFileNameToken(pg, "dimval3", dim3) } dim1Candidates := uniqueNonEmpty(resolvedDim1ID, dim1ID, dim1) if len(dim1Candidates) == 0 { dim1Candidates = []string{""} } dim3Candidates := uniqueNonEmpty(resolvedDim3ID, dim3ID, dim3) items := make([]ProductImageItem, 0, 16) selectedDim1 := "" selectedDim3 := "" var queryErr error for _, d1 := range dim1Candidates { localDim3Candidates := []string{""} if d1 != "" { if len(dim3Candidates) > 0 { localDim3Candidates = append([]string{}, dim3Candidates...) localDim3Candidates = append(localDim3Candidates, "") } } for _, d3 := range localDim3Candidates { var runErr error items, runErr = runQuery(d1, d3) if runErr != nil { queryErr = runErr continue } if len(items) > 0 { selectedDim1 = d1 selectedDim3 = d3 break } if selectedDim1 == "" && selectedDim3 == "" { selectedDim1 = d1 selectedDim3 = d3 } } if len(items) > 0 { break } } if queryErr != nil && len(items) == 0 { slog.Error("product_images.list.query_failed", "req_id", reqID, "code", code, "dim1", dim1, "dim1_id", dim1ID, "dim3", dim3, "dim3_id", dim3ID, "err", queryErr.Error(), ) http.Error(w, "Gorsel sorgu hatasi: "+queryErr.Error(), http.StatusInternalServerError) return } slog.Info("product_images.list.ok", "req_id", reqID, "code", code, "dim1", dim1, "dim1_id", dim1ID, "resolved_dim1_id", resolvedDim1ID, "dim3", dim3, "dim3_id", dim3ID, "resolved_dim3_id", resolvedDim3ID, "selected_dim1", selectedDim1, "selected_dim3", selectedDim3, "count", len(items), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(items) } } // GET /api/product-images/{id}/content func GetProductImageContentHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { reqID := strings.TrimSpace(r.Header.Get("X-Request-ID")) if reqID == "" { reqID = uuid.NewString() } w.Header().Set("X-Request-ID", reqID) idStr := mux.Vars(r)["id"] id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { http.Error(w, "Gecersiz gorsel id", http.StatusBadRequest) return } var ( fileName string storagePath string storedInDB bool binData []byte ) err = pg.QueryRow(` SELECT COALESCE(file_name,''), COALESCE(storage_path,''), COALESCE(stored_in_db,false), bin FROM dfblob WHERE id = $1 AND typ = 'img' `, id).Scan(&fileName, &storagePath, &storedInDB, &binData) if err != nil { if err == sql.ErrNoRows { http.NotFound(w, r) return } http.Error(w, "Gorsel okunamadi: "+err.Error(), http.StatusInternalServerError) return } if storedInDB && len(binData) > 0 { w.Header().Set("Content-Type", http.DetectContentType(binData)) w.Header().Set("Cache-Control", "public, max-age=3600") _, _ = w.Write(binData) return } resolved, _ := resolveStoragePath(storagePath) if resolved == "" { resolved, _ = resolveStoragePath(fileName) } if resolved == "" { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "public, max-age=3600") http.ServeFile(w, r, resolved) } } func resolveStoragePath(storagePath string) (string, []string) { raw := strings.TrimSpace(storagePath) if raw == "" { return "", nil } if i := strings.Index(raw, "?"); i >= 0 { raw = raw[:i] } raw = strings.ReplaceAll(raw, "\\", "/") if scheme := strings.Index(raw, "://"); scheme >= 0 { rest := raw[scheme+3:] if i := strings.Index(rest, "/"); i >= 0 { raw = rest[i:] } } raw = strings.TrimPrefix(raw, "./") raw = strings.TrimPrefix(raw, "/") raw = strings.TrimPrefix(raw, "uploads/") raw = filepath.ToSlash(filepath.Clean(raw)) relUploads := filepath.FromSlash(filepath.Join("uploads", raw)) candidates := []string{ filepath.Clean(storagePath), filepath.FromSlash(filepath.Clean(strings.TrimPrefix(storagePath, "/"))), filepath.FromSlash(filepath.Clean(raw)), relUploads, filepath.Join(".", relUploads), filepath.Join("..", relUploads), filepath.Join("..", "..", relUploads), } if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" { candidates = append(candidates, filepath.Join(root, raw), filepath.Join(root, relUploads), filepath.Join(root, "uploads", raw), ) } for _, p := range candidates { if p == "" { continue } if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, candidates } } return "", candidates }