はじめに
ITパスポートの過去問演習に月間ランキングをつけ、上位3名に毎月Amazonギフト券5,000円分をお渡しする学習サービス「パスポイ」を作りました。無料・メール登録のみで使えます。
この記事では、サービスの仕組みと技術スタック、月額コストの実際、そして個人開発でぶつかった不正対策の設計について書きます。
なぜ作ったか
ITパスポートに限らず、資格の過去問演習は効果的な学習法だとわかっていても、単調さゆえに続かないという課題があります。既存の過去問サイトはよくできているものが多いのですが、基本的には黙々と解くだけで、ゲーム性のようなものはあまりありません。
そこで、「勉強=我慢して続けるもの」を「勉強=ギフト券獲得のチャンス」に変えられないかと考えました。いわゆるポイ活の発想を学習に応用した形です。また、まとまった勉強時間が取りにくい人でも続けられるように、1回10問という短い単位で、スマホのスキマ時間に完結する設計にしています。
サービスの仕組み
ルールはできるだけシンプルにしています。
| 項目 | 内容 |
|---|---|
| ランキング対象期間 | 月間(暦月) |
| 順位の基準 | 月間の正解数 |
| 賞品 | 上位3名にAmazonギフト券5,000円分 |
| カウント上限 | 1日100問まで |
| 無効カウント | 解答時間1秒未満(連打とみなす) |
| 同点時の判定順 | ①総解答数が少ない方(正答率優先)→②最初の解答が早い方→③抽選 |
ポイントは「量を積んだ人が勝つ」のではなく、「正確に解いた人が勝つ」設計にしていることです。1日の上限を設けているのも、一気に周回するのではなく、継続して取り組んでもらうことを意図しています。
技術スタックと月額コスト
個人開発において「サーバー代で継続を諦める」というのはよくある話だと思いますが、今回はほぼ無料構成で運用しています。
| 役割 | 採用 | 月額 |
|---|---|---|
| ホスティング | Vercel(無料枠) | 0円 |
| DB・認証 | Supabase(無料枠) | 0円 |
| メール送信 | Resend(無料枠、独自ドメインSMTP) | 0円 |
| ドメイン | paspoy.com | 約150円 |
フロントエンドはNext.js(App Router)+ TypeScript + Tailwind CSSです。認証はSupabase Authを利用し、メール確認ありのフローにしています。確認メールはSupabase標準の送信機能ではなく、Resend経由で独自ドメインから送るようにしています(理由は後述します)。
UIコンポーネントは、ボタンやダイアログ、タブといった基本パーツを1つずつ手で書く代わりに、axiom-uiというオープンソースのコンポーネント集を使いました。shadcn/uiと似た「CLIでソースコードを自分のリポジトリにコピーして、あとは自分で自由に編集する」方式のライブラリで、npmランタイムへの依存がなく手元にコードが残るため、細かい挙動を自分好みに変えたいときに扱いやすかったです。パスポイでは button・input・dialog・tabs あたりを中心に利用しています。
現状の月額は実質ドメイン代の150円のみで、無料枠の範囲内で運用できています。
技術的に面白かったポイント
いくつか、個人的に設計を悩んだところを紹介します。
a. 不正対策の設計
ランキング形式で金銭的な報酬が絡むサービスなので、ここが一番気を使った部分です。性善説で作ると簡単に壊れる、というのを実感しました。
まず、解答にかかった時間はサーバー側で判定しています。クライアントから送られてきた「解答時間」をそのまま信用するのではなく、出題時刻と解答受信時刻の差分をサーバー側で計算し、一定のしきい値(1,000ms)未満の場合はスコア対象外として記録するようにしています。
// 疑似コード:解答受信時のサーバー側処理
const elapsed = receivedAt - questionServedAt;
const counted = elapsed >= MIN_ANSWER_MS; // MIN_ANSWER_MS = 1000
await db.answers.insert({
userId,
questionId,
isCorrect,
elapsedMs: elapsed,
counted, // false の場合はランキング集計から除外
});
こうしておくと、連打やスクリプトによる機械的な解答を弾きつつも、記録自体は残せるので後から挙動を分析できます。
また、1日100問という上限も、不正対策と学習効果の両方を意図した設計です。上限を設けることで「量を稼ぐ」戦略の旨味を減らし、結果的に「継続して正確に解く」方向にインセンティブを寄せています。
管理画面には、解答時間が短すぎて除外(counted: false)された割合が高いユーザーを検出できるレポート画面を用意し、異常なユーザーがいないか定期的に確認できるようにしています。
このあたりを作りながら感じたのは、「ルールは自然に守られるだろう」という前提ではなく、ドメインロジックとして明文化し、コードとして強制する必要があるということでした。
b. JST基準の月間ランキングSQL
集計処理で最初にハマったのが、タイムゾーンの扱いです。DBの日時はUTCで保存されているため、そのまま月初・月末を切ってしまうと、日本時間とは9時間ずれた集計期間になってしまいます。サービスとしては「日本時間の暦月・暦日」で締めたいので、集計クエリ側でタイムゾーン変換を行う必要がありました。
同点タイブレークについても、ルールで決めた優先順位(正答数→総解答数の少なさ→最初の解答の早さ)をそのままORDER BYで表現しています。
select
user_id,
count(*) filter (where is_correct and counted) as score,
count(*) filter (where counted) as answered,
min(answered_at) as first_answered_at
from answers
where counted
and (answered_at at time zone 'utc' at time zone 'Asia/Tokyo')
>= date_trunc('month', now() at time zone 'Asia/Tokyo')
group by user_id
order by score desc, answered asc, first_answered_at asc;
このクエリを書きながら思ったのは、「利用規約に書いた同点処理のルール」と「実際に動くSQL」が一致していないと意味がない、ということです。文章として整合性が取れていても、実装が違うルールで動いていたら不正対策にもならないので、規約の文言とクエリを見比べながら何度か書き直しました。
c. メール基盤
Supabaseの標準メール送信は、開発用途としては便利なのですが、1時間あたり2通までという制限があり、本番運用にはそのまま使えませんでした。そのため、確認メールなどの送信はResendに切り替え、独自ドメインからのSMTP送信という形にしています。
切り替えにあたっては、送信ドメインのDNSにDKIMやSPFのレコードを追加する作業が必要で、設定内容を間違えるとメールが届かなかったり迷惑メール判定されたりするため、ここは地道に確認しながら進めました。
個人開発の学び
今回、最初から作り込みすぎず、必要最低限の機能でとりあえず公開してみる、という進め方をしました。公開初日に想定していたよりも多くのユーザー登録があり、慌てて管理画面の不正検知まわりを急いで整えた場面もありました。想定より早く人が来ることも織り込んでおくべきだった、というのは反省点です。
また、実装だけでなく法務まわりも自分で用意しています。同点時の処理や、賞品としてのAmazonギフト券の送付フローなど、トラブルが起きやすそうな部分は利用規約に明記するようにしました。技術的な仕様と規約の文言がずれないよう、両方を並行して見直す作業も個人開発ならではの大変さだったと思います。
おわりに
ITパスポートを受ける予定がある方は、腕試しがてら覗いてみてください。無料で使えます。
今後は、ITパスポート以外の資格にも同じ仕組みを展開できないか検討しています。至らない点も多いサービスなので、感想やフィードバックがあればぜひ聞かせてください。