Skip to main content

โครงแอปและ Provider หลัก

ภาพรวม

client-web-2026 คือเว็บฝั่งลูกค้า (end-user) ของระบบ LINE Management พัฒนาด้วย Next.js 16 App Router ร่วมกับ React 19 หน้าเกือบทั้งหมดทำงานในรูปแบบ LIFF mini-app ที่ผู้ใช้เปิดจากภายในแอป LINE ไม่ว่าจะผ่าน rich menu ลิงก์ในห้องแชท หรือการสแกน QR

เอกสารนี้อธิบาย "โครงร่วม" ที่ทุกหน้าใช้ร่วมกัน ได้แก่ root layout, AppProvider, ชั้น React Query, ธีมของ Ant Design, Zustand store สำหรับเก็บสถานะข้ามหน้า และ axios instance ตัวเดียวที่ทุก service เรียกใช้ เหมาะสำหรับนักพัฒนาที่กำลังจะเพิ่มหน้าใหม่หรือแก้ไขพฤติกรรมระดับแอป

จุดสำคัญที่ต้องเข้าใจก่อนอ่านฟีเจอร์อื่นคือ โปรเจกต์นี้ไม่มี middleware และไม่มี route guard ฝั่งเซิร์ฟเวอร์เลย การตรวจสอบสิทธิ์ทั้งหมดเกิดที่ฝั่ง API ผ่าน HTTP header x-liff-token หน้าเว็บมีหน้าที่เพียง init LIFF ดึง token แล้วแนบไปกับ request หาก API ตอบกลับ 401 หรือ 403 หน้าเว็บจะเรนเดอร์สถานะที่เหมาะสมแทน

Business Flow

  1. ผู้ใช้เปิด URL (โดยปกติผ่าน LIFF) Next.js เรนเดอร์ root layout ซึ่งครอบเนื้อหาทั้งหมดด้วย AppProvider
  2. AppProvider รอให้ Zustand rehydrate ค่าจาก localStorage ให้เสร็จก่อน เพื่อป้องกัน hydration mismatch ระหว่างรอจะแสดงหน้า loading
  3. เมื่อ rehydrate เสร็จ ระบบจะครอบ provider ตามลำดับ: ReactQueryProviderAntdRegistryConfigProvider (ธีม) → App ของ Ant Design ซึ่งจำเป็นสำหรับการเรียก message และ modal
  4. หน้าย่อยแต่ละหน้าอ่านพารามิเตอร์ hash จาก URL แล้วเริ่ม flow ของตนเอง (ดู เส้นทาง hash และการระบุ LINE OA)
  5. ทุก request ออกผ่าน axios instance กลางที่ตั้ง baseURL จาก environment variable NEXT_PUBLIC_BASE_API_CLIENT_URL (ค่าเริ่มต้นคือ /api)
  6. หน้าแรกสุดที่ path / มีหน้าที่พิเศษ เนื่องจาก LIFF จะ redirect ผู้ใช้กลับมาที่ root endpoint พร้อม query liff.state หน้านี้จึงอ่านค่าดังกล่าวแล้วสั่ง router.replace ไปยัง path ปลายทางจริง

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

ชั้น Layout และ Provider

  • Root layout (src/app/layout.tsx) กำหนด metadata ของทั้งเว็บจาก environment variable NEXT_PUBLIC_SEO_TITLE และ NEXT_PUBLIC_SEO_DESCRIPTION
  • หน้า entry point (src/app/page.tsx) จัดการ liff.state แล้ว redirect พร้อมเรียกใช้ LIFF auth
  • AppProvider (src/providers/app.provider.tsx) รับผิดชอบการ rehydrate store, ลงทะเบียน Ant Design registry และติดตั้งธีม
  • ReactQueryProvider (src/providers/react-query.provider.tsx) ตั้งค่า staleTime 5 นาที, gcTime 10 นาที, ปิดการ retry อัตโนมัติ และไม่ refetch เมื่อกลับมาโฟกัสหน้าต่าง

ธีมและสไตล์

  • useThemeConfig() (src/hooks/use-theme-config.ts) คืนค่า ThemeConfig ของ Ant Design โดยกำหนดสีหลักเป็น #1677ff
  • Tailwind CSS 4 ใช้งานคู่กับ inline style ของ Ant Design โดยมีฟังก์ชันช่วย cn() (src/utils/tailwind.util.ts) สำหรับรวม class อย่างปลอดภัย

