ケーススタディ

AU-Van

同じ席を二度売らない、LINEで使えるバン予約

自分の大学の学生向けに、バンの座席予約システムを作り直しました。学生はLINEの中で席を押さえ、振込明細をアップロードし、お知らせを受け取れます。スタッフは管理画面で支払いを確認します。だれが席を取るかはデータベースが決め、1つの席を学生たちが同時に取り合うテストで確かめました。

担当
1人で開発(企画、バックエンド、フロントエンド、サーバー)
期間
2026年9月〜10月 · 433コミット
ステータス
デモ公開中。main のチェックが通るたびに自動で更新
種別
個人プロジェクト · 前のバージョンの作り直し
8人の学生が1つの席を取り合い、取れるのは1人だけ
8 → 1
Java・Web・E2E のテスト
430 / 303 / 10
AWSで新しい版に入れ替える間、ヘルスチェックはすべて成功
362 / 362
大事な設計の判断を、理由といっしょに記録
19

01

課題

学生はAU-Vanで、キャンパスのバンの席を予約します。最初のバージョンはNext.jsとMongoDBで作りました。何も問題がなければ動きます。でも、お金と席が関わる部分が弱かったのです。

  • 2人の学生が同じ席を取れてしまう。 同時に押すと起こりました。
  • 振込明細が公開されていた。 明細には銀行の口座番号が載ることがあり、リンクを知っていればだれでも見られました。
  • お知らせが消えることがあった。 予約は保存されても、お知らせだけが失敗し、その記録も残りませんでした。
  • 未払いの予約に期限がない。 席が出発まで「支払い待ち」のまま残ることがありました。キャンセル待ちもありませんでした。

学生はスマホで、キャンパスのWi-Fiで、LINEの中から予約します。通信は切れますし、2回タップすることもあります。そこで、1つのルールを決めて作り直しました。同じものを2人がほしがったら、データベースが勝者をちょうど1人だけ選ぶ。

02

全体の仕組み

AU-Vanは、バックエンド1つとWebサイト1つでできています。バックエンドはSpring BootのAPI(Java 21)です。WebサイトはReactで作りました。LINEの中の学生向け画面と、スタッフ用の管理画面の両方を表示します。開発者は私1人なので、アプリを1つにまとめたほうが作りやすく、テストもしやすいです。

  • ログイン: LINEが「この学生はだれか」をアプリに伝えます。APIはそれをLINEで1回だけ確認し、15分間使えるログイン証を出します。
  • 最後に決めるのはPostgreSQL 17(データベース)です。 大事なルールはすべて、データベースの中にあります。
  • 振込明細は非公開のファイル置き場に保存します。スタッフはAPIを通してだけ見られます。
  • 裏で動く処理は、お知らせの送信、期限切れの予約のキャンセル、キャンセル待ちの繰り上げです。APIの中で動き、データベースを読みます。
StudentLINE app · LIFFStaffadmin portalEC2 t4g.small · Docker ComposeCaddyTLSnginxSPA + /api proxySpring Boot APIauth·booking·outboxPostgres17S3 APIslipsLINE LoginID token verifyLINE MessagingFlex pushSQLprivateverifypush
図 1. 本番のサーバー。CaddyがHTTPSを担当し、nginxがWebサイトを配信してAPIへの通信を渡します。APIはPostgreSQL、非公開のファイル置き場、LINEとやりとりします。

03

1つの席に1行だけ

設計の中心はseat_claimsという1つのテーブルです。データベースは、1つの席につき1行しか認めません。同じ席の2行目が来たら、データベースそのものが断ります。

学生が席を選ぶと、APIは席を5分間押さえる行を追加します。先にだれかが押さえていれば、データベースが断り、学生には「この席は取られています」と表示されます。期限が切れた仮押さえは無視され、次の学生の行に置き換わります。

学生が予約を確定すると、APIは同じ行をそのまま予約に変えます。「仮押さえ」から「予約済み」まで、席はずっと守られています。アプリ側でもチェックしますが、分かりやすいメッセージを出すためだけです。取り合いの勝者は、いつもデータベースが決めます。

Seat mapderived on readSeat hold5 min · UNIQUEPENDING_PAYMENT2 h deadlinePAYMENT_UNDER_REVIEWslip in private S3CONFIRMED24 h + 1 h remindersCANCELLEDexpired · seats freedPAYMENT_REJECTEDnote to studentholdconfirmupload slipapprovedeadlinerejectresubmitexpires, no job needed
図 2. 席の一生。仮押さえは5分たつと自然に無効になります。未払いの予約は、支払い期限を過ぎると1分ごとのチェックでキャンセルされます。

