Skip to content

[feat] QRコードの短縮リンクとフォールバック検証の見直し - #6

Open
haruto-kamijo wants to merge 5 commits into
developfrom
feature/qr-short-code-and-fallback-validation
Open

[feat] QRコードの短縮リンクとフォールバック検証の見直し#6
haruto-kamijo wants to merge 5 commits into
developfrom
feature/qr-short-code-and-fallback-validation

Conversation

@haruto-kamijo

Copy link
Copy Markdown
Collaborator

概要

QRに焼き込むURLを短くし、フォールバック用キーワードの検証方式を変えました。

コミット 内容
6a859fc [chore] 一時的なgit worktreeをbiomeの検査対象から外す
1481df9 [feat] QRコードに短縮コードを追加し、印刷するURLを短くする
f9ca912 [fix] フォールバック用キーワードの検証を文字種から設定への登録有無に変える
9d68ab9 [feat] 転送先URLにコピーボタンを付ける
820ab8b [fix] FALLBACK_DESTINATIONS のキーを小文字に統一し、実際の転送先を反映する

22ファイル / +1,291 −131。テストは 65 → 99件

短縮コード(1481df9)

QRのURLが 103文字 / 57×57モジュールあり、印刷時に読み取りづらい状態でした。7桁の短縮コードを併設して 74文字 / 49×49 にします(実測)。

主キーは差し替えず short_code 列を追加しました。 QRCodes.idAccessLogs.qr_id の外部キー参照先で、スキーマにも「印刷されたURLに焼き込まれるので永久に安定していなければならない」と明記されています。既に刷ったポスターのUUID形式は今後も解決し続けます。

スキャン経路は id = ? OR short_code = ?1クエリで両形式を受けます。形状判定(36桁ハイフン区切りならUUID)は採りません — idは任意のTEXTで、負荷テストのシーダーは lt-qr-001-001 形式を使うため壊れます。2クエリにするとアプリで最も熱い経路のサブリクエストが倍になります。両列とも索引付きなので索引探索2回で済みます。

文字集合は 23456789abcdefghijkmnpqrstuvwxyz0/1/l/o を除いて読み間違いを防ぎ、小文字のみにしたのは SQLite の TEXT 比較が既定で大文字小文字を区別するためです。ちょうど32文字なので byte & 31 で偏りなく写像でき、1文字減らすと生成器に偏りが出ます。URL安全な文字だけなので、qrTargetUrl が id を生で補間している(パーセントエンコードしない)前提を保てます。

既存行は scripts/backfill-short-codes.mjs で割り当てます。マイグレーションとバックフィルは対で必要です。コードだけ先に出すと、short_code 列が無い状態のSQLエラーがフォールバック経路に吸収され、リダイレクトは成功したまま静かにアクセスログが欠けます。WHERE short_code IS NULL に限定するので二度実行しても安全です。

スキャン記録の実バグ修正(1481df9 に含む)

アクセスログの書き込みで、スキャンされた値ではなく行自身のidを使うよう直しました。

qrId: id,          // ← 短縮コードだと外部キー違反
qrId: target.id,   // ← 修正後

AccessLogs.qr_idQRCodes(id) への外部キーです。この書き込みは waitUntil でレスポンス経路の外にあるため、リダイレクトは成功したまま短縮コード経由のスキャンだけが黙って記録されない状態になっていました。

フォールバック検証(f9ca912)

キーワードはプルダウンで選ぶ形になったので、どの文字を使っているかは何も決めません。設定に登録があるかどうかが全てを決めます。 設定にないキーワードはD1が落ちる当日まで何も起きていないように見えるため、気づくのが最悪のタイミングになります。

文字種の正規表現を撤去し、FALLBACK_DESTINATIONS への登録有無を検証します。長さ上限(40文字)はスキーマに残します。

登録の必須化には例外を1つ設けました。保存済みの値と同じであれば通します。 これが無いと、設定からキーワードが消えたプロジェクトは保存自体ができなくなり、誰も触っていないフィールドを理由に改名が400で落ちます。そのキーワードは既にポスターに印刷されているため、管理画面も同じ理由で孤立したキーを保持しています。

登録判定は Object.keys(map).includes(next) を使います。next in map だと constructortoString がプロトタイプチェーン経由で「登録済み」と誤判定されます。

クライアント側も同じ規則にしますが、転送先リストの取得に失敗すると入力欄がテキスト入力に退化してリストを知り得ないので、そのときは一致検証を飛ばして長さだけ見ます。編集画面では保存済みの値を渡します。渡さないと、サーバーが許す改名をクライアントが送信前に弾いてしまいます。

コピーボタン(9d68ab9)

