# バグハント レポート — 6コアフロー 行レベル精読（2026-06-29）

**作成ブランチ**: `claude/content-verification-undx76`（main未マージ）
**位置づけ**: 構造監査（`docs/audit-degradation-techdebt-20260629.md`）の**補完**。コアフローを実コードで行レベル精読し、**論理バグ・境界値・例外/null未処理・状態取り違え・競合**を検出。各指摘は `file:line` 付き。
**注意**: 一部は実外部仕様に依存する **[要仕様確認]**（特に決済webhookのフィールド名）。修正前に該当箇所を確認のこと。

---

## 🔴 最優先（実害が大きい順・TOP5）

1. **決済webhookが未入金でも"成功"確定** — `isSuccessPayload` が想定外キー/値で末尾 `return true`。GETで副作用（CSRF除外）のため、token既知なら未入金paid化＋会員付与まで走る。→ `TelecomCreditWebhookService.php:127-138`【P1】
2. **金額不一致でも決済確定** — 不一致をログのみで継続しmarkPaid。→ `TelecomCreditWebhookService.php:75-82`【P2】
3. **メール重複送信** — `scenario_mail_deliveries` にユニーク制約なし＋リマインダが`reserved`を除外せず、cron/worker重複や送信中クラッシュで実メール二重送信。→ `MailDeliveryStateService.php:90-108`／`ScenarioReminderDeliveryService.php:113-121,142-197`【P3】
4. **会員サイトslug非グローバル一意で取り違え** — `unique(['user_id','slug'])`、解決は`user_id`非絞り＋`status`未確認。別オーナーの同名サイト/固定ページが表示され得る。→ baseline migration `:379`／`EnsureMemberBelongsToSlugSite.php:16`【P4】
5. **sequential受講が永久ロック** — `orderedLessons()`がトラック`status='published'`を絞らず、非公開トラックの未完了レッスンが順次判定に混入し後続が解放不能。→ `BundleCourse.php:80`／`BundleCourseAccessService.php:244-267`【P5】

> 構造監査の H7（冪等性）と重なる項目は本バグハントで**具体化・裏付け**された。P1/P2（決済成功判定）と取込の「背景画像取り違え」は本バグハントの新規検出。

---

## 1. 決済フロー
| 重大度 | 場所 | 内容 | 修正案 |
|---|---|---|---|
| 高 | `TelecomCreditWebhookService.php:127-138` | **[要仕様確認]** `isSuccessPayload` が既定 `return true`。未知キー/値で成功扱い→未入金paid | 既知キーで明示的に成功と判定できた時のみtrue、不明はNG |
| 高 | `TelecomCreditWebhookService.php:75-82` | 金額不一致でもログのみで `markPaid` 継続。`money<=0`照合スキップ | 不一致は`failed`で中止、`money<=0`も不正扱い |
| 中 | `:42-51` | `clientip`空で照合スキップ | clientip必須化 or `$request->ip()`照合 |
| 中 | `:69-73` | 失敗通知で無条件`failed`上書き→`paid`が降格 | `pending`時のみ`failed`に。`paid`維持 |
| 中 | `:117-119` | cancel時に付与/シナリオ登録の**巻き戻しなし** | cancelでgrant解除処理を呼ぶ |
| 中 | `PublicProductCheckoutController.php:80-88` | `thanks`がuidのみ`firstOrFail`、所有権/状態なし→個人情報露出余地 | view項目最小化 or status条件 |
| 低 | `OrderPostPaymentRedirectService.php:22-27` | redirect先がFILTER_VALIDATE_URLのみ＝**オープンリダイレクト** | スキーム/ホスト許可リスト |
| 低 | `PublicProductCheckoutController.php:84-92` | store冪等キーなし→二重送信でpending重複 | 既存pending再利用/PRG |

> 補足: webhookは `GET/POST`両対応＋CSRF除外（`web-public.php:21-26`）。**GETで副作用**が起きる設計が上記の悪用容易性を上げる。`markPaid`の成功側は冪等ガードあり（失敗/cancel側に無いのがM2）。

