กรณีศึกษา

AU-Van

ระบบจองรถตู้ผ่าน LINE ไม่มีวันจองซ้อน

สร้างระบบจองที่นั่งรถตู้สำหรับนักศึกษาในมหาวิทยาลัยของตัวเองขึ้นใหม่ นักศึกษา hold ที่นั่ง อัปโหลดสลิป และรับแจ้งเตือนได้โดยไม่ต้องออกจาก LINE ส่วนเจ้าหน้าที่ตรวจการชำระเงินในหน้าแอดมิน ใครจะได้ที่นั่งเป็นหน้าที่ของ database และพิสูจน์ด้วยเทสต์ที่ให้นักศึกษาหลายคนแย่งที่นั่งเดียวกันพร้อมกัน

บทบาท
พัฒนาคนเดียว: product, backend, frontend และเซิร์ฟเวอร์
ช่วงเวลา
ก.ย. - ต.ค. 2026 · 433 commits
สถานะ
มีเดโมออนไลน์ อัปเดตทุกครั้งที่ main ผ่านการเช็กทั้งหมด
ประเภท
โปรเจกต์ส่วนตัว · สร้างใหม่จากเวอร์ชันเก่า
นักศึกษา 8 คนแย่งที่นั่งเดียวกัน ได้แค่คนเดียว
8 → 1
เทสต์ Java, เว็บ และ end-to-end
430 / 303 / 10
health check ผ่านทั้งหมด ระหว่างที่ AWS เปลี่ยนเป็นเวอร์ชันใหม่
362 / 362
การตัดสินใจสำคัญที่จดไว้พร้อมเหตุผล
19

01

ปัญหา

นักศึกษาใช้ AU-Van จองที่นั่งรถตู้ของแคมปัส เวอร์ชันแรกทำด้วย Next.js กับ MongoDB ถ้าไม่มีอะไรผิดพลาดก็ใช้ได้ แต่จุดอ่อนดันไปอยู่ตรงที่เกี่ยวกับเงินและที่นั่งพอดี

  • นักศึกษาสองคนได้ที่นั่งเดียวกันได้ ถ้ากดพร้อมกันพอดี
  • สลิปโอนเงินเปิดเป็นสาธารณะ สลิปอาจมีเลขบัญชีธนาคาร และใครมีลิงก์ก็เปิดดูได้
  • แจ้งเตือนหายได้ booking บันทึกสำเร็จ แต่แจ้งเตือนอาจล้มเหลวเงียบ ๆ โดยไม่มีบันทึกอะไรเลย
  • booking ที่ยังไม่จ่ายไม่มีวันหมดอายุ ที่นั่งอาจค้างสถานะ "รอชำระเงิน" ไปจนรถออก และยังไม่มี waitlist ด้วย

นักศึกษาจองผ่านมือถือ ใช้ Wi-Fi ของแคมปัส และจองใน LINE เน็ตหลุดบ้าง กดสองครั้งบ้าง จึงสร้างระบบใหม่โดยยึดกฎข้อเดียว: ถ้ามีสองคนอยากได้สิ่งเดียวกัน database จะเลือกผู้ชนะแค่คนเดียวเสมอ

02

ภาพรวมของระบบ

AU-Van มี backend หนึ่งตัวกับเว็บหนึ่งตัว backend เป็น Spring Boot API (Java 21) ส่วนเว็บทำด้วย React แสดงทั้งหน้าของนักศึกษาใน LINE และพอร์ทัลของเจ้าหน้าที่ ทำคนเดียว การมีแอปเดียวจึงทำง่ายกว่าและเทสต์ง่ายกว่า

  • การล็อกอิน: LINE บอกแอปว่านักศึกษาคนนี้คือใคร API เช็กกับ LINE ครั้งเดียว แล้วออกบัตรผ่านที่ใช้ได้ 15 นาที
  • PostgreSQL 17 (database) เป็นคนตัดสินสุดท้าย กฎสำคัญทุกข้ออยู่ใน database เอง
  • สลิปโอนเงิน เก็บในที่เก็บไฟล์แบบ private เจ้าหน้าที่ดูได้ผ่าน API เท่านั้น
  • งานเบื้องหลัง ได้แก่ ส่งแจ้งเตือน ยกเลิก booking ที่เลยกำหนด และเลื่อนคิว waitlist งานพวกนี้รันอยู่ใน API และอ่านข้อมูลจาก database
