ケーススタディ
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の中で動き、データベースを読みます。
03
1つの席に1行だけ
設計の中心はseat_claimsという1つのテーブルです。データベースは、1つの席につき1行しか認めません。同じ席の2行目が来たら、データベースそのものが断ります。
学生が席を選ぶと、APIは席を5分間押さえる行を追加します。先にだれかが押さえていれば、データベースが断り、学生には「この席は取られています」と表示されます。期限が切れた仮押さえは無視され、次の学生の行に置き換わります。
学生が予約を確定すると、APIは同じ行をそのまま予約に変えます。「仮押さえ」から「予約済み」まで、席はずっと守られています。アプリ側でもチェックしますが、分かりやすいメッセージを出すためだけです。取り合いの勝者は、いつもデータベースが決めます。
04
電波が弱くても、予約は1回だけ
1行だけのルールで、同じ席に2つの仮押さえができることは防げます。でも予約の確定は、すでにある行を書きかえる処理です。2つの確定が同時に来ると、両方が通って予約が2つできるおそれがありました。そこで、確定には2つの守りを足しました。
- 送り直しても安全。 確定のリクエストには、それぞれ専用のキーが付いています。APIは返事を予約といっしょに保存します。同じリクエストがもう一度来ても、同じ返事を返すだけで、新しい予約は作りません。
- 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時間前のリマインダーまでです。
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週間分を残します。
10
プルリクエストごとの8つのチェック
プルリクエストのたびに、8つの自動チェックが動きます。
- Webサイト: コードの書き方、テスト、ビルド。
- コミットメッセージ: 決まった書き方を守っているか。
- API: すべてのテスト。
- データベースの取り合い: 本物のPostgreSQLでのテスト。
- インフラ: Terraformのチェック。AWSへのログインは不要です。
- デプロイ用スクリプト: ShellCheckでチェック。
- コンテナ: 2つのイメージがビルドでき、管理者(root)として動かず、正常に起動すること。
- 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
最終更新: