Switch rich menu
ภาพรวม
Switch rich menu คือเมนูย่อยที่ผูกกับเมนูหลัก (Rich menu default หรือ Rich menu custom) ใช้เป็นปลายทางเมื่อผู้ใช้กดช่องชนิด "สลับริชเมนู" จากเมนูหลัก เหมาะกับการทำเมนูหลายชั้นหรือแบบแท็บ จุดสำคัญที่ต้องรู้ก่อนเริ่มคือ การเชื่อมโยงระหว่างเมนูใช้ชื่อ alias ที่คำนวณจากสูตรแฮชคงที่ ไม่ได้ค้นหาจากฐานข้อมูล ฉะนั้นถ้าเมนูปลายทางยังไม่เคยถูกส่งขึ้น LINE จริง ปุ่มสลับจะกดแล้ว "ไม่มีอะไรเกิดขึ้น" โดยไม่มีข้อความแจ้งเตือนใด ๆ
บทบาทที่ใช้งาน — ผู้ดูแลที่กำลังออกแบบเมนูหลายชั้นต่อจากเมนู Rich menu default/custom ที่มีอยู่แล้ว (โมดูลนี้มี ModuleGate บังคับจริงที่ backend — ดูหัวข้อ สิทธิ์การเข้าถึง)
เส้นทางผู้ใช้งาน
1. ดูรายการเมนูย่อย
เปิดเมนู Rich menu > Switch rich menu หน้านี้มีตัวกรอง 3 ช่อง — ค้นหา (จากชื่อ), ริชเมนูหลัก (ดรอปดาวน์แบ่งกลุ่มเป็น "ริชเมนูเริ่มต้น" และ "ริชเมนูกำหนดเอง" พร้อมรูปตัวอย่างเล็ก ๆ ข้างชื่อ) และ อัปเดตเมื่อ (ช่วงวันที่ กำกับ "(เวลากรุงเทพฯ)")
ตารางแสดงคอลัมน์ ลำดับ / รูปภาพ / ชื่อ / ริชเมนูหลัก / อัปเดตเมื่อ / จัดการ — สังเกตว่าหน้านี้ไม่มีคอลัมน์สถานะ (ต่างจากอีกสองหน้า) เพราะเมนูย่อยไม่มีสถานะให้ผู้ใช้กำหนดเอง
2. สร้างเมนูย่อย
- ต้องมีเมนูหลักอยู่ก่อนเสมอ เพราะฟอร์มบังคับให้ระบุว่าเป็นเมนูย่อยของเมนูใด
- กดสร้างใหม่ เข้าฟอร์ม
/rich-menu/form?type=switch— หรือเข้าทางลัดจากฟอร์มของเมนูหลักโดยตรง: ในช่องการกระทำชนิด "สลับริชเมนู" มีปุ่ม "สร้างใหม่" ที่เปิดแท็บใหม่ไปยังฟอร์มนี้พร้อมส่งparentIdมาให้เลย (ปุ่มนี้ถูกปิดเมื่อเมนูหลักไม่อยู่ในสถานะแบบร่างหรือปิดใช้งาน) - เลือกริชเมนูหลัก — ดรอปดาวน์แบ่งกลุ่มเป็นเมนูเริ่มต้นและเมนูกำหนดเอง ค้นหาด้วยชื่อได้ การเปลี่ยนเมนูหลักจะล้างพื้นที่กดและการกระทำที่ตั้งไว้ทั้งหมด และในหน้าแก้ไข ช่องนี้ถูกปิดถาวร — ย้ายเมนูย่อยไปอยู่ใต้เมนูหลักตัวอื่นไม่ได้
- เลือกเลย์เอาต์ / อัปโหลดรูป / ตั้งค่าการกระทำต่อช่อง — เหมือนอีกสองชนิดทุกอย่าง (15 เลย์เอาต์ 3 แท็บ, รูป JPG/PNG ไม่เกิน 1 MB ตามความละเอียดที่กำหนด, การกระทำ 5 แบบ)
- ฟอร์มนี้ไม่มีช่องเลือกสถานะเลย ทั้งตอนสร้างและตอนแก้ไข — ระบบซ่อนไว้และตั้งค่าเป็น "เปิดใช้งาน" (active) ให้อัตโนมัติ ผลคือเมนูย่อยถูกส่งขึ้น LINE ทันทีที่บันทึก และเพราะสถานะเป็น active ตั้งแต่แรก ช่องในส่วนตั้งค่าการกระทำจึงบังคับกรอกทุกช่อง (ไม่มีโหมดแบบร่างให้เก็บงานค้างไว้)
- หนึ่งในตัวเลือกการกระทำคือ "สลับริชเมนู" ชี้กลับไปยังเมนูหลักหรือไปยังเมนูย่อยพี่น้องกันได้ ดรอปดาวน์แบ่งเป็นสองกลุ่ม — "ริชเมนูหลัก" และ "ริชเมนูย่อย" — พร้อมรูปตัวอย่างของเมนูปลายทางที่เลือก และมีข้อความกำกับว่า "คุณสามารถเลือกได้เฉพาะริชเมนูที่มีริชเมนูหลักเดียวกันเท่านั้น"
- กดบันทึก ยืนยัน แล้วระบบเรียก
POST /api/rich-menu
3. ดูสถิติและจัดการเมนูย่อยที่มีอยู่
- กดปุ่มแว่นตาเพื่อดูโหมดอ่านอย่างเดียว พร้อมตัวอย่างเมนูและตารางสถิติการคลิกรายช่อง และปุ่ม ซิงค์ใหม่ สำหรับสั่งคำนวณสถิติใหม่
- แก้ไขได้เฉพาะเมื่อเมนูหลักอยู่ในสถานะแบบร่างหรือปิดใช้งานเท่านั้น (สำหรับเมนูหลักชนิด custom) และแก้ไม่ได้เมื่อเมนูย่อยเองอยู่ในสถานะเสร็จสิ้น หมดอายุ หรือถูกลบ — tooltip บนปุ่มจะบอกเหตุผลตรง ๆ (เช่น "ไม่สามารถแก้ไขได้: ริชเมนูหลักต้องมีสถานะแบบร่างหรือปิดใช้งาน")
- ลบไม่ได้เมื่อเมนูย่อยเองหรือเมนูหลักอยู่ในสถานะ "เสร็จสิ้น" — สถานะอื่นลบได้ (ต้องยืนยันก่อน)
รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง
ยืนยันจาก internal/modules/richmenu/{service,update,create_update}.go, internal/modules/linemessageapi/richmenu.go, internal/core/httpx/codes.go ของ line-management-cms-api-go:
- ชนิด
switchเป็นชนิดเดียวที่ backend ตรวจกฎสถานะให้จริง — ฟังก์ชันValidateStatusTransition/ValidateStatusForAction/ValidateStatusForDeleteทั้งสามตัวเปิดด้วยif type == switchแล้วผ่านไปเลยสำหรับชนิดอื่น กฎที่บังคับจริงคือ: แบบร่างเปลี่ยนไปได้เฉพาะ "เปิดใช้งาน", เปิดใช้งานเปลี่ยนไปได้เฉพาะ "เปิดใช้งาน/ปิดใช้งาน/ลบ", ปิดใช้งานเปลี่ยนไปได้เฉพาะ "ลบ", และ ห้ามย้อนกลับจาก "ปิดใช้งาน" หรือ "ลบ" นอกจากนี้ยังห้ามแก้ไขเมนูย่อยที่อยู่ในสถานะ ปิดใช้งาน / ลบ / เสร็จสิ้น / หมดอายุ (ตอบ 400"Cannot modify rich menu in current status") และห้ามลบเมนูย่อยที่สถานะ "เสร็จสิ้น" - ผลข้างเคียงที่ตามมาจากสองข้อรวมกัน: เมนูย่อยที่ถูกปิดใช้งานแล้วจะแก้ไขผ่านหน้าเว็บไม่ได้อีกเลย — เพราะฟอร์มซ่อนช่องสถานะและส่ง
status = activeขึ้นมาตลอด แต่แถวจริงเป็นinactive(โดยมากถูกไล่เปลี่ยนมาจากเมนูหลัก) การบันทึกจึงถูกปฏิเสธด้วยกฎ "ห้ามย้อนกลับจากปิดใช้งาน" — และถูกปฏิเสธซ้ำอีกชั้นด้วยกฎ "ห้ามแก้ไขเมนูในสถานะปัจจุบัน" ทางเดียวคือสร้างเมนูย่อยใหม่ - การเชื่อมโยงปุ่มสลับใช้แฮชคงที่ ไม่มีการค้นหาในฐานข้อมูล — ตอนสร้างพื้นที่กดชนิดสลับ ระบบคำนวณ
richMenuAliasIdเป็นrichmenu-alias-+md5("{organizationId}_{lineOaId}_{รหัสเมนูปลายทาง}")16 ตัวแรก และตั้งdataเป็นrichmenu-changed-to-+ แฮชเดียวกัน ขณะที่เมนูแต่ละตัวลงทะเบียน alias ของตัวเองบน LINE ด้วยสูตรเดียวกันเป๊ะ — สองฝั่งจึงมาบรรจบกันโดยไม่ต้องเก็บความสัมพันธ์ไว้ที่ใดเลย ข้อดีคือline_rich_menu_idเปลี่ยนได้ทุกครั้งที่บันทึกทับโดยไม่ต้องแก้ปุ่มสลับ ข้อเสียคือถ้าเมนูปลายทางยังไม่เคยถูกส่งขึ้น LINE alias นั้นก็ไม่มีอยู่จริงบน LINE และการกดปุ่มจะไม่เกิดอะไรขึ้น - ระบบไม่ได้ตรวจว่าเมนูปลายทางพร้อมใช้จริงหรือไม่ก่อนบันทึก — โค้ดข้อความแสดงข้อผิดพลาด
RMN_003("Rich menu alias is not active") มีประกาศอยู่ในตารางรหัสข้อผิดพลาด และหน้าเว็บมีการรองรับทั้งRMN_003และRMN_004ไว้พร้อมข้อความอธิบาย แต่ โมดูลริชเมนูฝั่ง Go ไม่เคยส่งรหัสทั้งสองออกมาเลย (RMN_004ไม่ถูกประกาศในตารางรหัสด้วยซ้ำ) ผลคือการชี้ปุ่มสลับไปยังเมนูที่ยังไม่พร้อมจะ "บันทึกผ่าน" แล้วล้มเงียบตอนผู้ใช้กดบนแอป LINE - ดรอปดาวน์เลือกปลายทางในฟอร์ม Switch กรองสถานะไม่เหมือนฟอร์มอื่น — เมื่ออยู่ในฟอร์มของเมนูหลัก (default/custom) รายการเมนูย่อยที่ให้เลือกถูกกรองด้วย
status = activeแต่เมื่ออยู่ในฟอร์ม Switch เอง กลับไม่ส่งเงื่อนไขสถานะขึ้นไปเลย จึงเห็นเมนูย่อยพี่น้องทุกสถานะรวมทั้งที่ยังไม่เปิดใช้งาน — นี่คือช่องทางหลักที่ทำให้เกิดปุ่มสลับที่ชี้ไปยังเมนูที่ไม่พร้อมตามข้อก่อนหน้า ทั้งสองกรณีกรองด้วยparentIdเดียวกัน และตัดเมนูตัวเองออกจากรายการเสมอในหน้าแก้ไข - ทุกครั้งที่บันทึกทับ เมนูย่อยถูกสร้างใหม่บน LINE แล้วลบตัวเก่าทิ้ง — เหมือนอีกสองชนิด และ alias จะถูก update ก่อน ถ้าไม่สำเร็จจึงถอยไป create ใหม่ ฉะนั้นปุ่มสลับจากเมนูหลักไม่ต้องแก้ตามเลย
- การลบเมนูย่อยไม่ได้แก้เมนูหลักให้เลย — โค้ดในเส้นทางลบไปเก็บ "รายชื่อเมนูย่อยที่ต้องลบตาม" เฉพาะเมื่อเมนูที่กำลังลบเป็นชนิด custom เท่านั้น การลบเมนูย่อยจึงเป็นแค่ soft delete ตัวเอง (
status = delete+ ประทับdeleted_date) แล้วพยายามลบออกจาก LINE แบบ best-effort — พื้นที่กดชนิดสลับบนเมนูหลักยังชี้ไปที่ alias ที่ตายแล้ว จนกว่าจะเข้าไปแก้และบันทึกเมนูหลักใหม่ - สถิติของเมนูย่อยไม่ได้ใช้กลไก "เทียบ archive สองรอบล่าสุด" แบบเมนูตั้งต้น — กลไกคำนวณสดนั้นทำงานกับชนิด guest/member เท่านั้น (และแถว
rich_menu_archiveก็ถูกสร้างเฉพาะสองชนิดนั้น) เมนูย่อยจึงอ่านค่าจากtemplate_trackingที่ worker เขียนไว้ตรง ๆ - การติดตามคลิกของปุ่มสลับเก็บผ่าน webhook ไม่ใช่ลิงก์ redirect — พื้นที่กดชนิดสลับถูกแปลงเป็น LINE action ชนิด
richmenuswitchพร้อมแนบ metadata (contentType: rich_menu,type: richmenu_switch,organizationId,lineOaId,richMenuId,richMenuActionIndex,richMenuArchiveId) ลงในฟิลด์data— และเพราะเหตุการณ์นี้ไม่ผ่านบริการ redirect มันจึงไม่ปักธงRICH_MENU_STAT_DIRTYทำให้ cron รายชั่วโมงไม่หยิบเมนูนั้นไปคำนวณสถิติใหม่ ต้องกดปุ่ม ซิงค์ใหม่ เอง (รายละเอียดในหน้า Rich menu default)
เมนูที่เกี่ยวข้องกันในเส้นทางนี้
- Rich menu default — เป็นเมนูหลักได้ (ชนิด guest หรือ member) และเป็นที่ที่ผู้ใช้จะกดปุ่มสลับมาถึงเมนูย่อยนี้ ในฟอร์มของเมนูตั้งต้น รายการเมนูย่อยที่ให้เลือกถูกกรองเฉพาะที่สถานะ
active - Rich menu custom — เป็นเมนูหลักได้เช่นกัน และเป็นชนิดที่มีผลกับเมนูย่อยมากที่สุด: สถานะของเมนูย่อยจะถูกไล่เปลี่ยนตามเมื่อเมนู custom หมดอายุหรือถูกลบ (พร้อมเขียนเหตุผลลงคอลัมน์
reasonว่ามาจากเมนูหลักตัวใด) และสิทธิ์ในการแก้ไข/ลบเมนูย่อยก็ขึ้นกับสถานะของเมนู custom นั้น - Segment audience — ไม่เกี่ยวกับเมนูย่อยโดยตรง แต่เป็นตัวกำหนดว่าใครจะเห็นเมนูหลักชนิด custom (และจึงจะกดมาถึงเมนูย่อยนี้ได้)
- Workflow / Trigger rule — สั่งสลับริชเมนูให้ผู้ใช้รายคนได้โดยตรงผ่านงานคิว
line_change_richmenuชนิดsetRichMenuByTriggerRuleโดยไม่ต้องรอให้ผู้ใช้กดปุ่มเอง
จุดที่ควรระวัง
- ตรวจให้แน่ใจว่าเมนูปลายทางถูกส่งขึ้น LINE แล้วจริง ก่อนชี้ปุ่มสลับไปหา — ระบบไม่ตรวจให้ ไม่มีข้อความเตือน และผู้ใช้ปลายทางจะเจอปุ่มที่กดแล้วเงียบ
- เมนูย่อยที่ถูกปิดใช้งานไปแล้วแก้ไขไม่ได้อีก ต้องสร้างใหม่แทน (ดูรายละเอียดข้างต้น)
- เลือกเมนูหลักผิดตอนสร้างต้องสร้างใหม่ — ช่อง "ริชเมนูหลัก" ถูกปิดถาวรในหน้าแก้ไข ย้ายไปอยู่ใต้เมนูหลักตัวอื่นไม่ได้
- การเปลี่ยนเมนูหลักในหน้าสร้างจะล้างพื้นที่กดและการกระทำที่ตั้งไว้ทั้งหมด — ควรเลือกเมนูหลักให้เสร็จก่อนเริ่มตั้งค่าการกระทำ
- ลบเมนูย่อยแล้วต้องเข้าไปแก้เมนูหลักด้วย — ไม่เช่นนั้นปุ่มสลับบนเมนูหลักจะชี้ไปที่ปลายทางที่ไม่มีอยู่แล้ว
- ฟอร์มนี้ไม่มีโหมดแบบร่าง — เพราะสถานะถูกบังคับเป็น "เปิดใช้งาน" ทุกช่องจึงบังคับกรอกครบ ไม่สามารถเก็บงานที่ทำค้างไว้ได้
- หลีกเลี่ยงเลย์เอาต์แบบที่ 15 เช่นเดียวกับอีกสองชนิด — พื้นที่กดที่วาดไว้จะไม่ทำงานบน LINE จริง
สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)
- ฝั่งหน้าเว็บ — หน้านี้ถูกครอบด้วย
PermissionGuardตรวจ actionVIEWของ subjectrich-menu-switchซึ่งเป็น subject แยกจากrich-menu-defaultและrich-menu-custom - ฝั่งเซิร์ฟเวอร์ — ทุกเส้นทางถูกครอบด้วย
ModuleGate("rich-menu")ที่บังคับใช้จริง และเป็น gate ตัวเดียวที่คุมทั้งสามชนิดพร้อมกัน (Default / Custom / Switch อยู่บนตารางrich_menuเดียวกัน แยกด้วยคอลัมน์type) — ปิดโมดูลrich-menuให้องค์กรใด ทั้งสามหน้าจะได้ 403 พร้อมกัน ไม่มีทางเปิดให้ใช้เฉพาะเมนูย่อยหรือปิดเฉพาะเมนูย่อยที่ระดับ backend - ชนิดนี้เป็นชนิดเดียวที่มีการบังคับกฎ "การกระทำใดทำได้ในสถานะใด" ที่ฝั่งเซิร์ฟเวอร์จริง — ต่างจาก Default/Custom ที่กฎสถานะทั้งหมดอยู่ฝั่งหน้าเว็บ การเรียก API ตรงเพื่อแก้ไขเมนูย่อยที่ปิดใช้งาน/เสร็จสิ้น/หมดอายุ จะถูกปฏิเสธด้วย 400 จริง
- การกันข้ามองค์กร (IDOR) —
findById(ซึ่งPUTและDELETEเรียกใช้เป็นจังหวะแรก) ผูกline_oa_idของผู้เรียกเสมอ (ไม่พบได้ 400APP_007),recalculate-statเช็กความเป็นเจ้าของก่อนส่งงานเข้าคิว (ไม่พบได้ 404) และคำสั่ง soft delete ทุกคำสั่งผูกline_oa_id - policy annotation ระดับการกระทำมีประกาศไว้ครบแต่วิ่งผ่าน middleware ที่เป็น no-op — การบังคับระดับโมดูลอยู่ที่ ModuleGate ส่วนการบังคับระดับสถานะอยู่ในตรรกะของ service
DELETE /api/rich-menu/:idเป็น soft delete ส่วนDELETE /api/rich-menu/:id/hardต้องเป็น Super Admin และส่งconfirm=trueมาด้วยเท่านั้น
ไฟล์อ้างอิงจากซอร์สโค้ด
Frontend (cms-web-2026)
src/app/rich-menu/switch/page.tsx— จุดเข้าหน้ารายการ (PermissionGuardsubjectrich-menu-switch)src/components/rich-menu/list/rich-menu-switch-filter.tsx— ตัวกรอง 3 ช่องเฉพาะของชนิด switch (ค้นหา / ริชเมนูหลัก / อัปเดตเมื่อ)src/components/rich-menu/list/rich-menu.container.tsx— คอลัมน์ริชเมนูหลักและอัปเดตเมื่อ, การซ่อนคอลัมน์สถานะ, กฎ disable ปุ่มแก้ไข-ลบตามสถานะเมนูหลักsrc/components/rich-menu/form/action-setting/action-setting-render-type.tsx—RenderTypeAlias: ดรอปดาวน์แบ่งกลุ่ม, ปุ่ม "สร้างใหม่" ที่ส่งparentId, รูปตัวอย่างปลายทางsrc/components/rich-menu/form/default/rich-menu-default-form.container.tsx— การกรองรายการปลายทางด้วยparentId(และเงื่อนไขstatusที่ต่างกันตามชนิดฟอร์ม), การรับparentIdจาก URLsrc/components/rich-menu/form/default/components/RichMenuStatusRadioGroup.tsx— การซ่อนช่องสถานะทั้งหมดสำหรับชนิด switchsrc/services/rich-menu.service.ts— endpoint ทั้งหมดที่หน้าเว็บเรียกใช้
Backend (line-management-cms-api-go)
internal/modules/richmenu/service.go—ValidateStatusTransition/ValidateStatusForAction/ValidateStatusForDelete/GetAvailableStatuses(ทั้งหมดมีผลกับชนิด switch เท่านั้น) และเส้นทางลบinternal/modules/richmenu/update.go— การตรวจกฎสถานะก่อนแก้ไข, การไล่เปลี่ยนสถานะจากเมนูหลัก, การจัดการ alias แบบ update-แล้วถอยไป-createinternal/modules/richmenu/create_update.go—generateRichMenuAliasIdและลำดับการเรียก LINE ตอนสร้างinternal/modules/richmenu/util.go— สูตรแฮชของ alias และกฎตรวจความถูกต้องของชื่อ alias (ไม่ว่าง, ไม่เกิน 255 ตัว, เฉพาะa-z A-Z 0-9 - _)internal/modules/linemessageapi/richmenu.go— การแปลงพื้นที่กดชนิดสลับเป็น LINE actionrichmenuswitchและ metadata ที่แนบไปinternal/core/httpx/codes.go— รหัสRMN_003ที่ประกาศไว้แต่ไม่มีโมดูลใดส่งออกมา
ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ ริชเมนู (Default / Custom / Switch)