Tech Blog

電動キックボードシェアリングを最小E2Eまで動かすまで — Flutter + Spring Boot を dev profile で割り切る

Flutter Spring Boot Java PostgreSQL Mobility dev profile 設計

この記事でわかること

  • Flutter(モバイル)と Spring Boot(API)で電動キックボードシェアリングを最小E2Eまで動かすために、UI と API のどこで何を割り切ったか
  • ポート選択時に「機体を選ぶ」UX をどう作ったか
  • ログイン以降のフロー(決済 → 交通ルールテスト → eKYC → 乗車 → 返却 → レシート)を dev profile のモック実装でどう貫通させたか

対象読者

  • Flutter + Spring Boot でモバイルアプリ + バックエンドを並行して育てている方
  • 認証 / 決済 / eKYC を本物につなぐ前に「画面だけは一通り動かしたい」と考えている方
  • dev / prod のコード分岐をどこにどう仕込むかを悩んでいる方

動作環境

項目バージョン
Flutter3.x(Material 3)
Java21
Spring Boot3.4.5
PostgreSQL17.2
地図表示flutter_map(OpenStreetMap タイル)
位置情報geolocator
HTTPdio
状態管理flutter_riverpod
端末Pixel API 35 エミュレーター

シリーズ記事(開発進行中) — この記事は 街中の電動キックボードシェアリングを自作して理解する — 設計・実装・運用の記録シリーズ の一部です。プロジェクトは現在も継続中で、新しい記事や設計判断の追記が随時行われます。プロジェクト全体の動機・採用技術・画面IDなどの用語表・他記事への索引はリンク先にまとめています。

全体構成

  • 機体マスタ・ポートマスタ・ユーザー・レンタル・eKYC セッションは PostgreSQL に永続化
  • 外部依存(SMS / Stripe / eKYC ベンダー / MQTT IoT)は API 側のサービス実装の中でモック化
  • アプリは「実 API を見ている」つもりで通信し、サーバー側で割り切る
[ 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)

出発点:ホーム地図とポート

エミュレーターでアプリを起動すると、まず S02 ホーム画面が出ます。

S02 ホーム画面

ポートのピンは緑(利用可能な機体がある)とグレー(利用可能な機体ゼロ)に色分け。当初は「ピンに利用可能数のバッジを出すか」を迷いましたが、地図上に数字が並ぶと視覚的にうるさく、結局アイコンの色だけで状態を伝える設計に落ち着きました。


詰まりどころ① ポートをタップしたあと、何を見せるか

タップ前は集計値(利用可能/返却枠)しかないので、機体を選びたいユーザーが詰まります。そこで 「このポートのキックボード」セクションを子画面に出す API + UI を足しました。

  • API: GET /api/v1/ports/{portId}/scooters を新設し、機体ID・状態・バッテリー残量を返す
  • アプリ: ポートピンをタップしたら DraggableScrollableSheet で機体一覧をスクロール可能なシートとして表示

S02 ポート選択シート

AVAILABLE の機体だけ行をタップ可能にし、メンテナンス中はグレーで非活性にして「乗れないものは押せない」状態にしています。

機体をタップすると /destination?scooterId=... に遷移します。


詰まりどころ② 返却ポート選択 UI の統一

destination 画面(S04)は当初、地図でポートを選ばせてから画面下の 「乗車手続きへ」ボタンで遷移 する流れでした。ただ操作中の視線が画面下端に飛んでしまうのと、選択中ポートのフィードバックが弱いのが気になり、設計を変えました。

  • 返却枠の有無に関係なく 同じ件数のポートを表示(ホーム画面と件数を揃える)
  • 返却枠 0 のポートは ピンをグレー で描画
  • ポートタップ時に ホーム画面と同じサイズ・同じ「利用可能/返却枠」カード を出す
  • カードの下に 「ここで返却」ボタン を置き、押下で次のフローに進む(返却枠 0 のときは非活性)
  • 画面下端の「乗車手続きへ」ボタンは廃止

S04 destination 画面のピン表示

返却可能なポートを選んだ場合のシート:

S04 「ここで返却」活性

満杯ポート(返却枠 0)を選んだ場合のシート:

S04 「ここで返却」非活性

「機体選択時にホーム画面の上部カードが自動フェードアウトしていたのと 同じ挙動 を destination 画面の上部カードにも適用してほしい」というレビュー指摘も入ったので、Timer で 5 秒後に AnimatedOpacity で透明化し、右上の info_outline ボタンで再表示できるようにしてあります(両画面で同じ仕組みを使い回し)。


ログイン以降を dev profile で貫通させる

「ここで返却」を押すと、未ログインなら S05 ログイン画面に飛ばします。

S05 ログイン画面

SMS は外部送信せず、API 側のログに固定 OTP 123456 を吐くだけ のモック実装にしています。プロジェクトのフェーズが「動作確認最優先」なので、Twilio や Firebase Auth は後回し。

// AuthServiceImpl
log.info("[SMS Mock] 送信先電話番号: {}", phoneNumber);
log.info("[SMS Mock] 認証コードを発行しました: 123456");

ログインに成功すると、ダミー JWT (dummy-jwt-token-for-userid-{UUID}) が発行されます。


詰まりどころ③ 認証フィルタが無くて全部 401

ここで詰まりました。AuthService はダミー JWT を発行しますが、Spring Security 側に その JWT を解釈する Filter が無い/users/me /payments/** /ekyc/** /rentals/** が全部 401 で弾かれます。

本番では Resource Server に jwt 検証を任せる前提ですが、PoC でその実装まで踏み込むと時間が溶ける。そこで dev profile 限定の認証フィルタ を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> で受け取り、Bean が存在する dev プロファイルでだけ filter chain に差し込み。prod では Bean がないので素通りします。

DevDummyJwtAuthFilter devFilter = devDummyJwtAuthFilterProvider.getIfAvailable();
if (devFilter != null) {
    http.addFilterBefore(devFilter, UsernamePasswordAuthenticationFilter.class);
}

これで CurrentUserProvider が Authentication.getName() から UUID を取り出して以降の業務ロジックに渡せるようになりました。


決済・交通ルールテスト

決済登録は Stripe を直接叩かず、SetupIntentattach をモックで返すだけ。

S06 決済登録

// PaymentServiceImpl
String dummyClientSecret = "seti_mock_" + UUID.randomUUID().toString().substring(0, 8) + "_secret_mock123";
return new SetupIntentResponse(dummyClientSecret, stripeCustomerId);

attach 成功で hasPayment=true に更新するので、以降の RouteGuard 判定が次のステップに進みます。

交通ルールテストは「満点(100点)でのみ合格にする」というガードロジックを残したまま、画面側からスコア 100 を送って通します。


詰まりどころ④ eKYC が PENDING 止まり

eKYC の現実フローは「セッション作成 → 提出 → ベンダー側 webhook で APPROVED 通知」です。本番ならこの非同期性をそのまま使えばいいですが、dev では webhook を送ってくれる相手がいません。

ここも dev profile 限定で「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 では RouteGuardService が「kyc_status == PENDING」でブロックする条件に引っかからないようになります。


乗車・返却

ログイン以降のフラグが全部立つと、destination → 「ここで返却」 → そのまま S09 乗車開始確認に直行します。

S09 乗車開始確認

スライダーを最後まで引くと、API 側で:

  1. Stripe Mock で与信 500 円を確保
  2. MQTT Mock で解錠コマンドをログ出力(実 MQTT には投げない)
  3. レンタルレコードを IN_USE で DB に書く

の3点を実行します。StripePaymentServiceIotDeviceService も最初から完全モック実装なので、サービス層を触らずに dev で動かせるのが救いでした。

S10 乗車中

「ロック切替は段階リリース中のため無効です」と書いてあるのは、S10 の lock-toggle--dart-define=ENABLE_S10_LOCK_TOGGLE=true で段階リリース制御している都合です。

返却ボタンで S11 へ。

S11 返却確認


詰まりどころ⑤ 返却ジオフェンシング 50m

S11 では緯度経度を送ってサーバー側で返却ポートとの距離を Haversine で計算し、> 50m ならエラーで弾く実装が入っています。

double distance = calculateDistance(request.latitude(), request.longitude(),
        rental.getDestinationPort().getLatitude(),
        rental.getDestinationPort().getLongitude());
if (distance > 50.0) {
    throw new IllegalArgumentException("返却可能なエリア内に停車してください");
}

実機の GPS は当然ポートと数キロ離れた家の中を指しているし、返却画面の緯度経度入力欄はデフォルト値固定。毎回手で書き換えるのは現実的ではないので、dev profile では閾値を 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;
}

prod ではガード条件は残ったまま。dev だけ通り抜けます。


完走

返却完了でレシートが出ます。

S12 レシート

total_amount: ¥100 は dev のレートで返却までの分数を 1 円単位に丸めた結果。stripe_receipt_url は Stripe を本物で叩いていないので空のまま。これも本番化の項目として残しておくだけで OK。


設計で残した割り切り

  • dev profile の分岐は「API の Service 実装の中」に閉じ込め、Controller / DTO / Filter は本番と共通
  • app 側は dev / prod の区別を持たない。常に「実 API」と話している感覚でコードを書ける
  • Stripe / eKYC / SMS / MQTT のクライアントは存在しない。本番化のときに @Profile で差し替える前提

これらの判断のおかげで、UI の試行錯誤と API の動作確認を、外部サービスのアカウント整備とは別軸で進められました。


次のステップ

  • 本物の SMS(Twilio や Firebase Auth)への置換
  • Stripe テストモードへの接続(SetupIntent と PaymentIntent を本物に)
  • eKYC ベンダー API への接続と webhook 受け口の実装
  • MQTT ブローカー(EMQX や AWS IoT Core)への接続
  • ride-history / admin / swapper(作業員)アプリ

最小E2E が通っていると、これらの一つ一つを差し替えても他は壊れないので、本番化の作業を 「dev profile の if 分岐を外す」 単位で進められそうです。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。