StudentLINE app · LIFFStaffadmin portalEC2 t4g.small · Docker ComposeCaddyTLSnginxSPA + /api proxySpring Boot APIauth·booking·outboxPostgres17S3 APIslipsLINE LoginID token verifyLINE MessagingFlex pushSQLprivateverifypush
ภาพ 1. เซิร์ฟเวอร์ที่รันจริง: Caddy ดูแล HTTPS, nginx เสิร์ฟเว็บและส่งต่อ request ไปที่ API ส่วน API คุยกับ PostgreSQL, ที่เก็บไฟล์แบบ private และ LINE

03

หนึ่งที่นั่ง หนึ่งแถว

หัวใจของดีไซน์คือตารางเดียวชื่อ seat_claims database ยอมให้ที่นั่งหนึ่งมีได้แค่แถวเดียว ถ้ามีแถวที่สองของที่นั่งเดิมเข้ามา database จะปฏิเสธเอง

เมื่อนักศึกษาเลือกที่นั่ง API จะเพิ่มแถวที่ hold ที่นั่งไว้ 5 นาที ถ้ามีคนอื่นจองไปก่อน database จะปฏิเสธ และนักศึกษาจะเห็นว่า "ที่นั่งนี้มีคนจองแล้ว" hold ที่หมดเวลาจะถูกมองข้าม แล้วแถวของคนถัดไปจะมาแทนที่

พอนักศึกษากดยืนยัน API จะเปลี่ยนแถวเดิมนั้นให้เป็น booking ที่นั่งจึงถูกป้องกันตลอด ตั้งแต่ "hold" จนถึง "จองแล้ว" ฝั่งแอปก็เช็กด้วย แต่แค่เพื่อแสดงข้อความที่อ่านง่าย คนตัดสินว่าใครได้ที่นั่งคือ database เสมอ

Seat mapderived on readSeat hold5 min · UNIQUEPENDING_PAYMENT2 h deadlinePAYMENT_UNDER_REVIEWslip in private S3CONFIRMED24 h + 1 h remindersCANCELLEDexpired · seats freedPAYMENT_REJECTEDnote to studentholdconfirmupload slipapprovedeadlinerejectresubmitexpires, no job needed
ภาพ 2. ชีวิตของที่นั่งหนึ่งที่ hold จะหมดผลเองเมื่อครบ 5 นาที ส่วน booking ที่ยังไม่จ่ายจะถูกยกเลิกโดยงานที่เช็กทุกนาทีเมื่อเลยกำหนดชำระ

04

จองแค่ครั้งเดียว แม้ Wi-Fi จะไม่เสถียร

กฎแถวเดียวช่วยกันไม่ให้มีสอง hold บนที่นั่งเดียว แต่การยืนยัน booking คือการแก้แถวที่มีอยู่แล้ว ถ้ามีการยืนยันสองครั้งพร้อมกัน ทั้งสองอาจผ่านและได้สอง booking การยืนยันจึงมีตัวป้องกันเพิ่มอีก 2 อย่าง

  1. ส่งซ้ำได้อย่างปลอดภัย request ยืนยันแต่ละครั้งมี key ของตัวเอง API เก็บคำตอบไว้พร้อมกับ booking ถ้า request เดิมมาอีก ก็ได้คำตอบเดิมกลับไป และไม่มี booking ใหม่
  2. ทีละคน API ล็อกแถวของ hold ก่อนเช็ก request ที่สองต้องรอจนอันแรกเสร็จ

error ทุกตัวมีโค้ดสั้น ๆ แนบมา เว็บจึงแสดงข้อความที่ถูกต้องได้

05

การจ่ายเงิน: สลิปเป็นความลับ และมีกำหนดเวลาจริง

