1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

招待コード共有掲示板を「現金報酬なし」で設計した理由

1
Last updated at Posted at 2026-06-26

Claude Codeの記憶を4層に分けた話から続く「個人開発を量産するための自動化基盤」シリーズです。今回は、招待コード共有掲示板 InviteLoop(本番稼働中:inviteloop.vercel.app)を設計したときに最初に決めた方針、「報酬は公式特典のみ。現金のやりとりは一切排除する」 という選択の背景と、その判断をコードに落とし込むまでの話を書きます。

InviteLoop とは

InviteLoop は、各種サービスの招待コードをユーザーが投稿・共有できる掲示板です。Dropbox や Uber のような「紹介すると○○がもらえる」公式プログラムを持つサービスを対象に、招待コードを一か所に集めて探しやすくすることを目的にしています。

技術スタックは次のとおりです。

レイヤー 採用技術
フレームワーク Next.js 16.2.9(App Router)
データベース Turso(libsql / SQLite 互換)
ORM Drizzle ORM
認証 NextAuth.js v5(Google OAuth)
スタイリング Tailwind CSS v4
テスト Vitest + Playwright
デプロイ Vercel

一見シンプルに見えますが、設計でもっとも時間を使ったのはUI や機能よりも「報酬モデルをどこに引くか」という一点でした。

なぜ「現金報酬なし」にしたのか

招待コード掲示板を作ろうとすると、自然に浮かぶアイデアが「コードが使われたらポイントを付与して換金できるようにしよう」というものです。実際、海外には同様のモデルを持つサービスが存在します。それでも私が現金報酬を排除した理由は、大きく三つです。

理由1:各サービスの利用規約リスク

招待プログラムを提供しているサービスは、たいてい規約の中に「招待特典の転売・換金を禁止する」「第三者プラットフォームを介した特典の再配布を禁ずる」といった条項を持っています。InviteLoop が「コードを使ったら○円」という仕組みを持つと、それを媒介してユーザーが各サービスの規約を間接的に破る可能性が生まれます。

プラットフォーム側には、ユーザーの規約違反を助長した責任を問われるリスクがあります。「知らなかった」では済まないケースになりうるため、設計段階で現金の流れを完全に切ることにしました。

理由2:スパム・水増し投稿の誘発

現金報酬があると、「使われた回数に応じて収益が出る」という構造になります。すると、同一ユーザーが複数アカウントを使って自分のコードを使用報告する、あるいはボットで大量投稿するインセンティブが生まれます。

スパム対策は後付けで追加するより、インセンティブ設計の段階で封じる方がはるかに楽です。金銭的な動機を消すことで、不正行為のリターンをゼロにしました。

理由3:コミュニティの健全性

招待コード掲示板に期待される体験は「使えるコードを素早く見つけられること」です。現金が絡むと、投稿者が「できるだけ多く使われるコード」を優先して品質より数を追いかけるようになります。ここでいう品質とは「実際に有効なコードであること」「ノートが丁寧で使い方が分かること」を指します。報酬は公式特典だけにすることで、「本当に使えるコードを共有したい」というモチベーション以外が入りにくい設計にしました。

スキーマで「報酬」を最初から存在させない

設計判断をコードに落とし込む最初の一手は、DB スキーマに報酬の概念を一切書かないことでした。

// src/db/schema.ts(抜粋)
export const services = sqliteTable('service', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  category: text('category').notNull(),
  perkSummary: text('perk_summary').notNull().default(''),
  perkJa: text('perk_ja').notNull().default(''),
  perkEn: text('perk_en').notNull().default(''),
  officialUrl: text('official_url').notNull().default(''),
  createdAt: integer('created_at', { mode: 'timestamp_ms' })
    .notNull().$defaultFn(() => new Date()),
})

perkJa / perkEn は「このサービスの公式特典はこれです」という説明フィールドです。金額を入れることはありますが、それはあくまで公式が公表している情報をそのまま表示するためのもの。InviteLoop 独自のポイント残高や換金テーブルはスキーマのどこにも存在しません。

