zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core 承認履歴に結果だけでなく表示内容も残す設計

注文や申請の承認機能を作るときは、「承認済み」という結果だけでなく、承認したときに表示していた内容も保存しておくと、後から確認しやすくなります。

たとえば、注文金額が変更された場合でも、「利用者は変更前と変更後のどちらの金額を確認したのか」がわかります。

今回は、ASP.NET Coreで作られた承認機能の実装をもとに、承認履歴に何を残すか、そのためにどこまで仕組みを作るかを紹介します。

題材の実装では、依頼、表示内容、判断結果を分け、同じ依頼でも表示言語や説明文の違いを残せるようにしています。この構成を、注文の確認画面に置き換えて説明します。金額の変更は設計を考えるための例で、実際に発生した不具合の紹介ではありません。

コード例の名前と項目は、学習用に簡略化しています。データベースへの保存処理やAPI全体の実装は省略しています。

なぜ承認したときの表示内容を保存するのか

次のような注文を考えてみます。

タイミング注文の内容
利用者が確認したとき商品代金3,000円、送料500円、合計3,500円
利用者が承認した後配送方法の変更により、送料800円、合計3,800円

履歴画面で現在の注文データだけを表示すると、承認時の金額も3,800円だったように見えてしまいます。

この問題を避けるには、承認画面に表示する内容を別のデータとして保存します。注文データが後から変わっても、保存した表示内容は書き換えません。

このように、ある時点のデータを保存したものをスナップショットと呼びます。

この記事では、画面に表示する説明文や金額を保存します。画面を画像として保存するスクリーンショットとは異なります。

注文IDだけを保存する方法では足りないのか

最も簡単なのは、承認結果に注文IDを保存し、履歴を開くときに注文データを取得する方法です。注文内容が変更されず、過去の説明文を確認する必要もなければ、この方法で足りる場合があります。

ただし、注文金額が変わらなくても、承認画面の説明文が変わることがあります。

たとえば、同じ注文に対して「注文内容を確認してください」と表示する場合と、「注文内容とキャンセル条件を確認してください」と表示する場合では、利用者に確認を求める内容が異なります。注文IDと金額だけでは、その違いを残せません。

保存方法を選ぶときは、後から何を確認したいかで考えます。

後から確認したいこと保存方法の候補
誰がどの注文を承認したか注文ID、承認者、承認日時を保存する
どの版の注文を承認したか変更前の注文も保持し、承認結果に注文の版を保存する
どの説明文や言語で確認を求めたか注文の情報に加えて、表示内容も保存する

題材の実装が扱っているのは、3つ目のケースです。表示言語、タイトル、本文、明細などのデータを、承認依頼とは別に保存しています。

その分、保存するデータと取得処理は増えます。どの承認機能にも必須の構成ではなく、説明文まで履歴として残したい場合の選択肢です。

依頼・表示内容・承認結果を分ける

まず、注文についての承認依頼、画面に表示する内容、承認した結果を分けて考えます。

データ保存する内容の例
承認依頼注文Aについて確認してほしい、有効期限は明日まで
表示内容「注文内容の確認」というタイトル、合計3,500円という説明
承認結果利用者Bが、この表示内容に対して承認した

それぞれのデータには、識別用の番号であるIDを付けます。

承認結果に「どの表示内容のIDか」を保存すると、後から承認時の内容を取り出せます。

データのつながりの例
承認依頼:R001(注文Aの確認)
    ↓
表示内容:P001(合計3,500円)
    ↑
承認結果:D001(利用者BがP001を承認)

ここでのR001などは説明用のIDです。

表示内容を依頼から分ける理由は、1つの依頼に対して表示が1種類とは限らないからです。たとえば、同じ依頼を日本語で開いたときにP001、英語で開いたときにP002を保存し、英語の画面で承認した結果にはP002を残します。

反対に、表示言語が1つで説明文も変わらず、依頼ごとに表示内容が必ず1つなら、依頼データに表示用の項目を持たせる構成も考えられます。最初から別テーブルに分けること自体が目的ではありません。

