| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- package com.ruoyi.app.pay;
- import com.alibaba.fastjson2.JSONArray;
- import com.alibaba.fastjson2.JSONObject;
- import com.ruoyi.app.utils.linepay.LinePayClient;
- import com.ruoyi.app.utils.linepay.LinePayCredential;
- import com.ruoyi.app.utils.linepay.LinePayResponse;
- import com.ruoyi.common.exception.ServiceException;
- import com.ruoyi.system.domain.PosOrderLinePayment;
- import com.ruoyi.system.domain.PosOrderLineRefund;
- import com.ruoyi.system.domain.PosStoreLinePay;
- import com.ruoyi.system.service.IPosOrderLinePaymentService;
- import com.ruoyi.system.service.IPosOrderLineRefundService;
- import com.ruoyi.system.service.IPosStoreLinePayService;
- import org.springframework.stereotype.Service;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.beans.factory.annotation.Value;
- import java.util.Date;
- import java.util.Objects;
- import java.util.Set;
- import java.util.UUID;
- /** Executes idempotent full refunds and recovers uncertain refund outcomes by Retrieve. */
- @Service
- public class LinePayRefundService {
- private static final Set<String> RETRYABLE_CODES = Set.of("1900", "1902", "1999");
- private static final Set<String> UNCERTAIN_CODES = Set.of(
- "1164", "1165", "1198", "1199", "9000");
- private static final Set<String> CREDENTIAL_AUTH_FAILURE_CODES = Set.of("1104", "1105", "1106");
- private final IPosOrderLinePaymentService paymentService;
- private final IPosOrderLineRefundService refundService;
- private final IPosStoreLinePayService credentialService;
- private final LinePayClient linePayClient;
- private final LinePayFactService factService;
- private final LinePayGatewayAuditService auditService;
- @Value("${line-pay.reconcile.unknown-deadline-hours:24}")
- private long unknownDeadlineHours = 24L;
- @Value("${line-pay.reconcile.row-lease-seconds:120}")
- private long rowLeaseSeconds = 120L;
- public LinePayRefundService(IPosOrderLinePaymentService paymentService,
- IPosOrderLineRefundService refundService,
- IPosStoreLinePayService credentialService,
- LinePayClient linePayClient,
- LinePayFactService factService) {
- this(paymentService, refundService, credentialService, linePayClient, factService, null);
- }
- @Autowired
- public LinePayRefundService(IPosOrderLinePaymentService paymentService,
- IPosOrderLineRefundService refundService,
- IPosStoreLinePayService credentialService,
- LinePayClient linePayClient,
- LinePayFactService factService,
- LinePayGatewayAuditService auditService) {
- this.paymentService = paymentService;
- this.refundService = refundService;
- this.credentialService = credentialService;
- this.linePayClient = linePayClient;
- this.factService = factService;
- this.auditService = auditService;
- }
- public String requestFullRefund(Long paymentId, String source) {
- PosOrderLinePayment payment = paymentService.getById(paymentId);
- if (payment == null || !"PAID".equals(payment.getStatus())) {
- throw new ServiceException("Only a captured LINE Pay payment can be refunded");
- }
- PosOrderLineRefund refund = refundService.createIfAbsent(payment, source);
- if ("REFUNDED".equals(refund.getStatus())) {
- factService.applyRefundFact(payment.getId());
- return "REFUNDED";
- }
- if (refund.getReconcileDeadline() != null
- && !new Date().before(refund.getReconcileDeadline())) {
- if ("PROCESSING".equals(refund.getStatus())) {
- refundService.recoverExpiredProcessing(refund.getId(), refund.getVersion(),
- shortly(), unknownDeadline());
- refund = refundService.getByPaymentId(paymentId);
- }
- if (refund == null) {
- throw new ServiceException("LINE Pay refund state requires reconciliation");
- }
- String recovered = recoverUnknown(payment, refund, credentialFor(payment), false);
- if ("REFUNDED".equals(recovered)) {
- return recovered;
- }
- if ("MANUAL_REVIEW".equals(recovered)) {
- return recovered;
- }
- return markManualReview(refund.getPaymentId(), refund.getId(), refund.getVersion(),
- refund.getStatus());
- }
- LinePayCredential credential = credentialFor(payment);
- if ("PROCESSING".equals(refund.getStatus())) {
- if (refund.getLeaseUntil() != null && refund.getLeaseUntil().before(new Date())
- && refundService.recoverExpiredProcessing(refund.getId(), refund.getVersion(),
- shortly(), unknownDeadline()) == 1) {
- refund = refundService.getByPaymentId(paymentId);
- } else {
- return "PROCESSING";
- }
- }
- if ("UNKNOWN".equals(refund.getStatus())) {
- return recoverUnknown(payment, refund, credential);
- }
- if (!("CREATED".equals(refund.getStatus()) || "RETRY_WAIT".equals(refund.getStatus()))) {
- return refund.getStatus();
- }
- String owner = (source == null ? "LINE" : source) + "-" + UUID.randomUUID();
- if (refundService.claimProcessing(refund.getId(), refund.getVersion(), refund.getStatus(),
- owner, leaseUntil()) != 1) {
- return refund.getStatus();
- }
- long processingVersion = refund.getVersion() + 1;
- try {
- RefundEvidenceResult evidenceResult = retrieveRefundEvidence(
- payment, refund, credential, source);
- RefundEvidence existingRefund = evidenceResult.evidence();
- LinePayCredential actionCredential = evidenceResult.credential();
- if (existingRefund.fullRefundTransactionId() != null) {
- factService.applyRefundedFact(refund.getId(), payment.getId(), processingVersion,
- existingRefund.fullRefundTransactionId(), new Date());
- return "REFUNDED";
- }
- if (existingRefund.hasAnyRefund()) {
- return markManualReview(refund.getPaymentId(), refund.getId(), processingVersion,
- "PROCESSING");
- }
- if (!existingRefund.definitelyNotRefunded()) {
- refundService.markUnknown(refund.getId(), processingVersion, shortly(), unknownDeadline());
- return "UNKNOWN";
- }
- LinePayResponse response = gatewayCall("REFUND", source, payment, refund,
- actionCredential.id(),
- () -> linePayClient.refundFull(actionCredential, payment.getTransactionId()));
- String refundTransactionId = response.info() == null
- ? null : response.info().getString("refundTransactionId");
- if (response.isSuccess() && refundTransactionId != null
- ) {
- factService.applyRefundedFact(refund.getId(), payment.getId(), processingVersion,
- refundTransactionId, new Date());
- return "REFUNDED";
- }
- if (response != null && RETRYABLE_CODES.contains(response.returnCode())) {
- refundService.markRetryWait(refund.getId(), processingVersion, shortly());
- return "RETRY_WAIT";
- }
- if (response != null && response.httpStatus() >= 200 && response.httpStatus() < 300
- && response.returnCode() != null
- && !UNCERTAIN_CODES.contains(response.returnCode())) {
- refundService.markFailed(refund.getId(), processingVersion);
- return "FAILED";
- }
- } catch (Exception ignored) {
- // The gateway may have accepted the request; only Retrieve may decide its outcome.
- }
- refundService.markUnknown(refund.getId(), processingVersion, shortly(), unknownDeadline());
- return "UNKNOWN";
- }
- private LinePayCredential credentialFor(PosOrderLinePayment payment) {
- PosStoreLinePay credentialVersion = credentialService.getById(payment.getCredentialId());
- if (credentialVersion == null) {
- throw new ServiceException("LINE Pay credential version not found");
- }
- return new LinePayCredential(credentialVersion.getId(), credentialVersion.getChannelId(),
- credentialVersion.getChannelSecret());
- }
- /** Creates the durable idempotent intent; the scheduled worker performs the gateway call. */
- public String createRefundIntent(Long paymentId, String source) {
- PosOrderLinePayment payment = paymentService.getById(paymentId);
- if (payment == null || !"PAID".equals(payment.getStatus())) {
- throw new ServiceException("Only a captured LINE Pay payment can be refunded");
- }
- return refundService.createIfAbsent(payment, source).getStatus();
- }
- private String recoverUnknown(PosOrderLinePayment payment, PosOrderLineRefund refund,
- LinePayCredential credential) {
- return recoverUnknown(payment, refund, credential, true);
- }
- private String recoverUnknown(PosOrderLinePayment payment, PosOrderLineRefund refund,
- LinePayCredential credential, boolean reschedule) {
- try {
- RefundEvidence evidence = retrieveRefundEvidence(
- payment, refund, credential, "TASK").evidence();
- if (evidence.fullRefundTransactionId() != null) {
- factService.applyRefundedFact(refund.getId(), payment.getId(), refund.getVersion(),
- evidence.fullRefundTransactionId(), new Date());
- return "REFUNDED";
- }
- if (evidence.hasAnyRefund()) {
- return markManualReview(refund.getPaymentId(), refund.getId(), refund.getVersion(),
- refund.getStatus());
- }
- } catch (Exception ignored) {
- // Keep UNKNOWN; a later scheduled Retrieve can recover it.
- }
- if (reschedule) {
- Date deadline = refund.getReconcileDeadline() == null
- ? unknownDeadline() : refund.getReconcileDeadline();
- refundService.rescheduleUnknown(refund.getId(), refund.getVersion(), shortly(), deadline);
- }
- return "UNKNOWN";
- }
- private String markManualReview(Long paymentId, Long refundId, Long version,
- String expectedStatus) {
- if (refundService.markManualReview(refundId, version, expectedStatus) == 1) {
- return "MANUAL_REVIEW";
- }
- PosOrderLineRefund current = refundService.getByPaymentId(paymentId);
- if (current == null) {
- throw new ServiceException("LINE Pay refund state requires reconciliation");
- }
- if ("MANUAL_REVIEW".equals(current.getStatus()) || "REFUNDED".equals(current.getStatus())) {
- return current.getStatus();
- }
- if (("UNKNOWN".equals(current.getStatus()) || "PROCESSING".equals(current.getStatus()))
- && refundService.markManualReview(current.getId(), current.getVersion(),
- current.getStatus()) == 1) {
- return "MANUAL_REVIEW";
- }
- PosOrderLineRefund latest = refundService.getByPaymentId(paymentId);
- return latest == null ? current.getStatus() : latest.getStatus();
- }
- private RefundEvidenceResult retrieveRefundEvidence(PosOrderLinePayment payment,
- PosOrderLineRefund refund,
- LinePayCredential credential,
- String source) throws Exception {
- LinePayResponse retrieve = gatewayCall("RETRIEVE", source, payment, refund,
- credential.id(),
- () -> linePayClient.retrieveByTransactionId(credential, payment.getTransactionId()));
- RefundEvidence evidence = refundEvidence(retrieve, payment);
- if (!isCredentialAuthFailure(retrieve)) {
- return new RefundEvidenceResult(evidence, credential);
- }
- PosStoreLinePay original = credentialService.getById(payment.getCredentialId());
- PosStoreLinePay current = credentialService.getCurrent(payment.getStoreId());
- if (original == null || current == null || Objects.equals(current.getId(), original.getId())
- || !Objects.equals(current.getChannelId(), original.getChannelId())
- || !Objects.equals(current.getEnvironment(), original.getEnvironment())) {
- return new RefundEvidenceResult(evidence, credential);
- }
- LinePayCredential currentCredential = new LinePayCredential(current.getId(),
- current.getChannelId(), current.getChannelSecret());
- LinePayResponse proof = gatewayCall("RETRIEVE", source, payment, refund,
- currentCredential.id(),
- () -> linePayClient.retrieveByTransactionId(
- currentCredential, payment.getTransactionId()));
- RefundEvidence currentEvidence = refundEvidence(proof, payment);
- return currentEvidence.conclusive()
- ? new RefundEvidenceResult(currentEvidence, currentCredential)
- : new RefundEvidenceResult(evidence, credential);
- }
- private static boolean isCredentialAuthFailure(LinePayResponse response) {
- return response != null && CREDENTIAL_AUTH_FAILURE_CODES.contains(response.returnCode());
- }
- private static RefundEvidence refundEvidence(LinePayResponse response, PosOrderLinePayment expected) {
- if (response == null || !response.isSuccess() || response.rawBody() == null) {
- return RefundEvidence.INCONCLUSIVE;
- }
- JSONArray info = JSONObject.parseObject(response.rawBody()).getJSONArray("info");
- if (info == null || info.size() != 1) {
- return RefundEvidence.INCONCLUSIVE;
- }
- JSONObject payment = info.getJSONObject(0);
- if (payment == null || !expected.getTransactionId().equals(payment.getString("transactionId"))
- || !expected.getLineOrderId().equals(payment.getString("orderId"))
- || !"PAYMENT".equals(payment.getString("transactionType"))
- || !expected.getCurrency().equals(payment.getString("currency"))
- || !expected.getAmount().equals(paymentAmount(payment))) {
- return RefundEvidence.INCONCLUSIVE;
- }
- JSONArray refunds = payment.getJSONArray("refundList");
- if (refunds == null || refunds.isEmpty()) {
- return RefundEvidence.DEFINITELY_NOT_REFUNDED;
- }
- long refundedAmount = 0L;
- String lastRefundTransactionId = null;
- for (int i = 0; i < refunds.size(); i++) {
- JSONObject item = refunds.getJSONObject(i);
- if (item == null) {
- return RefundEvidence.INCONCLUSIVE;
- }
- String refundTransactionId = item.getString("refundTransactionId");
- Integer amount = item.getInteger("refundAmount");
- if (refundTransactionId == null || amount == null || amount == 0) {
- return RefundEvidence.INCONCLUSIVE;
- }
- refundedAmount += Math.abs((long) amount);
- lastRefundTransactionId = refundTransactionId;
- }
- return new RefundEvidence(true,
- refundedAmount == expected.getAmount() ? lastRefundTransactionId : null, false);
- }
- private static Integer paymentAmount(JSONObject transaction) {
- JSONArray payInfo = transaction.getJSONArray("payInfo");
- if (payInfo == null || payInfo.isEmpty()) {
- return null;
- }
- long total = 0L;
- for (int i = 0; i < payInfo.size(); i++) {
- JSONObject item = payInfo.getJSONObject(i);
- Integer amount = item == null ? null : item.getInteger("amount");
- if (amount == null || amount <= 0) {
- return null;
- }
- total += amount;
- }
- return total <= Integer.MAX_VALUE ? (int) total : null;
- }
- private record RefundEvidence(boolean hasAnyRefund, String fullRefundTransactionId,
- boolean definitelyNotRefunded) {
- private static final RefundEvidence DEFINITELY_NOT_REFUNDED =
- new RefundEvidence(false, null, true);
- private static final RefundEvidence INCONCLUSIVE = new RefundEvidence(false, null, false);
- private boolean conclusive() {
- return hasAnyRefund || definitelyNotRefunded;
- }
- }
- private record RefundEvidenceResult(RefundEvidence evidence, LinePayCredential credential) {
- }
- private static Date shortly() {
- return new Date(System.currentTimeMillis() + 30_000L);
- }
- private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
- PosOrderLineRefund refund,
- LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
- return gatewayCall(action, source, payment, refund, payment.getCredentialId(), call);
- }
- private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
- PosOrderLineRefund refund, Long credentialId,
- LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
- if (auditService == null) {
- return call.call();
- }
- return auditService.execute(action, source == null ? "LINE" : source,
- payment.getId(), refund.getId(), credentialId, payment.getStoreId(),
- payment.getDdId(), payment.getLineOrderId(), payment.getTransactionId(), call);
- }
- private Date unknownDeadline() {
- return new Date(System.currentTimeMillis() + unknownDeadlineHours * 60L * 60L * 1000L);
- }
- private Date leaseUntil() {
- return new Date(System.currentTimeMillis() + Math.max(60L, rowLeaseSeconds) * 1000L);
- }
- }
|