Skip to main content

Rich menu custom

ภาพรวม​

Rich menu custom คือริชเมนูที่ใช้เฉพาะช่วงเวลาที่กำหนด และเห็นเฉพาะคนในกลุ่มเป้าหมายที่เลือกจากเมนู Segment audience เหมาะกับแคมเปญตามฤดูกาลหรือโปรโมชันชั่วคราวที่ต้องการเมนูต่างจากปกติ จุดสำคัญที่ต้องรู้ก่อนเริ่มคือ การกดบันทึกไม่ได้ผูกเมนูให้ผู้ใช้ทันที — cms-api เพียงอ่านรายชื่อ LINE user ของกลุ่มเป้าหมายนั้นมาเก็บไว้ ส่วนการเรียก LINE ผูกเมนูให้ผู้ใช้จริงเป็นงานของ worker ที่วนตรวจทุก 5 นาที และจะเริ่มทำเมื่อถึงวันเวลาเริ่มที่ตั้งไว้แล้วเท่านั้น

บทบาทที่ใช้งาน — ทีมการตลาดที่ทำแคมเปญจำกัดกลุ่มหรือจำกัดเวลา (โมดูลนี้มี ModuleGate บังคับจริงที่ backend — ดูหัวข้อ สิทธิ์การเข้าถึง)

เส้นทางผู้ใช้งาน​

1. ดูรายการเมนูกำหนดเอง​

เปิดเมนู Rich menu > Rich menu custom หน้านี้มีแถบตัวกรอง 3 ช่อง — ค้นหา (จากชื่อ), สถานะ (มีให้เลือก 4 ค่า: เปิดใช้งาน / แบบร่าง / ปิดใช้งาน / หมดอายุ) และ ช่วงเวลา (ระบุเป็นช่วงวันที่ กำกับด้วย "(เวลากรุงเทพฯ)") ค่าที่กรองไว้ถูกจดจำใน sessionStorage ระหว่างที่อยู่ในหน้านี้

ตารางแสดงคอลัมน์ ลำดับ / รูปภาพ / ชื่อ / กลุ่มเป้าหมาย / ช่วงเวลา (เริ่ม–สิ้นสุด) / สถานะ / จัดการ เมนูที่ทำเครื่องหมายให้ระบบอัตโนมัติใช้จะมีป้าย "ระบบอัตโนมัติ" สีฟ้าต่อท้ายชื่อ และช่องกลุ่มเป้าหมายจะเป็น -

ปุ่มแก้ไขและลบถูกปิดตามสถานะ พร้อม tooltip อธิบายเหตุผลตรง ๆ (เช่น "ไม่สามารถแก้ไขได้: ริชเมนูกำหนดเองหมดอายุแล้ว")

2. สร้างเมนูกำหนดเอง​

  1. กดสร้างใหม่ เข้าฟอร์ม /rich-menu/form?type=custom
  2. กรอกชื่อ — ต้องไม่ซ้ำกับริชเมนูอื่นใน OA เดียวกัน
  3. เลือกวิธีกำหนดผู้เห็นเมนู — เปิดสวิตช์ "สำหรับระบบอัตโนมัติ / กฎการทำงาน" เพื่อให้ Workflow/Trigger rule เป็นผู้สั่งเปลี่ยนเมนูรายคน หรือปิดสวิตช์แล้วเลือกกลุ่มเป้าหมายจาก Segment audience (บังคับเลือก 1 กลุ่ม เมื่อสวิตช์ปิด)
  4. ระบุช่วงวันเวลาเริ่ม–สิ้นสุด (บังคับกรอกเสมอ แม้จะเปิดสวิตช์ระบบอัตโนมัติแล้วก็ยังบังคับ) — ตัวเลือกวันในอดีตถูกปิดไว้ และถ้าเลือกวันเป็นวันนี้ ชั่วโมง/นาทีที่ผ่านมาแล้วก็ถูกปิดด้วย
  5. เลือกเลย์เอาต์ / อัปโหลดรูป / ตั้งค่าการกระทำต่อช่อง — เหมือนเมนู Rich menu default ทุกอย่าง (15 เลย์เอาต์ 3 แท็บ, รูป JPG/PNG ไม่เกิน 1 MB ตามความละเอียดที่กำหนด, การกระทำ 5 แบบ, ป้ายชื่อการกระทำไม่เกิน 20 ตัวอักษร)
  6. เลือกสถานะ — เป็นชนิดเดียวในสามชนิดที่มีช่องเลือกสถานะให้จริง ค่าเริ่มต้นคือ "แบบร่าง" เลือกเป็น "เปิดใช้งาน" ได้ ถ้าเลือก "เปิดใช้งาน" แล้ววันเริ่มหรือวันสิ้นสุดอยู่ในอดีต ระบบจะไม่ให้ผ่าน (ตรวจสองทาง ทั้งเมื่อแก้วันที่และเมื่อแก้สถานะ)
  7. กดบันทึก ยืนยัน แล้วระบบเรียก POST /api/rich-menu

