| New file |
| | |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.sql.Connection; |
| | | import java.sql.DriverManager; |
| | | import java.sql.PreparedStatement; |
| | | import java.sql.ResultSet; |
| | | import java.sql.ResultSetMetaData; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.regex.Matcher; |
| | | import java.util.regex.Pattern; |
| | | |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuRefundCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuRefundGateway; |
| | | |
| | | /** |
| | | * 一次性工具:把 2026-09-16 旧取消流程遗留在通道延迟户里的两笔 ¥0.02 退回来(订单 520/521)。 |
| | | * |
| | | * <p>当时取消只看本地流水(写成 LOCAL_CLOSED),钱其实已经被通道收下并停在延迟户;本地既没有 |
| | | * SUCCEEDED 流水,也没有退款记录,人才端显示「已取消」。这里按仓库自己的网关类调接口: |
| | | * 先查单确认钱在,再发起全额退款,成功后把本地账补成与通道一致(流水置 SUCCEEDED、补退款记录、 |
| | | * 订单退出结算范围)。 |
| | | * |
| | | * <p>默认只读(查单);加 {@code --execute} 才真正发起退款并落库。 |
| | | * 用法:java -cp ... RefundStuckDelayedPayments [--execute] <yml路径> <jdbc-url> |
| | | */ |
| | | public class RefundStuckDelayedPayments { |
| | | |
| | | /** 两笔目标:订单号 / 支付流水号 / 收款子商户 / 原交易请求日期(均为 2026-09-16)。 */ |
| | | private static final List<Target> TARGETS = List.of( |
| | | new Target("P202609161320506589", "P202609161320512100", "6666000241804122", "20260916", 2L), |
| | | new Target("P202609161325126470", "P202609161325168095", "6666000241804122", "20260916", 2L)); |
| | | |
| | | private record Target(String orderNo, String paymentNo, String huifuId, String requestDate, long amountInCent) {} |
| | | |
| | | public static void main(String[] args) throws Exception { |
| | | boolean execute = List.of(args).contains("--execute"); |
| | | boolean check = List.of(args).contains("--check"); |
| | | Path yml = Path.of(args[args.length - 2]); |
| | | String jdbcUrl = args[args.length - 1]; |
| | | HuifuPaymentProperties properties = properties(yml); |
| | | System.out.println("环境=" + properties.getEnvironment() + " 系统号=" + properties.getSystemId() |
| | | + " 产品号=" + properties.getProductId() + " 收款商户=" + properties.getMerchantId() |
| | | + " 模式=" + (execute ? "执行退款" : check ? "查退款结果" : "只读查单")); |
| | | HuifuMiniProgramPaymentGateway queryGateway = new HuifuMiniProgramPaymentGateway(properties); |
| | | HuifuRefundGateway refundGateway = new HuifuRefundGateway(properties); |
| | | if (check) { |
| | | checkRefunds(refundGateway, jdbcUrl); |
| | | return; |
| | | } |
| | | |
| | | try (Connection connection = DriverManager.getConnection(jdbcUrl, "root", "123456")) { |
| | | for (Target target : TARGETS) { |
| | | System.out.println(); |
| | | System.out.println("==== 订单 " + target.orderNo() + " / 支付流水 " + target.paymentNo()); |
| | | Map<String, Object> data = businessData(queryGateway.queryMiniProgramPayment( |
| | | new HuifuPaymentQueryCommand(target.paymentNo(), target.huifuId(), null, target.requestDate()))); |
| | | print("查单", data); |
| | | String status = first(data, "trans_stat", "transStat"); |
| | | String code = first(data, "sub_resp_code", "resp_code", "code"); |
| | | String providerNo = first(data, "org_hf_seq_id", "orgHfSeqId", "hf_seq_id", "hfSeqId"); |
| | | String amount = first(data, "trans_amt", "transAmt"); |
| | | if (!"S".equalsIgnoreCase(status) || !successCode(code)) { |
| | | System.out.println("!! 通道未确认收款(trans_stat=" + status + " code=" + code + "),跳过退款"); |
| | | continue; |
| | | } |
| | | if (!execute) { |
| | | System.out.println("-- 只读模式:确认收款 ¥" + amount + "(通道流水号 " + providerNo + "),加 --execute 才退款"); |
| | | continue; |
| | | } |
| | | String refundNo = BusinessIdGenerator.next("R"); |
| | | Map<String, Object> refundData = businessData(refundGateway.createRefund(new HuifuRefundCommand( |
| | | refundNo, target.huifuId(), target.paymentNo(), providerNo, target.requestDate(), target.amountInCent()))); |
| | | print("退款", refundData); |
| | | String refundCode = first(refundData, "sub_resp_code", "resp_code", "code"); |
| | | String refundTransStatus = first(refundData, "trans_stat", "transStat"); |
| | | String refundProviderNo = first(refundData, "hf_seq_id", "hfSeqId"); |
| | | // 与 HuifuRefundService 同一套判定:成功 / 处理中 / 失败,处理中留给对账任务收敛。 |
| | | int localRefundStatus = successCode(refundCode) || "S".equalsIgnoreCase(refundTransStatus) ? 2 |
| | | : ("P".equalsIgnoreCase(refundTransStatus) || "00000100".equals(refundCode) ? 1 : 3); |
| | | record(connection, target, refundNo, providerNo, refundData, refundProviderNo, localRefundStatus); |
| | | System.out.println("-- 本地已补齐:流水置 SUCCEEDED、退款记录 " + refundNo + "(本地按 " |
| | | + (localRefundStatus == 2 ? "退款成功" : localRefundStatus == 1 ? "退款中,交对账收敛" : "退款失败") + " 落库)"); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** 查两笔退款的最新结果,并顺带看本地记录:退款成功后订单应退出结算范围。 */ |
| | | private static void checkRefunds(HuifuRefundGateway refundGateway, String jdbcUrl) throws Exception { |
| | | try (Connection connection = DriverManager.getConnection(jdbcUrl, "root", "123456")) { |
| | | for (Target target : TARGETS) { |
| | | Map<String, Object> local = new LinkedHashMap<>(); |
| | | try (PreparedStatement statement = connection.prepareStatement(""" |
| | | select r.refund_no, r.refund_status, r.provider_refund_no, r.provider_response_code, |
| | | r.provider_response_message, o.order_no, o.refund_status order_refund_status, |
| | | p.status payment_status |
| | | from order_refund r join benefit_order o on o.id = r.order_id |
| | | join payment_transaction p on p.payment_no = r.provider_original_req_seq_id |
| | | where r.order_no = ? and r.deleted = 0 order by r.id desc limit 1 |
| | | """)) { |
| | | statement.setString(1, target.orderNo()); |
| | | try (ResultSet rows = statement.executeQuery()) { |
| | | ResultSetMetaData meta = rows.getMetaData(); |
| | | if (rows.next()) { |
| | | for (int i = 1; i <= meta.getColumnCount(); i++) local.put(meta.getColumnLabel(i), rows.getObject(i)); |
| | | } |
| | | } |
| | | } |
| | | String refundNo = String.valueOf(local.getOrDefault("refund_no", "")); |
| | | String providerRefundNo = String.valueOf(local.getOrDefault("provider_refund_no", "")); |
| | | System.out.println(); |
| | | System.out.println("==== 订单 " + target.orderNo() + " / 退款 " + refundNo); |
| | | System.out.println("-- 本地:refund_status=" + local.get("refund_status") + " 订单 refund_status=" + local.get("order_refund_status") |
| | | + " 支付流水=" + local.get("payment_status") + " 通道退款号=" + providerRefundNo); |
| | | if (refundNo.isBlank()) continue; |
| | | // 查询参数与 HuifuRefundService.query 一致:退款请求号 + 退款通道号 + 原交易日期都在记录里。 |
| | | Map<String, Object> data = businessData(refundGateway.queryRefund(new HuifuRefundCommand( |
| | | refundNo, target.huifuId(), text(local.get("provider_original_req_seq_id")), |
| | | text(local.get("provider_original_no")), text(local.get("provider_original_req_date")), |
| | | target.amountInCent()), providerRefundNo)); |
| | | print("退款查询", data); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** 把本地账补成与通道一致:支付流水不再谎称 CLOSED,退款落到 order_refund,订单退出结算范围。 */ |
| | | private static void record(Connection connection, Target target, String refundNo, String providerNo, |
| | | Map<String, Object> refundData, String refundProviderNo, int localRefundStatus) throws Exception { |
| | | connection.setAutoCommit(false); |
| | | try (PreparedStatement payment = connection.prepareStatement(""" |
| | | update payment_transaction |
| | | set status = 'SUCCEEDED', provider_transaction_no = coalesce(nullif(?, ''), provider_transaction_no), |
| | | provider_response_code = ?, provider_response_message = ? |
| | | where payment_no = ? and status = 'CLOSED' |
| | | """); |
| | | PreparedStatement refund = connection.prepareStatement(""" |
| | | insert into order_refund(refund_no, order_id, order_no, talent_id, card_id, benefit_id, merchant_id, |
| | | order_type, paid_amount, refund_amount, refundable_amount, refund_status, |
| | | provider, provider_huifu_id, provider_refund_no, provider_original_no, |
| | | provider_original_req_seq_id, provider_original_req_date, |
| | | provider_response_code, provider_response_message, reason, operator_name, refund_time) |
| | | select ?, o.id, o.order_no, o.talent_id, o.card_id, o.benefit_id, o.merchant_id, o.order_type, |
| | | ?, ?, 0, ?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, ?, now() |
| | | from benefit_order o where o.order_no = ? and o.deleted = 0 |
| | | """); |
| | | PreparedStatement order = connection.prepareStatement( |
| | | "update benefit_order set refund_status = ? where order_no = ? and deleted = 0 and refund_status <> ?")) { |
| | | payment.setString(1, providerNo); |
| | | payment.setString(2, first(refundData, "sub_resp_code", "resp_code", "code")); |
| | | payment.setString(3, first(refundData, "sub_resp_desc", "resp_desc", "message")); |
| | | payment.setString(4, target.paymentNo()); |
| | | System.out.println(" 支付流水更新 " + payment.executeUpdate() + " 行"); |
| | | |
| | | refund.setString(1, refundNo); |
| | | refund.setLong(2, target.amountInCent()); |
| | | refund.setLong(3, target.amountInCent()); |
| | | refund.setInt(4, localRefundStatus); |
| | | refund.setString(5, target.huifuId()); |
| | | refund.setString(6, refundProviderNo); |
| | | refund.setString(7, providerNo); |
| | | refund.setString(8, target.paymentNo()); |
| | | refund.setString(9, target.requestDate()); |
| | | refund.setString(10, first(refundData, "sub_resp_code", "resp_code", "code")); |
| | | refund.setString(11, first(refundData, "sub_resp_desc", "resp_desc", "message")); |
| | | refund.setString(12, "取消订单后未退款的人工补退(旧取消流程遗留)"); |
| | | refund.setString(13, "系统补退"); |
| | | refund.setString(14, target.orderNo()); |
| | | System.out.println(" 退款记录插入 " + refund.executeUpdate() + " 行(refund_status=" + localRefundStatus + ")"); |
| | | // 退款成功才让订单退出结算范围;失败则把订单退款状态退回未退款(与 markRefundFailed 一致)。 |
| | | int orderRefundStatus = localRefundStatus == 2 ? 2 : 0; |
| | | if (localRefundStatus != 1) { |
| | | order.setInt(1, orderRefundStatus); |
| | | order.setString(2, target.orderNo()); |
| | | order.setInt(3, orderRefundStatus); |
| | | System.out.println(" 订单退款状态置为 " + orderRefundStatus + ":" + order.executeUpdate() + " 行(order_no=" + target.orderNo() + ")"); |
| | | } |
| | | connection.commit(); |
| | | } catch (Exception exception) { |
| | | connection.rollback(); |
| | | throw exception; |
| | | } |
| | | } |
| | | |
| | | /** 从 application.yml 读汇付配置:私钥不进命令行,也不写进这个文件。 */ |
| | | private static HuifuPaymentProperties properties(Path yml) throws Exception { |
| | | String content = Files.readString(yml, StandardCharsets.UTF_8); |
| | | HuifuPaymentProperties properties = new HuifuPaymentProperties(); |
| | | properties.setEnabled(true); |
| | | properties.setEnvironment(value(content, "environment")); |
| | | properties.setSystemId(value(content, "system-id")); |
| | | properties.setProductId(value(content, "product-id")); |
| | | properties.setMerchantId(value(content, "merchant-id")); |
| | | properties.setMiniProgramAppId(value(content, "mini-program-app-id")); |
| | | properties.setPrivateKey(value(content, "private-key")); |
| | | properties.setPlatformPublicKey(value(content, "platform-public-key")); |
| | | properties.setNotifyUrl(value(content, "notify-url")); |
| | | return properties; |
| | | } |
| | | |
| | | private static String value(String content, String key) { |
| | | Matcher matcher = Pattern.compile("(?m)^\\s*" + Pattern.quote(key) + ":\\s*(.+?)\\s*$").matcher(content); |
| | | if (!matcher.find()) throw new IllegalStateException("application.yml 缺少 " + key); |
| | | String raw = matcher.group(1); |
| | | Matcher env = Pattern.compile("^\\$\\{[^:}]+:(.*)\\}$").matcher(raw); |
| | | return env.matches() ? env.group(1) : raw; |
| | | } |
| | | |
| | | private static void print(String label, Map<String, Object> data) { |
| | | StringBuilder line = new StringBuilder("-- " + label + "回执:"); |
| | | for (Map.Entry<String, Object> entry : data.entrySet()) line.append(entry.getKey()).append('=').append(entry.getValue()).append(' '); |
| | | System.out.println(line); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | private static Map<String, Object> businessData(Map<String, Object> response) { |
| | | Object data = response.get("data"); |
| | | if (data instanceof Map<?, ?> map) return new LinkedHashMap<>((Map<String, Object>) map); |
| | | return new LinkedHashMap<>(response); |
| | | } |
| | | |
| | | private static String text(Object value) { |
| | | return value == null ? "" : String.valueOf(value).trim(); |
| | | } |
| | | |
| | | private static String first(Map<String, Object> data, String... keys) { |
| | | for (String key : keys) { |
| | | Object value = data.get(key); |
| | | if (value != null && !String.valueOf(value).isBlank()) return String.valueOf(value).trim(); |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | private static boolean successCode(String code) { |
| | | return "00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); |
| | | } |
| | | } |