Skip to main content

Menu Builder

ภาพรวม​

Menu Builder ใช้ออกแบบเมนูแบบหน้าเว็บหรือ LIFF ที่มีโครงสร้างเป็นต้นไม้หลายชั้น แล้วเผยแพร่ผ่านลิงก์ที่มี token สาธารณะ เหมาะกับการทำเมนูรวมลิงก์และหน้าเนื้อหาให้ลูกค้าเปิดจากปุ่มบนริชเมนูหรือจากข้อความที่ส่งออกไป

จุดสำคัญที่ต้องรู้ก่อนเริ่มคือ โครงเมนูทั้งชุดถูกเก็บเป็นก้อน JSON เดียว ในคอลัมน์ config ไม่ได้แตกเป็นตารางรายรายการ ทำให้ไม่มีข้อจำกัดความลึกของการซ้อนชั้น (ต่างจากเมนู Subcategories ที่จำกัด 4 ระดับ) และรายการเมนูที่ชี้ไปหน้าเนื้อหาหรือลิงก์เนื้อหา เก็บเป็นค่า token ณ ขณะที่เลือก ไม่ได้เก็บเป็น id ที่อ้างอิงกัน

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

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

1. ดูรายการเมนู​

เปิดเมนู Menu Builder — ต้องเลือก LINE OA ไว้ก่อน ไม่เช่นนั้นระบบขึ้นเตือน "กรุณาเลือก LINE OA ก่อน" และไม่ยิงคำขอใด ๆ ตารางมี 7 คอลัมน์: ลำดับ, ชื่อเมนู (พร้อมชื่อเทมเพลตเป็นบรรทัดรอง หรือ "เลย์เอาต์กำหนดเอง"), รายการ (นับรวมทุกชั้นแบบ recursive แสดงเป็นป้ายน้ำเงิน), สถานะ, โทเคนสาธารณะ (แสดงเป็น monospace คัดลอกได้ พร้อมปุ่มสร้างใหม่ข้าง ๆ), สร้างเมื่อ (DD/MM/YYYY) และ จัดการ

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

2. สร้างเมนู​

กด สร้างเมนู เข้าหน้าตัวสร้าง 3 คอลัมน์ พร้อมแถบหัวที่ตรึงไว้ด้านบน (ช่องชื่อเมนู + ปุ่มคัดลอกลิงก์/ตัวอย่าง/บันทึก):

  • คอลัมน์ซ้าย — โครงสร้างเมนู — ปุ่ม ขยายทั้งหมด / ย่อทั้งหมด / รีเซ็ตสไตล์ทั้งหมด และปุ่ม เพิ่มรายการเมนูหลัก ใต้ต้นไม้เป็นการ์ด เลือกเทมเพลต ให้เลือกระหว่าง List Layout กับ Grid Layout (แบบ Grid เลือกจำนวนคอลัมน์ได้ 1–4 โดย "4 คอลัมน์ (สูงสุด)")
  • คอลัมน์กลาง — ตัวอย่างสด — กรอบมือถือที่เดินเข้าเมนูย่อยได้จริง
  • คอลัมน์ขวา — แท็บ สไตล์รายการ / ธีมโดยรวม — แท็บแรกปรับสไตล์ของรายการที่เลือกไว้ (รวมแท็บย่อย กลุ่มเป้าหมาย ที่ตั้งกลุ่มผู้เห็นรายการนั้น) แท็บที่สองปรับธีมทั้งเมนู (สีหลัก, สีข้อความ, ความโค้งขอบ, ระยะห่างภายใน 4 ระดับ, ขนาดตัวอักษร) พร้อม ธีมสำเร็จรูป 5 แบบ — น้ำเงินเริ่มต้น, เขียวธรรมชาติ, ม่วงโมเดิร์น, แดงสดใส, ดำมินิมอล

3. เพิ่มรายการเมนู​

กด เพิ่มรายการเมนูหลัก หรือปุ่ม เพิ่มรายการย่อย ที่กิ่งใดก็ได้ เปิดหน้าต่างซ้อน (กว้าง 520px) มี ชื่อเมนู (บังคับ) และ ประเภทการทำงาน เลือกเป็นการ์ด 4 แบบ:

ประเภทค่าที่ต้องกรอกต่อ
โฟลเดอร์ (node)ไม่มี — ใช้เก็บรายการลูก
ลิงก์ภายนอก (linkout)URL ภายนอก (บังคับ ตรวจรูปแบบ URL ที่ฝั่งฟอร์ม)
หน้าเนื้อหา (content_page)เลือกจากตัวเลือกที่ดึงหน้าสถานะ published จากเมนู Content Pages
ลิงก์เนื้อหา (content_link)เลือกจากตัวเลือกที่ดึงลิงก์สถานะ active จากเมนู Content Links

ถ้าเลือก ลิงก์เนื้อหา จะมีตัวเลือก พฤติกรรม เพิ่มมา 2 แบบ — ไปยังหน้ารายการ (เปิดหน้ารายการเนื้อหาแบบเต็มพร้อมการค้นหาและตัวกรอง) หรือ แสดงเมนูย่อยแบบฝัง (ขยายรายการเนื้อหาในเมนูเลย) และเมื่อเลือกแบบฝังจะมี รูปแบบการแสดงผล ให้เลือกอีกชั้น — มุมมองการ์ด (รูป/ชื่อเรื่อง/ข้อความย่อ/วันที่) หรือ มุมมองปุ่ม (ปุ่มกะทัดรัด)

4. จัดลำดับและปรับแต่ง​

ลากวางกิ่งเพื่อจัดลำดับได้ แต่ ย้ายได้เฉพาะภายใต้รายการหลักเดิม — ลากข้ามแม่หรือลากทับกิ่งเพื่อเปลี่ยนให้เป็นลูกจะถูกปฏิเสธด้วยข้อความ "สามารถจัดเรียงรายการเมนูใหม่ได้เฉพาะภายใต้รายการหลักเดียวกันเท่านั้น" (ตรวจสองชั้น: allowDrop ปิดไว้ตั้งแต่ตอนลาก และตรวจซ้ำใน onDrop) ปุ่ม รีเซ็ตสไตล์ทั้งหมด ล้าง customStyle, image, showText, textPosition ของทุกรายการทุกชั้นกลับเป็นธีมเริ่มต้น (มีกล่องยืนยัน)

5. บันทึก ดูตัวอย่าง และคัดลอกลิงก์​

กด บันทึกเมนู ครั้งแรก ระบบสร้างเมนูและออก token สาธารณะให้ทันที แล้วเปลี่ยน URL เป็นโหมดแก้ไข (?id=…) ก่อนบันทึกครั้งแรก ปุ่ม ตัวอย่าง จะถูก disable และปุ่มคัดลอกลิงก์ไม่ปรากฏเลย (ข้อความเตือน: "กรุณาบันทึกเมนูก่อนเพื่อสร้างลิงก์ตัวอย่าง")

รูปแบบลิงก์ที่คัดลอกได้ 2 แบบ ขึ้นกับว่ามี รายการเมนูใดในเมนูนี้ (ทุกชั้น) ตั้งกลุ่มเป้าหมายไว้หรือไม่:

  • มีตั้งไว้ และ OA มี LIFF ID → https://liff.line.me/{liffId}/{lineOaHash}/menu/{token} (ข้อความ "คัดลอกลิงก์ LIFF แล้ว! (เปิดในแอป LINE เพื่อกรองกลุ่มเป้าหมาย)")
  • กรณีอื่น → {NEXT_PUBLIC_BASE_APP_URL หรือ origin ปัจจุบัน}/{lineOaHash}/menu/{token}

6. ทำสำเนา ออก token ใหม่ ลบ​

จากคอลัมน์จัดการในหน้ารายการ — ปุ่ม คัดลอก (ทำสำเนา) และปุ่ม สร้างโทเคนใหม่ (ไอคอนรีเฟรชในคอลัมน์โทเคน) ทำงานทันทีโดยไม่มีกล่องยืนยัน ส่วนปุ่ม ลบ มี Popconfirm ถามก่อน

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

