From a31dd4b74b0b47c408029757b10989e1eb555c4f Mon Sep 17 00:00:00 2001 From: 13405411873 <1994398261@qq.com> Date: Mon, 28 Apr 2025 19:23:49 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-admin/pom.xml | 19 + .../src/main/java/com/ruoyi/api/PayApi.java | 110 +++- .../com/ruoyi/api/util/ShopBeanConfig.java | 43 ++ .../com/ruoyi/api/util/TransferToUser.java | 594 ++++++++++++++++++ .../controller/MemberPointsController.java | 5 +- .../service/impl/MemberPointsServiceImpl.java | 3 +- .../com/ruoyi/payConfig/WechatPayConfig.java | 4 + 7 files changed, 771 insertions(+), 7 deletions(-) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/api/util/ShopBeanConfig.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/api/util/TransferToUser.java diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 4333424..d000cc5 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -28,11 +28,30 @@ wechatpay-apache-httpclient 0.4.7 + + com.github.binarywang + weixin-java-pay + 4.7.2.B + com.github.wechatpay-apiv3 wechatpay-java 0.2.17 + + + com.squareup.okhttp3 + okhttp + 4.10.0 + + + + + com.squareup.okio + okio + 3.0.0 + + org.springframework.boot diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/PayApi.java b/ruoyi-admin/src/main/java/com/ruoyi/api/PayApi.java index 9e8ba68..41dceb5 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/PayApi.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/PayApi.java @@ -5,18 +5,31 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.TypeReference; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.binarywang.wxpay.bean.transfer.TransferBillsRequest; +import com.github.binarywang.wxpay.bean.transfer.TransferBillsResult; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import com.ruoyi.api.util.TransferToUser; import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.config.WxAppConfig; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.member.domain.MemberOrder; +import com.ruoyi.member.domain.MemberPoints; import com.ruoyi.member.service.IMemberOrderService; +import com.ruoyi.member.service.IMemberPointsService; import com.ruoyi.payConfig.WechatPayConfig; +import com.ruoyi.system.service.ISysUserService; import com.wechat.pay.contrib.apache.httpclient.util.AesUtil; import com.wechat.pay.java.core.Config; +import com.wechat.pay.java.core.RSAAutoCertificateConfig; import com.wechat.pay.java.core.RSAPublicKeyConfig; +import com.wechat.pay.java.core.http.*; import com.wechat.pay.java.service.payments.jsapi.JsapiService; import com.wechat.pay.java.service.payments.jsapi.JsapiServiceExtension; import com.wechat.pay.java.service.payments.jsapi.model.*; @@ -24,6 +37,7 @@ import com.wechat.pay.java.service.payments.nativepay.NativePayService; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.RequestBody; import javax.annotation.Resource; import java.io.IOException; @@ -31,9 +45,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.security.*; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; +import java.util.*; @RestController @RequestMapping("/payApi") @@ -43,9 +55,18 @@ public class PayApi { @Autowired private IMemberOrderService memberOrderService; + @Autowired + private TransferToUser transferToUser; + @Autowired + private IMemberPointsService pointsService; + @Autowired + private ISysUserService sysUserService; + @Autowired + private WxAppConfig appConfig; + /** * type:h5、jsapi、app、native、sub_jsapi - * @param type + * @return */ @ApiOperation(value = "统一下单-统一接口", notes = "统一下单-统一接口") @@ -88,6 +109,55 @@ public class PayApi { return service.prepayWithRequestPayment(request); } + + + + + @RequestMapping("/entPay") + @ApiOperation(value = "商家转账给用户") + public JSONObject entPay(String orderNo){ + LambdaQueryWrapper queryWrapper =new LambdaQueryWrapper<>(); + queryWrapper.eq(MemberPoints::getOrderNo,orderNo).last("limit 1"); + MemberPoints memberPoints = pointsService.getOne(queryWrapper); + if (!memberPoints.getUserId().equals(SecurityUtils.getUserId())){ + throw new RuntimeException("非本人账户"); + } + SysUser sysUser = sysUserService.selectUserById(memberPoints.getUserId()); + // 初始化商户配置 + TransferToUser.TransferToUserRequest userRequest = new TransferToUser.TransferToUserRequest(); + userRequest.appid = appConfig.getAppId(); + userRequest.openid=sysUser.getOpenId(); + // 将元转换为分 + double pointsInYuan = memberPoints.getPoints(); + userRequest.transferAmount = BigDecimal.valueOf(pointsInYuan).multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.HALF_UP).longValue(); + + userRequest.notifyUrl = wechatPayConfig.getZhuanNotifyUrl(); + userRequest.outBillNo = orderNo; + userRequest.transferSceneId = "1000"; + userRequest.transferRemark = "通告快接积分提现"; + List transferSceneReportInfos = new ArrayList<>(); + TransferToUser.TransferSceneReportInfo transferSceneReportInfo = new TransferToUser.TransferSceneReportInfo(); + transferSceneReportInfo.infoContent = "发布通告得积分"; + transferSceneReportInfo.infoType = "活动名称"; + TransferToUser.TransferSceneReportInfo transferSceneReportInfo2 = new TransferToUser.TransferSceneReportInfo(); + transferSceneReportInfo2.infoContent = "发布通告得积分"; + transferSceneReportInfo2.infoType = "奖励说明"; + transferSceneReportInfos.add(transferSceneReportInfo); + transferSceneReportInfos.add(transferSceneReportInfo2); + userRequest.transferSceneReportInfos = transferSceneReportInfos; + TransferToUser.TransferToUserResponse run = transferToUser.run(userRequest); + JSONObject res = new JSONObject(); + res.put("runData",run); + JSONObject config = new JSONObject(); + config.put("appId",appConfig.getAppId()); + config.put("mchId",wechatPayConfig.getMchId()); + res.put("config",config); + return res; + + } + + + @ApiOperation(value = "支付回调", notes = "支付回调") @PostMapping("/payNotify") @Anonymous @@ -110,6 +180,38 @@ public class PayApi { } + @ApiOperation(value = "提现支付回调", notes = "提现支付回调") + @PostMapping("/zhuanNotify") + @Anonymous + public Map zhuanNotify(@RequestBody JSONObject jsonObject) throws GeneralSecurityException, IOException { + String key = wechatPayConfig.getApiV3Key(); + String json = jsonObject.toString(); + String associated_data = (String) JSONUtil.getByPath(JSONUtil.parse(json), "resource.associated_data"); + String ciphertext = (String) JSONUtil.getByPath(JSONUtil.parse(json), "resource.ciphertext"); + String nonce = (String) JSONUtil.getByPath(JSONUtil.parse(json), "resource.nonce"); + String decryptData = new AesUtil(key.getBytes(StandardCharsets.UTF_8)).decryptToString(associated_data.getBytes(StandardCharsets.UTF_8), nonce.getBytes(StandardCharsets.UTF_8), ciphertext); + //验签成功 + JSONObject decryptDataObj = JSONObject.parseObject(decryptData, JSONObject.class); + String orderNo = decryptDataObj.get("out_bill_no").toString(); + String state = decryptDataObj.get("state").toString(); + //调用业务系统 + if(state.equals("SUCCESS")){ + pointsService.payoutCallback(orderNo,"success"); + Map res = new HashMap<>(); + res.put("code", "SUCCESS"); + res.put("message", "成功"); + return res; + } else if (state.equals("FAIL")) { + pointsService.payoutCallback(orderNo,"fail"); + + Map res = new HashMap<>(); + res.put("code", "SUCCESS"); + res.put("message", "成功"); + return res; + } + return new HashMap<>(); + + } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/util/ShopBeanConfig.java b/ruoyi-admin/src/main/java/com/ruoyi/api/util/ShopBeanConfig.java new file mode 100644 index 0000000..7088a60 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/util/ShopBeanConfig.java @@ -0,0 +1,43 @@ +//package com.ruoyi.api.util; +// +//import com.github.binarywang.wxpay.config.WxPayConfig; +//import com.github.binarywang.wxpay.service.WxPayService; +//import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +//import com.ruoyi.common.utils.StringUtils; +//import com.ruoyi.payConfig.WechatPayConfig; +//import lombok.AllArgsConstructor; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +// +//@Configuration +//@AllArgsConstructor +//@ConditionalOnClass(WxPayService.class) +//public class ShopBeanConfig { +// @Autowired +// private WechatPayConfig wechatPayConfig; +// +// +// +// @Bean +// public WxPayService wxService() { +// WxPayConfig payConfig = new WxPayConfig(); +// payConfig.setAppId(StringUtils.trimToNull(wechatPayConfig.getAppId())); +// payConfig.setMchId(StringUtils.trimToNull(wechatPayConfig.getMchId())); +// payConfig.setMchKey(StringUtils.trimToNull(wechatPayConfig.getMchKey())); +// payConfig.setKeyPath(StringUtils.trimToNull(wechatPayConfig.getKeyPath())); +// payConfig.setApiV3Key(StringUtils.trimToNull(wechatPayConfig.getApiV3Key())); +// payConfig.setCertSerialNo(StringUtils.trimToNull(wechatPayConfig.getSerialNo())); +// payConfig.setPrivateKeyPath(StringUtils.trimToNull(wechatPayConfig.getPrivateKeyPath())); +// payConfig.setPrivateCertPath(StringUtils.trimToNull(wechatPayConfig.getPrivateCertPath())); +// payConfig.setNotifyUrl(StringUtils.trimToNull(wechatPayConfig.getZhuanNotifyUrl())); +// +// // 可以指定是否使用沙箱环境 +// payConfig.setUseSandboxEnv(false); +// +// WxPayService wxPayService = new WxPayServiceImpl(); +// wxPayService.setConfig(payConfig); +// return wxPayService; +// } +//} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/util/TransferToUser.java b/ruoyi-admin/src/main/java/com/ruoyi/api/util/TransferToUser.java new file mode 100644 index 0000000..8e69646 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/util/TransferToUser.java @@ -0,0 +1,594 @@ +package com.ruoyi.api.util; + + +import com.google.gson.ExclusionStrategy; +import com.google.gson.FieldAttributes; +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; +import com.google.gson.internal.Primitives; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.SignatureException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import javax.annotation.Resource; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; + +import com.ruoyi.payConfig.WechatPayConfig; +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.BufferedSource; +import org.springframework.stereotype.Component; + +/** + * 发起转账 + */ +@Component +public class TransferToUser { + private static final String host = "https://api.mch.weixin.qq.com"; + private static final String path = "/v3/fund-app/mch-transfer/transfer-bills"; + private static final String method = "POST"; + + + + + @Resource + private WechatPayConfig wechatPayConfig; + + + + /** + * https://pay.weixin.qq.com/doc/v3/merchant/4012716434 + * + * 商家转账用户确认模式下,用户申请收款时,商户可通过此接口申请创建转账单 + * + * * 接口返回的HTTP状态码及错误码,仅代表本次请求的结果,不能代表订单状态。 + * * 接口返回的HTTP状态码为200,且赔付状态为ACCEPT时,可认为发起商家转账成功。 + * * **接口返回的HTTP状态码不为200时,请商户务必不要立即更换商户订单单号重试**。可根据错误码列表中的描述和接口返回的信息进行处理,并在查询原订单结果为失败或者联系客服确认情况后,再更换商户订单号进行重试。否则会有重复转账的资金风险。 + * + * 注:单个商户的接口频率限制为100次/s + * + * @return TransferToUserResponse + */ + public TransferToUserResponse run(TransferToUserRequest request) { + // 请求参数验证 + + + // 构造HTTP请求 + String uri = path; + String body = this.createRequestBody(request); + + // 发送HTTP请求 + try (Response httpResponse = this.sendHttpRequest(uri, body)) { + // 应答结果检查 + String respBody = validateResponse(httpResponse); + + // 从HTTP应答报文构建返回数据 + return parseResponse(respBody); + } catch (IOException e) { + throw new UncheckedIOException("Sending request to " + uri + " failed.", e); + } + } + + /** + * 商家转账-发起转账-验证请求参数 + * + * 参数列表: + * - appid string(32): [必填] **【商户AppID】** 是微信开放平台和微信公众平台为开发者的应用程序(APP、小程序、公众号、企业号corpid即为此AppID)提供的一个唯一标识。此处,可以填写这四种类型中的任意一种APPID,但请确保该appid与商户号有绑定关系。详见:[普通商户模式开发必要参数说明](https://iwiki.woa.com/p/4013070756)。 + * - out_bill_no string(32): [必填] **【商户单号】** 商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一 + * - transfer_scene_id string(36): [必填] **【转账场景ID】** 该笔转账使用的转账场景,可前往“商户平台-产品中心-商家转账”中申请。如:1001-现金营销 + * - openid string(64): [必填] **【收款用户OpenID】** 用户在商户appid下的唯一标识。发起转账前需获取到用户的OpenID,获取方式详见 [参数说明](https://iwiki.woa.com/p/4012068676)。 + * - user_name string: [选填] **【收款用户姓名】** 收款方真实姓名。需要加密传入,支持标准RSA算法和国密算法,公钥由微信侧提供。
转账金额 >= 2,000元时,该笔明细必须填写
若商户传入收款用户姓名,微信支付会校验收款用户与输入姓名是否一致,并提供电子回单 + * - transfer_amount integer: [必填] **【转账金额】** 转账金额单位为“分”。 + * - transfer_remark string(32): [必填] **【转账备注】** 转账备注,用户收款时可见该备注信息,UTF8编码,最多允许32个字符 + * - notify_url string(256): [选填] **【通知地址】** 异步接收微信支付结果通知的回调地址,通知url必须为公网可访问的URL,必须为HTTPS,不能携带参数。 + * - user_recv_perception string: [选填] **【用户收款感知】** 用户收款时感知到的收款原因将根据转账场景自动展示默认内容。如有其他展示需求,可在本字段传入。各场景展示的默认内容和支持传入的内容,可查看产品文档了解。 + * - transfer_scene_report_infos array[TransferSceneReportInfo]: [选填] **【转账场景报备信息】** 各转账场景下需报备的内容,商户需要按照所属转账场景规则传参,详见[转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588)。 + * - transfer_scene_report_infos.info_type string(15): [必填] **【信息类型】** 不能超过15个字符,商户所属转账场景下的信息类型,此字段内容为固定值,需严格按照 [转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588) 传参。 + * - transfer_scene_report_infos.info_content string(32): [必填] **【信息内容】** 不能超过32个字符,商户所属转账场景下的信息内容,商户可按实际业务场景自定义传参,需严格按照 [转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588) 传参。 + * + * 请根据上述字段列表提供的参数的必要性、最长长度限制,并根据描述中所述的关联,编写参数检查逻辑,每个字段以驼峰命名Public访问,无需Getter。 + * @param request 请求参数 + */ + private void validateRequestParameters(TransferToUserRequest request) { + // 商家转账-发起转账-验证请求参数 生成示例,需选中该注释,通过 @demo 指令触发 + throw new UnsupportedOperationException("Method not implemented yet"); + } + + /** + * 商家转账-发起转账-构造HTTP请求包体 + * + * 包体参数列表: + * - appid string(32): [必填] **【商户AppID】** 是微信开放平台和微信公众平台为开发者的应用程序(APP、小程序、公众号、企业号corpid即为此AppID)提供的一个唯一标识。此处,可以填写这四种类型中的任意一种APPID,但请确保该appid与商户号有绑定关系。详见:[普通商户模式开发必要参数说明](https://iwiki.woa.com/p/4013070756)。 + * - out_bill_no string(32): [必填] **【商户单号】** 商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一 + * - transfer_scene_id string(36): [必填] **【转账场景ID】** 该笔转账使用的转账场景,可前往“商户平台-产品中心-商家转账”中申请。如:1001-现金营销 + * - openid string(64): [必填] **【收款用户OpenID】** 用户在商户appid下的唯一标识。发起转账前需获取到用户的OpenID,获取方式详见 [参数说明](https://iwiki.woa.com/p/4012068676)。 + * - user_name string: [选填] **【收款用户姓名】** 收款方真实姓名。需要加密传入,支持标准RSA算法和国密算法,公钥由微信侧提供。
转账金额 >= 2,000元时,该笔明细必须填写
若商户传入收款用户姓名,微信支付会校验收款用户与输入姓名是否一致,并提供电子回单 + * - transfer_amount integer: [必填] **【转账金额】** 转账金额单位为“分”。 + * - transfer_remark string(32): [必填] **【转账备注】** 转账备注,用户收款时可见该备注信息,UTF8编码,最多允许32个字符 + * - notify_url string(256): [选填] **【通知地址】** 异步接收微信支付结果通知的回调地址,通知url必须为公网可访问的URL,必须为HTTPS,不能携带参数。 + * - user_recv_perception string: [选填] **【用户收款感知】** 用户收款时感知到的收款原因将根据转账场景自动展示默认内容。如有其他展示需求,可在本字段传入。各场景展示的默认内容和支持传入的内容,可查看产品文档了解。 + * - transfer_scene_report_infos array[TransferSceneReportInfo]: [选填] **【转账场景报备信息】** 各转账场景下需报备的内容,商户需要按照所属转账场景规则传参,详见[转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588)。 + * - transfer_scene_report_infos.info_type string(15): [必填] **【信息类型】** 不能超过15个字符,商户所属转账场景下的信息类型,此字段内容为固定值,需严格按照 [转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588) 传参。 + * - transfer_scene_report_infos.info_content string(32): [必填] **【信息内容】** 不能超过32个字符,商户所属转账场景下的信息内容,商户可按实际业务场景自定义传参,需严格按照 [转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588) 传参。 + * + * 请参考上述字段列表从 request 中构造 TransferToUserRequest 类型,并根据描述对特定字段进行加密,加密函数使用 Utility.encrypt(this.wechatpayPublicKey, text) + * @return HTTP 请求报文String + */ + private String createRequestBody(TransferToUserRequest request) { + TransferToUserRequest body = new TransferToUserRequest(); + body.appid = request.appid; + body.outBillNo = request.outBillNo; + body.transferSceneId = request.transferSceneId; + body.openid = request.openid; + if (request.userName != null) { + body.userName = request.userName; // TODO: 需要加密 + } + body.transferAmount = request.transferAmount; + body.transferRemark = request.transferRemark; + if (request.notifyUrl != null) { + body.notifyUrl = request.notifyUrl; + } + if (request.userRecvPerception != null) { + body.userRecvPerception = request.userRecvPerception; + } + if (request.transferSceneReportInfos != null) { + body.transferSceneReportInfos = request.transferSceneReportInfos; + } + return Utility.toJson(body); + } + + private String buildAuthorization(String method, String uri, String body) { + return Utility.buildAuthorization(wechatPayConfig.getMchId(), wechatPayConfig.getSerialNo(), Utility.loadPrivateKeyFromPath(wechatPayConfig.getPrivateKeyPath()), method, uri, body); + } + + private Response sendHttpRequest(String uri, String body) throws IOException { + Request.Builder builder = new Request.Builder().url(host + uri); + builder.addHeader("Accept", "application/json"); + builder.addHeader("Wechatpay-Serial", wechatPayConfig.getPublicKeyId()); + builder.addHeader("Authorization", buildAuthorization(method, uri, body)); + builder.addHeader("Content-Type", "application/json"); + RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body); + builder.method(method, requestBody); + + Request request = builder.build(); + OkHttpClient client = new OkHttpClient.Builder().build(); + return client.newCall(request).execute(); + } + + private String validateResponse(Response response) { + String body = ""; + if (response.body() != null) { + try { + BufferedSource source = response.body().source(); + body = source.readUtf8(); + } catch (IOException e) { + throw new RuntimeException(String.format("An error occurred during reading response body. Status: %d", response.code()), e); + } + } + + if (response.code() >= 200 && response.code() < 300) { + // 2XX 成功,继续验证应答签名 + Headers headers = response.headers(); + Utility.validateResponse(wechatPayConfig.getPublicKeyId(),Utility.loadPublicKeyFromPath(wechatPayConfig.getPublicKeyPath()) , headers, body); + return body; + } + + // TODO: 根据错误码执行不同的处理 + throw new UnsupportedOperationException(String.format("接口请求错误,StatusCode: %d, Body: %s", + response.code(), body)); + } + /** + * 商家转账-发起转账-解析回包结果 + * - out_bill_no string(32): [必填] **【商户单号】** 商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一 + * - transfer_bill_no string(64): [必填] **【微信转账单号】** 微信转账单号,微信商家转账系统返回的唯一标识 + * - create_time string: [必填] **【单据创建时间】** 单据受理成功时返回,按照使用rfc3339所定义的格式,格式为yyyy-MM-DDThh:mm:ss+TIMEZONE + * - state string: [必填] **【单据状态】** 商家转账订单状态 + * - fail_reason string: [选填] **【失败原因】** 订单已失败或者已退资金时,会返回 [订单失败原因](https://iwiki.woa.com/p/4013774966) + * - package_info string: [选填] **【跳转领取页面的package信息】** 跳转微信支付收款页的package信息,[APP调起用户确认收款](https://iwiki.woa.com/p/4012719576) 或者 [JSAPI调起用户确认收款](https://iwiki.woa.com/p/4012716430) 时需要使用的参数。
单据创建后,**用户24小时内不领取将过期关闭**,建议拉起用户确认收款页面前,先查单据状态:如单据状态为待收款用户确认,可用之前的package信息拉起;单据到终态时需更换单号重新发起转账。 + * + * @return TransferToUserResponse + */ + private TransferToUserResponse parseResponse(String body) { + // 商家转账-发起转账-解析回包结果 生成示例,需选中该注释,通过 @demo 指令触发 + TransferToUserResponse response = Utility.fromJson(body, TransferToUserResponse.class); + return response; + } + + static class Utility { + private static final Gson gson = new GsonBuilder() + .disableHtmlEscaping() + .addSerializationExclusionStrategy(new ExclusionStrategy() { + @Override + public boolean shouldSkipField(FieldAttributes fieldAttributes) { + final Expose expose = fieldAttributes.getAnnotation(Expose.class); + return expose != null && !expose.serialize(); + } + + @Override + public boolean shouldSkipClass(Class aClass) { + return false; + } + }) + .addDeserializationExclusionStrategy(new ExclusionStrategy() { + @Override + public boolean shouldSkipField(FieldAttributes fieldAttributes) { + final Expose expose = fieldAttributes.getAnnotation(Expose.class); + return expose != null && !expose.deserialize(); + } + + @Override + public boolean shouldSkipClass(Class aClass) { + return false; + } + }) + .create(); + private static final char[] SYMBOLS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + private static final SecureRandom random = new SecureRandom(); + + public static String toJson(Object object) { return gson.toJson(object); } + + public static T fromJson(String json, Class classOfT) throws JsonSyntaxException { + return gson.fromJson(json, classOfT); + } + + private static String readKeyStringFromPath(String keyPath) { + try { + return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static PrivateKey loadPrivateKeyFromString(String keyString) { + try { + keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + return KeyFactory.getInstance("RSA").generatePrivate( + new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString))); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException(e); + } catch (InvalidKeySpecException e) { + throw new IllegalArgumentException(e); + } + } + + public static PrivateKey loadPrivateKeyFromPath(String keyPath) { + return loadPrivateKeyFromString(readKeyStringFromPath(keyPath)); + } + + public static PublicKey loadPublicKeyFromString(String keyString) { + try { + keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + return KeyFactory.getInstance("RSA").generatePublic( + new X509EncodedKeySpec(Base64.getDecoder().decode(keyString))); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException(e); + } catch (InvalidKeySpecException e) { + throw new IllegalArgumentException(e); + } + } + + public static PublicKey loadPublicKeyFromPath(String keyPath) { + return loadPublicKeyFromString(readKeyStringFromPath(keyPath)); + } + + public static String createNonce(int length) { + char[] buf = new char[length]; + for (int i = 0; i < length; ++i) { + buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)]; + } + return new String(buf); + } + + public static String encrypt(PublicKey publicKey, String plaintext) { + final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; + + try { + Cipher cipher = Cipher.getInstance(transformation); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new IllegalArgumentException("The current Java environment does not support " + transformation, e); + } catch (InvalidKeyException e) { + throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e); + } catch (BadPaddingException | IllegalBlockSizeException e) { + throw new IllegalArgumentException("Plaintext is too long", e); + } + } + + public static String sign(String message, String algorithm, PrivateKey privateKey) { + byte[] sign; + try { + Signature signature = Signature.getInstance(algorithm); + signature.initSign(privateKey); + signature.update(message.getBytes(StandardCharsets.UTF_8)); + sign = signature.sign(); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e); + } catch (InvalidKeyException e) { + throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e); + } catch (SignatureException e) { + throw new RuntimeException("An error occurred during the sign process.", e); + } + return Base64.getEncoder().encodeToString(sign); + } + + public static boolean verify(String message, String signature, String algorithm, PublicKey publicKey) { + try { + Signature sign = Signature.getInstance(algorithm); + sign.initVerify(publicKey); + sign.update(message.getBytes(StandardCharsets.UTF_8)); + return sign.verify(Base64.getDecoder().decode(signature)); + } catch (SignatureException e) { + return false; + } catch (InvalidKeyException e) { + throw new IllegalArgumentException("verify uses an illegal publickey.", e); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e); + } + } + + public static String buildAuthorization(String mchid, String certificateSerialNo, PrivateKey privateKey, + String method, String uri, String body) { + String nonce = createNonce(32); + long timestamp = Instant.now().getEpochSecond(); + + String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce, body == null ? "" : body); + + String signature = sign(message, "SHA256withRSA", privateKey); + + return String.format( + "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\",timestamp=\"%d\",serial_no=\"%s\"", + mchid, nonce, signature, timestamp, certificateSerialNo); + } + + public static String urlEncode(String content) { + try { + return URLEncoder.encode(content, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey, Headers headers, + String body) { + String timestamp = headers.get("Wechatpay-Timestamp"); + try { + Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp)); + // 拒绝过期请求 + if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) { + throw new IllegalArgumentException( + String.format("Validate http response,timestamp[%s] of httpResponse is expires, " + + "request-id[%s]", + timestamp, headers.get("Request-ID"))); + } + } catch (DateTimeException | NumberFormatException e) { + throw new IllegalArgumentException( + String.format("Validate http response,timestamp[%s] of httpResponse is invalid, request-id[%s]", timestamp, + headers.get("Request-ID"))); + } + String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), body == null ? "" : body); + String serialNumber = headers.get("Wechatpay-Serial"); + if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { + throw new IllegalArgumentException( + String.format("Invalid Wechatpay-Serial, Local: %s, Remote: %s", wechatpayPublicKeyId, serialNumber)); + } + String signature = headers.get("Wechatpay-Signature"); + + boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey); + if (!success) { + throw new IllegalArgumentException( + String.format("Validate response failed,the WechatPay signature is incorrect.%n" + + "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]", + headers.get("Request-ID"), headers, body)); + } + } + } + + /** + * TransferToUserResponse + */ + public static class TransferToUserResponse { + + /** + * 商户单号 + * 说明:商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一 + */ + @SerializedName("out_bill_no") + public String outBillNo; + + /** + * 微信转账单号 + * 说明:微信转账单号,微信商家转账系统返回的唯一标识 + */ + @SerializedName("transfer_bill_no") + public String transferBillNo; + + /** + * 单据创建时间 + * 说明:单据受理成功时返回,按照使用rfc3339所定义的格式,格式为yyyy-MM-DDThh:mm:ss+TIMEZONE + */ + @SerializedName("create_time") + public String createTime; + + /** + * 单据状态 + * 说明:商家转账订单状态 + */ + @SerializedName("state") + public TransferBillStatus state; + + /** + * 失败原因 + * 说明:订单已失败或者已退资金时,会返回 [订单失败原因](https://iwiki.woa.com/p/4013774966) + */ + @SerializedName("fail_reason") + public String failReason; + + /** + * 跳转领取页面的package信息 + * 说明:跳转微信支付收款页的package信息,[APP调起用户确认收款](https://iwiki.woa.com/p/4012719576) 或者 [JSAPI调起用户确认收款](https://iwiki.woa.com/p/4012716430) 时需要使用的参数。 单据创建后,**用户24小时内不领取将过期关闭**,建议拉起用户确认收款页面前,先查单据状态:如单据状态为待收款用户确认,可用之前的package信息拉起;单据到终态时需更换单号重新发起转账。 + */ + @SerializedName("package_info") + public String packageInfo; + } + /** + * TransferBillStatus + */ + public enum TransferBillStatus { + + @SerializedName("ACCEPTED") + ACCEPTED, + + @SerializedName("PROCESSING") + PROCESSING, + + @SerializedName("WAIT_USER_CONFIRM") + WAIT_USER_CONFIRM, + + @SerializedName("TRANSFERING") + TRANSFERING, + + @SerializedName("SUCCESS") + SUCCESS, + + @SerializedName("FAIL") + FAIL, + + @SerializedName("CANCELING") + CANCELING, + + @SerializedName("CANCELLED") + CANCELLED + } + + /** + * TransferSceneReportInfo + */ + public static class TransferSceneReportInfo { + + /** + * 信息类型 + * 说明:不能超过15个字符,商户所属转账场景下的信息类型,此字段内容为固定值,需严格按照 [转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588) 传参。 + */ + @SerializedName("info_type") + public String infoType; + + /** + * 信息内容 + * 说明:不能超过32个字符,商户所属转账场景下的信息内容,商户可按实际业务场景自定义传参,需严格按照 [转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588) 传参。 + */ + @SerializedName("info_content") + public String infoContent; + } + + /** + * TransferToUserRequest + */ + public static class TransferToUserRequest { + + /** + * 商户AppID + * 说明:是微信开放平台和微信公众平台为开发者的应用程序(APP、小程序、公众号、企业号corpid即为此AppID)提供的一个唯一标识。此处,可以填写这四种类型中的任意一种APPID,但请确保该appid与商户号有绑定关系。详见:[普通商户模式开发必要参数说明](https://iwiki.woa.com/p/4013070756)。 + */ + @SerializedName("appid") + public String appid; + + /** + * 商户单号 + * 说明:商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一 + */ + @SerializedName("out_bill_no") + public String outBillNo; + + /** + * 转账场景ID + * 说明:该笔转账使用的转账场景,可前往“商户平台-产品中心-商家转账”中申请。如:1001-现金营销 + */ + @SerializedName("transfer_scene_id") + public String transferSceneId; + + /** + * 收款用户OpenID + * 说明:用户在商户appid下的唯一标识。发起转账前需获取到用户的OpenID,获取方式详见 [参数说明](https://iwiki.woa.com/p/4012068676)。 + */ + @SerializedName("openid") + public String openid; + + /** + * 收款用户姓名 + * 说明:收款方真实姓名。需要加密传入,支持标准RSA算法和国密算法,公钥由微信侧提供。 转账金额 >= 2,000元时,该笔明细必须填写 若商户传入收款用户姓名,微信支付会校验收款用户与输入姓名是否一致,并提供电子回单 + */ + @SerializedName("user_name") + public String userName; + + /** + * 转账金额 + * 说明:转账金额单位为“分”。 + */ + @SerializedName("transfer_amount") + public Long transferAmount; + + /** + * 转账备注 + * 说明:转账备注,用户收款时可见该备注信息,UTF8编码,最多允许32个字符 + */ + @SerializedName("transfer_remark") + public String transferRemark; + + /** + * 通知地址 + * 说明:异步接收微信支付结果通知的回调地址,通知url必须为公网可访问的URL,必须为HTTPS,不能携带参数。 + */ + @SerializedName("notify_url") + public String notifyUrl; + + /** + * 用户收款感知 + * 说明:用户收款时感知到的收款原因将根据转账场景自动展示默认内容。如有其他展示需求,可在本字段传入。各场景展示的默认内容和支持传入的内容,可查看产品文档了解。 + */ + @SerializedName("user_recv_perception") + public String userRecvPerception; + + /** + * 转账场景报备信息 + * 说明:各转账场景下需报备的内容,商户需要按照所属转账场景规则传参,详见[转账场景报备信息字段说明](https://iwiki.woa.com/p/4013774588)。 + */ + @SerializedName("transfer_scene_report_infos") + public List transferSceneReportInfos; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/member/controller/MemberPointsController.java b/ruoyi-admin/src/main/java/com/ruoyi/member/controller/MemberPointsController.java index 2b8176a..fe10f6f 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/member/controller/MemberPointsController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/member/controller/MemberPointsController.java @@ -1,5 +1,6 @@ package com.ruoyi.member.controller; +import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.common.annotation.Log; @@ -75,10 +76,12 @@ public class MemberPointsController extends BaseController { public AjaxResult payout(@RequestBody MemberPoints memberPoints) { try { memberPointsService.payout(memberPoints); + JSONObject res =new JSONObject(); + res.put("orderNo",memberPoints.getOrderNo()); + return success(res); }catch (Exception e) { return error(e.getMessage()); } - return success(); } /** diff --git a/ruoyi-admin/src/main/java/com/ruoyi/member/service/impl/MemberPointsServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/member/service/impl/MemberPointsServiceImpl.java index 2176ca2..ea9e52e 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/member/service/impl/MemberPointsServiceImpl.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/member/service/impl/MemberPointsServiceImpl.java @@ -100,7 +100,6 @@ public class MemberPointsServiceImpl extends ServiceImpl list = list(lambdaQueryWrapper); if (list.isEmpty()){ @@ -203,6 +202,6 @@ public class MemberPointsServiceImpl extends ServiceImpl