Upsert 與冪等

新增、更新、去重的判斷規則,以及唯一的例外

View as Markdown

客戶、商品、規格(SKU)的建立端點都是同一套 upsert 規則:同一支端點,依你送出的欄位自動判斷這一筆該新增、更新,還是去重成更新既有的一筆。目的是讓 ERP 端可以重複推送同一份資料而不會產生重複紀錄,即使中途重試也一樣安全。

後台使用者是唯一的例外,規則不同,見本頁最後一節。

三條判斷規則

依序判斷:

  1. body 帶 id —— 一律視為更新該筆,直接依 id 找到那筆資料並更新,不會再去比對 outerSysCode
  2. body 沒帶 id,但 outerSysCode 命中既有資料 —— 改為更新命中的那一筆(這就是「冪等去重」:同一份 outerSysCode 重送多次,只會更新同一筆,不會越送越多筆)。
  3. 兩者都沒有命中 —— 新增一筆。

規格(SKU)的去重多一個限制:比對範圍限定在同一個商品之內。 判斷「這個 outerSysCode 是否已存在」時,比對的是「同一個 productId 底下」的既有規格,不同商品下即使 outerSysCode 完全相同,也不會互相匹配、也不會互相衝突。這是因為 SKU 代碼的唯一性本來就是以商品為範圍,不是全站唯一。

批次端點(/v1/customers/batch/v1/products/batch/v1/skus/batch)逐筆套用同一套規則,一筆一筆各自判斷新增或更新,互不影響;哪一筆成功、哪一筆失敗、是新增還是更新,都會在回應的逐筆結果裡分別回報。

三個分支各自長什麼樣子

POST /v1/customers 為例,假設 id=501outerSysCode="ERP-C-1001" 的客戶已經存在:

分支送出的 body結果
1. 帶 id{ "id": 501, "cellPhone": "02-2345-6789" }更新 id=501 那筆,不比對 outerSysCode
2. 不帶 idouterSysCode 命中{ "outerSysCode": "ERP-C-1001", "cellPhone": "02-2345-6789" }ERP-C-1001 已存在(就是 501),改為更新 501
3. 都沒命中{ "outerSysCode": "ERP-C-2002", "name": "新客戶股份有限公司" }ERP-C-2002 不存在,新增一筆新客戶

分支 1 與分支 2 的差別只在有沒有帶 id——兩者最終都會落到「更新 501」,但走的是不同的判斷路徑;重送分支 2 的 body 一百次,也只會有一筆 id=501 的客戶被反覆更新,不會變成一百筆客戶。

部分合併:省略的欄位永遠不會被清空

更新(不管是依 id 直接更新,還是依 outerSysCode 去重出來的更新)一律是部分合併:只有你實際送出的欄位會被寫入,省略的欄位維持原本的值。 這代表:

  • 只想改一個欄位時,body 裡只需要放那一個欄位,不需要把整筆資料重新送一次。
  • 省略某個欄位不會把它清空或重設成預設值——例如更新客戶時只送 shippingAddress,客戶原本的 nameshortNamecellPhone 都會維持不變。
  • 這條規則只適用於更新。新增時省略字串欄位視為空字串,省略數字欄位(例如規格的 pricesellPricequantityPrecision)視為 0,因為新增沒有「原本的值」可以保留。

實際看一次:客戶 id=501 目前長這樣——

更新前:GET /v1/customers/501
{
"id": 501,
"outerSysCode": "ERP-C-1001",
"name": "測試客戶股份有限公司",
"shortName": "測試客戶",
"cellPhone": "02-1234-5678",
"shippingAddress": "台北市信義區松高路1號",
"internalUserId": 12,
"status": "Active"
}

只送 cellPhone 這一個欄位去更新:

PUT /v1/customers/501
{
"cellPhone": "02-2345-6789"
}

更新後再查一次,只有 cellPhone 變了,其餘欄位(包含沒有出現在請求裡的 nameshortNameshippingAddressinternalUserId)原封不動:

更新後:再次 GET /v1/customers/501
{
"id": 501,
"outerSysCode": "ERP-C-1001",
"name": "測試客戶股份有限公司",
"shortName": "測試客戶",
"cellPhone": "02-2345-6789",
"shippingAddress": "台北市信義區松高路1號",
"internalUserId": 12,
"status": "Active"
}

status 不受這條規則影響,因為它本來就不是 upsert 請求裡的欄位——透過本 API 建立的客戶一律是 Active,狀態變更由後台維護。

PUT /{id}:路徑上的 id 永遠優先

PUT /v1/customers/{id}PUT /v1/products/{id}PUT /v1/skus/{id} 這三支路徑帶 id 的更新端點,路徑上的 id 永遠優先於 body 裡的 id——即使 body 另外指定了一個不同的 id,也一律以路徑上的為準,body 裡的 id 會被忽略。這是為了避免呼叫端不小心把更新導向另一筆資料:路徑已經明確表示「要更新哪一筆」,body 的 id 沒有機會覆蓋這個意圖。

PUT /v1/customers/501
{
"id": 999,
"cellPhone": "02-2345-6789"
}

這筆請求更新的是路徑上的 501,body 裡的 999 完全被忽略——不會去更新 id=999 那筆,也不會因為 999 不存在而報錯。回應裡的 id 也會是 501

例外:POST /v1/internal-users 沒有去重

後台使用者的建立端點不遵循上面的三條規則——它永遠是新增,沒有 outerSysCode 去重更新這個分支。 即使送出的 outerSysCode 跟某個既有後台使用者相同,也不會被視為要更新那一筆,而是照樣嘗試新增一筆新的。

因為沒有去重機制,重複送出同一份資料會撞到帳號唯一性:同一租戶內 account 重複,會回傳 400 account_already_used,不會更新既有帳號,也不會被靜默忽略。如果你需要更新既有後台使用者的資料(例如改姓名、改角色),必須帶 id 呼叫 PUT /v1/internal-users/{id}POST 這支端點永遠走新增這條路。

會有這個例外,是因為後台使用者是登入帳號,account 本身就必須全租戶唯一,語意上不適合像客戶/商品/規格那樣用 outerSysCode 去重合併——重複的 account 代表操作本身有問題,需要呼叫端明確處理,而不是悄悄併進既有帳號。