|
|
@@ -1,43 +1,317 @@
|
|
|
package com.ruoyi.app.omgpay;
|
|
|
|
|
|
+import com.alibaba.fastjson2.JSON;
|
|
|
+import com.alibaba.fastjson2.JSONObject;
|
|
|
+import com.alibaba.fastjson2.JSONArray;
|
|
|
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
|
|
|
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
|
|
|
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
|
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
|
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.AUTH_REQUIRED;
|
|
|
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.ORDER_REQUIRED;
|
|
|
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT;
|
|
|
+import java.time.LocalTime;
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.ZoneId;
|
|
|
+import java.time.ZonedDateTime;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Objects;
|
|
|
|
|
|
-/**
|
|
|
- * Test-stage refund boundary. OMG publishes no refund endpoint for its test environment,
|
|
|
- * so this service must fail closed before reading payment facts or changing local state.
|
|
|
- */
|
|
|
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.*;
|
|
|
+
|
|
|
+/** 持久化 OMG 退款意图,并按正式信用卡交易事实推进退款。 */
|
|
|
@Service
|
|
|
public class OmgPaymentRefundService {
|
|
|
private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundService.class);
|
|
|
+ private final OmgPaymentProperties properties;
|
|
|
+ private final IOmgPaymentAttemptService attempts;
|
|
|
+ private final JdbcTemplate jdbc;
|
|
|
+ private final OmgCheckMacSigner signer;
|
|
|
+ private final OmgRefundGateway gateway;
|
|
|
+ private final OmgQueryResponseParser parser;
|
|
|
+ private final Clock clock;
|
|
|
+
|
|
|
+ /** 保留测试环境边界测试的无参构造。 */
|
|
|
+ public OmgPaymentRefundService() {
|
|
|
+ this(new OmgPaymentProperties(), null, null, null, null, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ public OmgPaymentRefundService(OmgPaymentProperties properties,
|
|
|
+ IOmgPaymentAttemptService attempts,
|
|
|
+ JdbcTemplate jdbc,
|
|
|
+ OmgCheckMacSigner signer,
|
|
|
+ OmgRefundGateway gateway,
|
|
|
+ OmgQueryResponseParser parser) {
|
|
|
+ this(properties, attempts, jdbc, signer, gateway, parser, Clock.systemUTC());
|
|
|
+ }
|
|
|
+
|
|
|
+ OmgPaymentRefundService(OmgPaymentProperties properties,
|
|
|
+ IOmgPaymentAttemptService attempts, JdbcTemplate jdbc,
|
|
|
+ OmgCheckMacSigner signer, OmgRefundGateway gateway,
|
|
|
+ OmgQueryResponseParser parser, Clock clock) {
|
|
|
+ this.properties = properties;
|
|
|
+ this.attempts = attempts;
|
|
|
+ this.jdbc = jdbc;
|
|
|
+ this.signer = signer;
|
|
|
+ this.gateway = gateway;
|
|
|
+ this.parser = parser;
|
|
|
+ this.clock = clock;
|
|
|
+ }
|
|
|
|
|
|
public Void refund(Long userId, String orderId) {
|
|
|
if (userId == null) {
|
|
|
- throw business(AUTH_REQUIRED);
|
|
|
+ throw new OmgPaymentBusinessException(AUTH_REQUIRED);
|
|
|
+ }
|
|
|
+ if (orderId == null || orderId.isBlank() || orderId.trim().length() > 64) {
|
|
|
+ throw new OmgPaymentBusinessException(ORDER_REQUIRED);
|
|
|
+ }
|
|
|
+ if (!properties.isProduction()) {
|
|
|
+ log.warn("OMG refund unavailable in test environment orderId={}",
|
|
|
+ OmgPaymentController.safeLogOrderId(orderId));
|
|
|
+ throw new OmgPaymentBusinessException(PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT);
|
|
|
+ }
|
|
|
+ OmgPaymentOrderSnapshot order = attempts.selectOrder(orderId.trim());
|
|
|
+ if (order == null || !userId.equals(order.getUserId())
|
|
|
+ || !Long.valueOf(4L).equals(order.getState())
|
|
|
+ || attempts.selectPaidByDdId(orderId.trim()) == null) {
|
|
|
+ throw new OmgPaymentBusinessException(PAYMENT_REFUND_FAILED);
|
|
|
}
|
|
|
- String normalizedOrderId = normalizeOrderId(orderId);
|
|
|
- log.warn("OMG refund blocked environment=TEST, orderId={}, userId={}, reason=GATEWAY_UNAVAILABLE",
|
|
|
- OmgPaymentController.safeLogOrderId(normalizedOrderId), userId);
|
|
|
- throw business(PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT);
|
|
|
+ createRefundIntent(orderId.trim(), "USER_REQUEST");
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
- private static String normalizeOrderId(String orderId) {
|
|
|
- if (orderId == null || orderId.isBlank()) {
|
|
|
- throw business(ORDER_REQUIRED);
|
|
|
+ /** 与取消/迟到回调同事务,按支付尝试唯一键幂等写入。 */
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
+ public void createRefundIntent(String orderId, String source) {
|
|
|
+ jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
|
|
|
+ + "(attempt_id, dd_id, source, status, create_time, update_time) "
|
|
|
+ + "SELECT id, dd_id, ?, 'PENDING', NOW(), NOW() "
|
|
|
+ + "FROM pos_order_omg_attempt WHERE dd_id = ? AND attempt_status = 1",
|
|
|
+ source, orderId);
|
|
|
+ Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_omg_refund WHERE dd_id = ?",
|
|
|
+ Integer.class, orderId);
|
|
|
+ if (count == null || count == 0) {
|
|
|
+ throw new IllegalStateException("OMG captured payment missing for refund");
|
|
|
}
|
|
|
- String normalized = orderId.trim();
|
|
|
- if (normalized.length() > 64) {
|
|
|
- throw business(ORDER_REQUIRED);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 迟到或重复支付只退本次实收,不能误退订单上另一笔有效付款。 */
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
+ public void createRefundIntentForAttempt(Long attemptId, String source) {
|
|
|
+ int inserted = jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
|
|
|
+ + "(attempt_id, dd_id, source, status, create_time, update_time) "
|
|
|
+ + "SELECT id, dd_id, ?, 'PENDING', NOW(), NOW() "
|
|
|
+ + "FROM pos_order_omg_attempt WHERE id=? AND attempt_status=1",
|
|
|
+ source, attemptId);
|
|
|
+ if (inserted == 0) {
|
|
|
+ Integer existing = jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_omg_refund WHERE attempt_id=?",
|
|
|
+ Integer.class, attemptId);
|
|
|
+ if (existing == null || existing == 0) {
|
|
|
+ throw new IllegalStateException("OMG captured attempt missing for refund");
|
|
|
+ }
|
|
|
}
|
|
|
- return normalized;
|
|
|
}
|
|
|
|
|
|
- private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
|
|
|
- return new OmgPaymentBusinessException(code);
|
|
|
+ /** 补偿已取消或已切换渠道后的迟到实收,唯一键保证重复扫描幂等。 */
|
|
|
+ public void ensureOrphanedCapturedIntents() {
|
|
|
+ jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
|
|
|
+ + "(attempt_id, dd_id, source, status, create_time, update_time) "
|
|
|
+ + "SELECT a.id, a.dd_id, 'LATE_PAYMENT_SCAN', 'PENDING', NOW(), NOW() "
|
|
|
+ + "FROM pos_order_omg_attempt a JOIN pos_order o ON o.dd_id=a.dd_id "
|
|
|
+ + "WHERE a.attempt_status=1 AND (o.state=4 OR o.pay_type NOT IN ('2','5')) "
|
|
|
+ + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_refund r WHERE r.attempt_id=a.id)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 退款事实已确认后补偿订单支付状态,避免单次任务在两条更新之间中断。 */
|
|
|
+ public void reconcileRefundedOrderStatuses() {
|
|
|
+ jdbc.update("UPDATE pos_order o SET o.pay_status=2 WHERE o.state=4 AND o.pay_status=1 "
|
|
|
+ + "AND o.pay_type IN ('2','5') "
|
|
|
+ + "AND EXISTS (SELECT 1 FROM pos_order_omg_attempt a WHERE a.dd_id=o.dd_id AND a.attempt_status=1) "
|
|
|
+ + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_attempt a "
|
|
|
+ + "LEFT JOIN pos_order_omg_refund r ON r.attempt_id=a.id "
|
|
|
+ + "WHERE a.dd_id=o.dd_id AND a.attempt_status=1 "
|
|
|
+ + "AND (r.id IS NULL OR r.status!='REFUNDED'))");
|
|
|
+ }
|
|
|
+
|
|
|
+ public List<Long> pendingIds(int limit) {
|
|
|
+ return jdbc.queryForList("SELECT id FROM pos_order_omg_refund "
|
|
|
+ + "WHERE status IN ('PENDING', 'ACTION_UNKNOWN') "
|
|
|
+ + "AND (lease_until IS NULL OR lease_until < NOW()) ORDER BY id LIMIT ?",
|
|
|
+ Long.class, limit);
|
|
|
}
|
|
|
+
|
|
|
+ /** 不确定结果必须先查询事实;同一状态不重放同一退款动作。 */
|
|
|
+ public void processOne(Long refundId) {
|
|
|
+ if (!properties.isProduction()) {
|
|
|
+ jdbc.update("UPDATE pos_order_omg_refund SET last_error='TEST_ENVIRONMENT', update_time=NOW() "
|
|
|
+ + "WHERE id=? AND status IN ('PENDING','ACTION_UNKNOWN')", refundId);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ int claimed = jdbc.update("UPDATE pos_order_omg_refund SET lease_until=DATE_ADD(NOW(), INTERVAL 2 MINUTE), "
|
|
|
+ + "update_time=NOW() WHERE id=? AND status IN ('PENDING','ACTION_UNKNOWN') "
|
|
|
+ + "AND (lease_until IS NULL OR lease_until < NOW())", refundId);
|
|
|
+ if (claimed != 1) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ boolean actionStarted = false;
|
|
|
+ String priorStatus = "PENDING";
|
|
|
+ try {
|
|
|
+ RefundRow row = jdbc.queryForObject("SELECT attempt_id, status, last_action, last_gateway_status "
|
|
|
+ + "FROM pos_order_omg_refund WHERE id=?", (rs, n) ->
|
|
|
+ new RefundRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4)), refundId);
|
|
|
+ priorStatus = row.status();
|
|
|
+ OmgPaymentAttempt attempt = loadAttempt(row.attemptId());
|
|
|
+ String checkCode = properties.getCreditCheckCodes().get(attempt.getMerchantId());
|
|
|
+ if (!"PRODUCTION".equals(attempt.getGatewayEnvironment())) {
|
|
|
+ defer(refundId, "PAYMENT_CREATED_IN_TEST_ENVIRONMENT", "MANUAL_REVIEW");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (attempt.getCreditRefundId() == null || !attempt.getCreditRefundId().matches("[0-9]+")
|
|
|
+ || checkCode == null || !checkCode.matches("[0-9]+") || attempt.getTradeNo() == null) {
|
|
|
+ defer(refundId, "MISSING_CREDIT_REFUND_CREDENTIAL", priorStatus);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String gatewayStatus = creditStatus(attempt, checkCode);
|
|
|
+ if (List.of("已取消", "銀行已追回", "FULL_REFUND_CONFIRMED").contains(gatewayStatus)) {
|
|
|
+ markRefunded(refundId, attempt);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String action = nextAction(gatewayStatus);
|
|
|
+ if (action == null) {
|
|
|
+ defer(refundId, "GATEWAY_STATUS_REQUIRES_REVIEW", "MANUAL_REVIEW");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (row.lastAction() != null
|
|
|
+ && (row.lastAction().equals(action) || Objects.equals(row.lastGatewayStatus(), gatewayStatus))) {
|
|
|
+ defer(refundId, "WAITING_FOR_GATEWAY_STATE_CHANGE", "ACTION_UNKNOWN");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (inAutoCloseWindow()) {
|
|
|
+ defer(refundId, "AUTO_CLOSE_WINDOW", priorStatus);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 先持久化动作可能已发出,超时后只查事实。
|
|
|
+ jdbc.update("UPDATE pos_order_omg_refund SET status='ACTION_UNKNOWN', last_action=?, "
|
|
|
+ + "last_gateway_status=?, last_action_time=NOW(), attempt_count=attempt_count+1, "
|
|
|
+ + "last_error=NULL, update_time=NOW() WHERE id=?",
|
|
|
+ action, gatewayStatus, refundId);
|
|
|
+ actionStarted = true;
|
|
|
+ Map<String, String> response = parser.parse(gateway.doAction(actionRequest(attempt, action)));
|
|
|
+ if (!attempt.getMerchantId().equals(response.get("MerchantID"))
|
|
|
+ || !attempt.getMerchantTradeNo().equals(response.get("MerchantTradeNo"))
|
|
|
+ || !attempt.getTradeNo().equals(response.get("TradeNo"))) {
|
|
|
+ defer(refundId, "REFUND_RESPONSE_IDENTITY_MISMATCH", "MANUAL_REVIEW");
|
|
|
+ } else if (!"1".equals(response.get("RtnCode"))) {
|
|
|
+ defer(refundId, "REFUND_ACTION_REJECTED", "MANUAL_REVIEW");
|
|
|
+ }
|
|
|
+ } catch (Exception error) {
|
|
|
+ log.error("OMG refund result uncertain refundId={}", refundId, error);
|
|
|
+ defer(refundId, "GATEWAY_RESULT_UNKNOWN", actionStarted ? "ACTION_UNKNOWN" : priorStatus);
|
|
|
+ } finally {
|
|
|
+ jdbc.update("UPDATE pos_order_omg_refund SET lease_until=NULL WHERE id=?", refundId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private OmgPaymentAttempt loadAttempt(Long attemptId) {
|
|
|
+ return jdbc.queryForObject("SELECT id, dd_id, merchant_trade_no, merchant_id, amount, "
|
|
|
+ + "hash_key_snapshot, hash_iv_snapshot, trade_no, credit_refund_id, gateway_environment "
|
|
|
+ + "FROM pos_order_omg_attempt WHERE id=? AND attempt_status=1",
|
|
|
+ (rs, n) -> {
|
|
|
+ OmgPaymentAttempt attempt = new OmgPaymentAttempt();
|
|
|
+ attempt.setId(rs.getLong("id"));
|
|
|
+ attempt.setDdId(rs.getString("dd_id"));
|
|
|
+ attempt.setMerchantTradeNo(rs.getString("merchant_trade_no"));
|
|
|
+ attempt.setMerchantId(rs.getString("merchant_id"));
|
|
|
+ attempt.setAmount(rs.getInt("amount"));
|
|
|
+ attempt.setHashKeySnapshot(rs.getString("hash_key_snapshot"));
|
|
|
+ attempt.setHashIvSnapshot(rs.getString("hash_iv_snapshot"));
|
|
|
+ attempt.setTradeNo(rs.getString("trade_no"));
|
|
|
+ attempt.setCreditRefundId(rs.getString("credit_refund_id"));
|
|
|
+ attempt.setGatewayEnvironment(rs.getString("gateway_environment"));
|
|
|
+ return attempt;
|
|
|
+ }, attemptId);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String creditStatus(OmgPaymentAttempt attempt, String checkCode) {
|
|
|
+ Map<String, String> fields = new LinkedHashMap<>();
|
|
|
+ fields.put("MerchantID", attempt.getMerchantId());
|
|
|
+ fields.put("CreditRefundId", attempt.getCreditRefundId());
|
|
|
+ fields.put("CreditAmount", String.valueOf(attempt.getAmount()));
|
|
|
+ fields.put("CreditCheckCode", checkCode);
|
|
|
+ fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
|
|
|
+ JSONObject response = JSON.parseObject(gateway.queryCredit(fields));
|
|
|
+ if (response == null || (response.getString("RtnMsg") != null
|
|
|
+ && !response.getString("RtnMsg").isBlank())) {
|
|
|
+ throw new IllegalStateException("OMG credit query rejected");
|
|
|
+ }
|
|
|
+ JSONObject value = response.getJSONObject("RtnValue");
|
|
|
+ if (value == null || !attempt.getCreditRefundId().equals(value.getString("TradeID"))
|
|
|
+ || attempt.getAmount() != value.getIntValue("amount")
|
|
|
+ || value.getString("status") == null) {
|
|
|
+ throw new IllegalStateException("OMG credit query identity mismatch");
|
|
|
+ }
|
|
|
+ JSONArray closeData = value.getJSONArray("close_data");
|
|
|
+ if (closeData != null) {
|
|
|
+ long recovered = 0;
|
|
|
+ for (int i = 0; i < closeData.size(); i++) {
|
|
|
+ JSONObject item = closeData.getJSONObject(i);
|
|
|
+ if (item != null && "銀行已追回".equals(item.getString("status"))) {
|
|
|
+ recovered += item.getLongValue("amount");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (recovered == attempt.getAmount()) {
|
|
|
+ return "FULL_REFUND_CONFIRMED";
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return value.getString("status");
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, String> actionRequest(OmgPaymentAttempt attempt, String action) {
|
|
|
+ Map<String, String> fields = new LinkedHashMap<>();
|
|
|
+ fields.put("MerchantID", attempt.getMerchantId());
|
|
|
+ fields.put("MerchantTradeNo", attempt.getMerchantTradeNo());
|
|
|
+ fields.put("TradeNo", attempt.getTradeNo());
|
|
|
+ fields.put("Action", action);
|
|
|
+ fields.put("TotalAmount", String.valueOf(attempt.getAmount()));
|
|
|
+ fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
|
|
|
+ return fields;
|
|
|
+ }
|
|
|
+
|
|
|
+ static String nextAction(String status) {
|
|
|
+ return switch (status) {
|
|
|
+ case "已授權" -> "N";
|
|
|
+ case "要關帳" -> "E";
|
|
|
+ case "已關帳" -> "R";
|
|
|
+ default -> null;
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean inAutoCloseWindow() {
|
|
|
+ LocalTime now = ZonedDateTime.now(clock.withZone(ZoneId.of("Asia/Taipei"))).toLocalTime();
|
|
|
+ return !now.isBefore(LocalTime.of(20, 15)) && now.isBefore(LocalTime.of(20, 30));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void markRefunded(Long refundId, OmgPaymentAttempt attempt) {
|
|
|
+ jdbc.update("UPDATE pos_order_omg_refund SET status='REFUNDED', last_error=NULL, "
|
|
|
+ + "refund_time=NOW(), update_time=NOW() WHERE id=?", refundId);
|
|
|
+ jdbc.update("UPDATE pos_order SET pay_status=2 WHERE dd_id=? AND state=4 "
|
|
|
+ + "AND pay_status=1 AND pay_type IN ('2','5') "
|
|
|
+ + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_attempt a "
|
|
|
+ + "LEFT JOIN pos_order_omg_refund r ON r.attempt_id=a.id "
|
|
|
+ + "WHERE a.dd_id=pos_order.dd_id AND a.attempt_status=1 "
|
|
|
+ + "AND (r.id IS NULL OR r.status!='REFUNDED'))", attempt.getDdId());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void defer(Long refundId, String reason, String status) {
|
|
|
+ jdbc.update("UPDATE pos_order_omg_refund SET status=?, last_error=?, update_time=NOW() WHERE id=?",
|
|
|
+ status, reason, refundId);
|
|
|
+ }
|
|
|
+
|
|
|
+ private record RefundRow(Long attemptId, String status, String lastAction, String lastGatewayStatus) { }
|
|
|
}
|