From 477b028e8232dd9d7e0289b13f982ddd58bf6b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Mon, 10 Aug 2026 09:25:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E5=AE=9E=E7=8E=B0=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E8=AE=A2=E5=8D=95=E5=BE=AE=E4=BF=A1Native=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E5=8F=8A=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MyQrCodeUtil.generateBase64 生成二维码Base64字符串用于前端展示,无需写文件 - SubscriptionOrderPayResult新增支付二维码图片(qrImage)和订单状态(orderStatus)字段 - TenantSubscriptionOrderController新增创建订阅订单并生成微信Native支付二维码接口 - 新增查询订阅订单支付状态接口支持前端轮询支付结果 - TenantSubscriptionOrderController内部封装从订单构造价格结果方法,保证价格数据一致 - WxNativePayController支持订阅订单号支付,保留调用方传入订单号,避免随机生成 - 微信Native支付异步通知接口增加验签解密,解析交易结果后更新订阅订单支付状态与激活流程 - 异步通知中新增日志支持,完善异常捕获及幂等处理,保障回调安全和准确 - 依赖注入新增订阅订单及套餐服务,优化代码结构和职责分离 --- .../common/core/utils/MyQrCodeUtil.java | 15 +++ .../TenantSubscriptionOrderController.java | 108 +++++++++++++++++- .../controller/WxNativePayController.java | 85 +++++++++++--- .../result/SubscriptionOrderPayResult.java | 6 + 4 files changed, 200 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/gxwebsoft/common/core/utils/MyQrCodeUtil.java b/src/main/java/com/gxwebsoft/common/core/utils/MyQrCodeUtil.java index 48bdca1..f0c3e51 100644 --- a/src/main/java/com/gxwebsoft/common/core/utils/MyQrCodeUtil.java +++ b/src/main/java/com/gxwebsoft/common/core/utils/MyQrCodeUtil.java @@ -7,8 +7,10 @@ import cn.hutool.extra.qrcode.QrConfig; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URL; +import java.util.Base64; import java.util.HashMap; import static com.gxwebsoft.common.core.constants.QRCodeConstants.*; @@ -77,4 +79,17 @@ public class MyQrCodeUtil { QrCodeUtil.generate(content, config, FileUtil.file(filePath)); return qrcodeUrl; } + + /** + * 生成二维码并返回 base64(data:image/png;base64,...),用于前端直接展示,无需写文件 + * @param content 二维码内容(如微信支付 code_url) + * @return base64 图片字符串 + */ + public static String generateBase64(String content) throws IOException { + QrConfig config = new QrConfig(300, 300); + config.setMargin(1); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + QrCodeUtil.generate(content, config, "PNG", out); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(out.toByteArray()); + } } diff --git a/src/main/java/com/gxwebsoft/common/system/controller/TenantSubscriptionOrderController.java b/src/main/java/com/gxwebsoft/common/system/controller/TenantSubscriptionOrderController.java index 11ab86c..298decb 100644 --- a/src/main/java/com/gxwebsoft/common/system/controller/TenantSubscriptionOrderController.java +++ b/src/main/java/com/gxwebsoft/common/system/controller/TenantSubscriptionOrderController.java @@ -4,17 +4,25 @@ import cn.hutool.core.util.IdUtil; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.utils.MyQrCodeUtil; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; +import lombok.extern.slf4j.Slf4j; import com.gxwebsoft.common.system.entity.Order; +import com.gxwebsoft.common.system.entity.TenantPackage; +import com.gxwebsoft.common.system.entity.TenantSubscriptionOrder; import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.param.SubscriptionOrderParam; import com.gxwebsoft.common.system.result.SubscriptionOrderCreateResult; import com.gxwebsoft.common.system.result.SubscriptionOrderPayResult; import com.gxwebsoft.common.system.result.SubscriptionPriceResult; import com.gxwebsoft.common.system.service.SettingService; +import com.gxwebsoft.common.system.service.TenantPackageService; +import com.gxwebsoft.common.system.service.TenantSubscriptionOrderService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -29,6 +37,7 @@ import java.util.List; /** * 订阅订单接口 */ +@Slf4j @Tag(name = "订阅订单") @RestController @RequestMapping("/api/system/subscription-order") @@ -38,6 +47,10 @@ public class TenantSubscriptionOrderController extends BaseController { private SettingService settingService; @Resource private WxNativePayController wxNativePayController; + @Resource + private TenantSubscriptionOrderService subscriptionOrderService; + @Resource + private TenantPackageService tenantPackageService; @Operation(summary = "计算订阅订单价格") @PostMapping("/calculate-price") @@ -88,7 +101,6 @@ public class TenantSubscriptionOrderController extends BaseController { JSONObject config = loadSubscriptionConfig(); final SubscriptionPriceResult price = buildPriceResult(param, config); - price.setPayPrice(new BigDecimal("0.01")); if (price.getPayPrice() == null || price.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) { return fail("支付金额必须大于0", null); } @@ -114,6 +126,100 @@ public class TenantSubscriptionOrderController extends BaseController { return success(result); } + /** + * 创建官网开通订单并直接生成微信Native支付二维码。 + * 用于 /register 最后一步「支付开通」:建真实订单(TenantSubscriptionOrder)→ 调 Native 预下单 + * (沿用已落库的 orderNo,保证回调查询一致)→ 将 code_url 编码为 base64 二维码返回。 + */ + @Operation(summary = "创建开通订单并生成微信Native支付二维码") + @PostMapping("/create-and-pay") + public ApiResult createAndPay(@RequestBody SubscriptionOrderParam param) { + final User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录", null); + } + Integer packageId = param.getPackageId(); + if (packageId == null) { + // 默认基础版(version=10) + TenantPackage basic = tenantPackageService.getByVersion(10); + if (basic == null) { + return fail("未找到基础版套餐,请联系管理员配置", null); + } + packageId = basic.getPackageId(); + } + Integer payType = param.getPayType() != null ? param.getPayType() : 12; // 默认年付 + + // 1) 落库真实订单(状态 0 待支付) + TenantSubscriptionOrder order; + try { + order = subscriptionOrderService.createOrder(packageId, payType, loginUser.getTenantId(), loginUser.getUserId()); + } catch (Exception e) { + return fail(e.getMessage(), null); + } + + // 2) 用已落库的订单号生成支付二维码(关键:不能重新生成随机号) + Order payOrder = new Order(); + payOrder.setOrderNo(order.getOrderNo()); + payOrder.setPayPrice(order.getActualPrice()); + payOrder.setTotalPrice(order.getActualPrice()); + payOrder.setComments("官网基础版开通-" + order.getOrderNo()); + payOrder.setPayType(payType); + payOrder.setUserId(loginUser.getUserId()); + payOrder.setTenantId(loginUser.getTenantId()); + + ApiResult payResp = wxNativePayController.getCodeUrl(payOrder); + if (payResp.getCode() == null || !payResp.getCode().equals(Constants.RESULT_OK_CODE)) { + return fail(payResp.getMessage(), null); + } + + String codeUrl = String.valueOf(payResp.getData()); + String qrImage = null; + try { + qrImage = MyQrCodeUtil.generateBase64(codeUrl); + } catch (Exception e) { + log.warn("生成支付二维码图片失败,前端将改用 codeUrl 渲染: {}", e.getMessage()); + } + + SubscriptionOrderPayResult result = new SubscriptionOrderPayResult(); + result.setOrderNo(order.getOrderNo()); + result.setCodeUrl(codeUrl); + result.setQrImage(qrImage); + result.setOrderStatus(order.getOrderStatus()); + result.setPrice(buildPriceFromOrder(order)); + return success(result); + } + + /** + * 轮询订单支付状态(前端支付页轮询用)。 + * 状态由微信回调(wx-native-pay/notify)在服务端验签解密后翻转:0待支付→1已支付→2已激活。 + */ + @Operation(summary = "查询开通订单支付状态") + @GetMapping("/status/{orderNo}") + public ApiResult getOrderStatus(@PathVariable("orderNo") String orderNo) { + TenantSubscriptionOrder order = subscriptionOrderService.getByOrderNo(orderNo); + if (order == null) { + return fail("订单不存在", null); + } + SubscriptionOrderPayResult result = new SubscriptionOrderPayResult(); + result.setOrderNo(order.getOrderNo()); + result.setOrderStatus(order.getOrderStatus()); + result.setPrice(buildPriceFromOrder(order)); + return success(result); + } + + /** + * 由已落库订单构造前端所需的价格结果 + */ + private SubscriptionPriceResult buildPriceFromOrder(TenantSubscriptionOrder order) { + SubscriptionPriceResult price = new SubscriptionPriceResult(); + price.setPackageId(order.getPackageId()); + price.setOriginalPrice(order.getOriginalPrice() == null ? null : order.getOriginalPrice().setScale(2, RoundingMode.HALF_UP)); + price.setTotalPrice(order.getOriginalPrice() == null ? null : order.getOriginalPrice().setScale(2, RoundingMode.HALF_UP)); + price.setPayPrice(order.getActualPrice() == null ? null : order.getActualPrice().setScale(2, RoundingMode.HALF_UP)); + price.setDiscountAmount(order.getDiscountPrice() == null ? null : order.getDiscountPrice().setScale(2, RoundingMode.HALF_UP)); + return price; + } + /** * 从配置表读取订阅套餐配置(尝试多种key以兼容历史数据) */ diff --git a/src/main/java/com/gxwebsoft/common/system/controller/WxNativePayController.java b/src/main/java/com/gxwebsoft/common/system/controller/WxNativePayController.java index 73f4468..a905490 100644 --- a/src/main/java/com/gxwebsoft/common/system/controller/WxNativePayController.java +++ b/src/main/java/com/gxwebsoft/common/system/controller/WxNativePayController.java @@ -12,15 +12,22 @@ import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.system.entity.Order; import com.gxwebsoft.common.system.entity.Payment; +import com.gxwebsoft.common.system.entity.TenantSubscriptionOrder; import com.gxwebsoft.common.system.param.SettingParam; import com.gxwebsoft.common.system.service.OrderService; import com.gxwebsoft.common.system.service.SettingService; +import com.gxwebsoft.common.system.service.TenantSubscriptionOrderService; import com.wechat.pay.java.core.Config; import com.wechat.pay.java.core.RSAAutoCertificateConfig; +import com.wechat.pay.java.core.notification.NotificationConfig; +import com.wechat.pay.java.core.notification.NotificationParser; +import com.wechat.pay.java.core.notification.RSANotificationConfig; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction; import com.wechat.pay.java.service.payments.nativepay.NativePayService; import com.wechat.pay.java.service.payments.nativepay.model.Amount; import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest; import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse; +import cn.hutool.core.util.StrUtil; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.Operation; @@ -62,6 +69,8 @@ public class WxNativePayController extends BaseController { private CertificateService certificateService; @Resource private CertificateProperties certificateProperties; + @Resource + private TenantSubscriptionOrderService subscriptionOrderService; @Operation(summary = "生成付款码") @@ -92,7 +101,10 @@ public class WxNativePayController extends BaseController { PrepayRequest request = new PrepayRequest(); // 计算金额 order.setMoney(new BigDecimal(order.getPayPrice().toString())); - order.setOrderNo(CommonUtil.createOrderNo()); + // 若调用方已指定订单号(如订阅订单),则保留以关联回调查询;否则生成随机号 + if (order.getOrderNo() == null || order.getOrderNo().trim().isEmpty()) { + order.setOrderNo(CommonUtil.createOrderNo()); + } BigDecimal decimal = order.getMoney(); final BigDecimal multiply = decimal.multiply(new BigDecimal(100)); // 将 BigDecimal 转换为 Integer @@ -229,20 +241,67 @@ public class WxNativePayController extends BaseController { @Schema(description = "异步通知") @PostMapping("/notify/{tenantId}") public String wxNotify(@RequestHeader Map header, @RequestBody String body, @PathVariable("tenantId") Integer tenantId) { - System.out.println("异步通知*************** = "); - System.out.println("request header = " + header); - System.out.println("request body = " + body); - System.out.println("tenantId = " + tenantId); + log.info("微信Native支付异步通知, tenantId={}", tenantId); + try { + // 1) 获取支付配置用于验签/解密(优先 Native 支付配置,兜底会员支付配置,再兜底静态测试商户) + Payment payment = redisUtil.get("Payment:wxPay:".concat(tenantId.toString()), Payment.class); + if (payment == null) { + payment = redisUtil.get("Payment:1:".concat(tenantId.toString()), Payment.class); + } + String apiV3Key; + String apiclientCert; + if (payment != null) { + apiV3Key = payment.getApiKey(); + apiclientCert = config.getUploadPath().concat("/file").concat(payment.getApiclientCert()); + } else { + apiV3Key = WxNativePayController.apiV3Key; + apiclientCert = config.getUploadPath().concat("/file/wxpay/apiclient_cert.pem"); + } - // 推送微信官方支付结果(携带租户ID的POST请求) -// final String string = requestUtil.pushWxPayNotify(transaction, payment); + com.wechat.pay.java.core.notification.RequestParam requestParam = new com.wechat.pay.java.core.notification.RequestParam.Builder() + .serialNumber(header.get("wechatpay-serial")) + .nonce(header.get("wechatpay-nonce")) + .signature(header.get("wechatpay-signature")) + .timestamp(header.get("wechatpay-timestamp")) + .body(body) + .build(); - // 获取支付配置信息用于解密 - final SettingParam param = new SettingParam(); - param.setSettingKey("payment"); - param.setTenantId(tenantId); - final String uploadPath = config.getUploadPath(); // 服务器本地路径 + NotificationConfig notificationConfig = new RSANotificationConfig.Builder() + .apiV3Key(apiV3Key) + .certificatesFromPath(apiclientCert) + .build(); - return "fail"; + NotificationParser parser = new NotificationParser(notificationConfig); + + // 2) 验签 + 解密,拿到 Transaction + Transaction transaction = parser.parse(requestParam, Transaction.class); + if (transaction == null) { + log.warn("微信Native支付回调解析失败: transaction 为空"); + return "fail"; + } + if (transaction.getTradeState() != Transaction.TradeStateEnum.SUCCESS) { + log.info("微信Native支付回调:交易状态非成功, state={}", transaction.getTradeState()); + return "SUCCESS"; + } + + // 3) 按 out_trade_no(=订阅订单号)翻订单状态:0待支付→1已支付→2已激活 + String outTradeNo = transaction.getOutTradeNo(); + TenantSubscriptionOrder order = subscriptionOrderService.getByOrderNo(outTradeNo); + if (order == null) { + log.warn("微信Native支付回调:订单不存在, outTradeNo={}", outTradeNo); + return "SUCCESS"; + } + if (order.getOrderStatus() == 0) { + subscriptionOrderService.payOrder(outTradeNo, "wechat", transaction.getTransactionId()); + subscriptionOrderService.activateOrder(outTradeNo); + log.info("订阅订单支付成功并激活, orderNo={}, tenantId={}", outTradeNo, tenantId); + } else { + log.info("订阅订单已处理(幂等),orderNo={}, status={}", outTradeNo, order.getOrderStatus()); + } + return "SUCCESS"; + } catch (Exception e) { + log.error("微信Native支付回调处理失败: {}", e.getMessage(), e); + return "fail"; + } } } diff --git a/src/main/java/com/gxwebsoft/common/system/result/SubscriptionOrderPayResult.java b/src/main/java/com/gxwebsoft/common/system/result/SubscriptionOrderPayResult.java index 69de555..be00b94 100644 --- a/src/main/java/com/gxwebsoft/common/system/result/SubscriptionOrderPayResult.java +++ b/src/main/java/com/gxwebsoft/common/system/result/SubscriptionOrderPayResult.java @@ -16,6 +16,12 @@ public class SubscriptionOrderPayResult { @Schema(description = "支付二维码链接") private String codeUrl; + @Schema(description = "支付二维码图片(base64),前端可直接展示") + private String qrImage; + + @Schema(description = "订单状态 0待支付 1已支付 2已激活 3已取消") + private Integer orderStatus; + @Schema(description = "价格信息") private SubscriptionPriceResult price; }