Skip to content

[feat] 管理画面をモバイル対応しエラー表示・フォーム検証・QRダウンロードを改善 - #3

Merged
haruto-kamijo merged 13 commits into
release/api-hardeningfrom
release/web-ux
Jul 28, 2026
Merged

[feat] 管理画面をモバイル対応しエラー表示・フォーム検証・QRダウンロードを改善#3
haruto-kamijo merged 13 commits into
release/api-hardeningfrom
release/web-ux

Conversation

@haruto-kamijo

Copy link
Copy Markdown
Collaborator

What & why

現場ではスタッフが主にスマホで使う想定なので、そこで詰まる箇所を潰しました。

モバイルで最大の障害

サイドバーが無条件に w-60 で描画され、375pxの端末では240px = 画面の64%を占有し、コンテンツに約135pxしか残っていませんでした。丁寧に作られた「モバイルカード」レイアウトは、実際には与えられていない幅に押し込められていた状態です。

ハンバーガードロワーではなくトップバーにしました。ナビの行き先は2つで、うち /links/create は権限ゲート付きかつ既にページヘッダの主要CTAとして存在するため、実際の中身はブランド・言語・ログアウトの3つ。ドロワーは冗長なリンク1つを出すためにオーバーレイ・フォーカストラップ・スクロールロック・開閉状態を足すことになります。

セッション切れが行き止まりだった

authFetch は401でトークンを消した後レスポンスをそのまま返し、各ページがそれを HTTP 401 という文字列で赤バナーに出すだけでした。サイドバーは残り、利用者はそのページに留まり、以降のクリックはすべて同じように失敗し、手動リロード以外に脱出手段がない。8時間のトークンと複数日の学祭では確実に起きます。

/login?reason=session_expired&next=... へ誘導し、再ログイン後に元の画面へ戻すようにしました。誤発火のガードが必須でした——/auth/login(パスワード間違い)と /auth/me(未ログインの訪問者に毎回走る)の401は「セッション切れ」ではないので除外しています。

無言で失敗していたもの

  • 保存が失敗したのに誰も知らされない: 保存中にキャンセルするとフォームが閉じ、失敗が「もう描画されていないフォームの formError」に書かれていました。トーストにすることで、メッセージがコンポーネントのアンマウントを生き延びます。
  • 空白のみ入力でボタンが無反応: required は空白1文字で満たされ、その後 handleSubmit が trim して裸の return で抜けていました。空白を1つ入れて保存を押すと本当に何も起きない(メッセージもスピナーもなし)。 - CSVダウンロードが落ちない: アンカーがdocumentに .click() 直後に同期実行されていました。res.okはtrueなのでエラーも出ないまま永久に何も起きない。
  • 削除の実行中状態がない: ダブルタップでDELETEが2回飛び、2回目の404が「削除成功後のエラー」とし- 空の最終ページで詰む: 11件でページ2の唯一の行をりページングが nullを返すため、「ありません」と表示されたままページ1に戻る手段が消える(QRページはページングが非空分岐の内側にあり更に悪い)。
  • iOSプライベートブラウズで白画面: detectDefaultLocale が useState の初期化子内で localStorage を触っていて、そこがthrowするとアプリ全体がマウン を共用しプライベートタブで開くこともあるので、管理画面を丸ごと失う現実的な経路でした。

QRコードのダウンロード

物ごとに1つ生成する運用なので、出力の識別性が実害でした。

  • ファイル名を qr-.png から ポスター12_ポスター_1F掲示板-12.png に。
  • PNGの下部に名前・媒体・場所を焼き込み。印刷後に紙の上でどのポスター用か分かるのはキャプションだけです。
  • data: URL → blob URL。iOS Safari では data: URLり、ダイアログの状態を壊します。
  • 一覧にサムネイルを追加(同じ見た目の行が並ぶ中で 速の手段)。

削除確認

window.confirm を専用ダイアログに置換し、対象名を明示しました。プロジェクト削除はQRコードとアクセスログにカスケードする不可逆操作で、スマホでは編集と削除のボタンが約8px・高さ28pxで隣接していました。Chromeは連続ダイアログを抑制した後 confirm()
が黙ってfalseを返すため、削除が無反応になる問題も

