VWork バイブコーディングフレームワーク

軽量PHPブログエンジン Bludit で運用しているブログに、有料記事機能を自作しました。noteのように「途中まで無料で読めて、続きから有料」になるパターンです。決済は、法定通貨(PayPal 200円)と独自トークン(ERC20を20,000枚)の2レールを用意しました。

外部のペイウォールSaaSを使わず、PHPファイル3つとJSONファイル1つで完結しています。この記事では、その設計と、実装で踏んだ罠を解説します。

全体設計

構成要素は次の4つだけです。

データベースは使いません。低頻度の購入記録なら、flockで保護したJSONファイルで十分です。

1. 「途中まで無料」はサーバーサイドで分割する

記事本文に HTMLコメントのマーカーを1行入れます。

(ここまで無料の本文)

<!--paywall-->

(ここから有料の本文)

テーマ側(記事テンプレート)で、このマーカーを境に分割します。

$parts = preg_split('/<!--\s*paywall\s*-->/i', $page->content(), 2);
if (count($parts) === 2 && !pw_is_unlocked($page->key())) {
    echo $parts[0];               // 無料部分だけ出力
    include 'paywall-box.php';    // 決済UI
} else {
    echo $page->content();        // 購入済み or マーカー無しは全文
}

重要なのは、未購入者へのレスポンスHTMLに有料部分を1バイトも含めないことです。CSSで隠すだけの実装(display:noneやぼかし)は、ソース表示で全文読めてしまいます。分割は必ずサーバーサイドで行います。

2. PayPal: ブラウザの自己申告を信用しない

決済UIは PayPal の Smart Buttons(JS SDK)です。ブラウザ側で決済が完了すると注文IDが得られるので、それをAPIに送って解錠します。

ここに罠があります。ブラウザから送られてくる「決済完了しました」は偽装できます。 注文IDの形式さえ合っていれば、決済せずに解錠APIを叩けてしまう。

対策は、サーバー側でPayPal APIに注文を照合することです。

1. client_id + secret で OAuth トークン取得 (POST /v1/oauth2/token)
2. GET /v2/checkout/orders/{注文ID}
3. status == COMPLETED を確認
4. 金額 (200 JPY) を確認
5. 購入者メールは PayPal の応答から取る(ブラウザの申告値は使わない)

この照合を入れると、偽の注文IDは「注文が見つかりません」で弾かれます。実際にテストで偽IDを投げて拒否されることを確認しました。

Sandbox / Live の罠

PayPal Developer ダッシュボードは、開くと必ず Sandbox(テスト環境)側が表示されます。ここで作ったアプリの Client ID / Secret は、本番APIでは invalid_client で認証に失敗します。

私はこれで2回、Sandboxの資格情報を掴まされました。見分け方は簡単で、取得した資格情報で両方のエンドポイントにOAuthを投げることです。

ダッシュボード上部の Sandbox / Live 切り替えを「Live」にしてからアプリを作成する必要があります。切り替えた直後はアプリ一覧が空になるのが「Live側にいる」目印です。

3. トークン決済: オンチェーンをサーバーが直接見る

もう1つの決済レールは、Base チェーン上の ERC20 トークンです。読者は指定ウォレットへ 20,000 枚を送金し、送金元アドレスを入力して「支払い確認」を押します。

サーバーは、公開RPCに eth_getLogs を投げて Transfer イベントを直接確認します。

$topics = [
    keccak256("Transfer(address,address,uint256)"),  // イベントシグネチャ
    pad32($送金元ウォレット),                          // from
    pad32($受け取りウォレット),                        // to
];
// トークンコントラクト宛に、直近ブロック範囲で照会し、amountを合算

ポイントは3つあります。

送金額はイベントの data フィールド(32バイトのhex)から復元します。18桁のdecimalsを割るときは、閾値判定が目的なら float 精度で十分です。

4. 匿名のまま「買った人」を識別する

会員登録は要求しません。識別子は決済手段が勝手に連れてきます。

台帳には 識別子 × 記事キー で1レコード記録し、閲覧の維持は識別子をHMAC署名したCookie(1年)で行います。

Cookie: KURAGEPAY = identifier|HMAC-SHA256(identifier, server_secret)

Cookieは「目印」であって、解錠判定は毎回サーバーの台帳と照合します。署名鍵はサーバー側だけが持つので、Cookieを自作しても識別子を偽装できません。

別端末での再解錠は、メール購入者には「メール + PayPal注文ID」の両方一致を要求します(メールだけで解錠できると、他人のメールを入れるだけで読めてしまうため)。ウォレット購入者は、オンチェーンに送金の事実が刻まれているので、再検証するだけです。

5. 障害時の設計: 決済は fail-closed、それ以外は素直に

エントリー可否のようなゲートは fail-open(障害時は通す)にすることが多いですが、決済の解錠は逆で、確認できなければ必ず閉じる(fail-closed)にします。PayPal APIに到達できない・RPCが応答しない場合は、解錠せずエラーを返して再試行してもらいます。

一方で、購入済みの読者を締め出さないよう、台帳・Cookie検証はシンプルに保ち、依存を減らしています。ペイウォールの実装そのものが落ちた場合は、テーマ側の分岐で「マーカーが無い記事は全文表示」に倒れるため、無料記事には一切影響しません。

まとめ

外部SaaSに月額を払わなくても、有料記事は数百行で自作できます。決済の検証をサーバーに置くこと、これだけは省略しないでください。