# GOGO Share ポート共有MQTT — AI実装用仕様(自己完結版)

このファイルは、AIコーディングアシスタントに渡して「在庫publishクライアント」を実装させるための自己完結ドキュメントです。外部参照なしで実装に必要な情報をすべて含みます。人間向け: https://gogo-cycle-mqtt-stg.fly.dev/guide.html / 詳細仕様: https://gogo-cycle-mqtt-stg.fly.dev/spec.html

## 実装タスクの定義

シェアサイクル事業者システムに、以下の機能を実装する:

> 自社ポート(ステーション)の在庫(貸出可能台数・返却可能ドック数)が変化したとき、
> その状態を1件のJSONイベントとしてMQTTブローカーにpublishする。

- これは国交省COMmmmONS「シェアサイクルポート共有API標準仕様書_MQTT」v1.1.0(AsyncAPI 3.0.0)準拠のインターフェース。
- 目的: 事業者間で在庫変動を数秒以内に共有し、ポートの車両溢れ・在庫不足を防ぐ(イベント駆動)。
- スコープ: ポート単位の集計値のみ。1台毎の車両状態(車両ID・バッテリー等)は本IFでは扱わない。
- 受信(subscribe)の実装は任意。必要な場合の要件は末尾「受信側要件」参照。

## 接続情報

| 項目 | 値 |
|---|---|
| STGホスト | gogo-cycle-mqtt-stg.fly.dev |
| 本番ホスト | gogo-cycle-mqtt.fly.dev(実装は接続先の切替のみで対応できること) |
| ポート | 8883 |
| プロトコル | MQTT 3.1.1 または 5.0 over TLS(mqtts)。TLS1.2+、SNI必須、証明書はパブリックCA(ピンニング不要) |
| 認証 | ユーザー名+パスワード(MQTT CONNECT)。値は環境変数から読むこと。コードにハードコードしない |
| 推奨環境変数名 | MQTT_URL / MQTT_USERNAME / MQTT_PASSWORD / PARTNER_ID |
| partner_id | emobi(eMoBiの場合。他事業者: lovedrive / jtech / gogoshare) |

## MUST(必須要件)

1. トピックは正確に `jp/events/{PARTNER_ID}/stations/{station_id}/status` とする。
   - ACLにより自社プレフィックス `jp/events/{PARTNER_ID}/#` 以外へのpublishはブローカーに拒否される。
