Skip to main content

หน้ารายการเนื้อหา (Content Link)

ภาพรวม

Content link คือลิงก์ที่แอดมินสร้างจากฝั่ง CMS โดยกำหนดเงื่อนไขการกรองไว้ล่วงหน้า เช่น หมวดหมู่ หมวดหมู่ย่อย และกลุ่มผู้ใช้ที่มีสิทธิ์เห็น เมื่อผู้ใช้เปิดลิงก์นั้น หน้านี้จะแสดงรายการบทความทั้งหมดที่ตรงตามเงื่อนไข พร้อมช่องค้นหาและการโหลดเพิ่มแบบ infinite scroll

การใช้งานที่เหมาะสมที่สุดคือการทำ "คลังบทความ" ที่ผูกกับปุ่มใน rich menu หรือลิงก์ที่ส่งในแชท เพราะแอดมินสามารถเปลี่ยนเงื่อนไขการกรองได้ภายหลังโดยไม่ต้องแก้ลิงก์ที่เผยแพร่ไปแล้ว

Business Flow

  1. ผู้ใช้เข้าสู่ path /:hash/pages/:linkToken
  2. ระบบโหลดข้อมูล OA จาก hash หากพบว่า OA มีการตั้งค่า LIFF ไว้ ระบบจะทำการล็อกอิน LIFF ก่อนและรอจนได้รหัสผู้ใช้ เนื่องจากจำเป็นต่อการกรองรายการตามกลุ่มผู้ใช้
  3. ยิงคำขอดึงรายการบทความไปที่ GET /public-content/links/:token/contents พร้อมพารามิเตอร์หน้า จำนวนต่อหน้า และคำค้นหา โดยแนบ x-liff-token และใช้ infinite query ที่โหลด 12 รายการต่อหน้า
  4. อ่านข้อมูลของลิงก์จากผลลัพธ์หน้าแรกเพื่อแสดงชื่อ คำอธิบาย และ breadcrumb ของหมวดหมู่ พร้อมตั้งชื่อ document title ตามชื่อลิงก์
  5. ช่องค้นหามีการหน่วงเวลา 500 มิลลิวินาทีก่อนส่งคำขอ เมื่อคำค้นเปลี่ยน ระบบจะรีเซ็ตการแบ่งหน้ากลับไปเริ่มต้นใหม่
  6. การโหลดเพิ่มใช้ IntersectionObserver โดยตั้งระยะเตรียมโหลดล่วงหน้า 100 พิกเซล เมื่อผู้ใช้เลื่อนถึงจุดสังเกตท้ายรายการ ระบบจะโหลดหน้าถัดไปต่อเนื่องไปจนครบทุกหน้า
  7. เมื่อผู้ใช้แตะการ์ดบทความ ระบบจะพาไปยังหน้าอ่านบทความของ token นั้น

สถานะของหน้าจอ

หน้านี้จัดการสถานะครบทุกกรณีที่ผู้ใช้อาจพบ ได้แก่ กำลังโหลดข้อมูล OA, ไม่พบ OA, กำลังล็อกอิน, ไม่พบลิงก์ที่ร้องขอ, ค้นหาแล้วไม่มีผลลัพธ์ และโหลดรายการครบทั้งหมดแล้ว

หน้าจอและองค์ประกอบหลัก

หน้าเพจ

  • หน้ารายการเนื้อหา (src/app/[hash]/pages/[linkToken]/page.tsx) รับผิดชอบการจัดการ observer สำหรับ infinite scroll การหน่วงเวลาการค้นหา และการรวมผลลัพธ์จากหลายหน้าให้เป็นรายการเดียว

Hook และคอมโพเนนต์

  • Hook ดึงรายการ (hooks/useFetchContentsByLink.ts) รับพารามิเตอร์ token คำค้นหา จำนวนต่อหน้า และสถานะเปิดใช้งาน โดยคำนวณหน้าถัดไปจากการเทียบหมายเลขหน้าปัจจุบันกับจำนวนหน้าทั้งหมด
  • การ์ดบทความ (components/ContentCard.tsx) แสดงภาพปกด้วยคอมโพเนนต์ image ของ Next.js จัดรูปแบบวันที่ด้วย dayjs และแสดง badge ของหมวดหมู่และหมวดหมู่ย่อย

