From 10402464c5f200fc31c5956bfc1ccdb726f17cfc Mon Sep 17 00:00:00 2001 From: posimai Date: Sat, 11 Apr 2026 15:05:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B3=BC=E5=85=A5=E5=BE=8C=E3=83=9E?= =?UTF-8?q?=E3=82=B8=E3=83=83=E3=82=AF=E3=83=AA=E3=83=B3=E3=82=AF=E3=83=A1?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E8=87=AA=E5=8B=95=E9=80=81=E4=BF=A1=20+=20TT?= =?UTF-8?q?S=20=E3=81=AB=20purchaseMiddleware=20=E6=8E=A5=E7=B6=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- routes/stripe.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 8 +++--- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/routes/stripe.js b/routes/stripe.js index 9a634c88..132dbbca 100644 --- a/routes/stripe.js +++ b/routes/stripe.js @@ -94,6 +94,69 @@ module.exports = function createStripeHandler(pool) { await sendLicenseEmail(email, licenseKey); } + // ── 購入完了メール(マジックリンク付き)送信 ── + async function sendWelcomeMagicLink(email) { + if (!RESEND_API_KEY) { + console.warn('[Stripe] RESEND_API_KEY not set — skipping welcome email'); + return; + } + try { + // マジックリンク用トークンを生成(15分有効) + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + await pool.query( + `INSERT INTO magic_link_tokens (email, token, expires_at) VALUES ($1, $2, $3)`, + [email, token, expiresAt] + ); + + const loginBase = process.env.MAGIC_LINK_BASE_URL || 'https://posimai.soar-enrich.com'; + const magicLinkUrl = `${loginBase}/auth/verify?token=${token}&redirect=${encodeURIComponent('https://brief.posimai.soar-enrich.com')}`; + + const emailRes = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${RESEND_API_KEY}`, + }, + body: JSON.stringify({ + from: FROM_EMAIL, + to: email, + subject: 'Posimai へようこそ — ログインリンク', + html: ` +
+

ご購入ありがとうございます

+

Posimai パスの購入が完了しました。
+ 下のボタンをクリックするとすぐにアプリをご利用いただけます。

+
+ + Posimai を開く + +
+

このリンクは15分間有効です。
+ 期限切れの場合は ${loginBase}/login からログインしてください。

+
+

+ ご不明な点は ${APP_SUPPORT_EMAIL} までお問い合わせください。 +

+
+ `, + }), + }); + + if (!emailRes.ok) { + const errBody = await emailRes.text(); + console.error('[Stripe] Welcome email error:', emailRes.status, errBody); + } else { + console.log(`[Stripe] Welcome magic link sent to ${email}`); + } + } catch (e) { + console.error('[Stripe] sendWelcomeMagicLink error:', e.message); + } + } + // ── Posimai 購読購入処理(既存フロー) ── async function handlePosiPurchase(session) { const email = session.customer_details?.email?.toLowerCase(); @@ -121,6 +184,11 @@ module.exports = function createStripeHandler(pool) { [session.id, session.customer, session.subscription, userId] ); console.log(`[Stripe] Plan upgraded to premium for ${email}`); + + // 購入完了メールにマジックリンクを同梱(非同期・失敗しても購入処理には影響しない) + sendWelcomeMagicLink(email).catch(e => + console.error('[Stripe] Welcome email failed (non-critical):', e.message) + ); } // ── Webhook エントリーポイント ── diff --git a/server.js b/server.js index 4be2d6f0..d01c14a0 100644 --- a/server.js +++ b/server.js @@ -2159,8 +2159,8 @@ ${excerpt} return Buffer.from(await synthRes.arrayBuffer()); } - // POST /tts — テキストを音声(WAV)に変換して返す - r.post('/tts', authMiddleware, async (req, res) => { + // POST /tts — テキストを音声(WAV)に変換して返す(購入済みユーザーのみ) + r.post('/tts', authMiddleware, purchaseMiddleware, async (req, res) => { const { text, speaker = 1 } = req.body || {}; if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' }); if (text.length > 600) return res.status(400).json({ error: 'text too long (max 600 chars)' }); @@ -2203,7 +2203,7 @@ ${excerpt} }); // POST /tts/ready — 指定テキストがキャッシュ済みか確認(ポーリング用) - r.post('/tts/ready', authMiddleware, (req, res) => { + r.post('/tts/ready', authMiddleware, purchaseMiddleware, (req, res) => { const { texts, speaker = 1 } = req.body || {}; if (!texts || !Array.isArray(texts)) return res.json({ cached: 0, total: 0, ready: false }); const total = texts.length; @@ -2213,7 +2213,7 @@ ${excerpt} // POST /tts/warmup — バックグラウンドで事前合成してキャッシュを温める // ブラウザが Feed 読み込み直後に呼び出す。即座に 202 を返し、VOICEVOX をバックグラウンドで実行。 - r.post('/tts/warmup', authMiddleware, async (req, res) => { + r.post('/tts/warmup', authMiddleware, purchaseMiddleware, async (req, res) => { const { texts, speaker = 1 } = req.body || {}; if (!texts || !Array.isArray(texts) || texts.length === 0) { return res.status(400).json({ error: 'texts[] required' });