Files
bssapp/svc/routes/product_images.go
T

594 lines
15 KiB
Go

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"`
}
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 ""
}
// 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,
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
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); err != nil {
continue
}
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
}
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
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); err != nil {
continue
}
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
}
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 == "" {
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
}