その他

  • タップターゲットを44px以上に(ページネーションのシェブロンが主要ナビゲーションなのに28pxでした)。
  • 日付がアプリのロケールを無視していた(引数なしの toLocaleDateString())ので formatDateTime に統一。ja のヘッダは「作成日時」なのに時刻が落ちていた点も直しました。
  • QRダイアログにフォーカストラップ・Esc・スクロールロック・フォーカス復帰を追加。従来は onKeyDown={(e) => e.stopPropagation()} がキーイベントの伝播を能動的に止めていたため、上位にEscapeハンドラを足しても発火し得ませんでした。
  • 空状態を !error でゲート(取得失敗時にエラーと「 えたように見えていました)。
  • vitest を導入(23本)。目視で最も確認しにくく無言で壊れるものに絞っています。特に safeStorage は手作業では検証不能です。
  • k6の負荷テスト基盤(packages/api/loadtest/)。すべてローカルで実際に走らせて確認済み。

How this was tested

  • pnpm lint
  • pnpm typecheck
  • pnpm test(23 passed)
  • pnpm build
  • ヘッドレスChrome(375px / 1280px、ja-JP)で実機相当の検証
  • サイドバーが375pxで非表示・1280pxで表示、横スクロール0px、44px未満のボタン0個
  • セッション切れ(不正トークン)→ /login へ遷移、生
  • 誤パスワード → 「パスワードが正しくありません」(セッション切れと誤判定しない)
  • 名前重複 → 日本語メッセージ + name フィールドが aria-invalid="true"
  • 空白のみ入力 → 「この項目は必須です。」
  • 場所を空にした作成 → 成功トースト
  • 削除確認が「ポスター99」と名前を明示
  • 日付が 2026年7月25日(US形式は出ない)
  • QRダイアログ: フォーカスが中に入り、Escで閉じる
  • ダウンロード: ポスター12_ポスター_1F掲示板-12.png、704x788(縦長 = キャプション込み)
  • サムネイル10件、媒体/場所のラベル表示、aria-current と前後ページの aria-label
  • 未捕捉のページエラー0件

未検証(実機が必要)

  • 実機 iOS Safari: プライベートブラウズで白画面にURLではなくblobで保存されるか。どちらもエミュレータでは再現できません。
  • ダウンロードしたPNGを実際に印刷してスキャンする確認。

haruto-kamijo and others added 13 commits July 25, 2026 17:30
4画面に個別パッチを当てる代わりに、後続の修正が乗る共通基盤を先に作る。

## lib/storage.ts — safeStorage
Safari のプライベートブラウズやストレージが無効な環境では localStorage への
アクセス(getter含む)自体が例外を投げる。それが致命的になる箇所が2つあった:
トークンの読み書きと、detectDefaultLocale が useState の初期化子内で
呼ばれている箇所——後者は throw するとアプリ全体がマウント時に白画面になる。
現場ではスマホを共用しプライベートタブで開くこともあるので、管理画面を丸ごと
失う現実的な経路だった。失敗時はメモリにフォールバックし、タブの寿命の間は
セッションと言語切替が動く(永続化されないだけ)。

## components/ToastProvider.tsx
単なる4つのインラインバナーのDRY化ではない。**メッセージを出した
コンポーネントがアンマウントしても生き残る唯一の構造**で、これがアプリ最悪の
バグの根本原因への対処になる: QRコード編集の保存中にキャンセルするとフォームが
閉じ、失敗が「もう描画されていないフォームの formError」に書かれていた。
保存が失敗したのに利用者は何も知らされない状態だった。

加えて、これまで皆無だった成功フィードバックを供給する。作成・編集・削除は
すべて無言で完了しており、毎回「今の操作は成功したのか?」を疑う必要があった。

表示位置も意図的で、モバイルでは下端中央にした。従来のバナーはページ最上部に
出るため、スマホで9行目を削除して失敗すると画面外にスクロールしていて
「何も起きなかった」と見分けがつかなかった。errorは role=alert、それ以外は
role=status で読み上げる(従来はどのエラーも読み上げられなかった)。

