ลิงก์ติดตามการคลิก
ภาพรวม
ลิงก์ติดตามการคลิกคือหน้ากลาง (interstitial) ที่คั่นระหว่างการกดลิงก์กับปลายทางจริง เพื่อให้ระบบนับสถิติการคลิกได้ ใช้กับลิงก์ที่ต้องการวัดผล เช่น ปุ่มใน rich menu หรือปุ่มในข้อความที่ส่งถึงผู้ใช้
รูปแบบ URL คือ /{hash}/r/{id} โดย id คือ tracking token ที่ระบบสร้างไว้
เมื่อผู้ใช้กดเข้ามา หน้านี้จะพยายามระบุตัวตนผู้ใช้ผ่าน LIFF แล้วแลก token
เป็น URL ปลายทางจริง จากนั้นจึงนำทางต่อไป โดยฝั่ง API เป็นผู้บันทึก log การคลิก
หลักการออกแบบสำคัญที่สุดของหน้านี้คือ ต้องไม่ค้าง การระบุตัวตนเป็นเรื่องที่ทำได้ก็ดี ทำไม่ได้ก็ต้องไปต่อ ถ้า LIFF ยังไม่พร้อมภายใน 4 วินาที ระบบจะนำทางต่อ แบบไม่ระบุตัวตนทันที แทนที่จะปล่อยให้ผู้ใช้รออยู่กับหน้าเปล่า
Business Flow
- ผู้ใช้เปิด
/{hash}/r/{id}โดยidคือ tracking token - ระบบดึงข้อมูล OA จาก hash เพื่อหา
lineLiffId— ข้อมูลชุดนี้เป็นข้อมูลสาธารณะ ไม่ต้องผ่านการยืนยันตัวตน - ตัดสินใจว่าจะรอ LIFF หรือไม่:
- ไม่มี
lineLiffId— นำทางต่อทันทีแบบไม่ระบุตัวตน - มี
lineLiffIdและ LIFF พร้อมแล้ว — นำทางพร้อมแนบ token ระบุตัวตน - มี
lineLiffIdแต่ LIFF ยังไม่พร้อม — ตั้ง timeout 4 วินาที (LIFF_WAIT_TIMEOUT_MS) ครบแล้วนำทางต่อแบบไม่ระบุตัวตน
- ไม่มี
- ขณะนำทาง ระบบใช้
Promise.raceระหว่างการขอ ID token กับ timeout 4 วินาที เพื่อไม่ให้การรอ token บล็อกการนำทาง - เรียก
GET /tracking/redirect/{token}(แนบ headerx-liff-tokenถ้ามี) เพื่อรับdestinationUrlกลับมา - ตรวจสอบ same-origin ก่อนนำทาง — ถ้าหน้านี้ถูกเปิดในหน้าต่าง LIFF
(สังเกตจาก query
?le=1ที่ฝั่ง cms-api ใส่มา) และปลายทางเป็น LIFF URL ของ LIFF ID เดียวกัน ระบบจะแปลงเป็น path แบบ same-origin แล้วนำทางภายใน หน้าต่างเดิม เพื่อป้องกันการเปิดหน้าต่าง LIFF ซ้อนกัน ซึ่งจะทำให้เหลือหน้าต่างค้าง เมื่อผู้ใช้กดปิด — ลิงก์เก่าที่ไม่มีle=1และลิงก์ปลายทางภายนอกจะใช้ URL เดิม - นำทางด้วยการแทนที่ประวัติหน้า (
window.location.replace) เพื่อไม่ให้ผู้ใช้ กดย้อนกลับแล้ววนกลับมาที่หน้ากลางอีก - หากขั้นตอนใดล้มเหลว จะแสดงข้อความว่าไม่สามารถเปิดลิงก์ได้และให้ลองใหม่
- มีการป้องกันการนำทางซ้ำซ้อนไว้ เพื่อไม่ให้เกิดการนำทางสองรอบ
หน้าจอและองค์ประกอบหลัก
หน้านี้แทบไม่มี UI ให้ผู้ใช้โต้ตอบ เพราะมีอายุอยู่บนจอเพียงเสี้ยววินาที สิ่งที่ผู้ใช้เห็นมีเพียงสถานะกำลังโหลด และข้อความแจ้งข้อผิดพลาดหากแลก token ไม่สำเร็จ
โครงสร้างการทำงานประกอบด้วย:
- หน้า redirect (
src/app/[hash]/r/[id]/page.tsx) — client component ที่ควบคุมทั้ง flow รวมถึงการตั้ง timeout และการกันนำทางซ้ำ - บริการแลก token (
src/service/tracking-redirect.service.ts) — รับ tracking token และ LIFF token แล้วเรียก API เพื่อขอ URL ปลายทาง - ตัวช่วยแปลง LIFF URL (
src/lib/liff-same-origin.ts) — ตรวจว่าปลายทาง เป็น LIFF ตัวเดียวกันหรือไม่ และแปลงเป็น path ภายในถ้าใช่ - ค่าคงที่
LIFF_WAIT_TIMEOUT_MSกำหนดเวลารอ LIFF สูงสุดไว้ที่ 4,000 มิลลิวินาที
Endpoint ที่ใช้: GET /tracking/redirect/{token} ซึ่งตอบกลับเป็น
destinationUrl เพียงค่าเดียว
จุดเชื่อมต่อกับฟีเจอร์อื่น
- การยืนยันตัวตนผ่าน LIFF — ใช้งานแบบ best-effort คือถือว่าการระบุตัวตน เป็นทางเลือก ถ้าทำไม่สำเร็จก็บันทึกสถิติแบบไม่ระบุตัวตนแทน (ดู liff-authentication)
- ฝั่ง CMS เป็นผู้สร้างลิงก์ติดตามและเป็นผู้ใส่พารามิเตอร์
le=1ที่หน้านี้ใช้ตัดสินใจเรื่อง same-origin - Proxy ลิงก์แคมเปญ — ทำหน้าที่คนละแบบ หน้านี้เป็น client component ที่ต้องรอ LIFF ส่วน proxy แคมเปญเป็น server route ที่ส่งต่อคำขออย่างเดียว ไม่มี UI (ดู campaign-redirect-proxy)
รายละเอียดฝั่ง Backend (Client API)
endpoint ที่หน้านี้เรียกคือ GET /api/tracking/redirect/:token โดยรับ header x-liff-token
แบบไม่บังคับ หลักการสำคัญที่สุดฝั่ง backend สอดคล้องกับหลักการ "ต้องไม่ค้าง" ของหน้าเว็บ:
endpoint นี้คืน destinationUrl เสมอ การบันทึกสถิติเป็นงานพ่วงที่กลืน error ทั้งหมด
มีเพียงกรณีเดียวที่ตอบผิดพลาดคือหา token ไม่เจอ ซึ่งตอบ 404 "Tracking token not found"
สอง เส้นทางของการแลก token
- เส้นทาง built-in (ไม่แตะฐานข้อมูลเลย) — token เป็นข้อมูลที่เข้ารหัสด้วย AES-256-GCM
จาก keyring (
CAMPAIGN_LINK_KEYS) โดยพา URL ปลายทางและบริบทการติดตามมาในตัว ถอดรหัสสำเร็จและยังไม่หมดอายุก็คืนปลายทางได้ทันที - เส้นทาง legacy (อ่านตาราง) — ถ้าถอดรหัสไม่ได้ หรือยังไม่ได้ตั้ง keyring
ระบบจะเงียบๆ ตกไปค้นตาราง
tracking_tokenด้วยค่า token ตรงๆ ไม่มีแถว → 404 - ค่า field ของคลิกในเส้นทาง legacy ประกอบจากทั้งคอลัมน์ของแถวและ jsonb
metadataตามลำดับ fallback ที่กำหนดไว้ (เช่น label ใช้คอลัมน์tracking_labelก่อน แล้วจึงดูใน metadata แล้วจึงเป็นค่าว่าง)
เงื่อนไขที่ต้องผ่านก่อนจะนับคลิก
- ต้องยังไม่หมดอายุ
- ชนิดเนื้อหาต้องเป็น
rich_menu— token ชนิดlead_genไม่ถูกนับคลิก โดยเจตนา - เส้นทาง legacy มีเงื่อนไขเพิ่มว่าแถวต้องมี
status = 'active' - ถ้าเงื่อนไขไม่ผ่าน ระบบยัง คืนปลายทางตามปกติ เพียงแต่ไม่บันทึกสถิติ
สิ่งที่บันทึกและผลข้างเคียง
- ระบุตัวผู้ใช้ก่อน — ใช้กลไก LIFF verification เดียวกับฟีเจอร์อื่น (สร้างแถว guest
ให้อัตโนมัติถ้ายังไม่มี เพื่อให้ foreign key ผ่าน) ถ้าไม่มี token มา / ตั้งค่า verifier ไม่ครบ /
verify ไม่ผ่าน จะบันทึกเป็น แถวไม่ระบุตัวตน (
line_uidเป็น NULL) ไม่ใช่ error โดย log ฝั่ง server แยกแยะสาเหตุไว้ ("ไม่มี token ส่งมา" กับ "ส่งมาแต่ verify ไม่ผ่าน") เพื่อให้ตามสืบแถว anonymous ที่มากผิดปกติได้ - insert
tracking_logด้วยservice='redirect',action_type='click',type='uri',content_type='rich_menu'— จุดที่ควรรู้คือคอลัมน์titleทำหน้าที่เก็บ tracking label ด้วย เพราะเป็นคอลัมน์ NOT NULL - upsert
tracking_line_usersเพื่อ dedupe "ผู้ใช้คนนี้เคยกดจุดนี้แล้ว" ทำเฉพาะเมื่อรู้ตัวผู้ใช้ และมี rich menu id โดยคีย์เป็นรูปแบบrichMenuId:archiveId:actionIndex— รูปแบบคีย์นี้ต้องตรงกับ ฝั่ง worker ที่คำนวณสถิติ ถ้าเปลี่ยนข้างเดียวสถิติจะนับคนละชุดกัน; การชนกันของคีย์เดิมถือเป็น no-op ไม่ใช่ error - ตั้ง flag ใน Redis ชื่อ
RICH_MENU_STAT_DIRTY:ต่อด้วย id ของ rich menu อายุ 7,200 วินาที เพื่อบอก cron ว่าต้องคำนวณสถิติของ rich menu ตัวนี้ใหม่ — สถิติที่ CMS เห็นจึงไม่ได้มาจาก การนับสดทุกครั้ง แต่มาจากรอบ cron ที่ flag นี้กระตุ้น
ข้อสังเกตที่ควรรู้
- แยก namespace ด้วย AAD — tracking token และ campaign token ใช้ key ชุดเดียวกัน
(
CAMPAIGN_LINK_KEYS) แต่ผูก AAD ต่างกัน (tracking-link-v1) ทำให้ token ของแคมเปญ ถอดเป็น tracking token ไม่ได้ และกลับกันด้วย เป็นการกันไม่ให้ token ข้ามชนิดกันโดยตั้งใจ - keyring ว่างหรือถอดไม่ได้ = fail closed แบบเงียบ ไม่ใช่ error ที่ส่งถึงผู้ใช้ แต่ตกไปเส้นทาง legacy แทน ดังนั้นถ้าลืมตั้ง keyring ใน environment ลิงก์แบบ built-in จะกลายเป็น 404 ทั้งหมด ทั้งที่ token ถูกต้อง
richMenuIdที่เป็น 0 จะไม่ถูกตั้ง dirty flag (เงื่อนไขเป็นการเช็คแบบ truthy) ต่างจากการ upserttracking_line_usersที่ใช้เงื่อนไข "ไม่เป็น null" — เป็นความไม่สมมาตร ที่คงไว้เพื่อให้พฤติกรรมตรงกับระบบเดิม