โครงแอปและ 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
- ผู้ใช้เปิด URL (โดยปกติผ่าน LIFF) Next.js เรนเดอร์ root layout ซึ่งครอบเนื้อหาทั้งหมดด้วย
AppProvider AppProviderรอให้ Zustand rehydrate ค่าจากlocalStorageให้เสร็จก่อน เพื่อป้องกัน hydration mismatch ระหว่างรอจะแสดงหน้า loading- เมื่อ rehydrate เสร็จ ระบบจะครอบ provider ตามลำดับ:
ReactQueryProvider→AntdRegistry→ConfigProvider(ธีม) →Appของ Ant Design ซึ่งจำเป็นสำหรับการเรียกmessageและmodal - หน้าย่อยแต่ละหน้าอ่านพารามิเตอร์
hashจาก URL แล้วเริ่ม flow ของตนเอง (ดู เส้นทาง hash และการระบุ LINE OA) - ทุก request ออกผ่าน axios instance กลางที่ตั้ง baseURL จาก environment variable
NEXT_PUBLIC_BASE_API_CLIENT_URL(ค่าเริ่มต้นคือ/api) - หน้าแรกสุดที่ path
/มีหน้าที่พิเศษ เนื่องจาก LIFF จะ redirect ผู้ใช้กลับมาที่ root endpoint พร้อม queryliff.stateหน้านี้จึงอ่านค่าดังกล่าวแล้วสั่งrouter.replaceไปยัง path ปลายทางจริง
หน้าจอและองค์ประกอบหลัก
ชั้น Layout และ Provider
- Root layout (
src/app/layout.tsx) กำหนด metadata ของทั้งเว็บจาก environment variableNEXT_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ภายใต้คีย์APPuseUserStore(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จาก headerX-Request-Idที่ส่งมา หรือสุ่มขึ้นใหม่ถ้าไม่มี แล้ว สะท้อนกลับใน response headerX-Request-Id— มีประโยชน์มากเวลาแจ้งปัญหา เพราะใช้ค่านี้ไล่ log ได้ตรงตัว นอกจากนี้ยังอ่านx-langไปใช้เลือกภาษาของข้อความ error
Rate limit ระดับแอป (สิ่งที่ทำให้เว็บเจอ 429)
- นับด้วย client IP ไม่ใช่ผู้ใช้ โดยไล่หา IP จาก
x-forwarded-forตัวแรก →x-real-ip→x-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 เพี้ยนกลางทาง