นักศึกษาโอนเงินผ่านธนาคาร แล้วอัปโหลดรูปสลิป (ไม่เกิน 5 MB) API บันทึกไฟล์ก่อน แล้วค่อยเปลี่ยน booking เป็น PAYMENT_UNDER_REVIEW เจ้าหน้าที่ดูรูปได้ผ่าน API เท่านั้น ไม่มีลิงก์สาธารณะ

ตอนสร้างใหม่ เพิ่มกฎใหม่ด้วย คือ กำหนดเวลาชำระเงิน นักศึกษาต้องจ่ายภายใน 2 ชั่วโมง และไม่ช้ากว่า 1 ชั่วโมงก่อนรถออก ทุกนาทีจะมีงานคอยหา booking ที่เลยกำหนด แล้วยกเลิก ปล่อยที่นั่งคืน และส่งแจ้งเตือน กฎนี้อาจทำให้นักศึกษาเสียที่นั่งไป จึงจดไว้ว่าต้องประกาศให้ชัด

06

แจ้งเตือนที่ไม่หาย

ใน AU-Van ทุกครั้งที่ booking เปลี่ยน ระบบจะบันทึกแถว "ข้อความที่ต้องส่ง" ไปพร้อมกัน ถ้าไม่บันทึกทั้งคู่ ก็ไม่บันทึกเลยสักอัน วิธีนี้เรียกว่า outbox

ทุก 5 วินาที ตัวส่งจะหยิบข้อความที่ถึงเวลาส่ง แล้วทำเครื่องหมายว่า "กำลังส่ง" ไว้ 2 นาที เพื่อไม่ให้ตัวส่งตัวอื่นหยิบไปซ้ำ จากนั้นจึงส่งไปที่ LINE ข้อความแต่ละอันมี ID ของตัวเอง และ LINE ใช้ ID นี้ตัดข้อความซ้ำทิ้ง นักศึกษาจึงได้รับแต่ละข้อความแค่ครั้งเดียว

  • LINE ตอบว่า OK หรือ "ได้รับแล้ว" → SENT
  • LINE ยุ่ง ล่ม หรือไม่ตอบ → กลับไปเป็น PENDING แล้วลองใหม่ทีหลัง โดยรอนานขึ้นทุกครั้ง
  • ล้มเหลว 5 ครั้ง หรือถูกปฏิเสธชัดเจน (เช่น นักศึกษายังไม่ได้เพิ่มบัญชี LINE เป็นเพื่อน) → DEAD และเจ้าหน้าที่มองเห็นได้

มีข้อความ 10 แบบ ตั้งแต่ "จองสำเร็จ" ไปจนถึงการเตือนก่อนรถออก 24 ชั่วโมงและ 1 ชั่วโมง

one database transactionUPDATE bookingsINSERT outbox_eventsOutboxDispatcherevery 5 s · 2 min leaseLINE Messaging APIX-Line-Retry-KeySENT2xx / 409PENDING429/5xx · backoffDEAD5 tries / 4xxAdmin dead lettersclaim (UPDATE … WHERE)send, no tx
ภาพ 3. การเปลี่ยน booking กับแถวข้อความถูกบันทึกพร้อมกัน การส่งเกิดขึ้นทีหลัง และ retry key ของ LINE ทำให้นักศึกษาไม่ได้รับข้อความเดียวกันซ้ำ

07

Waitlist ที่ยุติธรรม

นักศึกษาเข้า waitlist ได้เฉพาะตอนที่เที่ยวรถเต็มแล้ว ที่นั่งว่างส่วนใหญ่มาจาก hold 5 นาทีที่หมดเวลา และกรณีนี้ไม่มีสัญญาณอะไรเลย จึงไม่รอสัญญาณว่า "ที่นั่งว่าง"

