背景
Pro課金機能+広告リリース統合ブランチrelease/pro-202605(back PR #216 / mobile PR #80)を、ドメインごとに分けた並行レビューで監査した結果見つかったバグ一覧です。対応は後日行うため、まずは記録として残します。
back PR #216で既に見つけて修正済みの2件(PlanCatalogのproduct_id不一致、development環境のngrok Host Authorization、back#333)と、mobile側のpro_featuresフラグ関連のPaywallModalデッドエンド(release/pro-202605へ直接commit済み)はこのリストには含みません。
🔴 マネタイズ・データ整合性に直結
1. Webhook順序逆転ガードの欠如(CancellationHandler / BillingIssueHandler / UncancellationHandler)
back/app/services/revenue_cat/handlers/cancellation_handler.rb:5-16
back/app/services/revenue_cat/handlers/billing_issue_handler.rb:5-16
back/app/services/revenue_cat/handlers/uncancellation_handler.rb:5-13
RenewalHandler/ExpirationHandlerはRevenueCat Webhookの順序逆転に備えたタイムスタンプ比較ガードを実装済みだが、上記3ハンドラは無条件に状態を上書きする。
失敗シナリオ: ユーザーが解約→即座に解約撤回した場合、CANCELLATIONとUNCANCELLATIONの到達順序は保証されない。UNCANCELLATION処理後に遅延していたCANCELLATIONが届くと、有効なサブスクリプションを無条件にcancelledへ巻き戻し、「解約手続きを受け付けました」という誤った通知メールを送信する。BillingIssueHandlerも同様に、決済復旧後の遅延BILLING_ISSUEで正常課金中のユーザーに誤通知が飛ぶ。ローカルDBで実際に再現確認済み(UNCANCELLATION→stale CANCELLATIONの順で送るとstatusがcancelledに巻き戻る)。
修正方向: RenewalHandler/ExpirationHandlerと同様のイベントタイムスタンプ比較ガードを追加する。
2. 動画アップロードの無料枠チェックがクライアント申告値を信用している
back/app/controllers/api/v2/media_attachments_controller.rb:31-44(complete_upload)
back/app/services/media_attachments/limit_validator.rb:25-28(valid_video?)
file_size_bytesはR2の実オブジェクトサイズでサーバー側再検証されるが、duration_seconds/width/heightはクライアントから送られた値をそのまま無料枠判定(30秒/480p以下 vs Pro版180秒/1080p)に使っている。実際の動画をffprobe等でサーバー側検証していない。
失敗シナリオ: 無料ユーザー(またはAPIを直接叩く誰か)が長時間・高解像度の動画をアップロードし、完了APIにduration_seconds: 10, height: 480等の偽値を送信すると、無料枠チェックを完全にすり抜けて保存できる。limit_validator.rbのコメントには「まさにこの種のクライアント側詐称を防ぐ意図」と明記されているが、duration_seconds/width/heightだけ対応漏れ。
3. 素振りのPro限定設定(インターバル/バイブ等)がback側で未検証
back/app/controllers/api/v2/shadow_swing_sessions_controller.rb(create)
サーバーはtarget_countとlogged_onしか受け取らず、interval/vibration/sound/voiceは一切送信されない。Pro判定はmobile/app/(shadow-swing)/setup.tsxのhasEntitlement("shadow_swing_custom_interval")等、クライアント側のみで行われている(trendアクションのみback側ガードあり)。
失敗シナリオ: 無料ユーザーでもcounter.tsxへ直接interval=1&vibration=1等のクエリパラメータで遷移すればPro限定機能を無償利用できる。
4. 素振りカウンターが0本セッションでも活動記録・Streakを不正加算
back/app/models/shadow_swing_session.rb:22-40(complete!)
back/app/services/activities/daily_activity_recalculator.rb:51-55, 74-78
complete!はswing_count >= 0しか検証せず、0本で完了しても無条件にPracticeLogを作成/更新する。集計側のpractice_menu_countはamountの値を見ず件数だけをカウントするため、intensity_levelが不正に上がる。
失敗シナリオ: mobileで素振りを開始してすぐ「終了」(count=0で呼び出し可能)を押すだけで、実際は1本も振っていないのに当日が「活動あり」として記録される。連打すればStreakと草のヒートマップを無限に水増しできる。
🟠 データ消失
5. 達成済み目標を削除すると獲得済みバッジも道連れで消える
back/app/models/goal.rb:6(has_many :goal_badges, dependent: :destroy)
back/app/controllers/api/v2/goals_controller.rb:38-41(destroyがis_finalized/is_achievedを見ない)
mobile/app/(goal)/[id].tsx:79-94(削除ボタンが達成済みでも常に表示、警告なし)
バッジは「達成の記念」として恒久保存される想定の機能だが、元の目標を削除するだけで無警告に失われる。season_id等の外部キーはon_delete: :nullifyにしてあるのにgoal_badgesだけ素のdependent: :destroyのまま、という非対称さからも実装漏れと考えられる。
修正方向: is_finalizedな目標の削除を禁止する、またはバッジはgoal_idを残したままdependent: :nullifyにする。
6. 練習メニューセット編集で、バリデーション失敗時に既存アイテムが消える
back/app/controllers/api/v2/menu_sets_controller.rb:31-39, 56-70(update / assign_items)
assign_itemsが既存のmenu_set_itemsをdestroy_allで即座かつ無条件に削除した後で@menu_set.saveを呼んでいる(トランザクション化されていない)。新しいアイテムはsave成功までメモリ上にしか存在しない。
失敗シナリオ: mobile側の編集画面(app/(menu-set)/edit.tsx)で名前のTextInputにmaxLengthが無いため51文字以上を入力可能。back側のname, length: { maximum: 50 }バリデーションで保存が失敗(422)すると、既存アイテムは既に削除済みでAPIは失敗を返すのに、データは永久に失われる。同PR内のPracticeSessions::Upsertは正しくトランザクション化されており、これだけ実装漏れとみられる。
修正方向: assign_items + saveをトランザクションで包む、またはdestroy_allをバリデーション成功後に移す。
7. スケジュールリマインドが、オフライン/ロード中を「予定0件」と誤解釈して端末通知を全消去
mobile/hooks/useSchedules.ts:15(schedules: data ?? [])
mobile/components/schedule/ScheduleReminderSync.tsx:13-22(isLoading/isErrorを見ずに同期実行)
mobile/services/scheduleReminderService.ts:22-31(呼ばれるたびに既存通知を全cancelしてから貼り直す)
失敗シナリオ: 自主練リマインドを複数登録済みのユーザーが、オフライン環境(機内モード等)でアプリを再起動すると、GET /api/v2/schedulesが失敗しschedulesが空配列扱いになる。ScheduleReminderSyncはこれを「予定がゼロになった」と解釈し、既存の全リマインド通知をキャンセルした後、空配列なので何も再登録しない。オンラインに戻すまで通知が消えたままになる。
修正方向: isLoadingまたはisError(かつ一度も成功取得していない)の間はsyncScheduleRemindersの呼び出しをスキップする。
🟡 クラッシュ・機能不全リスク
8. Android AdMob App IDがプレースホルダーのまま
"androidAppId": "REPLACE_WITH_ANDROID_ADMOB_APP_ID"
iosAppIdは実際のAdMob ID形式(ca-app-pub-xxx~xxx)だが、Android側は置き換え忘れの文字列のまま。上書きするapp.config.js等は存在しない。このままprebuildするとAndroidManifestに無効なApplication IDが書き込まれ、Google Mobile Ads SDKが広告ロード時に例外を投げてクラッシュする、または全広告リクエストが失敗する可能性が高い。AdMobコンソールでAndroid用アプリIDを発行し置き換える必要がある。
9. 素振りの保存失敗を隠蔽し、データ消失に気づけない
mobile/app/(shadow-swing)/counter.tsx(finish()内、catchで保存失敗を握りつぶす)
mobile/app/(shadow-swing)/complete.tsx(保存の成否に関わらず「練習記録に保存しました」と表示)
リトライ機構もローカル永続化もなく、通信不安定な環境(グラウンド等)で保存APIが失敗すると、実際に振った本数がサーバーに一切記録されないまま「保存済み」と誤認させる。コード内コメントで既知の技術的負債として認識されているが、UIが失敗を隠す点は実害がある。
🟢 軽微
10. アップロード中断時の孤立レコード・オブジェクトが永久にクリーンアップされない
back/app/models/concerns/plan_limits.rb:117-123(media_attachments_count_this_monthは1時間以上前のpending行を無料枠カウントから除外するが、削除する仕組みがない)
back/app/models/media_attachment.rb:14(MediaAttachmentDeletionJobはon: :destroyでのみ発火)
presign成功・R2へのPUT完了後にアプリがクラッシュ/通信断すると、DBのpendingレコードとR2オブジェクトが永久に残る。セキュリティ上の問題ではなくストレージコストの問題。
その他(実害小、参考記録)
- 目標の
achieved_atが、ユーザーが達成ボタンを押した日時ではなくFinalizeGoalsJob実行時刻で上書きされる(back/app/jobs/finalize_goals_job.rb:14-19)。現状V2::GoalSerializerがachieved_atを返しておらず表示箇所がないため実害小
- Pro解約後もスケジュールのカスタム通知文(
notification_message)がDBに残ったまま送信され続ける可能性(back/app/controllers/api/v2/schedules_controller.rb:59はリクエストパラメータのみガードしており、既存レコードは回収されない)
背景
Pro課金機能+広告リリース統合ブランチ
release/pro-202605(back PR #216 / mobile PR #80)を、ドメインごとに分けた並行レビューで監査した結果見つかったバグ一覧です。対応は後日行うため、まずは記録として残します。back PR #216で既に見つけて修正済みの2件(
PlanCatalogのproduct_id不一致、development環境のngrok Host Authorization、back#333)と、mobile側のpro_featuresフラグ関連のPaywallModalデッドエンド(release/pro-202605へ直接commit済み)はこのリストには含みません。🔴 マネタイズ・データ整合性に直結
1. Webhook順序逆転ガードの欠如(CancellationHandler / BillingIssueHandler / UncancellationHandler)
back/app/services/revenue_cat/handlers/cancellation_handler.rb:5-16back/app/services/revenue_cat/handlers/billing_issue_handler.rb:5-16back/app/services/revenue_cat/handlers/uncancellation_handler.rb:5-13RenewalHandler/ExpirationHandlerはRevenueCat Webhookの順序逆転に備えたタイムスタンプ比較ガードを実装済みだが、上記3ハンドラは無条件に状態を上書きする。失敗シナリオ: ユーザーが解約→即座に解約撤回した場合、
CANCELLATIONとUNCANCELLATIONの到達順序は保証されない。UNCANCELLATION処理後に遅延していたCANCELLATIONが届くと、有効なサブスクリプションを無条件にcancelledへ巻き戻し、「解約手続きを受け付けました」という誤った通知メールを送信する。BillingIssueHandlerも同様に、決済復旧後の遅延BILLING_ISSUEで正常課金中のユーザーに誤通知が飛ぶ。ローカルDBで実際に再現確認済み(UNCANCELLATION→staleCANCELLATIONの順で送るとstatusがcancelledに巻き戻る)。修正方向:
RenewalHandler/ExpirationHandlerと同様のイベントタイムスタンプ比較ガードを追加する。2. 動画アップロードの無料枠チェックがクライアント申告値を信用している
back/app/controllers/api/v2/media_attachments_controller.rb:31-44(complete_upload)back/app/services/media_attachments/limit_validator.rb:25-28(valid_video?)file_size_bytesはR2の実オブジェクトサイズでサーバー側再検証されるが、duration_seconds/width/heightはクライアントから送られた値をそのまま無料枠判定(30秒/480p以下 vs Pro版180秒/1080p)に使っている。実際の動画をffprobe等でサーバー側検証していない。失敗シナリオ: 無料ユーザー(またはAPIを直接叩く誰か)が長時間・高解像度の動画をアップロードし、完了APIに
duration_seconds: 10, height: 480等の偽値を送信すると、無料枠チェックを完全にすり抜けて保存できる。limit_validator.rbのコメントには「まさにこの種のクライアント側詐称を防ぐ意図」と明記されているが、duration_seconds/width/heightだけ対応漏れ。3. 素振りのPro限定設定(インターバル/バイブ等)がback側で未検証
back/app/controllers/api/v2/shadow_swing_sessions_controller.rb(create)サーバーは
target_countとlogged_onしか受け取らず、interval/vibration/sound/voiceは一切送信されない。Pro判定はmobile/app/(shadow-swing)/setup.tsxのhasEntitlement("shadow_swing_custom_interval")等、クライアント側のみで行われている(trendアクションのみback側ガードあり)。失敗シナリオ: 無料ユーザーでも
counter.tsxへ直接interval=1&vibration=1等のクエリパラメータで遷移すればPro限定機能を無償利用できる。4. 素振りカウンターが0本セッションでも活動記録・Streakを不正加算
back/app/models/shadow_swing_session.rb:22-40(complete!)back/app/services/activities/daily_activity_recalculator.rb:51-55, 74-78complete!はswing_count >= 0しか検証せず、0本で完了しても無条件にPracticeLogを作成/更新する。集計側のpractice_menu_countはamountの値を見ず件数だけをカウントするため、intensity_levelが不正に上がる。失敗シナリオ: mobileで素振りを開始してすぐ「終了」(count=0で呼び出し可能)を押すだけで、実際は1本も振っていないのに当日が「活動あり」として記録される。連打すればStreakと草のヒートマップを無限に水増しできる。
🟠 データ消失
5. 達成済み目標を削除すると獲得済みバッジも道連れで消える
back/app/models/goal.rb:6(has_many :goal_badges, dependent: :destroy)back/app/controllers/api/v2/goals_controller.rb:38-41(destroyがis_finalized/is_achievedを見ない)mobile/app/(goal)/[id].tsx:79-94(削除ボタンが達成済みでも常に表示、警告なし)バッジは「達成の記念」として恒久保存される想定の機能だが、元の目標を削除するだけで無警告に失われる。
season_id等の外部キーはon_delete: :nullifyにしてあるのにgoal_badgesだけ素のdependent: :destroyのまま、という非対称さからも実装漏れと考えられる。修正方向:
is_finalizedな目標の削除を禁止する、またはバッジはgoal_idを残したままdependent: :nullifyにする。6. 練習メニューセット編集で、バリデーション失敗時に既存アイテムが消える
back/app/controllers/api/v2/menu_sets_controller.rb:31-39, 56-70(update/assign_items)assign_itemsが既存のmenu_set_itemsをdestroy_allで即座かつ無条件に削除した後で@menu_set.saveを呼んでいる(トランザクション化されていない)。新しいアイテムはsave成功までメモリ上にしか存在しない。失敗シナリオ: mobile側の編集画面(
app/(menu-set)/edit.tsx)で名前のTextInputにmaxLengthが無いため51文字以上を入力可能。back側のname, length: { maximum: 50 }バリデーションで保存が失敗(422)すると、既存アイテムは既に削除済みでAPIは失敗を返すのに、データは永久に失われる。同PR内のPracticeSessions::Upsertは正しくトランザクション化されており、これだけ実装漏れとみられる。修正方向:
assign_items+saveをトランザクションで包む、またはdestroy_allをバリデーション成功後に移す。7. スケジュールリマインドが、オフライン/ロード中を「予定0件」と誤解釈して端末通知を全消去
mobile/hooks/useSchedules.ts:15(schedules: data ?? [])mobile/components/schedule/ScheduleReminderSync.tsx:13-22(isLoading/isErrorを見ずに同期実行)mobile/services/scheduleReminderService.ts:22-31(呼ばれるたびに既存通知を全cancelしてから貼り直す)失敗シナリオ: 自主練リマインドを複数登録済みのユーザーが、オフライン環境(機内モード等)でアプリを再起動すると、
GET /api/v2/schedulesが失敗しschedulesが空配列扱いになる。ScheduleReminderSyncはこれを「予定がゼロになった」と解釈し、既存の全リマインド通知をキャンセルした後、空配列なので何も再登録しない。オンラインに戻すまで通知が消えたままになる。修正方向:
isLoadingまたはisError(かつ一度も成功取得していない)の間はsyncScheduleRemindersの呼び出しをスキップする。🟡 クラッシュ・機能不全リスク
8. Android AdMob App IDがプレースホルダーのまま
mobile/app.json:138iosAppIdは実際のAdMob ID形式(ca-app-pub-xxx~xxx)だが、Android側は置き換え忘れの文字列のまま。上書きするapp.config.js等は存在しない。このままprebuildするとAndroidManifestに無効なApplication IDが書き込まれ、Google Mobile Ads SDKが広告ロード時に例外を投げてクラッシュする、または全広告リクエストが失敗する可能性が高い。AdMobコンソールでAndroid用アプリIDを発行し置き換える必要がある。9. 素振りの保存失敗を隠蔽し、データ消失に気づけない
mobile/app/(shadow-swing)/counter.tsx(finish()内、catchで保存失敗を握りつぶす)mobile/app/(shadow-swing)/complete.tsx(保存の成否に関わらず「練習記録に保存しました」と表示)リトライ機構もローカル永続化もなく、通信不安定な環境(グラウンド等)で保存APIが失敗すると、実際に振った本数がサーバーに一切記録されないまま「保存済み」と誤認させる。コード内コメントで既知の技術的負債として認識されているが、UIが失敗を隠す点は実害がある。
🟢 軽微
10. アップロード中断時の孤立レコード・オブジェクトが永久にクリーンアップされない
back/app/models/concerns/plan_limits.rb:117-123(media_attachments_count_this_monthは1時間以上前のpending行を無料枠カウントから除外するが、削除する仕組みがない)back/app/models/media_attachment.rb:14(MediaAttachmentDeletionJobはon: :destroyでのみ発火)presign成功・R2へのPUT完了後にアプリがクラッシュ/通信断すると、DBの
pendingレコードとR2オブジェクトが永久に残る。セキュリティ上の問題ではなくストレージコストの問題。その他(実害小、参考記録)
achieved_atが、ユーザーが達成ボタンを押した日時ではなくFinalizeGoalsJob実行時刻で上書きされる(back/app/jobs/finalize_goals_job.rb:14-19)。現状V2::GoalSerializerがachieved_atを返しておらず表示箇所がないため実害小notification_message)がDBに残ったまま送信され続ける可能性(back/app/controllers/api/v2/schedules_controller.rb:59はリクエストパラメータのみガードしており、既存レコードは回収されない)