04

電波が弱くても、予約は1回だけ

1行だけのルールで、同じ席に2つの仮押さえができることは防げます。でも予約の確定は、すでにある行を書きかえる処理です。2つの確定が同時に来ると、両方が通って予約が2つできるおそれがありました。そこで、確定には2つの守りを足しました。

  1. 送り直しても安全。 確定のリクエストには、それぞれ専用のキーが付いています。APIは返事を予約といっしょに保存します。同じリクエストがもう一度来ても、同じ返事を返すだけで、新しい予約は作りません。
  2. 1つずつ順番に。 APIは確認する前に、仮押さえの行をロックします。2つ目のリクエストは、1つ目が終わるまで待ちます。

どのエラーにも短いコードが付いているので、Webサイトは正しいメッセージを出せます。

05

支払い:明細は非公開、期限もある

学生は銀行振込で支払い、明細の画像(5 MBまで)をアップロードします。APIはまずファイルを保存し、そのあと予約をPAYMENT_UNDER_REVIEWに変えます。スタッフはAPIを通してだけ画像を見られます。公開リンクは使いません。

作り直しで、新しいルールも加えました。支払い期限です。学生は2時間以内に支払う必要があり、遅くとも出発の1時間前までです。1分ごとに処理が動き、期限を過ぎた予約を見つけます。そしてキャンセルし、席を空けて、お知らせを送ります。学生の席がなくなることもあるルールなので、はっきり知らせるべきだと記録に書きました。

06

消えないお知らせ

AU-Vanでは、予約が変わるたびに「送るべきお知らせ」の行も同時に保存します。両方保存されるか、どちらも保存されないかのどちらかです。この方法は「アウトボックス」と呼ばれます。

5秒ごとに送信係が動き、送る時間になったお知らせを集めます。それを2分間「送信中」にして、ほかの送信係が取らないようにします。そしてLINEに送ります。お知らせにはそれぞれIDがあり、LINEはそれを使って重複を無視します。だから学生に届くのは1回だけです。

  • LINEが「OK」か「もう受け取った」と答えた → SENT
  • LINEが混んでいる、止まっている、返事がない → PENDINGに戻ります。あとで送り直し、毎回少しずつ長く待ちます。
  • 5回失敗した、またははっきり断られた(例:学生がLINEアカウントを友だち追加していない)→ DEAD。スタッフが確認できます。

お知らせは10種類あります。「予約できました」から、出発24時間前と1時間前のリマインダーまでです。

one database transactionUPDATE bookingsINSERT outbox_eventsOutboxDispatcherevery 5 s · 2 min leaseLINE Messaging APIX-Line-Retry-KeySENT2xx / 409PENDING429/5xx · backoffDEAD5 tries / 4xxAdmin dead lettersclaim (UPDATE … WHERE)send, no tx
図 3. 予約の変更とお知らせの行は、いっしょに保存されます。送信はあとで行います。LINEの再送用キーのおかげで、同じお知らせが2回届くことはありません。

07

順番を守るキャンセル待ち

キャンセル待ちに入れるのは、便が満席のときだけです。席が空くいちばん多い理由は、5分の仮押さえが切れることです。そのときは何の合図も出ません。だから「席が空いた」という合図は待ちません。

そのかわり、1分ごとに処理が動き、次の順番の学生に席の仮押さえを渡します。有効なのは30分(または出発の1時間前まで)です。これもただの仮押さえなので、直接予約しようとする学生と取り合いになっても、同じデータベースのルールが決めます。処理は最初の失敗で止まるので、だれも順番を飛ばせません。チャンスは1回です。抜けてまた入ると、列のいちばん後ろになります。

08

テストで確かめる

「ちゃんと動く」と言うのは簡単です。だから、システムを壊そうとするテストにいちばん力を入れました。

  • 430件のJavaテストでAPIを確かめます。ほとんどは速いテスト用データベースで数秒で終わります。8人を同時にスタートさせ、席を取れるのが1人だけかを確かめるテストもあります。
  • 本物のPostgreSQLでのテストで、大事な取り合いをもう一度確かめます。同じ仮押さえを8回確定しても、予約は1件だけです。
  • 303件のWebサイトのテストで、予約と支払い確認の流れを確かめます。
  • 10件のE2EテストはPlaywrightを使い、実際のアプリを画面から操作します。予約、支払い、承認や却下、2人目の学生との席の取り合いまで試します。
  • わざと壊すチェック: 守りのチェックを1つ消し、テストが失敗するのを見てから、元に戻します。

