Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/payapp-live-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ mongle:
이미 커밋한 정확한 동일 통보는 시각 창이 지나도 인증/주문 검증 후 ACK한다.
문서의 실패 통보 재시도는10회이나 간격과 responsedate 갱신 여부는 확인이 필요하다.
처음 전달되거나 저장에 실패한 통보가 오래 지연되면 운영 주문이 미확정으로 남을 수 있다.
이 경우 판매자 결제 내역과 내부 운영 조회를 대조하고 페이앱에 재통보를 문의한다.
시각을 임의 수정한 위조 통보나 수동 잔액 SQL로 해결하지 않는다. 자동 상태 조회 API는 확인 전 가정하지 않는다.
이 경우 [미확정 주문 조회와 복구](payment-inspection-command.md#미확정-주문-목록과-통보-복구-141) 절차로 판매자 내역을 대조하고 페이앱에 재통보를 문의한다.
신뢰할 수 있는 원본 통보와 독립 대조 근거를 확보한 경우 단발 운영 명령으로 처리할 수 있다.
통보의 시각을 임의 수정하거나 잔액을 직접 갱신하지 않는다.

키 교체 전 대기/미확정 주문을 확인한다. 기존 주문이 남아 있으면 구키 통보를 차단하지 않도록 페이앱과 전환 순서를 맞춘다.
실제 HTTPS payurl이 반환되는지 확인하고 오래된 HTTP 예시에 맞춰 서버 검증을 낮추지 않는다.
Expand Down
56 changes: 56 additions & 0 deletions docs/payment-inspection-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`다.
다른 통보나 주문 상태 변경이 미리보기를 무효화하면 최신 상태를 다시 확인한다.
취소 확인으로 결제가 종료되면 기존 정책에 따라 탈퇴를 재시도할 수 있다. 승인 확인 후 유료분이 남으면
환불 접수가 필요한 기존 탈퇴 조건을 적용한다.
2 changes: 2 additions & 0 deletions scripts/mysql84-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
124 changes: 124 additions & 0 deletions scripts/payment_recovery_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""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)
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(),
"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, zone="Asia/Seoul"):
result = subprocess.run(
["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"])
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",
"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)

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:
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.")
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 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)
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
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;

@SpringBootApplication
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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()));
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.mongle.backend.domain.payment;

import org.springframework.data.jpa.repository.JpaRepository;

interface PaymentNotificationRecoveryRepository extends JpaRepository<PaymentNotificationRecovery, Long> {}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
package com.mongle.backend.domain.payment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PaymentNotificationRepository extends JpaRepository<PaymentNotification,String> {}
public interface PaymentNotificationRepository extends JpaRepository<PaymentNotification,String> {
java.util.Optional<PaymentNotification> findFirstByOrderIdOrderByRespondedAtDescEventHashDesc(Long orderId);
}
Original file line number Diff line number Diff line change
@@ -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]"; }
}
Loading