สถานะที่เก็บข้ามหน้า (Zustand store)

  • useAppStore (src/store/app.store.ts) เก็บ redirectUri, lineLiffId และ webhookKey โดย persist ลง localStorage ภายใต้คีย์ APP
  • useUserStore (src/store/user.store.ts) เก็บโปรไฟล์ผู้ใช้ (id, displayName, avatarUrl) ภายใต้คีย์ APP:USER

ชั้นเรียก API

  • axios instance กลางและตัวจัดการข้อผิดพลาด handleAxiosError() อยู่ที่ src/service/axios-instance.ts
  • ค่าคงที่ base URL แยกตามปลายทาง (BASE_API_CLIENT_URL, BASE_API_ADMIN_URL, BASE_API_LINE_URL) และรายชื่อ module ของ API รวมอยู่ที่ src/service/constants/

คอมโพเนนต์ UI ที่ใช้ซ้ำ

  • โครงหน้าและส่วนหัว/ส่วนท้ายอยู่ใน src/components/layout-app/ ประกอบด้วย main page wrapper, header และ footer
  • คอมโพเนนต์พื้นฐานอย่าง loading overlay, card และ skeleton อยู่ใน src/components/ui/

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

  • Next.js 16 App Router + React 19 ทุกหน้าเป็น client component ยกเว้น layout ที่ต้องทำ generateMetadata ฝั่งเซิร์ฟเวอร์
  • Ant Design 6 ใช้ร่วมกับ @ant-design/nextjs-registry ซึ่งจำเป็นสำหรับการทำงานกับ React 19
  • TanStack Query v5 เป็นกลไก data fetching ทั้งหมดของแอป ทั้ง query, infinite query และ mutation
  • Zustand 5 พร้อม persist middleware สำหรับสถานะที่ต้องคงอยู่ข้ามการรีเฟรช
  • axios เป็น HTTP client ตัวเดียวที่ทุก service ใช้ร่วมกัน
  • @line/liff เป็นฐานของการยืนยันตัวตน รายละเอียดอยู่ที่ การล็อกอิน LINE ผ่าน LIFF
  • โปรเจกต์ไม่มีไฟล์ middleware การ redirect และการตรวจสิทธิ์ทั้งหมดทำภายใน client component เท่านั้น

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

ปลายทางของ axios instance กลางคือ service client-api ซึ่งเขียนด้วย Go (gin + sqlx เขียน SQL มือ ไม่มี ORM) โดยตั้งใจให้เป็น port แบบ 1:1 ของ NestJS API เดิม ทั้งรูปแบบ error, ข้อความ validation, การ serialize วันที่ และ route path ทั้งหมด — เพื่อให้เปลี่ยน service เบื้องหลังได้โดยหน้าเว็บไม่ต้องแก้อะไรเลย

รูปแบบ error envelope ที่หน้าเว็บต้องรองรับ

ทุก error และ panic ถูกดักด้วย exception middleware แล้วแปลงเป็น envelope รูปเดียวกันเสมอ คือ {"statusCode", "message", "error"} โดย

  • message อาจเป็น string เดี่ยว หรือ array ของ string ก็ได้ (กรณี validation หลายข้อ) — handleAxiosError() ฝั่งเว็บต้องเผื่อทั้งสองรูปแบบ
  • error คือ HTTP reason phrase เช่น Bad Request, Unauthorized
  • error ที่ไม่ใช่ exception ที่ประกาศไว้ (bug/panic) จะกลายเป็น 500 พร้อมข้อความ System Failure เสมอ ไม่มีรายละเอียดภายในหลุดออกมา
  • เรียก path ที่ไม่มีอยู่จริงจะได้ 404 ใน envelope เดียวกัน ข้อความว่า Cannot <METHOD> <path> (ไม่ใช่หน้า 404 ของ framework)

ชั้น middleware ที่ทุก request ของหน้าเว็บวิ่งผ่าน

เรียงจากชั้นนอกเข้าใน: เก็บ metric (status + latency) → CORS → security header → exception handler แล้วเมื่อเข้ากลุ่ม /api จะเพิ่มอีก 2 ชั้นคือ request-scoped context และ rate limiter

  • CORS เปิดกว้างทั้งหมด (origin/methods/headers เป็น * แบบไม่มีเงื่อนไข) เป็น parity กับ service เดิม — เว็บจึงเรียกข้าม origin ได้โดยไม่ต้องตั้งค่าอะไร แต่ก็หมายความว่า ไม่มีการจำกัด origin ฝั่งเซิร์ฟเวอร์ ความปลอดภัยพึ่ง x-liff-token เพียงอย่างเดียว
  • security header ถูกใส่ครบชุด default ยกเว้น Cross-Origin-Resource-Policy ที่ถูกปิดไว้ (ตาม service เดิม) เพื่อให้โหลด resource ข้าม origin ได้
  • request-scoped context สร้าง transactionId จาก header X-Request-Id ที่ส่งมา หรือสุ่มขึ้นใหม่ถ้าไม่มี แล้ว สะท้อนกลับใน response header X-Request-Id — มีประโยชน์มากเวลาแจ้งปัญหา เพราะใช้ค่านี้ไล่ log ได้ตรงตัว นอกจากนี้ยังอ่าน x-lang ไปใช้เลือกภาษาของข้อความ error