## components/Modal.tsx
置き換え対象の QRDialog は role="dialog" aria-modal="true" を持ちながら、
フォーカストラップ・オートフォーカス・Escape・フォーカス復帰・スクロール
ロックがすべて無く、Tabがオーバーレイ背後のページに抜けていた。さらにパネルに
onKeyDown={(e) => e.stopPropagation()} が付いていて**キーイベントの伝播を
能動的に止めていた**ため、上位にEscapeハンドラを足しても発火し得なかった。
キーハンドラはキャプチャ段で登録し、子がpropagationを止めてもEscapeが死なない
ようにしている。

## components/ConfirmDialog.tsx
window.confirm の3つの実害への対処: (1) どの項目を削除するのか言わない
(プロジェクト削除はQRコードとアクセスログにカスケードし、スマホでは編集と削除の
ボタンが約8px・高さ28pxで隣接している)、(2) Chromeが連続ダイアログを抑制した
後は confirm() が黙ってfalseを返し削除が無反応になる、(3) 進行状態を出せない
ので実行中に無効化できず、ダブルタップでDELETEが2回飛び2回目の404が
「削除成功後のエラー」として表示されていた。

## lib/styles.ts
コンポーネント化ではなくクラス名定数にした。4画面のアプリでは全コントロールを
ラップする労力に見合わない。必要だったのは**タップ領域を1箇所で直せること**で、
ページネーションのシェブロンが主要ナビゲーションなのに28pxだった(推奨44px未満)。
入力欄の text-base も意図的で、16px未満だとiOS Safariがフォーカス時に
ビューポートをズームし、以降フォームの操作が狂ったズームのまま続く。

## その他
- ProtectedRoute のハードコード "Loading…" を i18n 化(common.loading は
  既に存在した)。日本語利用者が毎回最初に見る文字列。
- エラー・検証・セッション切れ用の i18n キーを ja/en 両方に追加。アプリが
  到達しうるのに言葉が無かった状態を埋める。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## セッション切れが行き止まりだった問題
authFetch は401でトークンを消した後、レスポンスをそのまま呼び出し元に返して
いた。各ページはそれを `HTTP 401` という文字列にして赤いバナーに出すだけで、
サイドバーは残り、利用者はそのページに留まり、以降のクリックはすべて同じ
ように失敗する。手動リロード以外に脱出手段が無い。8時間のトークンと複数日の
学祭では確実に起きる。

401(ただし後述の例外を除く)で window イベントを発火し、AuthProvider が
トーストを出して /login?reason=session_expired&next=... へ誘導する。next には
作業していたページを入れるので、再ログイン後に元の画面へ戻る。

**誤発火のガードが必須**だった: /auth/login(パスワード間違い)と /auth/me
(未ログインの訪問者に対して毎回走る)の401は「セッション切れ」ではないので、
これらは除外している。

## エラー文言の正規化
lib/api.ts に ApiError(code/fields/meta を持つ)と NetworkError を用意し、
コード→i18nキーの対応表を置いた。APIが文言を決めていたことが翻訳を不可能に
していた——英語の開発者向け文字列が日本語利用者に、ハードコードされた日本語の
409が英語利用者に出ていた。今はサーバーが「何が起きたか」を、クライアントが
「どう読ませるか」を持つ。

## ネットワーク障害とセッション切れの区別
useStaffAuth の `catch { setUser(null) }` は401と `TypeError: Failed to fetch`
を区別できず、Wi-Fiの瞬断やWorkerのコールドスタートで、正常なトークンのまま
説明もなく /login に飛ばされていた。NetworkError を分けて、ネットワーク障害では
再試行画面を出すようにした(ProtectedRoute)。

## LoginPage
- `err.message === 'Invalid password'` の文字列一致を廃止。英語のサーバー
  リテラルに依存していたので、APIの文言を変えると生の英語が漏れ、400の経路は
  既に漏れていた。
- セッション切れの案内を表示。
- next で元のページに戻る。ただし同一オリジンのパスのみ許可し、
  `?next=https://evil.example` がオープンリダイレクトにならないようにした。
- h-screen → min-h-dvh。固定高だとソフトキーボードで縮んだビューポートから
  中央寄せのカードが押し出され、スクロールで到達できず**ログインできなくなる**。
