CDN Thiết Bị Hưng Phát
Trang chủ / URL ảnh thống nhất
/storage/products

URL ảnh thống nhất

Spec để website tích hợp nhúng <img> trực tiếp từ CDN — không qua API, không round-trip PHP. nginx serve file thẳng với cache header immutable 1 năm.

Tổng quan

Trước đây mỗi ảnh phải gọi API GET /api/v1/albums/{id} để biết URL — gallery N ảnh = N request, chậm + tải PHP. Pattern mới đặt file trên disk theo công thức cố định từ SKU, client tự tính URL trực tiếp.

Tóm tắt: Biết SKU + index + kích thước → ghép URL. Không cần API. nginx serve trực tiếp file gzipped với cache header immutable.

So sánh trước / sau

AspectTrước (qua API)Sau (URL thống nhất)
Round-tripAPI → DB query → JSON → parse → render <img>Ghép string URL → render <img>
Latency mỗi ảnh~50-200ms (PHP + DB)~5-20ms (nginx + cache hit)
Gallery 24 ảnh24 API calls hoặc 1 call lớn0 API call, browser tự fetch
Browser cacheCache HTML pageCache mỗi file 1 năm (immutable)

URL Pattern

Cấu trúc

https://cdn.thietbihungphat.com/storage/products/{key}/{index}.{variant}
FieldMô tảVí dụ
key Slug-hóa từ album.sku (đã unique trong DB). Fallback album.slug nếu SKU null. LED-100W-PANELled-100w-panel
index Số thứ tự ảnh trong album (1, 2, 3...). Ảnh đầu (1) luôn là cover. 1, 2, 3
variant Format + kích thước. Xem bảng Danh sách kích thước. thumb_md.webp

Ví dụ đầy đủ

Album SKU = LED-100W-PANEL, 3 ảnh:

# Cover (index = 1)
https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.jpg                # original JPG
https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.webp               # full WebP
https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.avif               # full AVIF
https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_sm.webp      # 320w
https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_md.webp      # 640w
https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_lg.webp      # 1280w

# Ảnh thứ 2
https://cdn.thietbihungphat.com/storage/products/led-100w-panel/2.thumb_md.webp

# Ảnh thứ 3
https://cdn.thietbihungphat.com/storage/products/led-100w-panel/3.webp

Danh sách kích thước

Variant keySuffix fileWidthFormatMục đích
original.jpg / .pngfulltheo file gốcDownload, lightbox xem chi tiết
webp.webpfullWebPLightbox WebP-capable browser
avif.aviffullAVIFLightbox modern browser (Chrome 85+, Safari 16+)
thumb_sm.thumb_sm.webp320wWebPMobile listing, srcset min
thumb_md.thumb_md.webp640wWebPDefault card thumbnail
thumb_lg.thumb_lg.webp1280wWebPHero, desktop fullscreen

Lưu ý "no-upscale": Nếu ảnh gốc < kích thước variant (vd gốc 200w, variant thumb_md = 640w) → variant không tạo (404). Client cần fallback xuống webp full size khi gặp 404.

Variant optional (chỉ tạo khi admin bật): social_og (1200×630), social_twitter, card_square (800×800), card_landscape, instagram_post, instagram_story, hero_1920, mobile_thumb. Xem admin Settings → Hình ảnh.

Cache strategy

