iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 4

Day 4|分類名稱軟刪除之後,名字還能重新使用?

  • 分享至 

  • xImage
  •  

專案中的分類功能預想的的使用情境是這樣的 : 商品分類由管理員管理,管理員可以新增「飲料」類、「零食」類,不需要的分類可以刪掉,且所有登入者都可以看得到這份分類清單。

這個功能要保證三件事:

  1. 使用者看得到的分類裡,名稱不可重複。
  2. 刪除採軟刪除,資料列不真的消失,只把 is_deleted 從 0 改成 1,建立者與時間都還留著。
  3. 刪掉「飲料」之後,還是可以再建一個「飲料」。

第 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 步留下的那把鍵,寫入失敗

https://ithelp.ithome.com.tw/upload/images/20260918/20168667nm7oqfkFgG.png

上圖中第 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 表示它不佔資料列空間,需要時才計算,但仍可被索引。

索引裡的鍵從一個欄位變成兩個,同名就分得開了:

https://ithelp.ithome.com.tw/upload/images/20260918/201686676HY3gA1ESO.png

為什麼 entity 裡沒有 active_flag?

值既然由資料庫算,應用程式送值過去就會被 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);
}

上一篇
Day 3|Flyway:把 schema 當程式碼管理
下一篇
Day 5|為什麼 Controller 不能直接回傳 Entity?
系列文
做一個團購後端,順便搞懂那些事8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言