- autoComplete="current-password" を追加(共用スマホでパスワードマネージャが効く)。

残りの3ページは、モバイル対応・フォーム検証・トーストをまとめて1回で書き換える
(3回触るより差分が読みやすいため)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3ページとレイアウトを1回で書き換えた(モバイル対応・フォーム検証・トーストが
同じ箇所に集まるため、3回触るより差分が読みやすい)。

## AppLayout — モバイルの最大の障害
サイドバーが無条件に w-60 で描画され、375pxの端末では240px=画面の64%を占有し、
コンテンツに約135pxしか残っていなかった。スタッフはポスターを貼りながらスマホで
使うので、丁寧に作られた「モバイルカード」レイアウトは実際には与えられていない
幅に押し込められていた。

ハンバーガードロワーではなくトップバーにした。ナビの行き先は2つで、うち
/links/create は権限ゲート付きかつ既にページヘッダの主要CTAとして存在する。
つまり実際の中身はブランド・言語・ログアウトの3つで、44pxのターゲットでも56pxの
バーに収まる。ドロワーは冗長なリンク1つを出すためにオーバーレイ・フォーカス
トラップ・スクロールロック・開閉状態を足すことになる。

同時に h-screen → min-h-dvh(iOSの動的ツールバーによる下端の欠け)、main から
overflow-y-auto を削除(ドキュメント自体がスクロールするので window.scrollTo と
scrollIntoView が正しく動き、pull-to-refreshも戻る)、min-w-0(長いURLがflex行を
破壊しない)。

## hooks/useListQuery.ts — ページングの4つのバグ
両リストページにバイト単位で重複していた処理が、4通りに壊れていた:
(1) 11件でページ2の唯一の行を削除すると totalPages が1になりページング
コンポーネントが null を返すため、「ありません」と表示されたまま**ページ1に
戻る手段が無くなる**(QRページはページングが非空分岐の内側にあり更に悪い)、
(2) `setTotal(total - 1)` が `await fetchData()` の後でサーバーの新しい件数を
古い値由来の数で上書き、(3) AbortControllerも新旧判定も無く、1→3→5と素早く
押すと最後に届いた応答が勝つので選択中のページと中身が食い違う、
(4) アンマウント後の setState。

## フォーム検証
`required` は空白1文字で満たされ、その後 handleSubmit が trim して裸の return で
抜けていた。空白を1つ入れて保存を押すと**本当に何も起きない**——メッセージも
スピナーもフォームの閉じもない。フィールド単位のエラーを出し、最初の不正な
フィールドにフォーカスを移し、onBlur で trim するようにした。送信ボタンは
意図的に有効なままにしている(無効化は同じ行き止まりの別の姿)。

## 削除
window.confirm を ConfirmDialog に置換し、対象名を明示するようにした。
プロジェクト削除はQRコードとアクセスログにカスケードする不可逆操作で、スマホでは
編集と削除が約8px・高さ28pxで隣接していた。実行中の無効化も入れたので、
ダブルタップでDELETEが2回飛んで2回目の404が「削除成功後のエラー」として出る
ことも無くなった。

## QRコードのダウンロード
- ファイル名を `ポスター12_ポスター_1F掲示板-12.png` のように名前ベースに
  (従来は `qr-<uuid>.png` で、物ごとに1つ作る運用ではファイル名が唯一の識別子)。
- PNGの下部に名前・媒体・場所を焼き込む。印刷後に紙の上でどのポスター用か
  分かるのはキャプションだけ。
- data: URL から blob URL へ。iOS Safari では data: URL の download が
  タブ内遷移になりダイアログの状態を壊す。
- 一覧にサムネイルを追加。同じ見た目の行が並ぶ中で、編集・削除の対象を
  確認する最速の手段。

## その他
- QRDialog を Modal に載せ替え(フォーカストラップ・Esc・スクロールロック・
  フォーカス復帰)。medium/location にラベルを付け、モバイルとデスクトップで
  食い違っていたボタンラベルと日付の粒度を統一。
