|
|
@@ -1,298 +0,0 @@
|
|
|
-package com.ruoyi.app.utils.omg;
|
|
|
-
|
|
|
-import org.apache.http.NameValuePair;
|
|
|
-import org.apache.http.client.config.RequestConfig;
|
|
|
-import org.apache.http.client.entity.UrlEncodedFormEntity;
|
|
|
-import org.apache.http.client.methods.CloseableHttpResponse;
|
|
|
-import org.apache.http.client.methods.HttpPost;
|
|
|
-import org.apache.http.impl.client.CloseableHttpClient;
|
|
|
-import org.apache.http.impl.client.HttpClients;
|
|
|
-import org.apache.http.message.BasicNameValuePair;
|
|
|
-import org.apache.http.util.EntityUtils;
|
|
|
-import org.slf4j.Logger;
|
|
|
-import org.slf4j.LoggerFactory;
|
|
|
-import org.springframework.stereotype.Component;
|
|
|
-
|
|
|
-import java.net.URI;
|
|
|
-import java.net.URLDecoder;
|
|
|
-import java.nio.charset.StandardCharsets;
|
|
|
-import java.util.ArrayList;
|
|
|
-import java.util.Comparator;
|
|
|
-import java.util.LinkedHashMap;
|
|
|
-import java.util.List;
|
|
|
-import java.util.Map;
|
|
|
-
|
|
|
-/**
|
|
|
- * OMG(歐買尬/FunPoint) AIO 支付 HTTP 客户端与组参工具(httpclient4)。
|
|
|
- *
|
|
|
- * <p>独立于 newebpay 包,签名统一用 {@link OmgCheckMacValue}(SHA256),无 AES、无解密。
|
|
|
- *
|
|
|
- * <p>三类操作:
|
|
|
- * <ul>
|
|
|
- * <li>{@link #createAioForm}:幕前下单。后端组参 + 算 CheckMacValue,返回含 gatewayUrl 的 form 字段 Map,
|
|
|
- * 前端构建隐藏 form 自动 submit 到 OMG 收银台(本身不发 HTTP)。</li>
|
|
|
- * <li>{@link #queryTrade}:订单查询 QueryTradeInfo/V5(凭证验证探测 / 回调漏收补单),后端 POST,响应 k=v。</li>
|
|
|
- * <li>{@link #doAction}:信用卡退款/取消 CreditDetail/DoAction(US4),后端 POST,响应 k=v。</li>
|
|
|
- * </ul>
|
|
|
- *
|
|
|
- * @author ruoyi
|
|
|
- * @date 2026-07-29
|
|
|
- */
|
|
|
-@Component
|
|
|
-public class OmgPay {
|
|
|
-
|
|
|
- private static final Logger log = LoggerFactory.getLogger(OmgPay.class);
|
|
|
-
|
|
|
- /** 幕前下单端点(前端 Form Post 目标) */
|
|
|
- public static final String URL_AIO_CHECKOUT = "/Cashier/AioCheckOut/V5";
|
|
|
- /** 订单查询端点 */
|
|
|
- public static final String URL_QUERY_TRADE_INFO = "/Cashier/QueryTradeInfo/V5";
|
|
|
- /** 信用卡退款/取消端点 */
|
|
|
- public static final String URL_DO_ACTION = "/CreditDetail/DoAction";
|
|
|
-
|
|
|
- private static final int MAX_RESPONSE_BYTES = 64 * 1024;
|
|
|
- private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
|
|
|
- .setConnectTimeout(5000)
|
|
|
- .setConnectionRequestTimeout(5000)
|
|
|
- .setSocketTimeout(10000)
|
|
|
- .build();
|
|
|
-
|
|
|
- /**
|
|
|
- * 幕前下单组参:对业务参数生成 CheckMacValue,返回含 gatewayUrl 的 form 字段 Map。
|
|
|
- * 前端用返回字段构建隐藏 form,action={@code gatewayUrl},其余为 input,submit 跳转 OMG 收银台。
|
|
|
- *
|
|
|
- * @param baseUrl OMG 根地址(测试 payment-stage.funpoint.com.tw / 正式 payment.funpoint.com.tw)
|
|
|
- * @param cfg 门店 OMG 凭证
|
|
|
- * @param params 业务参数(MerchantID/MerchantTradeNo/MerchantTradeDate/PaymentType=aio/TotalAmount/
|
|
|
- * ReturnURL/ChoosePayment=ALL/EncryptType=1/ItemName 等,不含 CheckMacValue)
|
|
|
- * @return form 字段 Map(含 CheckMacValue 与 gatewayUrl;gatewayUrl 不参与签名)
|
|
|
- */
|
|
|
- public Map<String, String> createAioForm(String baseUrl, OmgPayConfig cfg, Map<String, String> params) {
|
|
|
- validateConfig(cfg);
|
|
|
- if (params == null || params.isEmpty()) {
|
|
|
- throw new IllegalArgumentException("OMG checkout parameters are required");
|
|
|
- }
|
|
|
- Map<String, String> form = new LinkedHashMap<>(params);
|
|
|
- // 仅对业务参数签名(form 此刻不含 CheckMacValue/gatewayUrl)
|
|
|
- String checkMacValue = OmgCheckMacValue.generate(form, cfg.getHashKey(), cfg.getHashIv());
|
|
|
- form.put("CheckMacValue", checkMacValue);
|
|
|
- form.put("gatewayUrl", endpoint(baseUrl, URL_AIO_CHECKOUT));
|
|
|
- log.info("[OMG] createAioForm >>> gatewayUrl={}, MerchantTradeNo={}, MerchantID={}",
|
|
|
- form.get("gatewayUrl"), form.get("MerchantTradeNo"), form.get("MerchantID"));
|
|
|
- return form;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 订单查询 QueryTradeInfo/V5(凭证验证探测 / 回调漏收补单)。响应为 k=v 文本,解析为 Map。
|
|
|
- * TradeStatus:0 未付 / 1 已付 / 10200095 失败。
|
|
|
- */
|
|
|
- public Map<String, String> queryTrade(String baseUrl, OmgPayConfig cfg, String merchantTradeNo) throws Exception {
|
|
|
- validateConfig(cfg);
|
|
|
- requireText(merchantTradeNo, "MerchantTradeNo");
|
|
|
- Map<String, String> params = new LinkedHashMap<>();
|
|
|
- params.put("MerchantID", cfg.getMerchantId());
|
|
|
- params.put("MerchantTradeNo", merchantTradeNo);
|
|
|
- params.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
|
|
|
- params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
|
|
|
- String resp = postForm(endpoint(baseUrl, URL_QUERY_TRADE_INFO), params);
|
|
|
- log.info("[OMG] queryTrade 查询返回 resp={}",resp);
|
|
|
- Map<String, String> result;
|
|
|
- try {
|
|
|
- result = parseKvResponse(resp);
|
|
|
- } catch (RuntimeException e) {
|
|
|
- log.error("[OMG] queryTrade 解析响应失败 MerchantTradeNo={} resp={}", merchantTradeNo, resp, e);
|
|
|
- throw e;
|
|
|
- }
|
|
|
- try {
|
|
|
- verifyResponse(result, cfg, true);
|
|
|
- } catch (RuntimeException e) {
|
|
|
- String receivedCmv = result.get("CheckMacValue");
|
|
|
- log.error("[OMG] queryTrade 验签失败 MerchantTradeNo={} TradeStatus={} RtnCode={} receivedCMV={} "
|
|
|
- + "nat_inc={} ci_inc={} nat_exc={} ci_exc={} hashKey={} hashIv={} resp={}",
|
|
|
- merchantTradeNo, result.get("TradeStatus"), result.get("RtnCode"), receivedCmv,
|
|
|
- safeGen(result, cfg, Comparator.naturalOrder(), false),
|
|
|
- safeGen(result, cfg, String.CASE_INSENSITIVE_ORDER, false),
|
|
|
- safeGen(result, cfg, Comparator.naturalOrder(), true),
|
|
|
- safeGen(result, cfg, String.CASE_INSENSITIVE_ORDER, true),
|
|
|
- mask(cfg.getHashKey()), mask(cfg.getHashIv()), resp, e);
|
|
|
- throw e;
|
|
|
- }
|
|
|
- verifyCorrelation(result, "MerchantID", cfg.getMerchantId());
|
|
|
- verifyCorrelation(result, "MerchantTradeNo", merchantTradeNo);
|
|
|
- return result;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 信用卡退款/取消 CreditDetail/DoAction(US4)。Action:C 關帳 / R 退刷 / E 取消 / N 放棄。
|
|
|
- * 仅正式端点可用(stage 不可用)。响应为 k=v 文本,RtnCode=1 成功。
|
|
|
- */
|
|
|
- public Map<String, String> doAction(String baseUrl, OmgPayConfig cfg, String merchantTradeNo,
|
|
|
- String tradeNo, String action, int totalAmount) throws Exception {
|
|
|
- validateConfig(cfg);
|
|
|
- requireText(merchantTradeNo, "MerchantTradeNo");
|
|
|
- requireText(tradeNo, "TradeNo");
|
|
|
- if (!("C".equals(action) || "R".equals(action) || "E".equals(action) || "N".equals(action))) {
|
|
|
- throw new IllegalArgumentException("Unsupported OMG action");
|
|
|
- }
|
|
|
- if (totalAmount <= 0) {
|
|
|
- throw new IllegalArgumentException("TotalAmount must be positive");
|
|
|
- }
|
|
|
- Map<String, String> params = new LinkedHashMap<>();
|
|
|
- params.put("MerchantID", cfg.getMerchantId());
|
|
|
- params.put("MerchantTradeNo", merchantTradeNo);
|
|
|
- params.put("TradeNo", tradeNo);
|
|
|
- params.put("Action", action);
|
|
|
- params.put("TotalAmount", String.valueOf(totalAmount));
|
|
|
- params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
|
|
|
- String resp = postForm(endpoint(baseUrl, URL_DO_ACTION), params);
|
|
|
- Map<String, String> result = parseKvResponse(resp);
|
|
|
- verifyResponse(result, cfg, false);
|
|
|
- if (!result.containsKey("RtnCode")) {
|
|
|
- throw new IllegalStateException("OMG response missing RtnCode");
|
|
|
- }
|
|
|
- return result;
|
|
|
- }
|
|
|
-
|
|
|
- /** 表单 POST(application/x-www-form-urlencoded),返回响应文本。 */
|
|
|
- private String postForm(String url, Map<String, String> params) throws Exception {
|
|
|
- List<NameValuePair> pairs = new ArrayList<>();
|
|
|
- for (Map.Entry<String, String> e : params.entrySet()) {
|
|
|
- pairs.add(new BasicNameValuePair(e.getKey(), e.getValue()));
|
|
|
- }
|
|
|
- HttpPost post = new HttpPost(url);
|
|
|
- post.setEntity(new UrlEncodedFormEntity(pairs, StandardCharsets.UTF_8));
|
|
|
- log.info("[OMG] postForm >>> url={}, MerchantTradeNo={}, MerchantID={}", url,
|
|
|
- params.get("MerchantTradeNo"), params.get("MerchantID"));
|
|
|
- try (CloseableHttpClient client = HttpClients.custom()
|
|
|
- .setDefaultRequestConfig(REQUEST_CONFIG)
|
|
|
- .disableRedirectHandling()
|
|
|
- .build();
|
|
|
- CloseableHttpResponse res = client.execute(post)) {
|
|
|
- int status = res.getStatusLine().getStatusCode();
|
|
|
- if (status < 200 || status >= 300) {
|
|
|
- throw new IllegalStateException("OMG returned HTTP " + status);
|
|
|
- }
|
|
|
- if (res.getEntity() == null) {
|
|
|
- throw new IllegalStateException("OMG returned an empty response");
|
|
|
- }
|
|
|
- long declaredLength = res.getEntity().getContentLength();
|
|
|
- if (declaredLength > MAX_RESPONSE_BYTES) {
|
|
|
- throw new IllegalStateException("OMG response is too large");
|
|
|
- }
|
|
|
- byte[] body = EntityUtils.toByteArray(res.getEntity());
|
|
|
- if (body.length == 0 || body.length > MAX_RESPONSE_BYTES) {
|
|
|
- throw new IllegalStateException("OMG returned an invalid response size");
|
|
|
- }
|
|
|
- String resp = new String(body, StandardCharsets.UTF_8).trim();
|
|
|
- if (resp.isEmpty()) {
|
|
|
- throw new IllegalStateException("OMG returned a blank response");
|
|
|
- }
|
|
|
- log.info("[OMG] postForm <<< status={}, bytes={}", status, body.length);
|
|
|
- return resp;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /** 解析 OMG k=v 文本响应(QueryTradeInfo/DoAction 返回 text/html,以 & 分隔)。 */
|
|
|
- Map<String, String> parseKvResponse(String resp) {
|
|
|
- Map<String, String> map = new LinkedHashMap<>();
|
|
|
- if (resp == null || resp.trim().isEmpty()) {
|
|
|
- throw new IllegalArgumentException("OMG response is blank");
|
|
|
- }
|
|
|
- for (String pair : resp.split("&", -1)) {
|
|
|
- if (pair.isEmpty()) {
|
|
|
- // 尾随 & 或连续 && 产生的空段是 OMG 真实响应常态,跳过;
|
|
|
- // 空段不含 key=value,不进入 map,不影响 CheckMacValue 验签。
|
|
|
- continue;
|
|
|
- }
|
|
|
- int idx = pair.indexOf('=');
|
|
|
- if (idx <= 0) {
|
|
|
- throw new IllegalArgumentException("Malformed OMG response field");
|
|
|
- }
|
|
|
- String key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);
|
|
|
- String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
|
|
|
- if (key.isEmpty()) {
|
|
|
- throw new IllegalArgumentException("Empty OMG response field");
|
|
|
- }
|
|
|
- // 重复键 last-wins(不抛):OMG 大响应(NeedExtraPaidInfo=Y)若含重复键,
|
|
|
- // 不让 queryTrade 在此直接失败;log.warn 记录便于排查,验签用同一 map 重算把关。
|
|
|
- String prev = map.put(key, value);
|
|
|
- if (prev != null) {
|
|
|
- log.warn("[OMG] 响应含重复键(取 last-wins): key={}, prev={}, cur={}", key, prev, value);
|
|
|
- }
|
|
|
- if (map.size() > 100) {
|
|
|
- throw new IllegalArgumentException("Too many OMG response fields");
|
|
|
- }
|
|
|
- }
|
|
|
- return map;
|
|
|
- }
|
|
|
-
|
|
|
- private void verifyResponse(Map<String, String> result, OmgPayConfig cfg, boolean signatureRequired) {
|
|
|
- String checkMacValue = result.get("CheckMacValue");
|
|
|
- if (checkMacValue == null || checkMacValue.isEmpty()) {
|
|
|
- if (signatureRequired) {
|
|
|
- throw new IllegalStateException("OMG response missing CheckMacValue");
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
- if (!OmgCheckMacValue.verify(result, cfg.getHashKey(), cfg.getHashIv())) {
|
|
|
- throw new IllegalStateException("OMG response CheckMacValue mismatch");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private void verifyCorrelation(Map<String, String> result, String key, String expected) {
|
|
|
- String actual = result.get(key);
|
|
|
- if (actual != null && !actual.isEmpty() && !expected.equals(actual)) {
|
|
|
- throw new IllegalStateException("OMG response " + key + " mismatch");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private String endpoint(String baseUrl, String path) {
|
|
|
- requireText(baseUrl, "OMG base URL");
|
|
|
- URI uri = URI.create(baseUrl.trim());
|
|
|
- if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null
|
|
|
- || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null
|
|
|
- || (uri.getPath() != null && !uri.getPath().isEmpty() && !"/".equals(uri.getPath()))) {
|
|
|
- throw new IllegalArgumentException("OMG base URL must be a plain HTTPS origin");
|
|
|
- }
|
|
|
- String normalized = baseUrl.trim();
|
|
|
- while (normalized.endsWith("/")) {
|
|
|
- normalized = normalized.substring(0, normalized.length() - 1);
|
|
|
- }
|
|
|
- return normalized + path;
|
|
|
- }
|
|
|
-
|
|
|
- private void validateConfig(OmgPayConfig cfg) {
|
|
|
- if (cfg == null) {
|
|
|
- throw new IllegalArgumentException("OMG credentials are required");
|
|
|
- }
|
|
|
- requireText(cfg.getMerchantId(), "MerchantID");
|
|
|
- requireText(cfg.getHashKey(), "HashKey");
|
|
|
- requireText(cfg.getHashIv(), "HashIV");
|
|
|
- }
|
|
|
-
|
|
|
- private void requireText(String value, String name) {
|
|
|
- if (value == null || value.trim().isEmpty()) {
|
|
|
- throw new IllegalArgumentException(name + " is required");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /** 脱敏凭证用于日志:前2后2+长度,够排查贴错又不泄露完整值。 */
|
|
|
- private static String mask(String s) {
|
|
|
- if (s == null) {
|
|
|
- return "null";
|
|
|
- }
|
|
|
- if (s.length() <= 4) {
|
|
|
- return "***(len=" + s.length() + ")";
|
|
|
- }
|
|
|
- return s.substring(0, 2) + "***" + s.substring(s.length() - 2) + "(len=" + s.length() + ")";
|
|
|
- }
|
|
|
-
|
|
|
- /** 诊断用:用指定排序/空值策略重算 CheckMacValue,失败返回 err 标记。 */
|
|
|
- private String safeGen(Map<String, String> result, OmgPayConfig cfg, Comparator<String> order, boolean excludeEmpty) {
|
|
|
- try {
|
|
|
- return OmgCheckMacValue.generate(result, cfg.getHashKey(), cfg.getHashIv(), order, excludeEmpty);
|
|
|
- } catch (Exception ce) {
|
|
|
- return "err:" + ce.getClass().getSimpleName();
|
|
|
- }
|
|
|
- }
|
|
|
-}
|