หน้ารายการเนื้อหา (Content Link)
ภาพรวม
Content link คือลิงก์ที่แอดมินสร้างจากฝั่ง CMS โดยกำหนดเงื่อนไขการกรองไว้ล่วงหน้า เช่น หมวดหมู่ หมวดหมู่ย่อย และกลุ่มผู้ใช้ที่มีสิทธิ์เห็น เมื่อผู้ใช้เปิดลิงก์นั้น หน้านี้จะแสดงรายการบทความทั้งหมดที่ตรงตามเงื่อนไข พร้อมช่องค้นหาและการโหลดเพิ่มแบบ infinite scroll
การใช้งานที่เหมาะสมที่สุดคือการทำ "คลังบทความ" ที่ผูกกับปุ่มใน rich menu หรือลิงก์ที่ส่งในแชท เพราะแอดมินสามารถเปลี่ยนเงื่อนไขการกรองได้ภายหลังโดยไม่ต้องแก้ลิงก์ที่เผยแพร่ไปแล้ว
Business Flow
- ผู้ใช้เข้าสู่ path
/:hash/pages/:linkToken - ระบบโหลดข้อมูล OA จาก hash หากพบว่า OA มีการตั้งค่า LIFF ไว้ ระบบจะทำการล็อกอิน LIFF ก่อนและรอจนได้รหัสผู้ใช้ เนื่องจากจำเป็นต่อการกรองรายการตามกลุ่มผู้ใช้
- ยิงคำขอดึงรายการบทความไปที่
GET /public-content/links/:token/contentsพร้อมพารามิเตอร์หน้า จำนวนต่อหน้า และคำค้นหา โดยแนบx-liff-tokenและใช้ infinite query ที่โหลด 12 รายการต่อหน้า - อ่านข้อมูลของลิงก์จากผลลัพธ์หน้าแรกเพื่อแสดงชื่อ คำอธิบาย และ breadcrumb ของหมวดหมู่ พร้อมตั้งชื่อ document title ตามชื่อลิงก์
- ช่องค้นหามีการหน่วงเวลา 500 มิลลิวินาทีก่อนส่งคำขอ เมื่อคำค้นเปลี่ยน ระบบจะรีเซ็ตการแบ่งหน้ากลับไปเริ่มต้นใหม่
- การโหลดเพิ่มใช้
IntersectionObserverโดยตั้งระยะเตรียมโหลดล่วงหน้า 100 พิกเซล เมื่อผู้ใช้เลื่อนถึงจุดสังเกตท้ายรายการ ระบบจะโหลดหน้าถัดไปต่อเนื่องไปจนครบทุกหน้า - เมื่อผู้ใช้แตะการ์ดบทความ ระบบจะพาไปยังหน้าอ่านบทความของ 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 ตัดสินว่าจะแสดงบทความชุดไหนตามลำดับนี้ (เข้าข้อแรกที่เข้าเงื่อนไข แล้วหยุด):
- ลิงก์กำหนด audience ไว้ → กรองด้วย audience ของลิงก์ (ไม่สนใจว่าผู้ใช้เป็นใคร)
- ลิงก์ไม่กำหนด แต่ ผู้ใช้มี audience → กรองด้วย audience ของผู้ใช้
- ไม่เข้าทั้งสองข้อ → แสดงเฉพาะบทความที่ไม่จำกัด audience (public เท่านั้น)
นี่อธิบายอาการที่พบบ่อย: ผู้ใช้ที่ยังไม่ล็อกอินจะเห็นรายการสั้นกว่าผู้ใช้ที่ล็อกอินแล้ว โดยไม่มี error ใดๆ
การ verify ผู้ใช้เป็นแบบ best-effort — ไม่เคยตอบ 401
- ถ้ามี
x-liff-tokenbackend จะพยายาม 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) มาพร้อมกับผลลัพธ์ทุกหน้า ไม่ใช่เฉพาะหน้าแรก — หน้าเว็บอ่านจากหน้าแรกก็เพียงพอ