転送先URLは画面の外へ持ち出す唯一の情報です(同僚にポスター確認を頼むチャット、リダイレクトを試すスマホ)。折り返された70文字の等幅文字列を手で選択するのが失敗点で、途中まで選択されたURLは「QRが壊れている」ように見えます。

navigator.clipboard は secure context を要求し、平文httpのLANアドレスでは使えないので、失敗時は手動コピーを促します。黙って成功したように見えるのが最悪なためです。アイコンだけでは何が起きたか伝わらないので、aria-labeltitle も状態に応じて切り替えます。

設定のキー(820ab8b)

Bingo / TikTok / "Youtube," が入っていましたが、いずれも機能しない形でした。

  • 大文字: 照合は単純なオブジェクト参照なので大文字小文字を区別する一方、管理画面の提案は遷移先ホストから導出するので必ず小文字。つまり Bingo は提示される bingo と永久に一致せず、しかもスキャン時に何も報告されません(意図的な空欄と区別する手段がないため)
  • "Youtube,": キー名に末尾のカンマ。クエリの予約文字なので &p=Youtube%2C とエンコードが必要になり、印刷するペイロードも伸びます

bingo / tiktok / youtube を実際の転送先とともに追加し、nut_fes は削除しました(どのプロジェクトも使っていません)。キー変更で孤立するプロジェクトはありません。

検証結果

ローカル(255行)と本番(13行)の両方で実測しました。

確認項目 結果
バックフィル(255行 / 13行) どちらも distinct = total, nulls 0, wrong_len 0、二度目は no-op
短縮コードでのスキャン 302、正しい遷移先
UUID形式でのスキャン(印刷済み対策) 302、同じ遷移先
外部キー qr_id は常に行のUUID。短縮コードのログ行は存在しない
アクセスログ スキャン1回に1行、404には0行
存在しないID 404
フォールバック検証 登録済み 201 / 未登録 400 / 空 201 / 41文字 400
孤立キーの改名(最重要の回帰) 200 で成功
日本語キーを設定に入れた場合 201 で受理(文字種制限が消えたことの実証)
コピーボタン クリップボードの内容が表示URLと一致、押下後2秒だけ「コピーしました」
モジュール数 103文字 57×57 → 74文字 49×49

各コミット単独で typecheck / test / lint すべてOK(5/5)、先端で build もOK。

相談したい点

  1. バックフィルスクリプトが生成器を8行ほど複製しています(素の node から .ts を import できないため)。テストが両者の文字集合と桁数の一致を検証しているので黙って乖離することはありませんが、1関数のためにビルド手順を足す選択肢もあります。
  2. qrTargetUrl の引数をオブジェクト({id, shortCode})に変えました。 位置引数のままなら既存の呼び出しを触らずに済みましたが、「短縮を優先する」を呼び出し側で間違えやすくなります。
  3. common.qrId / qrCodes.qrIdHeader は定義だけで未使用の翻訳キーです。UUIDを画面に出す誘惑になるので削除候補ですが、今回は触っていません。

haruto-kamijo and others added 5 commits July 29, 2026 20:31
ツールが作る作業ツリーはリポジトリのもう1つの完全なチェックアウトを含むため、
pnpm lint がそのコピーまで検査していた。実際のツリーには存在しないCRLFの指摘が
大量に出て、自分のコードに問題が無いときでも lint が壊れているように見える。

除外は .git/info/exclude に書かれているが、biome の useIgnoreFile はルートの
.gitignore しか読まないのでどちらも届かない。loadtest/.out と同じ理由。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QRに焼き込むURLが 103文字 / 57×57モジュールあり、印刷時に読み取りづらかった。
7桁の短縮コードを併設して 74文字 / 49×49 にする(実測)。

主キーを差し替えるのではなく short_code 列を足した。QRCodes.id は
AccessLogs.qr_id の外部キー参照先で、スキーマにも「印刷されたURLに焼き込まれる
ので永久に安定していなければならない」と明記してある。既存のUUID形式のURLは
今後も解決し続ける必要があり、紙は刷り直せない。

スキャン経路は id = ? OR short_code = ? の1クエリで両形式を受ける。
形状判定(36桁ハイフン区切りならUUID)は採らない。idは任意のTEXTで、負荷テストの
シーダーは lt-qr-001-001 形式を使うため壊れる。2クエリにするとアプリで最も熱い
経路のサブリクエストが倍になる。両列とも索引付き(idは主キー、short_codeは一意
索引)なので索引探索2回で済む。