ถ้าเลือกสถานะ "แบบร่าง" ระบบจะผ่อนคลายช่องในส่วนตั้งค่าการกระทำให้ไม่บังคับกรอก (ลิงก์ / ข้อความ / เบอร์โทร / ป้ายชื่อ / ประเภทการทำงาน) จึงเก็บงานที่ยังทำไม่เสร็จไว้ได้ และเมนูแบบร่างจะไม่ถูกส่งขึ้น LINE เลย

3. รอให้ระบบผูกเมนูให้ผู้ใช้​

  1. เมื่อถึงวันเวลาเริ่มที่ตั้งไว้ งานเบื้องหลัง (รอบทุก 5 นาที) จะหยิบเมนูที่สถานะ active และยังไม่เคยผูกผู้ใช้ ขึ้นมาทำงาน
  2. ระบบดึงรายชื่อ LINE user ที่บันทึกไว้ ตัดชื่อซ้ำ แล้วเรียก LINE ผูกเมนูทีละชุด 500 คน
  3. เสร็จแล้วระบบเขียนจำนวนผู้ใช้ที่ผูกได้ลงคอลัมน์ total และเปลี่ยนสถานะเป็น "เสร็จสิ้น" (completed) — สถานะนี้หมายถึง "ผูกให้ทุกคนครบแล้ว" ไม่ใช่ "แคมเปญจบแล้ว"
  4. เมื่อเลยวันเวลาสิ้นสุด งานรอบเดียวกันจะถอนเมนูออกจากผู้ใช้ (แยกทำระหว่างกลุ่ม guest และ member) แล้วเปลี่ยนสถานะเป็น "หมดอายุ" (expired)

4. ดูสถิติและจัดการเมนูที่มีอยู่​

  • กดปุ่มแว่นตาเพื่อดูโหมดอ่านอย่างเดียว พร้อมตัวอย่างเมนูและตารางสถิติการคลิกรายช่อง (ตำแหน่ง / ข้อมูล / ประเภทการทำงาน / คลิกทั้งหมด / คลิกไม่ซ้ำ) และปุ่ม ซิงค์ใหม่ สำหรับสั่งคำนวณสถิติใหม่
  • แก้ไขได้เมื่อสถานะเป็น "เปิดใช้งาน" หรือ "ปิดใช้งาน" เท่านั้น — แก้ไม่ได้เมื่อ "เสร็จสิ้น" หรือ "หมดอายุ" และแก้ไม่ได้ถ้าสถานะเป็น "เปิดใช้งาน" แต่วันเริ่มหรือวันสิ้นสุดผ่านไปแล้ว
  • ลบไม่ได้เมื่อสถานะเป็น "เสร็จสิ้น" — สถานะอื่นลบได้ (ต้องยืนยันก่อน) การลบเป็น soft delete และจะพาเมนูย่อยใน Switch rich menu ที่ผูกไว้ไปด้วย

รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง​

