ทำให้แอป E-Scooter Sharing วิ่งครบ E2E ขั้นต่ำ — Flutter + Spring Boot กับการตัดทอนด้วย dev profile
สิ่งที่คุณจะได้เรียนรู้
- จะแบ่งงานระหว่าง UI และ API ตรงไหน เมื่อต้องการให้แอป Flutter + Spring Boot e-scooter sharing วิ่งครบ E2E ขั้นต่ำ
- การออกแบบ UX “เลือกตัวรถภายในพอร์ต”
- การผลัก flow หลัง login (จ่ายเงิน → ทดสอบกฎจราจร → eKYC → ขับ → คืน → ใบเสร็จ) ผ่าน mock ใน dev profile
กลุ่มเป้าหมาย
- นักพัฒนาที่สร้างแอปมือถือ + backend คู่ขนานด้วย Flutter + Spring Boot
- คนที่ต้องการให้ “ทุกหน้าจอลื่นไปครั้งเดียว” ก่อนเชื่อมต่อ auth / payment / eKYC ของจริง
- วิศวกรที่สงสัยว่าจะวาง branch dev / prod ไว้ที่ไหนและอย่างไร
สภาพแวดล้อม
| รายการ | เวอร์ชัน |
|---|---|
| Flutter | 3.x (Material 3) |
| Java | 21 |
| Spring Boot | 3.4.5 |
| PostgreSQL | 17.2 |
| แสดงแผนที่ | flutter_map (OpenStreetMap tiles) |
| Location | geolocator |
| HTTP | dio |
| State management | flutter_riverpod |
| อุปกรณ์ | Pixel API 35 emulator |
บทความในชุด (อยู่ระหว่างการพัฒนา) — บทความนี้เป็นส่วนหนึ่งของ สร้าง E-Scooter Sharing ระดับท้องถนนตั้งแต่ศูนย์เพื่อทำความเข้าใจ — ชุดบันทึก Design, Implementation, และ Operations โปรเจกต์ยังดำเนินอยู่และมีการเพิ่มบทความและบันทึก design ใหม่อย่างต่อเนื่อง แรงจูงใจของชุด, technology stack, อภิธานศัพท์ screen ID, และดัชนีบทความถูกรวบรวมไว้ที่ลิงก์ด้านบน
โครงสร้างโดยรวม
- ข้อมูลหลักรถ / พอร์ต / ผู้ใช้ / การเช่า / เซสชัน eKYC เก็บถาวรใน PostgreSQL
- การพึ่งพาภายนอก (SMS / Stripe / eKYC vendor / MQTT IoT) ถูก mock อยู่ภายใน implementation ของ Service ฝั่ง API
- แอปคุยกับ API “เหมือนของจริง” ส่วนความจริงถูกบิดอยู่ฝั่ง server
[ Flutter app ] ──HTTP──> [ Spring Boot API (dev profile) ]
├─ Auth (SMS Mock)
├─ Payment (Stripe Mock)
├─ eKYC (Vendor Mock)
├─ Rental (DB จริง + Stripe Mock + MQTT Mock)
└─ PostgreSQL (DB จริง)
จุดเริ่ม: หน้าแผนที่และพอร์ต
เปิดแอปบน emulator จะเจอหน้าจอ S02 แผนที่หน้าหลัก

หมุดพอร์ตแบ่งสีเป็นเขียว (มีตัวให้ใช้ได้) และเทา (ไม่มีตัวให้ใช้ได้) ตอนแรกเราคิดจะใส่เลขจำนวนตัวที่ใช้ได้บนหมุด แต่แผนที่ก็แน่นไปด้วยตัวเลข สุดท้ายแค่ใช้สีก็พอแล้วในการสื่อสถานะ
จุดติดขัด ① กดพอร์ตแล้วต้องเห็นอะไร
ก่อนกด เห็นแค่ตัวเลขสรุป (ตัวที่ใช้ได้ / ช่องคืนว่าง) ผู้ใช้ที่อยากเลือกรถจะติดอยู่ตรงนี้ เลยเพิ่ม section “ตัวรถในพอร์ตนี้” ใน sheet ลูก
- API: เพิ่ม
GET /api/v1/ports/{portId}/scootersคืน scooter ID, สถานะ, แบตเตอรี่ - App: เมื่อกดหมุดพอร์ต เปิด
DraggableScrollableSheetแสดงรายการรถที่ scroll ได้

