← 制作実績 / ウェブアプリ / ケーススタディ
Prompt Swim
使った分だけ払う AI 画像ギャラリー——OpenAI による画像生成、Stripe Checkout、そして二重課金しないポイント台帳。
- 年
- 2024
- 役割
- 個人開発——プロダクト、デザイン、エンジニアリング、運用
- プラットフォーム
- Web(Next.js on Vercel)
- 技術
- Next.js, TypeScript, OpenAI Images API, Stripe Checkout, MongoDB, AWS S3, Auth.js (Google OAuth), Vercel
Prompt Swim は、AI が生成した画像を公開するギャラリーです。すべての画像に、それを生み出したプロンプトが添えられています。ユーザーは Google でサインインし、Stripe でポイントパックを購入して、そのポイントで OpenAI の DALL·E モデルを使って画像を生成します。生成結果はプロフィールと共有フィードに保存されるので、ギャラリーはそのまま、他の人から学べるプロンプトのライブラリにもなります。
見た目は小さなプロダクトですが、その下には多くの AI・SaaS プロダクトに共通する要素がそろっています。有料の外部 API、Webhook で結果を返してくる決済サービス、ユーザーアカウント、ファイルストレージ、そしてどこかが途中で失敗しても正しさを保たなければならない台帳です。このケーススタディでは、これらがどう組み合わさっているかを、同様の連携を計画しているチームに向けて紹介します。
ユーザー体験
- 閲覧。 トップページは全ユーザーの作品が新しい順に並ぶ無限スクロールのフィードです。画像を開くと、元のプロンプト、使用モデル、作者、シェア/保存ボタンが表示されます。
- サインイン。 Google OAuth のみ。ワンクリックで完了し、パスワードを保管する必要もありません。初回サインイン時に、一意のハンドル名を持つ公開プロフィールとポイントアカウントが作成されます。
- 生成。 モデル(DALL·E 3 または DALL·E 2)を選び、プロンプトを書き、必要なら匿名で投稿できます。画像は数秒で表示され、残高もすぐに更新されます。
- チャージ。 ポイントがなくなったら、料金ページで 4 種類のポイントパックを Stripe Checkout で購入できます。サブスクリプションはありません。
サブスクリプションではなくポイントにした理由
AI 画像生成には 1 リクエストごとに実費がかかり、利用量の波も激しいものです。3 枚だけ作って去る人もいれば、一晩で 300 枚作る人もいます。定額制では、前者から取りすぎるか、後者で赤字になるかのどちらかです。
前払いポイントなら両方を避けられます:
- 価格がコストに連動する。 DALL·E 3 は DALL·E 2 より上流のコストが高いため、30 ポイントではなく 60 ポイントにしています。モデルごとの価格は設定値なので、OpenAI が値上げしても環境変数を変えるだけで済み、再デプロイは不要です。
- 未払いの利用が発生しない。 API を呼ぶ前に支払いが済んでいるので、請求書の発行も、更新に失敗したカードの督促もありません。
- 大きいパックほどお得。 $2 パックは 1 ドルあたり 600 ポイント、$30 パックは 1 ドルあたり 1,000 ポイントです。
アーキテクチャ
Browser (Next.js App Router + React Query)
│
├─► /api/generate-image ── auth ─► OpenAI Images API
│ └─► copy result to S3
│ └─► Mongo transaction:
│ insert image + deduct points
│
├─► /api/create-checkout-session ─► Stripe Checkout
│ └─► order saved as "created"
│
└─► /api/images?page=n ─► paginated public feed
Stripe ─► /api/stripe-webhook ── verify signature
└─► Mongo transaction:
order → "completed" + credit points
- Next.js on Vercel が UI とすべての API ルートを 1 つのコードベースで提供します。DALL·E 3 の呼び出しは時間がかかることがあるため、画像生成ルートには長めの関数タイムアウトを設定しています。
- Auth.js + Google がサインインを担当します。セッションは JWT で保持し、ユーザーデータは公式アダプター経由で MongoDB に保存します。
createUserイベントでプロフィールとポイントアカウントを同時に作成するため、アカウントを持たないユーザーは存在しません。 - MongoDB にはプロフィール、画像、ポイントアカウント、注文を保存します。お金が絡む操作はすべてマルチドキュメントトランザクションの中で実行します。
- AWS S3 で画像を恒久的に保存します。OpenAI が返すのは一時的な URL だけなので、生成結果はすべてダウンロードし、ユーザーとモデルをメタデータとして付けて、独自の ID で再アップロードします。
OpenAI 連携:有料で不安定な外部依存として扱う
OpenAI の呼び出し自体は 1 行です。手間がかかるのは、その周りのコードです:
- お金を使う前に検証する。 未認証のリクエスト、空のプロンプト、未知のモデルは、上流に送る前に弾きます。モデル一覧のチェックはドロップダウンだけでなくサーバー側でも行います。
- 型付きのエラー。 OpenAI 側の失敗(コンテンツポリシー違反など)は
ImageGenerationErrorとして、わかりやすいメッセージ付きの 400 を返します。自分のコードのバグは 500 です。ユーザーに見えるのはスタックトレースではなく「プロンプトが拒否されました」です。 - モデルが実際に何をしたかを残す。 DALL·E 3 は生成前にプロンプトを書き換えます。書き換え後の
revised_promptをユーザーの元の入力と並べて保存しているので、プロンプトを学びたいユーザーにも、おかしな結果のデバッグにも役立ちます。 - アセットは自分で持つ。 何かを記録する前に画像を S3 にコピーしているので、1 時間後にリンク切れになる画像がギャラリーに並ぶことはありません。
Stripe 連携:Webhook を唯一の正とする
決済まわりで最も多いバグは、ユーザーが「支払い完了」ページにたどり着いたことを根拠にポイントを付与してしまうことです。ユーザーはタブを閉じますし、後から決済が確定する支払い方法もあり、成功 URL は直接開くこともできます。Prompt Swim では、ブラウザ側を根拠にポイントを付与することは一切ありません。
1. 価格はサーバーが決める。 クライアントが送るのは「どのパックを買うか」だけで、価格はサーバーが自前の価格表から計算します。両者が食い違えばリクエストを拒否するので、DevTools でリクエストを書き換えても $2 で 30,000 ポイントは買えません。
2. すべてのチェックアウトを注文として記録する。 Checkout Session の作成時に、Stripe のセッション ID をキーとして、ステータス created、ユーザー、ポイント数、セント単位の価格を持つ注文を保存します。
3. 検証済みの Webhook だけが注文を進められる。 Webhook ルートは生のリクエストボディで Stripe の署名を検証し、必要なイベントを処理します:
checkout.session.completed(payment_status: paid)→ 履行:注文 →completed、ポイント付与checkout.session.async_payment_succeeded→ 履行(後から決済が確定する支払い方法向け)checkout.session.async_payment_failed→ 注文 →failedcheckout.session.expired→ 注文 →expired
4. 履行は冪等。 Stripe は Webhook を再送し、同じイベントが 2 回届くこともあります。注文ステータスは小さなステートマシンになっています。created の注文は 1 回だけ履行でき、completed の注文を再度履行しても何も起きません。expired や failed の注文を履行しようとすると、黙ってポイントを付与するのではなくエラーになります。重複配信によって二重にポイントが付与されることはありません。
5. 注文ステータスと残高は同時に変わる。 注文を完了にする処理とポイントを加算する処理は、1 つの MongoDB トランザクションで実行します。両方成功するか、どちらも起きないかのどちらかなので、「支払ったのにポイントがない」という手作業で直すしかない状態は生まれません。
ポイント台帳
ポイントの消費も、向きが逆なだけで同じパターンです。画像の生成と保存が終わったあと、1 つのトランザクションで画像レコードの挿入とポイントの差し引きを行います。データベースへの書き込みが失敗すれば、ユーザーは見られない画像に課金されませんし、支払いのない画像がギャラリーに並ぶこともありません。
今ならこう変える
このシステムは役目を果たしてきましたが、本番運用の経験を踏まえて読み返すと、クライアント案件なら次の 3 点を詰めます:
- モデルを呼ぶ前にポイントを確保する。 現状は残高チェックが生成の後にあるため、ポイントが足りないユーザーでも、拒否される前に有料の OpenAI 呼び出しが 1 回発生します。「確保 → 生成 → 確定または解放」のフローにすれば解決します。
- 残高は条件付きのアトミック更新で扱う。 今は残高を読み、変更し、書き戻しています。トランザクション内なので正しく動きますが、
balance >= costを条件にした$inc1 回のほうがシンプルで、並行処理にも強くなります。 - 処理済みの Webhook イベント ID を保存する。 二重付与は注文のステートマシンですでに防げていますが、Stripe のイベント ID を残しておけば、リプレイや監査の追跡が楽になります。
どれもローンチ前に解決が必要なものではありませんでした。ただ、どこが限界なのかを把握していることこそが、デモと「他の人に引き継げるシステム」の違いだと考えています。
似たものをつくろうとしているなら
Prompt Swim は自分のプロダクトですが、既存のビジネスに AI や決済を追加するプロジェクトには、たいてい同じ部品が登場します:
- プロダクトに AI 機能を追加したい → 検証、型付きエラー処理、リクエストごとのコスト管理、自前の出力保存を備えたサーバーサイドでのモデル呼び出し
- 利用量に応じて課金したい → 前払いポイント、従量課金、または Stripe サブスクリプション。価格は必ずサーバー側で担保
- 安全に決済を受け付けたい → Stripe Checkout と、署名検証済みで冪等な Webhook を唯一の正とする構成
- お金とデータの整合性を保ちたい → 途中で失敗しても「課金されたのにサービスが提供されない」状態を生まないトランザクション書き込み
- 外部 API とつなぎたい → OAuth ログイン、クラウドストレージ、プロダクトが依存するあらゆる REST / Webhook サービス
よくご相談いただく内容:既存アプリへの生成 AI(画像・テキスト・画像認識)の組み込み、Stripe での従量課金・ポイント課金の構築、Next.js や Python のバックエンドへの決済・認証・ストレージサービスの連携、そして既存の連携に上記のような二重課金や Webhook 取りこぼしの問題がないかのレビューです。