電動キックボードシェアリングを最小E2Eまで動かすまで — Flutter + Spring Boot を dev profile で割り切る
この記事でわかること
- Flutter(モバイル)と Spring Boot(API)で電動キックボードシェアリングを最小E2Eまで動かすために、UI と API のどこで何を割り切ったか
- ポート選択時に「機体を選ぶ」UX をどう作ったか
- ログイン以降のフロー(決済 → 交通ルールテスト → eKYC → 乗車 → 返却 → レシート)を dev profile のモック実装でどう貫通させたか
対象読者
- Flutter + Spring Boot でモバイルアプリ + バックエンドを並行して育てている方
- 認証 / 決済 / eKYC を本物につなぐ前に「画面だけは一通り動かしたい」と考えている方
- dev / prod のコード分岐をどこにどう仕込むかを悩んでいる方
動作環境
| 項目 | バージョン |
|---|---|
| Flutter | 3.x(Material 3) |
| Java | 21 |
| Spring Boot | 3.4.5 |
| PostgreSQL | 17.2 |
| 地図表示 | flutter_map(OpenStreetMap タイル) |
| 位置情報 | geolocator |
| HTTP | dio |
| 状態管理 | 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 ホーム画面が出ます。

ポートのピンは緑(利用可能な機体がある)とグレー(利用可能な機体ゼロ)に色分け。当初は「ピンに利用可能数のバッジを出すか」を迷いましたが、地図上に数字が並ぶと視覚的にうるさく、結局アイコンの色だけで状態を伝える設計に落ち着きました。
詰まりどころ① ポートをタップしたあと、何を見せるか
タップ前は集計値(利用可能/返却枠)しかないので、機体を選びたいユーザーが詰まります。そこで 「このポートのキックボード」セクションを子画面に出す API + UI を足しました。
- API:
GET /api/v1/ports/{portId}/scootersを新設し、機体ID・状態・バッテリー残量を返す - アプリ: ポートピンをタップしたら
DraggableScrollableSheetで機体一覧をスクロール可能なシートとして表示

AVAILABLE の機体だけ行をタップ可能にし、メンテナンス中はグレーで非活性にして「乗れないものは押せない」状態にしています。
機体をタップすると /destination?scooterId=... に遷移します。
詰まりどころ② 返却ポート選択 UI の統一
destination 画面(S04)は当初、地図でポートを選ばせてから画面下の 「乗車手続きへ」ボタンで遷移 する流れでした。ただ操作中の視線が画面下端に飛んでしまうのと、選択中ポートのフィードバックが弱いのが気になり、設計を変えました。
- 返却枠の有無に関係なく 同じ件数のポートを表示(ホーム画面と件数を揃える)
- 返却枠 0 のポートは ピンをグレー で描画
- ポートタップ時に ホーム画面と同じサイズ・同じ「利用可能/返却枠」カード を出す
- カードの下に 「ここで返却」ボタン を置き、押下で次のフローに進む(返却枠 0 のときは非活性)
- 画面下端の「乗車手続きへ」ボタンは廃止

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

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

「機体選択時にホーム画面の上部カードが自動フェードアウトしていたのと 同じ挙動 を destination 画面の上部カードにも適用してほしい」というレビュー指摘も入ったので、Timer で 5 秒後に AnimatedOpacity で透明化し、右上の info_outline ボタンで再表示できるようにしてあります(両画面で同じ仕組みを使い回し)。
ログイン以降を dev profile で貫通させる
「ここで返却」を押すと、未ログインなら 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 を直接叩かず、SetupIntent と attach をモックで返すだけ。

// 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 乗車開始確認に直行します。

スライダーを最後まで引くと、API 側で:
- Stripe Mock で与信 500 円を確保
- MQTT Mock で解錠コマンドをログ出力(実 MQTT には投げない)
- レンタルレコードを
IN_USEで DB に書く
の3点を実行します。StripePaymentService も IotDeviceService も最初から完全モック実装なので、サービス層を触らずに dev で動かせるのが救いでした。

「ロック切替は段階リリース中のため無効です」と書いてあるのは、S10 の lock-toggle を --dart-define=ENABLE_S10_LOCK_TOGGLE=true で段階リリース制御している都合です。
返却ボタンで 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 だけ通り抜けます。
完走
返却完了でレシートが出ます。

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 分岐を外す」 単位で進められそうです。