Skip to content

Feature/qr caption scan count bulk csv - #4

Merged
haruto-kamijo merged 4 commits into
developfrom
feature/qr-caption-scan-count-bulk-csv
Jul 29, 2026
Merged

Feature/qr caption scan count bulk csv#4
haruto-kamijo merged 4 commits into
developfrom
feature/qr-caption-scan-count-bulk-csv

Conversation

@haruto-kamijo

Copy link
Copy Markdown
Collaborator

概要

本番リリースに向けて、実際にポスターを刷って運用する際に足りなかった4点を入れました。
本番へのデプロイは既に完了しています(挙動はこのブランチの内容と一致)。

コミット 内容
9c51141 QRコードの下に入れる文字を任意にし、ファイル名を 識別名_QR にする
86afa23 フォールバック用キーワードにアンダーバーを許容し、Xの転送先を追加
43a41fd QRコードごとのアクセス数を一覧に表示
d9b5858 選択したプロジェクトのアクセスログを1つのCSVでまとめて出力

11ファイル / +1,024 −102。テストは 42 → 65 件(web +23)。

変更内容

QRのキャプションとファイル名

必須2項目(名前・媒体)それぞれにチェックボックスを設け、どちらも既定はオフにしました。
ポスターのように自前のデザインを持つものにとって、下の文字は切り落とす手間でしかないためです。
チェックボックスには実際に印字される文字列を並記しています。「媒体」とだけ書いてあっても、
それが場所を伴って ポスター · 本館1F と出ることは伝わりません。

場所は任意項目なので独立したトグルを設けず、媒体のチェックに連動させています。媒体抜きで場所
だけが印字されると、紙の上で意味の取れない断片になります。

ファイル名は 造形大ポスター_ポスター_1F掲示板.png造形大ポスター_QR.png。媒体と場所は
コード間で重複しがちで、フォルダ内で実際に探す手がかりになる名前が埋もれていました。

選択は次回以降も引き継ぎます。QRは1枚ずつ落としますが印刷はまとめて行うので、20枚分チェック
し直すのが作業の大半になるためです。初回は必ずオフです。

キーワードのアンダーバー許容とXの転送先

X のアカウントが x.com/nut_fes で、アンダーバーが使えませんでした。弾いていたのは入力検証
だけ
で、実行時に解決する fallback.ts はもともとキーの文字種を制限していません。

アンダーバーはQRコードのサイズに影響しません。RFC 3986 の unreserved 文字なので
encodeURIComponent でパーセントエンコードされず、ペイロードは既にバイトモード(小文字はQRの
英数字モードに無い)なので他の文字と同じ8ビットで済みます。

FALLBACK_DESTINATIONS には xnut_fes の2つを登録しました。前者は deriveFallbackKey
がホストから導出する値(キーワード欄を空にしたプロジェクトのQRに入る)、後者は手で名付ける
ときに選ぶハンドル名です。キーが一致しないと静的フォールバックに落ちてしまい、それはこの
仕組みが防ごうとしている失敗そのものなので両方入れています。運用が固まったら片方を消せます。

QRごとのアクセス数

一覧の各行で、名前と同じ行の右端にバッジで表示します。イベント中にこの画面を開く理由そのもの
なので、下のメタ情報の行ではなく名前の横に置きました。

CSV一括出力

プロジェクト一覧にチェックボックスを追加し、GET /projects/access-logs/csv?projectIds=a,b,c
1本のCSVとして出力します。zip ではなく単一ファイルにしたのは、依存の追加が要らず、
複数同時ダウンロードがブラウザにブロックされないためです。どの行がどのプロジェクトかを示す
ために先頭に プロジェクト 列を追加しています(単一プロジェクト版には不要な列)。

設計判断(diffから読み取りにくい部分)

アクセス数をカウンタ列にしなかった理由 ← 一番見てほしい点です

QRCodes.scan_count を持たせて加算すれば表示は無料になりますが、1スキャンあたりのD1書き込み
が1回から2回に増えます。

Freeプランの上限 2万スキャンでの消費
書き込み 100,000行/日 ← 律速 20%
読み取り 5,000,000行/日 0.8%

負荷テストのレポートのとおり、このシステムの実質的な上限は書き込みです。カウンタ列は50倍
余っている資源を節約するために唯一の制約を半分(1日10万→5万スキャン)にする取引
になります。

代わりに表示中ページのIDに限定した GROUP BY 1本で数えています(idx_access_logs_qr_id
が効きます)。絞らないと10行を描画するために最大のテーブルを丸ごと走査します。プロジェクト
一覧が同じ修正を経ていたので規則を揃えました。

読み取りコストは(表示中QRのログ数 × 表示回数)で伸びます。2万件が全て同一ページに属する最悪
ケースで1回20,000行 = 1日約250回の表示相当。そのためポーリングや入力イベントには繋げて
いません
。自動更新にすると前提が変わります。

