Skip to main content

เส้นทาง [hash] และการระบุ LINE OA

ภาพรวม

mini-app ฝั่งลูกค้าทุกตัวอยู่ภายใต้ path รูปแบบ /:hash/... โดย hash คือรหัสสาธารณะของ LINE OA (ใช้ค่าเดียวกับ webhook key hash) ค่านี้เป็นกุญแจที่หน้าเว็บใช้ระบุว่ากำลังทำงานในบริบทของ OA ใด

หน้าเพจใช้ hash เพื่อค้นหาข้อมูลสี่อย่าง

  1. lineLiffId สำหรับ init LIFF
  2. formLiffId ซึ่งเป็น LIFF แยกสำหรับหน้าฟอร์ม (หากมีการตั้งค่าไว้)
  3. botBasicId ในรูปแบบ @xxxx สำหรับสร้างลิงก์ไปยังห้องแชทของ OA
  4. ชื่อและรูปภาพของ OA เพื่อใช้ทำ Open Graph metadata เวลาแชร์ลิงก์

นอกจากการ resolve ข้อมูลแล้ว layout ของ segment นี้ (src/app/[hash]/layout.tsx) ยังสร้าง link preview ระดับ OA ให้ทุกหน้าที่อยู่ภายใต้ path นี้แบบ server-side ด้วย

Business Flow

  1. ผู้ใช้เปิด URL ในรูปแบบ /:hash/:feature/...
  2. ฝั่งเซิร์ฟเวอร์: ฟังก์ชัน generateMetadata() ของ layout ยิงคำขอไปที่ endpoint ดึงข้อมูล OA โดยลองใช้ internal API URL ก่อน แล้วค่อย fallback ไปยัง public URL พร้อมกำหนด cache ไว้ 300 วินาที เมื่อได้ชื่อและรูปภาพ OA จะประกอบเป็น title, description, openGraph และ twitter card หากเรียกไม่สำเร็จจะ fallback ไปใช้ค่าจาก environment variable
  3. ฝั่ง Client: หน้าเพจเรียก useFetchGetLineOaByHash(hash) ผ่าน React Query โดยตั้ง retry ไว้เพียง 1 ครั้ง
  4. เก็บ lineLiffId ที่ได้ลงใน useAppStore แล้วส่งต่อให้ useLiffAuth เพื่อเริ่มกระบวนการล็อกอิน
  5. หน้าลูกสามารถประกาศ metadata ของตัวเองทับได้ Next.js จะ merge ค่าของ child ทับ parent ให้อัตโนมัติ
  6. path /:hash ซึ่งเป็นหน้า index ของ OA ทำหน้าที่เป็นหน้าตรวจสอบสิทธิ์แบบเรียบง่าย คือล็อกอิน LIFF แล้วแสดงโปรไฟล์ผู้ใช้ หากไม่พบโปรไฟล์จะคืนค่าเป็นหน้า Not Found

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

Layout และหน้า index

  • Layout ของ segment (src/app/[hash]/layout.tsx) รวมทั้ง generateMetadata() และฟังก์ชันดึงข้อมูล OA ฝั่งเซิร์ฟเวอร์
  • หน้า index (src/app/[hash]/page.tsx) เป็นหน้าตรวจ auth และแสดงโปรไฟล์

การ resolve ข้อมูล OA

  • useFetchGetLineOaByHash(hash) (src/hooks/use-fetch-line-oa-by-hash.ts) เป็น hook ฝั่ง client
  • LINE OA service (src/service/line-oa.service.ts) รองรับทั้งรูปแบบใหม่ที่คืน lineLiffId มาตรง ๆ และรูปแบบเดิมที่ซ้อนอยู่ใต้ออบเจ็กต์ข้อมูลล็อกอิน
  • Endpoint ที่ใช้คือ GET /line-oa/get-by-hash/:hash
  • Environment variable ที่เกี่ยวข้อง: BASE_API_INTERNAL_URL, NEXT_PUBLIC_BASE_API_CLIENT_URL, NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_SEO_TITLE และ NEXT_PUBLIC_SEO_DESCRIPTION

เส้นทางทั้งหมดภายใต้ segment นี้

Routeฟีเจอร์
/:hashหน้าตรวจสอบสิทธิ์และแสดงโปรไฟล์
/:hash/formหน้า catch สำหรับรับ liff.state แล้ว redirect ไปยังฟอร์ม
/:hash/form/:idหน้ากรอกฟอร์ม
/:hash/form/:id/thank-youหน้าขอบคุณหลังส่งฟอร์ม
/:hash/content/:tokenหน้าอ่านบทความ
/:hash/pages/:linkTokenหน้ารายการเนื้อหาตาม content link
/:hash/menu/:tokenหน้าเมนูที่สร้างจาก menu builder
/:hash/friend-trackแคมเปญเพิ่มเพื่อน โดยค้นหาแคมเปญจาก hash
/:hash/friend-track/:tokenแคมเปญเพิ่มเพื่อนที่ระบุด้วย token
/:hash/bulletinบอร์ดประกาศ
/:hash/bulletin/:postIdรายละเอียดประกาศ
/:hash/loyaltyบัตรสะสมแต้มสำหรับลูกค้า
/:hash/loyalty-staffเครื่องมือสำหรับพนักงาน
/:hash/booking/:tokenการจองนัดหมาย
/:hash/r/:idtracking redirect

เส้นทางที่อยู่นอก segment นี้