ยืนยันจาก internal/modules/richmenu/{create_update,update,service}.go ของ line-management-cms-api-go และ internal/richmenu/service_cron.go, internal/cronscheduler/rich_menu_lifecycle_scanner.go ของ line-management-worker-go:

  • การผูกกลุ่มเป้าหมายตอนบันทึกคือการ "อ่าน CSV แล้วเก็บรายชื่อไว้" ไม่ใช่การเรียก LINE — cms-api หา lineUserIdsPathFilename จากข้อมูลของกลุ่มเป้าหมายนั้น (จำกัดขอบเขตด้วย lineOaId + organizationId ของผู้เรียก) แล้วโหลดไฟล์ CSV จาก object storage เข้าตารางชั่วคราวของ DuckDB (ตั้งชื่อว่า {lineOaHash}_{audienceId}_{เวลาเป็นมิลลิวินาที}) อ่านออกมาทีละหน้า 10,000 แถว จนหมด ทำลายตารางชั่วคราวทิ้ง แล้วเก็บรายชื่อทั้งก้อนเป็น JSON array ลงตาราง rich_menu_member — จบที่นี่ ไม่มีการเรียก LINE ผูกผู้ใช้แม้แต่คนเดียวในจังหวะนี้
  • การผูกจริงเกิดที่ worker และผูกเป็นชุดละ 500 คน — cron รอบ ทุก 5 นาที ส่งงานเข้าคิว line_change_richmenu ให้ worker หยิบเมนูที่ type = custom, status = active, complete_link_user = false, start_date <= ตอนนี้ < end_date แล้วเรียก linkRichMenuIdToUsers ทีละ 500 คน (เพดานของ LINE) เสร็จแล้วอัปเดต complete_link_user = true, total = จำนวนผู้ใช้, status = completed ในคำสั่งเดียว — รอบ 5 นาทีเลือกไว้ตั้งใจ เพราะงานนี้ไม่มีกลไก claim กันงานซ้อน รอบ 1 นาทีจะทำให้งานรอบใหม่เริ่มก่อนรอบเก่าจบและยิง LINE ซ้ำ
  • สถานะ "เสร็จสิ้น" (completed) มาจาก worker ไม่ใช่จากคน และหมายถึง "ผูกครบแล้ว" — เป็นเหตุผลที่แก้ไขและลบไม่ได้อีก และเป็นสาเหตุที่เมนูซึ่งกลุ่มเป้าหมายไม่มีสมาชิกเลย จะกระโดดไปเป็น completed ทันทีโดยไม่เคยถูกผูกให้ใคร (โค้ดเช็ก: ไม่พบแถว rich_menu_member หรือมีแต่รายชื่อว่าง → เขียน completed แล้วข้าม)
  • เมนูที่เปิดสวิตช์ "ระบบอัตโนมัติ" จะไม่เคยถูก cron ผูกให้ใครเลย — ตอนบันทึก ระบบตั้ง complete_link_user = true และ audience_id = NULL ให้ทันที ทำให้เงื่อนไข complete_link_user = false ของ cron ไม่ตรงตลอดไป การผูกเมนูรายคนจึงเป็นหน้าที่ของ Workflow/Trigger rule ทั้งหมด — แต่เมนูนั้นยังหมดอายุตามวันสิ้นสุดที่ตั้งไว้อยู่ เพราะงานกวาดขาที่สองจับเมนูที่ complete_link_user = true และเลยวันสิ้นสุดแล้วมาเปลี่ยนเป็น expired
  • มีการกวาดเมนูค้างเพิ่มขึ้นมาก่อนทุกจังหวะที่ต้องคุยกับ LINE — งาน cron เริ่มด้วยคำสั่ง UPDATE ... RETURNING id เดียวที่เปลี่ยนเมนู custom ซึ่งเลยวันสิ้นสุดแต่ยังไม่เคยผูกผู้ใช้ (complete_link_user = false) ให้เป็น expired — วางไว้ก่อนโดยเจตนา เพราะจังหวะหลังจากนี้ทุกจังหวะจะ return ทันทีเมื่อ LINE ตอบผิดพลาด (เช่น channel token ถูกเพิกถอน) ซึ่งจะทำให้เมนูค้างไม่ถูกเคลียร์ตลอดไปในช่วงที่ต้องการมากที่สุด
  • การเปลี่ยนสถานะเป็น "หมดอายุ" หรือ "ลบ" จะไล่เปลี่ยนสถานะเมนูย่อยทั้งหมดตามไปด้วย — เมื่อบันทึกเมนู custom ด้วยสถานะ expired หรือ delete ระบบค้นหาเมนูชนิด switch ที่ parent_id เป็นเมนูนี้ (และยังไม่ถูกลบ) แล้วเขียนสถานะเดียวกันลงไปพร้อมเหตุผลว่า "Parent rich menu {id} status changed to {status}" — การลบก็เช่นกัน โดยเขียนเหตุผลว่า "Parent rich menu {id} was deleted" แล้วจึงพยายามลบทั้งเมนูแม่และเมนูย่อยออกจาก LINE แบบ best-effort (ล้มเหลวแค่เขียน log ไม่ทำให้คำขอล้ม)
  • ฝั่ง backend ไม่ตรวจกฎสถานะของเมนู custom เลย — ValidateStatusTransition / ValidateStatusForAction / ValidateStatusForDelete ทั้งสามฟังก์ชันเปิดด้วย if type == switch กฎทั้งหมดที่เห็นในหน้าเว็บ (แบบร่าง→เปิดใช้งาน, เปิดใช้งาน↔ปิดใช้งาน, เสร็จสิ้น/หมดอายุแก้ไม่ได้) เป็นการบังคับฝั่งหน้าเว็บล้วน ๆ การเรียก API ตรงข้ามหน้าเว็บทำได้
  • การตรวจชื่อซ้ำตอนสร้างและตอนแก้ไขใช้เงื่อนไขไม่เหมือนกัน — ตอนสร้างมองเฉพาะแถวที่ status NOT IN ('delete') แต่ตอนแก้ไขตรวจแค่ title + line_oa_id + id != ตัวเอง โดยไม่ตัดแถวที่ถูกลบออก ฉะนั้นชื่อที่เคยใช้กับเมนูที่ลบไปแล้วจะยังใช้ "สร้างใหม่" ได้ แต่จะ "แก้ไข" เมนูอื่นให้มาใช้ชื่อนั้นไม่ได้ (ได้ 400 "Title is already exist")
  • วันเวลาที่กรอกถูกตีความเป็นเวลากรุงเทพฯ ตรง ๆ — หน้าเว็บส่งขึ้นมาเป็นสตริง YYYY-MM-DD HH:mm แล้ว backend ต่อท้ายด้วย :00+07:00 ก่อนแปลงเป็นเวลาจริง (รองรับทั้งรูปแบบเว้นวรรคและ T คั่น) ถ้าแปลงไม่ได้จะเก็บเป็น NULL เงียบ ๆ ไม่ใช่ error
  • containerOriginal ที่ใช้คำนวณพื้นที่กดของเลย์เอาต์กำหนดเองถูกบันทึกทั้งแบบที่ 14 และ 15 (แบบ 14 = 480×324, แบบ 15 = 480×162) แต่โค้ดที่แปลงพื้นที่ที่ผู้ใช้ลากเป็นพิกัดบนภาพจริง (imagemap) ทำงานกับ แบบที่ 14 เท่านั้น — เลย์เอาต์แบบที่ 15 จึงส่งพื้นที่กดขึ้น LINE เป็นศูนย์ช่อง (รายละเอียดในหน้า Rich menu default)

