Merge remote-tracking branch 'origin/master'
This commit is contained in:
59
svc/main.go
59
svc/main.go
@@ -10,6 +10,7 @@ import (
|
|||||||
"bssapp-backend/routes"
|
"bssapp-backend/routes"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -164,6 +165,7 @@ InitRoutes — FULL V3 (Method-aware) PERMISSION EDITION
|
|||||||
func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router {
|
func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router {
|
||||||
|
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
mountUploads(r)
|
||||||
mountSPA(r)
|
mountSPA(r)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -582,7 +584,7 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/product-images/{id}/content", "GET",
|
"/api/product-images/{id}/content", "GET",
|
||||||
"order", "view",
|
"order", "view",
|
||||||
wrapV3(routes.GetProductImageContentHandler(pgDB)),
|
http.HandlerFunc(routes.GetProductImageContentHandler(pgDB)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -642,7 +644,35 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupSlog() {
|
||||||
|
level := new(slog.LevelVar)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) {
|
||||||
|
case "debug":
|
||||||
|
level.Set(slog.LevelDebug)
|
||||||
|
case "warn", "warning":
|
||||||
|
level.Set(slog.LevelWarn)
|
||||||
|
case "error":
|
||||||
|
level.Set(slog.LevelError)
|
||||||
|
default:
|
||||||
|
level.Set(slog.LevelInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
})
|
||||||
|
slog.SetDefault(slog.New(handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountUploads(r *mux.Router) {
|
||||||
|
root := uploadsRootDir()
|
||||||
|
log.Printf("🖼️ uploads root: %s", root)
|
||||||
|
r.PathPrefix("/uploads/").Handler(
|
||||||
|
http.StripPrefix("/uploads/", http.FileServer(http.Dir(root))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
setupSlog()
|
||||||
log.Println("🔥🔥🔥 BSSAPP BACKEND STARTED — LOGIN ROUTE SHOULD EXIST 🔥🔥🔥")
|
log.Println("🔥🔥🔥 BSSAPP BACKEND STARTED — LOGIN ROUTE SHOULD EXIST 🔥🔥🔥")
|
||||||
|
|
||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
@@ -807,3 +837,30 @@ func uiRootDir() string {
|
|||||||
|
|
||||||
return "../ui/dist/spa"
|
return "../ui/dist/spa"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uploadsRootDir() string {
|
||||||
|
if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" {
|
||||||
|
candidates := []string{
|
||||||
|
root,
|
||||||
|
filepath.Join(root, "uploads"),
|
||||||
|
}
|
||||||
|
for _, d := range candidates {
|
||||||
|
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := []string{
|
||||||
|
"./uploads",
|
||||||
|
"../uploads",
|
||||||
|
"../../uploads",
|
||||||
|
}
|
||||||
|
for _, d := range candidates {
|
||||||
|
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "./uploads"
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ type ttlCache struct {
|
|||||||
m map[string]cacheItem
|
m map[string]cacheItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type routeMeta struct {
|
||||||
|
module string
|
||||||
|
action string
|
||||||
|
}
|
||||||
|
|
||||||
|
var routeMetaCache sync.Map
|
||||||
|
|
||||||
|
var routeMetaFallback = map[string]routeMeta{
|
||||||
|
"GET /api/product-images": {module: "order", action: "view"},
|
||||||
|
"GET /api/product-images/{id}/content": {module: "order", action: "view"},
|
||||||
|
"GET /api/product-stock-query-by-attributes": {module: "order", action: "view"},
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// 🌍 GLOBAL SCOPE CACHE (for invalidation)
|
// 🌍 GLOBAL SCOPE CACHE (for invalidation)
|
||||||
// =====================================================
|
// =====================================================
|
||||||
@@ -912,35 +925,40 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// 3️⃣ ROUTE LOOKUP (path + method)
|
// 3️⃣ ROUTE LOOKUP (path + method) with cache+fallback
|
||||||
// =====================================================
|
// =====================================================
|
||||||
var module, action string
|
var module, action string
|
||||||
|
routeKey := r.Method + " " + pathTemplate
|
||||||
|
|
||||||
err = pg.QueryRow(`
|
if cached, ok := routeMetaCache.Load(routeKey); ok {
|
||||||
SELECT module_code, action
|
meta := cached.(routeMeta)
|
||||||
FROM mk_sys_routes
|
module, action = meta.module, meta.action
|
||||||
WHERE path = $1
|
} else {
|
||||||
AND method = $2
|
err = pg.QueryRow(`
|
||||||
`,
|
SELECT module_code, action
|
||||||
pathTemplate,
|
FROM mk_sys_routes
|
||||||
r.Method,
|
WHERE path = $1
|
||||||
).Scan(&module, &action)
|
AND method = $2
|
||||||
|
`,
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
log.Printf(
|
|
||||||
"❌ AUTHZ: route not registered: %s %s",
|
|
||||||
r.Method,
|
|
||||||
pathTemplate,
|
pathTemplate,
|
||||||
)
|
r.Method,
|
||||||
|
).Scan(&module, &action)
|
||||||
|
|
||||||
if pathTemplate == "/api/password/change" {
|
if err == nil {
|
||||||
http.Error(w, "password change route permission not found", http.StatusForbidden)
|
routeMetaCache.Store(routeKey, routeMeta{module: module, action: action})
|
||||||
|
} else if fb, ok := routeMetaFallback[routeKey]; ok {
|
||||||
|
module, action = fb.module, fb.action
|
||||||
|
routeMetaCache.Store(routeKey, fb)
|
||||||
|
log.Printf("⚠️ AUTHZ: route lookup fallback used: %s", routeKey)
|
||||||
|
} else if err == sql.ErrNoRows {
|
||||||
|
log.Printf("❌ AUTHZ: route not registered: %s %s", r.Method, pathTemplate)
|
||||||
|
http.Error(w, "route permission not found", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
log.Printf("❌ AUTHZ: route lookup db error: %s %s err=%v", r.Method, pathTemplate, err)
|
||||||
|
http.Error(w, "permission lookup failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Error(w, "route permission not found", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ var publicPaths = []string{
|
|||||||
"/api/auth/refresh",
|
"/api/auth/refresh",
|
||||||
"/api/password/forgot",
|
"/api/password/forgot",
|
||||||
"/api/password/reset",
|
"/api/password/reset",
|
||||||
|
"/api/product-images/",
|
||||||
|
"/uploads/",
|
||||||
}
|
}
|
||||||
|
|
||||||
func GlobalAuthMiddleware(db any, next http.Handler) http.Handler {
|
func GlobalAuthMiddleware(db any, next http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +60,9 @@ func RequestLogger(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
log.Printf("⬅️ %s %s | status=%d | %s", r.Method, r.URL.Path, sw.status, time.Since(start))
|
log.Printf("⬅️ %s %s | status=%d | %s", r.Method, r.URL.Path, sw.status, time.Since(start))
|
||||||
|
|
||||||
|
// High-frequency endpoints: skip route_access audit to reduce DB/log pressure.
|
||||||
|
skipAudit := r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/product-images")
|
||||||
|
|
||||||
// ---------- AUDIT (route_access) ----------
|
// ---------- AUDIT (route_access) ----------
|
||||||
al := auditlog.ActivityLog{
|
al := auditlog.ActivityLog{
|
||||||
ActionType: "route_access",
|
ActionType: "route_access",
|
||||||
@@ -95,9 +99,9 @@ func RequestLogger(next http.Handler) http.Handler {
|
|||||||
al.ErrorMessage = http.StatusText(sw.status)
|
al.ErrorMessage = http.StatusText(sw.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ ESKİ: auditlog.Write(al)
|
if !skipAudit {
|
||||||
// ✅ YENİ:
|
auditlog.Enqueue(r.Context(), al)
|
||||||
auditlog.Enqueue(r.Context(), al)
|
}
|
||||||
|
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
log.Println("⚠️ LOGGER: claims is NIL")
|
log.Println("⚠️ LOGGER: claims is NIL")
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,10 +26,22 @@ type ProductImageItem struct {
|
|||||||
// GET /api/product-images?code=...&color=...
|
// GET /api/product-images?code=...&color=...
|
||||||
func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc {
|
func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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"))
|
code := strings.TrimSpace(r.URL.Query().Get("code"))
|
||||||
color := strings.TrimSpace(r.URL.Query().Get("color"))
|
color := strings.TrimSpace(r.URL.Query().Get("color"))
|
||||||
|
|
||||||
if code == "" {
|
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)
|
http.Error(w, "Eksik parametre: code gerekli", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -43,14 +57,20 @@ JOIN mmitem i
|
|||||||
ON i.id = b.src_id
|
ON i.id = b.src_id
|
||||||
WHERE b.typ = 'img'
|
WHERE b.typ = 'img'
|
||||||
AND b.src_table = 'mmitem'
|
AND b.src_table = 'mmitem'
|
||||||
AND i.code = $1
|
AND UPPER(i.code) = UPPER($1)
|
||||||
AND ($2 = '' OR b.file_name ILIKE '%' || '-' || $2 || '-%')
|
AND ($2 = '' OR b.file_name ILIKE '%' || '-' || $2 || '-%')
|
||||||
ORDER BY COALESCE(b.sort_order, 999999), b.zlins_dttm DESC, b.id DESC
|
ORDER BY COALESCE(b.sort_order, 999999), b.zlins_dttm DESC, b.id DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := pg.Query(query, code, color)
|
rows, err := pg.Query(query, code, color)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Görsel sorgu hatası: "+err.Error(), http.StatusInternalServerError)
|
slog.Error("product_images.list.query_failed",
|
||||||
|
"req_id", reqID,
|
||||||
|
"code", code,
|
||||||
|
"color", color,
|
||||||
|
"err", err.Error(),
|
||||||
|
)
|
||||||
|
http.Error(w, "Gorsel sorgu hatasi: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -65,6 +85,13 @@ ORDER BY COALESCE(b.sort_order, 999999), b.zlins_dttm DESC, b.id DESC
|
|||||||
items = append(items, it)
|
items = append(items, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Info("product_images.list.ok",
|
||||||
|
"req_id", reqID,
|
||||||
|
"code", code,
|
||||||
|
"color", color,
|
||||||
|
"count", len(items),
|
||||||
|
)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(items)
|
_ = json.NewEncoder(w).Encode(items)
|
||||||
}
|
}
|
||||||
@@ -73,10 +100,22 @@ ORDER BY COALESCE(b.sort_order, 999999), b.zlins_dttm DESC, b.id DESC
|
|||||||
// GET /api/product-images/{id}/content
|
// GET /api/product-images/{id}/content
|
||||||
func GetProductImageContentHandler(pg *sql.DB) http.HandlerFunc {
|
func GetProductImageContentHandler(pg *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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"]
|
idStr := mux.Vars(r)["id"]
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
http.Error(w, "Geçersiz görsel id", http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,47 +138,118 @@ WHERE id = $1
|
|||||||
`, id).Scan(&fileName, &storagePath, &storedInDB, &binData)
|
`, id).Scan(&fileName, &storagePath, &storedInDB, &binData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
|
slog.Warn("product_images.content.not_found_row",
|
||||||
|
"req_id", reqID,
|
||||||
|
"id", id,
|
||||||
|
)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "Görsel okunamadı: "+err.Error(), http.StatusInternalServerError)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if storedInDB && len(binData) > 0 {
|
if storedInDB && len(binData) > 0 {
|
||||||
|
slog.Info("product_images.content.served_from_db",
|
||||||
|
"req_id", reqID,
|
||||||
|
"id", id,
|
||||||
|
"file_name", fileName,
|
||||||
|
"bytes", len(binData),
|
||||||
|
)
|
||||||
w.Header().Set("Content-Type", http.DetectContentType(binData))
|
w.Header().Set("Content-Type", http.DetectContentType(binData))
|
||||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
_, _ = w.Write(binData)
|
_, _ = w.Write(binData)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved := resolveStoragePath(storagePath)
|
resolved, tried := resolveStoragePath(storagePath)
|
||||||
if resolved == "" {
|
if resolved == "" {
|
||||||
|
slog.Warn("product_images.content.file_not_found",
|
||||||
|
"req_id", reqID,
|
||||||
|
"id", id,
|
||||||
|
"stored_in_db", storedInDB,
|
||||||
|
"file_name", fileName,
|
||||||
|
"storage_path", storagePath,
|
||||||
|
"tried", tried,
|
||||||
|
)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Info("product_images.content.served_from_file",
|
||||||
|
"req_id", reqID,
|
||||||
|
"id", id,
|
||||||
|
"file_name", fileName,
|
||||||
|
"storage_path", storagePath,
|
||||||
|
"resolved_path", resolved,
|
||||||
|
)
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
http.ServeFile(w, r, resolved)
|
http.ServeFile(w, r, resolved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveStoragePath(storagePath string) string {
|
func resolveStoragePath(storagePath string) (string, []string) {
|
||||||
raw := strings.TrimSpace(storagePath)
|
raw := strings.TrimSpace(storagePath)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return ""
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL/query temizligi ve platforma uygun normalize
|
||||||
|
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, "/")
|
||||||
|
raw = strings.TrimPrefix(raw, "uploads/")
|
||||||
|
raw = filepath.ToSlash(filepath.Clean(raw))
|
||||||
|
relUploads := filepath.FromSlash(filepath.Join("uploads", raw))
|
||||||
|
rawT300 := raw
|
||||||
|
relUploadsT300 := relUploads
|
||||||
|
if strings.Contains(filepath.ToSlash(relUploads), "uploads/image/") &&
|
||||||
|
!strings.Contains(filepath.ToSlash(relUploads), "uploads/image/t300/") {
|
||||||
|
rawT300 = strings.Replace(filepath.ToSlash(raw), "image/", "image/t300/", 1)
|
||||||
|
relUploadsT300 = filepath.FromSlash(
|
||||||
|
strings.Replace(filepath.ToSlash(relUploads), "uploads/image/", "uploads/image/t300/", 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
filepath.Clean(storagePath),
|
filepath.Clean(storagePath),
|
||||||
filepath.Clean(raw),
|
filepath.FromSlash(filepath.Clean(strings.TrimPrefix(storagePath, "/"))),
|
||||||
filepath.Join(".", raw),
|
filepath.FromSlash(filepath.Clean(raw)),
|
||||||
filepath.Join("..", raw),
|
relUploads,
|
||||||
|
relUploadsT300,
|
||||||
|
filepath.Join(".", relUploads),
|
||||||
|
filepath.Join(".", relUploadsT300),
|
||||||
|
filepath.Join("..", relUploads),
|
||||||
|
filepath.Join("..", relUploadsT300),
|
||||||
|
filepath.Join("..", "..", relUploads),
|
||||||
|
filepath.Join("..", "..", relUploadsT300),
|
||||||
}
|
}
|
||||||
|
|
||||||
if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" {
|
if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" {
|
||||||
candidates = append(candidates, filepath.Join(root, raw))
|
candidates = append(candidates,
|
||||||
|
filepath.Join(root, raw),
|
||||||
|
filepath.Join(root, filepath.FromSlash(rawT300)),
|
||||||
|
filepath.Join(root, relUploads),
|
||||||
|
filepath.Join(root, relUploadsT300),
|
||||||
|
filepath.Join(root, "uploads", raw),
|
||||||
|
filepath.Join(root, "uploads", filepath.FromSlash(rawT300)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range candidates {
|
for _, p := range candidates {
|
||||||
@@ -147,9 +257,9 @@ func resolveStoragePath(storagePath string) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||||
return p
|
return p, candidates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return "", candidates
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@
|
|||||||
|
|
||||||
<div class="total-row">
|
<div class="total-row">
|
||||||
<div class="total-cell">ADET</div>
|
<div class="total-cell">ADET</div>
|
||||||
|
<div class="total-cell">FOTO</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +177,24 @@
|
|||||||
<q-icon :name="isOpen(grp2.key) ? 'expand_less' : 'expand_more'" size="18px" />
|
<q-icon :name="isOpen(grp2.key) ? 'expand_less' : 'expand_more'" size="18px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sub-image level2-image">
|
||||||
|
<q-card flat bordered class="product-image-card">
|
||||||
|
<q-card-section class="q-pa-xs product-image-wrap">
|
||||||
|
<q-img
|
||||||
|
v-if="getProductImageUrl(grp1.productCode, grp2.colorCode)"
|
||||||
|
:src="getProductImageUrl(grp1.productCode, grp2.colorCode)"
|
||||||
|
fit="cover"
|
||||||
|
class="product-image"
|
||||||
|
loading="lazy"
|
||||||
|
@error="onProductImageError(grp1.productCode, grp2.colorCode)"
|
||||||
|
/>
|
||||||
|
<div v-else class="product-image-placeholder">
|
||||||
|
<q-icon name="image_not_supported" size="22px" color="grey-6" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="isOpen(grp2.key)">
|
<template v-if="isOpen(grp2.key)">
|
||||||
@@ -200,6 +219,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cell adet">{{ formatNumber(row.adet) }}</div>
|
<div class="cell adet">{{ formatNumber(row.adet) }}</div>
|
||||||
|
<div class="cell img-placeholder"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -217,7 +237,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import api from 'src/services/api'
|
import api from 'src/services/api'
|
||||||
import { usePermission } from 'src/composables/usePermission'
|
import { usePermission } from 'src/composables/usePermission'
|
||||||
@@ -260,6 +280,17 @@ const filters = ref({
|
|||||||
const attributeOptions = ref({})
|
const attributeOptions = ref({})
|
||||||
const filteredAttrOptions = ref({})
|
const filteredAttrOptions = ref({})
|
||||||
const rawRows = ref([])
|
const rawRows = ref([])
|
||||||
|
const productImageCache = ref({})
|
||||||
|
const productImageLoading = ref({})
|
||||||
|
const productImageListByCode = ref({})
|
||||||
|
const productImageListLoading = ref({})
|
||||||
|
const productImageFallbackByKey = ref({})
|
||||||
|
const productImageContentLoading = ref({})
|
||||||
|
const productImageBlobUrls = ref([])
|
||||||
|
const productImageListBlockedUntil = ref(0)
|
||||||
|
const IMAGE_LIST_CONCURRENCY = 8
|
||||||
|
let imageListActiveRequests = 0
|
||||||
|
const imageListWaitQueue = []
|
||||||
const activeSchema = ref(storeSchemaByKey.tak)
|
const activeSchema = ref(storeSchemaByKey.tak)
|
||||||
const activeGrpKey = ref('tak')
|
const activeGrpKey = ref('tak')
|
||||||
const openState = ref({})
|
const openState = ref({})
|
||||||
@@ -303,6 +334,172 @@ function parseNumber(value) {
|
|||||||
return Number.isFinite(n) ? n : 0
|
return Number.isFinite(n) ? n : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildImageKey(code, color) {
|
||||||
|
return `${String(code || '').trim().toUpperCase()}::${String(color || '').trim().toUpperCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUploadsPath(storagePath) {
|
||||||
|
const raw = String(storagePath || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
const normalized = raw.replace(/\\/g, '/')
|
||||||
|
const idx = normalized.toLowerCase().indexOf('/uploads/')
|
||||||
|
if (idx >= 0) {
|
||||||
|
return normalized.slice(idx)
|
||||||
|
}
|
||||||
|
if (normalized.toLowerCase().startsWith('uploads/')) {
|
||||||
|
return `/${normalized}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProductImageUrl(item) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return { contentUrl: '', publicUrl: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentUrl = ''
|
||||||
|
const imageId = Number(item.id || item.ID || 0)
|
||||||
|
if (Number.isFinite(imageId) && imageId > 0) {
|
||||||
|
contentUrl = `/api/product-images/${imageId}/content`
|
||||||
|
} else {
|
||||||
|
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||||
|
if (contentURL.startsWith('/api/')) contentUrl = contentURL
|
||||||
|
else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
|
||||||
|
let publicUrl = ''
|
||||||
|
const thumbFolder = (typeof window !== 'undefined' && window.devicePixelRatio > 1.5)
|
||||||
|
? 't600'
|
||||||
|
: 't300'
|
||||||
|
if (uploadsPath) {
|
||||||
|
// Thumbnail tercih et
|
||||||
|
if (uploadsPath.includes('/uploads/image/') && !uploadsPath.includes('/uploads/image/t300/') && !uploadsPath.includes('/uploads/image/t600/')) {
|
||||||
|
publicUrl = uploadsPath.replace('/uploads/image/', `/uploads/image/${thumbFolder}/`)
|
||||||
|
} else {
|
||||||
|
publicUrl = uploadsPath
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fileName = String(item.file_name || item.FileName || '').trim()
|
||||||
|
if (fileName) {
|
||||||
|
// b2b benzeri yapi: /uploads/image/t300|t600/<uuid>.jpg
|
||||||
|
publicUrl = `/uploads/image/${thumbFolder}/${fileName}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { contentUrl, publicUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductImageUrl(code, color) {
|
||||||
|
const key = buildImageKey(code, color)
|
||||||
|
const existing = productImageCache.value[key]
|
||||||
|
if (existing !== undefined) return existing || ''
|
||||||
|
|
||||||
|
void ensureProductImage(code, color)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onProductImageError(code, color) {
|
||||||
|
const key = buildImageKey(code, color)
|
||||||
|
const current = String(productImageCache.value[key] || '')
|
||||||
|
const fallback = String(productImageFallbackByKey.value[key] || '')
|
||||||
|
if (fallback && current !== fallback && !productImageContentLoading.value[key]) {
|
||||||
|
productImageContentLoading.value[key] = true
|
||||||
|
try {
|
||||||
|
const blobRes = await api.get(fallback, {
|
||||||
|
baseURL: '',
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
const blob = blobRes?.data
|
||||||
|
if (blob instanceof Blob) {
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
productImageBlobUrls.value.push(objectUrl)
|
||||||
|
productImageCache.value[key] = objectUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
} finally {
|
||||||
|
delete productImageContentLoading.value[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
productImageCache.value[key] = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureProductImage(code, color) {
|
||||||
|
const key = buildImageKey(code, color)
|
||||||
|
const codeTrim = String(code || '').trim().toUpperCase()
|
||||||
|
const colorTrim = String(color || '').trim().toUpperCase()
|
||||||
|
if (!codeTrim) {
|
||||||
|
productImageCache.value[key] = ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (Date.now() < Number(productImageListBlockedUntil.value || 0)) {
|
||||||
|
productImageCache.value[key] = ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (productImageCache.value[key] !== undefined) return productImageCache.value[key] || ''
|
||||||
|
if (productImageLoading.value[key]) return ''
|
||||||
|
|
||||||
|
productImageLoading.value[key] = true
|
||||||
|
try {
|
||||||
|
if (!productImageListByCode.value[codeTrim]) {
|
||||||
|
if (!productImageListLoading.value[codeTrim]) {
|
||||||
|
productImageListLoading.value[codeTrim] = true
|
||||||
|
try {
|
||||||
|
if (imageListActiveRequests >= IMAGE_LIST_CONCURRENCY) {
|
||||||
|
await new Promise((resolve) => imageListWaitQueue.push(resolve))
|
||||||
|
}
|
||||||
|
imageListActiveRequests++
|
||||||
|
const res = await api.get('/product-images', { params: { code: codeTrim } })
|
||||||
|
productImageListByCode.value[codeTrim] = Array.isArray(res?.data) ? res.data : []
|
||||||
|
} catch (err) {
|
||||||
|
productImageListByCode.value[codeTrim] = []
|
||||||
|
const status = Number(err?.response?.status || 0)
|
||||||
|
if (status >= 500 || status === 403 || status === 0) {
|
||||||
|
// Backend dengesizken istek firtinasini kisaca kes.
|
||||||
|
productImageListBlockedUntil.value = Date.now() + 30 * 1000
|
||||||
|
}
|
||||||
|
console.warn('[ProductStockByAttributes] product image list fetch failed', { code: codeTrim, err })
|
||||||
|
} finally {
|
||||||
|
imageListActiveRequests = Math.max(0, imageListActiveRequests - 1)
|
||||||
|
const nextInQueue = imageListWaitQueue.shift()
|
||||||
|
if (nextInQueue) nextInQueue()
|
||||||
|
delete productImageListLoading.value[codeTrim]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ayni code icin baska bir istek zaten calisiyorsa tamamlanmasini bekle.
|
||||||
|
while (productImageListLoading.value[codeTrim]) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const list = productImageListByCode.value[codeTrim] || []
|
||||||
|
|
||||||
|
let first = null
|
||||||
|
if (colorTrim) {
|
||||||
|
const needle = `-${colorTrim.toLowerCase()}-`
|
||||||
|
first = list.find((item) =>
|
||||||
|
String(item?.file_name || item?.FileName || '').toLowerCase().includes(needle)
|
||||||
|
) || null
|
||||||
|
}
|
||||||
|
if (!first) first = list[0] || null
|
||||||
|
|
||||||
|
const resolved = resolveProductImageUrl(first)
|
||||||
|
|
||||||
|
productImageCache.value[key] = resolved.publicUrl || resolved.contentUrl || ''
|
||||||
|
productImageFallbackByKey.value[key] = resolved.contentUrl || ''
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err })
|
||||||
|
productImageCache.value[key] = ''
|
||||||
|
productImageFallbackByKey.value[key] = ''
|
||||||
|
} finally {
|
||||||
|
delete productImageLoading.value[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
return productImageCache.value[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(v) {
|
function formatNumber(v) {
|
||||||
return parseNumber(v).toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
return parseNumber(v).toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||||
}
|
}
|
||||||
@@ -574,6 +771,13 @@ async function fetchStockByAttributes() {
|
|||||||
activeSchema.value = schemaMap?.[grpKey] || storeSchemaByKey.tak
|
activeSchema.value = schemaMap?.[grpKey] || storeSchemaByKey.tak
|
||||||
|
|
||||||
rawRows.value = list
|
rawRows.value = list
|
||||||
|
productImageCache.value = {}
|
||||||
|
productImageLoading.value = {}
|
||||||
|
productImageListByCode.value = {}
|
||||||
|
productImageListLoading.value = {}
|
||||||
|
productImageFallbackByKey.value = {}
|
||||||
|
productImageContentLoading.value = {}
|
||||||
|
productImageListBlockedUntil.value = 0
|
||||||
initOpenState(level1Groups.value)
|
initOpenState(level1Groups.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('fetchStockByAttributes error:', err)
|
console.error('fetchStockByAttributes error:', err)
|
||||||
@@ -590,8 +794,11 @@ async function fetchStockByAttributes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLevel2Click(_productCode, grp2) {
|
function onLevel2Click(productCode, grp2) {
|
||||||
toggleOpen(grp2.key)
|
toggleOpen(grp2.key)
|
||||||
|
if (isOpen(grp2.key)) {
|
||||||
|
void ensureProductImage(productCode, grp2.colorCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
@@ -609,11 +816,26 @@ function resetForm() {
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
openState.value = {}
|
openState.value = {}
|
||||||
activeSchema.value = storeSchemaByKey.tak
|
activeSchema.value = storeSchemaByKey.tak
|
||||||
|
productImageCache.value = {}
|
||||||
|
productImageLoading.value = {}
|
||||||
|
productImageListByCode.value = {}
|
||||||
|
productImageListLoading.value = {}
|
||||||
|
productImageFallbackByKey.value = {}
|
||||||
|
productImageContentLoading.value = {}
|
||||||
|
productImageListBlockedUntil.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAttributeOptions()
|
loadAttributeOptions()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
for (const url of productImageBlobUrls.value) {
|
||||||
|
try { URL.revokeObjectURL(url) } catch {}
|
||||||
|
}
|
||||||
|
productImageBlobUrls.value = []
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -622,6 +844,7 @@ onMounted(() => {
|
|||||||
--grp-title-w: 44px;
|
--grp-title-w: 44px;
|
||||||
--psq-header-h: 56px;
|
--psq-header-h: 56px;
|
||||||
--psq-col-adet: calc(var(--col-adet) + var(--beden-w));
|
--psq-col-adet: calc(var(--col-adet) + var(--beden-w));
|
||||||
|
--psq-col-img: 126px;
|
||||||
--psq-l1-lift: 42px;
|
--psq-l1-lift: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +857,8 @@ onMounted(() => {
|
|||||||
var(--col-alt)
|
var(--col-alt)
|
||||||
var(--col-aciklama)
|
var(--col-aciklama)
|
||||||
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w)*var(--beden-count)))
|
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w)*var(--beden-count)))
|
||||||
var(--psq-col-adet) !important;
|
var(--psq-col-adet)
|
||||||
|
var(--psq-col-img) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-grid-header .col-fixed,
|
.order-grid-header .col-fixed,
|
||||||
@@ -672,7 +896,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-grid-header .total-row {
|
.order-grid-header .total-row {
|
||||||
grid-column: 7 / -1 !important;
|
grid-column: 7 / 9 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-grid-header .total-cell {
|
.order-grid-header .total-cell {
|
||||||
@@ -680,7 +904,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-grid-header .total-cell:last-child {
|
.order-grid-header .total-cell:last-child {
|
||||||
width: var(--psq-col-adet) !important;
|
width: var(--psq-col-img) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-sub-header {
|
.order-sub-header {
|
||||||
@@ -691,7 +915,8 @@ onMounted(() => {
|
|||||||
var(--col-alt)
|
var(--col-alt)
|
||||||
var(--col-aciklama)
|
var(--col-aciklama)
|
||||||
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w)*var(--beden-count)))
|
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w)*var(--beden-count)))
|
||||||
var(--psq-col-adet) !important;
|
var(--psq-col-adet)
|
||||||
|
var(--psq-col-img) !important;
|
||||||
top: calc(
|
top: calc(
|
||||||
var(--header-h)
|
var(--header-h)
|
||||||
+ var(--filter-h)
|
+ var(--filter-h)
|
||||||
@@ -946,6 +1171,43 @@ onMounted(() => {
|
|||||||
border-left: 1px solid #d4c79f;
|
border-left: 1px solid #d4c79f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-sub-header.level-2 .sub-image.level2-image {
|
||||||
|
grid-column: 8 / 9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-left: 1px solid #d4c79f;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: #fffef7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card {
|
||||||
|
width: 108px;
|
||||||
|
height: 66px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.order-sub-header.level-2 .sub-right .top-total {
|
.order-sub-header.level-2 .sub-right .top-total {
|
||||||
grid-column: 1 / 2;
|
grid-column: 1 / 2;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
@@ -980,7 +1242,8 @@ onMounted(() => {
|
|||||||
var(--col-alt)
|
var(--col-alt)
|
||||||
var(--col-aciklama)
|
var(--col-aciklama)
|
||||||
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w) * var(--beden-count)))
|
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w) * var(--beden-count)))
|
||||||
var(--psq-col-adet) !important;
|
var(--psq-col-adet)
|
||||||
|
var(--psq-col-img) !important;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -1097,6 +1360,12 @@ onMounted(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-grid-body .summary-row .cell.img-placeholder {
|
||||||
|
grid-column: 8 / 9;
|
||||||
|
border-right: none !important;
|
||||||
|
background: #fffef7;
|
||||||
|
}
|
||||||
|
|
||||||
.order-grid-body {
|
.order-grid-body {
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user