- 日付を formatDateTime でアプリのロケールに合わせた。
- location を任意入力にし、ラベルに「(任意)」を付けた。
- 409 で name フィールドを赤枠にする(従来はどこが衝突したか分からなかった)。
- LanguageSwitcher が16px高だったのを44pxにし、aria-pressed と
  role="group" を追加(選択状態がフォントの太さだけで示されていた)。
- 空状態にアイコンとCTAを追加し、`!error` でゲートした(取得失敗時に
  エラーと「ありません」が並び、データが消えたように見えていた)。

検証: ヘッドレスChrome(375px/1280px, ja-JP)で実機相当の確認を行った。
サイドバーは375pxで非表示・1280pxで表示、横スクロール0px、44px未満のボタン0個、
サムネイル10件、媒体/場所のラベル表示、aria-current と前後ページのaria-label、
QRダイアログはフォーカスが入りEscで閉じる、ダウンロードは
`ポスター12_ポスター_1F掲示板-12.png` で704x788(=キャプション込み)、
名前重複は日本語メッセージ + name の aria-invalid=true、空白のみ入力は
「この項目は必須です。」を表示、場所を空にした作成は成功トースト、
削除確認は「ポスター99」と名前を表示、未捕捉エラー0件。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ページコンポーネントではなく、**目視で最も確認しにくく無言で壊れる**ものに
絞って23本。CIにも pnpm test を追加した(このリポジトリ初の自動テスト)。

- safeStorage: 手作業では検証不能。DevToolsから localStorage.getItem を
  throw させる方法は無く、防いでいるバグ(Safariプライベートブラウズでの
  マウント時白画面)は実機でしか再現しない。スタブが唯一の実証手段。
- describeError: ステータス/コード → i18nキーの対応表。APIのコード名を変えても
  汎用メッセージに落ちるだけで一見正常に見えるため、退行が気づかれない。
  特に「パスワード間違い」と「セッション切れ」が混ざらないことを固定した。
- formatDateTime: ロケールで出力が変わること、作成日時と言いながら時刻を
  落とさないこと、パースできない値が "Invalid Date" として利用者に出ないこと。
- slugForFilename: 日本語が残ること、パス禁止文字と全角スペースの処理、
  空のパートで区切りが残らないこと、ファイル名が空にならないこと。

ページコンポーネントは意図的に対象外。615行/708行のページは重いfetchモックを
要求し、手動チェックリストより信頼できないテストになる。分割後の課題として
CONTRIBUTING.md に記載した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/load-testing.md の計画を実行可能な形にした。**すべてローカルで実際に
走らせて確認済み**(未検証の負荷テストコードは無いより悪いため)。

## シード (loadtest/seed/)
generate.mjs が SQL を吐き、run.mjs が1ファイルずつ流す。D1 の制約への対処:
リテラル値(バインドパラメータ約100件上限の回避)、500行/文、明示トランザクション
なし。加えて実測した2つの落とし穴を織り込んだ——**wrangler dev 起動中は書き込め
ない**(workerd が HashIndex エラー)、**1ファイルに約4MB詰めると同じエラーで
落ちる**(10文ずつに分割)。run.mjs は失敗時にどのファイルまで進んだかを出す。

データは意図的に偏らせている: 1プロジェクトに約60%・その中の1QRに約30%、
3日間の日内変動 + 10分の急峻なスパイク1回、実物のUA(LINEアプリ内ブラウザと
LINEプレビュークローラーを隣に置き、混同してはいけない組を明示)。一様乱数は
インデックスの選択性を過大評価させ p95 が実際より良く見える。プロジェクト数を
25(PAGE_SIZE=10超)にしてあるのは、そうでないとページングのバグも集計を絞る
修正の効果も観測できないため。

run.mjs は --target=remote を拒否する。本番への一括シードは書き込みクォータを
食い、DELETEも書き込みに計上されるので掃除でもう一度食う。

## シナリオ (loadtest/k6/)
S1 持続 / S2 スパイク / S3 管理画面 / S4 CSV / S5 ログイン総当たり /
S6 スモーク(CIゲート) / P1 本番ランプ。