ให้มีงานที่รันทุกนาทีแทน งานนี้จะให้ hold ที่นั่งกับนักศึกษาคนถัดไปในคิว ใช้ได้ 30 นาที (หรือจนถึง 1 ชั่วโมงก่อนรถออก) มันก็คือ hold ธรรมดาอันหนึ่ง ถ้าไปชนกับคนที่จองตรง ๆ กฎเดียวกันของ database ก็เป็นคนตัดสิน งานนี้หยุดทันทีเมื่อเจอการล้มเหลวครั้งแรก จึงไม่มีใครแซงคิวได้ แต่ละคนได้โอกาสครั้งเดียว ถ้าออกแล้วเข้าใหม่ ก็ต้องไปต่อท้ายคิว

08

พิสูจน์ด้วยเทสต์

พูดว่าระบบทำงานถูกต้องน่ะง่าย จึงทุ่มแรงส่วนใหญ่ไปกับเทสต์ที่พยายามทำให้ระบบพัง

  • เทสต์ Java 430 ตัว เช็ก API ส่วนใหญ่รันบน database สำหรับเทสต์ที่เร็ว เสร็จในไม่กี่วินาที บางตัวปล่อยผู้ใช้ 8 คนพร้อมกันเป๊ะ แล้วเช็กว่ามีแค่คนเดียวที่ได้ที่นั่ง
  • เทสต์บน PostgreSQL จริง เช็กการแย่งที่สำคัญซ้ำอีกรอบ ยืนยัน hold เดียวกัน 8 ครั้ง ได้ booking แค่ 1
  • เทสต์ของเว็บ 303 ตัว ครอบคลุมการจองและการตรวจสลิป
  • end-to-end tests 10 ตัว ใช้ Playwright ที่กดใช้งานแอปจริงผ่านหน้าจอ เทสต์จะจอง จ่ายเงิน ได้รับการอนุมัติหรือถูกปฏิเสธ และแย่งที่นั่งกับนักศึกษาอีกคน
  • เช็กด้วยการลองทำพัง: ลบตัวป้องกันทีละตัว ดูว่ามีเทสต์ fail แล้วค่อยใส่คืน

มีบั๊กสองตัวที่เจอได้ก็ต่อเมื่อรันทั้งระบบ ตัวแรก nginx ปฏิเสธไฟล์ที่ใหญ่กว่า 1 MB สลิปจริงจึงอัปโหลดไม่ได้ ตัวที่สอง สคริปต์สร้าง database (Flyway) ไม่เคยรันจริงเลย เทสต์ผ่านเพราะมีเครื่องมืออื่นสร้างตารางให้ แต่บน PostgreSQL จริง แอปไม่มีตารางสักตาราง แก้แล้ว และเพิ่มเทสต์ไว้เช็กเรื่องนี้

09

เซิร์ฟเวอร์: AWS แบบใช้งานจริง

เขียน setup ของ AWS แบบเต็มไว้ด้วย Terraform ซึ่งเป็นเครื่องมือที่ให้เขียนเซิร์ฟเวอร์เป็นโค้ด

  • CloudFront (CDN) อยู่ด้านหน้า API ไม่ถูก cache เลย นักศึกษาคนหนึ่งจึงไม่มีทางเห็น booking ของอีกคน
  • แอปรันบน ECS Fargate ในสอง data center และอัปเดตทีละเครื่อง
  • ข้อมูลอยู่ใน RDS PostgreSQL 17 ส่วนสลิปอยู่ในที่เก็บแบบ private ที่เข้ารหัสไว้
  • ไม่ใช้ NAT gateway เพื่อประหยัดค่าใช้จ่าย

เปิดใช้หนึ่งครั้ง มีแอปสองเครื่องและ database ที่มีตัวสำรอง health check ผ่าน 362 จาก 362 ครั้ง ระหว่างที่ AWS เปลี่ยนเป็นเวอร์ชันใหม่ จากนั้นลบทิ้งในวันเดียวกัน

เดโมที่เปิดตลอดเวลารันบนเซิร์ฟเวอร์ EC2 ตัวเล็กหนึ่งเครื่องกับ Docker Compose ทุกครั้งที่ main ผ่านการเช็กทั้งหมด เวอร์ชันใหม่จะขึ้นเองอัตโนมัติ และมี backup ทุกคืน เก็บไว้หนึ่งสัปดาห์

