ゲーミフィケーション統合仕様(モバイル側責務) Gamification Integration (Mobile Responsibilities)

2026-04-22 のゲーミフィケーション脅威対応(T1〜T9)で、DB / BFF 側は防御の主要層を 実装済み。本書はモバイル(Flutter)が担う送信責務を規定する。 モバイルが正しい情報を送らないと、正規ユーザーの EXP 獲得がブロックされるため、 リリース前にここの実装と QA を通す必要がある。

2026-04-23 SDUI L3 化: 2026-04-23 SDUI L3 cutover: モバイルから叩く endpoint は 全て mobile BFF(Views と Actions) に統一されました (ADR-0004 SDUI L3)。本書中の旧 Resource 風 path(/v1/parking-sessions 等)は 使用禁止 で、Flutter は POST /v1/mobile/actions/sessions/start / POST /v1/mobile/actions/sessions/{id}/finalize を使うこと。 詳細は 画面 × API マップ The mobile client now talks only to the mobile BFF (Views & Actions) (ADR-0004 SDUI L3). The bare resource paths sprinkled below (/v1/parking-sessions etc.) are not callable; use POST /v1/mobile/actions/sessions/start and POST /v1/mobile/actions/sessions/{id}/finalize instead. See the screen × API map.

The T1–T9 gamification hardening of 2026-04-22 placed the main defensive layer on the database and BFF. This document describes the client-side responsibilities that the Flutter app must fulfill so that legitimate users can earn EXP correctly.

大原則:
  • EXP 付与・倍率・キャップ判定は全てサーバー確定。クライアントは「事実」を正確に送るだけ。
  • GPS / 写真ハッシュ / device_fingerprint のいずれかが欠けた要求は、対応する活動で EXP が付かない。
  • 失敗しても主機能(駐車・レビュー投稿)はブロックしない。EXP だけ加算されない形にする。
Guiding principles:
  • All EXP / multipliers / caps are confirmed server-side. The client simply reports facts.
  • Requests missing GPS, photo hash, or device_fingerprint won't earn EXP for that activity.
  • A failure here must never block the main flow (parking / reviews). EXP simply isn't granted.

1. GPS 位置情報(T1 対策) 1. GPS location (T1 defense)

駐車開始 / 終了時に端末の現在位置を取得し、API に start_lat/start_lng または end_lat/end_lng として送る。サーバー側は parking_lots.location との距離 300m 以内なら geo_verified=true と判定し、parking_start / parking_end の EXP を付与する。位置が取れないと EXP 0 になる。

推奨パッケージ Recommended package

取得精度とタイムアウト Accuracy & timeout

