| 欄位 | 內容 |
|---|---|
| 狀態 | Locked(B-1~B-5b 真相來源) |
| 日期 | 2026-07-20 |
| 版本 | 0.3 |
| 誰要遵守 | Spring 後端、前端 Mock、OpenAPI/Swagger |
| 相關清單 | plans/backend-implementation-checklist.md 線 B |
| 共用慣例 | common-api-conventions.md |
| 索引 | README.md |
為什麼要這份文件?
目前前端products.json是「頁面好用的胖物件」,資料庫則把名稱/圖/品牌拆在equipment_*,販售身分在products/product_variants。
兩邊本來就不一致。 這份契約是中間的寫死規格:Mock 與後端都改成輸出同一形狀,前端再慢慢接。
公開商品 API 回傳 Envelope + 精簡 Product(含 active variants);金額一律 字串;來源對齊 DB(products + equipment_items + product_variants + 主圖)。
Envelope/錯誤/金額規則見 common;本文件只鎖商品欄位。
| 來源 | 現況 | 與契約關係 |
|---|---|---|
DB products |
只有 id/status/item_id/時間 |
契約的販售身分 |
DB equipment_items + brand/category/images |
名稱、說明、品牌、分類、圖 | 契約的展示欄位 |
DB product_variants |
SKU、規格、真正售價 | 契約的 variants[] |
View sellable_product_variants |
可賣 variant 扁平列 | 結帳重算時可用;列表先組 SPU |
前端舊 Mock products.json |
另有 rentalId、branch、totalStock、interestTags… |
不在 v0.3;UI 衍生欄位另算 |
結論: 舊 Mock ≠ DB ≠ 本契約。接線時以本文件為準,不要再以 products.json 的胖欄位當 API 真相。
| 方法 | 路徑 | 認證 | 說明 |
|---|---|---|---|
GET |
/api/products?page=0&size=20&sort=id,asc |
公開(不必 Token) | 可販售商品分頁列表 |
GET |
/api/products/{id} |
公開 | 單筆詳情;id = products.id(如 P001) |
products.status = 'active'equipment_items.active = truevariants 只含 product_variants.status = 'active'NOT_FOUNDdata 為單一物件(不是陣列)列表端點支援下列 query parameters;詳情端點不支援。
| 參數 | 預設 | 規則 |
|---|---|---|
page |
0 |
從 0 起算,必須大於等於 0 |
size |
20 |
必須介於 1 至 100 |
sort |
id,asc |
格式 field,asc\|desc;僅允許 id 或 name |
page、size 或 sort → HTTP 400,錯誤碼 VALIDATION_ERROR。name 排序依 equipment_items.name;price 是 active variants 最低價的衍生欄位,v0.3 不支援排序。meta:| 參數 | 規則 |
|---|---|
category |
分類完整名稱,忽略大小寫;空白視為未指定 |
brand |
品牌完整名稱,忽略大小寫;空白視為未指定 |
minPrice |
active variant 價格下限,包含邊界且不得為負數 |
maxPrice |
active variant 價格上限,包含邊界且不得為負數 |
多個條件採 AND。商品至少一個 active variant 同時落在價格區間內才符合;minPrice > maxPrice 回傳 400 VALIDATION_ERROR。
{
"page": 0,
"size": 20,
"totalElements": 100,
"totalPages": 5
}
成功/錯誤格式見 common-api-conventions.md。
本資源:data 列表=Product[] 且 v0.3 必帶分頁 meta;詳情=Product 且不帶 meta。
錯誤範例:NOT_FOUND — "Product not found: P999"。
JSON 使用 camelCase。
下表「DB 來源」方便對照;回應裡不要出現 snake_case。
| JSON 欄位 | 型別 | 必填 | DB/規則 | 說明 |
|---|---|---|---|---|
id |
string | 是 | products.id |
商品 ID,如 "P001" |
itemId |
string | 是 | products.item_id |
裝備主檔 ID |
status |
string | 是 | products.status |
公開 API 實際只會看到 "active" |
name |
string | 是 | equipment_items.name |
顯示名稱 |
category |
string | null | 是* | product_categories.name |
分類顯示名;無則 null |
brand |
string | null | 是* | brands.name |
品牌顯示名;無則 null |
description |
string | null | 是* | equipment_items.description |
可含 HTML;無則 null |
image |
string | null | 是* | equipment_images.url(sort_order = 0) |
主圖;路徑如 /assets/...;無則 null |
price |
string | 是 | 衍生:active variants 的 最低 price |
列表卡片用;不是獨立 DB 欄位 |
variants |
array | 是 | 見下節 | 至少 1 筆(否則不應出現) |
*「必填」指鍵一定要出現;值可以是 null。
variants[]| JSON 欄位 | 型別 | 必填 | DB 來源 | 說明 |
|---|---|---|---|---|
id |
string | 是 | product_variants.id |
規格 ID |
sku |
string | 是 | product_variants.sku |
業務 SKU |
color |
string | null | 是* | product_variants.color |
可空 |
size |
string | null | 是* | product_variants.size |
可空 |
specification |
string | 是 | product_variants.specification |
規格文字(DB NOT NULL) |
price |
string | 是 | product_variants.price |
字串金額,兩位小數,如 "3200.00" |
availableQuantity |
integer | 是 | 商城庫存合計減 active 保留量,最低為 0 | 目前可售數量 |
inStock |
boolean | 是 | availableQuantity > 0 |
是否仍可販售 |
number 浮點與竄改)BigDecimal"0.00"、"1299.50"){
"success": true,
"data": [
{
"id": "P001",
"itemId": "E001",
"status": "active",
"name": "Coleman 六人帳篷",
"category": "帳篷",
"brand": "Coleman",
"description": "<p>適合露營使用。</p>",
"image": "/assets/images/products/P001-1.jpg",
"price": "3200.00",
"variants": [
{
"id": "V001",
"sku": "TENT-OLIVE",
"color": "深橄欖綠",
"size": null,
"specification": "深橄欖綠",
"price": "3200.00",
"availableQuantity": 8,
"inStock": true
},
{
"id": "V002",
"sku": "TENT-SAND",
"color": "沙漠棕",
"size": null,
"specification": "沙漠棕",
"price": "3300.00",
"availableQuantity": 0,
"inStock": false
}
]
}
]
}
{
"success": true,
"data": {
"id": "P001",
"itemId": "E001",
"status": "active",
"name": "Coleman 六人帳篷",
"category": "帳篷",
"brand": "Coleman",
"description": "<p>適合露營使用。</p>",
"image": "/assets/images/products/P001-1.jpg",
"price": "3200.00",
"variants": [
{
"id": "V001",
"sku": "TENT-OLIVE",
"color": "深橄欖綠",
"size": null,
"specification": "深橄欖綠",
"price": "3200.00",
"availableQuantity": 8,
"inStock": true
}
]
}
}
| 子項 | 狀態 | v0.3 行為 |
|---|---|---|
| B-5a 基本商品規格 | 已完成 | variants[] 只回 active variant,包含 id、sku、color、size、specification、price |
| B-5b 規格層級可售庫存 | 已完成 | v0.3 回傳 availableQuantity/inStock |
sellable_product_variants 只代表商品、器材與 variant 狀態可販售。後端另以 variant_id 為粒度,計算 inventory_stocks.on_hand_quantity 扣除 active product_stock_reservations.quantity;終止狀態不扣庫存,結果最低為 0。
以下舊 Mock/頁面欄位不得出現在本契約回應裡(避免前後端各寫各的):
| 欄位/概念 | 原因 |
|---|---|
rentalId/rentalEnabled |
租借領域,另 API |
interestTags/tags/specifications 物件 |
附屬表;不屬於 B-5a 基本 variant 契約,之後可升版擴充 |
images[](多圖) |
v0.3 只主圖 image |
totalStock/branch/variants[].branch |
v0.3 只提供 variant 可售量,不公開庫位分布 |
rating/reviewCount/salesCount |
評價/訂單衍生;前端可另算 |
variants[].label |
用 specification 或前端組合 color/size |
price 排序 |
需以 active variants 最低價做聚合,之後版本再談 |
若要加欄位:先改本文件版本號(v0.3…)→ 再改後端 → 再改 Mock,禁止只改一邊。
| 模式 | 行為 |
|---|---|
USE_MOCK_API = true |
讀取已是 v0.3 契約形狀的本地 JSON;不猜測或補齊缺漏欄位 |
USE_MOCK_API = false |
GET /api/products;必須先 解開 Envelope(取 data) |
參考實作:
frontend/data/catalog/products.contract.sample.jsonfrontend/storefront/js/api-mock.js 的 _readProductContract頁面若仍需要 number 價錢做 toLocaleString,只允許在 enrich/UI 層 Number(price),不得把 number 寫回「契約真相」。
@Operation/@Schema 描述與本文件相同的欄位、分頁參數與 metahttp://localhost:8080/swagger-ui.html → Catalog| 版本 | 日期 | 說明 |
|---|---|---|
| 0.3 | 2026-07-21 | B-4 商品篩選與 B-5b variant 可售量已實作;加入 availableQuantity、inStock |
| 0.2 | 2026-07-20 | 文件釐清 B-5:基本 variants[] 已隨 B-1/B-2 完成;規格層級可售庫存尚未實作,需升版後才能加入 |
| 0.2 | 2026-07-20 | B-3 驗收:非空跨頁、PostgreSQL id/name 雙向排序、非法參數 Envelope、超頁 meta 與 Controller HTTP 整合測試通過 |
| 0.2 | 2026-07-20 | B-3:列表支援 page/size/id、name 白名單排序,並回傳分頁 meta |
| 0.1 | 2026-07-20 | 初版:B-1/B-2 精簡欄位(甲)鎖定 |