その他

  • CSVはプロジェクト順→時刻降順、キーセット (project_id, accessed_at, id) の3列。
    全体を時刻順にすると idx_access_logs_project_accessed_at が順序付けに使えず、最大5万行を
    Worker内でソートして CPU 10ms 制限(1102) に当たります。表計算で開いたときの見やすさも
    こちらが勝ります。
  • 行数上限は選択の合計に。 プロジェクトごとだと10件チェックで10倍を1クリックで許可し、
    それは1日の読み取りクォータの10%です。
  • 全選択は表示中ページのみ。 他ページまで黙って含めると、見えていない量のCSVを1クリックで
    要求できてしまいます。
  • 指定IDが1つでも存在しなければ404。 黙って除外すると、チェックしたプロジェクトが欠けた
    まま完全に見えるファイルができます。
  • 正規表現の文字クラスはハイフンを最後に。 [a-z0-9-_]9-_ が範囲として解釈され、
    大文字と記号まで通ります。この点は useFieldErrors.test.ts で固定しました。
  • 太字は行の位置ではなく項目に紐づけ。 従来は1行目を太字にしていたため、名前をオフにすると
    媒体が太字になって見出しのように見えていました。

検証で見つけて直したもの

  1. CORS で Content-Disposition が読めていなかった — セーフリスト外のヘッダなので
    exposeHeaders が無いと、サーバーが組んだ日本語ファイル名を管理画面が読めず、クライアントが
    推測した名前で保存されていました。
  2. 一括CSVのファイル名が選択順を無視していたIN (...) の返却順(SQLiteのrowid順)で
    先頭を取っていたため、先頭に選んだプロジェクトではなく最も古いものの名前が付いていました。

検証結果

ローカルの17プロジェクト・10,769行で実測しました。

確認項目 結果
一括CSVの行数 10,769 = 10,769 完全一致(2,000行境界を6回跨いで欠落・重複なし)
アクセス数 API vs SQL の直接照合 5件すべて一致
プロジェクトのまとまり 17境界 / ユニーク17 → 分断なし
BOM / CRLF / 引用符エスケープ / 日本語ファイル名 すべて確認
51件 → 400 / 上限超過 → 413 / 未知ID → 404 / 未認証 → 401 確認
D1障害時のフォールバック6パターン nut_fesx の両方が X に転送

同一QR・同一ミリ秒のログ2件が同内容の行として出ましたが、DB上は別レコード(id 32696 / 32697)
で総行数が完全一致するため、キーセット3列目 id のタイブレークが機能した証拠です。

i18n.tsx など5ファイルは4つの変更が同じハンクに混在しており、ファイル単位では分割できず
ハンク単位で振り分けました。そのため個別に確認しています。

  • 分割前の状態との比較: 全11ファイル 1バイトも差異なし
  • 各コミット単独で typecheck / test / lint すべて OK(4/4)

相談したい点

  1. xnut_fes の二重登録(wrangler.jsonc)— 使う方が決まっていれば片方に絞ります。
  2. アクセス数の読み取りコストの前提 — 想定スキャン数が2万から大きく増えるなら見直しが必要です。
  3. CSVを1ファイルにまとめる方式 — プロジェクトごとに別ファイルが要るなら作り直します。
  4. 全選択を表示中ページに限った点 — 「全プロジェクトまとめて1発」が欲しいかどうか。

残作業(このPRの範囲外)

  • CSV_EXPORT_ENABLED が本番で "false" のため、CSV出力は本番では未稼働です。有効化前に
    本番で一度 S4 を回すことを docs/load-test-report-2026-07-27.md §5.7 に記載しています。
  • GIT_SHA 未設定のため /healthz"version":"dev" を返します(同レポート §5.4)。
  • 印刷したQRを実機でスキャンする確認(&p= が付いて 57×57 になっています)。

haruto-kamijo and others added 4 commits July 29, 2026 13:36
必須2項目(名前・媒体)それぞれにチェックボックスを設け、どちらも既定はオフに
する。既定を「入れない」にしたのは、ポスターのように自前のデザインを持つものに
とっては下の文字が切り落とす手間でしかないため。チェックボックスには実際に印字
される文字列を並記する。「媒体」とだけ書いてあっても、それが場所を伴って
「ポスター · 本館1F」と出ることは伝わらないため。

場所は任意項目なので独立したトグルを設けず、媒体と同じ行のまま媒体のチェックに
連動させる。媒体抜きで場所だけが印字されると紙の上で意味の取れない断片になる。

太字の判定を行の位置ではなく項目に紐づける。従来は1行目を太字にしていたため、
名前をオフにすると媒体が太字になって見出しのように見えていた。