## 2. 付与・会員アクセス
| 重大度 | 場所 | 内容 | 修正案 |
|---|---|---|---|
| 高 | `BundleCourse.php:80`(`orderedLessons`)／`BundleCourseAccessService.php:244-267` | 非公開トラック混入で **sequential後続が永久ロック** | joinに`courses.status='published'`追加 |
| 高 | `BundleCourseAccessService.php:121-125` | **[要確認]** Carbon版差で`diffInDays`符号/絶対値→「あと○日」表示崩れ | 引数順/`(int)`で正の差に固定 |
| 中 | `MemberLessonCompletionService.php:45-64` | `updateOrCreate`キーに`bundle_course_id`欠落→コース跨ぎで完了source取り違え/上書き | キーに`bundle_course_id`追加 |
| 中 | `BundleCourseAccessService.php:166-176` | `at_datetime`開始が`startOfDay`で0時に丸まり時刻指定無視 | parseのまま比較 or 仕様明記 |
| 中 | `MemberPortalController.php:144-148` | progressフォールバックに`completed`キー欠落→Undefined key | fallbackに`'completed'=>0` |
| 低 | `OrderPurchaseFulfillmentService.php:55-70` | `buyer_name='0'`で`?:`がnull化し名前落ち | `?? null`/`filled()`統一 |

## 3. 配信エンジン
| 重大度 | 場所 | 内容 | 修正案 |
|---|---|---|---|
| 重大 | `MailDeliveryStateService.php:90-108` | `updateOrCreate`キーにユニーク制約なし→競合/cron重複で**予約行重複**・状態残留 | `unique(source_type,subscriber_id,step/broadcast id)` |
| 重大 | `ScenarioReminderDeliveryService.php:113-121,142-197` | `dueBookings`が`reserved`を除外せず→送信中クラッシュ/再試行で**リマインド重複送信** | 除外に`reserved`追加 or 条件付きUPDATE+lock |
| 高 | `StepMailDispatchService.php:308-321` | `registration_plus_days` 当日指定で算出時刻が登録時刻より前だと**即時送信** | `delay_days=0`かつ過去なら翌日 or `max(base,at)` |
| 高 | `StepMailDispatchService.php:84-118` | 条件スキップ行(`skipped_condition`)が占有→後で条件成立しても**永久未送信** | スキップ行を非占有管理に |
| 中 | `:330-340` | 既存読者ポリシーでTZ混在比較→境界誤判定 | 同一TZに揃える |
| 中 | `StepMailMergeService.php:68-81` | `str_replace`が大小区別→`%Key%`未置換で生トークン残留 | key大小正規化 |
| 中 | `StepMailDispatchService.php:228-249` | `deactivateCurrent`を最終以外で設定すると後続が永久停止 | 最終ステップのみ可/UI警告 |
| 低 | `MailDeliveryStateService.php:119-139` | mark時に`queue_created_at`を毎回上書き→初回投入時刻消失 | mark系で更新値から外す |
| 低 | `ScenarioReminderDeliveryService.php:91` | 当日固定時刻(`days=0`)が`$days>0`不成立で無視 | `>=0`に緩和 |

## 4. 契約フロー
| 重大度 | 場所 | 内容 | 修正案 |
|---|---|---|---|
| 高 | `ContractInstanceController.php:178`／`ContractDocumentNumberGenerator.php:13-24` | 文書番号採番にtx/lockなし→並行作成で**番号重複/500** | tx+`lockForUpdate` or UNIQUE+リトライ |
| 高 | `ContractInstanceController.php:267-272` | `file_name`更新が新規アップロード保存より前→新規のみ時に**null上書き** | アップロードを先に/後で再計算 |
| 中 | `:822-826`(`send`) | 署名者0でもsend可→`pending_*`遷移後`isEditable=false`で**詰む** | send冒頭に署名者0/文書0チェック |
| 中 | `:459-470` | recipients.contact/approvers.emailに**email検証なし** | `email`ルール追加 |
| 中 | `:436,460,737` | `sign_order`一意性/連番なし→順次署名破綻 | sync時に1..N正規化 |
| 中 | `:1029-1030` | `pos_x/y`のみクランプ、`+width/height`でページ外 | `min(100-size,max(0,pos))` |
| 低 | `:847-852`(`updateStatus`) | 任意status無条件遷移、completed→draftで`completed_at`残留 | 許可遷移マップ |
| 低 | `:983-1038`／`:902-909` | settings/document_actions JSON無検証保存 | 許可キー抽出 |

