package routes import ( "database/sql" "encoding/json" "fmt" "log/slog" "net/http" "os" "path/filepath" "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"` } func tokenizeImageFileName(fileName string) []string { up := strings.ToUpper(strings.TrimSpace(fileName)) if up == "" { return nil } return strings.FieldsFunc(up, func(r rune) bool { isUpper := r >= 'A' && r <= 'Z' isDigit := r >= '0' && r <= '9' if isUpper || isDigit || r == '_' { return false } return true }) } func isAllDigits(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } func imageFileHasDim3Pattern(fileName string) bool { tokens := tokenizeImageFileName(fileName) for _, t := range tokens { parts := strings.SplitN(t, "_", 2) if len(parts) != 2 { continue } if len(parts[0]) == 3 && isAllDigits(parts[0]) && isAllDigits(parts[1]) { return true } } return false } func firstThreeDigitToken(tokens []string) string { for _, t := range tokens { base := t if idx := strings.Index(base, "_"); idx >= 0 { base = base[:idx] } if len(base) == 3 && isAllDigits(base) { return base } } return "" } func imageFileMatches(fileName, dim1, dim3 string) bool { dim1 = strings.ToUpper(strings.TrimSpace(dim1)) dim3 = strings.ToUpper(strings.TrimSpace(dim3)) if dim1 == "" && dim3 == "" { return true } tokens := tokenizeImageFileName(fileName) if len(tokens) == 0 { return false } matchesToken := func(token, target string) bool { if token == target { return true } // "002" filtresi, dosya adindaki "002_1" gibi varyantlari da yakalamali. if len(target) == 3 && isAllDigits(target) && strings.HasPrefix(token, target+"_") { return true } return false } hasToken := func(target string) bool { if target == "" { return true } for _, t := range tokens { if matchesToken(t, target) { return true } } return false } // dim1 filtresi varsa, dosya adindaki primary renk token'i farkliysa eslesme sayma. // Ornek: "017--002_1" dosyasi dim1=002 icin degil, primary=017 oldugu icin dislanmali. if dim1 != "" { primary := firstThreeDigitToken(tokens) if primary != "" && primary != dim1 { return false } } return hasToken(dim1) && hasToken(dim3) } // // LIST PRODUCT IMAGES // // 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")) } if code == "" { slog.Warn("product_images.list.bad_request", "req_id", reqID, "path", r.URL.Path, "query", r.URL.RawQuery, "reason", "missing_code", ) http.Error(w, "Eksik parametre: code gerekli", http.StatusBadRequest) return } query := ` SELECT b.id, b.file_name, COALESCE(b.file_size,0) AS file_size, COALESCE(b.storage_path,'') AS storage_path, UPPER(COALESCE(b.dimval1::text,'')) AS dimval1, UPPER(COALESCE(b.dimval2::text,'')) AS dimval2, UPPER(COALESCE(b.dimval3::text,'')) AS dimval3 FROM dfblob b JOIN mmitem i ON i.id = b.src_id WHERE b.typ = 'img' AND b.src_table = 'mmitem' AND ( -- exact code match (spaces ignored) UPPER(REPLACE(COALESCE(i.code,''), ' ', '')) = UPPER(REPLACE(COALESCE($1,''), ' ', '')) -- core-code fallback only when there is no exact item code for the input OR ( NOT EXISTS ( SELECT 1 FROM mmitem ix WHERE UPPER(REPLACE(COALESCE(ix.code,''), ' ', '')) = UPPER(REPLACE(COALESCE($1,''), ' ', '')) ) AND UPPER(REPLACE(REGEXP_REPLACE(COALESCE(i.code,''), '^.*-', ''), ' ', '')) = UPPER(REPLACE(REGEXP_REPLACE(COALESCE($1,''), '^.*-', ''), ' ', '')) ) ) ORDER BY COALESCE(b.sort_order,999999), b.zlins_dttm DESC, b.id DESC ` rows, err := pg.Query(query, code) if err != nil { slog.Error("product_images.list.query_failed", "req_id", reqID, "code", code, "dim1", dim1, "dim3", dim3, "err", err.Error(), ) http.Error(w, "Gorsel sorgu hatasi: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() items := make([]ProductImageItem, 0, 16) rowDim1ByID := make(map[int64]string, 16) matchedByDim := make([]ProductImageItem, 0, 16) matchedByName := make([]ProductImageItem, 0, 16) matchedByNameDim1Only := make([]ProductImageItem, 0, 16) dim1Upper := strings.ToUpper(strings.TrimSpace(dim1)) dim3Upper := strings.ToUpper(strings.TrimSpace(dim3)) for rows.Next() { var it ProductImageItem var rowDim1, rowDim2, rowDim3 string if err := rows.Scan( &it.ID, &it.FileName, &it.FileSize, &it.Storage, &rowDim1, &rowDim2, &rowDim3, ); err != nil { continue } it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) items = append(items, it) rowDim1ByID[it.ID] = strings.TrimSpace(rowDim1) dimMatched := true if dim1Upper != "" { // Bazı eski kayıtlarda dimval1 gerçek renk kodu yerine numeric id tutulmuş olabilir. // Bu yüzden dimval karşılaştırması yardımcı; asıl fallback file_name token eşleşmesidir. dimMatched = dimMatched && (rowDim1 == dim1Upper) } if dim3Upper != "" { dimMatched = dimMatched && (rowDim3 == dim3Upper || rowDim2 == dim3Upper) } if dimMatched { matchedByDim = append(matchedByDim, it) } if imageFileMatches(it.FileName, dim1Upper, dim3Upper) { matchedByName = append(matchedByName, it) } if dim1Upper != "" && imageFileMatches(it.FileName, dim1Upper, "") { matchedByNameDim1Only = append(matchedByNameDim1Only, it) } } if dim1Upper != "" || dim3Upper != "" { if dim3Upper != "" { // dim3 verildiginde kesin varyant listesi oncelikli. if len(matchedByName) > 0 { items = matchedByName } else if len(matchedByDim) > 0 { items = matchedByDim } else if len(matchedByNameDim1Only) > 0 { // dim3 pattern'i olmayan legacy tek-renk isimlerde dim1-only fallback. hasDim3Pattern := false for _, it := range matchedByNameDim1Only { if imageFileHasDim3Pattern(it.FileName) { hasDim3Pattern = true break } } if !hasDim3Pattern { targetDimval1 := make(map[string]struct{}, 4) for _, it := range matchedByNameDim1Only { if dv := rowDim1ByID[it.ID]; dv != "" { targetDimval1[dv] = struct{}{} } } clustered := make([]ProductImageItem, 0, len(items)) for _, it := range items { if _, ok := targetDimval1[rowDim1ByID[it.ID]]; ok { clustered = append(clustered, it) } } items = clustered } else { items = []ProductImageItem{} } } else { items = []ProductImageItem{} } } else { targetDimval1 := make(map[string]struct{}, 4) for _, it := range matchedByName { if dv := rowDim1ByID[it.ID]; dv != "" { targetDimval1[dv] = struct{}{} } } if len(targetDimval1) == 0 { for _, it := range matchedByNameDim1Only { if dv := rowDim1ByID[it.ID]; dv != "" { targetDimval1[dv] = struct{}{} } } } if len(targetDimval1) > 0 { clustered := make([]ProductImageItem, 0, len(items)) for _, it := range items { if _, ok := targetDimval1[rowDim1ByID[it.ID]]; ok { clustered = append(clustered, it) } } items = clustered } else if len(matchedByDim) > 0 { items = matchedByDim } else if len(matchedByName) > 0 { items = matchedByName } else if len(matchedByNameDim1Only) > 0 { items = matchedByNameDim1Only } else { items = []ProductImageItem{} } } } slog.Info("product_images.list.ok", "req_id", reqID, "code", code, "dim1", dim1, "dim3", dim3, "count", len(items), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(items) } } // // GET IMAGE CONTENT // // 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 { slog.Warn("product_images.content.bad_request", "req_id", reqID, "id_raw", idStr, "path", r.URL.Path, "reason", "invalid_id", ) 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 { slog.Warn("product_images.content.not_found_row", "req_id", reqID, "id", id, ) http.NotFound(w, r) return } slog.Error("product_images.content.query_failed", "req_id", reqID, "id", id, "err", err.Error(), ) http.Error(w, "Gorsel okunamadi: "+err.Error(), http.StatusInternalServerError) return } // DB içinde binary saklıysa 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, tried := resolveStoragePath(storagePath) if resolved == "" { slog.Warn("product_images.content.file_not_found", "req_id", reqID, "id", id, "file_name", fileName, "storage_path", storagePath, "tried", tried, ) http.NotFound(w, r) return } w.Header().Set("Cache-Control", "public, max-age=3600") http.ServeFile(w, r, resolved) } } // // FILE PATH RESOLVER // 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 }