using System.Text.Json; using Application.Abstractions.Messaging; namespace Web.Api.Endpoints.Payment; /// 토스 일반결제 웹훅 (PAYMENT_STATUS_CHANGED / DEPOSIT_CALLBACK — 토스 서버→서버) internal sealed class Webhook : IEndpoint { public void MapEndpoint(IEndpointRouteBuilder app) { /// 일반결제 웹훅은 서명 헤더가 없음 — 본문을 신뢰하지 않고 paymentKey 재조회 결과만 반영. /// 실패(5xx) 응답 시 토스가 재시도한다. app.MapPost("api/payments/webhook", async ( HttpContext httpContext, ISender sender, CancellationToken ct ) => { string? eventType = null; string? paymentKey = null; string? orderID = null; try { using var doc = await JsonDocument.ParseAsync(httpContext.Request.Body, cancellationToken: ct); var root = doc.RootElement; string? Get(JsonElement el, string key) => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; eventType = Get(root, "eventType"); orderID = Get(root, "orderId"); // DEPOSIT_CALLBACK 은 top-level orderId // PAYMENT_STATUS_CHANGED 는 data 에 Payment 객체 if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("data", out var data)) { paymentKey = Get(data, "paymentKey"); orderID ??= Get(data, "orderId"); } } catch (JsonException) { return Results.BadRequest(); } var result = await sender.Send(new Application.Features.Api.Payment.Toss.Webhook.Command(eventType, paymentKey, orderID), ct); // 일시 실패(재조회/저장 실패)는 5xx — 토스 재시도로 재처리 if (result.IsFailure) { return Results.StatusCode(StatusCodes.Status500InternalServerError); } return Results.Ok(); }) .WithTags("Payment") .AllowAnonymous(); } }