เมนูที่เกี่ยวข้องกันในเส้นทางนี้​

  • Segment audience — จำเป็นเสมอเมื่อไม่ได้เปิดสวิตช์ระบบอัตโนมัติ และเป็นที่มาของไฟล์ CSV รายชื่อ LINE user ที่ใช้ผูกเมนู — ถ้ากลุ่มเป้าหมายนั้นยังไม่มีรายชื่อพร้อม เมนูจะกลายเป็น "เสร็จสิ้น" ทั้งที่ไม่เคยมีใครเห็น
  • Switch rich menu — เมนู custom เป็น "เมนูหลัก" ให้เมนูย่อยผูกได้ และสถานะของเมนูย่อยถูกไล่เปลี่ยนตามเมนูหลักเมื่อเมนูหลักหมดอายุหรือถูกลบ ส่วนสิทธิ์ในการแก้ไขเมนูย่อยก็ขึ้นกับสถานะของเมนู custom ตัวนี้ด้วย
  • Rich menu default — ใช้ฟอร์มและโมดูลเดียวกัน ต่างกันที่เมนูตั้งต้นไม่มีช่วงเวลาและกลุ่มเป้าหมาย และเมนูตั้งต้นจะถูกตั้งเป็นค่าเริ่มต้นของ OA
  • Workflow / Trigger rule — เป็นผู้สั่งใช้เมนูที่เปิดสวิตช์ "ระบบอัตโนมัติ" ผ่านงานคิว line_change_richmenu ชนิด setRichMenuByTriggerRule