Rate limit ระดับแอป (สิ่งที่ทำให้เว็บเจอ 429)

  • นับด้วย client IP ไม่ใช่ผู้ใช้ โดยไล่หา IP จาก x-forwarded-for ตัวแรก → x-real-ipx-client-ip → socket
  • เป็น sliding window: เกิน limit แล้วจะ ตั้ง block key ทำให้ request ถัดไปตอบ 429 ทันทีตลอดช่วง block โดยไม่นับเพิ่ม — การกด retry รัวๆ จึงไม่ช่วยและไม่ทำให้ block ยาวขึ้น แต่ก็ไม่หลุดเร็วขึ้น ต้องรอให้ครบเวลา
  • ผลข้างเคียงที่ควรรู้: ผู้ใช้หลายคนที่ออกเน็ตผ่าน IP เดียวกัน (เช่น office NAT หรือ mobile carrier NAT) จะแชร์โควตากันทั้งก้อน — เป็นสาเหตุที่พบได้จริงของ 429 ที่ดูเหมือนไม่มีเหตุผล
  • เก็บ counter บน Redis (แทน limiter ที่เดิมเป็น in-memory ต่อ pod จึงนับรวมทุก pod ได้แล้ว) และ ถ้า Redis error จะปล่อยผ่านทั้งหมด (fail open) — ระบบไม่เคยปฏิเสธ traffic เพราะ storage พัง

Health check และผลต่อความพร้อมของ API

  • แยกเป็น liveness (/livez, /healthz — เช็คแค่ว่า process ยังอยู่) และ readiness (/readyz — ผูกกับผล ping ของ dependency ทุกตัวที่เปิดใช้ รวมถึงสถานะ draining)
  • liveness ตั้งใจไม่ผูกกับ database เพราะการรีสตาร์ต pod ไม่ช่วยแก้ปัญหา DB ล่ม และจะทำให้ pod ถูกฆ่าวนไปเรื่อยๆ
  • ผล ping ถูก cache จาก background ticker ไม่ได้ยิง DB ใหม่ทุกครั้งที่ probe เข้ามา
  • ตอน shutdown ระบบจะทำให้ readiness fail ก่อน หยุดรับ request เพื่อให้ load balancer ถอน pod ออกก่อน — ผลคือการ deploy ปกติไม่ควรทำให้ request ของหน้าเว็บขาดกลางทาง
  • มี endpoint GET /api ที่ตอบข้อความสั้นๆ แบบ text/plain ใช้เป็น smoke test ว่า API พร้อมจริง และ GET /api/health ที่คืน breakdown ต่อ dependency (ตอบ 503 ถ้ามีตัวใดล่ม)
  • /metrics และ profiling อยู่บนพอร์ต admin แยก (:9100) ที่เดียว ไม่ถูก expose ออกมาบนพอร์ตที่เว็บเรียก

Edge case ที่ควรรู้

  • Database, Redis และ message queue ทั้งหมด เปิดใช้แบบ optional — service boot ได้แม้ไม่มี backend ครบ ในกรณีนั้น rate limiter และ cache จะกลายเป็น no-op เงียบๆ ไม่ error ออกมา
  • ถ้า dependency ตัวใดล่มต่อเนื่อง ระบบจะ retry แล้วแจ้งเตือนเมื่อครบ 1 นาที และถ้าครบ 3 นาทีจะ drain แล้วออกจาก process ให้ orchestrator สร้าง pod ใหม่ — ช่วงนี้หน้าเว็บอาจเจอ error ชั่วคราวก่อนกลับมาปกติเอง
  • config ทั้งหมดถูก validate ตอน boot (fail-fast) ดังนั้นถ้า API ขึ้นมาได้ แปลว่าค่าตั้งค่าถูกต้องแล้ว ไม่ต้องสงสัยกรณี config เพี้ยนกลางทาง