招待コード本体のテーブルを見ても、報酬に関わるカラムはゼロです。

export const inviteCodes = sqliteTable('invite_code', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  serviceId: text('service_id').notNull().references(() => services.id, { onDelete: 'cascade' }),
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  code: text('code').notNull(),
  url: text('url').notNull().default(''),
  note: text('note').notNull().default(''),
  status: text('status', { enum: ['active', 'hidden', 'removed'] }).notNull().default('active'),
  clickCount: integer('click_count').notNull().default(0),
  upvotes: integer('upvotes').notNull().default(0),
  usageCount: integer('usage_count').notNull().default(0),
  reportCount: integer('report_count').notNull().default(0),
  createdAt: integer('created_at', { mode: 'timestamp_ms' })
    .notNull().$defaultFn(() => new Date()),
})

clickCountupvotesusageCountreportCount はどれも「掲示板としての信頼度・人気度を示す指標」であり、換金の根拠にはなりません。スキーマが真実の単一ソースである以上、存在しないカラムからは何も払えません。これが設計上の最強の担保です。

投稿制限と URL ドメイン検証

「報酬がなければ乱用されないか?」と問われると、正直ゼロではありません。自分のコードを上位に表示させたい、あるいは単なるいたずらで大量投稿するケースは考えられます。それを防ぐのが投稿レート制限とURL検証です。

// src/lib/rate-limit.ts
export const MAX_POSTS_PER_DAY = 5

export function checkRateLimit(i: RateLimitInput): RateLimitResult {
  if (i.alreadyPostedThisService) return { ok: false, reason: 'duplicate_service' }
  if (i.postsToday >= i.maxPerDay) return { ok: false, reason: 'daily_limit' }
  return { ok: true }
}

1ユーザーが同じサービスに複数のコードを投稿することは禁止されています(duplicate_service)。また、1日の投稿上限は5件(MAX_POSTS_PER_DAY = 5)です。

投稿フォームには「コードとセットで招待URLを貼れる」フィールドがありますが、ここに任意のURLを入れられると別サービスへの誘導に使われます。そこで、URL は当該サービスの公式ドメインのみ許可する仕組みにしました。

// src/lib/allowed-hosts.ts
export function allowedHostsFor(officialUrl: string): string[] {
  if (!officialUrl) return []
  try {
    const h = new URL(officialUrl).host
    return h.startsWith('www.') ? [h, h.slice(4)] : [h, `www.${h}`]
  } catch { return [] }
}

officialUrl から www あり/なし の両方を許可ホストとして生成し、validateSubmission の中で URL を検証します。

// src/lib/validation.ts(抜粋)
if (parsed.protocol !== 'https:' || !allowedHosts.includes(parsed.host)) {
  return { ok: false, error: 'url_not_allowed' }
}

https: 限定かつ公式ドメイン限定。これだけで外部リンクを使ったフィッシング的な使い方をほぼ封じられます。

自動モデレーション:3 件レポートでオートハイド

ユーザーからの通報を受けて、一定数に達したコードは自動的に非表示にします。

// src/lib/scoring.ts
export const REPORT_AUTOHIDE_THRESHOLD = 3
// src/repos/engagement.ts(reportCode 関数の末尾)
const reportCount = rows[0].reportCount
const hidden = reportCount >= REPORT_AUTOHIDE_THRESHOLD
if (hidden) {
  await db.update(inviteCodes)
    .set({ status: 'hidden' })
    .where(eq(inviteCodes.id, codeId))
}
return { ok: true as const, reportCount, hidden }

3件の通報で status'hidden' になります。一覧画面ではこのステータスを持つコードを除外するため、問題のある投稿は人手を介さずに非表示になります。闇雲にハードルを上げると正常なコードまで巻き込まれるので、閾値は現時点では控えめな3件です。

同一ユーザーが同じコードを複数回通報できないよう、report テーブルに (code_id, reporter_user_id) の複合ユニーク制約を設けています。