Service และชนิดข้อมูล

  • Content link service (src/service/content-link.service.ts) ให้ฟังก์ชันดึงรายการบทความตาม token ของลิงก์
  • ชนิดข้อมูลของ response พารามิเตอร์การค้นหา และรายการบทความ อยู่ใน src/service/types/content-link.type.ts

Endpoint ที่ใช้

  • GET /public-content/links/:token/contents รับพารามิเตอร์หน้า จำนวนต่อหน้า และคำค้นหา

จุดเชื่อมต่อกับฟีเจอร์อื่น

  • TanStack Query ในรูปแบบ infinite query เป็นกลไกหลักของการแบ่งหน้าและการโหลดเพิ่ม
  • Next.js Image ใช้โหมด fill พร้อมกำหนดขนาดแบบ responsive สำหรับภาพปกในการ์ด
  • dayjs จัดรูปแบบวันที่เผยแพร่
  • ปลายทางของการแตะการ์ดคือ หน้าอ่านบทความ
  • ถูกเรียกจาก เมนูสาธารณะ เมื่อ menu item เป็นชนิด content link ที่ตั้งพฤติกรรมให้เปิดเป็นหน้าใหม่
  • มี service ขนานอีกชุดหนึ่งอยู่ใต้โฟลเดอร์ของ menu ซึ่งเมนูใช้ดึงรายการบทความมาแสดงแบบ inline ภายในหน้าเมนูเอง โดยไม่พาผู้ใช้ออกจากหน้า

รายละเอียดฝั่ง Backend (Client API)

ตัวกรองมาจากลิงก์ ไม่ใช่จาก query ของหน้าเว็บ

หัวใจของฟีเจอร์นี้อยู่ที่ ตัวกรองทั้งหมด (หมวดหมู่ หมวดหมู่ย่อย ช่วงวันที่ audience) ถูกอ่านจากค่าที่ฝังอยู่ในตัวลิงก์ฝั่ง backend ไม่ใช่จากพารามิเตอร์ที่หน้าเว็บส่งมา — หน้าเว็บส่งได้เพียงหมายเลขหน้า จำนวนต่อหน้า และคำค้นหาเท่านั้น

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

บันไดการกรอง audience 3 ชั้น

backend ตัดสินว่าจะแสดงบทความชุดไหนตามลำดับนี้ (เข้าข้อแรกที่เข้าเงื่อนไข แล้วหยุด):

  1. ลิงก์กำหนด audience ไว้ → กรองด้วย audience ของลิงก์ (ไม่สนใจว่าผู้ใช้เป็นใคร)
  2. ลิงก์ไม่กำหนด แต่ ผู้ใช้มี audience → กรองด้วย audience ของผู้ใช้
  3. ไม่เข้าทั้งสองข้อ → แสดงเฉพาะบทความที่ไม่จำกัด audience (public เท่านั้น)

นี่อธิบายอาการที่พบบ่อย: ผู้ใช้ที่ยังไม่ล็อกอินจะเห็นรายการสั้นกว่าผู้ใช้ที่ล็อกอินแล้ว โดยไม่มี error ใดๆ

การ verify ผู้ใช้เป็นแบบ best-effort — ไม่เคยตอบ 401

  • ถ้ามี x-liff-token backend จะพยายาม verify เพื่อรู้ audience ของผู้ใช้ แต่ ความล้มเหลวทุกกรณีถูกกลืนทั้งหมด แล้วไปต่อโดยไม่กรองตาม audience ของผู้ใช้ (ตกไปที่บันไดชั้น 3)
  • ดังนั้น token หมดอายุหรือผิด channel จะไม่ทำให้หน้าเว็บ error แต่จะทำให้เห็นรายการน้อยลงเงียบๆ — เป็นสาเหตุที่หน้านี้ต้องรอให้ล็อกอินเสร็จก่อนยิงคำขอ ไม่งั้นผู้ใช้จะเห็นรายการไม่ครบโดยไม่รู้ตัว
  • ถ้าผู้ใช้รายงานว่า "เห็นบทความไม่ครบ" ให้ตรวจสถานะ token ก่อนเป็นอันดับแรก ไม่ใช่ดูที่ตัวกรองของลิงก์