ยืนยันจาก internal/modules/menubuilder/{controller,service,dto}.go และ internal/modules/publicmod/controller.go ของ line-management-cms-api-go กับ internal/menubuilder/{service,repository}.go ของ line-management-client-api-go:

  • token ของ Menu Builder อ่อนกว่าของโมดูลอื่นในระบบอย่างมีนัยสำคัญ — generateRandomToken() สุ่ม 12 ตัวอักษร จากชุด A–Z a–z 0–9 ด้วย Math.random() และความเห็นในโค้ดระบุไว้ตรง ๆ ว่า "non-cryptographic — parity, not security" เทียบกับเมนู Content Links และ Content Pages ที่ใช้ crypto.randomBytes(32) ได้ hex 64 ตัวอักษร ระบบยังวนสุ่มใหม่จนได้ค่าที่ไม่ซ้ำกับแถวที่ยังไม่ถูกลบ (generateUniqueToken)
  • ผู้สร้าง/ผู้แก้ไขของเมนูถูกบันทึกเป็นผู้ใช้ id 1 เสมอ ไม่ว่าใครทำ — controller เขียน req.user?.id || 1 แต่ payload ที่ JWT strategy คืนมามีแค่ { userId, lineOaId, organizationId } ไม่มีฟิลด์ id ค่าจึงตกไปที่ 1 ทุกครั้ง ความเห็นหัวไฟล์ controller อธิบายเรื่องนี้ไว้ชัดเจน (controllerUserID() คืนค่า 1 ตรง ๆ) — คอลัมน์ created_by/updated_by ของตาราง menu_builder จึงไม่สามารถใช้สืบว่าใครแก้เมนูได้เลย
  • รูปที่อัปโหลดในตัวสร้างเมนูไปกองอยู่โฟลเดอร์ default ร่วมกันทุกองค์กร — ด้วยเหตุผลเดียวกัน req.user?.lineOaHash || 'default' ตกไปที่ 'default' เสมอเพราะ payload ไม่มีฟิลด์นั้น (ต่างจากการอัปโหลดรูปในเมนู Content Pages ที่อ่าน LINE OA hash จาก context ได้จริงและแยกโฟลเดอร์ตาม OA) ข้อจำกัดไฟล์เหมือนกันคือ JPEG/PNG/GIF สูงสุด 10MB ตั้งชื่อเป็น menu-image-{timestamp}
  • มีถึงสาม endpoint สาธารณะสำหรับอ่านเมนูเดียวกัน และตอบต่างกันเรื่องสถานะ —
    • GET /api/menu-builder/public/:token (cms-api) → ถ้าสถานะไม่ใช่ active ตอบ 400 "Menu is not active"
    • GET /api/public/menu/:token (cms-api, โมดูล publicmod) → บังคับ status = active ในคำสั่ง query ถ้าไม่ตรงตอบ 404 "Menu not found" และมี ค่า token พิเศษ test-menu-token ที่คืนเมนูตัวอย่างแบบ hard-code โดยไม่แตะฐานข้อมูลเลย
    • GET /menu-builder/:token (client-api — endpoint ที่เว็บฝั่งผู้ใช้จริงเรียก) → คำสั่ง query กรองแค่ deleted_at IS NULL และความเห็นในโค้ดเขียนไว้ว่า "deliberately NO status filter" จึง เสิร์ฟเมนูที่ปิดสถานะ inactive ต่อไปตามปกติ
  • การกรองกลุ่มเป้าหมายเกิดที่เซิร์ฟเวอร์ ไม่ใช่ซ่อนด้วย CSS — filterItemsByAudience() ของ client-api เดินต้นไม้แบบ recursive: กรองลูกก่อน, รายการที่ไม่ได้ตั้ง audienceIds เห็นได้ทุกคน, รายการที่ตั้งไว้เห็นได้เมื่อกลุ่มใดกลุ่มหนึ่งตรงกับของผู้ใช้ (OR) และ รายการชนิดโฟลเดอร์ที่ลูกถูกกรองออกหมดจะถูกตัดทิ้งทั้งกิ่ง — รายการที่ไม่ผ่านการกรองไม่ถูกส่งออกมาใน response เลย
  • ยืนยัน LIFF ไม่ผ่านแล้วผู้ใช้เห็นเฉพาะรายการสาธารณะ (ไม่ใช่ error) — client-api พยายามอ่านกลุ่มเป้าหมายจาก x-liff-token แบบ best-effort ถ้าล้มเหลวจะกลืนไว้และเดินต่อด้วยชุดกลุ่มเป้าหมายว่าง ผลคือรายการที่จำกัดกลุ่มถูกซ่อนทั้งหมด — เป็นการ degrade แบบ เข้มขึ้น ต่างจากเมนู Content Links ที่การกลืน error ทำให้ตัวกรองของลิงก์ทำงานแทน ฝั่งหน้าเว็บของผู้ใช้ยังฉลาดพอที่จะ ข้ามขั้นตอน LIFF ไปเลยถ้าไม่มีรายการใดตั้งกลุ่มเป้าหมายไว้
  • config เป็นค่าที่บังคับต้องส่งตอนสร้าง และข้อความ error เป็นข้อความ TypeError ของ JavaScript — service อ่าน dto.config.version ตรง ๆ ถ้าไม่ส่ง config มาจะได้ 400 "Failed to create menu: Cannot read properties of undefined (reading 'version')" พอร์ต Go ทำซ้ำข้อความนี้แบบคำต่อคำโดยเจตนา และถ้าไม่ระบุ version มาระบบเติมให้เป็น "1.0"
  • การอัปเดต config เป็นการรวมแบบผิวเดียว (shallow merge) — { ...ของเดิม, ...ที่ส่งมา } เฉพาะคีย์ระดับบนสุด ดังนั้นส่ง config.items มาใหม่คือ แทนที่รายการเมนูทั้งชุด ไม่ได้รวมทีละรายการ ส่วน version ใช้ค่าใหม่ถ้าส่งมา ไม่งั้นคงค่าเดิม
  • พารามิเตอร์ sortBy ไม่มีการจำกัดรายการค่าที่ยอมรับ — DTO ตรวจแค่ @IsOptional() @IsString() แล้วค่าที่ได้ถูกส่งเข้าฟังก์ชันแปลงชื่อคอลัมน์ ซึ่งคืนค่าที่ไม่รู้จักกลับมาตามตัวอักษร เพื่อนำไปต่อในประโยค ORDER BY ตามที่ความเห็นในโค้ดอธิบายว่าเป็นการทำซ้ำพฤติกรรมของ TypeORM เดิมที่ทำให้ Postgres ปฏิเสธและกลายเป็น 500 (หน้าเว็บส่งค่าคงที่ createdAt เสมอ)
  • การตรวจสิทธิ์เจ้าของทำเมื่อมีค่า lineOaId ให้เทียบเท่านั้น — ทุกเมธอด (FindOne/Update/Delete/Clone/RegenerateToken) ตรวจแบบ if lineOaID != nil && m.LineOaID != *lineOaID — ถ้า JWT ไม่มี lineOaId การตรวจถูกข้ามไปทั้งหมด และเมื่อไม่ผ่านจะได้ 403 พร้อมข้อความแยกตามการกระทำ (เช่น "You do not have permission to clone this menu")
  • การลบเป็น soft delete แบบเจาะจงคอลัมน์เดียว — UPDATE menu_builder SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? (รัน Unscoped() เพื่อทำซ้ำพฤติกรรมของ TypeORM ที่ไม่เติม deleted_at IS NULL ซ้ำในคำสั่งลบ) ไม่ได้เปลี่ยนสถานะเป็น delete และไม่ได้เขียน deleted_by
  • การทำสำเนาคัดลอก config มาทั้งก้อนตามตัวอักษร — ตั้งชื่อเป็น "{ชื่อเดิม} (Copy)", ออก token ใหม่, บังคับสถานะเป็น active — ก้อน config เดิมถูกคัดมาเป๊ะ ๆ รวมทั้งค่า id, createdAt และ publicToken (ที่เป็นสตริงว่าง) ที่ฝั่งหน้าเว็บใส่ไว้ในนั้น จึงเป็นข้อมูลซ้ำที่ไม่ตรงกับความจริง
  • โมดูลนี้ไม่ใช้ซองคำตอบมาตรฐานของระบบ — ทุกเมธอดคืน object แบบ { code, message, data } ที่ประกอบเอง (เช่น { code: 200, message: "Menu cloned successfully", data: {...} }) ต่างจากโมดูล content ทั้งสี่ตัว — และ endpoint สาธารณะของ cms-api ตัดข้อมูลเหลือแค่ { id, name, config }

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

  • Content Pages — เป็นแหล่งของรายการเมนูชนิด "หน้าเนื้อหา" โดยตัวเลือกดึงเฉพาะหน้าสถานะ published และข้ามหน้าที่ยังไม่มี publicToken ค่าที่เก็บลงรายการเมนูคือ publicToken ของหน้านั้น
  • Content Links — เป็นแหล่งของรายการเมนูชนิด "ลิงก์เนื้อหา" โดยตัวเลือกดึงเฉพาะลิงก์สถานะ active ค่าที่เก็บคือ token ของลิงก์ และการดูตัวอย่างในคอลัมน์กลางเรียก GET /content-links/:id/preview-contents ของเมนูนั้นมาแสดงจริง
  • Categories / Subcategories — มีผลทางอ้อม เพราะเงื่อนไขหมวดหมู่ที่ตั้งไว้ในลิงก์เนื้อหาเป็นตัวกำหนดว่ารายการเมนูแบบฝังจะแสดงเนื้อหาอะไร
  • Segment audience — เป็นแหล่งของกลุ่มเป้าหมายที่ตั้งรายรายการเมนูได้จากแท็บ สไตล์รายการ > กลุ่มเป้าหมาย และเป็นตัวกำหนดว่าลิงก์ที่คัดลอกจะออกมาเป็นแบบ LIFF หรือเว็บปกติ
  • Rich menu / Rich message / Campaign — ปลายทางที่นำลิงก์เมนูไปฝังเป็นปุ่ม
  • LINE OA management — เป็นแหล่งของ lineOaHash และ lineLiffId ที่ใช้ประกอบลิงก์ (เรียกแยกอีกคำขอหลังหน้าโหลด)

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

  • ปุ่ม "สร้างโทเคนใหม่" ในหน้ารายการยิงทันทีโดยไม่มีกล่องยืนยัน — ต่างจากเมนู Content Links ที่มีกล่องยืนยันพร้อมคำเตือน กดผิดครั้งเดียวคือลิงก์เดิมตายทันที ไม่มีช่วงผ่อนผัน และถ้าลิงก์เดิมถูกฝังไว้ที่ริชเมนูหรือข้อความที่ส่งออกไปแล้ว ต้องไปแก้ปลายทางเองทั้งหมด ปุ่ม คัดลอก (ทำสำเนา) ก็ไม่มีกล่องยืนยันเช่นกัน — กดแล้วได้เมนูใหม่พร้อม token ใหม่ทันที
  • ตั้งสถานะเป็น inactive ไม่ได้ทำให้เมนูปิดสำหรับผู้ใช้จริง — endpoint ที่เว็บฝั่งผู้ใช้เรียก (GET /menu-builder/:token ของ client-api) ไม่กรองสถานะเลยโดยเจตนา ถ้าต้องการปิดจริง ให้ออก token ใหม่ (ทำให้ลิงก์เดิมใช้ไม่ได้) หรือลบเมนู
  • สถานะ draft ในตัวกรองไม่มีทางเกิดขึ้นจากหน้าจอนี้ — ฟอร์มส่ง status: 'active' เสมอทั้งตอนสร้างและตอนอัปเดต และการทำสำเนาก็บังคับเป็น active ตัวเลือก "ฉบับร่าง" ในตัวกรองจึงคืนผลว่างในทางปฏิบัติ (และไม่มีปุ่มใดที่ตั้งสถานะเป็น inactive ได้จากหน้าจอด้วย)
  • รายการเมนูเก็บ token เป็นค่าคงที่ ไม่ใช่การอ้างอิง — ถ้าไปกด "สร้างโทเคนใหม่" ที่เมนู Content Links รายการเมนูที่ชี้ไปลิงก์นั้นจะ พังเงียบ ๆ เพราะยังถือ token เก่า และการลบหน้าเนื้อหาหรือลิงก์เนื้อหาก็ทำให้รายการเมนูกลายเป็นทางตันโดยไม่มีคำเตือนใด ๆ ในหน้านี้ ต้องกลับมาแก้รายการเมนูเอง
  • ไม่มีเพดานความลึกของการซ้อนชั้นทั้งฝั่งหน้าเว็บและฝั่งเซิร์ฟเวอร์ — DTO ตรวจโครงสร้างของรายการซ้อน (items → children) แต่ไม่มีการนับระดับเลย ต่างจากเมนู Subcategories ที่จำกัด 4 ระดับ ซ้อนลึกเกินไปจะทำให้ผู้ใช้ปลายทางกดลึกหลายชั้นและตัวอย่างในคอลัมน์กลางอ่านยาก
  • created_by/updated_by ของตารางเมนูเป็น 1 ทุกแถว — ใช้ตรวจสอบย้อนหลังว่าใครสร้างหรือแก้เมนูไม่ได้ (ดูรายละเอียดข้างต้น)
  • รูปที่อัปโหลดในตัวสร้างเมนูอยู่ในโฟลเดอร์ default ร่วมกับ OA อื่น — เป็น URL สาธารณะ ควรระวังไม่อัปโหลดภาพที่ไม่ควรให้คนอื่นเห็น
  • ถ้ายังไม่ได้เลือก LINE OA หน้ารายการจะขึ้นเตือนแล้วไม่โหลดข้อมูลเลย — ป้ายจำนวนเมนูจะค้างที่ 0 โดยไม่มี error
  • ปุ่มคัดลอกลิงก์ถูก disable เมื่อระบบยังไม่รู้ค่า LINE OA hash — ค่านี้มาจากการเรียกข้อมูล LINE OA แยกอีกคำขอ ถ้าล้มเหลวจะขึ้นแค่ error ใน console
  • การอัปเดตเมนูส่ง config ทั้งก้อนทับของเดิม — ถ้ามีคนสองคนแก้เมนูเดียวกันพร้อมกัน คนที่กดบันทึกทีหลังจะทับงานของคนแรกทั้งหมด (ไม่มีการตรวจ version ใด ๆ)
  • ผู้ใช้คนอื่นใน LINE OA เดียวกันแก้/ลบ/ทำสำเนา/ออก token ใหม่ได้ทั้งหมด — การตรวจสิทธิ์เทียบแค่ line_oa_id

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

  • ฝั่งหน้าเว็บ — หน้ารายการและหน้าตัวสร้าง ไม่ได้ครอบด้วย PermissionGuard สิ่งเดียวที่ตรวจสิทธิ์คือแถบเมนูด้านข้าง ซึ่งเช็ค ability.can(VIEW, 'menu-builder') ก่อนจะแสดงรายการเมนูนี้ (แยกจากสิทธิ์ content-management ของกลุ่ม Content Management) — ถ้าไม่มีสิทธิ์ เมนูจะหาย แต่พิมพ์ URL /menu-builder ตรง ๆ ยังเข้าได้ เมนูนี้ยังปรากฏในแถบ Quick access ด้วย subject เดียวกัน
  • ฝั่งเซิร์ฟเวอร์ — ทั้ง 8 เส้นทางที่ต้องล็อกอินมี policy ประกาศไว้บน subject system_module (ไม่ใช่ line-oa เหมือนโมดูล content ทั้งสี่ตัว) แต่วิ่งผ่าน auth.CheckPolicies ที่เป็น no-op ปล่อยผ่านทุกคำขอ การบังคับจริงจึงเหลือแค่ JWT + การตรวจ line_oa_id ในแต่ละเมธอดของ service
  • ไม่มี ModuleGate ในโมดูลนี้ — ตรวจ menubuilder/controller.go แล้วไม่พบการเรียก modulegate.ModuleGate(d, "...") เลย (ต่างจากโมดูล campaign ที่มี) และไม่ปรากฏทั้งในรายการ gatedModules และ exemptModules ของ modulegate/coverage_test.go — ตามคำอธิบายในเทสต์นั้น ผู้ดูแลระดับแพลตฟอร์ม ปิดโมดูล Menu Builder ให้องค์กรใดไม่ได้ เพราะไม่มีจุดบังคับ
  • เส้นทาง GET /api/menu-builder/public/:token เป็น @Public() โดยเจตนา — ไม่ต้องมี JWT การป้องกันมาจาก token 12 ตัวอักษรเพียงอย่างเดียว (บวกการบังคับสถานะ active ที่ endpoint นี้) และคำตอบถูกลดรูปเหลือ { id, name, config }
  • ขอบเขตข้อมูลของ GET /api/menu-builder ถูกจำกัดด้วย LINE OA เพราะ controller เขียนทับค่า lineOaId ที่ส่งมาใน query ด้วยค่าจาก JWT ทุกครั้ง (เช่นเดียวกับ POST ที่เขียนทับ lineOaId ใน body) — ส่งค่า lineOaId ของ OA อื่นมาจึงไม่มีผล
  • การกรองกลุ่มเป้าหมายฝั่งผู้ใช้เป็นการบังคับจริงที่เซิร์ฟเวอร์ (รายการที่ไม่ผ่านไม่ถูกส่งออกมา) แต่ ต้องพึ่งการระบุตัวตนผ่าน LIFF — เปิดลิงก์แบบเว็บปกติโดยไม่มี x-liff-token จะเห็นเฉพาะรายการที่ไม่จำกัดกลุ่มเท่านั้น