// src/db/schema.ts(抜粋)
export const reports = sqliteTable('report', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  codeId: text('code_id').notNull().references(() => inviteCodes.id, { onDelete: 'cascade' }),
  reporterUserId: text('reporter_user_id'),
  reason: text('reason').notNull().default(''),
  createdAt: integer('created_at', { mode: 'timestamp_ms' })
    .notNull().$defaultFn(() => new Date()),
}, (t) => ({ uniq: uniqueIndex('report_code_user_uq').on(t.codeId, t.reporterUserId) }))

フェアな並び順:スコア×加重シャッフル

投稿数が増えてくると「どの順番でコードを並べるか」が UX の要になります。純粋にスコア順(人気順)で並べると、古くから上位にいるコードが有利になりすぎます。逆に新着順だと、すぐに埋もれて投票を受けるチャンスがなくなります。

この問題を解決するために「加重確率シャッフル」を採用しました。

// src/lib/scoring.ts
export const SCORE_CLICK_WEIGHT = 1
export const SCORE_USAGE_WEIGHT = 5
export const SCORE_UPVOTE_WEIGHT = 3

export function computeScore(input: {
  clickCount: number
  usageCount: number
  upvoteCount: number
}): number {
  return (
    input.clickCount * SCORE_CLICK_WEIGHT +
    input.usageCount * SCORE_USAGE_WEIGHT +
    input.upvoteCount * SCORE_UPVOTE_WEIGHT
  )
}

「実際に使用した(usageCount)」を最も重く評価し(×5)、「いいね(upvotes)」が続き(×3)、「クリックしただけ(clickCount)」が最も軽い(×1)という重み付けです。

並び順の計算は computeFairOrder が担います。

// src/lib/ordering.ts(抜粋)
export function computeFairOrder<T extends Orderable>(
  codes: T[],
  opts: { newQuota: number; seed: number; now: number; freshWindowMs: number },
): T[] {
  const fresh = codes
    .filter((x) => opts.now - x.createdAt < opts.freshWindowMs)
    .sort((a, b) => a.createdAt - b.createdAt)
  const reserved = fresh.slice(0, Math.max(0, opts.newQuota))
  const reservedIds = new Set(reserved.map((x) => x.id))
  const rest = codes.filter((x) => !reservedIds.has(x.id))
  const weight = (x: T) => 1 + Math.min(x.score * ORDER_SCORE_WEIGHT, ORDER_MAX_BOOST)
  return [...reserved, ...weightedSeededShuffle(rest, weight, opts.seed)]
}

直近7日間(FRESH_WINDOW_MS)の新着コードを一定枠で先頭確保し、残りをスコアに応じた確率で並べます。確率の乱数は mulberry32 シードで固定されており、同じシードを与えると同じ順になります(画面リロードのたびにバラバラにならない)。

お問い合わせと通知の設計

掲示板系のサービスは問い合わせ対応がじわじわと増えます。外部サービスを追加することなく、Turso の既存 DB に保存する設計にしました。

// src/db/schema.ts(抜粋)
export const contactMessages = sqliteTable('contact_message', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  name: text('name').notNull(),
  email: text('email').notNull(),
  body: text('body').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp_ms' })
    .notNull().$defaultFn(() => new Date()),
  // Discord通知済みか(0=未通知)。着信時に通知を試み、失敗分はcronが回収する。
  notified: integer('notified').notNull().default(0),
})

お問い合わせが届くと、まず DB に保存してから Discord の Webhook に通知を投げます。

// src/app/api/contact/route.ts(抜粋)
const id = await createContactMessage(db, v.value)

// 着信を即 Discord 通知。失敗しても保存は守る(cron が後で回収)。
const ok = await notifyContact({
  project: 'InviteLoop',
  id,
  name: v.value.name,
  email: v.value.email,
  message: v.value.body,
  createdAt: Date.now(),
})
if (ok) await markContactNotified(db, id)

