Notes
開発記録
不正利用の監査を、どうやって
「無かった」と証明するか

クーポン企画が終わったあと、必ず一度は聞かれることがあります。 「不正な利用はなかったのか」。
原資が協賛金や公費だと、この問いは避けて通れません。 そして厄介なことに、「無かった」を示すのは「有った」を示すよりずっと難しい。 有ったなら1件挙げれば済みますが、無かったことは全部を調べないと言えません。
紙のクーポンだと、ここで詰まります。回収した券束を数え直すことはできても、 「同じ人が何枚使ったか」は券に書いていないので分かりません。 結局「特に問題は報告されていません」としか書けない。 それは事実の報告ではなく、単に見ていないだけです。
方針: 止めない。ただし全部記録する
設計の出発点は、自動でブロックしないと決めたことです。
クーポンを使うのは、店員さんの目の前に立っているお客様です。 そこで「不正の疑いがあります」と画面に出てしまったら、その人はもう二度と使ってくれません。 しかも疑わしく見えるパターンの大半は、実際には正常です。 家族で来て同じスマホのテザリングを使っている、店のWiFiにみんなが繋いでいる、 同じ機種の同じOSバージョンを使っている。これで「同一端末」に見えてしまう。
誤検知の代償を払うのはお客様と店舗で、得をするのは誰もいない。 だから検知は「止めるため」ではなく「あとで確かめるため」に使う。
止めてよいのは、疑いようのない3つだけにしました。LINEで本人確認していない人、すでに上限回数まで使った人、年齢確認を済ませていない人。 いずれも判定が一意で、誤って止める余地がありません。
それ以外はすべて通します。そのかわり、利用のたびに時刻・店舗・エリア・人数・ 接続元IP・ブラウザの種類を記録します。判定に使わないデータでも記録します。あとから問われたときに答えられるかどうかは、その瞬間に何を残したかで決まるからです。
監査は4つの見方を重ねる
終了後、全利用データを走査して、店舗とユーザーそれぞれに「確認したほうがよさそうな度合い」を付けます。 単独では判断できないものばかりなので、複数の見方を重ねます。
1. 端末の指紋がぶつかっていないか
接続元IPとブラウザ情報の組を1つのキーとして、同じキーで別々のアカウントが利用しているかを数えます。 1人が複数アカウントを作って回している場合、ここに出ます。
ただし前述のとおり、店のWiFiでも家族でも同じことが起きます。 なので「重複あり/なし」の二値ではなく、 そのユーザーが属する束の大きさ(同じ指紋を共有しているアカウント数)を持ちます。 2人なら家族かもしれない。5人を超えると、説明がつきにくくなる。
// 同一の指紋を共有しているアカウントの数
clusterSize >= 5 → 濃厚
clusterSize >= 3 → 注意
clusterSize > 1 → 記録だけ2. 1つの店舗に端末が集中していないか
店舗側から見ると、別の絵が見えます。 ある店の利用のうち、いちばん多い単一端末が占める割合を出します。 店員さんが自分のスマホで代わりに操作していれば、この割合が跳ね上がります。
あわせて、その店の利用のうち重複指紋に該当する割合、来店人数の平均、 そして10分の窓に何件詰まっているかを見ます。 最後のものは、閉店後にまとめて処理したときに強く出ます。
3. 使ってはいけない時間帯に使われていないか
企画によっては利用できない時間帯を設けます。 この判定でつまずきやすいのが時差で、 サーバーの時刻をそのまま使うと日本時間と9時間ずれます。
日付や時刻の比較をするときは、必ずタイムゾーンを明示するようにしています。new Date().toISOString() は世界標準時なので、 「日本時間の何時か」を知りたい場面では使えません。
// 日本時間の「時」を取り出す
const hour = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tokyo',
hour: '2-digit',
hour12: false,
}).format(usedAt)日をまたぐ時間帯(22時〜翌2時など)も指定できるようにしています。 開始が終了より大きいときは日跨ぎとして扱います。
4. 移動が物理的に可能か
エリアごとに1回まで、という条件の企画では、別エリアでの利用が短時間で連続していないかを見ます。 車で1時間かかるエリアの利用が10分差で並んでいれば、本人が移動したとは考えにくい。
ここも自動では止めません。実際、隣接するエリアの境界近くに住んでいれば 20分で移動できることもあります。判断は人がします。
おまけ: 突出している店舗を機械的に拾う
「他と比べて明らかに多い店」を見つけたいのですが、平均と標準偏差で外れ値を取ると、 その外れ値自身が平均を押し上げてしまって検出できません。 そこで中央値と、中央値からの絶対偏差の中央値を使っています。 少数の極端な値に引きずられにくい指標です。
スコアは「順番」であって「判定」ではない
4つの見方それぞれに点を付けて合計し、高い順に並べます。 この数字は人が確認する順番を決めるためだけのもので、 点が高いから不正だ、という意味は持たせていません。
管理画面には、その店・その人がなぜ上位に来たのかを短いラベルで並べて出します。 「重複疑い比率」「端末集中」「短時間集中」「エリア横断が短時間」。 こう出しておくと、見た担当者が「これは商店街の共有WiFiだ」と即座に判断できます。 スコアだけを見せると、その判断ができません。
機械は候補を絞るところまで。決めるのは人。 この線を引かないと、誤検知の責任の所在が消えます。
報告書に何を書くか
監査報告書として出しているのは、次のような内容です。
- 対象期間と、走査した利用データの全件数(サンプリングしていないこと)
- 上の4つの観点それぞれの該当件数と、該当した理由
- 該当した案件を1件ずつ確認した結果と、その判断理由
- 管理画面での操作ログ(誰がいつ何を変更したか)
大事なのは3つ目です。「該当0件でした」ではなく、 「該当したN件を確認し、うちN件は同一世帯からの利用と判断した」まで書く。 これがあると、読む人は結論ではなく過程を検証できます。
そして、この報告書を出すために特別な作業をしていません。 利用のたびに記録を残していれば、集計は終了後に走らせるだけです。あとから作れないものを、その場で残しておく。 監査の設計はほとんどそれに尽きます。
ほかの開発記録

地図が真っ白のまま出ない ― ピンだけ見えるときに疑う3つ
ピンは正しい位置に出るのに、地図そのものが出ない。原因は3つあり、どれも「描画が始まらない」という同じ形をしていました。現地で使う画面なので、待ち方を変えて直した記録です。

集計から1名だけ消えていた話
同じ回答データから出した2つの集計が、5,937名と5,938名でずれました。深夜2時に回答した1名が月別のバケツから落ちて、黙って消えていた話です。期間の定義を変えたときに、どこを直し忘れるのか。

LINE認証で「1人1回」を実現する設計
アプリを入れずに、1人が1回だけ使えることをどう保証するか。LINEを選んだ理由、ボタン連打で二重に記録されない仕組み、スクリーンショットで使い回せない画面まで。同じ設計は入場受付やスタンプラリーにもそのまま使えます。

アンケートのクロス集計から報告書PDFを自動生成するまで
集計と作図に数週間かかっていた報告書を、管理画面のボタン1つで出せるようにしました。表の組み立て方、紙のレイアウトでつまずいた点、そして67MBのPDFが出てしまった原因の話。