Skip to main content

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 — ไม่เปลี่ยนหน้า ฟอร์มมี:

  1. ชื่อลิงก์ — บังคับกรอก placeholder ตัวอย่าง "เช่น ข่าวสาร, อัปเดตสินค้า"
  2. คำอธิบาย — ไม่บังคับ (ระบุว่าใช้งานภายใน)
  3. กล่องสีเทาหัวข้อ เงื่อนไขการกรอง (เว้นว่างเพื่อแสดงเนื้อหาทั้งหมด) บรรจุ 4 ช่อง:
    • หมวดหมู่ — placeholder "ทุกหมวดหมู่"
    • หมวดหมู่ย่อย — ถูกปิดจนกว่าจะเลือกหมวดหมู่ (placeholder เปลี่ยนเป็น "กรุณาเลือกหมวดหมู่ก่อน") เมื่อเลือกแล้วโหลด dropdown แบบลำดับชั้นที่วาดเส้น ├─/└─ จากเมนู Subcategories และการเปลี่ยนหมวดหมู่จะล้างค่านี้ทิ้งทุกครั้ง
    • ช่วงวันที่เผยแพร่ — RangePicker
    • ตัวกรองกลุ่มเป้าหมาย — เลือกได้หลายกลุ่มจากเมนู Segment audience placeholder "ทุกกลุ่มเป้าหมาย (ไม่กรอง)"
  4. สถานะ — สวิตช์ 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 บน subject line-oa) แต่วิ่งผ่าน auth.CheckPolicies ที่เป็น no-op ปล่อยผ่านทุกคำขอ การบังคับจริงเหลือแค่ JWT + validateOwnership ที่เทียบ line_oa_id (Super Admin ข้ามได้ทั้งหมด) และ การออก token ใหม่ใช้ policy CREATE ไม่ใช่ 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 อย่างตั้งใจตอน update
  • src/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, jsNumberID
  • internal/modules/contentlink/service.go — create/update/remove/regenerate-token, การ sanitize LINE OA, การทำให้ audienceIds เป็น [], parseJSDate
  • internal/modules/contentlink/preview.go — getAllDescendantSubcategoryIds, SQL กรอง jsonb ของกลุ่มเป้าหมาย, การเรียง published_at DESC
  • internal/modules/contentlink/dto.go — กฎ validation, optionalChainTrim และพฤติกรรม 500
  • internal/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 > 500
  • internal/contentlink/repository.go — IncrementClickCount (จุดเดียวในระบบที่เพิ่มตัวนับคลิก)

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