Content Management — Content Links
ภาพรวม
Content Links คือลิงก์ที่มี token ประจำตัว ซึ่งไม่ได้ชี้ไปยังบทความใดบทความหนึ่งโดยตรง แต่เก็บ เงื่อนไขการคัดเลือกเนื้อหา ไว้ (หมวดหมู่, หมวดหมู่ย่อย, ช่วงวันที่เผยแพร่, กลุ่มเป้าหมาย) เมื่อผู้รับเปิดลิงก์ ระบบจะไปดึงหน้าเนื้อหาที่ตรงเงื่อนไข ณ ขณะนั้นมาแสดงเป็นรายการ ทีมการตลาดจึงสร้างลิงก์ครั้งเดียวแล้วนำไปติดในริชเมนูหรือแคมเปญได้เลย เนื้อหาที่ผู้ใช้เห็นจะอัปเดตตามบทความใหม่ที่ตรงเงื่อนไขโดยไม่ต้องแก้ลิงก์อีก
จุดสำคัญที่ต้องรู้ก่อนเริ่มคือ ช่อง "ตัวกรองกลุ่มเป้าหมาย" เป็นตัวกรองเนื้อหา ไม่ใช่การจำกัดว่าใครเปิดลิงก์ได้ — ตามที่ tooltip ในฟอร์มระบุไว้ตรง ๆ ว่า "เนื้อหาจะแสดงหากตรงกับกลุ่มเป้าหมายที่เลือกไว้อย่างน้อยหนึ่งกลุ่ม" ใครถือ token ก็เปิดลิงก์ได้ เพียงแต่รายการเนื้อหาที่เห็นถูกกรองไว้
บทบาทที่ใช้งาน — ทีมการตลาดที่ต้องการลิงก์รวมบทความแบบไม่ต้องอัปเดตซ้ำทุกครั้งที่มีเนื้อหาใหม่ (โมดูลนี้ ไม่มี ModuleGate ที่ backend — ดูหัวข้อ สิทธิ์การเข้าถึง)
เส้นทางผู้ใช้งาน
1. ดูรายการลิงก์
เปิดเมนู Content Management > Content Links หัวข้อหน้าคือ ลิงก์เนื้อหา ตารางมี 6 คอลัมน์:
- ชื่อ — คลิกเพื่อเปิดหน้าต่างแก้ไข พร้อมคำอธิบายบรรทัดล่าง (ตัดที่ 50 ตัวอักษร)
- ตัวกรอง — แสดงเงื่อนไขเป็นป้ายสี: หมวดหมู่ (น้ำเงิน), หมวดหมู่ย่อย (ฟ้า), ช่วงวันที่ (ป้ายปฏิทิน รูปแบบ
MM/DD), จำนวนกลุ่มเป้าหมาย (ม่วง) — ถ้าไม่มีเงื่อนไขเลยจะแสดงข้อความ "ไม่มีตัวกรอง (แสดงเนื้อหาทั้งหมด)" - สถานะ — ป้ายเขียว/ส้ม (active/inactive)
- จำนวนคลิก — badge ตัวเลขสีน้ำเงิน แสดง 0 ด้วย
- สร้างเมื่อ — รูปแบบ
MMM DD, YYYY - การดำเนินการ — ปุ่มคัดลอกลิงก์แบบกดเร็ว + เมนูจุดสามจุด
ตัวกรองมี 3 ช่อง — ค้นหาลิงก์, กรองตามสถานะ (active/inactive), กรองตามหมวดหมู่ (ดึงจาก endpoint dropdown ของเมนู Categories)
2. สร้างหรือแก้ไขลิงก์
กด สร้างลิงก์ เปิด หน้าต่างซ้อน (Modal) กว้าง 600px — ไม่เปลี่ยนหน้า ฟอร์มมี:
- ชื่อลิงก์ — บังคับกรอก placeholder ตัวอย่าง "เช่น ข่าวสาร, อัปเดตสินค้า"
- คำอธิบาย — ไม่บังคับ (ระบุว่าใช้งานภายใน)
- กล่องสีเทาหัวข้อ เงื่อนไขการกรอง (เว้นว่างเพื่อแสดงเนื้อหาทั้งหมด) บรรจุ 4 ช่อง:
- หมวดหมู่ — placeholder "ทุกหมวดหมู่"
- หมวดหมู่ย่อย — ถูกปิดจนกว่าจะเลือกหมวดหมู่ (placeholder เปลี่ยนเป็น "กรุณาเลือกหมวดหมู่ก่อน") เมื่อเลือกแล้วโหลด dropdown แบบลำดับชั้นที่วาดเส้น
├─/└─จากเมนู Subcategories และการเปลี่ยนหมวดหมู่จะล้างค่านี้ทิ้งทุกครั้ง - ช่วงวันที่เผยแพร่ — RangePicker
- ตัวกรองกลุ่มเป้าหมาย — เลือกได้หลายกลุ่มจากเมนู Segment audience placeholder "ทุกกลุ่มเป้าหมาย (ไม่กรอง)"
- สถานะ — สวิตช์ active/inactive ค่าเริ่มต้นเป็น active
กด สร้าง / อัปเดต แล้วหน้าต่างปิดและตารางโหลดใหม่ Token ถูกสร้างโดยอัตโนมัติที่ฝั่งเซิร์ฟเวอร์ตอนบันทึกครั้งแรก ไม่มีช่องให้กรอก
3. คัดลอกลิงก์ไปใช้
กดปุ่มคัดลอก (หรือเมนู คัดลอกลิงก์) รูปแบบลิงก์ที่ได้ต่างกัน 2 แบบ:
- มีกลุ่มเป้าหมายตั้งไว้ และ OA มี LIFF ID →
https://liff.line.me/{liffId}/{lineOaHash}/pages/{token}พร้อมข้อความ "คัดลอกลิงก์ LIFF แล้ว! (เปิดในแอป LINE)" - กรณีอื่น →
{NEXT_PUBLIC_BASE_APP_URL}/{lineOaHash}/pages/{token}พร้อมข้อความ "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว"
นำลิงก์ไปฝังในริชเมนู (เมนู Rich menu), แคมเปญ (เมนู Campaign), หรือรายการเมนูใน Menu Builder
4. ออก token ใหม่ หรือ ลบลิงก์
จากเมนูจุดสามจุด:
- สร้างโทเค็นใหม่ — กล่องยืนยันหัวข้อ "สร้างโทเค็นใหม่" ระบุชื่อลิงก์ พร้อมคำเตือนสีแดงว่า "ลิงก์เดิมจะใช้งานไม่ได้ทันที" ปุ่มยืนยัน "สร้างใหม่" (ปุ่มสีแดง)
- ลบ — กล่องยืนยันหัวข้อ "ลบลิงก์เนื้อหา" ระบุชื่อลิงก์ (soft delete)
รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง
ยืนยันจาก internal/modules/contentlink/{controller,service,preview,dto}.go ของ line-management-cms-api-go และ internal/publiccontent/service.go ของ line-management-client-api-go:
- การกรองกลุ่มเป้าหมายฝั่งผู้ใช้ทำงานเป็น "บันไดสามขั้น" — เมื่อผู้ใช้เปิดลิงก์ผ่าน public API (
line-management-client-api-go) ตรรกะคือ (1) ถ้าลิงก์ตั้งกลุ่มเป้าหมายไว้ ใช้กลุ่มของ ลิงก์ เป็นตัวกรอง (2) ถ้าไม่ได้ตั้ง แต่ผู้เปิดมีกลุ่มเป้าหมายติดตัวจาก LIFF ใช้กลุ่มของ ผู้ใช้ เป็นตัวกรอง (3) ถ้าทั้งสองอย่างไม่มี จะแสดงเฉพาะเนื้อหาสาธารณะ — สังเกตว่าขั้น (1) มาก่อนขั้น (2) เสมอ ดังนั้นตั้งกลุ่มเป้าหมายที่ลิงก์แล้ว กลุ่มเป้าหมายของผู้ใช้จะไม่ถูกนำมาพิจารณาเลย - การยืนยัน LIFF token ล้มเหลวถูกกลืนไว้เงียบ ๆ — public API พยายามอ่านกลุ่มเป้าหมายของผู้เปิดจาก
x-liff-tokenแบบ best-effort ถ้ายืนยันไม่ผ่าน ระบบไม่ตอบ error แต่เดินหน้าต่อโดยไม่มีข้อมูลกลุ่มเป้าหมายของผู้ใช้ ยืนยันอีกครั้งว่ากลุ่มเป้าหมายบนลิงก์นี้ ไม่ใช่ประตูควบคุมการเข้าถึง ต่างจากหน้าเนื้อหาเดี่ยว (เมนู Content Pages) ที่ตอบ 403 "Authentication required for this content" เมื่อไม่มี LIFF token - ตัวนับจำนวนคลิกเพิ่มจากฝั่ง public API เท่านั้น และเพิ่มก่อนดึงเนื้อหา —
UPDATE content_link SET click_count = click_count + 1อยู่ในline-management-client-api-go(โมดูลpubliccontent) ไม่มีที่ไหนใน cms-api เขียนคอลัมน์นี้เลย ดังนั้นการกด preview จากหลังบ้านหรือหน้ารายการไม่ทำให้ตัวเลขขยับ — และเพราะการนับเกิด ก่อน การ query เนื้อหา คำขอที่จบด้วยรายการว่างหรือ error ก็ยังนับไปแล้ว - ลิงก์สถานะ inactive ตอบ 404 ไม่ใช่ 403 — public API ค้นด้วย
FindActiveByTokenที่บังคับstatus = activeปิดสวิตช์สถานะจึงได้ข้อความ "Content link not found or inactive" — ปิดสถานะกับออก token ใหม่จึงให้ผลเหมือนกันในมุมผู้ใช้ ต่างกันที่ปิดสถานะย้อนกลับได้ - หน้าเนื้อหาที่ลิงก์คัดมาต้องเป็นสถานะ
publishedเท่านั้น — ทั้ง endpoint preview ฝั่งหลังบ้านและ public API กรองcontent_page.status = 'published'และdeleted_date IS NULLเสมอ เรียงผลด้วยpublished_at DESC— หน้าที่เป็นฉบับร่างหรือเก็บถาวรจะไม่โผล่มาเด็ดขาด แม้จะตรงเงื่อนไขหมวดหมู่ทุกข้อ - เงื่อนไขหมวดหมู่ย่อยรวมลูกหลานทุกชั้นให้อัตโนมัติ —
getAllDescendantSubcategoryIds()เดินหาลูกหลานแบบ recursive (กรองline_oa_id+deleted_date IS NULL) แล้วใส่ทั้งชุดลงเงื่อนไขsubcategory_id IN (...)— เลือกหมวดย่อยระดับ L2 จึงได้เนื้อหาของ L3 และ L4 ใต้มันมาด้วย โดยไม่ต้องเลือกเอง - ตัวกรองกลุ่มเป้าหมายฝั่ง preview เขียนเป็น SQL jsonb ตรง ๆ และ "ปล่อยเนื้อหาสาธารณะผ่าน" — เงื่อนไขคือ
audience_ids = '[]'::jsonb OR audience_ids IS NULL OR EXISTS (... elem::int = ANY(...))แปลว่าหน้าเนื้อหาที่ไม่จำกัดกลุ่มเป้าหมายจะ ปรากฏในลิงก์ที่ตั้งกลุ่มเป้าหมายไว้ด้วยเสมอ ไม่ได้ถูกกรองออก - ช่วงวันที่เผยแพร่ใช้ค่าเที่ยงคืนของวันที่เลือก และเปรียบเทียบแบบ inclusive กับ timestamp — ฟอร์มส่งวันที่รูปแบบ
YYYY-MM-DD(ไม่มีเวลา) backend แปลงเป็นเที่ยงคืน UTC แล้วเทียบpublished_at >= fromและpublished_at <= to— ผลคือ เนื้อหาที่เผยแพร่ในวันเดียวกับวันสิ้นสุดแต่หลังเที่ยงคืนไปแล้วจะไม่ติดเงื่อนไข ถ้าต้องการรวมวันสุดท้าย ให้ตั้งวันสิ้นสุดเลื่อนไปอีกหนึ่งวัน audienceIdsถูกทำให้เป็น[]เสมอ ไม่มีทางเป็นnull— ทั้ง create และ update แปลงค่าnull/ไม่ส่งมาให้เป็น jsonb[]ตามที่ความเห็นในโค้ดระบุ (dto.audienceIds ?? []) ทำให้เงื่อนไขตรวจ "มีกลุ่มเป้าหมายหรือไม่" ใช้การเช็คความยาวอาร์เรย์ได้อย่างเดียวPATCHแยกแยะ "ไม่ส่งค่ามา" กับ "ส่ง null มา" ได้จริง — DTO เก็บ flag...Setแยกจากค่า ดังนั้นส่งcategoryId: nullมาจะ ล้างค่าเป็น NULL ส่วนไม่ส่ง key มาเลยจะ คงค่าเดิม — และหน้าเว็บใช้พฤติกรรมนี้อย่างตั้งใจ (โค้ดส่งvalues.categoryId ?? nullพร้อมความเห็นว่า "Explicitly set null when undefined to ensure update works")- ส่งค่าที่ไม่ใช่สตริงมาใน
name/descriptionจะได้ 500 — ทำซ้ำTypeErrorของvalue?.trim()ในต้นฉบับ TypeScript ด้วยข้อความvalue.trim is not a function(เหมือนโมดูล Subcategories) GET /content-links/:idแยก 403 กับ 404 — query ค้นด้วยid + deleted_date IS NULLโดยไม่กรองline_oa_idแล้วเรียกvalidateOwnershipทีหลัง id ของ OA อื่นตอบ 403 "You do not have permission to access this content link" ส่วน id ที่ไม่มีตอบ 404 "Content link not found"- public API จำกัดความลึกของการแบ่งหน้า — ถ้า
skip > 500(เช่น page 51 ที่ limit 10) จะได้ 400 "Page number exceeds maximum allowed" — ป้องกันการไล่ดูดข้อมูลทั้งชุดผ่านลิงก์สาธารณะ
เมนูที่เกี่ยวข้องกันในเส้นทางนี้
- Categories — เป็นเงื่อนไขคัดเลือกหลัก และเป็นตัวกรองในหน้ารายการด้วย (เรียก endpoint
dropdownเดียวกัน) - Subcategories — เงื่อนไขชั้นที่สอง โดยเลือกกิ่งหนึ่งแล้วครอบคลุมลูกหลานทั้งหมดอัตโนมัติ
- Content Pages — เป็นเนื้อหาปลายทางที่ลิงก์นี้คัดมาแสดง (เฉพาะที่เผยแพร่แล้ว) และค่า
audienceIdsที่ตั้งไว้ในหน้าเนื้อหาแต่ละหน้าก็มีผลกับการกรองด้วย - Segment audience — เป็นแหล่งของกลุ่มเป้าหมายในช่องตัวกรอง และเป็นตัวกำหนดว่าลิงก์ที่คัดลอกจะออกมาเป็นรูปแบบ LIFF หรือเว็บปกติ
- Menu Builder — รายการเมนูชนิดลิงก์รวมเนื้อหาอ้างอิงลิงก์เหล่านี้โดยตรง และเป็น ที่เดียวในระบบหลังบ้านที่เรียก endpoint
GET /content-links/:id/preview-contentsเพื่อแสดงตัวอย่างเนื้อหาในพรีวิวเมนู - Rich menu / Campaign — ปลายทางที่นำลิงก์ไปฝัง
จุดที่ควรระวัง
- หน้ารายการนี้ไม่มีปุ่มดูตัวอย่างเนื้อหาที่ลิงก์จะคัดมา — backend มี
GET /content-links/:id/preview-contentsที่คืนทั้งรายการเนื้อหาและlinkInfoพร้อมใช้ แต่หน้าจอ Content Links ไม่เรียกใช้เลย มีแค่ Menu Builder ที่เรียก (ในไฟล์หน้ารายการยังมี import ไอคอนEyeOutlinedและFilterOutlinedค้างไว้โดยไม่ถูกใช้ ซึ่งบ่งชี้ว่าปุ่มนี้เคยมีแผนไว้) - ช่องค้นหาต้องกด Enter สองครั้ง — ฟังก์ชันค้นหาเซ็ต state แล้วเรียกโหลดข้อมูลทันทีในบรรทัดถัดไป ซึ่งยังอ่านคำค้นเดิมจาก closure และ effect ที่โหลดข้อมูลก็ไม่ได้ผูก
searchTextไว้ใน dependency (พฤติกรรมเดียวกับหน้า Categories) - ถ้าไม่ได้ตั้งค่า
NEXT_PUBLIC_BASE_APP_URLลิงก์ที่คัดลอกจะเป็นhttps://client.example.com/...— โค้ดใช้ค่านั้นเป็น fallback ตรง ๆ ไม่ได้เตือนอะไร (ต่างจากหน้า Content Pages ที่ fallback เป็นwindow.location.origin) - token เป็นสิ่งเดียวที่ป้องกันการเข้าถึงลิงก์ — กลุ่มเป้าหมายเป็นเพียงตัวกรองรายการเนื้อหา และการยืนยัน LIFF ที่ล้มเหลวก็ถูกกลืนไป ถ้า token รั่วไหล ให้กด "สร้างโทเค็นใหม่" หรือปิดสถานะเป็น inactive
- ออก token ใหม่แล้วลิงก์เดิมตายทันที ไม่มีช่วงผ่อนผัน — ถ้าลิงก์เดิมถูกฝังไว้ในริชเมนู แคมเปญที่ส่งไปแล้ว หรือรายการใน Menu Builder ต้องไปแก้ปลายทางเองทั้งหมด
- เว้นเงื่อนไขว่างทุกช่องหมายถึง "แสดงหน้าเนื้อหาที่เผยแพร่แล้วทั้งหมดของ OA นี้" — หัวข้อในฟอร์มบอกไว้ตรง ๆ ("เว้นว่างเพื่อแสดงเนื้อหาทั้งหมด") และตารางจะแสดงว่า "ไม่มีตัวกรอง (แสดงเนื้อหาทั้งหมด)" ให้ระวังเวลาสร้างลิงก์แล้วยังไม่ได้ตั้งเงื่อนไข
- ในหน้าต่างแก้ไข ค่า
subcategoryIdถูกเซ็ตด้วยsetTimeout(..., 300)เพื่อรอให้ dropdown ลำดับชั้นโหลดเสร็จ — ถ้าเน็ตช้ากว่านั้น ช่องหมวดหมู่ย่อยอาจแสดงค่าว่างแม้ข้อมูลจริงจะมีค่าอยู่ ให้ปิดหน้าต่างแล้วเปิดใหม่ - ความยาว
nameถูกจำกัด 255 ตัวอักษรที่ backend แต่ฟอร์มไม่ได้จำกัดหรือแสดงตัวนับให้ — ส่วนdescriptionไม่มีเพดานความยาวทั้งสองฝั่ง - ผู้ใช้คนอื่นใน LINE OA เดียวกันแก้/ลบ/ออก token ใหม่ได้ทั้งหมด —
validateOwnershipตรวจแค่line_oa_id
สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)
- ฝั่งหน้าเว็บ — หน้ารายการ ไม่ได้ครอบด้วย
PermissionGuardสิ่งเดียวที่ตรวจสิทธิ์คือการแสดงเมนูในแถบด้านข้าง ซึ่งเช็คability.can(VIEW, 'content-management')ครอบทั้งกลุ่ม Content Management — พิมพ์ URL/content-linksตรง ๆ ยังเข้าได้แม้ไม่มีสิทธิ์ - ฝั่งเซิร์ฟเวอร์ — ทั้ง 7 เส้นทางมี policy ประกาศไว้ (
READALL/READ/CREATE/UPDATE/DELETEบน subjectline-oa) แต่วิ่งผ่านauth.CheckPoliciesที่เป็น no-op ปล่อยผ่านทุกคำขอ การบังคับจริงเหลือแค่ JWT +validateOwnershipที่เทียบline_oa_id(Super Admin ข้ามได้ทั้งหมด) และ การออก token ใหม่ใช้ policyCREATEไม่ใช่DELETEทั้งที่ผลลัพธ์คือทำให้ลิงก์เดิมใช้ไม่ได้ - ไม่มี
ModuleGateในโมดูลนี้ — ตรวจcontentlink/controller.goแล้วไม่พบการเรียกmodulegate.ModuleGate(d, "...")เลย (ต่างจากโมดูลcampaignที่มี) และไม่ปรากฏทั้งในรายการgatedModulesและexemptModulesของmodulegate/coverage_test.go— ผู้ดูแลระดับแพลตฟอร์มจึง ปิดโมดูลนี้ให้องค์กรใดไม่ได้ - ไม่มีเส้นทางสาธารณะ (
@Public) ในโมดูลนี้เลย — การอ่านลิงก์ฝั่งผู้ใช้เกิดที่line-management-client-api-go(โมดูลpubliccontent) ซึ่งค้นด้วย token + สถานะ active และไม่ต้องมี JWT โดยเจตนา - ขอบเขตข้อมูลถูกจำกัดด้วย LINE OA ที่
GET /content-links(กรองline_oa_idของผู้เรียกก่อนเงื่อนไขอื่นทุกครั้ง) — ส่วนfindOne,update,remove,regenerate-tokenและpreview-contentsค้นด้วย id ก่อนแล้วตรวจสิทธิ์ทีหลัง - ข้อมูล LINE OA ที่ติดกลับมาในคำตอบถูก ลดรูปเหลือ 3 ฟิลด์ (
id,lineOaName,lineOaHash) โดยฟังก์ชัน sanitize — หน้าเว็บใช้lineOaHashจากตรงนี้ประกอบลิงก์
ไฟล์อ้างอิงจากซอร์สโค้ด
Frontend (cms-web-2026)
src/app/content-links/page.tsx— ตาราง 6 คอลัมน์, ป้ายเงื่อนไข, การประกอบ URL แบบ LIFF vs เว็บปกติ, กล่องยืนยันออก token ใหม่และลบsrc/app/content-links/components/ContentLinkFormModal.tsx— ฟอร์มในหน้าต่างซ้อน, การผูกหมวดหมู่ → หมวดหมู่ย่อย, การส่งnullอย่างตั้งใจตอน updatesrc/services/content-link.service.ts— endpoint ที่หน้าเว็บเรียกใช้ รวมถึงgetPreviewContentsที่มีแต่ Menu Builder เรียกsrc/components/menu-builder/MenuPreviewEnhanced.tsx— ผู้เรียกpreview-contentsรายเดียวในระบบหลังบ้านmessages/th/content-links.json,messages/en/content-links.json— ข้อความบนหน้าจอทั้งชุด รวม tooltip ที่อธิบายว่ากลุ่มเป้าหมายเป็นตัวกรอง
Backend (line-management-cms-api-go)
internal/modules/contentlink/controller.go— 7 เส้นทาง, การไม่มี ModuleGate,jsNumberIDinternal/modules/contentlink/service.go— create/update/remove/regenerate-token, การ sanitize LINE OA, การทำให้audienceIdsเป็น[],parseJSDateinternal/modules/contentlink/preview.go—getAllDescendantSubcategoryIds, SQL กรอง jsonb ของกลุ่มเป้าหมาย, การเรียงpublished_at DESCinternal/modules/contentlink/dto.go— กฎ validation,optionalChainTrimและพฤติกรรม 500internal/entities/content_link.go—BeforeCreateที่สุ่ม token 32 ไบต์internal/modules/modulegate/coverage_test.go— รายการโมดูลที่มี ModuleGate จริงและโมดูลที่ยกเว้น
Backend (line-management-client-api-go)
internal/publiccontent/service.go—GetContentsByLinkToken, บันไดสามขั้นของการกรองกลุ่มเป้าหมาย, การกลืน error ของ LIFF, เพดานskip > 500internal/contentlink/repository.go—IncrementClickCount(จุดเดียวในระบบที่เพิ่มตัวนับคลิก)
ดูรายละเอียดเชิงเทคนิคเพิ่มเติม (เวอร์ชันก่อนหน้าของทีมเอกสาร) ที่ ลิงก์รวมเนื้อหา