紡Book の勉強ノートに戻る
コードレビュー学習2026.06.24

共有相手だけが写真を見られて、作った本人は見られない — アクセス経路ごとに「中身の解決」を書き分けた話

DjangoDRF設計紡Book

Codex と Claude のレビューで浮かんだ「アクセス経路ごとの中身の解決」まわりの観察を、勉強のためにまとめたものです。綴Photo のテンプレページ機能(写真を組んでページを作り、合言葉付きリンクで共有できる)のコードが題材。

この記事で見ていく点

  • 保存しているのは写真そのものではなく「どの写真を、どのテンプレで並べるか」の指示書だけ、という設計
  • その指示書を実写真に引き当てる処理が片方の経路にしか無く、作った本人の画面にだけダミーが出ていたこと

席次表方式そのものは妥当な設計だ。写真の実体を二重に持たずに済みますし、テンプレを差し替えても並びの指示はそのまま使えます。問題は「では、その席次表を実データに解決するのは誰か」だった。

コードで起きていたこと

写真を見る経路は2つあります。作った本人が保存後に開く経路と、共有相手が合言葉付きリンクから開く経路です。この2つで、中身の解決が通っているかどうかが食い違っていた。

保存されているもの通る経路(入口ごとに別)画面に出るものlayout_json(席次表)photoId 12 / 45 / 78 の並びだけ=写真の実体は持たない本人が開く入口ログインで確認解決の箱が無いそのまま返すだけ席次表だけ→ ダミー写真共有相手が開く入口合言葉で確認解決の箱を通るphotoId → 実写真・言葉中身も届く→ 本物が並ぶ同じデータなのに、解決の箱が片方の経路にしか無い = 見る人によって中身が変わる

右側の出口が上下で違うのは、真ん中の箱が上のレーンにだけ無いからだ。データが欠けているのではなく、通っていない。

共有経路は build_photo_pool(...) を呼んで写真プールを解決していた。一方、本人経路のシリアライザ(サーバーの中のデータを、画面に返せる形に詰め替える梱包係)は、返す項目のリストがこれだけだった。

python
# 本人(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 はちゃんと返ります。データは欠けていません。欠けているのは解決のひと手間だけで、保存物を眺めていても気づきにくいです。しかも片方(共有経路)は正しく動くので、機能が壊れているようにも見えません。テストで共有リンクを踏めば写真は出ます。静かに片方だけが劣化します。

改善の方向

本人経路のシリアライザにも、共有経路とまったく同じ解決を通します。

python
# 本人経路のシリアライザにも 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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。