File /storage/* có cache header (từ .htaccess):

Cache-Control: public, max-age=31536000, immutable

Hệ quả:

  • Browser cache file 1 năm, không hit server lại.
  • immutable → browser bỏ qua revalidation (304 check) khi reload.
  • Cache-bust: không cần (file không thay đổi nội dung — đổi nội dung = upload ảnh mới với index khác).
  • Đổi SKU → URL thay đổi → bypass cache cũ tự nhiên.

Code mẫu HTML

Embed cơ bản (single img)

<img src="https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_md.webp"
     alt="Đèn LED 100W Panel — ảnh chính"
     width="640" height="480"
     loading="lazy" decoding="async">

Picture với AVIF/WebP fallback + responsive srcset

<picture>
    <source type="image/avif"
            srcset="https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.avif">
    <source type="image/webp"
            srcset="https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_sm.webp 320w,
                    https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_md.webp 640w,
                    https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_lg.webp 1280w"
            sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw">
    <img src="https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_md.webp"
         alt="Đèn LED 100W Panel"
         width="640" height="480"
         loading="lazy" decoding="async">
</picture>

Hero LCP (ảnh đầu trên page — preload + eager)

<!-- Trong <head> -->
<link rel="preload" as="image"
      href="https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_lg.webp"
      imagesrcset="https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_sm.webp 320w,
                   https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_md.webp 640w,
                   https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_lg.webp 1280w"
      imagesizes="100vw">

<!-- Trong <body> -->
<img src="https://cdn.thietbihungphat.com/storage/products/led-100w-panel/1.thumb_lg.webp"
     alt="Đèn LED 100W Panel"
     width="1280" height="960"
     loading="eager"
     fetchpriority="high"
     decoding="async">

Framework integration

Laravel Blade (server-side)

@php
    $sku = 'LED-100W-PANEL';
    $key = \Illuminate\Support\Str::slug($sku);
    $base = config('app.url') . '/storage/products/' . $key;
@endphp

<img src="{{ $base }}/1.thumb_md.webp"
     alt="..." width="640" height="480" loading="lazy">

JavaScript (vanilla)

const CDN = 'CDN_PLACEHOLDER';

function imageUrl(sku, index = 1, variant = 'thumb_md') {
    const key = sku.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
    if (variant === 'webp' || variant === 'avif') {
        return `${CDN}/storage/products/${key}/${index}.${variant}`;
    }
    if (variant === 'original') {
        return `${CDN}/storage/products/${key}/${index}.jpg`; // hoặc png/webp tùy gốc
    }
    return `${CDN}/storage/products/${key}/${index}.${variant}.webp`;
}

// Sử dụng
imageUrl('LED-100W-PANEL', 1, 'thumb_md');
// → CDN_PLACEHOLDER/storage/products/led-100w-panel/1.thumb_md.webp

React component

const CDN = process.env.NEXT_PUBLIC_CDN_URL; // hoặc hard-code CDN base URL

function skuToKey(sku) {
    return sku.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}

function ProductImage({ sku, index = 1, alt, className }) {
    const key = skuToKey(sku);
    const base = `${CDN}/storage/products/${key}/${index}`;

    return (
        <picture className={className}>
            <source type="image/avif" srcSet={`${base}.avif`} />
            <source
                type="image/webp"
                srcSet={`
                    ${base}.thumb_sm.webp 320w,
                    ${base}.thumb_md.webp 640w,
                    ${base}.thumb_lg.webp 1280w
                `}
                sizes="(max-width: 640px) 100vw, 33vw"
            />
            <img
                src={`${base}.thumb_md.webp`}
                alt={alt}
                width={640}
                height={480}
                loading="lazy"
                decoding="async"
            />
        </picture>
    );
}

Vue 3 composable

// composables/useCdnImage.js
const CDN = 'CDN_PLACEHOLDER';

export function useCdnImage(sku, index = 1) {
    const key = sku.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
    const base = `${CDN}/storage/products/${key}/${index}`;
    return {
        avif: `${base}.avif`,
        webp: `${base}.webp`,
        original: `${base}.jpg`,
        thumb: (size) => `${base}.thumb_${size}.webp`,
        srcset: `${base}.thumb_sm.webp 320w, ${base}.thumb_md.webp 640w, ${base}.thumb_lg.webp 1280w`,
    };
}

Thay CDN_PLACEHOLDER bằng https://cdn.thietbihungphat.com trong code thực tế.

Edge cases

Ảnh nhỏ hơn variant → variant không tồn tại (404)

Image processor có rule "no-upscale" — nếu ảnh gốc bé hơn target width, không tạo thumb đó. Vd ảnh gốc 200×150, variant thumb_md (640w) sẽ không có file → URL 404.

Fallback chain (client side):

<img src="https://cdn.thietbihungphat.com/storage/products/sku/1.thumb_md.webp"
     onerror="this.onerror=null;this.src='https://cdn.thietbihungphat.com/storage/products/sku/1.webp'"
     alt="...">

Hoặc dùng <picture> với thumb_md source TRƯỚC webp fallback — browser tự pick.

SKU đổi sau khi upload

  • Model event updating tự rename folder products/{old}products/{new} + update storage_path DB.
  • URL cũ trở thành 404 ngay.
  • Cần update các backlink/HTML đã hard-code URL cũ.
  • Khuyến cáo: Đừng đổi SKU sau khi đã publish + index Google.

Album bị xóa

Trang public (/cat/album-slug) trả 404. File trên disk vẫn còn (đến khi force-delete từ thùng rác). URL vẫn 200 trong period soft-delete.

Album không có SKU

Fallback dùng album.slug (unique trong category, slug tự gen từ name).

# Album "Máy khoan demo" không SKU → slug "may-khoan-demo"
https://cdn.thietbihungphat.com/storage/products/may-khoan-demo/1.thumb_md.webp

Đặc biệt: SKU có ký tự đặc biệt

  • Slug-hóa: lowercase + chỉ giữ [a-z0-9-], replace khác bằng -, collapse dashes.
  • "LED 100W (Panel)""led-100w-panel"
  • "LED/100W""led-100w"
  • "Đèn LED""den-led" (Vietnamese diacritic loại bỏ)

Migration từ pattern cũ

Nếu website đang gọi API /api/v1/albums/{id} để lấy URL ảnh, đây là cách chuyển:

Bước 1 — Lấy SKU + danh sách ảnh (1 lần)

# Vẫn cần API cho metadata (alt_text, danh sách ảnh, kích thước)
GET /api/v1/albums/{id}

# Response chứa:
{
  "data": {
    "sku": "LED-100W-PANEL",
    "image_url_pattern": {
      "key": "led-100w-panel",
      "base": "https://cdn.thietbihungphat.com/storage/products/led-100w-panel/{index}",
      "examples": { ... }
    },
    "images": [
      { "sort": 1, "alt_text": "...", "width": 1920, "height": 1080 },
      { "sort": 2, "alt_text": "...", "width": 1600, "height": 900 }
    ]
  }
}

Bước 2 — Render <img> bằng pattern

Không cần đọc original_url trong response nữa — ghép URL từ key + sort.

Bước 3 — Cache metadata phía client/server

Metadata album thay đổi rất ít. Cache trong app phía bạn 1 giờ - 1 ngày tùy frequency. File URLs không cần cache vì browser đã cache immutable 1 năm.

Checklist tích hợp

  1. Verify URL pattern bằng curl trước khi code: curl -I https://cdn.thietbihungphat.com/storage/products/sku-test/1.thumb_md.webp
  2. Thêm width + height attr trên <img> để tránh layout shift (CLS).
  3. Hero image: loading="eager" + fetchpriority="high", các ảnh khác loading="lazy".
  4. Always alt mô tả nội dung ảnh (cho a11y + SEO).
  5. Picture element cho AVIF/WebP fallback + responsive srcset → tải nhanh nhất theo browser.
  6. Fallback chain: nếu thumb 404 (ảnh gốc bé) → fallback .webp full hoặc .jpg original.
  7. Slugify SKU đồng nhất với rule server: lowercase + replace non-alphanum bằng dash + collapse + strip diacritics.
  8. Không cache URL trong app — URL tính được ngay từ SKU, cache lại = thêm complexity vô ích.

FAQ

Cần API key để truy cập file ảnh không?

Không. /storage/* là public, nginx serve trực tiếp. API key chỉ cần cho /api/v1/* (metadata + write operations).

Có rate limit cho file ảnh không?

Không có app-level rate limit. nginx-level connection limits áp dụng (mặc định khá cao). CDN proxy phía trên (Cloudflare nếu có) có thể có thêm limit.

Hỗ trợ HTTP/2 không?

Có. nginx serve HTTP/2 mặc định trên HTTPS. Nhiều request file song song trong 1 connection.

Có CORS không?

File /storage/* không có CORS header — browser cho phép load <img> cross-origin mặc định. Nếu cần fetch qua JS với credentials, cần CORS — hỏi admin.

Tôi muốn lấy ngẫu nhiên 1 ảnh theo SKU?

Vẫn dùng API: GET /api/v1/images/random?sku=LED-100W. Pattern URL không hỗ trợ random (random ý nghĩa runtime).

Làm sao biết album có bao nhiêu ảnh?

Vẫn cần API GET /api/v1/albums/{id} để biết số lượng + metadata. Pattern URL chỉ giúp build URL khi đã biết sort index.

Tôi có thể HEAD probe để check ảnh tồn tại?

Được. curl -I {url} trả 200 nếu file có, 404 nếu không. Dùng cho health check / fallback detection.