到着率モデル(constant/ramping-arrival-rate)を使うのは、QRスキャンが「遅くなっても
人は同じ頻度でスキャンする」現象だから。VU固定のクローズドモデルはサーバーが
遅くなると自己抑制し、探している劣化を隠す。`redirects: 0` はスキャン経路で必須
(追跡すると遷移先のサイトを測ってしまう)。

## 実測結果
- **S6 スモーク: 21/21 通過。** 302 + no-store、不明IDは404、クローラーは
  OGメタ、**LINEアプリ内ブラウザは302(人間扱い)**、ページング重複なし・
  新しい順、`javascript:` URLは400、CSVのBOM・ボット列・ファイル名。
- **S1: 1,717リクエストに対しAccessLogsが 20,006 → 21,723 = +1,717。完全に1:1。**
  waitUntil への移動でスパイク時に書き込みを落としていないことの実証。
- S3: 2万ログ・25プロジェクトで p95=99ms、ページ1と3の重複なし、limit=500 が
  50に丸められる(注: 計画の50万行ではなく2万行での数値)。
- S4: 12,009行で **TTFB 14〜23ms / 全体 400〜500ms**。TTFBが全体の約4%で、
  ストリーミングが効いていることを示す。

途中で見つけた点: `Transfer-Encoding: chunked` は k6 からアサートできない
(Go の HTTP クライアントがデコード後にヘッダマップから削除する。curl -D では
見える)。**TTFBが全体より大幅に短いこと**という観測可能な性質に置き換えた。
スモークでは意図的な4xxが http_req_failed 30% と表示されて障害に見えるため、
responseCallback で 2xx-4xx を成功扱いにし、5xx のみ失敗とした(判定は checks)。

## CI
smoke ジョブを追加(schema適用 → 500行シード → wrangler dev 起動 →
/healthz を待つ → k6)。**挙動をゲートし数値はゲートしない** — 共有ランナーで
安定した負荷数値は出ず、そうすると1ヶ月でジョブを無効化することになる。
S1〜S5 は意図的にCIに入れていない。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in the D1-failure fallback so the web side can generate QR codes carrying
the `&p=<keyword>` the Worker resolves. PR#3's base is release/api-hardening, so
this merge does not appear in that PR's diff.

# Conflicts:
#	packages/api/package.json
#	pnpm-lock.yaml
APIのフォールバック(前コミット)を実際に使えるようにする。QRコードに
`&p=<キーワード>` を埋め、D1が読めないときWorkerがそのキーワードから転送先を
決められるようにした。

## キーワードの入手経路

プロジェクトに入力欄を追加し、**転送先URLのホストから初期値を提案**する
(`https://www.instagram.com/x` → `instagram`)。手で編集したら以降URLの変更では
上書きしない——この値は印刷物に焼き込まれるので、黙って書き換えるのは提案しないより
悪い。編集画面では既存の値(空も含む)が既に検討済みの選択なので、フォームを開いた
だけでURLが書き換え始めないようにしている。

## データ移行を不要にした

キーワードが空のプロジェクトは、QR生成時に `deriveFallbackKey(destinationUrl)` の
結果を使う。**この変更以前に作ったプロジェクトも、手で編集して回らずにフォール
バックが効く。**

## ASCII限定の理由

日本語キーワードはURLエンコードで1文字9文字になり、シンボルが 57x57 から 61x61 に
育つ。APIのzodと同じ `^[a-z0-9][a-z0-9-]*$` をクライアント側でも検証し、
メッセージはアプリの言語で出す。

## 検証(ヘッドレスChrome、実測)

- instagram のURLを入れると `instagram` が、nutfes.net に変えると `nutfes` が入る。
- **手で `my-choice` にした後にURLを変えても上書きされない。**
- 日本語キーワードは「半角の英小文字・数字・ハイフンのみ...」で拒否される。
- 保存後のQRが `...?id=<uuid>&p=instagram` になる。
- **キーワード未設定のプロジェクトのQRも `&p=instagram`**(ホストから導出)。
- 編集画面を開いてURLを変えても、既存のキーワードは変わらない。
- 未捕捉のページエラー0件。k6スモーク 18/18、単体テスト API 11 + Web 31。

## その他