2. publishは QoS 1、retain true で行う。
3. ペイロードは下記JSON Schemaに適合するUTF-8のJSONとする(Schema: https://gogo-cycle-mqtt-stg.fly.dev/event.schema.json )。
4. event_id は `{自社partner_id}-{UUID v8}` 形式で publish毎に一意とする(例 `emobi-017f22e2-79b0-8cc3-98c4-dc0c0c07398f`)。接頭辞は source_partner_id と一致。UUID v8(RFC 9562)の内部レイアウトは自社の自由(タイムスタンプ等を埋め込み可、時刻順レイアウト推奨)。受信側はこれで重複排除する。
5. timestamp はUTCのRFC3339(例 2026-08-18T10:30:00Z)。JSTのまま送らない。受信側はこれで新旧判定する。
6. トピック中の station_id / PARTNER_ID と、JSON内の payload.station_id / source_partner_id を一致させる。
7. 在庫の状態変化時に即時(数秒以内)にpublishする。変化がないときの定期再送はしない。
8. クライアントIDは `{PARTNER_ID}-{stg|prd}-{プロセス毎に一意な値}`。同一クライアントIDの同時接続は後勝ちで切断されるため、固定値にしない。
9. 切断時は自動再接続する(5秒間隔推奨)。切断中に発生した状態変化は再接続後に最新値をpublishする(最新値が正。全履歴の再送は不要)。
10. メッセージは64KB以内(通常1KB未満なので、Schemaに従えば自然に満たす)。

## ペイロード仕様

正しい例:

```json
{
  "event_id": "emobi-017f22e2-79b0-8cc3-98c4-dc0c0c07398f",
  "event_type": "station.status_updated",
  "source_partner_id": "emobi",
  "timestamp": "2026-08-18T10:30:00Z",
  "payload": {
    "station_id": "1234",
    "num_vehicles_available": 5,
    "num_docks_available": 10,
    "reason_code": "VEHICLE_RETURNED"
  }
}
```

フィールド定義:

- event_id (string, 必須): 通知の一意ID。重複排除用。形式は `{自社partner_id}-{UUID v8}`(MUST 4参照)。
- event_type (string, 必須): 初版で送るのは "station.status_updated" のみ。
  - station.status_updated: ポートの台数・返却可能数が変わった(必須対応)
  - ride.started / ride.updated / ride.terminated: ライド開始/状態変化/終了(任意。実装不要)
- source_partner_id (string, 必須): 自社のpartner_id。
- timestamp (string, 必須): イベント発生時刻。UTC RFC3339。
- payload.station_id (string, 必須): 自社システムのポートID。
- payload.num_vehicles_available (integer, 必須, 0以上): 貸出可能台数。
- payload.num_docks_available (integer, 必須, 0以上): 返却可能ドック数。
- payload.reason_code (string, 任意): 変動理由。
  - VEHICLE_RETURNED: ユーザーによる車両返却
  - REBALANCE_DROP_OFF: 再配置による入庫
  - REBALANCE_PICK_UP: 再配置による出庫
  - MAINTENANCE_OUT: 故障対応等による回収
  - VEHICLE_RENTED: ユーザーによる車両貸出(本事業の拡張値。標準原本には無い。仕様書 v1.5 で追加。貸し出した事業者が付ける)
  - 任意項目なので省略してよい。省略しても台数の同期には影響しない。
  - 受信側は未知の reason_code を無視し、台数の値だけで状態を更新すること。

## 発火箇所(自社システム側のイベントとの対応)

- 貸出成立 → num_vehicles_available -1 / num_docks_available +1、reason_code=VEHICLE_RENTED
- 返却成立 → +1 / -1、reason_code=VEHICLE_RETURNED
- 再配置の入庫/出庫 → REBALANCE_DROP_OFF / REBALANCE_PICK_UP
- 故障回収 → MAINTENANCE_OUT
- サービス起動時・再接続時 → 全ポートの現在値を1回ずつpublish(retainされるため受信側の初期同期になる)

## 受け入れ基準(Definition of Done)

1. STGに接続し、テストポート1件をpublishできる(認証エラー・ACL拒否がない)。
2. https://gogo-cycle-mqtt-stg.fly.dev/ (資格情報でログイン)に該当ポートが表示され、値の変化が数秒で反映される。
3. 同一station_idに2回publishした後に新規subscribeすると、最後の値だけがretainedで届く。
4. プロセスを強制終了→再起動しても、自動的に再接続してpublishが継続する。
5. 生成するJSONが https://gogo-cycle-mqtt-stg.fly.dev/event.schema.json のSchema検証を通る。

検証用コマンド(mosquitto-clients):

```
mosquitto_pub -h gogo-cycle-mqtt-stg.fly.dev -p 8883 --capath /etc/ssl/certs \
  -u emobi -P $MQTT_PASSWORD -i emobi-stg-test -q 1 -r \
  -t 'jp/events/emobi/stations/test-001/status' \
  -m '{"event_id":"emobi-017f22e2-79b0-8cc3-98c4-dc0c0c073001","event_type":"station.status_updated","source_partner_id":"emobi","timestamp":"2026-08-18T10:30:00Z","payload":{"station_id":"test-001","num_vehicles_available":5,"num_docks_available":10}}'

mosquitto_sub -h gogo-cycle-mqtt-stg.fly.dev -p 8883 --capath /etc/ssl/certs \
  -u emobi -P $MQTT_PASSWORD -i emobi-stg-sub -t 'jp/events/#' -v
```

テストデータの削除: 同一トピックに空ペイロードを retain付きでpublishするとretainedメッセージが消える。

## 受信側要件(任意。他社在庫を利用する場合のみ)

- subscribe: `jp/events/#`(全体)または `jp/events/{partner_id}/#`。QoS 1。clean session(永続セッション不使用)。keepalive 60秒。
- 接続直後にretainedメッセージとして各ポートの最新状態が届く。これが初期同期。retained/ライブは同一ロジックで処理してよい。
- event_id で重複排除すること(QoS1は再配送があり得る)。
- ステーション毎に最後に適用した timestamp を保持し、それより古いイベントは破棄すること。
- 未知の event_type・未知のフィールドは無視すること(前方互換)。不正JSONは破棄してログし、処理を継続すること。

## 参考実装

Node.js(mqtt npm パッケージ)によるサンプル: リポジトリ subsidy-cycle の mqtt/examples/node-publisher/
(publish.js=単発送信 / simulate.js=連続送信 / subscribe.js=受信。環境変数は上記の推奨名と同一)
