From 5c55c73cd2de6ae03afa26cc13dffebe98f6d0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sun, 8 Nov 2026 22:54:48 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=AF=B8=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EB=90=9C=20=ED=8E=98=EC=9D=B4=EC=95=B1=20=ED=86=B5?= =?UTF-8?q?=EB=B3=B4=20=EB=B3=B5=EA=B5=AC=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/MongleBackendApplication.java | 5 + .../payment/PaymentFeedbackService.java | 16 ++- .../payment/PaymentNotificationRecovery.java | 42 ++++++ ...PaymentNotificationRecoveryRepository.java | 5 + .../PaymentNotificationRepository.java | 4 +- .../payment/PaymentRecoveryEvidence.java | 8 ++ .../payment/PaymentRecoveryService.java | 123 +++++++++++++++++ .../user/withdrawal/WithdrawalData.java | 3 + .../payment/PaymentInspectCommand.java | 6 +- .../payment/PaymentInspectRequest.java | 25 ++-- .../operations/payment/PaymentInspector.java | 43 +++++- .../payment/PaymentRecoveryCommand.java | 126 ++++++++++++++++++ .../payment/PaymentRecoveryContext.java | 86 ++++++++++++ .../V12__payment_notification_recovery.sql | 11 ++ .../migration/Mysql84MigrationTest.java | 30 +++-- .../domain/InitialSchemaValidationTest.java | 2 +- .../AiGenerationLoggingIntegrationTest.java | 2 +- .../domain/payment/PaymentMigrationTest.java | 2 +- .../payment/PaymentInspectorContract.java | 3 +- .../payment/PaymentReclaimerContract.java | 3 +- 20 files changed, 514 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRecovery.java create mode 100644 src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRecoveryRepository.java create mode 100644 src/main/java/com/mongle/backend/domain/payment/PaymentRecoveryEvidence.java create mode 100644 src/main/java/com/mongle/backend/domain/payment/PaymentRecoveryService.java create mode 100644 src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryCommand.java create mode 100644 src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryContext.java create mode 100644 src/main/resources/db/migration/V12__payment_notification_recovery.sql diff --git a/src/main/java/com/mongle/backend/MongleBackendApplication.java b/src/main/java/com/mongle/backend/MongleBackendApplication.java index 68c59d5..48b5a53 100644 --- a/src/main/java/com/mongle/backend/MongleBackendApplication.java +++ b/src/main/java/com/mongle/backend/MongleBackendApplication.java @@ -5,6 +5,7 @@ import com.mongle.backend.operations.payment.PaymentInspectCommand; import com.mongle.backend.operations.payment.PaymentReclaimCommand; import com.mongle.backend.operations.payment.PaymentRefundCommand; +import com.mongle.backend.operations.payment.PaymentRecoveryCommand; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -12,6 +13,10 @@ public class MongleBackendApplication { public static void main(String[] args) { + if (PaymentRecoveryCommand.requested(args)) { + System.exit(PaymentRecoveryCommand.run(args, System.getenv(), System.in, System.out)); + return; + } if (PaymentRefundCommand.requested(args)) { System.exit(PaymentRefundCommand.run(args, System.getenv(), System.out)); return; diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java b/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java index 9dac7fb..d622571 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java @@ -23,6 +23,14 @@ public void accept(PayAppFeedback event) { transactions.requireAccountingUser(user); var order=orders.findByPublicIdAndUserId(event.orderId(),user) .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)); + bindVerifiedOrder(order, event); + // 이미 저장한 정확히 같은 통보는 시각 창이 지나도 재지급 없이 ACK한다. + if(notifications.existsById(event.eventHash())) return; + verifier.requireFresh(event); + applyVerified(order, event); + } + + void bindVerifiedOrder(PaymentOrder order, PayAppFeedback event) { if(!PaymentHashes.equal(order.getNotificationToken(),event.token())) invalid(); if(event.partialCancellation()) { if(event.originalAmount()!=order.getPrice() || event.amount()>order.getPrice()) invalid(); @@ -32,9 +40,11 @@ public void accept(PayAppFeedback event) { order.bindTransaction(event.transactionId()); } transactions.bind(order,event.transactionId()); - // 이미 저장한 정확히 같은 통보는 시각 창이 지나도 재지급 없이 ACK한다. - if(notifications.existsById(event.eventHash())) return; - verifier.requireFresh(event); + } + + // Callers authenticate the event and hold the accounting user lock. + void applyVerified(PaymentOrder order, PayAppFeedback event) { + Long user = order.getUserId(); // 운영 회수 완료는 종결 상태다. 검증된 늦은 통보는 감사용으로 보존하되 재지급/재보류하지 않는다. if(order.getState()==PaymentOrder.State.CREDIT_RECLAIMED) { notifications.saveAndFlush(PaymentNotification.create(event,order.getId())); diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRecovery.java b/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRecovery.java new file mode 100644 index 0000000..62e4b48 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRecovery.java @@ -0,0 +1,42 @@ +package com.mongle.backend.domain.payment; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "payment_notification_recoveries") +class PaymentNotificationRecovery { + @Id + private Long orderId; + @Column(nullable = false, length = 64, unique = true) + private String eventHash; + @Column(nullable = false, length = 80) + private String operatorReference; + @Column(nullable = false, length = 64) + private String evidenceHash; + @Column(nullable = false, length = 64) + private String snapshotHash; + @JdbcTypeCode(SqlTypes.TIMESTAMP) + @Column(nullable = false) + private Instant recoveredAt; + + static PaymentNotificationRecovery record(Long orderId, String eventHash, String operatorReference, + String evidenceHash, String snapshotHash, Instant recoveredAt) { + var recovery = new PaymentNotificationRecovery(); + recovery.orderId = orderId; + recovery.eventHash = eventHash; + recovery.operatorReference = operatorReference; + recovery.evidenceHash = evidenceHash; + recovery.snapshotHash = snapshotHash; + recovery.recoveredAt = recoveredAt; + return recovery; + } +} diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRecoveryRepository.java b/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRecoveryRepository.java new file mode 100644 index 0000000..cc1873e --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRecoveryRepository.java @@ -0,0 +1,5 @@ +package com.mongle.backend.domain.payment; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface PaymentNotificationRecoveryRepository extends JpaRepository {} diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRepository.java b/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRepository.java index ee79c20..95e28d7 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRepository.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentNotificationRepository.java @@ -1,3 +1,5 @@ package com.mongle.backend.domain.payment; import org.springframework.data.jpa.repository.JpaRepository; -public interface PaymentNotificationRepository extends JpaRepository {} +public interface PaymentNotificationRepository extends JpaRepository { + java.util.Optional findFirstByOrderIdOrderByRespondedAtDescEventHashDesc(Long orderId); +} diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRecoveryEvidence.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRecoveryEvidence.java new file mode 100644 index 0000000..33a0b58 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRecoveryEvidence.java @@ -0,0 +1,8 @@ +package com.mongle.backend.domain.payment; + +/** Trusted original notification and a separate confirmation from the seller portal. */ +public record PaymentRecoveryEvidence(String originalForm, String confirmedTransaction, int confirmedState, + long confirmedAmount) { + @Override + public String toString() { return "PaymentRecoveryEvidence[redacted]"; } +} diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRecoveryService.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRecoveryService.java new file mode 100644 index 0000000..aa294bc --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRecoveryService.java @@ -0,0 +1,123 @@ +package com.mongle.backend.domain.payment; + +import com.mongle.backend.global.error.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class PaymentRecoveryService { + private static final Set TERMINAL_FEEDBACK = Set.of(4, 8, 9, 32, 64); + + private final PaymentOrderRepository orders; + private final PaymentNotificationRepository notifications; + private final PaymentNotificationRecoveryRepository recoveries; + private final PaymentTransactionRepository bindings; + private final PaymentTransactions transactions; + private final PaymentFeedbackService feedback; + private final PayAppFeedbackVerifier verifier; + private final Clock authClock; + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public Result preview(String orderId, String reference, PaymentRecoveryEvidence evidence) { + var event = authenticated(orderId, evidence); + var order = orders.findByPublicId(orderId).orElseThrow(() -> failure("ORDER_NOT_FOUND")); + return evaluate(order, event, reference, null); + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public Result apply(String orderId, String reference, String expectedSnapshot, PaymentRecoveryEvidence evidence) { + var event = authenticated(orderId, evidence); + var user = orders.findOwnerByPublicId(orderId).orElseThrow(() -> failure("ORDER_NOT_FOUND")); + transactions.requireAccountingUser(user); + var order = orders.findByPublicIdAndUserId(orderId, user).orElseThrow(() -> failure("ORDER_NOT_FOUND")); + var preview = evaluate(order, event, reference, expectedSnapshot); + if (!"READY".equals(preview.status())) return preview; + if (!PaymentHashes.equal(preview.snapshot(), expectedSnapshot)) throw failure("SNAPSHOT_CHANGED"); + + feedback.bindVerifiedOrder(order, event); + feedback.applyVerified(order, event); + recoveries.saveAndFlush(PaymentNotificationRecovery.record(order.getId(), event.eventHash(), reference, + evidenceHash(event), expectedSnapshot, authClock.instant().truncatedTo(ChronoUnit.MICROS))); + return result("RECOVERED", order, null); + } + + private PayAppFeedback authenticated(String orderId, PaymentRecoveryEvidence evidence) { + PayAppFeedback event; + try { + event = verifier.verify(PayAppForm.decode(evidence.originalForm())); + } catch (BusinessException | IllegalArgumentException problem) { + throw failure("INVALID_ORIGINAL_NOTIFICATION"); + } + if (!orderId.equals(event.orderId()) || !event.transactionId().equals(evidence.confirmedTransaction()) + || event.amount() != evidence.confirmedAmount() || event.state() != evidence.confirmedState()) { + throw failure("SELLER_CONFIRMATION_MISMATCH"); + } + if (!TERMINAL_FEEDBACK.contains(event.state())) throw failure("RESULT_NOT_FINAL"); + return event; + } + + private Result evaluate(PaymentOrder order, PayAppFeedback event, String reference, String expectedSnapshot) { + if (!PaymentHashes.equal(order.getNotificationToken(), event.token()) || event.amount() != order.getPrice() + || !"KRW".equals(order.getCurrency())) throw failure("ORDER_EVIDENCE_MISMATCH"); + if ((order.getProviderTransaction() != null && !order.getProviderTransaction().equals(event.transactionId())) + || bindings.findById(event.transactionId()) + .filter(binding -> !order.getId().equals(binding.getOrderId())).isPresent()) { + throw failure("TRANSACTION_CONFLICT"); + } + + var prior = recoveries.findById(order.getId()); + if (prior.isPresent()) { + var recovered = prior.get(); + if (!recovered.getEvidenceHash().equals(evidenceHash(event)) || !recovered.getOperatorReference().equals(reference) + || (expectedSnapshot != null && !recovered.getSnapshotHash().equals(expectedSnapshot))) { + throw failure("RECOVERY_ALREADY_RECORDED"); + } + return result("ALREADY_RECOVERED", order, null); + } + if (notifications.existsById(event.eventHash())) return result("ALREADY_RECORDED", order, null); + + var now = authClock.instant(); + boolean unconfirmed = order.getState() == PaymentOrder.State.UNKNOWN + || order.getState() == PaymentOrder.State.PENDING + || (order.getState() == PaymentOrder.State.REQUESTING && !order.getRequestDeadline().isAfter(now)); + if (!unconfirmed || order.getCreditGrantId() != null) throw failure("ORDER_NOT_UNCONFIRMED"); + + var last = notifications.findFirstByOrderIdOrderByRespondedAtDescEventHashDesc(order.getId()).orElse(null); + var earliest = order.getCreatedAt().toInstant(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS).minusSeconds(1); + if (event.respondedAt().isAfter(now.plusSeconds(120)) || event.respondedAt().isBefore(earliest) + || (last != null && (event.respondedAt().isBefore(last.getRespondedAt()) + || TERMINAL_FEEDBACK.contains(last.getStateCode())))) { + throw failure("NOTIFICATION_TIMELINE_CONFLICT"); + } + + String snapshot = PaymentHashes.sha256(order.getPublicId() + ":" + order.getVersion() + ":" + + evidenceHash(event) + ":" + reference + ":" + (last == null ? "" : last.getEventHash())); + return result("READY", order, snapshot); + } + + private static String evidenceHash(PayAppFeedback event) { + return PaymentHashes.sha256(event.eventHash() + ":" + event.approvedAt()); + } + + private static Result result(String status, PaymentOrder order, String snapshot) { + return new Result(status, order.getPublicId(), order.getState(), order.getCreditGrantId() != null, snapshot); + } + + private static Failure failure(String code) { return new Failure(code); } + + public static final class Failure extends RuntimeException { + Failure(String code) { super(code); } + } + + public record Result(String status, String orderId, PaymentOrder.State orderState, boolean creditGranted, + String snapshot) {} +} diff --git a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java index b8c2b55..10c1374 100644 --- a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java +++ b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java @@ -33,6 +33,8 @@ union all select max(s.resolved_at) event_at from payment_refund_resolutions s join payment_refund_requests r on r.id=s.refund_id where r.user_id=:user union all select max(f.confirmed_at) event_at from payment_refund_follow_ups f join payment_refund_requests r on r.id=f.refund_id where r.user_id=:user + union all select max(r.recovered_at) event_at from payment_notification_recoveries r + join payment_orders o on o.id=r.order_id where o.user_id=:user union all select max(created_at) event_at from credit_ledger where user_id=:user and source_type='PAYMENT' ) accounting_dates """) @@ -146,6 +148,7 @@ public void deleteAccounting(Long user, LocalDateTime now) { "delete from payment_refund_requests where user_id=:user", "delete from payment_credit_reclamations where user_id=:user", "delete from credit_payment_holds where user_id=:user", + "delete from payment_notification_recoveries where order_id in (select id from payment_orders where user_id=:user)", "delete from payment_notifications where order_id in (select id from payment_orders where user_id=:user)", "delete from payment_transaction_bindings where order_id in (select id from payment_orders where user_id=:user)", "delete from payment_orders where user_id=:user", diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentInspectCommand.java b/src/main/java/com/mongle/backend/operations/payment/PaymentInspectCommand.java index c446891..1458f07 100644 --- a/src/main/java/com/mongle/backend/operations/payment/PaymentInspectCommand.java +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentInspectCommand.java @@ -23,9 +23,9 @@ public static int run(String[] arguments, Map environment, Print throw new Failure("INVALID_ARGUMENTS"); } var settings = PaymentInspectSettings.fromEnvironment(environment); - var report = - new PaymentInspector(settings.dataSource(), Clock.systemUTC()) - .inspect(request.orderId()); + var inspector = new PaymentInspector(settings.dataSource(), Clock.systemUTC()); + var report = request.mode() == PaymentInspectRequest.Mode.ORDER + ? inspector.inspect(request.orderId()) : inspector.unconfirmed(request.orderId()); output.println(json.writeValueAsString(report)); return 0; } catch (Exception failure) { diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentInspectRequest.java b/src/main/java/com/mongle/backend/operations/payment/PaymentInspectRequest.java index 8a62a60..5da7ddb 100644 --- a/src/main/java/com/mongle/backend/operations/payment/PaymentInspectRequest.java +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentInspectRequest.java @@ -2,9 +2,9 @@ import java.util.*; -record PaymentInspectRequest(String orderId) { +record PaymentInspectRequest(Mode mode, String orderId) { + enum Mode { ORDER, UNCONFIRMED } static PaymentInspectRequest parse(String[] arguments) { - if (arguments.length != 2) throw new IllegalArgumentException(); var values = new HashMap(); for (String argument : arguments) { int split = argument.indexOf('='); @@ -13,12 +13,19 @@ static PaymentInspectRequest parse(String[] arguments) { argument.substring(0, split), argument.substring(split + 1)) != null) throw new IllegalArgumentException(); } - if (!values.keySet().equals(Set.of("--payment-inspect", "--order-id")) - || !"order".equals(values.get("--payment-inspect"))) - throw new IllegalArgumentException(); - String id = values.get("--order-id"); - if (!id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") - || !UUID.fromString(id).toString().equals(id)) throw new IllegalArgumentException(); - return new PaymentInspectRequest(id); + String mode = values.remove("--payment-inspect"); + if ("order".equals(mode) && values.keySet().equals(Set.of("--order-id"))) { + return new PaymentInspectRequest(Mode.ORDER, canonical(values.get("--order-id"))); + } + if ("unconfirmed".equals(mode) && (values.isEmpty() || values.keySet().equals(Set.of("--after-order-id")))) { + return new PaymentInspectRequest(Mode.UNCONFIRMED, + values.isEmpty() ? "" : canonical(values.get("--after-order-id"))); + } + throw new IllegalArgumentException(); + } + + private static String canonical(String id) { + if (!UUID.fromString(id).toString().equals(id)) throw new IllegalArgumentException(); + return id; } } diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java b/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java index 24e1ca2..d514925 100644 --- a/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java @@ -23,6 +23,47 @@ final class PaymentInspector { } Map inspect(String orderId) throws SQLException { + return read(db -> snapshot(db, orderId)); + } + + Map unconfirmed(String afterOrderId) throws SQLException { + return read(db -> { + Instant now = clock.instant(); + var rows = db.query(""" + select candidates.*, + (select count(*) from payment_notifications n where n.order_id=candidates.id) as notice_count, + (select max(n.responded_at) from payment_notifications n where n.order_id=candidates.id) as last_notice_at + from ( + select id,public_id,state,request_deadline,created_at + from payment_orders + where public_id>? and (state in ('UNKNOWN','PENDING') or (state='REQUESTING' and request_deadline<=?)) + order by public_id limit 21 + ) candidates order by public_id + """, row -> { + String state = row.getString("state"); + long notices = row.getLong("notice_count"); + return map("orderId", row.getString("public_id"), "storedState", state, + "effectiveState", "REQUESTING".equals(state) ? "UNKNOWN" : state, + "reason", switch (state) { + case "REQUESTING" -> "REQUEST_DEADLINE_PASSED"; + case "PENDING" -> "AWAITING_FINAL_NOTIFICATION"; + default -> "REQUEST_RESULT_UNKNOWN"; + }, + "createdAt", instant(row, "created_at"), "requestDeadline", instant(row, "request_deadline"), + "notificationCount", notices, "lastNotificationAt", instant(row, "last_notice_at")); + }, afterOrderId, Timestamp.from(now)); + var page = rows.stream().limit(20).toList(); + return map("event", "payment-unconfirmed-orders", "scope", "INTERNAL_DATABASE_ONLY", "observedAt", now, + "items", page, "nextAfterOrderId", rows.size() > 20 ? page.getLast().get("orderId") : null); + }); + } + + @FunctionalInterface + private interface ReadOperation { + Map execute(Session db) throws SQLException; + } + + private Map read(ReadOperation operation) throws SQLException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); Connection connection; try { @@ -35,7 +76,7 @@ Map inspect(String orderId) throws SQLException { connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); connection.setAutoCommit(false); try { - return snapshot(new Session(connection, deadline), orderId); + return operation.execute(new Session(connection, deadline)); } catch (SQLTimeoutException failure) { throw new PaymentInspectCommand.Failure("INSPECTION_TIMEOUT"); } finally { diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryCommand.java b/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryCommand.java new file mode 100644 index 0000000..881d1c2 --- /dev/null +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryCommand.java @@ -0,0 +1,126 @@ +package com.mongle.backend.operations.payment; + +import com.mongle.backend.domain.credit.CreditProperties; +import com.mongle.backend.domain.payment.PayAppProperties; +import com.mongle.backend.domain.payment.PaymentRecoveryEvidence; +import com.mongle.backend.domain.payment.PaymentRecoveryService; +import com.mongle.backend.global.error.BusinessException; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.io.InputStream; +import java.io.PrintStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.*; + +public final class PaymentRecoveryCommand { + private static final int MAX_INPUT_BYTES = 65_536; + + private PaymentRecoveryCommand() {} + + public static boolean requested(String[] args) { + return Arrays.stream(args).anyMatch(arg -> arg.startsWith("--payment-recovery")); + } + + public static int run(String[] args, Map env, InputStream input, PrintStream output) { + var json = JsonMapper.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).build(); + try (var ignored = new PaymentInspectLogging()) { + var request = Request.parse(args); + if (!"true".equals(env.get("MONGLE_PAYMENT_RECOVERY_ENABLED"))) + throw new PaymentInspectCommand.Failure("OPERATOR_COMMAND_DISABLED"); + var dbEnv = new HashMap<>(env); + dbEnv.put("MONGLE_PAYMENT_INSPECT_ENABLED", "true"); + dbEnv.put("MONGLE_PAYMENT_INSPECT_EXPECTED_HOST", env.get("MONGLE_PAYMENT_RECOVERY_EXPECTED_HOST")); + dbEnv.put("MONGLE_PAYMENT_INSPECT_EXPECTED_DATABASE", env.get("MONGLE_PAYMENT_RECOVERY_EXPECTED_DATABASE")); + var source = PaymentInspectSettings.fromEnvironment(dbEnv).dataSource(); + var payApp = payApp(env); + var evidence = evidence(json, input); + boolean creditEnabled = "true".equals(env.get("MONGLE_CREDIT_ENABLED")); + var credits = new CreditProperties(creditEnabled, CreditProperties.ExpiryPolicy.NO_EXPIRY, + null, null, null, null, null); + try (var context = PaymentRecoveryContext.open(source, payApp, credits, Clock.systemUTC())) { + var recovery = context.getBean(PaymentRecoveryService.class); + var result = request.snapshot() == null + ? recovery.preview(request.orderId(), request.reference(), evidence) + : recovery.apply(request.orderId(), request.reference(), request.snapshot(), evidence); + output.println(json.writeValueAsString(result)); + return 0; + } + } catch (Exception failure) { + String code = switch (failure) { + case PaymentInspectCommand.Failure known -> known.getMessage(); + case PaymentRecoveryService.Failure known -> known.getMessage(); + case BusinessException business -> business.getErrorCode().getCode(); + default -> "PAYMENT_RECOVERY_FAILED"; + }; + output.println(json.writeValueAsString(Map.of("event", "payment-recovery-failed", "code", code))); + return 1; + } + } + + private static PayAppProperties payApp(Map env) { + try { + if (!"true".equals(env.get("MONGLE_PAYAPP_ENABLED"))) throw new IllegalArgumentException(); + return new PayAppProperties(true, env.get("PAYAPP_SELLER_ID"), env.get("PAYAPP_LINK_KEY"), + env.get("PAYAPP_LINK_VALUE"), env.get("PAYAPP_REQUEST_KEY"), + URI.create(env.get("PAYAPP_FEEDBACK_URL")), URI.create(env.get("PAYAPP_RETURN_URL")), + List.of("card"), Duration.ofSeconds(3), Duration.ofSeconds(30)); + } catch (RuntimeException invalid) { + throw new PaymentInspectCommand.Failure("INVALID_PAYAPP_CONFIGURATION"); + } + } + + private static PaymentRecoveryEvidence evidence(JsonMapper json, InputStream input) { + try { + byte[] bytes = input.readNBytes(MAX_INPUT_BYTES + 1); + if (bytes.length > MAX_INPUT_BYTES) throw new IllegalArgumentException(); + var node = json.readTree(bytes); + if (!node.isObject() || node.size() != 4 || !node.path("originalForm").isString() + || !node.path("confirmedTransaction").isString() + || !node.path("confirmedState").isIntegralNumber() || !node.path("confirmedState").canConvertToInt() + || !node.path("confirmedAmount").isIntegralNumber() || !node.path("confirmedAmount").canConvertToLong()) { + throw new IllegalArgumentException(); + } + String form = node.path("originalForm").asString(); + String transaction = node.path("confirmedTransaction").asString(); + long amount = node.path("confirmedAmount").asLong(); + if (form.isBlank() || form.getBytes(StandardCharsets.UTF_8).length > 32768 + || !transaction.matches("[1-9][0-9]{0,39}") || amount < 1 || amount > Integer.MAX_VALUE) { + throw new IllegalArgumentException(); + } + return new PaymentRecoveryEvidence(form, transaction, node.path("confirmedState").asInt(), amount); + } catch (Exception invalid) { + throw new PaymentInspectCommand.Failure("INVALID_RECOVERY_EVIDENCE"); + } + } + + private record Request(String orderId, String reference, String snapshot) { + static Request parse(String[] args) { + try { + var values = new HashMap(); + for (String arg : args) { + int split = arg.indexOf('='); + if (split < 1 || values.putIfAbsent(arg.substring(0, split), arg.substring(split + 1)) != null) + throw new IllegalArgumentException(); + } + String mode = values.remove("--payment-recovery"); + String id = values.remove("--order-id"); + String reference = values.remove("--operator-reference"); + String snapshot = "apply".equals(mode) ? values.remove("--expected-snapshot") : null; + if (!values.isEmpty() || !Set.of("preview", "apply").contains(mode) + || !UUID.fromString(id).toString().equals(id) + || !reference.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,79}") + || ("apply".equals(mode) && !snapshot.matches("[0-9a-f]{64}"))) + throw new IllegalArgumentException(); + return new Request(id, reference, snapshot); + } catch (RuntimeException invalid) { + throw new PaymentInspectCommand.Failure("INVALID_ARGUMENTS"); + } + } + } +} diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryContext.java b/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryContext.java new file mode 100644 index 0000000..2cc9fbd --- /dev/null +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryContext.java @@ -0,0 +1,86 @@ +package com.mongle.backend.operations.payment; + +import com.mongle.backend.domain.credit.CreditAccount; +import com.mongle.backend.domain.credit.CreditAccountRepository; +import com.mongle.backend.domain.credit.CreditProperties; +import com.mongle.backend.domain.credit.CreditService; +import com.mongle.backend.domain.payment.*; +import com.mongle.backend.domain.user.entity.User; +import com.mongle.backend.domain.user.repository.UserRepository; +import com.mongle.backend.global.config.JpaConfig; + +import jakarta.persistence.EntityManagerFactory; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import javax.sql.DataSource; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** Only accounting infrastructure and the normal payment handlers run in the one-shot command. */ +final class PaymentRecoveryContext { + private PaymentRecoveryContext() {} + + static AnnotationConfigApplicationContext open(DataSource source, PayAppProperties payApp, + CreditProperties credits, Clock clock) { + var context = new AnnotationConfigApplicationContext(); + try { + context.registerBean(DataSource.class, () -> source); + context.registerBean(PayAppProperties.class, () -> payApp); + context.registerBean(CreditProperties.class, () -> credits); + context.registerBean(Clock.class, () -> clock); + context.registerBean(PaymentProperties.class, () -> new PaymentProperties(false, List.of())); + context.register(Infrastructure.class, CreditService.class, PaymentCatalog.class, + PaymentTransactions.class, PayAppFeedbackVerifier.class, PaymentFeedbackService.class, + PaymentRecoveryService.class); + context.refresh(); + return context; + } catch (RuntimeException failure) { + context.close(); + throw failure; + } + } + + // Explicitly registered above, outside ordinary component scanning. + @EnableTransactionManagement + @EnableJpaRepositories(basePackageClasses = {PaymentOrderRepository.class, CreditAccountRepository.class, UserRepository.class}) + @Import(JpaConfig.class) + static class Infrastructure { + @Bean + PaymentGateway paymentGateway() { + return new PaymentGateway() { + @Override public boolean available() { return false; } + @Override public CompletableFuture request(Command command) { + throw new IllegalStateException("Outbound payment requests are unavailable in recovery"); + } + }; + } + + @Bean + LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource source) { + var factory = new LocalContainerEntityManagerFactoryBean(); + factory.setDataSource(source); + factory.setPackagesToScan(CreditAccount.class.getPackageName(), User.class.getPackageName(), + PaymentOrder.class.getPackageName()); + factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); + factory.setJpaPropertyMap(Map.of("hibernate.hbm2ddl.auto", "validate", "hibernate.jdbc.time_zone", "UTC", + "hibernate.show_sql", false, "jakarta.persistence.query.timeout", 5000)); + return factory; + } + + @Bean + JpaTransactionManager transactionManager(EntityManagerFactory factory) { + var manager = new JpaTransactionManager(factory); + manager.setDefaultTimeout(15); + return manager; + } + } +} diff --git a/src/main/resources/db/migration/V12__payment_notification_recovery.sql b/src/main/resources/db/migration/V12__payment_notification_recovery.sql new file mode 100644 index 0000000..16f651c --- /dev/null +++ b/src/main/resources/db/migration/V12__payment_notification_recovery.sql @@ -0,0 +1,11 @@ +CREATE TABLE payment_notification_recoveries ( + order_id BIGINT NOT NULL PRIMARY KEY, + event_hash VARCHAR(64) NOT NULL, + operator_reference VARCHAR(80) NOT NULL, + evidence_hash VARCHAR(64) NOT NULL, + snapshot_hash VARCHAR(64) NOT NULL, + recovered_at DATETIME(6) NOT NULL, + CONSTRAINT uk_payment_recovery_event UNIQUE (event_hash), + CONSTRAINT fk_payment_recovery_order FOREIGN KEY (order_id) REFERENCES payment_orders(id), + CONSTRAINT fk_payment_recovery_notification FOREIGN KEY (event_hash) REFERENCES payment_notifications(event_hash) +); diff --git a/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java b/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java index 871051c..0faf42e 100644 --- a/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java +++ b/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java @@ -83,7 +83,7 @@ String location() throws Exception { assertThat(new MigrationEngine(source, "flyway_existing", "filesystem:" + priorRelease).execute("migrate")) .containsEntry("targetVersion", "3"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='recent_interpretations'", Integer.class)).isZero(); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 8).containsEntry("targetVersion", "11"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 9).containsEntry("targetVersion", "12"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); assertThatThrownBy(() -> engine.execute("baseline")).hasMessage("HISTORY_ALREADY_EXISTS"); assertThat(jdbc.queryForObject("SELECT nickname FROM users WHERE email='preserved@test.invalid'", String.class)).isEqualTo("keep"); @@ -102,7 +102,7 @@ String location() throws Exception { jdbc.update("INSERT INTO users(email,nickname,created_at,updated_at) VALUES('summary-preserved@test.invalid','keep-summary',NOW(),NOW())"); var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); var engine=new MigrationEngine(source,"flyway_summary"); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 7).containsEntry("targetVersion", "11"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 8).containsEntry("targetVersion", "12"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version in ('1','3','4') order by installed_rank")).isEqualTo(history); assertThat(jdbc.queryForObject("select nickname from users where email='summary-preserved@test.invalid'",String.class)).isEqualTo("keep-summary"); @@ -124,9 +124,9 @@ String location() throws Exception { var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); var accounts=jdbc.queryForList("select * from credit_accounts");var grants=jdbc.queryForList("select * from credit_grants"); var engine=new MigrationEngine(ds,"flyway_reclamation"); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 6).containsEntry("targetVersion", "11"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 7).containsEntry("targetVersion", "12"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); - assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('6','7','8','9','10','11') order by installed_rank")).isEqualTo(history); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('6','7','8','9','10','11','12') order by installed_rank")).isEqualTo(history); assertThat(jdbc.queryForList("select * from credit_accounts")).isEqualTo(accounts); var upgradedGrants=jdbc.queryForList("select * from credit_grants"); upgradedGrants.forEach(row -> assertThat(row.remove("forfeited")).isEqualTo(0L)); @@ -146,13 +146,13 @@ String location() throws Exception { var before=jdbc.queryForMap("select * from users where email='legal-existing@test.invalid'"); var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); var engine=new MigrationEngine(ds,"flyway_legal"); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 4).containsEntry("targetVersion", "11"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 5).containsEntry("targetVersion", "12"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); var after=jdbc.queryForMap("select * from users where email='legal-existing@test.invalid'"); for(String column:java.util.List.of("terms_version","terms_digest","privacy_version","privacy_digest")) assertThat(after.remove(column)).isNull(); assertThat(after).isEqualTo(before); - assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('8','9','10','11') order by installed_rank")).isEqualTo(history); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('8','9','10','11','12') order by installed_rank")).isEqualTo(history); assertThat(jdbc.queryForObject("select count(*) from legal_consents",Long.class)).isZero(); assertThat(jdbc.queryForObject("select count(*) from legal_document_versions where document_digest is null",Long.class)).isEqualTo(2); } @@ -234,14 +234,26 @@ insert into payment_refund_evidence(refund_id,provider_transaction,amount,full_c values(?,'555010',1100,true) """, refund); var v10History = jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); - var engine = new MigrationEngine(source, "flyway_refund"); - assertThat(engine.execute("migrate")).containsEntry("targetVersion", "11").containsEntry("migrationsExecuted", 1); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); + String followUpMigration = "V11__payment_refund_follow_up.sql"; + try (var sql = new ClassPathResource("db/migration/" + followUpMigration).getInputStream()) { + Files.copy(sql, prior.resolve(followUpMigration)); + } + assertThat(new MigrationEngine(source, "flyway_refund", "filesystem:" + prior).execute("migrate")) + .containsEntry("targetVersion", "11").containsEntry("migrationsExecuted", 1); assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version<>'11' order by installed_rank")) .isEqualTo(v10History); assertThat(jdbc.queryForMap("select amount,full_cancellation,follow_up_id from payment_refund_evidence where refund_id=?", refund)) .containsEntry("amount", 1100L).containsEntry("full_cancellation", true).containsEntry("follow_up_id", null); assertThat(jdbc.queryForObject("select count(*) from payment_refund_follow_ups", Long.class)).isZero(); + var v11History = jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); + var v11Orders = jdbc.queryForList("select * from payment_orders"); + var engine = new MigrationEngine(source, "flyway_refund"); + assertThat(engine.execute("migrate")).containsEntry("targetVersion", "12").containsEntry("migrationsExecuted", 1); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version<>'12' order by installed_rank")) + .isEqualTo(v11History); + assertThat(jdbc.queryForList("select * from payment_orders")).isEqualTo(v11Orders); + assertThat(jdbc.queryForObject("select count(*) from payment_notification_recoveries", Long.class)).isZero(); } @Test void baselineRejectsColumnIndexAndForeignKeyDriftEvenWith33Tables() throws Exception { diff --git a/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java b/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java index 88385bf..e69786d 100644 --- a/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java +++ b/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java @@ -11,7 +11,7 @@ @SpringBootTest(properties = { "spring.datasource.url=jdbc:h2:mem:mongle-schema;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "spring.sql.init.mode=always", - "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.sql", + "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.sql,classpath:db/migration/V12__payment_notification_recovery.sql", "spring.jpa.hibernate.ddl-auto=validate" }) @ActiveProfiles("test") diff --git a/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java b/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java index d1b8f6e..b10e285 100644 --- a/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java @@ -47,7 +47,7 @@ @SpringBootTest(properties = { "spring.datasource.url=jdbc:h2:mem:mongle-ai-logging;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "spring.sql.init.mode=always", - "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.sql", + "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.sql,classpath:db/migration/V12__payment_notification_recovery.sql", "spring.jpa.hibernate.ddl-auto=validate", "mongle.ai.liner.api-key=logging-test-key", "mongle.ai.liner.connect-timeout=300ms", diff --git a/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java b/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java index 6eaab22..46411f6 100644 --- a/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java +++ b/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java @@ -5,7 +5,7 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.jdbc.core.JdbcTemplate; @SpringBootTest(properties={"spring.datasource.url=jdbc:h2:mem:payment-schema;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", - "spring.sql.init.mode=always","spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.sql","spring.jpa.hibernate.ddl-auto=validate"}) + "spring.sql.init.mode=always","spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.sql,classpath:db/migration/V12__payment_notification_recovery.sql","spring.jpa.hibernate.ddl-auto=validate"}) @ActiveProfiles("test") class PaymentMigrationTest { @Test void initialSchemaMatchesHibernateAndMigrationCanBeAppliedTwice() { diff --git a/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java b/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java index 1f2e21c..7788be3 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java @@ -43,7 +43,8 @@ DataSource dataSource() { ""); new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"),new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql"), new ClassPathResource("db/migration/V9__payment_refund_intake.sql"), new ClassPathResource("db/migration/V10__payment_refund_settlement.sql"), - new ClassPathResource("db/migration/V11__payment_refund_follow_up.sql")).execute(ds); + new ClassPathResource("db/migration/V11__payment_refund_follow_up.sql"), + new ClassPathResource("db/migration/V12__payment_notification_recovery.sql")).execute(ds); return ds; } diff --git a/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java b/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java index d1231c4..cc0c36c 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java @@ -55,7 +55,8 @@ DataSource dataSource() { new ClassPathResource("db/schema-initial.sql"), new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql"), new ClassPathResource("db/migration/V9__payment_refund_intake.sql"), new ClassPathResource("db/migration/V10__payment_refund_settlement.sql"), - new ClassPathResource("db/migration/V11__payment_refund_follow_up.sql")) + new ClassPathResource("db/migration/V11__payment_refund_follow_up.sql"), + new ClassPathResource("db/migration/V12__payment_notification_recovery.sql")) .execute(ds); return ds; } From 59f0ff7c806a20353d706405ca2ac7b3ebb28eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sun, 8 Nov 2026 22:54:48 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20=EA=B2=B0=EC=A0=9C=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EA=B2=BD=ED=95=A9=EA=B3=BC=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?JAR=20=EA=B2=80=EC=A6=9D=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/mysql84-test.py | 2 + scripts/payment_recovery_smoke.py | 100 ++++++++++++++ .../payment/RefundIntakeIntegrationTest.java | 130 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 scripts/payment_recovery_smoke.py diff --git a/scripts/mysql84-test.py b/scripts/mysql84-test.py index aaee386..2ec567c 100644 --- a/scripts/mysql84-test.py +++ b/scripts/mysql84-test.py @@ -223,6 +223,8 @@ def credit_command(operation, key="packaged-smoke", amount="100", target=account verify_payment_inspection(repo, jars[0], credit_env, sql) from payment_reclamation_smoke import verify as verify_payment_reclamation verify_payment_reclamation(repo, jars[0], credit_env, sql) + from payment_recovery_smoke import verify as verify_payment_recovery + verify_payment_recovery(repo, jars[0], credit_env, sql) return 0 except (Exception, KeyboardInterrupt): diff --git a/scripts/payment_recovery_smoke.py b/scripts/payment_recovery_smoke.py new file mode 100644 index 0000000..1c04bae --- /dev/null +++ b/scripts/payment_recovery_smoke.py @@ -0,0 +1,100 @@ +"""Run the packaged recovery command against the disposable TLS fixture.""" +import hashlib +import json +import subprocess +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from urllib.parse import urlencode + + +def verify(repo, jar, env, sql): + if not env["DB_URL"].startswith("jdbc:mysql://127.0.0.1:") or not env["DB_URL"].endswith("/credit_test"): + raise RuntimeError("Disposable loopback fixture required.") + database = "credit_test" + seller, key, value, secret = ("fixture-seller", "fixture-link-key", "fixture-link-value", "fixture-request-key") + config = { + **env, "MONGLE_PAYMENT_INSPECT_ENABLED": "true", "MONGLE_PAYMENT_INSPECT_EXPECTED_HOST": "127.0.0.1", + "MONGLE_PAYMENT_INSPECT_EXPECTED_DATABASE": database, "MONGLE_PAYMENT_RECOVERY_ENABLED": "true", + "MONGLE_PAYMENT_RECOVERY_EXPECTED_HOST": "127.0.0.1", "MONGLE_PAYMENT_RECOVERY_EXPECTED_DATABASE": database, + "MONGLE_PAYMENT_ENABLED": "false", "MONGLE_PAYAPP_ENABLED": "true", "MONGLE_CREDIT_ENABLED": "true", + "PAYAPP_SELLER_ID": seller, "PAYAPP_LINK_KEY": key, "PAYAPP_LINK_VALUE": value, "PAYAPP_REQUEST_KEY": secret, + "PAYAPP_FEEDBACK_URL": "https://api.example.invalid/api/v1/payments/payapp/feedback", + "PAYAPP_RETURN_URL": "https://example.invalid/payment", + } + owner = sql("""INSERT INTO users(email,nickname,created_at,updated_at) + VALUES('recovery-cli@test.invalid','Recovery',UTC_TIMESTAMP(),UTC_TIMESTAMP()); SELECT LAST_INSERT_ID();""", database).strip() + + def fixture(state, deadline): + public_id, token = str(uuid.uuid4()), str(uuid.uuid4()) + sql(f"""INSERT INTO payment_orders(public_id,user_id,request_id,input_hash,notification_token,product_id, + product_name,price,currency,credits,state,request_deadline,version,created_at,updated_at) + VALUES('{public_id}',{owner},'{uuid.uuid4()}',REPEAT('a',64),'{token}','credit-pack','Pack', + 1100,'KRW',10,'{state}',UTC_TIMESTAMP()+INTERVAL {deadline} MINUTE,0, + UTC_TIMESTAMP()-INTERVAL 2 HOUR,UTC_TIMESTAMP());""", database) + return public_id, token + + order, token = fixture("UNKNOWN", -60) + expired, _ = fixture("REQUESTING", -60) + pending, _ = fixture("PENDING", -60) + fresh, _ = fixture("REQUESTING", 10) + ready, _ = fixture("READY", -60) + stamp = (datetime.now(timezone(timedelta(hours=9))) - timedelta(hours=1)).strftime("%Y%m%d%H%M%S") + transaction = str(uuid.uuid4().int % 10**25 + 1) + form = urlencode({ + "userid": seller, "linkkey": key, "linkval": value, "hash": hashlib.sha256((seller+stamp+secret).encode()).hexdigest(), + "responsedate": stamp, "pay_date": stamp, "var1": order, "var2": token, "mul_no": transaction, + "pay_state": "4", "price": "1100", + }) + evidence = json.dumps({"originalForm": form, "confirmedTransaction": transaction, + "confirmedState": 4, "confirmedAmount": 1100}) + + def command(args, data="", expected=0, overrides=None): + result = subprocess.run( + ["java", "-Xmx192m", "-jar", str(jar), *args], cwd=repo, env={**config, **(overrides or {})}, + input=data, capture_output=True, text=True, timeout=90, + ) + sensitive = (token, key, value, secret, transaction, config["DB_PASSWORD"], config["DB_TRUSTSTORE_PASSWORD"]) + if (result.returncode != expected or result.stderr.strip() or len(result.stdout.splitlines()) != 1 + or any(item in result.stdout for item in sensitive)): + # Print only a whitelisted diagnostic code from a failed fixture. + try: + code = json.loads(result.stdout).get("code", "") + except (ValueError, AttributeError): + code = "" + allowed = {"OPERATOR_COMMAND_DISABLED", "PAYMENT_RECOVERY_FAILED", "INVALID_CONFIGURATION", + "INVALID_PAYAPP_CONFIGURATION", "INVALID_RECOVERY_EVIDENCE", "INVALID_ORIGINAL_NOTIFICATION", + "ORDER_NOT_UNCONFIRMED", "NOTIFICATION_TIMELINE_CONFLICT", "SNAPSHOT_CHANGED"} + raise RuntimeError("Packaged payment recovery failed. " + (code if code in allowed else "")) + return json.loads(result.stdout) + + preview_args = ["--payment-recovery=preview", "--order-id=" + order, "--operator-reference=fixture-141"] + if command(preview_args, evidence, expected=1, overrides={"MONGLE_PAYMENT_RECOVERY_ENABLED": "false"}).get("code") != "OPERATOR_COMMAND_DISABLED": + raise RuntimeError("Recovery flag was bypassed.") + if command(preview_args, evidence + " {}", expected=1).get("code") != "INVALID_RECOVERY_EVIDENCE": + raise RuntimeError("Recovery accepted trailing evidence.") + + sql("REVOKE INSERT,UPDATE,DELETE ON credit_test.* FROM 'mongle_app'@'127.0.0.1';") + try: + items = command(["--payment-inspect=unconfirmed"])["items"] + found = {item["orderId"]: item for item in items} + if (set(found) != {order, expired, pending} or fresh in found or ready in found + or found[expired]["effectiveState"] != "UNKNOWN" or found[pending]["notificationCount"] != 0): + raise RuntimeError("Unconfirmed discovery mismatch.") + later = command(["--payment-inspect=unconfirmed", "--after-order-id=" + order])["items"] + if any(item["orderId"] <= order for item in later): + raise RuntimeError("Unconfirmed cursor repeated a previous row.") + preview = command(preview_args, evidence) + if preview["status"] != "READY" or preview["creditGranted"]: + raise RuntimeError("Recovery preview mismatch.") + finally: + sql("GRANT SELECT,INSERT,UPDATE,DELETE ON credit_test.* TO 'mongle_app'@'127.0.0.1';") + + apply_args = ["--payment-recovery=apply", *preview_args[1:], "--expected-snapshot=" + preview["snapshot"]] + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: command(apply_args, evidence), range(2))) + if sorted(item["status"] for item in results) != ["ALREADY_RECOVERED", "RECOVERED"]: + raise RuntimeError("Recovery JVMs did not serialize exactly once.") + if sql(f"SELECT count(*) FROM credit_ledger WHERE user_id={owner} AND kind='GRANT';", database).strip() != "1": + raise RuntimeError("Recovery duplicated or skipped the credit ledger.") + print("Packaged JAR: unconfirmed discovery and preview SELECT-only; concurrent recovery applied once.", flush=True) diff --git a/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java b/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java index e4475dd..c50ca40 100644 --- a/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java @@ -66,6 +66,7 @@ class RefundIntakeIntegrationTest { @Autowired PaymentOrderRepository orders; @Autowired RefundService refunds; @Autowired PaymentFeedbackService feedback; + @Autowired PaymentRecoveryService recovery; @Autowired PayAppFeedbackVerifier verifier; @Autowired CreditService credits; @Autowired CreditReservationService reservations; @@ -304,6 +305,135 @@ void authenticatedIntakeAndWithdrawalPreserveOpenReceiptsAndIsolateOtherMembers( assertThat(users.findById(user).orElseThrow().isWithdrawn()).isTrue(); } + @Test + void recoversAgedApprovalThroughTheOperatorPathAndPreservesWebhookFreshness() throws Exception { + var order = readyOrder(10); + jdbc.update("update payment_orders set state='UNKNOWN',provider_transaction=null where id=?", order.getId()); + Instant notice = recoveryNoticeTime(order); + var form = notification(order, 4, notice); + form.put("pay_date", form.get("responsedate")); + now = notice.plus(Duration.ofHours(1)); + var evidence = recoveryEvidence(form); + assertError(() -> withdrawals.withdraw(user), UserErrorCode.PAYMENT_UNCONFIRMED); + try (var client = HttpClient.newHttpClient()) { + var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/api/v1/payments/payapp/feedback")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(evidence.originalForm())).build(); + assertThat(client.send(request, HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(400); + var preview = recovery.preview(order.getPublicId(), "approval-141", evidence); + assertThat(preview.status()).isEqualTo("READY"); + assertThat(credits.balance(user).available()).isZero(); + assertThat(recovery.apply(order.getPublicId(), "approval-141", preview.snapshot(), evidence).status()) + .isEqualTo("RECOVERED"); + assertThat(recovery.apply(order.getPublicId(), "approval-141", preview.snapshot(), evidence).status()) + .isEqualTo("ALREADY_RECOVERED"); + assertThat(client.send(request, HttpResponse.BodyHandlers.ofString()).body()).isEqualTo("SUCCESS"); + } + assertThat(orders.findById(order.getId()).orElseThrow().getApprovedAt()).isEqualTo(notice); + assertThat(credits.balance(user).available()).isEqualTo(10); + assertThat(jdbc.queryForObject("select count(*) from payment_notification_recoveries where order_id=?", + Long.class, order.getId())).isEqualTo(1); + assertThat(jdbc.queryForObject("select count(*) from payment_notifications where order_id=?", + Long.class, order.getId())).isEqualTo(1); + assertError(() -> withdrawals.withdraw(user), UserErrorCode.REFUND_REQUIRED); + } + + @Test + void confirmedCancellationAllowsWithdrawalAndAuditIsPurgedAfterRetention() { + var order = readyOrder(10); + var readyUser = user; + withdrawals.withdraw(readyUser); + assertThat(users.findById(readyUser).orElseThrow().isWithdrawn()).isTrue(); + + user = member(); + var pending = readyOrder(10); + now = recoveryNoticeTime(pending); + feedback.accept(verifier.verify(notification(pending, 10, now))); + assertError(() -> withdrawals.withdraw(user), UserErrorCode.PAYMENT_UNCONFIRMED); + now = now.plusSeconds(1); + var evidence = recoveryEvidence(notification(pending, 32, now)); + var preview = recovery.preview(pending.getPublicId(), "cancel-141", evidence); + assertThat(recovery.apply(pending.getPublicId(), "cancel-141", preview.snapshot(), evidence).orderState()) + .isEqualTo(PaymentOrder.State.CANCELLED); + assertThat(credits.balance(user).available()).isZero(); + withdrawals.withdraw(user); + now = now.atOffset(ZoneOffset.UTC).plusYears(5).plusDays(1).toInstant(); + assertThat(retention.purge(user)).isTrue(); + assertThat(jdbc.queryForObject("select count(*) from payment_notification_recoveries where order_id=?", + Long.class, pending.getId())).isZero(); + assertThat(orders.findById(order.getId())).isPresent(); + } + + @Test + void rejectsInvalidEvidenceAndStalePreviewThenSerializesWithTheNormalWebhook() throws Exception { + var order = readyOrder(10); + now = recoveryNoticeTime(order); + jdbc.update("update payment_orders set state='REQUESTING' where id=?", order.getId()); + var initial = recoveryEvidence(notification(order, 4, now)); + assertThatThrownBy(() -> recovery.preview(order.getPublicId(), "race", initial)) + .hasMessage("ORDER_NOT_UNCONFIRMED"); + + now = now.plus(Duration.ofMinutes(2)); + var invalid = new PaymentRecoveryEvidence(initial.originalForm(), providerId(order), 4, 999); + assertThatThrownBy(() -> recovery.preview(order.getPublicId(), "race", invalid)) + .hasMessage("SELLER_CONFIRMATION_MISMATCH"); + var wrongAmount = notification(order, 4, now); + wrongAmount.put("price", "999"); + assertThatThrownBy(() -> recovery.preview(order.getPublicId(), "race", recoveryEvidence(wrongAmount))) + .hasMessage("ORDER_EVIDENCE_MISMATCH"); + var invalidHash = notification(order, 4, now); + invalidHash.put("hash", "b".repeat(64)); + assertThatThrownBy(() -> recovery.preview(order.getPublicId(), "race", recoveryEvidence(invalidHash))) + .hasMessage("INVALID_ORIGINAL_NOTIFICATION"); + var wrongTransaction = notification(order, 4, now); + wrongTransaction.put("mul_no", "99900001"); + assertThatThrownBy(() -> recovery.preview(order.getPublicId(), "race", recoveryEvidence(wrongTransaction))) + .hasMessage("TRANSACTION_CONFLICT"); + + var stale = recovery.preview(order.getPublicId(), "race", initial); + now = now.plusSeconds(1); + feedback.accept(verifier.verify(notification(order, 10, now))); + assertThatThrownBy(() -> recovery.apply(order.getPublicId(), "race", stale.snapshot(), initial)) + .hasMessage("NOTIFICATION_TIMELINE_CONFLICT"); + now = now.plusSeconds(1); + var finalForm = notification(order, 4, now); + var evidence = recoveryEvidence(finalForm); + var preview = recovery.preview(order.getPublicId(), "race", evidence); + assertThatThrownBy(() -> recovery.apply(order.getPublicId(), "race", "a".repeat(64), evidence)) + .hasMessage("SNAPSHOT_CHANGED"); + + var start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var live = pool.submit(() -> { + if (!start.await(10, TimeUnit.SECONDS)) throw new AssertionError("webhook start timeout"); + feedback.accept(verifier.verify(finalForm)); + return null; + }); + var replay = pool.submit(() -> { + if (!start.await(10, TimeUnit.SECONDS)) throw new AssertionError("recovery start timeout"); + return recovery.apply(order.getPublicId(), "race", preview.snapshot(), evidence); + }); + start.countDown(); + live.get(15, TimeUnit.SECONDS); + assertThat(replay.get(15, TimeUnit.SECONDS).status()).isIn("RECOVERED", "ALREADY_RECORDED"); + } + assertThat(credits.balance(user).available()).isEqualTo(10); + assertThat(jdbc.queryForObject("select count(*) from credit_ledger where user_id=? and kind='GRANT'", + Long.class, user)).isEqualTo(1); + assertThat(jdbc.queryForObject("select count(*) from payment_notifications where order_id=? and state_code=4", + Long.class, order.getId())).isEqualTo(1); + } + + private Instant recoveryNoticeTime(PaymentOrder order) { + return orders.findById(order.getId()).orElseThrow().getCreatedAt().toInstant(ZoneOffset.UTC) + .truncatedTo(ChronoUnit.SECONDS).plusSeconds(1); + } + + private static PaymentRecoveryEvidence recoveryEvidence(Map form) { + return new PaymentRecoveryEvidence(PayAppForm.encode(form), form.get("mul_no"), + Integer.parseInt(form.get("pay_state")), Long.parseLong(form.get("price"))); + } + @Test void storesOnlyVerifiedApprovalTimeAndNeverReplacesItOnReplay() { var order = readyOrder(10); From 28074f20df5e4964446d503befde22e2d67d4b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sun, 8 Nov 2026 22:54:48 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20=EB=AF=B8=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=9A=B4=EC=98=81=20=EB=8C=80=EC=A1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=B5=EA=B5=AC=20=EC=A0=88=EC=B0=A8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/payapp-live-readiness.md | 5 +-- docs/payment-inspection-command.md | 56 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/docs/payapp-live-readiness.md b/docs/payapp-live-readiness.md index 07578b6..1abb9df 100644 --- a/docs/payapp-live-readiness.md +++ b/docs/payapp-live-readiness.md @@ -144,8 +144,9 @@ mongle: 이미 커밋한 정확한 동일 통보는 시각 창이 지나도 인증/주문 검증 후 ACK한다. 문서의 실패 통보 재시도는10회이나 간격과 responsedate 갱신 여부는 확인이 필요하다. 처음 전달되거나 저장에 실패한 통보가 오래 지연되면 운영 주문이 미확정으로 남을 수 있다. -이 경우 판매자 결제 내역과 내부 운영 조회를 대조하고 페이앱에 재통보를 문의한다. -시각을 임의 수정한 위조 통보나 수동 잔액 SQL로 해결하지 않는다. 자동 상태 조회 API는 확인 전 가정하지 않는다. +이 경우 [미확정 주문 조회와 복구](payment-inspection-command.md#미확정-주문-목록과-통보-복구-141) 절차로 판매자 내역을 대조하고 페이앱에 재통보를 문의한다. +신뢰할 수 있는 원본 통보와 독립 대조 근거를 확보한 경우 단발 운영 명령으로 처리할 수 있다. +통보의 시각을 임의 수정하거나 잔액을 직접 갱신하지 않는다. 키 교체 전 대기/미확정 주문을 확인한다. 기존 주문이 남아 있으면 구키 통보를 차단하지 않도록 페이앱과 전환 순서를 맞춘다. 실제 HTTPS payurl이 반환되는지 확인하고 오래된 HTTP 예시에 맞춰 서버 검증을 낮추지 않는다. diff --git a/docs/payment-inspection-command.md b/docs/payment-inspection-command.md index 02d297f..a9c56ed 100644 --- a/docs/payment-inspection-command.md +++ b/docs/payment-inspection-command.md @@ -106,3 +106,59 @@ H2와 임시 MySQL 8.4에서 미결제·무상/다른 구매 혼합·구매별 ## 미사용 구매 회수 이력 (#119) 새 V6 적용 후 같은 릴리스의 명령을 사용한다. purchase.reclaimed와 reclamation.id/amount를 제공하고 원래 지급량=사용 가능+예약+사용 확정+회수량으로 검증한다. PAYMENT_HOLD 원장은 과거 이력으로 보존한다. 회수 완료 건은 현재 보류가 없어도 이력과 RECLAIM 원장이 일치해야 하며, RECLAMATION_STATE_MISMATCH/RECLAMATION_LEDGER_MISMATCH를 별도로 탐지한다. ACCOUNT_BALANCE_OUT_OF_RANGE는 계정 합계가 long 범위를 벗어났다는 의미다. 실제 PG 거래 상태는 별도로 확인한다. + +## 미확정 주문 목록과 통보 복구 (#141) + +`--payment-inspect=unconfirmed`는 `UNKNOWN`, 요청 기한이 지난 `REQUESTING`, +`PENDING`을 읽기 전용으로 조회한다. 미사용 결제창 `READY`와 기한 전 `REQUESTING`은 제외한다. +주문 UUID·저장 상태·유효 상태·미확정 사유·통보 건수·마지막 통보 시각을 최대 20건 반환한다. +다음 페이지는 `nextAfterOrderId`를 `--after-order-id`에 넣어 조회한다. 목록에는 개인 연락처, +PG 거래번호나 원본 통보가 없다. 특정 건은 기존 `--payment-inspect=order`로 대조한다. + +```bash +java -jar "$MONGLE_JAR" --payment-inspect=unconfirmed +java -jar "$MONGLE_JAR" --payment-inspect=order --order-id="$ORDER_ID" +``` + +### 운영 대조와 승인 + +1. 페이앱 판매자 화면에서 **현재 최종 결과**와 거래번호·금액을 확인한다. + 기존 결제요청번호가 없으면 주문 식별값 등 독립적으로 확인 가능한 자료로 원주문을 먼저 연결한다. + 금액만 같거나 정산 전후 취소가 진행 중이면 보류한다. +2. 우선 페이앱에 정상 재통보를 요청한다. 공개 웹훅을 통해 검증되고 커밋되면 운영 복구가 필요 없다. + 공개 매뉴얼에서 확인할 수 있는 재시도와 별개로, 재통보 가능 여부와 새 시각 발급 여부는 판매자 계정의 지원 채널에서 확인한다. +3. 정상 재통보가 되지 않았고 신뢰할 수 있는 경로로 페이앱 원본 FORM 통보를 확보했다면 + 아래 명령을 사용한다. 판매자 화면에서 별도로 대조한 `confirmedTransaction`, `confirmedState`, + `confirmedAmount`와 URL 인코딩된 `originalForm`을 JSON으로 담아 권한이 제한된 임시 파일에서 + 표준입력으로 전달한다. 원본을 편집하거나 서명·토큰을 새로 조합하지 않는다. 실행 인자·출력·지원 티켓에는 + 원본이나 인증값을 넣지 않고, 작업 후 임시 파일을 정리한다. +4. `READY`의 스냅샷을 받아 적용하고 다시 조회한다. 원본이나 독립 대조 근거가 없으면 + 미확정 상태를 유지한다. 현재 문서에서 확인된 일반 주문 상태 조회 API가 없으므로 자동 조회·승인·취소는 실행하지 않는다. + +### 복구 명령 실행 + +V12 적용이 완료된 릴리스에서 단발 프로세스로 실행한다. +`MONGLE_PAYMENT_RECOVERY_ENABLED=true`와 `MONGLE_PAYMENT_RECOVERY_EXPECTED_HOST`, +`MONGLE_PAYMENT_RECOVERY_EXPECTED_DATABASE`를 해당 실행 환경에만 설정한다. +기존 앱의 검증 TLS 연결과 페이앱 인증 설정을 사용한다. 구매 크레딧을 지급하려면 기존 크레딧 기능이 활성화돼 있어야 한다. +기존 [EC2/SSM 실행 방식](#ec2ssm-실행-순서)을 사용할 때는 제한된 파일을 표준입력으로 전달할 수 있도록 구성한다. + +```bash +java -jar "$MONGLE_JAR" --payment-recovery=preview --order-id="$ORDER_ID" \ + --operator-reference=case-141-001 < "$PROTECTED_EVIDENCE_FILE" + +java -jar "$MONGLE_JAR" --payment-recovery=apply --order-id="$ORDER_ID" \ + --operator-reference=case-141-001 --expected-snapshot="$RECOVERY_SNAPSHOT" < "$PROTECTED_EVIDENCE_FILE" +``` + +이 명령은 결제 완료(4), 최종 요청 취소(8/32), 전액 승인 취소(9/64)를 해당 미확정 주문에 반영한다. +대기·부분취소 결과는 이 경로로 확정하지 않는다. 오래된 원본은 운영 명령에서만 검사하며 +공개 웹훅의 시각 제한은 유지한다. 주문보다 이르거나 허용 미래 시각을 넘은 통보, 더 새 통보와 충돌하는 +원본, 거래번호·금액·주문 토큰이 불일치하는 증빙은 거절한다. + +적용은 정상 웹훅과 같은 사용자 잠금·통보 저장·지급 원장을 사용한다. 복구 이력은 원통보·주문과 +함께 보존 및 파기한다. 정상 통보가 먼저 들어온 동일 건은 `ALREADY_RECORDED`, +이미 같은 입력으로 복구한 경우 `ALREADY_RECOVERED`다. +다른 통보나 주문 상태 변경이 미리보기를 무효화하면 최신 상태를 다시 확인한다. +취소 확인으로 결제가 종료되면 기존 정책에 따라 탈퇴를 재시도할 수 있다. 승인 확인 후 유료분이 남으면 +환불 접수가 필요한 기존 탈퇴 조건을 적용한다. From 9c608ff19ad801f34f221f404a7b710d0c6de081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sun, 8 Nov 2026 22:57:17 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=ED=86=B5=EB=B3=B4=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EB=AF=B8=ED=99=95=EC=A0=95=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20=EC=A1=B0=ED=9A=8C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/payment_recovery_smoke.py | 31 +++++++++++++++---- .../operations/payment/PaymentInspector.java | 2 +- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/scripts/payment_recovery_smoke.py b/scripts/payment_recovery_smoke.py index 1c04bae..b2fe4ea 100644 --- a/scripts/payment_recovery_smoke.py +++ b/scripts/payment_recovery_smoke.py @@ -64,7 +64,8 @@ def command(args, data="", expected=0, overrides=None): code = "" allowed = {"OPERATOR_COMMAND_DISABLED", "PAYMENT_RECOVERY_FAILED", "INVALID_CONFIGURATION", "INVALID_PAYAPP_CONFIGURATION", "INVALID_RECOVERY_EVIDENCE", "INVALID_ORIGINAL_NOTIFICATION", - "ORDER_NOT_UNCONFIRMED", "NOTIFICATION_TIMELINE_CONFLICT", "SNAPSHOT_CHANGED"} + "ORDER_NOT_UNCONFIRMED", "NOTIFICATION_TIMELINE_CONFLICT", "SNAPSHOT_CHANGED", + "ORDER_DATA_INVALID", "PAYMENT_INSPECTION_FAILED", "DB_CONNECTION_FAILED"} raise RuntimeError("Packaged payment recovery failed. " + (code if code in allowed else "")) return json.loads(result.stdout) @@ -76,11 +77,29 @@ def command(args, data="", expected=0, overrides=None): sql("REVOKE INSERT,UPDATE,DELETE ON credit_test.* FROM 'mongle_app'@'127.0.0.1';") try: - items = command(["--payment-inspect=unconfirmed"])["items"] - found = {item["orderId"]: item for item in items} - if (set(found) != {order, expired, pending} or fresh in found or ready in found - or found[expired]["effectiveState"] != "UNKNOWN" or found[pending]["notificationCount"] != 0): - raise RuntimeError("Unconfirmed discovery mismatch.") + found = {} + cursor = None + while True: + args = ["--payment-inspect=unconfirmed"] + if cursor is not None: + args.append("--after-order-id=" + cursor) + page = command(args) + items = page["items"] + if len(items) > 20 or any(item["orderId"] in found or (cursor and item["orderId"] <= cursor) + for item in items): + raise RuntimeError("Unconfirmed cursor repeated a previous row or exceeded the page size.") + found.update((item["orderId"], item) for item in items) + next_cursor = page["nextAfterOrderId"] + if next_cursor is None: + break + if not items or next_cursor != items[-1]["orderId"]: + raise RuntimeError("Unconfirmed cursor did not advance.") + cursor = next_cursor + if not {order, expired, pending}.issubset(found) or fresh in found or ready in found: + raise RuntimeError("Unconfirmed discovery did not select the expected fixture states.") + if (found[expired]["effectiveState"] != "UNKNOWN" or found[pending]["notificationCount"] != 0 + or found[pending]["lastNotificationAt"] is not None): + raise RuntimeError("Unconfirmed discovery returned unexpected timing or notification data.") later = command(["--payment-inspect=unconfirmed", "--after-order-id=" + order])["items"] if any(item["orderId"] <= order for item in later): raise RuntimeError("Unconfirmed cursor repeated a previous row.") diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java b/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java index d514925..d9a1f4c 100644 --- a/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java @@ -50,7 +50,7 @@ where public_id>? and (state in ('UNKNOWN','PENDING') or (state='REQUESTING' and default -> "REQUEST_RESULT_UNKNOWN"; }, "createdAt", instant(row, "created_at"), "requestDeadline", instant(row, "request_deadline"), - "notificationCount", notices, "lastNotificationAt", instant(row, "last_notice_at")); + "notificationCount", notices, "lastNotificationAt", notices == 0 ? null : instant(row, "last_notice_at")); }, afterOrderId, Timestamp.from(now)); var page = rows.stream().limit(20).toList(); return map("event", "payment-unconfirmed-orders", "scope", "INTERNAL_DATABASE_ONLY", "observedAt", now, From 5fc89a55a1e3a492f4df4f3b11e2f718977e638b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sun, 8 Nov 2026 23:59:50 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EB=B3=B5=EA=B5=AC=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=9D=98=20=EC=8A=A4=ED=82=A4=EB=A7=88=C2=B7=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EB=A7=A4=ED=95=91=EC=9D=84=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20DB=EC=99=80=20=EC=9D=BC=EC=B9=98=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/payment_recovery_smoke.py | 13 +++++++++---- .../operations/payment/PaymentRecoveryContext.java | 6 +++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/payment_recovery_smoke.py b/scripts/payment_recovery_smoke.py index b2fe4ea..171bd1f 100644 --- a/scripts/payment_recovery_smoke.py +++ b/scripts/payment_recovery_smoke.py @@ -39,7 +39,8 @@ def fixture(state, deadline): pending, _ = fixture("PENDING", -60) fresh, _ = fixture("REQUESTING", 10) ready, _ = fixture("READY", -60) - stamp = (datetime.now(timezone(timedelta(hours=9))) - timedelta(hours=1)).strftime("%Y%m%d%H%M%S") + notice_at = datetime.now(timezone.utc) - timedelta(hours=1) + stamp = notice_at.astimezone(timezone(timedelta(hours=9))).strftime("%Y%m%d%H%M%S") transaction = str(uuid.uuid4().int % 10**25 + 1) form = urlencode({ "userid": seller, "linkkey": key, "linkval": value, "hash": hashlib.sha256((seller+stamp+secret).encode()).hexdigest(), @@ -49,9 +50,9 @@ def fixture(state, deadline): evidence = json.dumps({"originalForm": form, "confirmedTransaction": transaction, "confirmedState": 4, "confirmedAmount": 1100}) - def command(args, data="", expected=0, overrides=None): + def command(args, data="", expected=0, overrides=None, zone="Asia/Seoul"): result = subprocess.run( - ["java", "-Xmx192m", "-jar", str(jar), *args], cwd=repo, env={**config, **(overrides or {})}, + ["java", "-Duser.timezone=" + zone, "-Xmx192m", "-jar", str(jar), *args], cwd=repo, env={**config, **(overrides or {})}, input=data, capture_output=True, text=True, timeout=90, ) sensitive = (token, key, value, secret, transaction, config["DB_PASSWORD"], config["DB_TRUSTSTORE_PASSWORD"]) @@ -111,9 +112,13 @@ def command(args, data="", expected=0, overrides=None): apply_args = ["--payment-recovery=apply", *preview_args[1:], "--expected-snapshot=" + preview["snapshot"]] with ThreadPoolExecutor(max_workers=2) as pool: - results = list(pool.map(lambda _: command(apply_args, evidence), range(2))) + results = list(pool.map(lambda zone: command(apply_args, evidence, zone=zone), ("UTC", "Asia/Seoul"))) if sorted(item["status"] for item in results) != ["ALREADY_RECOVERED", "RECOVERED"]: raise RuntimeError("Recovery JVMs did not serialize exactly once.") if sql(f"SELECT count(*) FROM credit_ledger WHERE user_id={owner} AND kind='GRANT';", database).strip() != "1": raise RuntimeError("Recovery duplicated or skipped the credit ledger.") + approved = sql(f"""SELECT DATE_FORMAT(approved_at,'%Y%m%d%H%i%s') + FROM payment_orders WHERE public_id='{order}';""", database).strip() + if approved != notice_at.strftime("%Y%m%d%H%M%S"): + raise RuntimeError("Recovery did not preserve the UTC provider approval time.") print("Packaged JAR: unconfirmed discovery and preview SELECT-only; concurrent recovery applied once.", flush=True) diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryContext.java b/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryContext.java index 2cc9fbd..2dbf47c 100644 --- a/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryContext.java +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentRecoveryContext.java @@ -10,6 +10,7 @@ import com.mongle.backend.global.config.JpaConfig; import jakarta.persistence.EntityManagerFactory; +import org.hibernate.boot.model.naming.PhysicalNamingStrategySnakeCaseImpl; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; @@ -72,7 +73,10 @@ LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource source) { PaymentOrder.class.getPackageName()); factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); factory.setJpaPropertyMap(Map.of("hibernate.hbm2ddl.auto", "validate", "hibernate.jdbc.time_zone", "UTC", - "hibernate.show_sql", false, "jakarta.persistence.query.timeout", 5000)); + "hibernate.show_sql", false, "jakarta.persistence.query.timeout", 5000, + "hibernate.physical_naming_strategy", PhysicalNamingStrategySnakeCaseImpl.class.getName(), + // MySQL DATETIME holds UTC wall time. Avoid conversion via the host JVM zone. + "hibernate.type.java_time_use_direct_jdbc", true)); return factory; }