เฉพาะแถวที่เป็น AVAILABLE เท่านั้นที่กดได้ ส่วนที่กำลังซ่อมจะเป็นสีเทา — “อันที่ขับไม่ได้ก็กดไม่ได้”
กดตัวรถจะ navigate ไปที่ /destination?scooterId=...
จุดติดขัด ② รวม UI การเลือกพอร์ตคืนเข้าด้วยกัน
หน้าจอ destination (S04) เดิมให้เลือกพอร์ตบนแผนที่ แล้วกด “ดำเนินการเตรียมขับ” ที่ปุ่มล่างจอ สายตาต้องกระโดดลงล่าง และ feedback ของพอร์ตที่เลือกก็อ่อน เลยออกแบบใหม่
- แสดง จำนวนพอร์ตเท่ากัน ไม่ว่าจะมีช่องคืนหรือไม่ (จำนวนตรงกับหน้าหลัก)
- หมุดที่มีช่องคืน 0 วาดด้วย สีเทา
- เมื่อกด แสดง sheet ขนาดเดียวกัน และ การ์ด “ใช้ได้ / ช่องคืน” เหมือนกับหน้าหลัก
- เพิ่มปุ่ม “คืนที่นี่” ใต้การ์ด (ปิดใช้งานเมื่อช่องคืน = 0)
- เอาปุ่ม “ดำเนินการเตรียมขับ” ที่ล่างจอออก

พอร์ตที่คืนได้:

พอร์ตเต็ม (ช่องคืน 0):

มี review note บอกว่าให้การ์ดด้านบนของหน้า destination fade out แบบเดียวกับหน้าหลัก เลยใส่ Timer ให้ fade ใน 5 วินาทีผ่าน AnimatedOpacity พร้อมปุ่ม info_outline มุมขวาบนเพื่อกดให้กลับมาเห็น — ใช้กลไกเดียวกันระหว่างสองหน้าจอ
ผลักด้านหลัง login ผ่าน dev profile
กด “คืนที่นี่” ในขณะที่ยังไม่ login จะ route ไปหน้า S05 login

