個人開発しているUnity製のスマホゲームにて、「手動送信ではなく、ユーザーごとの最適な時間帯(現地時刻)に自動でプッシュ通知を送りたい」と考えました。
専用のバックエンドを常時稼働させるのではなく、無料枠〜低コストで運用できる Cloudflare Workers(Cron Triggers) と D1 を組み合わせて、端末ごとに最適化された通知配信システムを構築しました。
この記事では、「1日1通まで、ユーザーのローカル時刻に合わせて届ける」 仕組みを、実際のコード抜粋やつまずいたポイントとともに解説します。
前提・動作環境
-
Cloudflare Workers: wrangler
^4.146.0(compatibility_date = "2026-09-01"), D1, Cron Triggers -
TypeScript:
^7.0.2 -
ライブラリ:
jose^6.2.12(JWT検証・署名用) -
テスト:
vitest^5.0.3 - 通知基盤: Firebase Cloud Messaging (FCM) HTTP v1 API
- 認証: Firebase 匿名認証(IDトークン)
※実装はClaude Codeを活用して作成し、npm test で全テスト(73件)を通過させて検証しています。
全体像とアーキテクチャ
設計の核心は、「次に送る時刻(next_send_at)」の計算をリクエスト受信時に済ませ、D1に保持しておくことです。定期実行処理側は「時刻が過ぎたレコードを抽出して送るだけ」のシンプルな作りにしています。
クライアント(Unity)側の役割
プレイ開始時に「日付×遊んだ時間帯」を端末内に記憶し、直近14日間で最も頻度の高かった時間帯(0〜23の整数)を算出します。個人情報保護と通信量削減のため、詳細なアクセスログそのものは送らず、集計した時間帯(preferred_hour)のみをサーバーに送信します。
1. 端末登録と本人確認(Auth)
提供するエンドポイントは PUT /v1/devices/me(登録・更新)と DELETE /v1/devices/me(削除)の2つです。
独自の固定APIキーをアプリ内に埋め込むとリバースエンジニアリングで悪用されるリスクがあるため、アプリ側で使用している Firebase匿名認証のIDトークン を Authorization: Bearer で受け取り、jose ライブラリで検証しています。
export async function verifyIdToken(
token: string,
projectId: string,
keys: JWTVerifyGetKey = getGoogleKeys(),
): Promise<string | null> {
try {
const { payload } = await jwtVerify(token, keys, {
issuer: `https://securetoken.google.com/${projectId}`,
audience: projectId,
algorithms: ['RS256'],
requiredClaims: ['exp', 'sub'],
});
return typeof payload.sub === 'string' && payload.sub !== '' ? payload.sub : null;
} catch (e) {
// Googleの公開鍵が取得できないなどの一時障害時は、401ではなく呼び出し元で500エラーとして扱う
if (e instanceof errors.JWKSTimeout || e instanceof errors.JWKSInvalid || !(e instanceof errors.JOSEError)) {
throw e;
}
return null;
}
}
検証に成功した場合、取得した sub (Firebase UID) をD1の主キー(Primary Key)として使用します。
2. D1 のテーブル設計
1端末につき1行で管理します。通知日時計算に必要な最小限の列に絞っています。
CREATE TABLE devices (
uid TEXT PRIMARY KEY,
fcm_token TEXT NOT NULL,
tz_offset_min INTEGER NOT NULL, -- UTCからの時差(分単位)
daily_enabled INTEGER NOT NULL,
preferred_hour INTEGER NOT NULL, -- 最く遊ぶ時間帯(0〜23)
last_active_local_date TEXT NOT NULL, -- 最終アクティブ日(現地日付)
sent_days TEXT NOT NULL DEFAULT '', -- 配信済みの日数記録(例: "1,2")
next_send_at INTEGER, -- 次回配信時刻 (UTCのUNIX秒 / NULLは予定なし)
next_day_offset INTEGER, -- 最終アクティブ日から何日目の通知か (1/2/3/7/14)
updated_at INTEGER NOT NULL -- 最終更新日時 (UTCのUNIX秒)
);
CREATE INDEX idx_devices_next_send_at ON devices(next_send_at);
next_send_at にインデックスを貼ることで、 Cron 実行時に配信対象のレコードをフルスキャンせずに高速取得できるようにしています。
アプリから PUT を受け取った際は、前回のアクセス日と照合し、日数が進んでいればカウント(sent_days)をリセットして1日目から再計算のうえ INSERT ... ON CONFLICT(uid) DO UPDATE で保存します。
3. 送信日時の計算ロジック
配信日時の判定ロジックは、DB通信や外部依存を持たない純粋関数として分離しています。
- リテンション施策: 最終アクティブ日から 1・2日目(デイリー通知)、3・7・14日目(復帰促進通知)の計5回。
- 夜間・早朝対策: 睡眠を妨げないよう、22〜4時のアクティブユーザーは 21:30、5〜7時のユーザーは 8:00 に配信時刻をずらします。
export const DAILY_OFFSETS = [1, 2];
export const LAPSED_OFFSETS = [3, 7, 14];
export const LATE_LIMIT_SEC = 2 * 3600; // 2時間を過ぎた遅延通知はスキップ
// 希望時間帯に応じた配信時刻(現地時刻)を返却
export function sendClock(preferredHour: number): { h: number; m: number } {
if (preferredHour >= 22 || preferredHour <= 4) return { h: 21, m: 30 };
if (preferredHour <= 7) return { h: 8, m: 0 };
return { h: preferredHour, m: 0 };
}
export function nextSend(dev: {
daily_enabled: boolean; preferred_hour: number; last_active_local_date: string;
tz_offset_min: number; sent_days: string;
}, nowSec: number): { at: number; offset: number } | null {
const offsets = (dev.daily_enabled ? DAILY_OFFSETS : []).concat(LAPSED_OFFSETS);
const done = new Set(dev.sent_days.split(',').filter(s => s !== '').map(Number));
const { h, m } = sendClock(dev.preferred_hour);
for (const off of offsets) {
if (done.has(off)) continue;
const at = localToUtcSec(addDays(dev.last_active_local_date, off), h, m, dev.tz_offset_min);
// 障害等で予定より2時間以上過ぎてしまった通知は送らずにスキップ(夜中に古い通知が届くのを防ぐ)
if (at < nowSec - LATE_LIMIT_SEC) continue;
return { at, offset: off };
}
return null;
}
4. 定期実行(Cron Triggers)
15分ごとに Worker を起動して配信処理を行います。
workers_dev = false
routes = [{ pattern = "<your-domain>", custom_domain = true }]
[triggers]
crons = ["*/15 * * * *"]
[[d1_databases]]
binding = "DB"
database_name = "sw-push"
database_id = "<your-database-id>"
[vars]
FIREBASE_PROJECT_ID = "<your-firebase-project-id>"
MAX_SENDS_PER_RUN = "200"
1回の Cron 実行あたり最大200件を取得して処理します(15分間隔のため、2時間で最大1,600件の配信能力)。
export async function selectDueDevices(db: D1Database, nowSec: number, limit: number): Promise<DeviceRow[]> {
const { results } = await db
.prepare('SELECT * FROM devices WHERE next_send_at IS NOT NULL AND next_send_at <= ? ORDER BY next_send_at LIMIT ?')
.bind(nowSec, limit)
.all<DeviceRow>();
return results;
}
const result = await sendMessage(accessToken, env.FIREBASE_PROJECT_ID, message);
const log = { uid: row.uid, sent_at: nowSec, day_offset: offset, kind, template_id: templateId, result };
if (result === 'ok') {
// 送信成功:sent_days にオフセットを追加し、次回の送信予定を再計算して更新
const sentDays = addSentDay(row.sent_days, offset);
// ...
} else if (result === 'unregistered') {
// アプリ削除等でトークンが無効化されている場合は DB から削除
await deleteUnregistered(env.DB, row, log);
} else {
// 429や5xx等のエラー:行は維持し、15分後の次回ループでリトライさせる
// ...
}
5. FCM HTTP v1 配信とトークン管理
FCM HTTP v1 API へのアクセスには Google のアクセストークンが必要です。
サービスアカウントの秘密鍵から jose ライブラリ(importPKCS8 と SignJWT)を用いて RS256 JWT を作成し、Google Token エンドポイント経由でアクセストークンを発行しています。
取得したアクセストークンは有効期限とともに D1 の meta テーブルにキャッシュし、残り有効期限が5分以上あれば再利用することで無駄な認証通信を削減しています。
6. 同意管理とデータクリーンアップ
- 同意フロー: アプリ内で「通知を許可」したユーザーのみトークンを送信します。未許可の段階では Firebase Messaging のトークン取得も行いません。
-
オフ通知時の削除: 設定から通知を OFF に切り替えた場合、アプリから
DELETE /v1/devices/meが呼ばれ、該当端末のデータが D1 から削除されます。 - ガベージコレクション: 30日間一度も更新がないレコード(アプリ削除後に通知OFFリクエストすら来なかった端末など)は、定期バッチ処理で自動削除します。
export async function deleteOldData(db: D1Database, nowSec: number): Promise<void> {
await db.batch([
db.prepare('DELETE FROM devices WHERE updated_at < ?').bind(nowSec - RETENTION_SEC),
db.prepare('DELETE FROM send_log WHERE sent_at < ?').bind(nowSec - RETENTION_SEC),
]);
}
実装中にハマったポイント・工夫した点
開発中やテスト過程で直面した問題と、その対策です。
① 400エラーの全削除防止(FCM応答の判定)
FCMは、端末のトークンが無効な場合だけでなく、送信Payload(JSON)側に不備がある場合も 400 INVALID_ARGUMENT を返します。
400 エラーを一律で「無効な端末」と判定してDBから削除してしまうと、バックエンド側の実装ミス1つで全ユーザーの登録データが消えてしまいます。
エラーレスポンスの本文に registration token が含まれているか、または fieldViolations の対象が message.token であることを明示的に判定した上で削除処理を行うようにしました。
if (status === 404 || codes.includes('UNREGISTERED')) return 'unregistered';
// トークン自体が原因のエラーである場合のみ 'unregistered' として扱う
if (status === 400 && (error?.status === 'INVALID_ARGUMENT' || codes.includes('INVALID_ARGUMENT'))
&& isTokenProblem(error?.message, details)) {
return 'unregistered';
}
return `error:${status}`;
② 定期実行とクライアント更新の競合制御(Optimistic Concurrency)
Cron 処理がDB行を読み込んでから送信完了・DB更新を行うまでの間に、タイミング悪くユーザーがアプリを開いて PUT リクエストが届くケースがあります。
このとき Cron 側の古い状態(last_active_local_date)で上書きしてしまうのを防ぐため、UPDATE 文の条件句に処理開始時の last_active_local_date を指定し、値が変わっている場合は更新をスキップ(新規PUT側の計算結果を優先)するように修正しました。
`UPDATE devices SET sent_days = ?, recent_template_ids = ?, next_send_at = ?, next_day_offset = ?
WHERE uid = ? AND last_active_local_date = ?`
③ バックグラウンド遷移時の送信制御(モバイルOSの制限)
当初は「アプリがバックグラウンドに回った瞬間」に最新状態を PUT 送信する設計にしていました。
しかし Android / iOS の仕様上、アプリ退避時のUnity側の非同期通信は安定して完結しないケースがありました。そのため無理なバックグラウンド通信はやめ、「ゲーム内で状態が変わったタイミング」および「次回アプリ起動時」に送信する運用に割り切りました。
まとめ
Cloudflare Workers + D1 を使うことで、外部の有料配信サービスや常時稼働サーバーを導入することなく、「ユーザーごとの現地時刻に合わせたカスタムプッシュ通知機能」 をシンプルに構築できました。
今回構築した仕組みは、開発中のUnity製ゲーム『ソリティア世界旅行』のバージョン1.1にて実際に運用を開始しています。