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–4src/components/menu-builder/ContentPagePicker.tsx,ContentLinkPicker.tsx— การกรองสถานะและการเก็บค่าเป็น tokensrc/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— การตรวจสิทธิ์ระดับเมนูด้วย subjectmenu-builder
Backend (line-management-cms-api-go)
internal/modules/menubuilder/controller.go— 9 เส้นทาง,controllerUserID()ที่คืน 1 เสมอ,lineOaHash = "default"ของการอัปโหลด, การเขียนทับlineOaId, การไม่มี ModuleGateinternal/modules/menubuilder/service.go—generateRandomToken(12 ตัวอักษร non-crypto), การรวม config แบบ shallow,columnForSort, soft delete, clone, regenerate tokeninternal/modules/menubuilder/dto.go— ประเภทรายการเมนู 4 แบบ,contentLinkBehavior, การตรวจsortByที่ไม่จำกัดค่าinternal/modules/publicmod/controller.go—GET /public/menu/:tokenและ token ตัวอย่างtest-menu-tokenแบบ hard-codeinternal/modules/modulegate/coverage_test.go— รายการโมดูลที่มี ModuleGate จริงและโมดูลที่ยกเว้น
Backend (line-management-client-api-go)
internal/menubuilder/service.go—filterItemsByAudience(การกรองแบบ recursive และการตัดโฟลเดอร์ที่ว่าง), การกลืน error ของ LIFFinternal/menubuilder/repository.go— คำสั่ง query ที่ ไม่กรองสถานะโดยเจตนา
ดูรายละเอียดเชิงเทคนิคเพิ่มเติม (เวอร์ชันก่อนหน้าของทีมเอกสาร) ที่ ตัวสร้างเมนู