SMS ไม่ได้ส่งจริง ฝั่ง API จะแค่ log OTP ตายตัว 123456 — implementation แบบ mock เพราะ phase ตอนนี้คือ “ตรวจสอบพฤติกรรมก่อน” ส่วน Twilio / Firebase Auth ค่อยมาทีหลัง
// AuthServiceImpl
log.info("[SMS Mock] phone number: {}", phoneNumber);
log.info("[SMS Mock] OTP issued: 123456");
ล็อกอินสำเร็จ จะได้รับ dummy JWT (dummy-jwt-token-for-userid-{UUID})
จุดติดขัด ③ ไม่มี Auth Filter ทุก API เป็น 401
ติดอยู่ตรงนี้ AuthService ออก dummy JWT ให้ แต่ฝั่ง Spring Security ไม่มี Filter ที่ตีความ JWT นี้ /users/me /payments/** /ekyc/** /rentals/** เลย return 401 หมด
ใน production จะให้ Resource Server จัดการ JWT verification แต่ทำขนาดนั้นใน PoC ก็เปลืองเวลา ทางแก้: เพิ่ม filter เฉพาะ dev เพียง 1 ตัว
@Component
@Profile("dev")
public class DevDummyJwtAuthFilter extends OncePerRequestFilter {
private static final Pattern DUMMY_TOKEN_PATTERN =
Pattern.compile("^dummy-jwt-token-for-userid-([0-9a-fA-F\\-]{36})$");
@Override
protected void doFilterInternal(...) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
var matcher = DUMMY_TOKEN_PATTERN.matcher(authHeader.substring(7));
if (matcher.matches()) {
var auth = new UsernamePasswordAuthenticationToken(
matcher.group(1), null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}
SecurityConfig รับผ่าน ObjectProvider<DevDummyJwtAuthFilter> และเพิ่มเข้าใน filter chain เฉพาะเมื่อ bean นั้นมีอยู่ (dev) ใน prod bean จะไม่ถูกสร้าง chain เลยผ่านเฉย ๆ
DevDummyJwtAuthFilter devFilter = devDummyJwtAuthFilterProvider.getIfAvailable();
if (devFilter != null) {
http.addFilterBefore(devFilter, UsernamePasswordAuthenticationFilter.class);
}
จากนี้ CurrentUserProvider สามารถดึง UUID จาก Authentication.getName() เพื่อส่งต่อให้ business logic ได้
การจ่ายเงินและทดสอบกฎจราจร
การลงทะเบียนวิธีจ่ายเงินไม่แตะ Stripe — SetupIntent และ attach คืน mock อย่างเดียว

String dummyClientSecret = "seti_mock_" + UUID.randomUUID().toString().substring(0, 8) + "_secret_mock123";
return new SetupIntentResponse(dummyClientSecret, stripeCustomerId);
attach สำเร็จจะเปลี่ยน hasPayment=true และ RouteGuard step ถัดไปก็จะเดินหน้า
ทดสอบกฎจราจรยังคง guard “เฉพาะคะแนนเต็มเท่านั้นที่ผ่าน” ไว้ หน้าจอส่งคะแนน 100 เพื่อผ่าน
จุดติดขัด ④ eKYC ค้างที่ PENDING
flow eKYC จริงคือ “สร้าง session → ส่ง → APPROVED ผ่าน webhook จาก vendor” สิ่งนี้ใน prod ใช้ได้ แต่ใน dev ไม่มีใครส่ง webhook มาให้
เราเลย branch ตรงนี้ด้วย — ใน dev ให้ submit แล้วกระโดดเป็น APPROVED เลย
// EkycServiceImpl.completeSubmission
String nextStatus = isDevProfileActive() ? "APPROVED" : "PENDING";
user.setKycStatus(nextStatus);
session.setProviderStatus(nextStatus);
if ("APPROVED".equals(nextStatus)) {
session.setReviewedAt(LocalDateTime.now());
}
พฤติกรรม prod ยังอยู่ ส่วน dev จะไม่ติดเงื่อนไข “kyc_status == PENDING” ของ RouteGuardService อีกต่อไป
การขับและการคืน
เมื่อ flag ทั้งหมดสว่างแล้ว destination → “คืนที่นี่” → S09 ยืนยันเริ่มขับ โดยตรง

ลาก slider จนสุดจะ trigger ฝั่ง API ให้ทำ:
- Stripe Mock ค้างวงเงิน ¥500
- MQTT Mock log คำสั่งปลดล็อก (ไม่ส่ง MQTT จริง)
- insert record เช่าสถานะ
IN_USEลง DB
ทั้ง StripePaymentService และ IotDeviceService ก็ถูก mock ทั้งหมดตั้งแต่แรก เลยไม่ต้องแตะ service layer เพื่อให้ run ใน dev ได้

ข้อความ “การสลับล็อกถูกปิดใช้ระหว่างปล่อยแบบ phased” เกิดจาก lock-toggle ของ S10 ถูก gate ด้วย --dart-define=ENABLE_S10_LOCK_TOGGLE=true เพื่อควบคุมการปล่อยแบบเป็นช่วง ๆ
กดปุ่ม return ไป S11

จุดติดขัด ⑤ Geofence คืน 50m
S11 ส่ง lat/lng server คำนวณ Haversine ระหว่างพอร์ตปลายทาง ถ้าระยะทาง > 50m จะ reject
double distance = calculateDistance(request.latitude(), request.longitude(),
rental.getDestinationPort().getLatitude(),
rental.getDestinationPort().getLongitude());
if (distance > 50.0) {
throw new IllegalArgumentException("ต้องจอดในพื้นที่คืนที่กำหนด");
}
GPS ของ emulator ชี้ไปยังบ้านที่ห่างจากพอร์ตหลายกิโลเมตร และค่า default lat/lng ของหน้า return ก็ตายตัว การแก้ด้วยมือทุกครั้งไม่จริง เลยใน dev profile ผ่อน threshold เป็น 50000m (ปิดใช้งานเทียบเท่า)
private static final double DEV_GEOFENCE_METERS = 50_000.0;
private static final double PROD_GEOFENCE_METERS = 50.0;
private double currentGeofenceMeters() {
boolean isDev = Arrays.asList(environment.getActiveProfiles()).contains("dev");
return isDev ? DEV_GEOFENCE_METERS : PROD_GEOFENCE_METERS;
}
guard เองยังอยู่ใน prod เฉพาะ dev ที่ผ่านได้
ผ่านเส้นชัย
คืนเสร็จจะแสดงใบเสร็จ

total_amount: ¥100 คือ rate ต่อนาทีของ dev ปัดให้ใกล้ที่สุดเป็นเยน stripe_receipt_url ว่างเพราะเราไม่ได้ยิง Stripe จริง สองอย่างนี้อยู่ในรายการ checklist สำหรับ productionization
สิ่งที่จงใจตัดทิ้ง
- dev profile branches จำกัดอยู่ใน Service ฝั่ง API Controller / DTO / Filter ใช้ร่วมกับ prod
- App ไม่รู้จัก dev/prod เขียนเหมือนคุยกับ “API จริง” เสมอ
- ยังไม่มี Stripe / eKYC / SMS / MQTT client จะถูกสลับเข้าผ่าน
@Profileตอน productionization
การตัดสินใจเหล่านี้ทำให้การ iterate UI กับการตรวจสอบ API เดินบนรางคนละเส้นกับการ onboarding ของ external service
ขั้นถัดไป
- สลับ SMS ไปใช้ของจริง (Twilio หรือ Firebase Auth)
- ต่อ Stripe test mode (SetupIntent และ PaymentIntent ของจริง)
- เชื่อม eKYC vendor API และ webhook receiver
- ต่อ MQTT broker (EMQX หรือ AWS IoT Core)
- ride-history / admin / swapper (operator) apps
มี E2E ขั้นต่ำแล้ว แต่ละข้างต้นสลับได้โดยไม่กระทบกัน แผน productionization ตอนนี้คือ “เอา if ของ dev profile ออก” ทีละจุด