ダウンロードするPNGのファイル名から媒体と場所を落とし、識別名_QR.png にする。
この2つはコード間で重複しがちで、フォルダ内で実際に探す手がかりになる名前が
埋もれていた。'QR' は文字列連結ではなく slugForFilename の引数として渡し、
名前が空でも _QR.png という壊れた見た目にならないようにする。

選択は safeStorage に保存して次回以降も引き継ぐ。QRコードは1枚ずつダウンロード
するが印刷はまとめて行うので、20枚分チェックし直すのが作業の大半になるため。
初回は QR_CAPTION_DEFAULTS のままで、キャプションは付かない。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
X のアカウントが x.com/nut_fes で、キーワードにアンダーバーを使えないと
ソーシャルのハンドルをそのまま設定できなかった。弾いていたのは入力検証だけで、
実行時に解決する fallback.ts はもともとキーの文字種を制限していない。

アンダーバーはQRコードのサイズに影響しない。RFC 3986 の unreserved 文字なので
encodeURIComponent でパーセントエンコードされず、ペイロードは既にバイトモード
(小文字はQRの英数字モードに無い)なので他の文字と同じ8ビットで済む。

文字クラスはハイフンを最後に置く。[a-z0-9-_] と書くと 9-_ が範囲として解釈され、
大文字と記号まで通ってしまう。この点は useFieldErrors.test.ts で固定した。

FALLBACK_DESTINATIONS には x と nut_fes の2つのキーを登録する。前者は
deriveFallbackKey がホスト x.com から導出する値で、キーワード欄を空にした
プロジェクトのQRに入る。後者は手で名付けるときに選ぶであろうハンドル名。
キーが一致しないと静的フォールバックに落ちてしまい、それはこの仕組みが
防ごうとしている失敗そのものなので、両方登録しておく。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
イベント中にこの画面を開く理由そのものなので、下のメタ情報の行ではなく名前と
同じ行に置く。

集計は表示中のページのIDに限定した GROUP BY 1本で、既存の
idx_access_logs_qr_id が効く。絞らないと10行を描画するために最大のテーブルを
丸ごと走査することになる。プロジェクト一覧が同じ修正を経ているので規則を揃えた。

QRCodes に scan_count 列を持たせてスキャンごとに加算すれば表示は無料になるが、
1スキャンあたりのD1書き込みが1回から2回に増える。Freeプランでは書き込みが
100,000行/日で律速する一方、読み取りは5,000,000行/日で余っている。カウンタ列は
50倍余っている資源を節約するために唯一の制約を半分にする取引になるので採らない。

読み取りコストは(表示中のQRに属するログ数 × 表示回数)で伸びる。2万件のログが
すべて同一ページに属する最悪ケースで1回20,000行、1日あたり約250回の表示に相当
する。管理画面としては十分だが、そのためポーリングや入力イベントには繋げない。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
プロジェクト一覧にチェックボックスを追加し、選択分を1本のCSVとして出力する
エンドポイント GET /projects/access-logs/csv?projectIds=a,b,c を新設する。
zip ではなく単一ファイルにしたのは、依存の追加が要らず、複数同時ダウンロードが
ブラウザにブロックされないため。どの行がどのプロジェクトかを示すために
プロジェクト列を先頭に足す(単一プロジェクト版には不要な列)。

行数上限は選択の合計に対してかける。プロジェクトごとにすると10件チェックで
10倍の行数を1クリックで許可することになり、それはFreeプランの1日の読み取り
クォータの10%にあたる。

並び順はプロジェクト順→各プロジェクト内で時刻降順とし、キーセットを
(project_id, accessed_at, id) の3列にする。全体を時刻順にすると
idx_access_logs_project_accessed_at が順序付けに使えず、最大50,000行を
Worker内でソートすることになってCPU 10ms 制限に当たる。表計算で開いたときの
見やすさもこちらが勝る。

全選択は表示中のページのみを対象にする。他ページまで黙って含めると、見えて
いない量のCSVを1クリックで要求できてしまう。

指定したIDが1つでも存在しなければ404にする。黙って除外すると、ユーザーが
チェックしたプロジェクトが欠けたまま完全に見えるファイルができる。

CORS に exposeHeaders: Content-Disposition を追加する。このヘッダはCORSの
セーフリスト外なので、これが無いと管理画面はサーバーが組んだ日本語ファイル名を
読めず、クライアントが推測した名前で保存されていた。ファイル名は選択順の先頭を
使う。IN(...) の返却順(SQLiteのrowid順)で引くと、選択の先頭ではなく最も古い
プロジェクトの名前が付いてしまう。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@haruto-kamijo
haruto-kamijo force-pushed the feature/qr-caption-scan-count-bulk-csv branch from d9b5858 to 1565cd9 Compare July 29, 2026 04:48

@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

@haruto-kamijo
haruto-kamijo merged commit 15755d2 into develop Jul 29, 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.

2 participants