- README に設定手順と、**D1障害中はスキャンが計上されないこと**、
  **キーワードを変えても印刷済みQRには反映されないこと**を明記。
- ルートの package.json にマージで重複していた `test` キーを削除。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in GET /projects/fallback-destinations so the keyword field can become a
picker driven by the Worker's FALLBACK_DESTINATIONS var.
自由入力だと「設定に無いキーワードを打ち込んでしまい、フォールバックが何も
しない」状態を作れてしまう。しかもそれが発覚するのは**D1が落ちた瞬間**——最悪の
タイミング。選択式にすることで、この失敗が構造的に起きなくなる。

候補は新設の GET /projects/fallback-destinations から取る。Worker の
FALLBACK_DESTINATIONS はバインディングでブラウザから読めないため、APIを経由する
必要がある。結果として **wrangler.jsonc の増減がコード変更なしでそのまま
フォームに反映される**。

## 設計上の判断

- **設定に無い保存済みキーワードは黙って消さない。** 一覧に無い値は「(設定に
  ありません)」の選択肢として残し、警告文を出す。消してしまうと、無関係な項目を
  編集しただけで**既に印刷済みのQRに対応する値が書き換わる**。
- **転送先URLからの自動提案は、設定に実在するキーワードにのみ一致させる。**
  実在しないキーを提案すると、選ばれているように見えて障害時に何もしない。
  一致しなければ「なし」のままにする。
- **一覧の取得に失敗したらテキスト入力に退化する。** この一覧は1つの項目のための
  利便機能でしかないので、APIの瞬断でフォーム全体が使えなくなるのは過剰。
  トーストも出さない(フォームを開くたびに鳴るため)。
- 選択中のキーワードの転送先URLをその場に表示し、「なし」を選んだときは静的な
  FALLBACK_URL を表示する。どこへ飛ぶのかを推測させない。

## 検証(ヘッドレスChrome + wrangler dev、実測)

- 候補が環境変数から反映される(instagram / web の2件 + 「なし」)。
- instagram のURLを入れると instagram が自動選択され、**候補に無いホスト
  (example.org)では「なし」のまま**になる。
- 「web」を選んで保存 → 保存値が `web`、QRが `...&p=web` になる。
- **設定を instagram削除 / shop・booth追加 に変更 → コード変更なしで候補が
  booth, shop, web に変わる。**
- DB障害中に `&p=web` → event ページ、`&p=shop` → shop、**設定から消した
  `&p=instagram` は静的 FALLBACK_URL に落ちる**(壊れない)。
- 設定に無い保存済みキー `retired-key` は編集画面を開いても保持され、警告が出る。
- 未捕捉のページエラー0件。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in the real FALLBACK_DESTINATIONS values so the keyword picker offers
instagram and web.
## Cloudflare のDDoSテスト指針に該当していた

Cloudflare は「プロパティがCloudflareにホストされている」「トラフィックが
Cloudflareを経由する」場合に事前連絡を求めている。**Worker on *.workers.dev は
その両方**で、しかも通常の回避策が構造的に使えない——オリジンが存在しないので
IPを直接叩けず、DNSレコードではないのでグレイクラウドにできず、自分のゾーンでは
ないので IP Access Rules も設定できない。WAFのレート制限が効かないのと同じ理由。

対応として **P1 を 5→200rps から 5→50rps に下げた**(約9,000 → 約3,000件、
D1書込 9% → 3%)。50rps は想定実ピーク(通常5rps、ステージ告知時の最悪値50rps)
そのもので、攻撃の模擬ではなく容量確認として説明がつく。これを大きく超える数字が
必要なら先にSupportチケットを開く、とドキュメントに明記した。

## S2 の主張を実測で訂正

「S2 は D1 が直列化するかエラーを吐くかを答える唯一のシナリオ」と書いていたが
言い過ぎだった。実測すると:

  no 5xx / d1_write_conflicts 0
  http_reqs 6761 (20.6/s)   ← 目標500/sに対して
  dropped_iterations 29791
  http_req_waiting avg=11.9s

