Codex と Claude のレビューで浮かんだ「アクセス経路ごとの中身の解決」まわりの観察を、勉強のためにまとめたものです。綴Photo のテンプレページ機能(写真を組んでページを作り、合言葉付きリンクで共有できる)のコードが題材。
この記事で見ていく点
- 保存しているのは写真そのものではなく「どの写真を、どのテンプレで並べるか」の指示書だけ、という設計
- その指示書を実写真に引き当てる処理が片方の経路にしか無く、作った本人の画面にだけダミーが出ていたこと
席次表方式そのものは妥当な設計だ。写真の実体を二重に持たずに済みますし、テンプレを差し替えても並びの指示はそのまま使えます。問題は「では、その席次表を実データに解決するのは誰か」だった。
コードで起きていたこと
写真を見る経路は2つあります。作った本人が保存後に開く経路と、共有相手が合言葉付きリンクから開く経路です。この2つで、中身の解決が通っているかどうかが食い違っていた。
右側の出口が上下で違うのは、真ん中の箱が上のレーンにだけ無いからだ。データが欠けているのではなく、通っていない。
共有経路は build_photo_pool(...) を呼んで写真プールを解決していた。一方、本人経路のシリアライザ(サーバーの中のデータを、画面に返せる形に詰め替える梱包係)は、返す項目のリストがこれだけだった。
# 本人(owner)取得経路で使われるシリアライザの返却フィールド
fields = ["id", "title", "layout_json", "created_at", "updated_at"]
# layout_json(席次表)は返すが、解決済みの写真プールは返さないlayout_json(席次表)は返ってくる。だが、それを実写真に引き当てた photos が入っていない。そして表示コンポーネントは、photos が渡ってこないとダミーデータ(sampleData)にフォールバックする作りになっていた。エラーは出ない。
| 作った本人が保存後に開く | 共有相手が合言葉リンクから開く | |
|---|---|---|
| 解決のひと手間 | 通らない(シリアライザが photos を返さない) |
build_photo_pool(...) を通る |
| 画面に渡るもの | 席次表だけ | 実写真URL・言葉に解決済み |
| 出るもの | ダミーデータにフォールバック=自分の写真が出ない | 本物の写真がちゃんと並ぶ |
自分のデータなのに、自分には見えず、共有した相手にだけ見える。ただし逆転しているのは見え方だけで、権限そのものはずれていない(共有相手が見てはいけないものを見たわけではない)。逆になったのは「中身が組み立てられて返るかどうか」の一点だ。
ページを作る側の API は本番ではフラグで OFF にしてある(正確には、OFF なのはテンプレページの作成・取得と共有リンクの発行で、合言葉リンクを開く公開エンドポイントだけは常時有効。ただし本番ではページも共有リンクも作れないので、この逆転が外に出ることはなかった)。公開前にコードを読んで見つけた構造上の穴だ。今回の転び方も「本人に出ない」側で、実害としては軽い。ただし偏在そのものはどちらにも転ぶ——解決を本人経路にだけ書いていたら、今度は見せてはいけないものが出る側に倒れていた。
なぜそうなりやすいか
写真への解決を、最初に必要になった経路(共有相手の経路)にだけ書いたからだ。共有機能を作るときに「共有相手に写真を見せる」を考えるのは自然な流れで、そこに build_photo_pool を置きます。
問題は本人経路のほうで、「保存したデータをそのまま返せばいい」と思ってしまいます。実際 layout_json はちゃんと返ります。データは欠けていません。欠けているのは解決のひと手間だけで、保存物を眺めていても気づきにくいです。しかも片方(共有経路)は正しく動くので、機能が壊れているようにも見えません。テストで共有リンクを踏めば写真は出ます。静かに片方だけが劣化します。
改善の方向
本人経路のシリアライザにも、共有経路とまったく同じ解決を通します。
# 本人経路のシリアライザにも photos を足し、
# 共有経路と「まったく同じ」関数を呼ぶ
class TemplatePageLayoutSerializer(...):
photos = serializers.SerializerMethodField() # 解決済みの写真プールを足す
def get_photos(self, obj):
request = self.context.get("request")
# 共有経路が呼んでいるのと同一の解決関数を、本人経路でも呼ぶ
return build_photo_pool(obj.layout_json, obj.owner, request)ポイントは「本人用に別の解決を新しく書く」のではなく、共有経路が使っているのと同じ関数を呼ぶこと。別々に書くと、また片方だけ直す/片方だけ仕様が変わる、という同じ罠に戻ります。この関数の中には「持ち主の写真だけを拾い、他人のもの・消えた番号は落とす」という絞り込みも同居しているので、窓口が1つなら、経路が2つでも3つでも絞り込みを書き忘れられる場所がそもそも無くなります。
学びポイント
解決関数は最初から1つしかなかった。それでもズレたのは、本人経路がその関数を呼んでいなかったからだ。効いたのは関数を作ることではなく、返す側(シリアライザ)が必ずその関数を通る位置に解決を置き直したこと。窓口を1つにするのと、全員がその窓口を通るのは、別の作業になります。
見るべきは2つだけです。「このデータには入り口が何個あるか」「中身の組み立ては全部の入り口で同じ関数を通っているか」。入り口が複数あって組み立てが別々なら、いつか片方だけずれます。
追記: その後の対処(P1-a)
この食い違いは実際に直した(フラグ OFF のうちに)。本人経路のシリアライザにも photos を足し、共有経路とまったく同じ解決関数を呼ぶようにした。いまは両経路とも同じ1つの関数(実コードでの名前は resolve_layout)を通ります。
その上で共有側だけは、解決したあとに一部の項目(1枚の写真に複数綴った言葉の全文)を落として返すようにした(2026-07 の追加)。これは「解決が別々」ではなく、「同じ解決結果から、共有相手に見せる分だけを意図的に絞る」という別レイヤーの判断だ。
なお、この resolve_layout はその後さらに役を増やしている。保存する形そのものを作り替えたときも、この1か所で「画面が前から読んでいる形」に戻す役をそのまま担った(→「保存の形を変えても、画面は1行も触らずに済んだ」)。解決を1か所に集めておいた利きが、次の変更でそのまま返ってきた形だ。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。