Content Management — Subcategories
ภาพรวม
Subcategories คือชั้นการจัดหมวดที่อยู่ใต้เมนู Categories ซ้อนกันเองได้หลายระดับ แสดงผลเป็น โครงสร้างต้นไม้ (tree) เพื่อให้เห็นความสัมพันธ์แม่-ลูกได้ทันที เหมาะกับทีมคอนเทนต์ที่มีเนื้อหาปริมาณมากและต้องจัดกลุ่มละเอียดกว่าหมวดหมู่หลัก เช่น "โปรโมชัน > ลูกค้าใหม่ > เดือนมกราคม"
จุดสำคัญที่ต้องรู้ก่อนเริ่มคือ ระบบนับหมวดหมู่หลักเป็นระดับที่ 1 และเพดานที่บังคับจริงคือระดับที่ 4 ดังนั้นหมวดหมู่ย่อยมีได้ 3 ชั้น คือ L2, L3, L4 ตรงกับป้ายกำกับสีในหน้าจอ (L2 น้ำเงิน / L3 เขียว / L4 ม่วง) — คำใบ้ในฟอร์มที่เขียนว่า "สูงสุด 4 ระดับ" นับรวมหมวดหมู่หลักไปแล้ว
บทบาทที่ใช้งาน — ทีมคอนเทนต์ที่วางโครงหมวดละเอียดต่อจากเมนู Categories (โมดูลนี้ ไม่มี ModuleGate ที่ backend — ดูหัวข้อ สิทธิ์การเข้าถึง)
เส้นทางผู้ใช้งาน
1. เข้าหน้าโครงต้นไม้
เปิดเมนู Content Management > Subcategories ได้ 2 ทาง:
- เข้าตรง — หน้าจะแสดง หมวดหมู่ย่อยทั้งหมด เป็นแผงพับ (Collapse) แยกตามหมวดหมู่หลัก ระบบดึงรายการหมวดหมู่จาก endpoint dropdown แล้ววน เรียก tree ของแต่ละหมวดทีละคำขอ และกางแผงของหมวดแรกที่มีหมวดย่อยให้อัตโนมัติ
- กดมาจากเมนู Categories — ระบบส่ง
categoryIdและcategoryNameมาทาง URL หน้านี้จะกรองหมวดที่ระบุไว้ให้ทันทีและแสดงหัวข้อว่า หมวดหมู่ย่อยของ "ชื่อหมวด" พร้อมปุ่ม ดูหมวดหมู่ทั้งหมด สำหรับกลับไปมุมมองรวม
ในมุมมองกรองหมวดเดียว ระบบ กางทุกกิ่งให้อัตโนมัติ และแสดงแถบคำอธิบายระดับ (L2 - หมวดหมู่ย่อย / L3 - หมวดหมู่ย่อยระดับ 2 / L4 - หมวดหมู่ย่อยระดับ 3) พร้อมคำใบ้ท้ายต้นไม้ว่า "ลากรายการเพื่อจัดเรียงลำดับภายในระดับเดียวกัน"
แต่ละกิ่งในต้นไม้แสดง — ที่จับสำหรับลาก, ไอคอนสีตามระดับ, ชื่อ, ป้าย L2/L3/L4, slug แบบ monospace (tooltip เป็น /slug) และเมนูจุดสามจุด
2. เพิ่มหมวดหมู่ย่อย
- กด สร้างหมวดหมู่ย่อย ที่หัวหน้าจอ เพื่อสร้างกิ่งระดับ L2
- หรือเปิดเมนูจุดสามจุดของกิ่งใดก็ได้แล้วเลือก เพิ่มรายการย่อย — เมนูนี้จะ ไม่ปรากฏเลย ถ้ากิ่งนั้นอยู่ระดับ 4 แล้ว ระบบพาไปฟอร์มพร้อมส่ง
categoryIdและparentIdให้เติมค่าล่วงหน้า
ฟอร์มมี 7 ช่อง:
- หมวดหมู่หลัก — บังคับเลือก ล็อกไว้ในโหมดแก้ไข
- หมวดหมู่ย่อยหลัก (ไม่บังคับ) — ตัวเลือกใช้ dropdown แบบลำดับชั้นที่วาดเส้น
├─/└─ให้เห็นโครงต้นไม้ (ตัวหนังสือ monospace) ช่องนี้ถูกปิดถ้ายังไม่เลือกหมวดหมู่หลัก และ ล็อกไว้ในโหมดแก้ไข ใต้ช่องมีคำใบ้ว่า "เว้นว่างไว้เพื่อสร้างเป็นรายการย่อยโดยตรงของหมวดหมู่ ระดับปัจจุบัน: N" ซึ่งเปลี่ยนเป็นข้อความสีแดง "ถึงระดับความลึกสูงสุดแล้ว (4 ระดับ) ไม่สามารถเพิ่มรายการย่อยได้" เมื่อระดับของแม่ที่เลือกถึงเพดาน - ชื่อหมวดหมู่ย่อย — บังคับกรอก สูงสุด 255 ตัวอักษร พร้อมตัวนับ สร้าง slug ให้อัตโนมัติ
- Slug — บังคับกรอก สูงสุด 255 ตัวอักษร ตรวจ regex
^[a-z0-9-]+$ที่ฝั่งฟอร์ม - คำอธิบาย — ไม่บังคับ สูงสุด 1,000 ตัวอักษร
- ลำดับการเรียง — ตัวเลข ≥ 0 คำใบ้ระบุว่า "ตัวเลขน้อยจะแสดงก่อนภายในหมวดหมู่เดียวกัน"
- สถานะ — active / inactive
บันทึกแล้วระบบพากลับไปหน้าต้นไม้ที่กรองหมวดหมู่นั้นไว้ให้
3. จัดลำดับด้วยการลากวาง
ลากกิ่งไปวางใน ช่องว่างระหว่างกิ่ง เท่านั้น — วางทับตัวกิ่ง (เพื่อทำให้เป็นลูก) จะถูกปฏิเสธด้วยข้อความ "วางระหว่างรายการเพื่อจัดเรียงลำดับ ใช้เมนู 'เพิ่มรายการย่อย' เพื่อสร้างรายการซ้อน" และการลากข้ามแม่จะถูกปฏิเสธด้วย "สามารถจัดเรียงลำดับได้เฉพาะภายในระดับเดียวกันเท่านั้น" (ตรวจสองชั้น: allowDrop ปิดไว้ตั้งแต่ตอนลาก และตรวจซ้ำใน onDrop)
เมื่อวางสำเร็จ หน้าเว็บคำนวณลำดับใหม่ของ พี่น้องทั้งกลุ่ม แล้วส่ง PATCH /content-subcategories/reorder/sort-order ไปทีเดียวพร้อม sortOrder เป็นเลขไล่ตั้งแต่ 0 แล้วจึงโหลดต้นไม้ใหม่
4. ลบกิ่ง
จากเมนูจุดสามจุด เลือก ลบ หน้าเว็บ ยิงคำสั่งลบแบบไม่บังคับไปก่อน เพื่อให้เซิร์ฟเวอร์เป็นผู้บอกว่ามีสิ่งที่ผูกอยู่หรือไม่ ถ้าไม่มีอะไรผูกก็ลบสำเร็จทันทีพร้อมข้อความ "ลบหมวดหมู่ย่อยสำเร็จ" — ถ้ามี เซิร์ฟเวอร์จะตอบ 409 กลับมา (ดูข้อควรระวังด้านล่างเรื่องกล่องยืนยันที่ตั้งใจให้แสดงจำนวนที่กระทบ)
รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง
ยืนยันจาก internal/modules/contentsubcategory/{controller,service,dto}.go ของ line-management-cms-api-go:
- ระดับชั้นถูกคำนวณจากแม่ที่ฝั่งเซิร์ฟเวอร์ ไม่ได้เชื่อค่าที่ส่งมา —
validateParentAndGetLevel()คืน 2 เมื่อไม่มีแม่ ไม่เช่นนั้นคืนparent.Level + 1และปฏิเสธเมื่อparent.Level >= 4ด้วย 400 "Maximum nesting depth reached (4 levels)" DTO มีช่องlevelที่ตรวจ@Min(2) @Max(4)อยู่จริง แต่ service ไม่เคยอ่านค่านั้นเลย — ส่งlevel: 4มาพร้อมกับparentIdที่อยู่ระดับ 2 ก็ยังได้ระดับ 3 ตามการคำนวณ - แม่ต้องอยู่หมวดหมู่หลักเดียวกัน — ถ้า
parent.CategoryID != dto.CategoryIDจะได้ 400 "Parent subcategory must belong to the same category" และถ้าไม่พบแม่ (หรือแม่ถูกลบไปแล้ว) จะได้ 404 "Parent subcategory not found" - slug ต้องไม่ซ้ำ "ต่อหนึ่งหมวดหมู่หลัก" ไม่ใช่ต่อ LINE OA — เงื่อนไขคือ
category_id + slug + deleted_date IS NULL(โดย ไม่กรองline_oa_idเลย) ข้อความชนคือ 409 "A subcategory with this slug already exists in this category" — slug เดียวกันจึงใช้ซ้ำได้ในหมวดหมู่ต่างกัน แต่ห้ามซ้ำแม้จะอยู่กิ่งคนละแม่ในหมวดเดียวกัน - slug ถูก lowercase + trim ที่ backend ก่อนตรวจ regex เหมือนเมนู Categories (transform
value?.toLowerCase().trim()ทำงานก่อน@Matches) และname/descriptionถูก trim ให้ด้วย - ส่งค่าที่ไม่ใช่สตริงมาใน
name/slug/descriptionจะได้ 500 ไม่ใช่ 400 — เพราะต้นฉบับ TypeScript เรียกvalue?.trim()ตรง ๆ ซึ่งจะโยนTypeErrorเมื่อค่าเป็นตัวเลขหรือ object และ ValidationPipe ไม่ได้ดักไว้ พอร์ต Go ทำซ้ำพฤติกรรมนี้อย่างเจาะจงด้วยข้อความvalue.trim is not a function(ฟังก์ชันtrimCrashในโค้ดมีอยู่เพื่อเรื่องนี้ล้วน ๆ) - หมวดหมู่ย่อยสถานะ inactive หายไปจากต้นไม้ทั้งหมด — endpoint ที่หน้าจอนี้ใช้คือ
.../treeและ.../hierarchical-dropdownซึ่งกรองstatus = 'active'เสมอ ในขณะที่GET /content-subcategories(endpoint รายการแบบแบ่งหน้า ที่หน้าจอนี้ ไม่ได้เรียกใช้เลย) กรองเพียงstatus != 'delete'— ตั้งสถานะเป็น inactive จึงเท่ากับ "ซ่อนจากหน้าจอนี้" และ กิ่งลูกทั้งหมดใต้มันจะโผล่ขึ้นมาเป็นรากใหม่ เพราะbuildTree()วางโนดที่หาแม่ในชุดข้อมูลไม่เจอไว้ที่ระดับราก - การจัดลำดับตรวจ "ต้องมีแม่เดียวกัน" ที่เซิร์ฟเวอร์จริง —
UpdateSortOrder()โหลดทุก id ที่ส่งมา ถ้าจำนวนแถวที่เจอไม่เท่ากับจำนวนรายการที่ส่ง จะได้ 400 "Some subcategories not found" แล้วจึงรวบparentIdทั้งหมดเป็น set (ใช้-1เป็น sentinel แทนค่า null) ถ้ามีมากกว่าหนึ่งค่าจะได้ 400 "Can only reorder subcategories within the same parent" หลังจากนั้นจึงตรวจสิทธิ์เจ้าของทีละแถว และอัปเดต แถวละคำสั่ง SQL ไม่ได้ห่อทรานแซกชัน — ถ้าล้มกลางทาง ลำดับจะค้างครึ่ง ๆ กลาง ๆ - การลบแบบบังคับยกเลิกการเชื่อมโยงหน้าเนื้อหา แต่ไม่แตะ
updated_date—Remove(force=true)เดินหา id ลูกหลานทั้งหมดแบบ recursive ก่อน แล้ว (1) เซ็ตsubcategory_id = NULLให้content_pageทุกแถวที่ผูกกิ่งเหล่านั้น (2) soft delete ลูกหลานทั้งหมด (3) soft delete ตัวเอง — ขั้นตอนที่ (1) และ (2) รันด้วยSkipHooksโดยเจตนา เพื่อทำซ้ำพฤติกรรมของ QueryBuilder ต้นฉบับที่ไม่ดัน@UpdateDateColumn— หน้าเนื้อหาที่ถูกยกเลิกการเชื่อมโยงจึงไม่มีร่องรอยว่า "เพิ่งถูกแก้" ในคอลัมน์อัปเดตล่าสุด - การลบแบบไม่บังคับตอบ 409 ที่ "ตกจำนวนที่กระทบ" หายไปจาก body — ต้นฉบับ TypeScript โยน
ConflictExceptionที่มี object แนบaffectedContentCountและhintมาด้วย แต่ตัวกรอง exception อ่านเฉพาะ.messageซึ่งเป็นสตริง ผลคือ body ที่ส่งกลับมีแต่ข้อความ "Cannot delete subcategory with dependencies" ความเห็นในโค้ด Go ระบุการตัดฟิลด์นี้ไว้อย่างชัดเจนว่าเป็นการทำซ้ำพฤติกรรมเดิม — ส่วนกรณีที่ลบสำเร็จ (force=trueหรือไม่มีสิ่งใดผูก) จะได้ body ครบ{ deleted, affectedContentCount, affectedChildrenCount } GET /content-subcategories/:idและ.../pathแยก 403 กับ 404 — query ค้นด้วยid + deleted_date IS NULLโดยไม่กรองline_oa_idแล้วเรียกvalidateOwnershipทีหลัง id ของ OA อื่นจึงตอบ 403 "You do not have permission to access this subcategory" ส่วน id ที่ไม่มีเลยตอบ 404 "Subcategory not found" —.../pathยังไล่ขึ้นไปหาแม่ทีละชั้นจนถึงราก แล้วคืนเป็นอาร์เรย์เรียงจากรากลงมา:idที่ไม่ใช่ตัวเลขทำให้ได้ 500 พร้อมข้อความinvalid input syntax— ทำซ้ำพฤติกรรม+idของ JavaScript เหมือนโมดูล Categories
เมนูที่เกี่ยวข้องกันในเส้นทางนี้
- Categories — เป็นหมวดแม่โดยตรงของโครงต้นไม้นี้ และเป็นจุดเข้าอีกทางหนึ่ง (ตัวเลขจำนวนหมวดย่อย / เมนู "ดูหมวดหมู่ย่อย") การลบหมวดหมู่หลักลบทั้งต้นไม้นี้ทิ้งไปด้วย
- Content Pages — ใช้
hierarchical-dropdownจากที่นี่มาเป็นตัวเลือกหมวดหมู่ย่อยตอนสร้าง/แก้ไขหน้าเนื้อหา และเป็นสิ่งที่ถูก "ยกเลิกการเชื่อมโยง" เมื่อสั่งลบกิ่งแบบบังคับ - Content Links — ใช้หมวดหมู่ย่อยเป็นหนึ่งในเงื่อนไขคัดเลือกเนื้อหา โดยเรียก endpoint
hierarchical-dropdownเดียวกัน - Menu Builder — เมื่อรายการเมนูอ้างอิงลิงก์รวมเนื้อหา เงื่อนไขหมวดหมู่ย่อยที่ตั้งไว้ก็มีผลกับสิ่งที่ผู้ใช้เห็นในเมนูนั้น
จุดที่ควรระวัง
- กล่องยืนยัน "ลบหมวดหมู่ย่อยที่มีรายการเกี่ยวข้อง" ที่ตั้งใจให้แสดงจำนวนที่กระทบไม่มีทางปรากฏ — หน้าเว็บอ่าน
affectedContentCountและaffectedChildrenCountจาก body ของ error 409 แต่ backend ตัดสองฟิลด์นั้นออกไป (ดูหัวข้อรายละเอียดข้างต้น) ทั้งสองค่าจึงเป็นundefined→ 0 เงื่อนไขที่จะเปิดกล่องไม่เคยเป็นจริง ผู้ใช้เห็นเพียงข้อความ error "ไม่สามารถลบหมวดหมู่ย่อยได้" — ผลคือ ปุ่ม "ลบต่อไป" (force delete) เข้าไม่ถึงจากหน้าจอ ต้องเรียกDELETE /api/content-subcategories/:id?force=trueเองถ้าจำเป็น - มุมมอง "ดูทั้งหมด" ยิง API หนึ่งคำขอต่อหนึ่งหมวดหมู่แบบเรียงต่อกัน — โค้ดวน
forแล้วawaitทีละหมวด ไม่ได้ยิงขนาน ยิ่งมีหมวดหมู่หลักเยอะ หน้ายิ่งโหลดช้าเป็นทวีคูณ (พร้อมกับคำขอ dropdown ตอนต้น) - ตั้งหมวดหมู่ย่อยเป็น inactive แล้วโครงต้นไม้เปลี่ยนรูป — กิ่งที่ปิดจะหายไปทั้งกิ่ง และลูกของมันจะเลื่อนขึ้นมาแสดงเป็นรากของหมวดนั้นแทน โดยที่ป้ายระดับ (L3/L4) ยังเป็นค่าเดิม ทำให้เห็นกิ่ง L3 อยู่ที่ตำแหน่งราก
- ตั้งชื่อหมวดหมู่ย่อยเป็นภาษาไทยจะได้ slug ว่าง — ตัวสร้าง slug ตัดทุกอักขระที่ไม่ใช่
[a-z0-9\s-]ทิ้ง โดยไม่ถอดอักษรไทย (โค้ดชุดเดียวกับเมนู Categories ต่างจากเมนู Content Pages ที่มีตารางถอดอักษร) ต้องพิมพ์ slug เอง - ย้ายกิ่งไปอยู่ใต้แม่คนอื่นทำไม่ได้จากหน้าจอ — ทั้งช่องหมวดหมู่หลักและหมวดหมู่ย่อยหลักถูกล็อกในโหมดแก้ไข และการลากวางจำกัดอยู่ในกลุ่มพี่น้องเดียวกัน (ที่ระดับ API นั้น
PATCH /:idรับparentIdได้จริงและเขียนค่าลงไปตรง ๆ โดยไม่คำนวณlevelใหม่ — จึงทำให้ข้อมูล level เพี้ยนได้ถ้าเรียก API ตรง) - ความยาวคำอธิบาย 1,000 ตัวอักษรเป็นข้อจำกัดฝั่งหน้าเว็บเท่านั้น — DTO ฝั่ง backend ตรวจแค่ว่าเป็นสตริง (ต่างจาก
name255 และthumbnail500 ที่มีจริง) - การจัดลำดับไม่ได้ห่อทรานแซกชัน — ถ้าอัปเดตล้มกลางทาง ลำดับของพี่น้องกลุ่มนั้นจะค้างไม่ครบ ให้ลากจัดใหม่อีกครั้ง
สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)
- ฝั่งหน้าเว็บ — หน้าต้นไม้และฟอร์ม ไม่ได้ครอบด้วย
PermissionGuardสิ่งเดียวที่ตรวจสิทธิ์คือการแสดงเมนูในแถบด้านข้าง ซึ่งเช็คability.can(VIEW, 'content-management')ครอบทั้งกลุ่ม Content Management — พิมพ์ URL/content-subcategoryตรง ๆ ยังเข้าได้แม้ไม่มีสิทธิ์ - ฝั่งเซิร์ฟเวอร์ — ทั้ง 12 เส้นทางมี policy ประกาศไว้ (
READALL/READ/CREATE/UPDATE/DELETEบน subjectline-oa) แต่วิ่งผ่านauth.CheckPoliciesที่เป็น no-op ปล่อยผ่านทุกคำขอ การบังคับจริงเหลือแค่ JWT +validateOwnershipที่เทียบline_oa_id(Super Admin ข้ามได้ทั้งหมด) และvalidateCategoryที่ตรวจline_oa_idของหมวดหมู่แม่อีกชั้นก่อนสร้าง - ไม่มี
ModuleGateในโมดูลนี้ — ตรวจcontentsubcategory/controller.goแล้วไม่พบการเรียกmodulegate.ModuleGate(d, "...")เลย (ต่างจากโมดูลcampaignที่มี) และไม่ปรากฏทั้งในรายการgatedModulesและexemptModulesของmodulegate/coverage_test.go— ผู้ดูแลระดับแพลตฟอร์มจึง ปิดโมดูลนี้ให้องค์กรใดไม่ได้ เพราะไม่มีจุดบังคับ - ขอบเขตข้อมูลถูกจำกัดด้วย LINE OA ในทุก endpoint ที่คืนหลายรายการ (
findAll,dropdown,by-category/*ล้วนกรองline_oa_idของผู้เรียก) — ยกเว้นfindOne,:id/pathและการตรวจ slug ซ้ำ ที่ไม่กรองline_oa_idในคำสั่ง query - ไม่มีเส้นทางสาธารณะ (
@Public) ในโมดูลนี้เลย
ไฟล์อ้างอิงจากซอร์สโค้ด
Frontend (cms-web-2026)
src/app/content-subcategory/page.tsx— มุมมองต้นไม้ทั้งสองแบบ, การลากวางจัดลำดับ (allowDrop/onDrop), ตรรกะการลบสองขั้น,MAX_LEVELและสีป้ายระดับsrc/app/content-subcategory/form/page.tsx— ฟอร์ม 7 ช่อง, การล็อกช่องในโหมดแก้ไข, คำใบ้ระดับ, ตัวสร้าง slug อัตโนมัติsrc/services/content-subcategory.service.ts— endpoint ที่หน้าเว็บเรียกใช้ (getTreeByCategoryId,getHierarchicalDropdownByCategoryId,updateSortOrder,deleteById(id, force))src/services/axios-instance.ts—handleAxiosErrorที่โยน body ของ error ออกมาตรง ๆ (สำคัญกับข้อควรระวังเรื่องกล่องยืนยันการลบ)messages/th/content-subcategory.json,messages/en/content-subcategory.json— ข้อความบนหน้าจอทั้งชุด
Backend (line-management-cms-api-go)
internal/modules/contentsubcategory/controller.go— 12 เส้นทาง, การไม่มี ModuleGate,jsUnaryPlusInt64และพฤติกรรม NaN → 500internal/modules/contentsubcategory/service.go—maxSubcategoryLevel,validateParentAndGetLevel,buildTree,GetHierarchicalDropdown(เส้น├─/└─),UpdateSortOrder,Removeแบบ force พร้อมSkipHooksinternal/modules/contentsubcategory/dto.go— transform lowercase/trim,trimCrash(พฤติกรรม 500), ช่องlevelที่ตรวจแต่ไม่ถูกใช้internal/modules/auth/policy.go—CheckPoliciesที่เป็น no-opinternal/modules/modulegate/coverage_test.go— รายการโมดูลที่มี ModuleGate จริงและโมดูลที่ยกเว้น
ดูรายละเอียดเชิงเทคนิคเพิ่มเติม (เวอร์ชันก่อนหน้าของทีมเอกสาร) ที่ หมวดหมู่ย่อยเนื้อหาแบบลำดับชั้น