diff --git a/com/wechat/pay/java/core/exception/ServiceException.java b/com/wechat/pay/java/core/exception/ServiceException.java new file mode 100644 index 0000000..4e3d434 --- /dev/null +++ b/com/wechat/pay/java/core/exception/ServiceException.java @@ -0,0 +1,86 @@ +package com.wechat.pay.java.core.exception; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.wechat.pay.java.core.http.HttpRequest; +import com.wechat.pay.java.core.util.GsonUtil; + +/** 发送HTTP请求成功,返回异常时抛出。例如返回状态码小于200或大于等于300、返回体参数不完整。 */ +public class ServiceException extends WechatPayException { + + private static final long serialVersionUID = -7174975090366956652L; + + private final HttpRequest httpRequest; + private final int httpStatusCode; + private final String responseBody; + private String errorCode; + private String errorMessage; + + /** + * 返回状态码小于200或大于300调用 + * + * @param httpRequest http请求 + * @param httpStatusCode http状态码 + * @param responseBody http返回体 + */ + public ServiceException(HttpRequest httpRequest, int httpStatusCode, String responseBody) { + super( + String.format( + "Wrong HttpStatusCode[%d]%nhttpResponseBody[%.1024s]\tHttpRequest[%s]", + httpStatusCode, responseBody, httpRequest)); + this.httpRequest = httpRequest; + this.httpStatusCode = httpStatusCode; + this.responseBody = responseBody; + if (responseBody != null && !responseBody.isEmpty()) { + JsonObject jsonObject = GsonUtil.getGson().fromJson(responseBody, JsonObject.class); + JsonElement code = jsonObject.get("code"); + JsonElement message = jsonObject.get("message"); + this.errorCode = code == null ? null : code.getAsString(); + this.errorMessage = message == null ? null : message.getAsString(); + } + } + + /** + * 获取序列化版本UID + * + * @return UID + */ + public static long getSerialVersionUID() { + return serialVersionUID; + } + + /** + * 获取HTTP请求 + * + * @return HTTP请求 + */ + public HttpRequest getHttpRequest() { + return httpRequest; + } + + /** + * 获取HTTP返回体 + * + * @return HTTP返回体 + */ + public String getResponseBody() { + return responseBody; + } + + /** + * 获取HTTP状态码 + * + * @return HTTP状态码 + */ + public int getHttpStatusCode() { + return httpStatusCode; + } + + public String getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/com/wechat/pay/java/core/http/AbstractHttpClient.java b/com/wechat/pay/java/core/http/AbstractHttpClient.java new file mode 100644 index 0000000..b4f8f25 --- /dev/null +++ b/com/wechat/pay/java/core/http/AbstractHttpClient.java @@ -0,0 +1,151 @@ +package com.wechat.pay.java.core.http; + +import static com.wechat.pay.java.core.http.Constant.ACCEPT; +import static com.wechat.pay.java.core.http.Constant.AUTHORIZATION; +import static com.wechat.pay.java.core.http.Constant.OS; +import static com.wechat.pay.java.core.http.Constant.REQUEST_ID; +import static com.wechat.pay.java.core.http.Constant.USER_AGENT; +import static com.wechat.pay.java.core.http.Constant.USER_AGENT_FORMAT; +import static com.wechat.pay.java.core.http.Constant.VERSION; +import static com.wechat.pay.java.core.http.Constant.WECHAT_PAY_SERIAL; +import static java.net.HttpURLConnection.HTTP_MULT_CHOICE; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.util.Objects.requireNonNull; + +import com.wechat.pay.java.core.auth.Credential; +import com.wechat.pay.java.core.auth.Validator; +import com.wechat.pay.java.core.exception.MalformedMessageException; +import com.wechat.pay.java.core.exception.ServiceException; +import com.wechat.pay.java.core.exception.ValidationException; +import com.wechat.pay.java.core.http.HttpRequest.Builder; +import java.io.InputStream; + +/** 请求客户端抽象基类 */ +public abstract class AbstractHttpClient implements HttpClient { + + protected final Credential credential; + protected final Validator validator; + + public AbstractHttpClient(Credential credential, Validator validator) { + this.credential = requireNonNull(credential); + this.validator = requireNonNull(validator); + } + + @Override + public HttpResponse execute(HttpRequest httpRequest, Class responseClass) { + HttpRequest innerRequest = + new Builder() + .url(httpRequest.getUrl()) + .httpMethod(httpRequest.getHttpMethod()) + .headers(httpRequest.getHeaders()) + .addHeader(AUTHORIZATION, getAuthorization(httpRequest)) + .addHeader(USER_AGENT, getUserAgent()) + .addHeader(WECHAT_PAY_SERIAL, getWechatPaySerial()) + .body(httpRequest.getBody()) + .build(); + OriginalResponse originalResponse = innerExecute(innerRequest); + validateResponse(originalResponse); + return assembleHttpResponse(originalResponse, responseClass); + } + + @Override + public InputStream download(String url) { + HttpRequest originRequest = + new HttpRequest.Builder().httpMethod(HttpMethod.GET).url(url).build(); + HttpRequest httpRequest = + new HttpRequest.Builder() + .url(url) + .httpMethod(HttpMethod.GET) + .addHeader(AUTHORIZATION, getAuthorization(originRequest)) + .addHeader(ACCEPT, "*/*") + .addHeader(USER_AGENT, getUserAgent()) + .addHeader(WECHAT_PAY_SERIAL, getWechatPaySerial()) + .build(); + return innerDownload(httpRequest); + } + + protected abstract InputStream innerDownload(HttpRequest httpRequest); + + protected abstract OriginalResponse innerExecute(HttpRequest httpRequest); + + private void validateResponse(OriginalResponse originalResponse) { + + if (isInvalidHttpCode(originalResponse.getStatusCode())) { + throw new ServiceException( + originalResponse.getRequest(), + originalResponse.getStatusCode(), + originalResponse.getBody()); + } + + if (originalResponse.getBody() != null + && !originalResponse.getBody().isEmpty() + && !MediaType.APPLICATION_JSON.equalsWith(originalResponse.getContentType())) { + throw new MalformedMessageException( + String.format( + "Unsupported content-type[%s]%nhttpRequest[%s]", + originalResponse.getContentType(), originalResponse.getRequest())); + } + + if (!validator.validate(originalResponse.getHeaders(), originalResponse.getBody())) { + String requestId = originalResponse.getHeaders().getHeader(REQUEST_ID); + throw new ValidationException( + String.format( + "Validate response failed,the WechatPay signature is incorrect.%n" + + "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]", + requestId, originalResponse.getHeaders(), originalResponse.getBody())); + } + } + + protected boolean isInvalidHttpCode(int httpCode) { + return httpCode < HTTP_OK || httpCode >= HTTP_MULT_CHOICE; + } + + private HttpResponse assembleHttpResponse( + OriginalResponse originalResponse, Class responseClass) { + return new HttpResponse.Builder() + .originalResponse(originalResponse) + .serviceResponseType(responseClass) + .build(); + } + + private String getSignBody(RequestBody requestBody) { + if (requestBody == null) { + return ""; + } + if (requestBody instanceof JsonRequestBody) { + return ((JsonRequestBody) requestBody).getBody(); + } + if (requestBody instanceof FileRequestBody) { + return ((FileRequestBody) requestBody).getMeta(); + } + throw new UnsupportedOperationException( + String.format("Unsupported RequestBody Type[%s]", requestBody.getClass().getName())); + } + + private String getUserAgent() { + return String.format( + USER_AGENT_FORMAT, + getClass().getPackage().getImplementationVersion(), + OS, + VERSION == null ? "Unknown" : VERSION, + credential.getClass().getSimpleName(), + validator.getClass().getSimpleName(), + getHttpClientInfo()); + } + + private String getWechatPaySerial() { + return this.validator.getSerialNumber(); + } + + /** + * 获取http客户端信息,用于User-Agent。 格式:客户端名称/版本 示例:okhttp3/4.9.3 + * + * @return 客户端信息 + */ + protected abstract String getHttpClientInfo(); + + private String getAuthorization(HttpRequest request) { + return credential.getAuthorization( + request.getUri(), request.getHttpMethod().name(), getSignBody(request.getBody())); + } +} diff --git a/src/main/java/com/gxwebsoft/payment/service/WxTransferService.java b/src/main/java/com/gxwebsoft/payment/service/WxTransferService.java index a8565c9..1fd2a29 100644 --- a/src/main/java/com/gxwebsoft/payment/service/WxTransferService.java +++ b/src/main/java/com/gxwebsoft/payment/service/WxTransferService.java @@ -16,6 +16,8 @@ import com.wechat.pay.java.core.http.JsonRequestBody; import com.wechat.pay.java.core.http.MediaType; import com.wechat.pay.java.core.exception.ServiceException; import com.wechat.pay.java.core.util.GsonUtil; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; import lombok.extern.slf4j.Slf4j; @@ -214,7 +216,8 @@ public class WxTransferService { "未传入完整且对应的转账场景报备信息:请在配置中设置 wechatpay.transfer.scene-report-infos-json(需与 transfer_scene_id=" + transferSceneId + " 的报备信息一致)"); } else { - throw se; + // 透传更友好的错误信息(避免前端看到 SDK 的 Wrong HttpStatusCode…) + throw toPaymentException(se); } } TransferBillsResponse response = httpResponse.getServiceResponse(); @@ -226,6 +229,9 @@ public class WxTransferService { } catch (PaymentException e) { // 业务/参数错误保持原样抛出,避免被包装成 systemError throw e; + } catch (ServiceException se) { + // 兜底:上面的分支未覆盖到的 ServiceException 统一转换为业务可读信息 + throw toPaymentException(se); } catch (Exception e) { log.error("微信商家转账失败(升级版): tenantId={}, outBillNo={}, openid={}, amountFen={}, err={}", tenantId, outBillNo, openid, amountFen, e.getMessage(), e); @@ -233,6 +239,62 @@ public class WxTransferService { } } + private static PaymentException toPaymentException(ServiceException se) { + String code = se.getErrorCode(); + String msg = se.getErrorMessage(); + String detail = extractDetail(se.getResponseBody()); + + // 微信侧 400 通常是请求参数/业务规则不匹配(如 openid 与 appid 不匹配、单号重复、报备信息不一致等) + if (se.getHttpStatusCode() == 400) { + StringBuilder sb = new StringBuilder(); + sb.append("微信商家转账请求无效"); + if (StrUtil.isNotBlank(msg)) { + sb.append(":").append(msg); + } + if (StrUtil.isNotBlank(detail)) { + sb.append("(").append(detail).append(")"); + } + if (StrUtil.isNotBlank(code)) { + sb.append(" [").append(code).append("]"); + } + return PaymentException.paramError(sb.toString()); + } + + // 其他状态码按系统错误处理,保留 code/msg 便于排查 + StringBuilder sb = new StringBuilder("微信商家转账失败"); + if (StrUtil.isNotBlank(msg)) { + sb.append(":").append(msg); + } + if (StrUtil.isNotBlank(detail)) { + sb.append("(").append(detail).append(")"); + } + if (StrUtil.isNotBlank(code)) { + sb.append(" [").append(code).append("]"); + } + return PaymentException.systemError(sb.toString(), se); + } + + private static String extractDetail(String responseBody) { + if (StrUtil.isBlank(responseBody)) { + return null; + } + try { + JsonObject obj = GsonUtil.getGson().fromJson(responseBody, JsonObject.class); + if (obj == null) { + return null; + } + JsonElement detailEl = obj.get("detail"); + if (detailEl != null && !detailEl.isJsonNull()) { + // 常见为字符串;若为对象/数组,toString() 也能提供排错信息 + String detail = detailEl.isJsonPrimitive() ? detailEl.getAsString() : detailEl.toString(); + return StrUtil.isBlank(detail) ? null : detail; + } + } catch (Exception ignore) { + // ignore + } + return null; + } + private static String limitLen(String s, int maxLen) { if (s == null) { return null; diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopDealerWithdrawController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopDealerWithdrawController.java index a30ebd9..fcbd7c8 100644 --- a/src/main/java/com/gxwebsoft/shop/controller/ShopDealerWithdrawController.java +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopDealerWithdrawController.java @@ -238,16 +238,17 @@ public class ShopDealerWithdrawController extends BaseController { return fail("tenantId为空,无法发起微信转账"); } - String openid = StrUtil.isNotBlank(db.getOpenId()) ? db.getOpenId() : db.getOfficeOpenid(); + // 小程序“收款确认页”只能使用小程序openid;公众号openid传入会导致微信侧 400(INVALID_REQUEST)。 + String openid = db.getOpenId(); if (StrUtil.isBlank(openid)) { - // 兜底:从分销商信息关联获取openid + // 兜底:从分销商信息关联获取openid(同样应为小程序openid) ShopDealerUser dealerUser = shopDealerUserService.getByUserIdRel(db.getUserId()); if (dealerUser != null && StrUtil.isNotBlank(dealerUser.getOpenid())) { openid = dealerUser.getOpenid(); } } if (StrUtil.isBlank(openid)) { - return fail("用户openid为空,无法拉起微信收款确认页"); + return fail("用户小程序openid为空,无法拉起微信收款确认页"); } // 使用提现记录ID构造单号,保持幂等;微信要求 5-32 且仅字母/数字 diff --git a/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopDealerWithdrawMapper.xml b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopDealerWithdrawMapper.xml index 767ee27..180f9d4 100644 --- a/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopDealerWithdrawMapper.xml +++ b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopDealerWithdrawMapper.xml @@ -4,7 +4,13 @@ - SELECT a.*, b.nickname, b.phone AS phone, b.avatar,b.openid,b.office_openid, c.real_name as realName + SELECT a.*, + b.nickname, + b.phone AS phone, + b.avatar, + b.openid AS openId, + b.office_openid AS officeOpenid, + c.real_name AS realName FROM shop_dealer_withdraw a LEFT JOIN gxwebsoft_core.sys_user b ON a.user_id = b.user_id LEFT JOIN gxwebsoft_core.sys_user_verify c ON a.user_id = c.user_id AND c.status = 1 diff --git a/src/main/resources/application-ysb.yml b/src/main/resources/application-ysb.yml index 1b72f25..283f5a4 100644 --- a/src/main/resources/application-ysb.yml +++ b/src/main/resources/application-ysb.yml @@ -44,7 +44,21 @@ mqtt: connection-timeout: 10 keep-alive-interval: 20 auto-reconnect: true - +# Mybatis-plus配置 +mybatis-plus: + mapper-locations: classpath*:com/gxwebsoft/**/*Mapper.xml + configuration: + map-underscore-to-camel-case: true + cache-enabled: true + global-config: + banner: false + # SqlRunner.db().xxx 需要开启该开关,否则会报: + # Mapped Statements collection does not contain value for com.baomidou.mybatisplus.core.mapper.SqlRunner.Delete + enable-sql-runner: true + db-config: + id-type: auto + logic-delete-value: 1 + logic-not-delete-value: 0 # 框架配置 config: # 文件服务器 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2c62f2f..cf7c925 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -82,7 +82,10 @@ mybatis-plus: map-underscore-to-camel-case: true cache-enabled: true global-config: - :banner: false + banner: false + # SqlRunner.db().xxx 需要开启该开关,否则会报: + # Mapped Statements collection does not contain value for com.baomidou.mybatisplus.core.mapper.SqlRunner.Delete + enable-sql-runner: true db-config: id-type: auto logic-delete-value: 1