> 誤削除・所有権は**問題なし**を確認: signers/recipients/approvers syncはインスタンススコープで他行を消さない。`authorizeInstance`の所有権検証は全アクションで漏れなし（**ただしオペレーター権限スコープは未確認**＝`auth()->id()`のみ）。

## 5. 認可・会員IDOR・イベント
| 重大度 | 場所 | 内容 | 修正案 |
|---|---|---|---|
| 高 | baseline migration `:379`／`EnsureMemberBelongsToSlugSite.php:16` 他 | slug `unique(['user_id','slug'])`＝**非グローバル一意**、解決はuser_id非絞り→別オーナーサイト取り違え | slugをグローバル一意化 or 作成時に重複拒否 |
| 中 | `EventBookingRecorder.php:30-41` | 重複申込チェックがtx外→**TOCTOU**で並行二重申込 | チェックをtx内へ移動 |
| 中 | `OperatorCompanySelectController.php:78-109` | `switch`が`previous()`着地→切替先で非許可URL着地 | intendedを`firstAllowedUrl`固定 |
| 中 | `EnsureMemberSiteGuestPreview.php:16` | プレビューsite解決で`status`未確認 | `where('status','active')`追加 |
| 低 | `PublicPublishedEventController.php:78` | `schedule_id`をevent非スコープで`findOrFail`（recorderが再検証で防御） | `where('event_id',...)`でスコープ |

> 会員レッスンIDORは**堅牢**を確認: `authorizeCourse`で`site_id`一致＋grant＋track/lesson親子照合。数値ID総当たりで他サイト/他会員へ到達不可。定員境界(`>=capacity`)はoff-by-oneなし。

## 6. UTAGE/URL取り込み
| 重大度 | 場所 | 内容 | 修正案 |
|---|---|---|---|
| 高 | `UtagePageImportService.php:88-92`（meta生成`:2987-3024`） | section視覚メタが**DOM順index対応**で、popup除外条件不一致時に**背景画像が別セクションに付く** | 安定キー(id属性)で突合 |
| 中 | `:1644` vs `:1657` | px→%換算の分母が未クランプ（CSSと非対称）、0で除算余地 | 1644もクランプ済分母 |
| 中 | `:1734`／`:1624` | 修復時に列コンテナ幅が常に均等分割（`col_width`無視）→**再取り込みで非対称カラム画像幅がデグレ** | 保存済`col_ratios`を分母に |
| 中 | `:2479` | `mapUtageVideo`がprovider`youtube`固定→Vimeo/mp4再生不可 | URL/video_typeでprovider判定 |
| 低 | `:2611-2618` | `custom_css`をsubstr(バイト)切詰め→UTF-8破断 | `mb_strcut` |
| 低 | `FunnelPageImportImageMirrorService.php:33,46` | bg画像でurlCacheリセット→同一画像の重複DL/MediaFile | キャッシュ継続使用 |

> 抽出ロジック（ブレースバランス・v-bind/INITIAL_STATE）、`section_width`正規化、`guessUtageColRatios`、width修復、隣接見出し統合などは**テスト有で健全**を確認（誤検出回避のため明記）。高/中の上位2件（背景画像取り違え・列比率均等化）は**テスト未カバー**。

---

## Cursor 再チェック用プロンプト

```
docs/bug-hunt-core-flows-20260629.md の各バグ(P1..P5 と各フローの指摘)を、実コードを開いて検証してください。各件:
1) 現コードで再現するか（file:line引用）
2) [要仕様確認]はテレコム決済の実フィールド名/成功コードを要確認と明記
3) 真なら最小diffの修正、偽なら誤検出の根拠
特に最優先:
- P1: TelecomCreditWebhookService::isSuccessPayload の既定戻り値（不明ペイロードでpaidになるか）
- P2: 金額不一致時に処理継続してmarkPaidするか
- P3: scenario_mail_deliveries のunique制約有無、リマインダのreserved除外有無
- P4: membership_sites.slug のunique定義、slug解決でuser_id/status絞りの有無
- P5: BundleCourse::orderedLessons がcourses.status='published'を絞るか
修正は1件1コミット、docs/funnel-spec.md と §11 も更新。
```

*本書は実コード精読に基づく。[要仕様確認] はテレコム決済の外部仕様確定後に最終判断。*
