Tech Blog

ทำให้แอป E-Scooter Sharing วิ่งครบ E2E ขั้นต่ำ — Flutter + Spring Boot กับการตัดทอนด้วย dev profile

by Tech Writer
Flutter Spring Boot Java PostgreSQL Mobility dev profile Design

สิ่งที่คุณจะได้เรียนรู้

  • จะแบ่งงานระหว่าง 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 ไว้ที่ไหนและอย่างไร

สภาพแวดล้อม

รายการเวอร์ชัน
Flutter3.x (Material 3)
Java21
Spring Boot3.4.5
PostgreSQL17.2
แสดงแผนที่flutter_map (OpenStreetMap tiles)
Locationgeolocator
HTTPdio
State managementflutter_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 แผนที่หน้าหลัก

S02 home

หมุดพอร์ตแบ่งสีเป็นเขียว (มีตัวให้ใช้ได้) และเทา (ไม่มีตัวให้ใช้ได้) ตอนแรกเราคิดจะใส่เลขจำนวนตัวที่ใช้ได้บนหมุด แต่แผนที่ก็แน่นไปด้วยตัวเลข สุดท้ายแค่ใช้สีก็พอแล้วในการสื่อสถานะ


จุดติดขัด ① กดพอร์ตแล้วต้องเห็นอะไร

ก่อนกด เห็นแค่ตัวเลขสรุป (ตัวที่ใช้ได้ / ช่องคืนว่าง) ผู้ใช้ที่อยากเลือกรถจะติดอยู่ตรงนี้ เลยเพิ่ม section “ตัวรถในพอร์ตนี้” ใน sheet ลูก

  • API: เพิ่ม GET /api/v1/ports/{portId}/scooters คืน scooter ID, สถานะ, แบตเตอรี่
  • App: เมื่อกดหมุดพอร์ต เปิด DraggableScrollableSheet แสดงรายการรถที่ scroll ได้

S02 port sheet

เฉพาะแถวที่เป็น AVAILABLE เท่านั้นที่กดได้ ส่วนที่กำลังซ่อมจะเป็นสีเทา — “อันที่ขับไม่ได้ก็กดไม่ได้”

กดตัวรถจะ navigate ไปที่ /destination?scooterId=...


จุดติดขัด ② รวม UI การเลือกพอร์ตคืนเข้าด้วยกัน

หน้าจอ destination (S04) เดิมให้เลือกพอร์ตบนแผนที่ แล้วกด “ดำเนินการเตรียมขับ” ที่ปุ่มล่างจอ สายตาต้องกระโดดลงล่าง และ feedback ของพอร์ตที่เลือกก็อ่อน เลยออกแบบใหม่

  • แสดง จำนวนพอร์ตเท่ากัน ไม่ว่าจะมีช่องคืนหรือไม่ (จำนวนตรงกับหน้าหลัก)
  • หมุดที่มีช่องคืน 0 วาดด้วย สีเทา
  • เมื่อกด แสดง sheet ขนาดเดียวกัน และ การ์ด “ใช้ได้ / ช่องคืน” เหมือนกับหน้าหลัก
  • เพิ่มปุ่ม “คืนที่นี่” ใต้การ์ด (ปิดใช้งานเมื่อช่องคืน = 0)
  • เอาปุ่ม “ดำเนินการเตรียมขับ” ที่ล่างจอออก

S04 destination map

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

S04 "คืนที่นี่" เปิดใช้งาน

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

S04 "คืนที่นี่" ปิดใช้งาน

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


ผลักด้านหลัง login ผ่าน dev profile

กด “คืนที่นี่” ในขณะที่ยังไม่ login จะ route ไปหน้า S05 login

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 อย่างเดียว

S06 payment registration

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 ยืนยันเริ่มขับ โดยตรง

S09 ride start confirmation

ลาก slider จนสุดจะ trigger ฝั่ง API ให้ทำ:

  1. Stripe Mock ค้างวงเงิน ¥500
  2. MQTT Mock log คำสั่งปลดล็อก (ไม่ส่ง MQTT จริง)
  3. insert record เช่าสถานะ IN_USE ลง DB

ทั้ง StripePaymentService และ IotDeviceService ก็ถูก mock ทั้งหมดตั้งแต่แรก เลยไม่ต้องแตะ service layer เพื่อให้ run ใน dev ได้

S10 active ride

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

กดปุ่ม return ไป S11

S11 return confirmation


จุดติดขัด ⑤ 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 ที่ผ่านได้


ผ่านเส้นชัย

คืนเสร็จจะแสดงใบเสร็จ

S12 receipt

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 ออก” ทีละจุด

ส่งข้อความได้ตามสบาย

กรุณาส่งข้อความ หากมีคำปรึกษาด้านเทคนิค ความคิดเห็น หรือคำถาม