表示内容を保存する型を作る

C#では、表示内容を次のような型で表せます。

ApprovalDisplay.cs
using System;

public record ApprovalDisplay(
    Guid Id,
    Guid RequestId,
    string Title,
    string Body,
    decimal TotalAmount,
    string Currency,
    DateTimeOffset CreatedAt
);

recordは、複数の値をひとまとまりのデータとして扱うためのC#の機能です。

この例では、次の項目を持たせています。

項目意味
Idこの表示内容を識別するID
RequestIdどの承認依頼の表示内容かを表すID
Title画面に表示するタイトル
Body画面に表示する説明文
TotalAmount確認してもらう合計金額
Currency金額の通貨。日本円ならJPY
CreatedAtこのデータを作成した日時

GuidはIDに使う型、decimalは金額などの数値に使う型、DateTimeOffsetは日時とUTCからの時差を扱う型です。

商品名、個数、配送先も判断に必要なら、それらも保存対象に含めます。項目を決めるときは、その値が変わったら、もう一度確認してもらう必要があるかを考えると整理しやすくなります。

たとえば、送料や配送先は保存対象になり得ますが、ボタンの角丸や画面の余白は、このデータには含めません。題材の実装でも、表示する文章と明細などのデータを保存し、HTML全体や画面画像は保存していません。この方式で残せるのは表示に使ったデータであり、当時の見た目そのものではありません。

このコードはデータの形を決めるものです。実際のアプリでは、データベースに対応するテーブルを用意して保存します。

また、recordを使ってもデータベースの上書きを防げるわけではありません。保存済みの表示内容を変更しないように、更新処理も設計します。

保存したデータを画面に表示する

表示内容は、画面を開くときにサーバー側で作成して保存します。

サーバーは、注文データからタイトル、説明文、金額を組み立て、表示IDと一緒に画面へ返します。画面は、受け取ったデータを表示します。

処理の順序は次のとおりです。

  1. 利用者が承認画面を開く。
  2. サーバーが注文内容から表示データを作る。
  3. サーバーが表示データを保存し、表示IDと一緒に返す。
  4. 画面が受け取った説明文や金額を表示する。
  5. 利用者が内容を確認して承認ボタンを押す。

保存する金額と表示する金額を、別々に現在の注文データから取得すると、その間の変更によって食い違う可能性があります。保存した表示データを、そのまま画面の表示にも使うようにします。

画面側では、表示データを取得している間は承認ボタンを無効にします。取得に失敗した場合も、内容を確認できないまま承認へ進まないようにします。

言語を切り替えられる場合は、表示する文章と表示IDを一緒に切り替えます。「画面の文章は英語なのに、送信するIDは日本語のまま」という組み合わせを作らないためです。

承認するときは表示IDを送る

承認ボタンが押されたら、画面は表示に使ったデータのIDをサーバーへ送ります。

金額や説明文を画面から送り直し、それを正しい内容として保存する必要はありません。サーバーに保存した表示データを、IDで取り出します。

承認結果の型は、たとえば次のように定義できます。

ApprovalDecision.cs
using System;

public record ApprovalDecision(
    Guid Id,
    Guid DisplayId,
    Guid ApprovedBy,
    DateTimeOffset ApprovedAt
);

DisplayIdが、先ほど保存したApprovalDisplay.Idに対応します。ApprovedByは承認した利用者のID、ApprovedAtは承認した日時です。

この例は承認だけを扱います。拒否も記録したい場合は、「承認・拒否のどちらか」を表す項目を追加します。

承認した利用者は、サーバー側のログイン情報から特定します。画面から送られた利用者IDをそのまま信用する設計にはしません。

表示IDだけで承認を許可しない

表示IDを受け取ったら、サーバー側で次の点を確認します。

確認すること理由
表示IDが対象の承認依頼に属しているか別の注文の表示内容で承認されるのを防ぐため
ログイン中の利用者に承認権限があるか他の利用者の依頼を勝手に承認されるのを防ぐため
承認依頼の期限が切れていないか古い依頼への承認を防ぐため
すでに承認・拒否されていないか同じ依頼を重複して処理しないため

