ASP.NET Core 二重送信に強いAPIを作る② 受付結果をDBに保存する
問い合わせの受付APIを、ASP.NET CoreとPostgreSQLで実装します。
先にSELECTして「まだ存在しない」と確認するだけでは、同時に届いた2件が両方とも登録へ進む可能性があります。今回はDBの一意制約で、同じ操作の登録を1件に制限します。
受付と操作キーを同じ行に保存する
受付テーブルCREATE TABLE inquiry_receipt (
id uuid PRIMARY KEY,
user_id uuid NOT NULL,
operation_key uuid NOT NULL,
subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 100),
message text NOT NULL CHECK (char_length(message) BETWEEN 1 AND 2000),
received_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, operation_key)
);会員テーブルへの外部キーは、既存の会員管理の構成に合わせて追加します。
受付データとキーを同じ行へ置くため、1回のINSERTで両方が保存されます。「受付だけ作成され、キーの記録に失敗した」という中途半端な状態を避けられます。
複数テーブルを更新する処理へ広げる場合は、それらを同じトランザクションで確定する設計が必要です。
INSERTできなければ既存の受付を取得する
保存処理は.NETのDbDataSourceを受け取り、PostgreSQLへ接続して実行します。
ReceiptStore.csusing System.Data.Common;
public sealed class ReceiptStore(DbDataSource dataSource)
{
public async Task<Receipt> GetOrCreateAsync(
Guid userId, Guid key, InquiryBody body, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using (var insert = connection.CreateCommand())
{
insert.CommandText = """
INSERT INTO inquiry_receipt
(id, user_id, operation_key, subject, message)
VALUES (@id, @user_id, @operation_key, @subject, @message)
ON CONFLICT (user_id, operation_key) DO NOTHING
RETURNING id, subject, message, received_at;
""";
insert.CommandTimeout = 10;
Add(insert, "id", Guid.NewGuid());
Add(insert, "user_id", userId);
Add(insert, "operation_key", key);
Add(insert, "subject", body.Subject);
Add(insert, "message", body.Message);
await using var reader = await insert.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct)) return Read(reader);
}
// INSERTとは別のSQL文で、先行する処理が保存した行を読み取る。
await using var select = connection.CreateCommand();
select.CommandText = """
SELECT id, subject, message, received_at
FROM inquiry_receipt
WHERE user_id = @user_id AND operation_key = @operation_key;
""";
select.CommandTimeout = 10;
Add(select, "user_id", userId);
Add(select, "operation_key", key);
await using var existing = await select.ExecuteReaderAsync(ct);
if (!await existing.ReadAsync(ct))
throw new InvalidOperationException("受付結果を取得できませんでした。");
return Read(existing);
}
private static void Add(DbCommand command, string name, object value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.Value = value;
command.Parameters.Add(parameter);
}
private static Receipt Read(DbDataReader reader) => new(
reader.GetGuid(0), reader.GetString(1), reader.GetString(2),
reader.GetFieldValue<DateTimeOffset>(3));
}ON CONFLICT (user_id, operation_key) DO NOTHINGで、同じ組み合わせが既にある場合は追加しません。初回ならRETURNINGから作成した行を取得できます。PostgreSQLのINSERTの説明に、競合時の指定方法が記載されています。
対象とする一意制約を明示しているので、受付IDなど別の制約に違反したエラーまで「再送だった」と解釈しません。
received_atはDBが生成した値を読み取って返します。再送時にAPIサーバーの現在時刻を使うと、同じ操作なのに受付時刻が変わってしまいます。
INSERTとSELECTを別のSQL文にする理由
先行するINSERTがまだ確定していない場合、後続のINSERTは一意制約の判定で待機することがあります。先行処理がコミットすれば、後続は追加せず終了します。
その後、別のSELECTで保存済みの行を読み取ります。1つのSQL文の中にINSERTと検索を詰め込むと、同じスナップショットでは競合先の行が見えないケースがあるためです。
このコードは、明示的なトランザクションや外側のTransactionScopeを使わず、通常のRead Committedでコマンドを順に実行する前提です。Repeatable Readなどへ変える場合は、同じ読み取り結果になると考えないでください。PostgreSQLのRead Committedの説明も参照してください。
この流れは、受付行を途中で削除・変更しない契約があって成立します。SELECTで見つからなかった場合は、コードのようにエラーとして扱います。新しいキーを作って勝手に別件を追加する処理にはしません。
APIで認証と本文の一致を確認する
第1回のInquiryRulesと、保存処理を使ってコントローラーを作ります。
InquiriesController.csusing System.Security.Claims;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Authorize]
[AutoValidateAntiforgeryToken]
[Route("api/inquiries")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class InquiriesController(ReceiptStore store) : ControllerBase
{
[HttpGet("csrf")]
public IActionResult Csrf([FromServices] IAntiforgery antiforgery) =>
Ok(new { token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken });
[HttpPost]
public async Task<IActionResult> Create(
InquiryInput input, CancellationToken ct)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized();
var values = Request.Headers["Idempotency-Key"];
if (values.Count != 1 || !Guid.TryParseExact(values[0], "D", out var key) || key == Guid.Empty)
return Problem(statusCode: 400, title: "操作キーが不正です。");
var body = InquiryRules.Normalize(input);
if (!InquiryRules.IsValid(body))
return Problem(statusCode: 400, title: "件名は1〜100文字、本文は1〜2000文字で入力してください。");
var receipt = await store.GetOrCreateAsync(userId, key, body, ct);
if (!InquiryRules.Matches(receipt, body))
return Problem(statusCode: 409, title: "同じ操作キーで異なる内容は送信できません。");
return StatusCode(201, new ReceiptResult(receipt.Id, receipt.ReceivedAt));
}
}利用者IDはGUID形式のNameIdentifierを前提にしています。既存アプリの会員ID形式が異なる場合は変更してください。
キーが同じでも内容が異なれば、Matchesで拒否します。先に保存された本文を上書きすることはありません。同時に異なる本文が届いた場合も、先に保存できた側の内容が基準になります。
DB接続エラーやタイムアウトは、アプリ共通の例外処理で記録します。すべてのDBエラーを一律に「重複」として扱う処理は追加しません。
接続と認証設定を組み込む
PostgreSQLのドライバーにはNpgsqlを使います。対象の.NETに対応するバージョンを選んでプロジェクトへ追加してください。保存処理はNpgsqlのデータソースをDbDataSourceとして受け取れます。
Program.csへ統合する登録using System.Data.Common;
using Npgsql;
builder.Services.AddSingleton<DbDataSource>(_ =>
NpgsqlDataSource.Create(
builder.Configuration.GetConnectionString("App")
?? throw new InvalidOperationException("DB接続設定がありません。")));
builder.Services.AddScoped<ReceiptStore>();
builder.Services.AddControllersWithViews();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});接続文字列は環境変数や開発用シークレットなどで管理します。データソースを共有し、必要なときに接続を取得する方式はNpgsqlの基本的な使い方を参照してください。
AddControllersWithViewsは、AutoValidateAntiforgeryTokenのフィルターを登録するために使用します。既存のCookie認証、認可、UseAuthentication、UseAuthorization、MapControllersも必要です。
APIが未認証ならログインHTMLへリダイレクトせず401を返す設定にします。画面とAPIはHTTPSの同じOriginで公開し、開発中は/apiをバックエンドへプロキシしてください。
再送でも認証を省略しない
保存済みの結果を返すだけでも、現在の認証と閲覧範囲の確認は必要です。キーを知っていることは、利用者本人である証明にはなりません。
この例では、すべてのPOSTに認証とCSRF検証を適用し、DBの検索にも認証済み利用者IDを含めています。組織や権限による制限がある機能へ流用する場合は、その認可も再送のたびに実施してください。
次は同じ操作をReactから再送するで、キーと本文を画面に保持します。