ไฟล์อ้างอิงจากซอร์สโค้ด​

Frontend (cms-web-2026)

  • src/app/menu-builder/page.tsx — ตาราง 7 คอลัมน์, ตัวกรองแบบพักค่า, การประกอบ URL แบบ LIFF vs เว็บปกติ, ปุ่มออก token ใหม่/ทำสำเนาที่ไม่มีกล่องยืนยัน
  • src/app/menu-builder/form/page.tsx — หน้าตัวสร้าง 3 คอลัมน์, ค่าเริ่มต้นของ theme/template, ตรรกะบันทึกที่ส่ง status: 'active' เสมอ
  • src/app/menu-builder/preview/page.tsx — หน้าตัวอย่างที่เปิดด้วย ?token=
  • src/components/menu-builder/MenuTree.tsx — ต้นไม้โครงเมนู, การ์ดเลือกประเภท 4 แบบ, กฎการลากวาง, ปุ่มรีเซ็ตสไตล์
  • src/components/menu-builder/ButtonCustomizer.tsx — แท็บสไตล์รายการและแท็บย่อยกลุ่มเป้าหมาย (จุดเดียวที่ตั้ง audienceIds รายรายการได้)
  • src/components/menu-builder/ThemeCustomizer.tsx, TemplateSelector.tsx — ธีมสำเร็จรูป 5 แบบ, เทมเพลต List/Grid และคอลัมน์ 1–4
  • src/components/menu-builder/ContentPagePicker.tsx, ContentLinkPicker.tsx — การกรองสถานะและการเก็บค่าเป็น token
  • src/components/menu-builder/MenuPreviewEnhanced.tsx — ตัวอย่างสด และการเรียก preview-contents ของลิงก์เนื้อหา
  • src/services/menu-builder.service.ts — endpoint ที่หน้าเว็บเรียกใช้
  • src/components/layout/sidemenu/sidemenu.container.tsx, src/components/layout/quick-access/quick-access.tsx — การตรวจสิทธิ์ระดับเมนูด้วย subject menu-builder

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

  • internal/modules/menubuilder/controller.go — 9 เส้นทาง, controllerUserID() ที่คืน 1 เสมอ, lineOaHash = "default" ของการอัปโหลด, การเขียนทับ lineOaId, การไม่มี ModuleGate
  • internal/modules/menubuilder/service.go — generateRandomToken (12 ตัวอักษร non-crypto), การรวม config แบบ shallow, columnForSort, soft delete, clone, regenerate token
  • internal/modules/menubuilder/dto.go — ประเภทรายการเมนู 4 แบบ, contentLinkBehavior, การตรวจ sortBy ที่ไม่จำกัดค่า
  • internal/modules/publicmod/controller.go — GET /public/menu/:token และ token ตัวอย่าง test-menu-token แบบ hard-code
  • internal/modules/modulegate/coverage_test.go — รายการโมดูลที่มี ModuleGate จริงและโมดูลที่ยกเว้น

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

  • internal/menubuilder/service.go — filterItemsByAudience (การกรองแบบ recursive และการตัดโฟลเดอร์ที่ว่าง), การกลืน error ของ LIFF
  • internal/menubuilder/repository.go — คำสั่ง query ที่ ไม่กรองสถานะโดยเจตนา

ดูรายละเอียดเชิงเทคนิคเพิ่มเติม (เวอร์ชันก่อนหน้าของทีมเอกสาร) ที่ ตัวสร้างเมนู