たとえば、注文Aの承認APIへ注文Bの表示IDが送られた場合は、承認を受け付けません。表示IDが存在するだけでなく、対象の依頼の表示IDかを確認します。

また、画面のボタンを無効にするだけでは、同時に届いた2つの承認を防げません。サーバー側でも、更新時に依頼の状態が変わっていないか確認する必要があります。

更新が重なった場合の対処方法は、以下の記事で紹介しています。

ETagを使って更新の競合を検知する方法

3,500円の表示内容への承認を、そのまま3,800円の注文に使ってはいけません。

この例では、金額などの承認対象を変更する場合、古い承認依頼を使えなくし、新しい内容で承認依頼を作り直すものとします。表示内容の保存に加えて、実際の処理も承認された内容と一致させます。

履歴では保存した表示内容を使う

承認履歴を表示するときは、承認結果に保存したDisplayIdから、当時の説明文や金額を取得します。

現在の注文が3,800円に変わっていても、過去の承認履歴には、保存した3,500円の表示データを使います。

なお、表示データを保存しても、利用者がすべての文章を読んだことまでは証明できません。記録できるのは、承認結果と結び付いた表示データです。

同じ表示内容は再利用する

表示内容を保存する構成では、再読み込みのたびに同じデータを追加するかも決める必要があります。

題材の実装では、言語、説明文のバージョン、タイトル、本文、明細などのデータからハッシュを計算し、同じ依頼に同じハッシュの表示があれば再利用しています。ハッシュは、データから計算する一定の長さの値で、ここでは同じ表示内容を探す手がかりです。

表示IDと作成日時は、ハッシュの計算に含めていません。それらは新しく作るたびに変わるため、比較に含めると、文章が同じでも別の内容として扱われてしまうからです。

操作題材の実装での扱い
同じ依頼を同じ表示データで再表示する保存済みの表示IDを返す
表示言語や本文を変える別の表示として保存する
説明文のバージョンを変える本文が同じでも別の表示として保存する

バージョンは、説明文を作るルールの変更を区別する番号です。たとえばv1、v2のように付けます。番号だけでは当時の文章はわからないため、実装では本文も一緒に保存しています。

この再利用には注意点もあります。保存済みのデータを返した場合、その作成日時は今回画面を開いた時刻にはなりません。表示内容の記録と、毎回の閲覧の記録は別です。「何回開いたか」も必要なら、閲覧履歴を別に記録します。

小さなアプリで表示回数が少なければ、まずは毎回保存する方法でも始められます。再利用を入れるかどうかは、重複データの量と実装の複雑さを比べて決めます。また、ハッシュによる再利用は、同時に届いた承認の重複処理を防ぐ仕組みとは別です。

動作を確認する

この設計で大切なのは、正常に承認できることに加えて、「表示と承認結果の対応が崩れないこと」です。自分のアプリに取り入れる場合は、次の操作を確認します。以下は確認項目の例で、コード例を実行したテスト結果ではありません。

操作期待する結果
表示内容を取得して承認するその表示IDが承認結果に保存される
承認後に注文データを変更して履歴を開く承認時に保存した説明文と金額が表示される
別の依頼の表示IDを送る承認できない
表示データの取得に失敗するエラーを表示し、承認できない
日本語から英語へ切り替えて承認する英語の表示内容のIDが承認結果に残る
同じ表示を再利用して履歴を確認する表示の作成日時と承認日時を区別して確認できる
画面を開いた後に承認期限が切れるサーバー側で承認を受け付けない
同じ依頼を同時に承認する重複した承認結果を作らない

承認結果の保存項目を決めるときは、「誰が承認したか」だけで足りるのか、「どの金額や説明文に対する承認か」まで必要なのかを考えてみてください。後者が必要なら、表示内容を残し、そのIDを判断結果に結び付けることで、現在の注文データからはわからない承認の前提を確認できます。


関連記事