http_req_blocked/connecting はマイクロ秒なので接続ではなく、**サーバー側が1
リクエストに11.9秒かかっていた**。飽和していたのはローカルの workerd で、単一
SQLiteファイル+シングルスレッドの構成は本番D1とは別物。S2から言えるのは
「完全に飽和させても書き込み競合と5xxは出ない」までで、本番スループットについては
何も言えない。

この構成では必ず超えるレイテンシ・失敗率のしきい値を外した(常に失敗するテストは
無視されるようになる)。判定は d1_write_conflicts==0 と checks>99% のみ。
gracefulStop も追加——最も遅いリクエストが4分34秒かかり、k6がそれを待って
ハングしていた。

## handleSummary がサマリを丸ごと消していたバグ

3つのシナリオで handleSummary が値を返しており、これは k6 の標準サマリを
**置き換える**ため、全メトリクスが出力されていなかった。最初のS2実行で数字が
一切見えなかったのはこれが原因。teardown に移した。

## loadtest/seed/seed-remote.mjs を追加

本番のシードデータを管理API経由で作り、qrids マニフェストを書き出す。既定の
qrids.json はローカル採番のIDなので、**これなしで本番に向けると404しか測れない**。
SQLを本番に流さないのは、ノートPCから実DBへの書き込み経路を開けないため。

ローカルWorkerに対して検証済み: 引数不足で停止 / 誤パスワードで401 / 二重シード
拒否 / `[loadtest]` 接頭辞の無いものは削除しないガード / cleanupでカスケード削除 /
ローカル用マニフェストの退避。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
対象: trackinglink.nutfes-nutmeg9488.workers.dev(D1 は APAC、接続コロ NRT)。
事前バックアップを取得し、終了後に [loadtest] を全削除して元の状態
(プロジェクト1件・QR2件・ログ9件)に戻したことを確認済み。

## P1 — スキャン経路 5→50rps

5,062リクエスト / 失敗 0.00% / p95 36.9ms / p99 65.5ms / max 498.7ms。
エッジのレート制限(429・1015)はゼロ。必要VU数は30確保に対して11。

**waitUntil の書き込みが 5,062リクエスト → 5,062行で完全に1:1、欠落ゼロ。**
一連の変更で最もリスクの高い箇所だったので、本番で1件も落ちないことを確認できた
のが最大の成果。Zipf分散も設計どおり(1プロジェクトに3,042件=60%)。

## 実測による訂正3件

1. **P1の発行数は約3,000ではなく5,062だった。** ランプ区間を数え落としていた。
   D1書き込みは日次クォータの約5%(掃除の削除分を含めて約10%)。
2. **S5のしきい値が設定値ベースで間違っていた。** 601回中490回が429で111回が通過
   ——約3.7回/秒漏れる。Cloudflareのレート制限バインディングは best-effort かつ
   ロケーション単位なので、設定の「10回/60秒」は上限ではなく目標値。正常に動作
   している実行で `count>500` が失敗したので、実測ベース(75%以上)に直した。
   3.7回/秒は1日約32万回。強いパスワードには意味のある防御だが、弱いパスワードには
   何の役にも立たない——時間を稼ぐだけで、推測可能であることを埋め合わせない。
3. **S3は本番では意味のある負荷になっていない。** ログが約5,100件しかないので、
   集計を絞る修正の効果を測ったことにはならない(それは50万行のローカルで見る)。

## 測れていないこと(明記)

本番D1の高並列時の挙動、リクエスト単位のCPU時間(k6からは見えない)、
access_log_insert_failed の有無。

## その他

- biome の useIgnoreFile はルートの .gitignore しか読まないため、loadtest/.out が
  lint 対象に入っていた。files.ignore に追加。
- **D1のエクスポートは生IPを含むアクセスログが入る**ので backup-*.sql を .gitignore に
  追加した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
本番(P1/S3/S5)とローカル(S1/S2/S4/S6)の結果、そこから分かったこと、
測れていないこと、リリース前の推奨アクションをまとめる。

スループットは制約ではなく、実質の上限は D1 の書き込みクォータ
100,000 行/日であることを明記した。

load-testing.md の §5.5 は数値を二重に持たないようレポートへの
リンクに置き換える。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@haruto-kamijo
haruto-kamijo merged commit c8b3ec9 into release/api-hardening Jul 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant