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
| Aspect | Trước (qua API) | Sau (URL thống nhất) |
|---|---|---|
| Round-trip | API → 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 ảnh | 24 API calls hoặc 1 call lớn | 0 API call, browser tự fetch |
| Browser cache | Cache HTML page | Cache mỗi file 1 năm (immutable) |
URL Pattern
Cấu trúc
https://cdn.thietbihungphat.com/storage/products/{key}/{index}.{variant}
| Field | Mô tả | Ví dụ |
|---|---|---|
key |
Slug-hóa từ album.sku (đã unique trong DB). Fallback album.slug nếu SKU null. |
LED-100W-PANEL → led-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 key | Suffix file | Width | Format | Mục đích |
|---|---|---|---|---|
original | .jpg / .png | full | theo file gốc | Download, lightbox xem chi tiết |
webp | .webp | full | WebP | Lightbox WebP-capable browser |
avif | .avif | full | AVIF | Lightbox modern browser (Chrome 85+, Safari 16+) |
thumb_sm | .thumb_sm.webp | 320w | WebP | Mobile listing, srcset min |
thumb_md | .thumb_md.webp | 640w | WebP | Default card thumbnail |
thumb_lg | .thumb_lg.webp | 1280w | WebP | Hero, 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
updatingtự rename folderproducts/{old}→products/{new}+ updatestorage_pathDB. - 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
- Verify URL pattern bằng curl trước khi code:
curl -I https://cdn.thietbihungphat.com/storage/products/sku-test/1.thumb_md.webp - Thêm
width+heightattr trên <img> để tránh layout shift (CLS). - Hero image:
loading="eager"+fetchpriority="high", các ảnh khácloading="lazy". - Always
altmô tả nội dung ảnh (cho a11y + SEO). - Picture element cho AVIF/WebP fallback + responsive srcset → tải nhanh nhất theo browser.
- Fallback chain: nếu thumb 404 (ảnh gốc bé) → fallback
.webpfull hoặc.jpgoriginal. - Slugify SKU đồng nhất với rule server:
lowercase + replace non-alphanum bằng dash + collapse + strip diacritics. - 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.