專案中的分類功能預想的的使用情境是這樣的 : 商品分類由管理員管理,管理員可以新增「飲料」類、「零食」類,不需要的分類可以刪掉,且所有登入者都可以看得到這份分類清單。
這個功能要保證三件事:
is_deleted 從 0 改成 1,建立者與時間都還留著。第 2 點讓資料留在表裡,第 1 點卻要求名稱不重複。那筆已經被刪掉的「飲料」,在判斷有沒有重複的時候到底算不算數?看起來這兩個條件互相矛盾啊!
現在回頭看這個分類功能的規則,好像沒有必要做到這麼嚴格,我後來去看了其他電商專案的做法,其實他們在定義分類功能的規格上,都沒有特別處理「分類名稱不能重複、軟刪除後可以再建立一個相同的分類」,不過就當作是練習吧! 也許以後會做到其他功能也有一樣的規則。
name 上加唯一索引第一點規則交給資料庫最省事:
UNIQUE KEY `uk_name` (`name`)
這麼做的好處就是,讓UK把關資料的唯一性,任何一條寫入路徑都繞不過去,比在每個寫入點各查一次可靠(總是有可能哪天會忘記寫)。
─── 第 1 步:建立分類「飲料」 ────────────────────
表裡的列 #1 name=飲料 is_deleted=0
索引的鍵 ('飲料')
清單顯示 飲料
─── 第 2 步:刪除「飲料」 ────────────────────────
表裡的列 #1 name=飲料 is_deleted=1 ← 只改了一個欄位,列還在
索引的鍵 ('飲料') ← 索引不看 is_deleted,鍵也還在
清單顯示 (空的)
─── 第 3 步:重新建立分類「飲料」 ────────────────
要寫的列 #2 name=飲料 is_deleted=0
要寫的鍵 ('飲料') ← 撞上第 1 步留下的那把鍵,寫入失敗

上圖中第 3 步在使用者眼中完全合理,卻寫不進去,違反了第 3 點的規則,刪掉之後使用者卻沒辦法重新建立一樣的分類。
軟刪除等於讓「已刪除」的資料繼續參與資料唯一性的判斷,結論就是這條路不可行。
唯一索引只看 name,而 name 表達不了這一列有沒有效。要讓它自己分辨,就得把「有沒有效」也放進比對範圍。
SQL 的唯一索引有一條特性可以借用:NULL 不等於任何值,包括另一個 NULL。 利用這一點加一個衍生欄位,有效的時候是 1,已刪除時是 NULL,如此一來就可以排除掉已經軟刪除的資料:
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '軟刪除:0=有效,1=已刪',
`active_flag` TINYINT GENERATED ALWAYS AS (IF(`is_deleted` = 0, 1, NULL)) VIRTUAL,
UNIQUE KEY `uk_name_active` (`name`, `active_flag`)
GENERATED ALWAYS AS 表示這一欄的值不是寫進去的,是資料庫依括號裡的運算式從同一列算出來,is_deleted 一改它就跟著變。VIRTUAL 表示它不佔資料列空間,需要時才計算,但仍可被索引。
索引裡的鍵從一個欄位變成兩個,同名就分得開了:

值既然由資料庫算,應用程式送值過去就會被 MySQL 擋下來。而 MyBatis-Plus是拿 entity 有值的欄位去組 SQL:只要 active_flag 出現在 entity 裡,查詢就會把它讀進物件,物件再拿去更新,它就跟著被寫回資料庫,由於active_flag會自己更新,就不需要寫進去entity讓MyBatis-Plus去更新了。
@Data
@TableName("categories")
public class Category {
private Integer isDeleted;
// active_flag 是 generated column,應用程式不該寫它,故不映射
}
軟刪除因此只動 is_deleted 一個欄位:
Category update = new Category();
update.setCategoryId(request.getCategoryId());
update.setVersion(request.getVersion());
update.setIsDeleted(1);
update.setUpdatedBy(operator);
CategoryControllerTest 裡有一支測試走的就是最初那三步:
@Test
@DisplayName("刪除後同名可重新建立 → 201(uk_name_active 的軟刪設計)")
void recreateSameNameAfterDelete() throws Exception {
JsonNode created = createCategory("刪除-重生", 1);
deleteCategory(created.get("categoryId").asInt(), 0, adminToken)
.andExpect(status().isOk());
// helper 內建 isCreated 斷言:這裡沒炸 = 同名成功重建
createCategory("刪除-重生", 2);
}