項目理由
LocationAccuracyhigh(Android PRIORITY_HIGH_ACCURACY / iOS kCLLocationAccuracyBestサーバー検証閾値 300m に収めるため、Wi-Fi / セル測位では不足する場合あり
タイムアウト7 秒UX とのバランス。7 秒で取れなければ座標無しで送信し、EXP を諦める
キャッシュ許容30 秒以内のキャッシュを許容駐車場到着〜駐車開始で再取得コストを避ける

権限フロー Permission flow

  1. 初回は駐車開始ボタン押下時に Geolocator.requestPermission()
  2. 拒否された場合は「位置情報を使えないと EXP が付きません。設定から許可できます」という控えめなトーストを表示。駐車自体は続行。
  3. iOS「許可しない」(LocationPermission.deniedForever)なら OS 設定へのディープリンクを案内。
  4. VPN / モックプロバイダ疑惑(Position.isMocked=true)は記録だけしてサーバーに送信。判定はサーバー側。

API 連携 API integration

// 駐車開始 (Idempotency-Key ヘッダ必須)
POST /v1/mobile/actions/sessions/start
Idempotency-Key: <uuid-v4>
{
  "parking_lot_id": "...",
  "vehicle_type": "sedan",
  "start_lat": 35.681236,        // ← 端末から取得
  "start_lng": 139.767125,       // ← 端末から取得
  "planned_end_at": null         // 任意
}

// 駐車終了 (Idempotency-Key ヘッダ必須)
POST /v1/mobile/actions/sessions/{id}/finalize
Idempotency-Key: <uuid-v4>
{
  "ended_at":   "2026-04-22T15:30:00+09:00",  // 任意
  "started_at": null,                         // 入庫時刻訂正(任意)
  "user_entered_fee_minor": null,             // 手入力料金(任意, minor unit)
  "end_lat": 35.681240,                       // 任意
  "end_lng": 139.767130
}

失敗時の UX Failure UX

2. 写真コンテンツハッシュ(T5 対策) 2. Photo content hash (T5 defense)

駐車セッションの写真アップロード時、同一ユーザー内での写真使い回しを検知するため、 クライアント側で画像バイナリの SHA-256 (hex 64 文字) を計算して送る。 サーバー側で同一ユーザーの過去写真と重複したら duplicate_of 列に記録され、 写真つきレビュー EXP ボーナス等の対象外になる(投稿自体は可)。

推奨パッケージ Recommended package

計算タイミング When to compute

  1. 画像ピック or 撮影完了直後、圧縮・リサイズを行うの原本バイナリで計算する。
  2. 計算した hash を state に保持。R2 へのアップロード完了後、PUT API にセットで送る。
  3. 圧縮後のバイナリでハッシュを取ると同一写真でもハッシュがブレるので、必ず原本で計算すること。

API 連携 API integration

// 写真アップロードはレビュー作成 API に同梱する形に統一済み(2026-04-23 SDUI L3)。
// レビュー本文 + R2 へ PUT 済みの photo メタ情報を一括で送る。
POST /v1/mobile/actions/reviews/create
Idempotency-Key: <uuid-v4>
{
  "lot_id": "...",
  "rating": 5,
  "body": "...",
  "photos": [
    {
      "r2_key": "reviews/.../photo.jpg",
      "content_type": "image/jpeg",
      "size_bytes": 1048576,
      "content_hash": "3f4d8b2a..."   // ← SHA-256 hex 64 文字
    }
  ]
}

// 駐車セッション写真も同パターン(review-compose と feedback フローで PUT 済み URL を返した上で
// content_hash を BFF へ渡す)。サーバー側で同一ユーザーの過去写真と一致したら
// duplicate_of に既存写真 id が記録され、写真ボーナス対象外になる(投稿自体は成功)。

Dart 実装例 Dart snippet

import 'package:crypto/crypto.dart';
import 'dart:convert';

Future<String> sha256OfFile(File file) async {
  final bytes = await file.readAsBytes();
  return sha256.convert(bytes).toString(); // hex 64 文字
}

is_duplicate=true のときの UX UX when is_duplicate=true

3. Device Fingerprint(T4 複垢紹介対策) 3. Device fingerprint (T4 multi-account referral defense)

紹介コード適用時、被紹介者のデバイス識別子を送る。招待元のデバイスと 一致する場合、or 他ユーザーで既に登録されている場合は duplicate_device エラーで拒否される。正規ユーザーでも 家族で端末共有する場合は紹介できないが、この運用リスクは T4 の監査報告で 受容済み。

取得する識別子 Identifiers to collect

プラットフォーム識別子取得方法永続性
AndroidSSAID(Settings.Secure.ANDROID_IDdevice_info_plusAndroidDeviceInfo.idファクトリリセットまで不変
iOSIDFV(identifierForVendordevice_info_plusIosDeviceInfo.identifierForVendor同一ベンダーの全アプリ削除でリセット

送信形式 Payload format

サーバー側は単一文字列として扱う。プラットフォームに応じて "android:<ssaid>" / "ios:<idfv>" のようにプレフィックスを付けると、監査 UI で プラットフォーム別に可視化しやすい。

API 連携 API integration

// 紹介コードはサインアップ時に POST /v1/mobile/actions/auth/sign-up へ同梱、
// もしくはサインアップ後に Profile / Referrals フローから適用する設計(2026-04-30 時点)。
// device_fingerprint は紹介適用 1 リクエストにだけ同梱し、ローカルには保存しない。

POST /v1/mobile/actions/auth/sign-up
Idempotency-Key: <uuid-v4>
{
  "email": "...",
  "password": "...",
  "referral_code": "A3B7CKDE",
  "device_fingerprint": "android:f8e3...4c2d"
}

// ActionEnvelope 失敗例(duplicate_device は ActionEnvelope.states.error の code として返る)
{
  "data": { "signup_pending": true },
  "states": {
    "error": [{ "code": "duplicate_device", "message_code": "referral.error.duplicate_device", ... }]
  }
}

推奨パッケージ Recommended package

取得失敗時 When it can't be obtained

4. レスポンス受信 — celebration オブジェクト 4. Response handling — the celebration object

POST /v1/mobile/actions/sessions/{id}/finalizeActionEnvelope には data.celebration が含まれる。Flutter は enabled=false なら静かに次画面へ遷移、 enabled=true なら badge → level_up → exp gauge の順に演出を再生する。 Schema 本体は api/src/schema/view/session-celebration-view.ts

// ActionEnvelope (envelope 全体)
{
  "data": {
    "session_id": "...",
    "celebration": {
      "enabled": true,                  // false なら演出スキップ
      "reward": {
        "exp_granted": 30,
        "exp_base": 20,
        "multiplier": 1.5,
        "total_exp": 1250,
        "level_before": 8,
        "level_after": 9,
        "level_up": true,
        "badges_earned": [ ... ],
        "daily_cap_hit": false,
        "geo_verified": true,
        "reason": null                  // "geo_not_verified" / "daily_cap_reached" / "cooldown" / null
      },
      "referral_confirmation": {
        "confirmed": true,
        "usage_id": "...",
        "referrer_reward": { ... },
        "referee_reward":  { ... }
      }
    }
  },
  "navigation": { "target": "parking_history", "strategy": "pop_to_root" },
  "fallback_behavior": { ... },
  "meta": { ... }
}

UI マッピング UI mapping

フロー図(駐車終了 → celebration → feedback) Flow (finalize → celebration → feedback)

sequenceDiagram participant U as User participant App as Flutter participant BFF as Workers BFF (sessions actions) participant Core as core/parking-sessions participant DB as Postgres (RPC) U->>App: tap "駐車終了" App->>BFF: POST /v1/mobile/actions/sessions/{id}/finalize
(Idempotency-Key, end_lat, end_lng) BFF->>Core: finalizeParkingSession() Core->>DB: finalize_parking_session RPC
(award_user_activity_v2 内部で
geo_verified / daily_cap / multiplier 判定) DB-->>Core: { session, reward } Core-->>BFF: { sessionId, celebration } BFF-->>App: ActionEnvelope { data.celebration, navigation } alt celebration.enabled = true App->>U: badge → level_up → exp gauge U->>App: 閉じる end App->>BFF: POST /:id/feedback (good / bad / skip) BFF-->>App: ActionEnvelope { navigation: next_flow } App->>U: navigation 通り遷移(history / write-review 等)

5. サーバー側拒否の活動タイプ別対応 5. Reasons your request may earn 0 EXP

activity_typerequires_geodaily_capper_target_daily_capcooldown
parking_start / parking_end102
night_parking / ev_charge52
unique_parking / sponsor_checkin3 / 101
review_post51
rating101
share3300 s
search / ai_search20 / 1010–30 s
login / app_open1(1 日 1 回)
referral_applied / referral_confirmed1 / —

上記は 2026-04-22 時点の activity_exp_rules seed。値は BFF 側で変更される可能性があるため、モバイルで表示する場合は GET /v1/mobile/views/profile/gamificationrecent_exp_events + earned_badges + pending_badges) を参照する。activity 別の cap / exp_amount は非公開(クライアントは reason ラベルで結果を見る)。

6. 実装チェックリスト(リリース前) 6. Pre-release checklist

7. 関連仕様書・実装 7. Related specs & implementation