หน้าแรก (/), หน้า Not Found, หน้า verify-line-login, campaign redirect proxy ที่ /c/:token, หน้า sandbox สำหรับทดลอง Ant Design และ API route ตัวอย่างของ Sentry

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

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

endpoint GET /api/line-oa/get-by-hash/:hash เป็น endpoint แรกที่ทุกหน้า LIFF เรียก และเป็นเส้นทางเดียวที่ออกแบบให้ ไม่ต้องยืนยันตัวตนเลย เพราะหน้าเว็บยังไม่มี LIFF ID จึงยังไม่สามารถทำ liff.init() เพื่อขอ token ได้ — เป็นปัญหาไก่กับไข่ที่ endpoint นี้แก้ให้

กฎการค้นหาที่ทำให้เกิดอาการ "resolve ได้แต่ล็อกอินไม่ผ่าน"

จุดนี้สำคัญและมักทำให้เข้าใจผิด: backend ค้น OA ด้วยเงื่อนไขที่ หลวมกว่า ขั้นตอน verify token

  • endpoint นี้ยอมรับ OA ที่ยังไม่ถูกลบ (ไม่ได้บังคับว่าต้อง active)
  • แต่ขั้นตอน verify token ของ การล็อกอิน LINE ผ่าน LIFF บังคับว่า OA ต้อง active
  • ผลคือ OA ที่ถูกปิดใช้งาน (inactive) จะ resolve hash สำเร็จ หน้าเว็บได้ LIFF ID ไปแล้ว liff.init() ผ่าน แต่พอเรียก API จริงจะได้ 401 Invalid channel — อาการดูเหมือนปัญหา auth แต่ต้นเหตุอยู่ที่สถานะของ OA
  • hash ที่ไม่มีอยู่จริงจะได้ 400 (ไม่ใช่ 404) พร้อม message เป็นรหัส APP_007 ตาม parity กับระบบเดิม ฝั่งเว็บจึงควรจับที่ status 400 ของ endpoint นี้ว่า "OA ไม่พบ"

รูปแบบค่าที่ตอบกลับ และเหตุผลที่หลายฟิลด์เป็น null ได้

  • lineLiffId และ formLiffId อ่านมาจากข้อมูลตั้งค่า LINE Login ของ OA โดย ค่าว่างหรือ key ที่ไม่มีอยู่จะกลายเป็น null ไม่ใช่ string ว่าง — ฝั่งเว็บต้องเช็ค null ก่อนใช้ ไม่ควรพึ่ง truthiness ของ string เพียงอย่างเดียว
  • formLiffId เป็น null ได้ตามปกติ (OA ที่ไม่ได้ตั้ง LIFF แยกสำหรับฟอร์ม) กรณีนั้นหน้าฟอร์มต้องใช้ LIFF ID หลัก
  • imageUrl เกิดจากการแปลง path ของรูปปกใน storage ให้เป็น public URL โดยมีกฎรอบคอบไว้แล้ว: path ว่าง → null (ไม่ยิง URL ที่ชี้ไป bucket root เปล่าๆ ซึ่งจะทำให้ og:image เสีย), ค่าที่เป็น absolute URL อยู่แล้วจะส่งผ่านไปตรงๆ ไม่แปลงซ้ำ และถ้าระบบไม่ได้ตั้งค่า storage ไว้จะได้ null โดยไม่ error
  • botBasicId คือ @-id สาธารณะ ใช้ประกอบลิงก์ห้องแชท OA รูปแบบ https://line.me/R/ti/p/<id> ซึ่งจำเป็นตอนเปิดหน้าขอบคุณใน browser ภายนอก (นอกแอป LINE)
  • name คือชื่อ OA ที่หน้าเว็บนำไปใช้เป็น document title และ OG title

ข้อสังเกตด้าน security

  • endpoint นี้ เปิดสาธารณะโดยเจตนา จึงคืนเฉพาะฟิลด์ที่ปลอดภัยจริง — ทุกค่าที่ส่งกลับเป็นข้อมูลที่ปรากฏบนหน้า LINE สาธารณะของ OA อยู่แล้ว
  • ห้ามเพิ่ม credential ใดๆ (channel secret, access token, ค่า config ภายใน) ลงใน response นี้เด็ดขาด — ถ้าต้องการข้อมูลเพิ่มสำหรับหน้าเว็บ ต้องไปสร้าง endpoint ที่ต้อง verify token แยก
  • การที่ hash ถูกเดาได้ไม่ถือเป็นช่องโหว่ในตัวเอง เพราะทุกเส้นทางที่มีข้อมูลจริงยังต้องผ่าน channel binding อีกชั้น

Edge case ที่ควรรู้

  • endpoint นี้ไม่มีการ cache ฝั่ง backend — การ cache 300 วินาทีที่เห็นในเอกสารนี้เป็นของ generateMetadata() ฝั่ง Next.js เท่านั้น ดังนั้นถ้าแอดมินแก้ชื่อหรือรูปปก OA ค่าใหม่จะมาถึงหน้า client ทันที แต่ OG metadata อาจยังเป็นค่าเดิมจนกว่า cache จะหมดอายุ
  • ข้อมูลตัวเดียวกันนี้ยังถูกใช้ต่อโดยฟีเจอร์อื่นผ่านการ resolve hash ของโดเมนตัวเอง (บอร์ดประกาศ, บัตรสะสมแต้ม) ซึ่งอาจมีเงื่อนไขสถานะ OA ต่างจาก endpoint นี้เล็กน้อย