システム全体を動かして、初めて見つかったバグが2つあります。1つ目は、nginxが1 MBを超えるファイルを断っていたことです。そのため、本物の振込明細が送れませんでした。2つ目は、データベースを作るスクリプト(Flyway)が一度も動いていなかったことです。テストでは別のツールがテーブルを作っていたので、通っていました。でも本物のPostgreSQLには、テーブルがまったくありませんでした。直したうえで、それを確かめるテストも足しました。

09

サーバー:本番に近いAWS

AWSの本格的な構成も、Terraformで書きました。サーバーの構成をコードで書けるツールです。

  • いちばん前にCloudFront(CDN)を置きました。APIへの通信はキャッシュしないので、ある学生の予約が別の学生に見えることはありません。
  • アプリはECS Fargateの上で、2つのデータセンターに分かれて動きます。更新は1台ずつ行います。
  • データはRDS PostgreSQL 17に入れます。明細は、非公開で暗号化された保存場所に置きます。
  • NAT gatewayは使いません。 費用をおさえるためです。

一度、アプリ2台と予備つきのデータベースで起動しました。新しい版に入れ替える間、362回のヘルスチェックがすべて成功しました。 そのあと、同じ日に削除しました。

いつも動いているデモは、小さなEC2サーバー1台とDocker Composeで動いています。mainがすべてのチェックを通るたびに、新しい版が自動で公開されます。毎晩バックアップを取り、1週間分を残します。

StudentsCloudFront4 behavioursS3 webOAC · SPA fallbackVPC 10.20.0.0/16 · no NATALBCF-only SGAZ 1aAZ 1bFargate taskSpring BootFargate taskSpring BootRDS PostgresprimaryRDS standbyMulti-AZregionalS3 slipsprivate · SSEECRimmutableSSMsecretsCloudWatch3 alarms/assets, //api/*sync
図 4. Terraformで書いたAWSの構成。2つのデータセンターでアプリ2台と予備つきのデータベースを一度起動し、そのあと削除しました。

10

プルリクエストごとの8つのチェック

プルリクエストのたびに、8つの自動チェックが動きます。

  1. Webサイト: コードの書き方、テスト、ビルド。
  2. コミットメッセージ: 決まった書き方を守っているか。
  3. API: すべてのテスト。
  4. データベースの取り合い: 本物のPostgreSQLでのテスト。
  5. インフラ: Terraformのチェック。AWSへのログインは不要です。
  6. デプロイ用スクリプト: ShellCheckでチェック。
  7. コンテナ: 2つのイメージがビルドでき、管理者(root)として動かず、正常に起動すること。
  8. E2E: Playwrightのテスト。

このうち3つ(Webサイト、コミットメッセージ、API)は、通らないとマージできません。さらに、パスワードやキーがもれていないか、シークレットスキャナーがコードを見張っています。

11

割り切ったことと、次にやること

  • サーバーが1台だけ。 そのサーバーが止まるとデモも止まり、バックアップも同じディスクにあります。本当に利用者が増えたら、Terraformの構成が使えます。
  • 速いテスト用データベースでしか確かめていない取り合いがある。 キャンセル待ち、支払い確認、期限切れも、本物のPostgreSQLで確かめるべきです。
  • 定期的な処理には待ち時間がある。 繰り上げやお知らせは、次のチェックが動くまで待ちます。
  • 次にやりたいこと: 学生用のキャンセルボタン、送れなかったお知らせを管理画面から送り直す機能、便が中止になったときに予約もまとめてキャンセルする機能。

ここに書いた判断は、見直したいものも含めて、すべて19件の設計記録(ADR)に残しています。

技術スタック

  • Java 21
  • Spring Boot
  • React
  • TypeScript
  • PostgreSQL
  • Flyway
  • LINE LIFF
  • LINE Messaging API
  • Docker
  • Terraform
  • AWS
  • GitHub Actions
  • Playwright
  • Testcontainers

最終更新:

次のケーススタディVetMiMiシドニーのアートセラピストのために、予約と非公開のビデオ通話、かんたんな投稿ができるサイトを作りました。ケーススタディを読む

もっと知りたい方へ

コードのこと、なぜそう決めたか、次にやりたいことまで、よろこんでお話しします。気軽にメッセージをください。

お問い合わせ