สิ่งที่ backend ตรวจและบันทึก

  • ลิงก์ที่ไม่พบหรือถูกปิดใช้งาน → 404 Content link not found or inactive
  • OA ของลิงก์ต้อง active ไม่งั้น 404 Invalid or inactive channel
  • นับ click count ของลิงก์ทุกครั้งที่เรียก endpoint นี้ และนับก่อนดึงข้อมูล — ผลข้างเคียงสำคัญคือ การเลื่อนโหลดหน้าถัดไปด้วย infinite scroll ก็นับเป็นคลิกเพิ่มด้วย เพราะเป็นการเรียก endpoint เดียวกัน ตัวเลขคลิกใน CMS จึงหมายถึง "จำนวนครั้งที่มีการดึงรายการ" ไม่ใช่ "จำนวนผู้เปิดลิงก์"
  • การเลือกหมวดหมู่ย่อยจะถูก ขยายเป็นหมวดหมู่ย่อยลูกทั้งหมดที่ active โดยอัตโนมัติ — ลิงก์ที่ผูกกับหมวดย่อยแม่จึงเห็นบทความของหมวดย่อยลูกด้วย

กฎ validation ของพารามิเตอร์ที่หน้าเว็บส่งได้

  • rate limit เฉพาะ route นี้คือ 10 ครั้งต่อ 60 วินาทีต่อ IP เข้มกว่า rate limit ระดับแอปทั่วไป — จุดนี้ควรระวังกับ infinite scroll ที่เลื่อนเร็ว หรือการพิมพ์ค้นหาต่อเนื่อง (การหน่วง 500 มิลลิวินาทีของช่องค้นหาช่วยเรื่องนี้โดยตรง)
  • คำค้นหาถูก sanitize ก่อน validate: ลบอักขระกลุ่ม <>'"%;()& ทิ้ง แล้วบังคับให้เหลือเฉพาะตัวอักษรไทย/อังกฤษ ตัวเลข ช่องว่าง ขีดกลางและขีดล่าง ยาวไม่เกิน 100 ตัวอักษร
  • หมายเลขหน้ารับ 1–100 และจำนวนต่อหน้ารับ 1–50 — หน้านี้ตั้งไว้ที่ 12 รายการต่อหน้าซึ่งอยู่ในช่วงที่รับได้
  • การกัน deep pagination: ถ้าตำแหน่งเริ่มต้นเกิน 500 รายการจะได้ 400 Page number exceeds maximum allowed — ที่ 12 รายการต่อหน้าแปลว่า เลื่อนได้ประมาณหน้าที่ 42 แล้วจะเจอ error ไม่ใช่ "โหลดครบทุกหน้า" ตามที่หน้าเว็บคาด ลิงก์ที่มีบทความจำนวนมากจึงควรมีตัวกรองหรือช่องค้นหาช่วย

ประสิทธิภาพและรูปแบบข้อมูล

  • backend ดึงคำแปลของทุกบทความในหน้าเดียวแบบ batch เดียว และ cache ข้อมูลหมวดหมู่ต่อหนึ่งหน้าผลลัพธ์ เพื่อไม่ให้เกิดปัญหา N+1 query
  • ข้อมูลหมวดหมู่ที่แนบมากับแต่ละบทความถูกอ่านแบบ ไม่กรองสถานะ (ตาม parity กับระบบเดิม) — หมวดหมู่ที่ถูกปิดหรือลบไปแล้วยังอาจปรากฏเป็น badge บนการ์ดได้
  • หมวดหมู่ที่หาไม่เจอจะ ไม่มี key อยู่ในผลลัพธ์เลย (ไม่ใช่ค่า null) ฝั่งเว็บจึงต้องเช็คการมีอยู่ของ key ก่อนใช้
  • ข้อมูลของลิงก์ (ชื่อ คำอธิบาย หมวดหมู่สำหรับ breadcrumb) มาพร้อมกับผลลัพธ์ทุกหน้า ไม่ใช่เฉพาะหน้าแรก — หน้าเว็บอ่านจากหน้าแรกก็เพียงพอ