StudentsCloudFront4 behavioursS3 webOAC · SPA fallbackVPC 10.20.0.0/16 · no NATALBCF-only SGAZ 1aAZ 1bFargate taskSpring BootFargate taskSpring BootRDS PostgresprimaryRDS standbyMulti-AZregionalS3 slipsprivate · SSEECRimmutableSSMsecretsCloudWatch3 alarms/assets, //api/*sync
ภาพ 4. setup ของ AWS ที่เขียนด้วย Terraform เปิดใช้หนึ่งครั้ง มีแอปสองเครื่องในสอง data center และ database ที่มีตัวสำรอง แล้วจึงลบทิ้ง

10

8 ด่านในทุก pull request

ทุก pull request จะมีการเช็กอัตโนมัติ 8 ด่านนี้

  1. เว็บ: เช็กรูปแบบโค้ด เทสต์ และ build
  2. ข้อความ commit: ต้องเขียนตามรูปแบบที่กำหนด
  3. API: เทสต์ทั้งหมด
  4. การแย่งกันใน database: เทสต์บน PostgreSQL จริง
  5. Infrastructure: เช็ก Terraform โดยไม่ต้องล็อกอิน AWS
  6. สคริปต์ deploy: เช็กด้วย ShellCheck
  7. Container: build image ได้ทั้งสองตัว ไม่รันด้วยสิทธิ์ admin (root) และเปิดขึ้นมาได้ปกติ
  8. End-to-end: เทสต์ Playwright

ในนี้มี 3 ด่าน (เว็บ ข้อความ commit และ API) ที่ต้องผ่านก่อนจึงจะ merge ได้ นอกจากนี้ยังมีตัวสแกน secret คอยดูว่ามีรหัสผ่านหรือ key หลุดเข้าไปในโค้ดหรือไม่

11

สิ่งที่ยอมแลก และสิ่งที่จะทำต่อ

  • มีเซิร์ฟเวอร์เครื่องเดียว ถ้าเครื่องนี้ล่ม เดโมก็ล่ม และ backup ก็อยู่ในดิสก์เดียวกัน ถ้ามีผู้ใช้จริงเข้ามาเยอะ setup ของ Terraform ก็พร้อมใช้
  • การแย่งบางแบบเทสต์แค่บน database สำหรับเทสต์ waitlist การตรวจสลิป และการหมดเวลาชำระ ควรเทสต์บน PostgreSQL จริงด้วย
  • งานที่รันตามรอบเวลาต้องรอบ้าง การเลื่อนคิวและแจ้งเตือนต้องรอจนถึงรอบถัดไป
  • สิ่งที่จะทำต่อ: ปุ่มยกเลิกสำหรับนักศึกษา ส่งข้อความที่ล้มเหลวใหม่จากพอร์ทัล และยกเลิก booking ทั้งหมดเมื่อเที่ยวรถถูกยกเลิก

ทุกการตัดสินใจข้างบน รวมถึงอันที่อยากกลับมาแก้ ถูกจดไว้เป็น ADR 19 ฉบับ

เทคโนโลยี

  • Java 21
  • Spring Boot
  • React
  • TypeScript
  • PostgreSQL
  • Flyway
  • LINE LIFF
  • LINE Messaging API
  • Docker
  • Terraform
  • AWS
  • GitHub Actions
  • Playwright
  • Testcontainers

อัปเดตล่าสุด:

กรณีศึกษาถัดไปVetMiMiเว็บจองนัด วิดีโอคอลส่วนตัว และโพสต์ง่าย ๆ สำหรับนักบำบัดด้วยศิลปะในซิดนีย์อ่านกรณีศึกษา

อยากรู้เพิ่มเติม?

ยินดีเล่าให้ฟังทั้งเรื่องโค้ด เหตุผลที่เลือกแบบนี้ และสิ่งที่อยากทำต่อ ส่งข้อความมาได้เลย

ติดต่อพูดคุย