จุดที่ควรระวัง​

  • บันทึกสำเร็จไม่ได้แปลว่าผู้ใช้เห็นเมนูแล้ว — ต้องรอถึงวันเวลาเริ่ม แล้วรออีกไม่เกิน 5 นาทีให้งานเบื้องหลังผูกเมนูให้ (กลุ่มใหญ่จะใช้เวลานานกว่านั้น เพราะผูกทีละ 500 คน)
  • สถานะ "เสร็จสิ้น" ไม่ได้แปลว่าแคมเปญจบ แต่แปลว่า "ผูกให้ทุกคนครบแล้ว" — และพอถึงสถานะนี้จะแก้ไขและลบไม่ได้อีก ถ้าต้องแก้เนื้อหา ให้แก้ก่อนถึงวันเริ่ม
  • กลุ่มเป้าหมายที่ไม่มีสมาชิกจะทำให้เมนูกลายเป็น "เสร็จสิ้น" ทันทีโดยไม่เคยแสดงกับใคร — ควรตรวจยอดสมาชิกในเมนู Segment audience ก่อนตั้งเมนู
  • การลบเมนูหลักลบเมนูย่อยไปด้วย — ตรวจก่อนว่ามีเมนูย่อยใน Switch rich menu ที่ยังใช้งานอยู่หรือไม่
  • ค่าเวลาสิ้นสุดที่ตัวเลือกวันเวลาเติมให้เป็นค่าเริ่มต้นคือ 11:59 ไม่ใช่ 23:59 — ถ้าต้องการให้เมนูอยู่ถึงสิ้นวัน ต้องแก้เวลาเอง
  • ปุ่มคัดลอกเมนูมีโค้ดรองรับอยู่ในฟอร์ม (ผ่าน query copy) แต่ ปุ่มในหน้ารายการถูกคอมเมนต์ปิดไว้ จึงยังไม่มีทางเข้าถึงจากหน้าจอ
  • ตัวกรองสถานะในหน้ารายการไม่มีตัวเลือก "เสร็จสิ้น" แม้ตารางจะแสดงป้ายสถานะนี้ได้ — กรองแยกเมนูที่เสร็จสิ้นแล้วออกมาดูตรง ๆ ไม่ได้

สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)​

  • ฝั่งหน้าเว็บ — หน้านี้ถูกครอบด้วย PermissionGuard ตรวจ action VIEW ของ subject rich-menu-custom ซึ่งเป็น subject แยกจาก rich-menu-default และ rich-menu-switch
  • ฝั่งเซิร์ฟเวอร์ — ทุกเส้นทางถูกครอบด้วย ModuleGate("rich-menu") ที่บังคับใช้จริง และเป็น gate ตัวเดียวที่คุมทั้งสามชนิดพร้อมกัน (Default / Custom / Switch อยู่บนตาราง rich_menu เดียวกัน แยกด้วยคอลัมน์ type) — ปิดโมดูล rich-menu ให้องค์กรใด ทั้งสามหน้าจะได้ 403 พร้อมกัน ไม่มีทางปิดเฉพาะชนิด custom ที่ระดับ backend
  • การอ่านกลุ่มเป้าหมายถูกจำกัดขอบเขตด้วยผู้เรียกเสมอ — คำสั่งค้นกลุ่มเป้าหมายก่อนอ่าน CSV ผูกทั้ง line_oa_id และ organization_id ของผู้เรียกไว้ ถ้าส่ง audienceId ขององค์กรอื่นมาจะได้ 400 APP_007 ไม่ใช่ข้อมูลของคนอื่น
  • การกันข้ามองค์กร (IDOR) — findById (ซึ่ง PUT และ DELETE เรียกใช้เป็นจังหวะแรก) ผูก line_oa_id ของผู้เรียกเสมอ, recalculate-stat เช็กความเป็นเจ้าของก่อนส่งงานเข้าคิว (ไม่พบได้ 404), และคำสั่ง soft delete ทุกคำสั่งผูก line_oa_id ไว้ด้วย
  • policy annotation ระดับการกระทำมีประกาศไว้ครบแต่วิ่งผ่าน middleware ที่เป็น no-op — การบังคับจริงอยู่ที่ ModuleGate ระดับโมดูล
  • 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/custom/page.tsx — จุดเข้าหน้ารายการ (PermissionGuard subject rich-menu-custom)
  • src/components/rich-menu/list/rich-menu-custom-filter.tsx — ตัวกรอง 3 ช่องเฉพาะของชนิด custom
  • src/components/rich-menu/list/rich-menu.container.tsx — คอลัมน์กลุ่มเป้าหมาย/ช่วงเวลา, ป้าย "ระบบอัตโนมัติ", กฎ disable ปุ่มแก้ไข-ลบและ tooltip
  • src/components/rich-menu/form/default/rich-menu-default-form.container.tsx — validateCustomRichMenuDatesAndStatus, การประกอบ FormData (สลับระหว่าง isForAutomation กับ audienceId)
  • src/components/rich-menu/form/default/rich-menu-default-form.tsx — สวิตช์ระบบอัตโนมัติ, ช่องกลุ่มเป้าหมาย, ตัวเลือกช่วงวันเวลา
  • src/components/rich-menu/form/default/components/RichMenuStatusRadioGroup.tsx — ตัวเลือกสถานะที่แสดงตามสถานะที่บันทึกไว้จริง
  • src/services/rich-menu.service.ts, src/services/audience.service.ts — endpoint ที่หน้าเว็บเรียกใช้

Backend (line-management-cms-api-go)

  • internal/modules/richmenu/create_update.go — processRichMenuAudience, initDuckDbTempTableByAudienceID, getAudienceByIDScoped, ลำดับการเรียก LINE
  • internal/modules/richmenu/update.go — การไล่เปลี่ยนสถานะเมนูย่อย, การตรวจชื่อซ้ำตอนแก้ไข
  • internal/modules/richmenu/service.go — setFilter (กรองช่วงวันที่แบบทับซ้อนสำหรับชนิด custom), Delete และการพาเมนูย่อยไปด้วย
  • internal/modules/richmenu/controller.go — เส้นทางและ ModuleGate("rich-menu")

Worker (line-management-worker-go)

  • internal/richmenu/service_cron.go — cronSetRichMenuCustom (ผูกทีละ 500 คน), cronDeleteRichMenuCustom (ถอนเมนูและตั้ง expired), expireCustomRichMenusWithoutCompletedLink
  • internal/cronscheduler/rich_menu_lifecycle_scanner.go — จังหวะ cron ทุก 5 นาทีและเหตุผลที่ไม่ใช้ทุก 1 นาที

ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ ริชเมนู (Default / Custom / Switch)