重要なのは「Discord 通知が失敗しても、DB への保存は必ず成功させる」という順序です。通知の失敗は notified = 0 のまま放置され、翌日 0 時(UTC)に実行される Cron が拾い直します。

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/notify-contacts",
      "schedule": "0 0 * * *"
    }
  ]
}
// src/app/api/cron/notify-contacts/route.ts(抜粋)
// 自己修復: 着信時の Discord 通知が落ちた問い合わせ(notified=0)を毎日回収する。
const pending = await listPendingContacts(db, BATCH)
for (const c of pending) {
  const ok = await notifyContact({ ... })
  if (ok) {
    await markContactNotified(db, c.id)
    sent++
  }
}

「通知は少なくとも翌日までには届く」という保証を、外部サービスなし・Turso だけで実現しています。この自己修復パターンは別プロジェクトでも横展開しているものです。

IP ハッシュによるプライバシー保護

クリック数や使用報告を記録するとき、IPアドレスをそのまま保存することは個人情報の観点から避けたいです。

// src/lib/ip.ts
export function hashIp(ip: string, salt: string): string {
  if (!ip) return ''
  return createHash('sha256').update(`${salt}:${ip}`).digest('hex')
}

AUTH_SECRET を salt にして SHA-256 でハッシュ化します。元の IP アドレスは DB に残らず、同一 IP からの重複操作だけを検出できます。IP ハッシュ+時間ウィンドウで重複クリックを除外する仕組みも同様です。

// src/lib/scoring.ts
export const CLICK_DEDUP_WINDOW_MS = 6 * 60 * 60 * 1000  // 6時間

6時間以内の同一 IP ハッシュからのクリックは1件としてカウントします。

設計で迷った点(落とし穴)

「公式特典の金額表示」と「InviteLoop 独自の報酬」の混同

perkJa / perkEn に「紹介で○円もらえます」という公式情報を書くと、ユーザーが「InviteLoop がお金をくれる」と誤解するケースが想定されます。フロントエンドの表示を設計するとき、「このサービスの公式特典」であることをラベルで明確に分けることになりました。DBのカラム名だけでなく、UIの文言設計まで連動します。

status の enum 設計

当初 'active''hidden' だけで十分かと思っていましたが、管理者が意図的に永久削除したいケース(明らかに詐欺的なコードなど)が発生したときのために 'removed' を追加しました。最初から3段階にしておいて良かったです。'hidden' はユーザー通報で自動遷移し、'removed' は管理者が明示的に操作するという使い分けです。

Vercel Cron の最小間隔

Vercel の Hobby プランでは Cron の最小間隔が1日です。「リアルタイムに近い通知の回収」はできないため、あくまでフォールバックと割り切る設計にしています。本番の通知は着信と同時に届くベストエフォートで、Cron は保険です。

自己投票・自己使用報告の防止

voterecordUsage の両関数で、自分のコードへの操作を弾いています。

// src/repos/engagement.ts(vote 関数の抜粋)
if (owner === userId) return { ok: false as const, reason: 'self' as const }

この制約を入れ忘れると、投稿者が自分のコードのスコアを上げ放題になります。「報酬は公式特典だけ」という方針でも、ランキング上位を狙う動機は残るため、スコア操作の防線は別途必要です。

まとめ

  • 現金報酬を排除した理由は規約リスク・スパム誘発・コミュニティの健全性の3点
  • 排除の実装はスキーマに報酬カラムを存在させないことが最初の一手
  • 不正抑止MAX_POSTS_PER_DAY = 5・1サービス1コード・URL ドメイン検証・3件オートハイドの組み合わせ
  • 公平な並び順は click×1、usage×5、upvote×3 のスコアと加重確率シャッフルで実現
  • お問い合わせ通知は「DB保存→Discord通知→失敗分はCronが翌日回収」の自己修復パターン
  • IPアドレスは SHA-256+salt でハッシュ化し、元の値はDBに残さない

「現金なし」という制約は機能を削るように見えて、実際には「何を防がなければいけないか」が明確になるため、設計がシンプルになります。招待コードという性質上、スパムと規約リスクを最初に封じておくことが、長く運用できるサービスの条件だと判断しています。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?