アクセスログの書き込みで、スキャンされた値ではなく行自身のidを使うよう修正した。
AccessLogs.qr_id は QRCodes(id) への外部キーなので、短縮コードでスキャンされると
制約違反になる。この書き込みは waitUntil でレスポンス経路の外にあるため、
リダイレクトは成功したまま短縮コード経由のスキャンだけが記録されずに消えていた。

文字集合は 23456789abcdefghijkmnpqrstuvwxyz。0/1/l/o を除いて読み間違いを防ぎ、
小文字のみにしたのは SQLite の TEXT 比較が既定で大文字小文字を区別するため。
ちょうど32文字なので byte & 31 で偏りなく写像できる。1文字減らすと生成器に
偏りが出る。URL安全な文字だけなので、qrTargetUrl が id を生で補間している
(パーセントエンコードしない)前提を保てる。

既存行には scripts/backfill-short-codes.mjs でコードを割り当てる。
マイグレーションとバックフィルは対で、後者を実行するまで既存行は NULL のまま
(UUID形式では引き続き解決する)。ローカル255行で検証済み、二度目は no-op。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
キーワードは FALLBACK_DESTINATIONS から引いたプルダウンで選ぶ形になったので、
どの文字を使っているかは何も決めない。設定に登録があるかどうかが全てを決める。
設定にないキーワードはD1が落ちる当日まで何も起きていないように見えるため、
気づくのが最悪のタイミングになる。

文字種の正規表現を撤去し、parseFallbackMap への登録有無を検証する。長さ上限
(40文字)はスキーマに残す。列と印刷されるペイロードを縛るのはこちらの役目。

登録の必須化には例外を1つ設けた。保存済みの値と同じであれば通す。これが無いと、
設定からキーワードが消えたプロジェクトは保存自体ができなくなり、誰も触っていない
フィールドを理由に改名が400で落ちる。そのキーワードは既にポスターに印刷されて
いるため、管理画面も同じ理由で孤立したキーを表示したまま保持している。作成時は
保存済みの値が無いので登録が必須。

登録判定は Object.keys(map).includes(next) を使う。next in map だと
constructor や toString がプロトタイプチェーン経由で「登録済み」と誤判定される。

クライアント側も同じ規則にする。転送先リストの取得に失敗すると入力欄がテキスト
入力に退化してリストを知り得ないので、そのときは一致検証を飛ばして長さだけ見る。
編集画面では保存済みの値を渡す。渡さないと、サーバーが許す改名をクライアントが
送信前に弾いてしまう。

ASCIIに寄せる根拠(日本語キーワードは1文字9文字分にパーセントエンコードされ、
シンボルが 57×57 から 61×61 に育つ)は消えるのではなく、設定を書く人の責任に
移る。wrangler.jsonc と README に記録した。READMEの手順も逆順に直した。
「プロジェクトを作ってから設定に追記」では400になる。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
このURLは画面の外へ持ち出す唯一の情報で、同僚にポスターの確認を頼むチャットや、
リダイレクトを試すスマホに貼られる。折り返された70文字の等幅文字列を手で選択
するのがその失敗点で、途中まで選択されたURLは「QRが壊れている」ように見える。

押した状態は2秒だけアイコンとラベルに出す。アイコンだけだとスクリーンリーダー
利用者に何も起きたことが伝わらないため、aria-label と title の両方を状態に応じて
切り替える。タイマーはアンマウント時に解除する。閉じたダイアログに向けて発火
させないため。

navigator.clipboard は secure context を要求する。http://localhost は満たすが
平文httpのLANアドレスは満たさないので、失敗経路は実在する。黙って成功したように
見えるのが最悪なので、手動コピーを促すトーストを出す。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
本番の設定に Bingo / TikTok / "Youtube," が入っていたが、いずれも機能しない状態
だった。

照合は単純なオブジェクト参照なので大文字小文字を区別する。一方で管理画面が提案
するキーワードは遷移先ホストから導出するため必ず小文字になる。つまり Bingo という
エントリは、プロジェクトに提示される bingo と永久に一致しない。しかもスキャン時に
何も報告されない。意図的な空欄とキーの取り違えを区別する手段がないため。

"Youtube," はキー名に末尾のカンマが入っていた。カンマはクエリの予約文字なので
&p=Youtube%2C とエンコードが必要になり、印刷するペイロードも伸びる。

bingo / tiktok / youtube を実際の転送先とともに追加し、instagram の URL を本番で
使われている値に合わせた。nut_fes は削除した。本番の設定から消えており、どの
プロジェクトも使っていない。x で足りる。

キーの変更で孤立するプロジェクトはない。本番が使っているのは "" / instagram /
web / x だけであることを確認済み。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Aquarius0715 Aquarius0715 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

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.

2 participants