From faf16add2cd7c99974ccdc770f740f510224ed99 Mon Sep 17 00:00:00 2001 From: "925116093-qq.com" <925116093@qq.com> Date: Fri, 4 Jul 2025 17:39:05 +0800 Subject: [PATCH] 202507041738 --- .../src/main/resources/application.yml | 13 +- ...161DA75F56B976B5F5EE5051ED60B0C288BB9.pem} | 0 .../com/ruoyi/system/config/WechatConfig.java | 57 +- .../controller/ApiWechatPayController.java | 84 + .../system/controller/AppletController.java | 386 ++++- .../system/controller/UsersController.java | 45 +- .../controller/WechatPayV3Controller.java | 405 +++++ .../controllerUtil/AppletControllerUtil.java | 695 +++++++- .../controllerUtil/WechatCertificateUtil.java | 198 +++ .../controllerUtil/WechatPayV3Util.java | 1503 +++++++++++++++-- .../service/WechatCertificateService.java | 167 ++ .../system/UserDemandQuotationMapper.xml | 11 +- .../mapper/system/UserGroupBuyingMapper.xml | 11 +- .../mapper/system/UsersInvoiceInfoMapper.xml | 6 +- ruoyi-ui/src/api/system/program.js | 12 + ruoyi-ui/src/api/system/users.js | 20 + ruoyi-ui/src/utils/moneyFormat.js | 41 + .../src/views/system/IntegralOrder/index.vue | 84 +- .../src/views/system/PayMoneyLog/index.vue | 16 +- .../src/views/system/QuoteCraft/index.vue | 19 +- .../src/views/system/QuoteMaterial/index.vue | 19 +- ruoyi-ui/src/views/system/Users/index.vue | 723 +++++++- ruoyi-ui/src/views/system/info/index.vue | 1367 +++++++++++---- ruoyi-ui/src/views/system/program/index.vue | 72 +- ruoyi-ui/src/views/system/quotation/index.vue | 20 +- ruoyi-ui/src/views/system/transfer/index.vue | 16 +- .../workerMoneyLog/WorkerMoneyLogTable.vue | 44 +- .../src/views/system/workerMoneyLog/index.vue | 38 +- 28 files changed, 5518 insertions(+), 554 deletions(-) rename ruoyi-admin/src/main/resources/wechat/{pay.pem => wechatpay_492161DA75F56B976B5F5EE5051ED60B0C288BB9.pem} (100%) create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/ApiWechatPayController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/WechatPayV3Controller.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/WechatCertificateUtil.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/WechatCertificateService.java create mode 100644 ruoyi-ui/src/utils/moneyFormat.js diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 7296036..7bca6c7 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -13,10 +13,21 @@ ruoyi: # 验证码类型 math 数字计算 char 字符验证 captchaType: math wechat: - appid: wx73d0202b3c8a6d68 + appid: wx1234567890123456 mchid: 1672571923 apikey: sssssssssssssssssssssssssssssssS certpath: wechat/apiclient_cert.p12 + apiv3-key: sssssssssssssssssssssssssssssssS + serial-no: 492161DA75F56B976B5F5EE5051ED60B0C288BB9 + private-key-path: wechat/apiclient_key.pem + cert-dir: wechat + # 微信支付平台证书序列号(临时使用商户证书序列号进行测试) + wechatpay-serial: 492161DA75F56B976B5F5EE5051ED60B0C288BB9 + + # appid: wx73d0202b3c8a6d68 + # mchid: 1672571923 + # apikey: sssssssssssssssssssssssssssssssS + # certpath: wechat/apiclient_cert.p12 # 七牛云配置 qiniu: # 是否启用七牛云上传 true-启用七牛云 false-使用本地上传 diff --git a/ruoyi-admin/src/main/resources/wechat/pay.pem b/ruoyi-admin/src/main/resources/wechat/wechatpay_492161DA75F56B976B5F5EE5051ED60B0C288BB9.pem similarity index 100% rename from ruoyi-admin/src/main/resources/wechat/pay.pem rename to ruoyi-admin/src/main/resources/wechat/wechatpay_492161DA75F56B976B5F5EE5051ED60B0C288BB9.pem diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/config/WechatConfig.java b/ruoyi-system/src/main/java/com/ruoyi/system/config/WechatConfig.java index 4514a52..c5e2b19 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/config/WechatConfig.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/config/WechatConfig.java @@ -7,10 +7,19 @@ import org.springframework.stereotype.Component; @ConfigurationProperties(prefix = "wechat") public class WechatConfig { - private String appid; - private String mchid; - private String apikey; - private String certpath; + private String appid; + private String mchid; + private String apikey; + private String certpath; + + // 微信支付V3新增配置 + private String apiv3Key; + private String privateKeyPath; + private String serialNo; + private String certDir; + + // 微信支付平台证书序列号(新增) + private String wechatpaySerial; public String getAppid() { return appid; @@ -43,4 +52,44 @@ public class WechatConfig { public void setCertpath(String certpath) { this.certpath = certpath; } + + public String getApiv3Key() { + return apiv3Key; + } + + public void setApiv3Key(String apiv3Key) { + this.apiv3Key = apiv3Key; + } + + public String getPrivateKeyPath() { + return privateKeyPath; + } + + public void setPrivateKeyPath(String privateKeyPath) { + this.privateKeyPath = privateKeyPath; + } + + public String getSerialNo() { + return serialNo; + } + + public void setSerialNo(String serialNo) { + this.serialNo = serialNo; + } + + public String getCertDir() { + return certDir; + } + + public void setCertDir(String certDir) { + this.certDir = certDir; + } + + public String getWechatpaySerial() { + return wechatpaySerial; + } + + public void setWechatpaySerial(String wechatpaySerial) { + this.wechatpaySerial = wechatpaySerial; + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ApiWechatPayController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ApiWechatPayController.java new file mode 100644 index 0000000..5ccad06 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ApiWechatPayController.java @@ -0,0 +1,84 @@ +package com.ruoyi.system.controller; + +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.system.ControllerUtil.WechatPayV3Util; +import com.ruoyi.system.service.WechatCertificateService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 微信支付API控制器(无需认证) + * + * @author Mr. Zhang Pan + * @version 1.0 + * @date 2025-01-17 + */ +@RestController +@RequestMapping("/api/wechat/pay/v3") +public class ApiWechatPayController extends BaseController { + + private static final Logger log = LoggerFactory.getLogger(ApiWechatPayController.class); + + @Autowired + private WechatPayV3Util wechatPayV3Util; + + @Autowired + private WechatCertificateService wechatCertificateService; + + /** + * 快速提现(简化参数) + */ + @PostMapping("/quick-withdraw") + @Log(title = "微信支付V3快速提现", businessType = BusinessType.OTHER) + public AjaxResult quickWithdraw(@RequestParam String openid, + @RequestParam BigDecimal amount, + @RequestParam(required = false) String desc) { + try { + log.info("⚡ API快速提现请求 - 用户: {}, 金额: {}元", openid.substring(0, 6) + "****", amount); + + Map result = wechatPayV3Util.quickWithdraw(openid, amount, desc); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("快速提现成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ API快速提现异常: {}", e.getMessage(), e); + return AjaxResult.error("快速提现失败: " + e.getMessage()); + } + } + + /** + * 测试私钥加载 + */ + @GetMapping("/test-key") + @Log(title = "微信支付V3测试私钥", businessType = BusinessType.OTHER) + public AjaxResult testKey() { + try { + log.info("🔐 API测试私钥加载"); + + // 通过反射调用私有方法测试私钥加载 + java.lang.reflect.Method method = WechatPayV3Util.class.getDeclaredMethod("getPrivateKey"); + method.setAccessible(true); + Object privateKey = method.invoke(wechatPayV3Util); + + if (privateKey != null) { + return AjaxResult.success("私钥加载成功", "私钥算法: " + privateKey.getClass().getSimpleName()); + } else { + return AjaxResult.error("私钥加载失败"); + } + } catch (Exception e) { + log.error("❌ API测试私钥异常: {}", e.getMessage(), e); + return AjaxResult.error("测试私钥失败: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/AppletController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/AppletController.java index ee2d99d..6b7b53b 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/AppletController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/AppletController.java @@ -2,9 +2,11 @@ package com.ruoyi.system.controller; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.ControllerUtil.*; import com.ruoyi.system.domain.*; @@ -35,18 +37,26 @@ import com.ruoyi.system.domain.QuoteMaterial; /** * 小程序控制器 - *

- * 提供小程序端所需的API接口 - * 主要功能: - * 1. 服务分类管理 - * 2. 服务商品列表和详情 - * 3. 广告图片获取 - * 4. 配置信息查询 - * 5. 用户信息验证 + * + * 提供小程序端所需的API接口,包括: + * 1. 服务分类管理 - 获取服务分类列表 + * 2. 服务商品管理 - 商品列表、详情、搜索 + * 3. 广告图片管理 - 获取各类型广告图片 + * 4. 配置信息管理 - 系统配置查询 + * 5. 用户信息管理 - 登录验证、信息修改 + * 6. 地址管理 - 收货地址增删改查 + * 7. 订单管理 - 服务订单、商品订单 + * 8. 购物车管理 - 商品购物车操作 + * 9. 支付相关 - 微信支付、订单支付 + * 10. 师傅端功能 - 接单、报价、服务流程 + * 11. 会员相关 - 充值、消费记录 + * 12. 积分商城 - 积分商品兑换 + * 13. 优惠券管理 - 用户优惠券 + * 14. 售后服务 - 返修申请 * * @author Mr. Zhang Pan - * @version 1.0 - * @date 2025-05-26 + * @version 2.0 + * @date 2025-01-17 */ @RestController public class AppletController extends BaseController { @@ -134,34 +144,91 @@ public class AppletController extends BaseController { @Autowired private IUserSecondaryCardService userSecondaryCardService; + + @Autowired + private IUserDemandQuotationService userDemandQuotationService; + + @Autowired + private IUserBenefitPointsService userBenefitPointsService; + + + + @Autowired + private WechatPayV3Util wechatPayV3Util; + @PostMapping("/api/quick-refund") + public AjaxResult quickRefund( + @RequestParam String orderNo, + @RequestParam BigDecimal refundFee, + @RequestParam(required = false) String reason) { + + try { + Map result = wechatPayV3Util.quickRefund(orderNo, refundFee, reason); + + if ((Boolean) result.get("success")) { + return success(result.get("data")); + } else { + return error((String) result.get("message")); + } + } catch (Exception e) { + return error("快速退款异常:" + e.getMessage()); + } + } + + + /** + * 用户提现 + * + * @param openid 用户openid + * @param amount 金额(分) + * @param desc 提现描述 + * @param userName 真实姓名(可选,用于实名校验) + * @return 提现结果 + */ + @PostMapping("/api/withdraw") + public AjaxResult withdraw(@RequestParam String openid, + @RequestParam int amount, + @RequestParam(required = false) String desc, + @RequestParam(required = false) String userName) { + try { + + + Map result = wechatPayV3Util.withdraw(openid, amount, desc, userName); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("提现申请成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + + return AjaxResult.error("提现申请失败: " + e.getMessage()); + } + } + /** * 获取服务分类列表 - * - * @param request HTTP请求对象 - * @return 分类列表数据 - *

- * 接口说明: + * + * 功能说明: * - 获取状态为启用的服务分类 * - 自动添加图片CDN前缀 - * - 支持用户登录状态验证 + * - 支持用户登录状态验证(可选) + * + * @param request HTTP请求对象 + * @return 分类列表数据 */ @GetMapping(value = "/api/service/cate") - public AjaxResult getInfo(HttpServletRequest request) { + public AjaxResult getServiceCategories(HttpServletRequest request) { try { // 验证用户登录状态(可选) - Map userData = AppletControllerUtil.getUserData(request.getHeader("token"), usersService); + AppletControllerUtil.getUserData(request.getHeader("token"), usersService); - // 构建查询条件:状态启用且类型为服务 + // 查询启用状态的服务分类 ServiceCate serviceCateQuery = new ServiceCate(); - serviceCateQuery.setStatus(1L); // 启用状态 - // serviceCateQuery.setType(1L); // 服务类型 - // 查询分类列表 + serviceCateQuery.setStatus(1L); List categoryList = serviceCateService.selectServiceCateList(serviceCateQuery); // 为每个分类添加CDN前缀 - for (ServiceCate category : categoryList) { - category.setIcon(AppletControllerUtil.buildImageUrl(category.getIcon())); - } + AppletControllerUtil.addImageCdnPrefixForCategories(categoryList); return AppletControllerUtil.appletSuccess(categoryList); } catch (Exception e) { @@ -172,43 +239,33 @@ public class AppletController extends BaseController { /** * 获取系统配置信息 - * - * @param name 配置项名称 - * @param request HTTP请求对象 - * @return 配置信息数据 - *

- * 接口说明: + * + * 功能说明: * - 根据配置名称获取对应的配置值 * - 配置值以JSON格式返回 * - 支持动态配置管理 + * + * @param name 配置项名称 + * @param request HTTP请求对象 + * @return 配置信息数据 */ @GetMapping(value = "/api/public/config/{name}") - public AjaxResult config(@PathVariable("name") String name, HttpServletRequest request) { + public AjaxResult getConfig(@PathVariable("name") String name, HttpServletRequest request) { try { // 参数验证 - if (name == null || name.trim().isEmpty()) { + if (StringUtils.isEmpty(name)) { return AppletControllerUtil.appletWarning("配置名称不能为空"); } - // 构建查询条件 - SiteConfig configQuery = new SiteConfig(); - configQuery.setName(name.trim()); - - // 查询配置列表 - List configList = siteConfigService.selectSiteConfigList(configQuery); - - if (!configList.isEmpty()) { - // 解析配置值为JSON对象 - String configValue = configList.get(0).getValue(); - if (configValue != null && !configValue.trim().isEmpty()) { - JSONObject jsonObject = JSONObject.parseObject(configValue); - return AppletControllerUtil.appletSuccess(jsonObject); - } else { - return AppletControllerUtil.appletWarning("配置值为空"); - } - } else { + // 查询配置信息 + SiteConfig config = AppletControllerUtil.getSiteConfig(name, siteConfigService); + if (config == null) { return AppletControllerUtil.appletWarning("未找到指定的配置项:" + name); } + + // 解析配置值为JSON对象 + JSONObject configJson = AppletControllerUtil.parseConfigValue(config.getValue()); + return AppletControllerUtil.appletSuccess(configJson); } catch (Exception e) { return AppletControllerUtil.appletError("获取配置信息失败:" + e.getMessage()); } @@ -6642,33 +6699,82 @@ public class AppletController extends BaseController { } // 3. 获取新参数并校验必填 - Long orderId = params.get("orderid") != null ? Long.valueOf(params.get("orderid").toString()) : null; + Long oid = params.get("oid") != null ? Long.valueOf(params.get("oid").toString()) : null; Integer dataType = params.get("datatype") != null ? Integer.valueOf(params.get("datatype").toString()) : null; - if (orderId == null || dataType == null) { + if (oid == null || dataType == null) { return AppletControllerUtil.appletWarning("orderid和datatype不能为空"); } + + + BigDecimal invoiceMoney = BigDecimal.ZERO; String invoiceText = ""; + String orderid = ""; if (dataType == 1) { - GoodsOrder order = goodsOrderService.selectGoodsOrderById(orderId); + // 商品订单:xxx商品*数量=xxx元 + GoodsOrder order = goodsOrderService.selectGoodsOrderById(oid); if (order != null) { + orderid=order.getOrderId(); invoiceMoney = order.getTotalPrice(); - invoiceText = "商品订单-" + order.getOrderId(); + // 获取商品名称 + String productName = order.getProductName(); + if (productName == null || productName.trim().isEmpty()) { + // 如果订单中没有商品名称,尝试从商品表获取 + ServiceGoods serviceGoods = serviceGoodsService.selectServiceGoodsById(order.getProductId()); + if (serviceGoods != null) { + productName = serviceGoods.getTitle(); + } else { + productName = "商品"; + } + } + Long quantity = order.getNum() != null ? order.getNum() : 1L; + invoiceText = productName + "*" + quantity + "=" + invoiceMoney + "元"; } } else if (dataType == 2) { - Order order = orderService.selectOrderById(orderId); + // 服务订单:xxx服务多少元 + Order order = orderService.selectOrderById(oid); + if (order != null) { + orderid=order.getOrderId() ; invoiceMoney = order.getTotalPrice(); - invoiceText = "服务订单-" + order.getOrderId(); + // 获取服务名称 + String serviceName = order.getProductName(); + if (serviceName == null || serviceName.trim().isEmpty()) { + // 如果订单中没有服务名称,尝试从服务表获取 + ServiceGoods serviceGoods = serviceGoodsService.selectServiceGoodsById(order.getProductId()); + if (serviceGoods != null) { + serviceName = serviceGoods.getTitle(); + } else { + serviceName = "服务"; + } + } + invoiceText = serviceName + "服务" + invoiceMoney + "元"; } } else if (dataType == 3) { - UserMemberRechargeLog recharge = userMemberRechargeLogService.selectUserMemberRechargeLogById(orderId.intValue()); + // 充值订单:xxx充值金额xxx元 + UserMemberRechargeLog recharge = userMemberRechargeLogService.selectUserMemberRechargeLogById(oid.intValue()); if (recharge != null) { + orderid=recharge.getOrderid(); invoiceMoney = recharge.getInmoney(); - invoiceText = "充值订单-" + recharge.getInmoney(); + String rechargeName = recharge.getReamk(); + if (rechargeName == null || rechargeName.trim().isEmpty()) { + rechargeName = "会员"; + } + invoiceText = rechargeName + "充值金额" + invoiceMoney + "元"; } } - // 4. 构建发票信息对象 + + + // 4. 检查是否已经开过票(防止重复开票) + UsersInvoiceInfo checkQuery = new UsersInvoiceInfo(); + checkQuery.setOrderid(orderid); + List existingInvoices = usersInvoiceInfoService.selectUsersInvoiceInfoList(checkQuery); + if (existingInvoices != null && !existingInvoices.isEmpty()) { + return AppletControllerUtil.appletWarning("该订单已经开过发票,不能重复开票"); + } + + + // 5. 构建发票信息对象 UsersInvoiceInfo info = new UsersInvoiceInfo(); info.setUid(user.getId().intValue()); info.setInvoiceTitle((String) params.get("invoiceTitle")); @@ -6681,11 +6787,12 @@ public class AppletController extends BaseController { info.setWechat((String) params.get("wechat")); info.setType(Integer.parseInt(params.get("type").toString())); info.setCategory(Integer.parseInt(params.get("category").toString())); - info.setOrderid(orderId.toString()); + info.setOrderid(orderid); info.setInvoicemoney(invoiceMoney); info.setStatus(1); info.setInvoicetext(invoiceText); - // 5. 保存或更新发票信息 + + // 6. 保存或更新发票信息 Integer id = params.get("id") != null ? Integer.parseInt(params.get("id").toString()) : null; if (id != null) { info.setId(id); @@ -6823,7 +6930,7 @@ public AjaxResult serviceOrderComment(@RequestBody Map params, H comment.setNumType(Long.valueOf(numType)); comment.setUid(user.getId()); comment.setWorkerId(order.getWorkerId()); - + // 处理图片 if (params.containsKey("images") && params.get("images") != null) { String images = JSON.toJSONString(params.get("images")); @@ -6846,13 +6953,13 @@ public AjaxResult serviceOrderComment(@RequestBody Map params, H orderLog.setOrderId(orderId); orderLog.setTitle("订单评价"); orderLog.setType(BigDecimal.valueOf(8)); - + Map logContent = new HashMap<>(); logContent.put("text", content); logContent.put("image", params.get("images")); logContent.put("num", num); orderLog.setContent(JSON.toJSONString(logContent)); - + orderLogService.insertOrderLog(orderLog); // 3. 更新订单状态 @@ -6927,25 +7034,25 @@ public AjaxResult checkUserDefault(HttpServletRequest request) { // 1. 获取并验证分页参数 int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1; int limit = params.get("limit") != null ? Integer.parseInt(params.get("limit").toString()) : 15; - + Map pageValidation = PageUtil.validatePageParams(page, limit); if (!(Boolean) pageValidation.get("valid")) { return AppletControllerUtil.appletWarning((String) pageValidation.get("message")); } - + // 2. 获取type参数 Long type = params.get("type") != null ? Long.parseLong(params.get("type").toString()) : null; - + // 3. 创建查询对象 UserSecondaryCard queryParams = new UserSecondaryCard(); queryParams.setStatus(1L); // 只查询状态为1的数据 if (type != null) { queryParams.setType(type); } - + // 4. 设置分页参数 PageHelper.startPage(page, limit); - + // 5. 执行查询 List list = userSecondaryCardService.selectUserSecondaryCardList(queryParams); @@ -6957,10 +7064,10 @@ public AjaxResult checkUserDefault(HttpServletRequest request) { // 6. 获取分页信息并构建响应 TableDataInfo tableDataInfo = getDataTable(list); - + // 7. 构建符合要求的分页响应格式 Map pageData = PageUtil.buildPageResponse(tableDataInfo, page, limit); - + return AppletControllerUtil.appletSuccess(pageData); } catch (Exception e) { @@ -6969,6 +7076,81 @@ public AjaxResult checkUserDefault(HttpServletRequest request) { } } + /** + * 师傅报价接口 + * + * @param params 包含orderid和money的参数 + * @param request HTTP请求对象 + * @return 报价结果 + */ + @PostMapping("/api/worker/quote/price") + public AjaxResult workerQuotePrice(@RequestBody Map params, HttpServletRequest request) { + try { + // 1. 验证用户登录状态 + String token = request.getHeader("token"); + Map userValidation = AppletLoginUtil.validateUserToken(token, usersService); + if (!(Boolean) userValidation.get("valid")) { + return AppletControllerUtil.appletWarning("用户未登录或token无效"); + } + + // 2. 获取用户信息 + Users user = (Users) userValidation.get("user"); + if (user == null) { + return AppletControllerUtil.appletWarning("用户信息获取失败"); + } + + // 3. 验证必要参数 + if (params == null || params.get("orderid") == null || params.get("money") == null) { + return AppletControllerUtil.appletWarning("订单ID和报价金额不能为空"); + } + + // 4. 获取参数 + String orderId = params.get("orderid").toString(); + BigDecimal quoteMoney = new BigDecimal(params.get("money").toString()); + + // 5. 查询订单是否存在 + Order order = orderService.selectOrderByOrderId(orderId); + if (order == null) { + return AppletControllerUtil.appletWarning("订单不存在"); + } + + // 6. 查询用户是否已对该订单报过价 + UserDemandQuotation queryParams = new UserDemandQuotation(); + queryParams.setWorkerid(user.getId()); + queryParams.setOrderid(orderId); + List existingQuotes = userDemandQuotationService.selectUserDemandQuotationList(queryParams); + + // 7. 处理报价 + UserDemandQuotation quoteRecord; + if (existingQuotes != null && !existingQuotes.isEmpty()) { + // 已有报价,更新 + quoteRecord = existingQuotes.getFirst(); + quoteRecord.setMoney(quoteMoney); + quoteRecord.setQuotationTime(new Date()); + quoteRecord.setUpdateTime(new Date()); + userDemandQuotationService.updateUserDemandQuotation(quoteRecord); + } else { + // 新增报价 + quoteRecord = new UserDemandQuotation(); + quoteRecord.setWorkerid(user.getId()); + quoteRecord.setOrderid(orderId); + quoteRecord.setMoney(quoteMoney); + quoteRecord.setQuotationTime(new Date()); + quoteRecord.setStatus(1L); // 设置状态为有效 + quoteRecord.setWorkername(user.getName()); + quoteRecord.setWorkerimage(user.getAvatar()); + quoteRecord.setCreateTime(new Date()); + userDemandQuotationService.insertUserDemandQuotation(quoteRecord); + } + + return AppletControllerUtil.appletSuccess("报价成功"); + + } catch (Exception e) { + System.err.println("师傅报价异常:" + e.getMessage()); + return AppletControllerUtil.appletError("报价失败:" + e.getMessage()); + } + } + @PostMapping("api/group/once_pay") public AjaxResult apigroupOncePay(@RequestBody Map params, HttpServletRequest request) { // 1. 验证用户登录状态 @@ -7025,6 +7207,68 @@ public AjaxResult checkUserDefault(HttpServletRequest request) { } return AppletControllerUtil.appletWarning("支付失败"); } + /** + * 小程序端:查询用户服务金/消费金日志(分页,PageUtil.buildPageResponse) + * type: 1=服务金,2=消费金 + * limit: 每页条数,page: 页码 + */ + @GetMapping("/api/user/benefit/log") + public AjaxResult getUserBenefitLog( + @RequestParam(value = "type") Integer type, + @RequestParam(value = "limit", defaultValue = "10") int limit, + @RequestParam(value = "page", defaultValue = "1") int page, + HttpServletRequest request) { + try { + // 1. 验证分页参数 + Map pageValidation = PageUtil.validatePageParams(page, limit); + if (!(Boolean) pageValidation.get("valid")) { + return AppletControllerUtil.appletWarning((String) pageValidation.get("message")); + } + // 2. 验证用户登录状态 + String token = request.getHeader("token"); + Map userValidation = AppletLoginUtil.validateUserToken(token, usersService); + if (!(Boolean) userValidation.get("valid")) { + return AppletControllerUtil.appletUnauthorized(); + } + + // 3. 获取用户信息 + Users user = (Users) userValidation.get("user"); + if (user == null) { + return AppletControllerUtil.appletWarning("用户信息获取失败"); + } + + // 4. 设置分页参数 + PageHelper.startPage(page, limit); + + // 5. 查询服务金/消费金日志 + UserBenefitPoints query = new UserBenefitPoints(); + query.setUid(user.getId()); + query.setType(Long.valueOf(type)); + List logList = userBenefitPointsService.selectUserBenefitPointsList(query); + + // 6. 获取分页信息并构建响应 + TableDataInfo tableDataInfo = getDataTable(logList); + Map pageData = PageUtil.buildPageResponse(tableDataInfo, page, limit); + + return AppletControllerUtil.appletSuccess(pageData); + } catch (Exception e) { + System.err.println("查询用户服务金/消费金日志异常:" + e.getMessage()); + return AppletControllerUtil.appletError("查询服务金/消费金日志失败:" + e.getMessage()); + } + } + + + /** + * 小程序用户余额日志查询接口 + */ + @PostMapping("/api/user/balance/logs") + public AjaxResult getBalanceLogList(HttpServletRequest request, @RequestBody Map params) { + // 从请求头获取token + String token = request.getHeader("token"); + + // 调用工具类方法 + return AppletControllerUtil.getUserBalanceLogList(params, token, usersService, userMemnerConsumptionLogService); + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/UsersController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/UsersController.java index b0b82ea..4f9184c 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/UsersController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/UsersController.java @@ -4,16 +4,11 @@ import java.util.List; import javax.servlet.http.HttpServletResponse; import com.ruoyi.system.domain.QuoteType; +import com.ruoyi.system.domain.UserBenefitPoints; +import com.ruoyi.system.service.IUserBenefitPointsService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; @@ -22,6 +17,8 @@ import com.ruoyi.system.domain.Users; import com.ruoyi.system.service.IUsersService; import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.system.domain.UserMemnerConsumptionLog; +import com.ruoyi.system.service.IUserMemnerConsumptionLogService; /** * 【请填写功能名称】Controller @@ -36,6 +33,11 @@ public class UsersController extends BaseController @Autowired private IUsersService usersService; + @Autowired + private IUserBenefitPointsService userBenefitPointsService; + @Autowired + private IUserMemnerConsumptionLogService userMemnerConsumptionLogService; + /** * 查询【请填写功能名称】列表 */ @@ -141,4 +143,31 @@ public class UsersController extends BaseController { return toAjax(usersService.deleteUsersByIds(ids)); } + + /** + * 获取用户消费金或服务金日志记录 + */ + @PreAuthorize("@ss.hasPermi('system:users:query')") + @GetMapping("/benefitLogs/{userId}/{type}") + public TableDataInfo getBenefitLogs(@PathVariable("userId") Long userId, @PathVariable("type") Integer type) + { + startPage(); + UserBenefitPoints userBenefitPoints = new UserBenefitPoints(); + userBenefitPoints.setUid(userId); + userBenefitPoints.setType(Long.valueOf(type)); + return getDataTable(userBenefitPointsService.selectUserBenefitPointsList(userBenefitPoints)); + } + + /** + * 获取用户余额变动日志记录 + */ + @PreAuthorize("@ss.hasPermi('system:users:query')") + @GetMapping("/balanceLogs/{userId}") + public TableDataInfo getBalanceLogs(@PathVariable("userId") Long userId) + { + startPage(); + UserMemnerConsumptionLog query = new UserMemnerConsumptionLog(); + query.setUid(Math.toIntExact(userId)); // + return getDataTable(userMemnerConsumptionLogService.selectUserMemnerConsumptionLogList(query)); + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/WechatPayV3Controller.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/WechatPayV3Controller.java new file mode 100644 index 0000000..dee79e9 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/WechatPayV3Controller.java @@ -0,0 +1,405 @@ +package com.ruoyi.system.controller; + +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.system.ControllerUtil.WechatPayV3Util; +import com.ruoyi.system.service.WechatCertificateService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 微信支付V3控制器 + * + * 提供微信支付V3 API的REST接口: + * 1. 申请退款 + * 2. 查询退款 + * 3. 用户提现 + * 4. 查询转账 + * + * @author Mr. Zhang Pan + * @version 1.0 + * @date 2025-01-17 + */ +@RestController +@RequestMapping("/wechat/pay/v3") +public class WechatPayV3Controller extends BaseController { + + private static final Logger log = LoggerFactory.getLogger(WechatPayV3Controller.class); + + @Autowired + private WechatPayV3Util wechatPayV3Util; + + @Autowired + private WechatCertificateService wechatCertificateService; + + /** + * 申请退款 + * + * @param orderNo 商户订单号 + * @param refundNo 退款单号 + * @param totalFee 订单总金额(分) + * @param refundFee 退款金额(分) + * @param reason 退款原因 + * @param notifyUrl 退款回调通知地址(可选) + * @return 退款结果 + */ + @PostMapping("/refund") + @Log(title = "微信支付V3退款", businessType = BusinessType.OTHER) + public AjaxResult refund(@RequestParam String orderNo, + @RequestParam String refundNo, + @RequestParam int totalFee, + @RequestParam int refundFee, + @RequestParam(required = false) String reason, + @RequestParam(required = false) String notifyUrl) { + try { + log.info("🔄 收到退款请求 - 订单号: {}, 退款单号: {}, 退款金额: {}分", orderNo, refundNo, refundFee); + + Map result = wechatPayV3Util.refund(orderNo, refundNo, totalFee, refundFee, reason, notifyUrl); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("退款申请成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 退款申请异常: {}", e.getMessage(), e); + return AjaxResult.error("退款申请失败: " + e.getMessage()); + } + } + + /** + * 快速退款(简化参数) + * + * @param orderNo 商户订单号 + * @param refundFee 退款金额(元) + * @param reason 退款原因 + * @return 退款结果 + */ + @PostMapping("/quick-refund") + @Log(title = "微信支付V3快速退款", businessType = BusinessType.OTHER) + public AjaxResult quickRefund(@RequestParam String orderNo, + @RequestParam BigDecimal refundFee, + @RequestParam(required = false) String reason) { + try { + log.info("⚡ 收到快速退款请求 - 订单号: {}, 退款金额: {}元", orderNo, refundFee); + + Map result = wechatPayV3Util.quickRefund(orderNo, refundFee, reason); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("快速退款成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 快速退款异常: {}", e.getMessage(), e); + return AjaxResult.error("快速退款失败: " + e.getMessage()); + } + } + + /** + * 查询退款 + * + * @param refundNo 退款单号 + * @return 退款查询结果 + */ + @GetMapping("/refund/{refundNo}") + @Log(title = "微信支付V3查询退款", businessType = BusinessType.OTHER) + public AjaxResult queryRefund(@PathVariable String refundNo) { + try { + log.info("🔍 收到退款查询请求 - 退款单号: {}", refundNo); + + Map result = wechatPayV3Util.queryRefund(refundNo); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("查询退款成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 查询退款异常: {}", e.getMessage(), e); + return AjaxResult.error("查询退款失败: " + e.getMessage()); + } + } + + /** + * 用户提现 + * + * @param openid 用户openid + * @param amount 金额(分) + * @param desc 提现描述 + * @param userName 真实姓名(可选,用于实名校验) + * @return 提现结果 + */ + @PostMapping("/withdraw") + @Log(title = "微信支付V3用户提现", businessType = BusinessType.OTHER) + public AjaxResult withdraw(@RequestParam String openid, + @RequestParam int amount, + @RequestParam(required = false) String desc, + @RequestParam(required = false) String userName) { + try { + log.info("💰 收到提现请求 - 用户: {}, 金额: {}分", openid.substring(0, 6) + "****", amount); + + Map result = wechatPayV3Util.withdraw(openid, amount, desc, userName); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("提现申请成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 提现申请异常: {}", e.getMessage(), e); + return AjaxResult.error("提现申请失败: " + e.getMessage()); + } + } + + /** + * 快速提现(简化参数) + * + * @param openid 用户openid + * @param amount 金额(元) + * @param desc 提现描述 + * @return 提现结果 + */ + @Anonymous + @PostMapping("/quick-withdraw") + @Log(title = "微信支付V3快速提现", businessType = BusinessType.OTHER) + public AjaxResult quickWithdraw(@RequestParam String openid, + @RequestParam BigDecimal amount, + @RequestParam(required = false) String desc) { + try { + log.info("⚡ 收到快速提现请求 - 用户: {}, 金额: {}元", openid.substring(0, 6) + "****", amount); + + Map result = wechatPayV3Util.quickWithdraw(openid, amount, desc); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("快速提现成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 快速提现异常: {}", e.getMessage(), e); + return AjaxResult.error("快速提现失败: " + e.getMessage()); + } + } + + /** + * 简化提现(不加密敏感字段,用于测试) + * + * @param openid 用户openid + * @param amount 金额(分) + * @param desc 提现描述 + * @return 提现结果 + */ + @Anonymous + @PostMapping("/withdraw-simple") + @Log(title = "微信支付V3简化提现", businessType = BusinessType.OTHER) + public AjaxResult withdrawSimple(@RequestParam String openid, + @RequestParam int amount, + @RequestParam(required = false) String desc) { + try { + log.info("🧪 收到简化提现请求 - 用户: {}, 金额: {}分", openid.substring(0, 6) + "****", amount); + + Map result = wechatPayV3Util.withdrawSimple(openid, amount, desc); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("简化提现申请成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 简化提现申请异常: {}", e.getMessage(), e); + return AjaxResult.error("简化提现申请失败: " + e.getMessage()); + } + } + + /** + * 查询转账批次 + * + * @param outBatchNo 商户批次单号 + * @param needDetail 是否需要转账明细(默认false) + * @return 转账查询结果 + */ + @GetMapping("/transfer/{outBatchNo}") + @Log(title = "微信支付V3查询转账", businessType = BusinessType.OTHER) + public AjaxResult queryTransfer(@PathVariable String outBatchNo, + @RequestParam(defaultValue = "false") boolean needDetail) { + try { + log.info("🔍 收到转账查询请求 - 批次号: {}, 需要明细: {}", outBatchNo, needDetail); + + Map result = wechatPayV3Util.queryTransfer(outBatchNo, needDetail); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("查询转账成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 查询转账异常: {}", e.getMessage(), e); + return AjaxResult.error("查询转账失败: " + e.getMessage()); + } + } + + /** + * 测试私钥加载 + * + * @return 测试结果 + */ + @Anonymous + @GetMapping("/test-key") + @Log(title = "微信支付V3测试私钥", businessType = BusinessType.OTHER) + public AjaxResult testKey() { + try { + log.info("🔐 开始测试私钥加载"); + + // 通过反射调用私有方法测试私钥加载 + java.lang.reflect.Method method = WechatPayV3Util.class.getDeclaredMethod("getPrivateKey"); + method.setAccessible(true); + Object privateKey = method.invoke(wechatPayV3Util); + + if (privateKey != null) { + return AjaxResult.success("私钥加载成功", "私钥算法: " + privateKey.getClass().getSimpleName()); + } else { + return AjaxResult.error("私钥加载失败"); + } + } catch (Exception e) { + log.error("❌ 测试私钥异常: {}", e.getMessage(), e); + return AjaxResult.error("测试私钥失败: " + e.getMessage()); + } + } + + /** + * 金额转换工具 + * + * @param yuan 金额(元) + * @return 转换结果 + */ + @GetMapping("/convert/yuan-to-fen") + public AjaxResult yuanToFen(@RequestParam BigDecimal yuan) { + try { + int fen = WechatPayV3Util.yuanToFen(yuan); + return AjaxResult.success("转换成功", Map.of("yuan", yuan, "fen", fen)); + } catch (Exception e) { + return AjaxResult.error("转换失败: " + e.getMessage()); + } + } + + /** + * 金额转换工具 + * + * @param fen 金额(分) + * @return 转换结果 + */ + @GetMapping("/convert/fen-to-yuan") + public AjaxResult fenToYuan(@RequestParam int fen) { + try { + BigDecimal yuan = WechatPayV3Util.fenToYuan(fen); + return AjaxResult.success("转换成功", Map.of("fen", fen, "yuan", yuan)); + } catch (Exception e) { + return AjaxResult.error("转换失败: " + e.getMessage()); + } + } + + /** + * 生成订单号 + * + * @param prefix 前缀 + * @return 订单号 + */ + @GetMapping("/generate-order-no") + public AjaxResult generateOrderNo(@RequestParam(defaultValue = "ORDER_") String prefix) { + try { + String orderNo = WechatPayV3Util.generateOrderNo(prefix); + return AjaxResult.success("生成成功", Map.of("orderNo", orderNo)); + } catch (Exception e) { + return AjaxResult.error("生成失败: " + e.getMessage()); + } + } + + /** + * 获取微信支付平台证书 + * + * @return 平台证书信息 + */ + @Anonymous + @GetMapping("/certificates") + @Log(title = "获取微信支付平台证书", businessType = BusinessType.OTHER) + public AjaxResult getCertificates() { + try { + log.info("📜 收到获取平台证书请求"); + + Map result = wechatPayV3Util.getCertificates(); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("获取平台证书成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 获取平台证书异常: {}", e.getMessage(), e); + return AjaxResult.error("获取平台证书失败: " + e.getMessage()); + } + } + + /** + * 无序列号提现测试 + * + * @param openid 用户openid + * @param amount 金额(分) + * @param desc 提现描述 + * @return 提现结果 + */ + @Anonymous + @PostMapping("/withdraw-without-serial") + @Log(title = "微信支付V3无序列号提现测试", businessType = BusinessType.OTHER) + public AjaxResult withdrawWithoutSerial(@RequestParam String openid, + @RequestParam int amount, + @RequestParam(required = false) String desc) { + try { + log.info("🧪 收到无序列号提现测试请求 - 用户: {}, 金额: {}分", openid.substring(0, 6) + "****", amount); + + Map result = wechatPayV3Util.withdrawWithoutSerial(openid, amount, desc); + + if ((Boolean) result.get("success")) { + return AjaxResult.success("无序列号提现测试成功", result.get("data")); + } else { + return AjaxResult.error(result.get("message").toString()); + } + } catch (Exception e) { + log.error("❌ 无序列号提现测试异常: {}", e.getMessage(), e); + return AjaxResult.error("无序列号提现测试失败: " + e.getMessage()); + } + } + + /** + * 自动更新微信支付平台证书序列号 + * + * @return 更新结果 + */ + @Anonymous + @PostMapping("/auto-update-certificate-serial") + @Log(title = "自动更新微信支付平台证书序列号", businessType = BusinessType.OTHER) + public AjaxResult autoUpdateCertificateSerial() { + try { + log.info("🔄 收到自动更新证书序列号请求"); + + boolean updated = wechatCertificateService.autoUpdatePlatformCertificateSerial(); + + if (updated) { + return AjaxResult.success("微信支付平台证书序列号更新成功,请重启应用使配置生效"); + } else { + return AjaxResult.error("更新失败,请查看日志了解详情"); + } + } catch (Exception e) { + log.error("❌ 自动更新证书序列号异常: {}", e.getMessage(), e); + return AjaxResult.error("自动更新失败: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/AppletControllerUtil.java b/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/AppletControllerUtil.java index 12abff4..d64154b 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/AppletControllerUtil.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/AppletControllerUtil.java @@ -6,6 +6,7 @@ import com.alibaba.fastjson2.JSONObject; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.utils.spring.SpringUtils; import com.ruoyi.system.domain.*; +import com.ruoyi.system.domain.AppleDoMain.OrderApple; import com.ruoyi.system.service.*; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; @@ -774,6 +775,7 @@ public class AppletControllerUtil { // 查询该分类下的商品 ServiceGoods serviceGoodsQuery = new ServiceGoods(); serviceGoodsQuery.setCateId(category.getId()); + serviceGoodsQuery.setStatus("1"); // 如果有关键词,添加搜索条件 if (keywords != null && !keywords.trim().isEmpty()) { @@ -1104,7 +1106,7 @@ public class AppletControllerUtil { } // 4. 检查用户状态 - if (!"1".equals(user.getStatus())) { + if (user.getStatus()!=1) { result.put("valid", false); result.put("message", "用户账号已被禁用"); return result; @@ -4006,4 +4008,695 @@ public class AppletControllerUtil { return "未知状态"; } } + + // ============================== 新增的工具方法 ============================== + + /** + * 为分类列表中的图片添加CDN前缀 + * + * @param categoryList 分类列表 + */ + public static void addImageCdnPrefixForCategories(List categoryList) { + if (categoryList != null) { + for (ServiceCate category : categoryList) { + category.setIcon(buildImageUrl(category.getIcon())); + } + } + } + + /** + * 为广告图片列表添加CDN前缀 + * + * @param advImgList 广告图片列表 + */ + public static void addImageCdnPrefixForAdvImages(List advImgList) { + if (advImgList != null) { + for (AdvImg advImg : advImgList) { + advImg.setImage(buildImageUrl(advImg.getImage())); + } + } + } + + /** + * 获取系统配置信息 + * + * @param name 配置名称 + * @param siteConfigService 配置服务 + * @return 配置对象,如果不存在返回null + */ + public static SiteConfig getSiteConfig(String name, ISiteConfigService siteConfigService) { + if (name == null || name.trim().isEmpty()) { + return null; + } + + SiteConfig configQuery = new SiteConfig(); + configQuery.setName(name.trim()); + List configList = siteConfigService.selectSiteConfigList(configQuery); + + return configList.isEmpty() ? null : configList.get(0); + } + + /** + * 解析配置值为JSON对象 + * + * @param configValue 配置值字符串 + * @return JSON对象,解析失败返回空对象 + */ + public static JSONObject parseConfigValue(String configValue) { + if (configValue == null || configValue.trim().isEmpty()) { + return new JSONObject(); + } + + try { + return JSONObject.parseObject(configValue); + } catch (Exception e) { + return new JSONObject(); + } + } + + /** + * 验证用户地址归属权 + * + * @param addressId 地址ID + * @param userId 用户ID + * @param userAddressService 地址服务 + * @return 用户地址对象,如果不存在或无权访问返回null + */ + public static UserAddress validateUserAddress(Long addressId, Long userId, IUserAddressService userAddressService) { + if (addressId == null || userId == null) { + return null; + } + + UserAddress userAddress = userAddressService.selectUserAddressById(addressId); + if (userAddress == null || !userAddress.getUid().equals(userId)) { + return null; + } + + return userAddress; + } + + /** + * 处理地址修改逻辑 + * + * @param params 修改参数 + * @param user 当前用户 + * @param userAddressService 地址服务 + * @return 处理结果 + */ + public static AjaxResult processAddressEdit(Map params, Users user, IUserAddressService userAddressService) { + try { + // 参数验证 + if (params == null || params.get("id") == null) { + return appletWarning("地址ID不能为空"); + } + + Long addressId; + try { + addressId = Long.valueOf(params.get("id").toString()); + if (addressId <= 0) { + return appletWarning("地址ID无效"); + } + } catch (NumberFormatException e) { + return appletWarning("地址ID格式错误"); + } + + // 查询原地址信息并验证归属权 + UserAddress existingAddress = validateUserAddress(addressId, user.getId(), userAddressService); + if (existingAddress == null) { + return appletWarning("地址不存在或无权修改"); + } + + // 构建更新的地址对象 + UserAddress updateAddress = buildUpdateAddress(params, addressId, user.getId()); + + // 验证必填字段 + String validationResult = validateAddressParams(updateAddress); + if (validationResult != null) { + return appletWarning(validationResult); + } + + // 处理默认地址逻辑 + if (updateAddress.getIsDefault() != null && updateAddress.getIsDefault() == 1L) { + userAddressService.updateUserAddressDefault(user.getId()); + } + + // 执行地址更新 + int updateResult = userAddressService.updateUserAddress(updateAddress); + + if (updateResult > 0) { + return appletSuccess("地址修改成功"); + } else { + return appletWarning("地址修改失败"); + } + } catch (Exception e) { + return appletError("修改地址失败:" + e.getMessage()); + } + } + + /** + * 处理地址新增逻辑 + * + * @param params 新增参数 + * @param user 当前用户 + * @param userAddressService 地址服务 + * @return 处理结果 + */ + public static AjaxResult processAddressAdd(Map params, Users user, IUserAddressService userAddressService) { + try { + // 构建新增的地址对象 + UserAddress newAddress = buildNewAddress(params, user.getId()); + + // 验证必填字段 + String validationResult = validateAddressParams(newAddress); + if (validationResult != null) { + return appletWarning(validationResult); + } + + // 处理默认地址逻辑 + if (newAddress.getIsDefault() != null && newAddress.getIsDefault() == 1L) { + userAddressService.updateUserAddressDefault(user.getId()); + } + + // 执行地址新增 + int insertResult = userAddressService.insertUserAddress(newAddress); + + if (insertResult > 0) { + return appletSuccess("地址新增成功"); + } else { + return appletWarning("地址新增失败"); + } + } catch (Exception e) { + return appletError("新增地址失败:" + e.getMessage()); + } + } + + /** + * 处理售后返修申请 + * + * @param params 申请参数 + * @param user 当前用户 + * @param orderService 订单服务 + * @param orderReworkService 返修服务 + * @return 处理结果 + */ + public static AjaxResult processReworkApplication(Map params, Users user, + IOrderService orderService, IOrderReworkService orderReworkService) { + try { + // 参数验证 + if (params == null) { + return appletWarning("请求参数不能为空"); + } + + String orderId = (String) params.get("order_id"); + String phone = (String) params.get("phone"); + String mark = (String) params.get("mark"); + + if (orderId == null || orderId.isEmpty()) { + return appletWarning("订单ID不能为空"); + } + + if (phone == null || phone.isEmpty()) { + return appletWarning("联系电话不能为空"); + } + + // 验证手机号格式 + if (!phone.matches("^1[3-9]\\d{9}$")) { + return appletWarning("联系电话格式不正确"); + } + + if (mark == null || mark.isEmpty()) { + return appletWarning("返修原因不能为空"); + } + + // 查询订单信息并验证归属权 + Order order = orderService.selectOrderByOrderId(orderId); + if (order == null) { + return appletWarning("订单不存在"); + } + + if (!order.getUid().equals(user.getId())) { + return appletWarning("无权操作此订单"); + } + + // 验证订单状态是否允许申请售后 + if (!isOrderAllowRework(order.getStatus())) { + return appletWarning("当前订单状态不允许申请售后"); + } + + // 处理售后返修申请 + boolean reworkResult = processReworkApplication(order, phone, mark, user, orderReworkService, orderService); + + if (reworkResult) { + return appletSuccess("售后返修申请已提交,我们会尽快联系您处理"); + } else { + return appletWarning("售后申请提交失败,请稍后重试"); + } + } catch (Exception e) { + return appletError("售后申请失败:" + e.getMessage()); + } + } + + /** + * 小程序查询用户余额日志接口(简化版) + * + * @param params 请求参数Map,包含分页参数page、limit + * @param token 用户token + * @param usersService 用户服务 + * @param userMemnerConsumptionLogService 用户余额日志服务 + * @return 余额日志分页数据 + * + * 请求参数: + * - page: 页码,默认1 + * - limit: 每页数量,默认10,最大50 + * + * 返回数据格式: + * { + * "code": 200, + * "msg": "OK", + * "data": { + * "current_page": 1, + * "data": [ + * { + * "id": 1, + * "amount": "+100.00", + * "before_amount": "0.00", + * "after_amount": "100.00", + * "remark": "服务收入", + * "created_time": "2024-01-01 12:00:00" + * } + * ], + * "total": 100, + * "per_page": 10, + * "last_page": 10, + * "has_more": true + * } + * } + */ + public static AjaxResult getUserBalanceLogList(Map params, String token, + IUsersService usersService, + IUserMemnerConsumptionLogService userMemnerConsumptionLogService) { + try { + // 1. 验证用户token + Map tokenValidation = validateUserToken(token, usersService); + if (!(Boolean) tokenValidation.get("valid")) { + return appletUnauthorized((String) tokenValidation.get("message")); + } + + // 从tokenValidation中获取用户信息Map,然后提取userId + Map userInfoMap = (Map) tokenValidation.get("userInfo"); + Long userId = (Long) userInfoMap.get("userId"); + + // 2. 解析分页参数 + int page = 1; + int limit = 10; + + if (params != null && params.get("page") != null) { + try { + page = Integer.parseInt(params.get("page").toString()); + if (page < 1) page = 1; + } catch (NumberFormatException e) { + return appletWarning("页码参数格式错误"); + } + } + + if (params != null && params.get("limit") != null) { + try { + limit = Integer.parseInt(params.get("limit").toString()); + if (limit < 1) limit = 10; + if (limit > 50) limit = 50; // 限制最大每页数量 + } catch (NumberFormatException e) { + return appletWarning("每页数量参数格式错误"); + } + } + + // 3. 设置分页 + PageHelper.startPage(page, limit); + + // 4. 构建查询条件 + UserMemnerConsumptionLog queryCondition = new UserMemnerConsumptionLog(); + queryCondition.setUid(userId.intValue()); + + // 5. 查询余额日志列表 + List logList = userMemnerConsumptionLogService.selectUserMemnerConsumptionLogList(queryCondition); + + // 6. 构建返回数据 + List> formattedLogList = buildSimpleBalanceLogResponseList(logList); + + // 7. 构建分页信息 + PageInfo pageInfo = new PageInfo<>(logList); + Map responseData = new HashMap<>(); + responseData.put("current_page", pageInfo.getPageNum()); + responseData.put("data", formattedLogList); + responseData.put("total", pageInfo.getTotal()); + responseData.put("per_page", pageInfo.getPageSize()); + responseData.put("last_page", pageInfo.getPages()); + responseData.put("has_more", pageInfo.isHasNextPage()); + + return appletSuccess(responseData); + + } catch (Exception e) { + System.err.println("查询用户余额日志异常:" + e.getMessage()); + return appletError("查询余额日志失败:" + e.getMessage()); + } + } + + /** + * 构建简化的用户余额日志响应列表 + * + * @param logList 原始日志列表 + * @return 格式化的日志响应列表 + */ + public static List> buildSimpleBalanceLogResponseList(List logList) { + List> formattedList = new ArrayList<>(); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + for (UserMemnerConsumptionLog log : logList) { + Map logData = new HashMap<>(); + + // 基础信息 + logData.put("id", log.getId()); + + // 金额信息格式化 + BigDecimal amount = log.getConsumptionmoney() != null ? log.getConsumptionmoney() : BigDecimal.ZERO; + String amountStr; + if (log.getType() != null && log.getType() == 1) { + // 收入显示正号 + amountStr = "+" + amount.setScale(2, BigDecimal.ROUND_HALF_UP).toString(); + } else { + // 支出显示负号 + amountStr = "-" + amount.setScale(2, BigDecimal.ROUND_HALF_UP).toString(); + } + logData.put("amount", amountStr); + + // 余额信息 + logData.put("before_amount", log.getBeformoney() != null ? + log.getBeformoney().setScale(2, BigDecimal.ROUND_HALF_UP).toString() : "0.00"); + logData.put("after_amount", log.getAftermoney() != null ? + log.getAftermoney().setScale(2, BigDecimal.ROUND_HALF_UP).toString() : "0.00"); + + // 备注信息 + logData.put("remark", log.getReamk() != null ? log.getReamk() : ""); + + // 时间信息 + if (log.getCreatedAt() != null) { + logData.put("created_time", sdf.format(log.getCreatedAt())); + } else if (log.getConsumptiontime() != null) { + logData.put("created_time", sdf.format(log.getConsumptiontime())); + } else { + logData.put("created_time", null); + } + + formattedList.add(logData); + } + + return formattedList; + } + + // ============================== 用户余额日志接口响应类 ============================== + + /** + * 用户余额日志接口统一响应类 + * 适用于 /api/user/benefit/log 接口 + */ + public static class UserBalanceLogResponse { + /** + * 响应状态码 + * 200: 成功 + * 422: 业务提示 + * 332: 未登录 + * 500: 系统错误 + */ + private Integer code; + + /** + * 响应消息 + */ + private String msg; + + /** + * 响应数据 + */ + private UserBalanceLogPageData data; + + // 构造方法 + public UserBalanceLogResponse() {} + + public UserBalanceLogResponse(Integer code, String msg, UserBalanceLogPageData data) { + this.code = code; + this.msg = msg; + this.data = data; + } + + // Getter 和 Setter + public Integer getCode() { return code; } + public void setCode(Integer code) { this.code = code; } + + public String getMsg() { return msg; } + public void setMsg(String msg) { this.msg = msg; } + + public UserBalanceLogPageData getData() { return data; } + public void setData(UserBalanceLogPageData data) { this.data = data; } + } + + /** + * 用户余额日志分页数据类 + */ + public static class UserBalanceLogPageData { + /** + * 当前页码 + */ + private Integer current_page; + + /** + * 每页数量 + */ + private Integer per_page; + + /** + * 总记录数 + */ + private Long total; + + /** + * 总页数 + */ + private Integer last_page; + + /** + * 是否还有更多数据 + */ + private Boolean has_more; + + /** + * 余额日志列表 + */ + private List data; + + // 构造方法 + public UserBalanceLogPageData() {} + + // Getter 和 Setter + public Integer getCurrent_page() { return current_page; } + public void setCurrent_page(Integer current_page) { this.current_page = current_page; } + + public Integer getPer_page() { return per_page; } + public void setPer_page(Integer per_page) { this.per_page = per_page; } + + public Long getTotal() { return total; } + public void setTotal(Long total) { this.total = total; } + + public Integer getLast_page() { return last_page; } + public void setLast_page(Integer last_page) { this.last_page = last_page; } + + public Boolean getHas_more() { return has_more; } + public void setHas_more(Boolean has_more) { this.has_more = has_more; } + + public List getData() { return data; } + public void setData(List data) { this.data = data; } + } + + /** + * 用户余额日志单条记录类 + */ + public static class UserBalanceLogItem { + /** + * 日志记录ID + */ + private Integer id; + + /** + * 变动金额 + * 格式:"+100.00" 表示收入,"-50.00" 表示支出 + */ + private String amount; + + /** + * 变动前余额 + * 格式:保留两位小数,如 "150.00" + */ + private String before_amount; + + /** + * 变动后余额 + * 格式:保留两位小数,如 "250.00" + */ + private String after_amount; + + /** + * 变动说明/备注 + * 如:服务收入、商品购买、提现等 + */ + private String remark; + + /** + * 记录创建时间 + * 格式:yyyy-MM-dd HH:mm:ss + */ + private String created_time; + + // 构造方法 + public UserBalanceLogItem() {} + + public UserBalanceLogItem(Integer id, String amount, String before_amount, + String after_amount, String remark, String created_time) { + this.id = id; + this.amount = amount; + this.before_amount = before_amount; + this.after_amount = after_amount; + this.remark = remark; + this.created_time = created_time; + } + + // Getter 和 Setter + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + public String getAmount() { return amount; } + public void setAmount(String amount) { this.amount = amount; } + + public String getBefore_amount() { return before_amount; } + public void setBefore_amount(String before_amount) { this.before_amount = before_amount; } + + public String getAfter_amount() { return after_amount; } + public void setAfter_amount(String after_amount) { this.after_amount = after_amount; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + public String getCreated_time() { return created_time; } + public void setCreated_time(String created_time) { this.created_time = created_time; } + } + + /** + * 构建用户余额日志响应对象的工具方法 + * + * @param code 响应码 + * @param msg 响应消息 + * @param pageInfo 分页信息 + * @param logList 日志列表 + * @return 完整的响应对象 + */ + public static UserBalanceLogResponse buildUserBalanceLogResponse(Integer code, String msg, + PageInfo pageInfo, + List> logList) { + UserBalanceLogResponse response = new UserBalanceLogResponse(); + response.setCode(code); + response.setMsg(msg); + + if (pageInfo != null && logList != null) { + UserBalanceLogPageData pageData = new UserBalanceLogPageData(); + pageData.setCurrent_page(pageInfo.getPageNum()); + pageData.setPer_page(pageInfo.getPageSize()); + pageData.setTotal(pageInfo.getTotal()); + pageData.setLast_page(pageInfo.getPages()); + pageData.setHas_more(pageInfo.isHasNextPage()); + + // 转换日志列表 + List items = new ArrayList<>(); + for (Map logMap : logList) { + UserBalanceLogItem item = new UserBalanceLogItem(); + item.setId((Integer) logMap.get("id")); + item.setAmount((String) logMap.get("amount")); + item.setBefore_amount((String) logMap.get("before_amount")); + item.setAfter_amount((String) logMap.get("after_amount")); + item.setRemark((String) logMap.get("remark")); + item.setCreated_time((String) logMap.get("created_time")); + items.add(item); + } + + pageData.setData(items); + response.setData(pageData); + } + + return response; + } + + /** + * 用户余额日志接口实际返回值备注说明 + * + * API接口:/api/user/benefit/log + * + * 完整返回值结构说明: + * { + * "msg": "OK", // 响应消息,成功时为"OK" + * "code": 200, // 响应状态码:200=成功,422=业务提示,332=未登录,500=系统错误 + * "data": { // 响应数据 + * "per_page": "10", // 每页显示条数,字符串类型 + * "total": 1, // 总记录数,整数类型 + * "data": [ // 日志记录列表 + * { + * "createBy": null, // 创建人,通常为null + * "createTime": null, // 创建时间,通常为null + * "updateBy": null, // 更新人,通常为null + * "updateTime": null, // 更新时间,通常为null + * "remark": null, // 系统备注,通常为null + * "id": 1, // 记录ID,整数类型,唯一标识 + * "type": 1, // 变动类型:1=收入,2=支出 + * "dotime": "2025-07-04", // 操作日期,格式:yyyy-MM-dd + * "ordermoney": 10.00, // 订单金额,BigDecimal类型,保留2位小数 + * "money": 1.00, // 变动金额,BigDecimal类型,保留2位小数 + * "ordertype": 1, // 订单类型:1=服务订单,2=商品订单,3=其他 + * "uid": 2750, // 用户ID,整数类型 + * "beformoney": 0.00, // 变动前金额,BigDecimal类型,保留2位小数 + * "aftremoney": 10.00, // 变动后金额,BigDecimal类型,保留2位小数 + * "createdAt": "2025-07-04", // 创建日期,格式:yyyy-MM-dd + * "updatedAt": null, // 更新日期,通常为null + * "orderid": 4, // 关联订单ID,整数类型 + * "reamk": "5" // 用户备注/说明,字符串类型 + * } + * ], + * "last_page": 1, // 最后一页页码,整数类型 + * "next_page_url": null, // 下一页URL,通常为null + * "from": 1, // 当前页起始记录位置 + * "to": 1, // 当前页结束记录位置 + * "prev_page_url": null, // 上一页URL,通常为null + * "current_page": 1 // 当前页码,整数类型 + * } + * } + * + * 字段类型说明: + * - BigDecimal字段:ordermoney, money, beformoney, aftremoney + * - Integer字段:id, type, ordertype, uid, orderid, total, last_page, from, to, current_page + * - String字段:dotime, createdAt, updatedAt, reamk, per_page + * - 可为null的字段:createBy, createTime, updateBy, updateTime, remark, updatedAt, next_page_url, prev_page_url + * + * 业务逻辑说明: + * 1. type=1时表示收入,money为正值;type=2时表示支出,money为负值 + * 2. beformoney + money = aftremoney(变动前金额 + 变动金额 = 变动后金额) + * 3. orderid关联具体的订单记录 + * 4. reamk字段存储用户的备注信息 + * 5. ordermoney是关联订单的总金额 + * 6. ordertype区分不同类型的订单来源 + * + * 前端展示建议: + * 1. 金额显示:根据type字段决定显示正负号,如"+1.00"或"-1.00" + * 2. 时间显示:优先使用createdAt,格式化为"yyyy-MM-dd HH:mm:ss" + * 3. 备注显示:使用reamk字段内容 + * 4. 分页信息:使用current_page、total、last_page构建分页组件 + * + * 错误处理: + * - code!=200时,显示msg中的错误信息 + * - data字段可能为null或空数组 + * - 金额字段可能为null,需要默认值处理 + */ } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/WechatCertificateUtil.java b/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/WechatCertificateUtil.java new file mode 100644 index 0000000..151c870 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/WechatCertificateUtil.java @@ -0,0 +1,198 @@ +package com.ruoyi.system.ControllerUtil; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.utils.spring.SpringUtils; +import com.ruoyi.system.config.WechatConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.UUID; + +/** + * 微信支付平台证书获取工具类 + * + * @author Mr. Zhang Pan + * @version 1.0 + * @date 2025-01-17 + */ +@Component +public class WechatCertificateUtil { + + private static final Logger log = LoggerFactory.getLogger(WechatCertificateUtil.class); + private static final String V3_BASE_URL = "https://api.mch.weixin.qq.com"; + private static final String CERTIFICATES_URL = "/v3/certificates"; + private static final String ALGORITHM_RSA_SHA256 = "SHA256withRSA"; + private static final RestTemplate restTemplate = new RestTemplate(); + + private static WechatConfig wechatConfig() { + return SpringUtils.getBean(WechatConfig.class); + } + + /** + * 获取微信支付平台证书序列号 + * + * @return 平台证书序列号 + */ + public String getPlatformCertificateSerial() { + log.info("🔍 开始获取微信支付平台证书序列号"); + + try { + // 构建请求 + String fullUrl = V3_BASE_URL + CERTIFICATES_URL; + HttpHeaders headers = buildHeaders(); + + log.info("🚀 发送请求到: {}", fullUrl); + + HttpEntity requestEntity = new HttpEntity<>("", headers); + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.GET, + requestEntity, + String.class + ); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + JSONObject jsonResponse = JSONObject.parseObject(responseBody); + + if (jsonResponse != null && jsonResponse.containsKey("data")) { + JSONArray dataArray = jsonResponse.getJSONArray("data"); + if (dataArray != null && !dataArray.isEmpty()) { + JSONObject firstCert = dataArray.getJSONObject(0); + String serialNo = firstCert.getString("serial_no"); + + log.info("✅ 成功获取平台证书序列号: {}", maskSensitiveData(serialNo)); + return serialNo; + } + } + } + + log.error("❌ 获取平台证书序列号失败,响应: {}", response.getBody()); + return null; + + } catch (Exception e) { + log.error("❌ 获取平台证书序列号异常: {}", e.getMessage(), e); + return null; + } + } + + /** + * 构建请求头 + */ + private HttpHeaders buildHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + headers.set("Accept", "application/json"); + headers.set("User-Agent", "RuoYi-WechatPay-V3"); + + // 构建Authorization头 + String authorization = buildAuthorizationHeader(); + headers.set("Authorization", authorization); + + return headers; + } + + /** + * 构建Authorization头 + */ + private String buildAuthorizationHeader() { + long timestamp = System.currentTimeMillis() / 1000; + String nonceStr = generateNonceStr(); + String signature = buildSignature("GET", CERTIFICATES_URL, timestamp, nonceStr, ""); + + return String.format("WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\",timestamp=\"%d\",serial_no=\"%s\"", + wechatConfig().getMchid(), nonceStr, signature, timestamp, wechatConfig().getSerialNo()); + } + + /** + * 生成V3签名 + */ + private String buildSignature(String method, String url, long timestamp, String nonceStr, String body) { + String message = method + "\n" + url + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; + + try { + PrivateKey privateKey = getPrivateKey(); + Signature signature = Signature.getInstance(ALGORITHM_RSA_SHA256); + signature.initSign(privateKey); + signature.update(message.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(signature.sign()); + } catch (Exception e) { + log.error("❌ 生成签名失败: {}", e.getMessage(), e); + throw new RuntimeException("生成签名失败", e); + } + } + + /** + * 获取商户私钥 + */ + private PrivateKey getPrivateKey() { + try { + String privateKeyPath = wechatConfig().getPrivateKeyPath(); + String privateKeyContent; + + if (privateKeyPath.startsWith("classpath:")) { + String resourcePath = privateKeyPath.substring(10); + try (var inputStream = this.getClass().getClassLoader().getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new RuntimeException("无法找到classpath资源: " + resourcePath); + } + privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } else { + try (var inputStream = this.getClass().getClassLoader().getResourceAsStream(privateKeyPath)) { + if (inputStream == null) { + throw new RuntimeException("无法找到classpath资源: " + privateKeyPath); + } + privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + // 清理私钥格式 + privateKeyContent = privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + + byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(spec); + + } catch (Exception e) { + log.error("❌ 获取商户私钥失败: {}", e.getMessage(), e); + throw new RuntimeException("获取商户私钥失败: " + e.getMessage(), e); + } + } + + /** + * 生成随机字符串 + */ + private String generateNonceStr() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 32); + } + + /** + * 脱敏显示敏感数据 + */ + private String maskSensitiveData(String data) { + if (data == null || data.length() <= 6) { + return "****"; + } + return data.substring(0, 3) + "****" + data.substring(data.length() - 3); + } +} \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/WechatPayV3Util.java b/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/WechatPayV3Util.java index 94f5e61..601c861 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/WechatPayV3Util.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controllerUtil/WechatPayV3Util.java @@ -3,6 +3,8 @@ package com.ruoyi.system.ControllerUtil; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.system.config.WechatConfig; import com.ruoyi.common.utils.spring.SpringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -10,195 +12,1480 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.*; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.text.SimpleDateFormat; import java.util.*; /** - * 微信支付v3工具类(小程序支付/退款/提现) - * 你需要在WechatConfig中补充apiv3Key、privateKeyPath、serialNo等配置,并实现v3签名算法。 - * 所有v3接口均需用商户API私钥签名,回包敏感字段需用apiv3Key解密。 - * 详细文档见:https://pay.weixin.qq.com/wiki/doc/apiv3/ + * 微信支付V3工具类 + * + * 提供微信支付V3 API的完整功能实现: + * 1. JSAPI支付(小程序支付) + * 2. 申请退款 + * 3. 查询退款 + * 4. 商户提现(转账到零钱) + * 5. 查询转账 + * 6. V3签名生成和验证 + * 7. 敏感字段解密 + * + * @author Mr. Zhang Pan + * @version 2.0 + * @date 2025-01-17 */ @Component public class WechatPayV3Util { + private static final Logger log = LoggerFactory.getLogger(WechatPayV3Util.class); private static final RestTemplate restTemplate = createRestTemplate(); + + // 微信支付V3 API地址 + private static final String V3_BASE_URL = "https://api.mch.weixin.qq.com"; + private static final String JSAPI_PAY_URL = "/v3/pay/transactions/jsapi"; + private static final String REFUND_URL = "/v3/refund/domestic/refunds"; + private static final String TRANSFER_BATCH_URL = "/v3/transfer/batches"; + private static final String QUERY_REFUND_URL = "/v3/refund/domestic/refunds/"; + private static final String QUERY_TRANSFER_URL = "/v3/transfer/batches/out-batch-no/"; + + // 算法常量 + private static final String ALGORITHM_RSA_SHA256 = "SHA256withRSA"; + private static final String ALGORITHM_AES_GCM = "AES/GCM/NoPadding"; private static RestTemplate createRestTemplate() { RestTemplate template = new RestTemplate(); template.getMessageConverters().forEach(converter -> { if (converter instanceof org.springframework.http.converter.StringHttpMessageConverter) { ((org.springframework.http.converter.StringHttpMessageConverter) converter) - .setDefaultCharset(java.nio.charset.StandardCharsets.UTF_8); + .setDefaultCharset(StandardCharsets.UTF_8); } }); + log.info("✅ RestTemplate创建成功,已配置UTF-8编码"); return template; } private static WechatConfig wechatConfig() { - return SpringUtils.getBean(WechatConfig.class); + WechatConfig config = SpringUtils.getBean(WechatConfig.class); + log.info("📋 获取微信配置成功 - AppID: {}, MchID: {}", + maskSensitiveData(config.getAppid()), maskSensitiveData(config.getMchid())); + return config; } /** - * v3 小程序支付下单(JSAPI) - * @param openid 用户openid - * @param orderNo 商户订单号 - * @param totalFee 支付金额(分) - * @param body 商品描述 - * @param notifyUrl 回调通知地址 - * @return 下单结果,包含前端调起支付的参数 + * 脱敏显示敏感数据 */ - public Map jsapiPay(String openid, String orderNo, int totalFee, String body, String notifyUrl) { - Map result = new HashMap<>(); - try { - String appid = wechatConfig().getAppid(); - String mchid = wechatConfig().getMchid(); - // TODO: 你需要在WechatConfig中补充apiv3Key、privateKeyPath、serialNo等配置 - Map amount = new HashMap<>(); - amount.put("total", totalFee); - amount.put("currency", "CNY"); - Map payer = new HashMap<>(); - payer.put("openid", openid); - Map params = new HashMap<>(); - params.put("appid", appid); - params.put("mchid", mchid); - params.put("description", body); - params.put("out_trade_no", orderNo); - params.put("notify_url", notifyUrl); - params.put("amount", amount); - params.put("payer", payer); - String bodyJson = new JSONObject(params).toJSONString(); - - // 生成v3签名串,构造Authorization头 - // TODO: 你需要实现v3签名算法,生成Authorization头 - String authorization = "请实现v3签名算法,生成Authorization头"; - - String v3Url = "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"; - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", authorization); - headers.set("Content-Type", "application/json"); - headers.set("Accept", "application/json"); - HttpEntity requestEntity = new HttpEntity<>(bodyJson, headers); - ResponseEntity response = restTemplate.exchange(v3Url, HttpMethod.POST, requestEntity, String.class); - - // 解析响应,获取prepay_id - // TODO: 你需要根据微信v3返回结构处理,部分字段加密需用apiv3Key解密 - result.put("success", true); - result.put("response", response.getBody()); - result.put("message", "v3下单接口请求已发送,请根据返回内容进一步处理"); - } catch (Exception e) { - result.put("success", false); - result.put("message", "v3下单异常:" + e.getMessage()); + private static String maskSensitiveData(String data) { + if (data == null || data.length() <= 6) { + return "****"; + } + return data.substring(0, 3) + "****" + data.substring(data.length() - 3); + } + + // ============================== 公共方法 ============================== + + /** + * 生成V3签名字符串 + */ + private String buildSignature(String method, String url, long timestamp, String nonceStr, String body) { + log.info("🔐 开始生成V3签名"); + log.info(" ├─ HTTP方法: {}", method); + log.info(" ├─ 请求URL: {}", url); + log.info(" ├─ 时间戳: {}", timestamp); + log.info(" ├─ 随机字符串: {}", nonceStr); + log.info(" └─ 请求体长度: {} 字符", body != null ? body.length() : 0); + + String message = method + "\n" + url + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; + log.debug("🔍 待签名字符串: \n{}", message); + + try { + PrivateKey privateKey = getPrivateKey(); + Signature signature = Signature.getInstance(ALGORITHM_RSA_SHA256); + signature.initSign(privateKey); + signature.update(message.getBytes(StandardCharsets.UTF_8)); + String signResult = Base64.getEncoder().encodeToString(signature.sign()); + log.info("✅ V3签名生成成功,签名长度: {} 字符", signResult.length()); + return signResult; + } catch (Exception e) { + log.error("❌ V3签名生成失败: {}", e.getMessage(), e); + throw new RuntimeException("生成签名失败", e); } - return result; } /** - * v3 申请退款 + * 构建Authorization头 + */ + private String buildAuthorizationHeader(String method, String url, String body) { + log.info("🔑 开始构建Authorization头"); + long timestamp = System.currentTimeMillis() / 1000; + String nonceStr = generateNonceStr(); + String signature = buildSignature(method, url, timestamp, nonceStr, body); + + String authHeader = String.format("WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\",timestamp=\"%d\",serial_no=\"%s\"", + wechatConfig().getMchid(), nonceStr, signature, timestamp, wechatConfig().getSerialNo()); + + log.info("✅ Authorization头构建成功"); + log.debug("🔍 Authorization: {}", authHeader); + return authHeader; + } + + /** + * 构建通用请求头 + * + * @param method HTTP方法 + * @param url 请求URL + * @param body 请求体 + * @return HttpHeaders + */ + private HttpHeaders buildCommonHeaders(String method, String url, String body) { + return buildCommonHeaders(method, url, body, true); + } + + /** + * 构建通用请求头 + * + * @param method HTTP方法 + * @param url 请求URL + * @param body 请求体 + * @param includeWechatpaySerial 是否包含Wechatpay-Serial头 + * @return HttpHeaders + */ + private HttpHeaders buildCommonHeaders(String method, String url, String body, boolean includeWechatpaySerial) { + log.info("📤 开始构建请求头"); + HttpHeaders headers = new HttpHeaders(); + + // 构建Authorization头 + headers.set("Authorization", buildAuthorizationHeader(method, url, body)); + + // 设置基本头部 + headers.set("Content-Type", "application/json"); + headers.set("Accept", "application/json"); + headers.set("User-Agent", "RuoYi-WechatPay-V3"); + + // 重要:添加微信支付平台证书序列号 + if (includeWechatpaySerial) { + // 根据微信支付V3文档,对于转账接口,可以暂时不设置Wechatpay-Serial头 + String wechatpaySerial = getWechatpayPlatformSerial(); + if (wechatpaySerial != null && !wechatpaySerial.trim().isEmpty() && + !wechatpaySerial.equals(wechatConfig().getSerialNo())) { + // 只有当平台证书序列号与商户证书序列号不同时才设置 + headers.set("Wechatpay-Serial", wechatpaySerial); + log.info(" ├─ 已添加微信支付平台证书序列号: {}", maskSensitiveData(wechatpaySerial)); + } else { + // 临时使用商户证书序列号 + headers.set("Wechatpay-Serial", wechatConfig().getSerialNo()); + log.warn(" ⚠️ 使用商户证书序列号作为平台证书序列号: {}", + maskSensitiveData(wechatConfig().getSerialNo())); + } + } else { + log.info(" ├─ 跳过Wechatpay-Serial头设置(测试模式)"); + } + + log.info("✅ 请求头构建完成"); + log.debug("🔍 请求头详情: Authorization=[已隐藏], Wechatpay-Serial={}", + headers.getFirst("Wechatpay-Serial")); + + return headers; + } + + /** + * 获取微信支付平台证书序列号 + * 优先从配置中获取,如果没有配置则尝试自动获取 + * + * @return 微信支付平台证书序列号 + */ + private String getWechatpayPlatformSerial() { + // 优先从配置文件中获取微信支付平台证书序列号 + String wechatpaySerial = wechatConfig().getWechatpaySerial(); + + if (wechatpaySerial != null && !wechatpaySerial.trim().isEmpty()) { + log.debug("🔍 从配置中获取微信支付平台证书序列号: {}", maskSensitiveData(wechatpaySerial)); + return wechatpaySerial; + } + + // 如果配置中没有,尝试自动获取 + try { + String autoSerial = getWechatpaySerialFromApi(); + if (autoSerial != null && !autoSerial.trim().isEmpty()) { + log.info("🔍 自动获取微信支付平台证书序列号成功: {}", maskSensitiveData(autoSerial)); + return autoSerial; + } + } catch (Exception e) { + log.warn("⚠️ 自动获取微信支付平台证书序列号失败: {}", e.getMessage()); + } + + // 如果都没有,返回null,将使用商户证书序列号作为临时方案 + log.debug("🔍 未配置微信支付平台证书序列号,将使用商户证书序列号"); + return null; + } + + /** + * 从API自动获取微信支付平台证书序列号 + * + * @return 平台证书序列号 + */ + private String getWechatpaySerialFromApi() { + // 这里暂时返回null,避免循环调用 + // 实际实现需要通过其他方式获取 + return null; + } + + /** + * 获取商户私钥 + */ + private PrivateKey getPrivateKey() { + log.info("🔐 开始加载商户私钥"); + try { + String privateKeyPath = wechatConfig().getPrivateKeyPath(); + log.info(" ├─ 私钥文件路径: {}", privateKeyPath); + + // 处理不同类型的路径 + String privateKeyContent = null; + if (privateKeyPath.startsWith("classpath:")) { + // classpath路径处理 + String resourcePath = privateKeyPath.substring(10); + log.info(" ├─ 解析classpath路径: {}", resourcePath); + + try (InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new RuntimeException("无法找到classpath资源: " + resourcePath); + } + privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + log.info(" ├─ 从classpath读取私钥成功,内容长度: {} 字符", privateKeyContent.length()); + } + } else if (privateKeyPath.startsWith("/") || privateKeyPath.contains(":")) { + // 绝对路径处理 + log.info(" ├─ 使用绝对路径: {}", privateKeyPath); + privateKeyContent = new String(Files.readAllBytes(Paths.get(privateKeyPath)), StandardCharsets.UTF_8); + log.info(" ├─ 从绝对路径读取私钥成功,内容长度: {} 字符", privateKeyContent.length()); + } else { + // 相对路径处理(相对于classpath) + log.info(" ├─ 使用相对路径,尝试从classpath加载: {}", privateKeyPath); + try (InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(privateKeyPath)) { + if (inputStream == null) { + throw new RuntimeException("无法找到classpath资源: " + privateKeyPath); + } + privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + log.info(" ├─ 从classpath读取私钥成功,内容长度: {} 字符", privateKeyContent.length()); + } + } + + if (privateKeyContent == null || privateKeyContent.trim().isEmpty()) { + throw new RuntimeException("私钥文件内容为空"); + } + + // 清理私钥内容格式 + log.info(" ├─ 开始清理私钥格式"); + privateKeyContent = privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + + if (privateKeyContent.isEmpty()) { + throw new RuntimeException("清理后的私钥内容为空,请检查私钥文件格式"); + } + + log.info(" ├─ 私钥格式清理完成,Base64内容长度: {} 字符", privateKeyContent.length()); + + // 解码并生成私钥 + byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = keyFactory.generatePrivate(spec); + + log.info("✅ 商户私钥加载成功,算法: {}", privateKey.getAlgorithm()); + return privateKey; + } catch (Exception e) { + log.error("❌ 获取商户私钥失败: {}", e.getMessage(), e); + throw new RuntimeException("获取商户私钥失败: " + e.getMessage(), e); + } + } + + /** + * 生成随机字符串 + */ + private String generateNonceStr() { + String nonceStr = UUID.randomUUID().toString().replace("-", "").substring(0, 32); + log.debug("🎲 生成随机字符串: {}", nonceStr); + return nonceStr; + } + + /** + * AES-GCM解密 + */ + private String decryptAesGcm(String associatedData, String nonce, String ciphertext) { + log.info("🔓 开始AES-GCM解密"); + log.info(" ├─ 附加数据: {}", associatedData); + log.info(" ├─ 随机串长度: {} 字符", nonce != null ? nonce.length() : 0); + log.info(" └─ 密文长度: {} 字符", ciphertext != null ? ciphertext.length() : 0); + + try { + byte[] key = wechatConfig().getApiv3Key().getBytes(StandardCharsets.UTF_8); + SecretKeySpec secretKey = new SecretKeySpec(key, "AES"); + + Cipher cipher = Cipher.getInstance(ALGORITHM_AES_GCM); + GCMParameterSpec spec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec); + cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); + + byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); + String decryptedValue = new String(decryptedBytes, StandardCharsets.UTF_8); + + log.info("✅ AES-GCM解密成功,明文长度: {} 字符", decryptedValue.length()); + return decryptedValue; + } catch (Exception e) { + log.error("❌ AES-GCM解密失败: {}", e.getMessage(), e); + throw new RuntimeException("解密失败", e); + } + } + + // ============================== 退款功能 ============================== + + /** + * 申请退款 + * * @param orderNo 商户订单号 * @param refundNo 退款单号 * @param totalFee 订单总金额(分) * @param refundFee 退款金额(分) * @param reason 退款原因 - * @param notifyUrl 退款回调通知地址 + * @param notifyUrl 退款回调通知地址(可选) * @return 退款结果 */ public Map refund(String orderNo, String refundNo, int totalFee, int refundFee, String reason, String notifyUrl) { + log.info("🔄 开始申请退款"); + log.info(" ├─ 商户订单号: {}", orderNo); + log.info(" ├─ 退款单号: {}", refundNo); + log.info(" ├─ 订单总金额: {} 分 ({}元)", totalFee, fenToYuan(totalFee)); + log.info(" ├─ 退款金额: {} 分 ({}元)", refundFee, fenToYuan(refundFee)); + log.info(" ├─ 退款原因: {}", reason); + log.info(" └─ 回调地址: {}", notifyUrl); + Map result = new HashMap<>(); + try { - String appid = wechatConfig().getAppid(); - String mchid = wechatConfig().getMchid(); - // TODO: 你需要在WechatConfig中补充apiv3Key、privateKeyPath、serialNo等配置 + // 参数验证 + log.info("📋 开始参数验证"); + if (orderNo == null || orderNo.trim().isEmpty()) { + log.warn("❌ 参数验证失败: 商户订单号不能为空"); + result.put("success", false); + result.put("message", "商户订单号不能为空"); + return result; + } + + if (refundNo == null || refundNo.trim().isEmpty()) { + log.warn("❌ 参数验证失败: 退款单号不能为空"); + result.put("success", false); + result.put("message", "退款单号不能为空"); + return result; + } + + if (refundFee <= 0 || refundFee > totalFee) { + log.warn("❌ 参数验证失败: 退款金额无效 (退款金额: {}, 订单金额: {})", refundFee, totalFee); + result.put("success", false); + result.put("message", "退款金额无效"); + return result; + } + log.info("✅ 参数验证通过"); + + // 构建请求参数 + log.info("🔧 开始构建请求参数"); Map amount = new HashMap<>(); amount.put("refund", refundFee); amount.put("total", totalFee); amount.put("currency", "CNY"); + Map params = new HashMap<>(); params.put("out_trade_no", orderNo); params.put("out_refund_no", refundNo); - params.put("reason", reason); - params.put("notify_url", notifyUrl); + params.put("reason", reason != null ? reason : "用户申请退款"); params.put("amount", amount); - String bodyJson = new JSONObject(params).toJSONString(); - - // 生成v3签名串,构造Authorization头 - // TODO: 你需要实现v3签名算法,生成Authorization头 - String authorization = "请实现v3签名算法,生成Authorization头"; - - String v3Url = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds"; - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", authorization); - headers.set("Content-Type", "application/json"); - headers.set("Accept", "application/json"); + + if (notifyUrl != null && !notifyUrl.trim().isEmpty()) { + params.put("notify_url", notifyUrl); + } + + String bodyJson = JSONObject.toJSONString(params); + log.info("✅ 请求参数构建完成"); + log.debug("🔍 请求体JSON: {}", bodyJson); + + // 构建请求头 + HttpHeaders headers = buildCommonHeaders("POST", REFUND_URL, bodyJson); + + // 发送请求 + String fullUrl = V3_BASE_URL + REFUND_URL; + log.info("🚀 开始发送退款请求"); + log.info(" ├─ 请求方法: POST"); + log.info(" ├─ 请求地址: {}", fullUrl); + log.info(" └─ 请求体大小: {} 字节", bodyJson.getBytes(StandardCharsets.UTF_8).length); + HttpEntity requestEntity = new HttpEntity<>(bodyJson, headers); - ResponseEntity response = restTemplate.exchange(v3Url, HttpMethod.POST, requestEntity, String.class); - - // 解析响应 - // TODO: 你需要根据微信v3返回结构处理,部分字段加密需用apiv3Key解密 - result.put("success", true); - result.put("response", response.getBody()); - result.put("message", "v3退款接口请求已发送,请根据返回内容进一步处理"); + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.POST, + requestEntity, + String.class + ); + + // 处理响应 + log.info("📥 收到退款响应"); + log.info(" ├─ 响应状态码: {}", response.getStatusCode()); + log.info(" └─ 响应体大小: {} 字节", + response.getBody() != null ? response.getBody().getBytes(StandardCharsets.UTF_8).length : 0); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + JSONObject jsonResponse = JSONObject.parseObject(responseBody); + + log.info("✅ 退款申请成功"); + log.info("🔍 响应详情:"); + if (jsonResponse != null) { + log.info(" ├─ 退款ID: {}", jsonResponse.getString("refund_id")); + log.info(" ├─ 退款状态: {}", jsonResponse.getString("status")); + log.info(" ├─ 创建时间: {}", jsonResponse.getString("create_time")); + log.info(" └─ 退款金额: {} 分", jsonResponse.getJSONObject("amount").getInteger("refund")); + } + log.debug("🔍 完整响应: {}", responseBody); + + result.put("success", true); + result.put("data", jsonResponse); + result.put("message", "申请退款成功"); + } else { + log.error("❌ 退款申请失败"); + log.error(" ├─ 状态码: {}", response.getStatusCode()); + log.error(" └─ 错误响应: {}", response.getBody()); + + result.put("success", false); + result.put("message", "申请退款失败,状态码:" + response.getStatusCode()); + result.put("error", response.getBody()); + } + } catch (Exception e) { + log.error("❌ 退款申请异常: {}", e.getMessage(), e); result.put("success", false); - result.put("message", "v3退款异常:" + e.getMessage()); + result.put("message", "申请退款异常:" + e.getMessage()); } + + log.info("🏁 退款申请流程结束,结果: {}", result.get("success")); return result; } /** - * v3 商户提现(转账到零钱) + * 查询退款 + * + * @param refundNo 退款单号 + * @return 退款查询结果 + */ + public Map queryRefund(String refundNo) { + log.info("🔍 开始查询退款"); + log.info(" └─ 退款单号: {}", refundNo); + + Map result = new HashMap<>(); + + try { + // 参数验证 + log.info("📋 开始参数验证"); + if (refundNo == null || refundNo.trim().isEmpty()) { + log.warn("❌ 参数验证失败: 退款单号不能为空"); + result.put("success", false); + result.put("message", "退款单号不能为空"); + return result; + } + log.info("✅ 参数验证通过"); + + String url = QUERY_REFUND_URL + refundNo; + String fullUrl = V3_BASE_URL + url; + + // 构建请求头 + HttpHeaders headers = buildCommonHeaders("GET", url, ""); + + // 发送请求 + log.info("🚀 开始发送查询请求"); + log.info(" ├─ 请求方法: GET"); + log.info(" └─ 请求地址: {}", fullUrl); + + HttpEntity requestEntity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.GET, + requestEntity, + String.class + ); + + // 处理响应 + log.info("📥 收到查询响应"); + log.info(" ├─ 响应状态码: {}", response.getStatusCode()); + log.info(" └─ 响应体大小: {} 字节", + response.getBody() != null ? response.getBody().getBytes(StandardCharsets.UTF_8).length : 0); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + JSONObject jsonResponse = JSONObject.parseObject(responseBody); + + log.info("✅ 退款查询成功"); + log.info("🔍 查询结果:"); + if (jsonResponse != null) { + log.info(" ├─ 退款状态: {}", jsonResponse.getString("status")); + log.info(" ├─ 退款金额: {} 分", jsonResponse.getJSONObject("amount").getInteger("refund")); + log.info(" ├─ 退款时间: {}", jsonResponse.getString("success_time")); + log.info(" └─ 退款渠道: {}", jsonResponse.getString("channel")); + } + log.debug("🔍 完整响应: {}", responseBody); + + result.put("success", true); + result.put("data", jsonResponse); + result.put("message", "查询退款成功"); + } else { + log.error("❌ 退款查询失败"); + log.error(" ├─ 状态码: {}", response.getStatusCode()); + log.error(" └─ 错误响应: {}", response.getBody()); + + result.put("success", false); + result.put("message", "查询退款失败,状态码:" + response.getStatusCode()); + result.put("error", response.getBody()); + } + + } catch (Exception e) { + log.error("❌ 退款查询异常: {}", e.getMessage(), e); + result.put("success", false); + result.put("message", "查询退款异常:" + e.getMessage()); + } + + log.info("🏁 退款查询流程结束,结果: {}", result.get("success")); + return result; + } + + // ============================== 提现功能 ============================== + + /** + * 商户提现(转账到零钱) + * + * @param openid 用户openid + * @param amount 金额(分) + * @param desc 提现描述 + * @param userName 真实姓名(可选,用于实名校验) + * @return 提现结果 + */ + public Map withdraw(String openid, int amount, String desc, String userName) { + log.info("💰 开始用户提现"); + log.info(" ├─ 用户OpenID: {}", maskSensitiveData(openid)); + log.info(" ├─ 提现金额: {} 分 ({}元)", amount, fenToYuan(amount)); + log.info(" ├─ 提现描述: {}", desc); + log.info(" └─ 真实姓名: {}", userName != null ? maskSensitiveData(userName) : "未提供"); + + Map result = new HashMap<>(); + + try { + // 参数验证 + log.info("📋 开始参数验证"); + if (openid == null || openid.trim().isEmpty()) { + log.warn("❌ 参数验证失败: 用户openid不能为空"); + result.put("success", false); + result.put("message", "用户openid不能为空"); + return result; + } + + if (amount <= 0) { + log.warn("❌ 参数验证失败: 提现金额必须大于0,当前: {}", amount); + result.put("success", false); + result.put("message", "提现金额必须大于0"); + return result; + } + log.info("✅ 参数验证通过"); + + // 生成批次号和明细号(只包含数字和字母) + String outBatchNo = generateValidBatchNo("WITHDRAW"); + String detailNo = generateValidBatchNo("DETAIL"); + log.info("🆔 生成业务单号"); + log.info(" ├─ 批次号: {}", outBatchNo); + log.info(" └─ 明细号: {}", detailNo); + + // 构建转账明细 + log.info("🔧 开始构建转账明细"); + Map detail = new HashMap<>(); + detail.put("out_detail_no", detailNo); + detail.put("transfer_amount", amount); + detail.put("transfer_remark", desc != null ? desc : "用户提现"); + detail.put("openid", openid); + + // 如果提供了真实姓名,进行RSA加密 + if (userName != null && !userName.trim().isEmpty()) { + log.info("🔒 开始加密用户姓名"); + String encryptedUserName = encryptSensitiveField(userName); + detail.put("user_name", encryptedUserName); + log.info(" └─ 已添加加密后的实名校验信息"); + } else { + log.info(" └─ 未提供用户姓名,跳过实名校验"); + } + + List> detailList = new ArrayList<>(); + detailList.add(detail); + + // 构建请求参数 + log.info("🔧 开始构建请求参数"); + Map params = new HashMap<>(); + params.put("appid", wechatConfig().getAppid()); + params.put("out_batch_no", outBatchNo); + params.put("batch_name", "用户提现"); + params.put("batch_remark", "用户申请提现到零钱"); + params.put("total_amount", amount); + params.put("total_num", 1); + params.put("transfer_detail_list", detailList); + + String bodyJson = JSONObject.toJSONString(params); + log.info("✅ 请求参数构建完成"); + log.debug("🔍 请求体JSON: {}", bodyJson); + + // 构建请求头 + HttpHeaders headers = buildCommonHeaders("POST", TRANSFER_BATCH_URL, bodyJson); + + // 发送请求 + String fullUrl = V3_BASE_URL + TRANSFER_BATCH_URL; + log.info("🚀 开始发送提现请求"); + log.info(" ├─ 请求方法: POST"); + log.info(" ├─ 请求地址: {}", fullUrl); + log.info(" └─ 请求体大小: {} 字节", bodyJson.getBytes(StandardCharsets.UTF_8).length); + + HttpEntity requestEntity = new HttpEntity<>(bodyJson, headers); + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.POST, + requestEntity, + String.class + ); + + // 处理响应 + log.info("📥 收到提现响应"); + log.info(" ├─ 响应状态码: {}", response.getStatusCode()); + log.info(" └─ 响应体大小: {} 字节", + response.getBody() != null ? response.getBody().getBytes(StandardCharsets.UTF_8).length : 0); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + JSONObject jsonResponse = JSONObject.parseObject(responseBody); + + log.info("🔓 开始解密敏感字段"); + // 解密敏感字段(如果有) + decryptSensitiveFields(jsonResponse); + + // 提取关键信息 + Map transferInfo = new HashMap<>(); + transferInfo.put("out_batch_no", outBatchNo); + transferInfo.put("batch_id", jsonResponse.getString("batch_id")); + transferInfo.put("create_time", jsonResponse.getString("create_time")); + transferInfo.put("batch_status", jsonResponse.getString("batch_status")); + + log.info("✅ 提现申请成功"); + log.info("🔍 提现详情:"); + log.info(" ├─ 微信批次ID: {}", jsonResponse.getString("batch_id")); + log.info(" ├─ 批次状态: {}", jsonResponse.getString("batch_status")); + log.info(" ├─ 创建时间: {}", jsonResponse.getString("create_time")); + log.info(" └─ 商户批次号: {}", outBatchNo); + log.debug("🔍 完整响应: {}", responseBody); + + result.put("success", true); + result.put("data", jsonResponse); + result.put("transfer_info", transferInfo); + result.put("message", "提现申请成功"); + } else { + log.error("❌ 提现申请失败"); + log.error(" ├─ 状态码: {}", response.getStatusCode()); + log.error(" └─ 错误响应: {}", response.getBody()); + + result.put("success", false); + result.put("message", "提现申请失败,状态码:" + response.getStatusCode()); + result.put("error", response.getBody()); + } + + } catch (Exception e) { + log.error("❌ 提现申请异常: {}", e.getMessage(), e); + result.put("success", false); + result.put("message", "提现异常:" + e.getMessage()); + } + + log.info("🏁 提现申请流程结束,结果: {}", result.get("success")); + return result; + } + + /** + * 查询转账批次 + * + * @param outBatchNo 商户批次单号 + * @param needDetail 是否需要转账明细 + * @return 转账查询结果 + */ + public Map queryTransfer(String outBatchNo, boolean needDetail) { + log.info("🔍 开始查询转账"); + log.info(" ├─ 批次单号: {}", outBatchNo); + log.info(" └─ 是否需要明细: {}", needDetail); + + Map result = new HashMap<>(); + + try { + // 参数验证 + log.info("📋 开始参数验证"); + if (outBatchNo == null || outBatchNo.trim().isEmpty()) { + log.warn("❌ 参数验证失败: 批次单号不能为空"); + result.put("success", false); + result.put("message", "批次单号不能为空"); + return result; + } + log.info("✅ 参数验证通过"); + + String url = QUERY_TRANSFER_URL + outBatchNo; + if (needDetail) { + url += "?need_query_detail=true&detail_status=ALL"; + log.info("📝 已添加查询明细参数"); + } + String fullUrl = V3_BASE_URL + url; + + // 构建请求头 + HttpHeaders headers = buildCommonHeaders("GET", url, ""); + + // 发送请求 + log.info("🚀 开始发送查询请求"); + log.info(" ├─ 请求方法: GET"); + log.info(" └─ 请求地址: {}", fullUrl); + + HttpEntity requestEntity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.GET, + requestEntity, + String.class + ); + + // 处理响应 + log.info("📥 收到查询响应"); + log.info(" ├─ 响应状态码: {}", response.getStatusCode()); + log.info(" └─ 响应体大小: {} 字节", + response.getBody() != null ? response.getBody().getBytes(StandardCharsets.UTF_8).length : 0); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + JSONObject jsonResponse = JSONObject.parseObject(responseBody); + + log.info("🔓 开始解密敏感字段"); + // 解密敏感字段(如果有) + decryptSensitiveFields(jsonResponse); + + log.info("✅ 转账查询成功"); + log.info("🔍 查询结果:"); + if (jsonResponse != null) { + log.info(" ├─ 批次状态: {}", jsonResponse.getString("batch_status")); + log.info(" ├─ 转账总金额: {} 分", jsonResponse.getInteger("total_amount")); + log.info(" ├─ 转账总笔数: {}", jsonResponse.getInteger("total_num")); + log.info(" └─ 创建时间: {}", jsonResponse.getString("create_time")); + } + log.debug("🔍 完整响应: {}", responseBody); + + result.put("success", true); + result.put("data", jsonResponse); + result.put("message", "查询转账成功"); + } else { + log.error("❌ 转账查询失败"); + log.error(" ├─ 状态码: {}", response.getStatusCode()); + log.error(" └─ 错误响应: {}", response.getBody()); + + result.put("success", false); + result.put("message", "查询转账失败,状态码:" + response.getStatusCode()); + result.put("error", response.getBody()); + } + + } catch (Exception e) { + log.error("❌ 转账查询异常: {}", e.getMessage(), e); + result.put("success", false); + result.put("message", "查询转账异常:" + e.getMessage()); + } + + log.info("🏁 转账查询流程结束,结果: {}", result.get("success")); + return result; + } + + // ============================== 工具方法 ============================== + + /** + * 解密响应中的敏感字段 + */ + private void decryptSensitiveFields(JSONObject jsonResponse) { + if (jsonResponse == null) { + log.debug("🔓 无需解密:响应为空"); + return; + } + + log.info("🔓 开始递归解密敏感字段"); + int decryptedCount = decryptObjectFields(jsonResponse); + log.info("✅ 敏感字段解密完成,共解密 {} 个字段", decryptedCount); + } + + private int decryptObjectFields(JSONObject obj) { + if (obj == null) return 0; + + int decryptedCount = 0; + for (String key : obj.keySet()) { + Object value = obj.get(key); + if (value instanceof JSONObject) { + JSONObject encryptedField = (JSONObject) value; + if (encryptedField.containsKey("algorithm") && + encryptedField.containsKey("ciphertext") && + encryptedField.containsKey("nonce")) { + + // 这是一个加密字段,进行解密 + log.info("🔍 发现加密字段: {}", key); + String algorithm = encryptedField.getString("algorithm"); + String ciphertext = encryptedField.getString("ciphertext"); + String nonce = encryptedField.getString("nonce"); + String associatedData = encryptedField.getString("associated_data"); + + if ("AEAD_AES_256_GCM".equals(algorithm)) { + String decryptedValue = decryptAesGcm(associatedData, nonce, ciphertext); + obj.put(key, decryptedValue); + decryptedCount++; + log.info("✅ 字段 {} 解密成功", key); + } else { + log.warn("⚠️ 不支持的加密算法: {}", algorithm); + } + } else { + // 递归处理嵌套对象 + decryptedCount += decryptObjectFields(encryptedField); + } + } + } + return decryptedCount; + } + + /** + * RSA加密敏感字段 + * 使用微信支付平台证书的公钥加密敏感信息 + * + * @param plaintext 明文 + * @return 加密后的密文(Base64编码) + */ + private String encryptSensitiveField(String plaintext) { + if (plaintext == null || plaintext.trim().isEmpty()) { + return null; + } + + log.info("🔒 开始RSA加密敏感字段"); + log.info(" └─ 明文长度: {} 字符", plaintext.length()); + + try { + // 获取微信支付平台证书的公钥 + PublicKey publicKey = getWechatpayPublicKey(); + + // 使用RSA/ECB/OAEPWithSHA-1AndMGF1Padding进行加密 + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + + byte[] encryptedBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + String encryptedText = Base64.getEncoder().encodeToString(encryptedBytes); + + log.info("✅ RSA加密成功,密文长度: {} 字符", encryptedText.length()); + return encryptedText; + } catch (Exception e) { + log.error("❌ RSA加密失败: {}", e.getMessage(), e); + throw new RuntimeException("RSA加密失败: " + e.getMessage(), e); + } + } + + /** + * 获取微信支付平台证书的公钥 + * 用于加密敏感字段 + * + * @return 微信支付平台证书的公钥 + */ + private PublicKey getWechatpayPublicKey() { + log.info("🔐 开始获取微信支付平台证书公钥"); + + try { + // 尝试从本地证书文件中读取 + String certDir = wechatConfig().getCertDir(); + String wechatpaySerial = getWechatpayPlatformSerial(); + + if (wechatpaySerial != null && certDir != null) { + String certFileName = "wechatpay_" + wechatpaySerial + ".pem"; + String certPath = certDir + "/" + certFileName; + + log.info(" ├─ 尝试加载证书文件: {}", certPath); + + try (InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(certPath)) { + if (inputStream != null) { + String certContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + PublicKey publicKey = parseCertificatePublicKey(certContent); + log.info("✅ 从本地证书文件获取公钥成功"); + return publicKey; + } + } catch (Exception e) { + log.warn("⚠️ 从本地证书文件获取公钥失败: {}", e.getMessage()); + } + } + + // 如果本地证书文件不存在,临时使用商户证书的公钥(仅用于测试) + log.warn("⚠️ 未找到微信支付平台证书,临时使用商户证书公钥(仅测试用)"); + return getMerchantPublicKey(); + + } catch (Exception e) { + log.error("❌ 获取微信支付平台证书公钥失败: {}", e.getMessage(), e); + throw new RuntimeException("获取微信支付平台证书公钥失败: " + e.getMessage(), e); + } + } + + /** + * 从证书内容中解析公钥 + * + * @param certContent 证书内容(PEM格式) + * @return 公钥 + */ + private PublicKey parseCertificatePublicKey(String certContent) throws Exception { + // 清理证书内容 + String cleanCert = certContent + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s+", ""); + + byte[] certBytes = Base64.getDecoder().decode(cleanCert); + + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate = (X509Certificate) certFactory.generateCertificate( + new ByteArrayInputStream(certBytes)); + + return certificate.getPublicKey(); + } + + /** + * 获取商户证书的公钥(临时方案) + * + * @return 商户证书的公钥 + */ + private PublicKey getMerchantPublicKey() throws Exception { + // 这里可以从商户证书文件中读取公钥 + // 临时实现:从私钥生成对应的公钥(仅用于测试) + PrivateKey privateKey = getPrivateKey(); + + // 注意:这只是临时方案,实际应该使用微信支付平台证书的公钥 + // 这里我们需要从商户证书文件中读取公钥 + String certPath = wechatConfig().getCertDir() + "/apiclient_cert.pem"; + + try (InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(certPath)) { + if (inputStream != null) { + String certContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + return parseCertificatePublicKey(certContent); + } + } catch (Exception e) { + log.warn("⚠️ 读取商户证书失败: {}", e.getMessage()); + } + + // 如果都失败了,抛出异常 + throw new RuntimeException("无法获取公钥,请确保微信支付平台证书或商户证书文件存在"); + } + + /** + * 快速退款(简化参数) + * + * @param orderNo 商户订单号 + * @param refundFee 退款金额(元,会自动转换为分) + * @param reason 退款原因 + * @return 退款结果 + */ + public Map quickRefund(String orderNo, BigDecimal refundFee, String reason) { + log.info("⚡ 开始快速退款"); + log.info(" ├─ 订单号: {}", orderNo); + log.info(" ├─ 退款金额: {}元", refundFee); + log.info(" └─ 退款原因: {}", reason); + + // 生成退款单号(符合微信支付V3规范) + String refundNo = generateValidRefundNo("REFUND"); + log.info("🆔 生成退款单号: {}", refundNo); + + // 这里需要查询原订单金额,简化处理假设退款金额就是订单金额 + int refundFeeInt = refundFee.multiply(new BigDecimal(100)).intValue(); + int totalFeeInt = refundFeeInt; // 实际应用中需要查询原订单金额 + + log.warn("⚠️ 注意:当前使用简化处理,订单总金额 = 退款金额"); + log.info("💰 金额转换: {}元 = {}分", refundFee, refundFeeInt); + + return refund(orderNo, refundNo, totalFeeInt, refundFeeInt, reason, null); + } + + /** + * 生成符合微信支付V3规范的批次号 + * 规则:只能包含数字和字母,长度不超过32位 + * + * @param prefix 前缀 + * @return 批次号 + */ + private String generateValidBatchNo(String prefix) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); + String timestamp = sdf.format(new Date()); + String random = String.valueOf((int) (Math.random() * 10000)); + + // 确保只包含数字和字母 + String batchNo = prefix + timestamp + random; + + // 限制长度不超过32位 + if (batchNo.length() > 32) { + batchNo = batchNo.substring(0, 32); + } + + log.debug("🆔 生成批次号: {} (长度: {})", batchNo, batchNo.length()); + return batchNo; + } + + /** + * 生成符合微信支付V3规范的退款单号 + * 规则:只能包含数字和字母,长度不超过64位 + * + * @param prefix 前缀 + * @return 退款单号 + */ + private String generateValidRefundNo(String prefix) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS"); + String timestamp = sdf.format(new Date()); + String random = String.valueOf((int) (Math.random() * 10000)); + + // 确保只包含数字和字母 + String refundNo = prefix + timestamp + random; + + // 限制长度不超过64位 + if (refundNo.length() > 64) { + refundNo = refundNo.substring(0, 64); + } + + log.debug("🆔 生成退款单号: {} (长度: {})", refundNo, refundNo.length()); + return refundNo; + } + + /** + * 快速提现(简化参数) + * + * @param openid 用户openid + * @param amount 金额(元,会自动转换为分) + * @param desc 提现描述 + * @return 提现结果 + */ + public Map quickWithdraw(String openid, BigDecimal amount, String desc) { + log.info("⚡ 开始快速提现"); + log.info(" ├─ 用户OpenID: {}", maskSensitiveData(openid)); + log.info(" ├─ 提现金额: {}元", amount); + log.info(" └─ 提现描述: {}", desc); + + int amountInt = amount.multiply(new BigDecimal(100)).intValue(); + log.info("💰 金额转换: {}元 = {}分", amount, amountInt); + + return withdraw(openid, amountInt, desc, null); + } + + /** + * 简化提现(不加密敏感字段,用于测试) + * * @param openid 用户openid * @param amount 金额(分) * @param desc 提现描述 * @return 提现结果 */ - public Map withdraw(String openid, int amount, String desc) { + public Map withdrawSimple(String openid, int amount, String desc) { + log.info("💰 开始简化提现(不加密敏感字段)"); + log.info(" ├─ 用户OpenID: {}", maskSensitiveData(openid)); + log.info(" ├─ 提现金额: {} 分 ({}元)", amount, fenToYuan(amount)); + log.info(" └─ 提现描述: {}", desc); + Map result = new HashMap<>(); + try { - String appid = wechatConfig().getAppid(); - String mchid = wechatConfig().getMchid(); - // TODO: 你需要在WechatConfig中补充apiv3Key、privateKeyPath、serialNo等配置 - String outBatchNo = "BATCH" + System.currentTimeMillis(); - String batchName = "商户提现"; - String batchRemark = desc; - String detailNo = "DETAIL" + System.currentTimeMillis(); + // 参数验证 + log.info("📋 开始参数验证"); + if (openid == null || openid.trim().isEmpty()) { + log.warn("❌ 参数验证失败: 用户openid不能为空"); + result.put("success", false); + result.put("message", "用户openid不能为空"); + return result; + } + + if (amount <= 0) { + log.warn("❌ 参数验证失败: 提现金额必须大于0,当前: {}", amount); + result.put("success", false); + result.put("message", "提现金额必须大于0"); + return result; + } + log.info("✅ 参数验证通过"); + + // 生成批次号和明细号(只包含数字和字母) + String outBatchNo = generateValidBatchNo("WITHDRAW"); + String detailNo = generateValidBatchNo("DETAIL"); + log.info("🆔 生成业务单号"); + log.info(" ├─ 批次号: {}", outBatchNo); + log.info(" └─ 明细号: {}", detailNo); + + // 构建转账明细(不加密) + log.info("🔧 开始构建转账明细(简化版)"); Map detail = new HashMap<>(); detail.put("out_detail_no", detailNo); detail.put("transfer_amount", amount); - detail.put("transfer_remark", desc); + detail.put("transfer_remark", desc != null ? desc : "用户提现"); detail.put("openid", openid); + // 注意:简化版不包含user_name字段,避免加密问题 + List> detailList = new ArrayList<>(); detailList.add(detail); + + // 构建请求参数 + log.info("🔧 开始构建请求参数"); Map params = new HashMap<>(); - params.put("appid", appid); + params.put("appid", wechatConfig().getAppid()); params.put("out_batch_no", outBatchNo); - params.put("batch_name", batchName); - params.put("batch_remark", batchRemark); + params.put("batch_name", "用户提现"); + params.put("batch_remark", "用户申请提现到零钱"); params.put("total_amount", amount); params.put("total_num", 1); params.put("transfer_detail_list", detailList); - String bodyJson = new JSONObject(params).toJSONString(); - // 生成v3签名串,构造Authorization头 - // TODO: 你需要实现v3签名算法,生成Authorization头 - String authorization = "请实现v3签名算法,生成Authorization头"; + String bodyJson = JSONObject.toJSONString(params); + log.info("✅ 请求参数构建完成"); + log.debug("🔍 请求体JSON: {}", bodyJson); + + // 构建请求头 + HttpHeaders headers = buildCommonHeaders("POST", TRANSFER_BATCH_URL, bodyJson); + + // 发送请求 + String fullUrl = V3_BASE_URL + TRANSFER_BATCH_URL; + log.info("🚀 开始发送提现请求"); + log.info(" ├─ 请求方法: POST"); + log.info(" ├─ 请求地址: {}", fullUrl); + log.info(" └─ 请求体大小: {} 字节", bodyJson.getBytes(StandardCharsets.UTF_8).length); + + HttpEntity requestEntity = new HttpEntity<>(bodyJson, headers); + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.POST, + requestEntity, + String.class + ); + + // 处理响应 + log.info("📥 收到提现响应"); + log.info(" ├─ 响应状态码: {}", response.getStatusCode()); + log.info(" └─ 响应体大小: {} 字节", + response.getBody() != null ? response.getBody().getBytes(StandardCharsets.UTF_8).length : 0); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + JSONObject jsonResponse = JSONObject.parseObject(responseBody); + + log.info("🔓 开始解密敏感字段"); + // 解密敏感字段(如果有) + decryptSensitiveFields(jsonResponse); + + // 提取关键信息 + Map transferInfo = new HashMap<>(); + transferInfo.put("out_batch_no", outBatchNo); + transferInfo.put("batch_id", jsonResponse.getString("batch_id")); + transferInfo.put("create_time", jsonResponse.getString("create_time")); + transferInfo.put("batch_status", jsonResponse.getString("batch_status")); + + log.info("✅ 简化提现申请成功"); + log.info("🔍 提现详情:"); + log.info(" ├─ 微信批次ID: {}", jsonResponse.getString("batch_id")); + log.info(" ├─ 批次状态: {}", jsonResponse.getString("batch_status")); + log.info(" ├─ 创建时间: {}", jsonResponse.getString("create_time")); + log.info(" └─ 商户批次号: {}", outBatchNo); + log.debug("🔍 完整响应: {}", responseBody); + + result.put("success", true); + result.put("data", jsonResponse); + result.put("transfer_info", transferInfo); + result.put("message", "简化提现申请成功"); + } else { + log.error("❌ 简化提现申请失败"); + log.error(" ├─ 状态码: {}", response.getStatusCode()); + log.error(" └─ 错误响应: {}", response.getBody()); + + result.put("success", false); + result.put("message", "简化提现申请失败,状态码:" + response.getStatusCode()); + result.put("error", response.getBody()); + } + + } catch (Exception e) { + log.error("❌ 简化提现申请异常: {}", e.getMessage(), e); + result.put("success", false); + result.put("message", "简化提现异常:" + e.getMessage()); + } + + log.info("🏁 简化提现申请流程结束,结果: {}", result.get("success")); + return result; + } - String v3Url = "https://api.mch.weixin.qq.com/v3/transfer/batches"; + /** + * 格式化金额(元转分) + */ + public static int yuanToFen(BigDecimal yuan) { + int fen = yuan.multiply(new BigDecimal(100)).intValue(); + log.debug("💰 金额转换: {}元 = {}分", yuan, fen); + return fen; + } + + /** + * 格式化金额(分转元) + */ + public static BigDecimal fenToYuan(int fen) { + BigDecimal yuan = new BigDecimal(fen).divide(new BigDecimal(100), 2, BigDecimal.ROUND_HALF_UP); + log.debug("💰 金额转换: {}分 = {}元", fen, yuan); + return yuan; + } + + /** + * 生成订单号 + */ + public static String generateOrderNo(String prefix) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); + String timestamp = sdf.format(new Date()); + String random = String.valueOf((int) (Math.random() * 10000)); + String orderNo = prefix + timestamp + random; + log.debug("🆔 生成订单号: {}", orderNo); + return orderNo; + } + + /** + * 获取微信支付平台证书 + * 通过API自动获取微信支付平台证书和序列号 + * + * @return 平台证书信息 + */ + public Map getCertificates() { + log.info("📜 开始获取微信支付平台证书"); + Map result = new HashMap<>(); + + try { + String url = "/v3/certificates"; + String fullUrl = V3_BASE_URL + url; + + // 构建请求头(不包含Wechatpay-Serial,因为这是获取证书的接口) HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", authorization); + headers.set("Authorization", buildAuthorizationHeader("GET", url, "")); + headers.set("Accept", "application/json"); + headers.set("User-Agent", "RuoYi-WechatPay-V3"); + + log.info("🚀 开始请求平台证书"); + log.info(" ├─ 请求方法: GET"); + log.info(" └─ 请求地址: {}", fullUrl); + + HttpEntity requestEntity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.GET, + requestEntity, + String.class + ); + + log.info("📥 收到证书响应"); + log.info(" ├─ 响应状态码: {}", response.getStatusCode()); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + JSONObject jsonResponse = JSONObject.parseObject(responseBody); + + log.info("✅ 获取平台证书成功"); + + // 解析证书列表 + if (jsonResponse.containsKey("data")) { + Object dataObj = jsonResponse.get("data"); + if (dataObj instanceof List) { + @SuppressWarnings("unchecked") + List certList = (List) dataObj; + + log.info("🔍 找到 {} 个平台证书", certList.size()); + + for (int i = 0; i < certList.size(); i++) { + if (certList.get(i) instanceof Map) { + @SuppressWarnings("unchecked") + Map cert = (Map) certList.get(i); + + String serialNo = (String) cert.get("serial_no"); + String effectiveTime = (String) cert.get("effective_time"); + String expireTime = (String) cert.get("expire_time"); + + log.info(" 证书 {}: 序列号={}, 生效时间={}, 过期时间={}", + i + 1, + maskSensitiveData(serialNo), + effectiveTime, + expireTime); + + // 返回第一个有效证书的序列号 + if (i == 0) { + result.put("platform_serial", serialNo); + result.put("effective_time", effectiveTime); + result.put("expire_time", expireTime); + } + } + } + } + } + + result.put("success", true); + result.put("data", jsonResponse); + result.put("message", "获取平台证书成功"); + + // 如果获取到了平台证书序列号,给出配置建议 + if (result.containsKey("platform_serial")) { + String platformSerial = (String) result.get("platform_serial"); + log.info("💡 建议配置:"); + log.info(" 在application.yml中设置:"); + log.info(" wechat:"); + log.info(" wechatpay-serial: {}", platformSerial); + } + + } else { + log.error("❌ 获取平台证书失败"); + log.error(" ├─ 状态码: {}", response.getStatusCode()); + log.error(" └─ 错误响应: {}", response.getBody()); + + result.put("success", false); + result.put("message", "获取平台证书失败,状态码:" + response.getStatusCode()); + result.put("error", response.getBody()); + } + + } catch (Exception e) { + log.error("❌ 获取平台证书异常: {}", e.getMessage(), e); + result.put("success", false); + result.put("message", "获取平台证书异常:" + e.getMessage()); + } + + log.info("🏁 获取平台证书流程结束,结果: {}", result.get("success")); + return result; + } + + /** + * 无Wechatpay-Serial头的提现方法(解决证书序列号错误问题) + * + * @param openid 用户openid + * @param amount 金额(分) + * @param desc 提现描述 + * @return 提现结果 + */ + public Map withdrawWithoutSerial(String openid, int amount, String desc) { + log.info("🧪 开始无序列号提现测试"); + log.info(" ├─ 用户OpenID: {}", maskSensitiveData(openid)); + log.info(" ├─ 提现金额: {} 分 ({}元)", amount, fenToYuan(amount)); + log.info(" └─ 提现描述: {}", desc); + + Map result = new HashMap<>(); + + try { + // 参数验证 + log.info("📋 开始参数验证"); + if (openid == null || openid.trim().isEmpty()) { + log.warn("❌ 参数验证失败: 用户openid不能为空"); + result.put("success", false); + result.put("message", "用户openid不能为空"); + return result; + } + + if (amount <= 0) { + log.warn("❌ 参数验证失败: 提现金额必须大于0,当前: {}", amount); + result.put("success", false); + result.put("message", "提现金额必须大于0"); + return result; + } + log.info("✅ 参数验证通过"); + + // 生成批次号和明细号 + String outBatchNo = generateValidBatchNo("WITHDRAW"); + String detailNo = generateValidBatchNo("DETAIL"); + log.info("🆔 生成业务单号"); + log.info(" ├─ 批次号: {}", outBatchNo); + log.info(" └─ 明细号: {}", detailNo); + + // 构建转账明细 + log.info("🔧 开始构建转账明细"); + Map detail = new HashMap<>(); + detail.put("out_detail_no", detailNo); + detail.put("transfer_amount", amount); + detail.put("transfer_remark", desc != null ? desc : "用户提现"); + detail.put("openid", openid); + + List> detailList = new ArrayList<>(); + detailList.add(detail); + + // 构建请求参数 + log.info("🔧 开始构建请求参数"); + Map params = new HashMap<>(); + params.put("appid", wechatConfig().getAppid()); + params.put("out_batch_no", outBatchNo); + params.put("batch_name", "用户提现"); + params.put("batch_remark", "用户申请提现到零钱"); + params.put("total_amount", amount); + params.put("total_num", 1); + params.put("transfer_detail_list", detailList); + + String bodyJson = JSONObject.toJSONString(params); + log.info("✅ 请求参数构建完成"); + log.debug("🔍 请求体JSON: {}", bodyJson); + + // 构建请求头(不包含Wechatpay-Serial) + log.info("📤 开始构建请求头(不包含Wechatpay-Serial)"); + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", buildAuthorizationHeader("POST", TRANSFER_BATCH_URL, bodyJson)); headers.set("Content-Type", "application/json"); headers.set("Accept", "application/json"); + headers.set("User-Agent", "RuoYi-WechatPay-V3"); + log.info("✅ 请求头构建完成(已跳过Wechatpay-Serial)"); + + // 发送请求 + String fullUrl = V3_BASE_URL + TRANSFER_BATCH_URL; + log.info("🚀 开始发送提现请求"); + log.info(" ├─ 请求方法: POST"); + log.info(" ├─ 请求地址: {}", fullUrl); + log.info(" └─ 请求体大小: {} 字节", bodyJson.getBytes(StandardCharsets.UTF_8).length); + HttpEntity requestEntity = new HttpEntity<>(bodyJson, headers); - ResponseEntity response = restTemplate.exchange(v3Url, HttpMethod.POST, requestEntity, String.class); - - // 解析响应 - // TODO: 你需要根据微信v3返回结构处理,部分字段加密需用apiv3Key解密 - result.put("success", true); - result.put("response", response.getBody()); - result.put("message", "v3提现接口请求已发送,请根据返回内容进一步处理"); + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.POST, + requestEntity, + String.class + ); + + // 处理响应 + log.info("📥 收到提现响应"); + log.info(" ├─ 响应状态码: {}", response.getStatusCode()); + log.info(" └─ 响应体大小: {} 字节", + response.getBody() != null ? response.getBody().getBytes(StandardCharsets.UTF_8).length : 0); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + JSONObject jsonResponse = JSONObject.parseObject(responseBody); + + result.put("success", true); + result.put("data", jsonResponse); + result.put("message", "无序列号提现测试成功"); + + log.info("✅ 无序列号提现测试成功"); + } else { + log.error("❌ 无序列号提现测试失败"); + log.error(" ├─ 状态码: {}", response.getStatusCode()); + log.error(" └─ 错误响应: {}", response.getBody()); + + result.put("success", false); + result.put("message", "无序列号提现测试失败,状态码:" + response.getStatusCode()); + result.put("error", response.getBody()); + } + } catch (Exception e) { + log.error("❌ 无序列号提现测试异常: {}", e.getMessage(), e); result.put("success", false); - result.put("message", "v3提现异常:" + e.getMessage()); + result.put("message", "无序列号提现测试异常:" + e.getMessage()); } + + log.info("🏁 无序列号提现测试流程结束,结果: {}", result.get("success")); return result; } } \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/WechatCertificateService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/WechatCertificateService.java new file mode 100644 index 0000000..86c3c99 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/WechatCertificateService.java @@ -0,0 +1,167 @@ +package com.ruoyi.system.service; + +import com.ruoyi.system.ControllerUtil.WechatCertificateUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.List; + +/** + * 微信支付证书管理服务 + * + * @author Mr. Zhang Pan + * @version 1.0 + * @date 2025-01-17 + */ +@Service +public class WechatCertificateService { + + private static final Logger log = LoggerFactory.getLogger(WechatCertificateService.class); + + @Autowired + private WechatCertificateUtil wechatCertificateUtil; + + /** + * 自动获取并更新微信支付平台证书序列号到配置文件 + * + * @return 是否更新成功 + */ + public boolean autoUpdatePlatformCertificateSerial() { + log.info("🔄 开始自动更新微信支付平台证书序列号"); + + try { + // 获取平台证书序列号 + String platformSerial = wechatCertificateUtil.getPlatformCertificateSerial(); + + if (platformSerial == null || platformSerial.trim().isEmpty()) { + log.error("❌ 无法获取微信支付平台证书序列号"); + return false; + } + + // 更新配置文件 + boolean updated = updateConfigFile(platformSerial); + + if (updated) { + log.info("✅ 微信支付平台证书序列号更新成功: {}", maskSensitiveData(platformSerial)); + return true; + } else { + log.error("❌ 更新配置文件失败"); + return false; + } + + } catch (Exception e) { + log.error("❌ 自动更新微信支付平台证书序列号异常: {}", e.getMessage(), e); + return false; + } + } + + /** + * 更新配置文件中的微信支付平台证书序列号 + * + * @param platformSerial 平台证书序列号 + * @return 是否更新成功 + */ + private boolean updateConfigFile(String platformSerial) { + try { + // 配置文件路径 + Path configPath = Paths.get("ruoyi-admin/src/main/resources/application.yml"); + + if (!Files.exists(configPath)) { + log.error("❌ 配置文件不存在: {}", configPath); + return false; + } + + // 读取配置文件 + List lines = Files.readAllLines(configPath); + boolean updated = false; + + // 查找并更新配置 + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + + // 查找注释掉的wechatpay-serial配置 + if (line.trim().startsWith("# wechatpay-serial:")) { + // 替换为有效配置 + String indent = getIndentation(line); + lines.set(i, indent + "wechatpay-serial: " + platformSerial); + updated = true; + log.info("✅ 更新配置行: {}", lines.get(i)); + break; + } + + // 查找已存在的wechatpay-serial配置 + if (line.trim().startsWith("wechatpay-serial:")) { + // 更新现有配置 + String indent = getIndentation(line); + lines.set(i, indent + "wechatpay-serial: " + platformSerial); + updated = true; + log.info("✅ 更新配置行: {}", lines.get(i)); + break; + } + } + + // 如果没有找到配置,则添加新配置 + if (!updated) { + // 查找wechat配置块,在其中添加新配置 + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.trim().startsWith("cert-dir:")) { + // 在cert-dir后面添加新配置 + String indent = getIndentation(line); + lines.add(i + 1, indent + "# 微信支付平台证书序列号(自动获取)"); + lines.add(i + 2, indent + "wechatpay-serial: " + platformSerial); + updated = true; + log.info("✅ 添加新配置: wechatpay-serial: {}", platformSerial); + break; + } + } + } + + if (updated) { + // 写回文件 + Files.write(configPath, lines, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + log.info("✅ 配置文件更新成功: {}", configPath); + return true; + } else { + log.warn("⚠️ 未找到合适的位置更新配置"); + return false; + } + + } catch (IOException e) { + log.error("❌ 更新配置文件异常: {}", e.getMessage(), e); + return false; + } + } + + /** + * 获取行的缩进 + */ + private String getIndentation(String line) { + int indent = 0; + for (char c : line.toCharArray()) { + if (c == ' ') { + indent++; + } else { + break; + } + } + return " ".repeat(indent); + } + + /** + * 脱敏显示敏感数据 + */ + private String maskSensitiveData(String data) { + if (data == null || data.length() <= 6) { + return "****"; + } + return data.substring(0, 3) + "****" + data.substring(data.length() - 3); + } +} \ No newline at end of file diff --git a/ruoyi-system/src/main/resources/mapper/system/UserDemandQuotationMapper.xml b/ruoyi-system/src/main/resources/mapper/system/UserDemandQuotationMapper.xml index 6159165..ee26aff 100644 --- a/ruoyi-system/src/main/resources/mapper/system/UserDemandQuotationMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/UserDemandQuotationMapper.xml @@ -49,8 +49,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" status, workerimage, quotation_time, - created_at, - updated_at, + created_at, + updated_at #{id}, @@ -61,8 +61,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{status}, #{workerimage}, #{quotationTime}, - #{createdAt}, - #{updatedAt}, + NOW(), + NOW() @@ -76,8 +76,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" status = #{status}, workerimage = #{workerimage}, quotation_time = #{quotationTime}, - created_at = #{createdAt}, - updated_at = #{updatedAt}, + updated_at = NOW(), where id = #{id} diff --git a/ruoyi-system/src/main/resources/mapper/system/UserGroupBuyingMapper.xml b/ruoyi-system/src/main/resources/mapper/system/UserGroupBuyingMapper.xml index a383383..2841f5f 100644 --- a/ruoyi-system/src/main/resources/mapper/system/UserGroupBuyingMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/UserGroupBuyingMapper.xml @@ -53,8 +53,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" transaction_id, paytype, product_id, - created_at, - updated_at, + created_at, + updated_at #{id}, @@ -68,8 +68,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{transactionId}, #{paytype}, #{productId}, - #{createdAt}, - #{updatedAt}, + NOW(), + NOW() @@ -86,8 +86,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" transaction_id = #{transactionId}, paytype = #{paytype}, product_id = #{productId}, - created_at = #{createdAt}, - updated_at = #{updatedAt}, + updated_at = NOW(), where id = #{id} diff --git a/ruoyi-system/src/main/resources/mapper/system/UsersInvoiceInfoMapper.xml b/ruoyi-system/src/main/resources/mapper/system/UsersInvoiceInfoMapper.xml index 6f46315..471de72 100644 --- a/ruoyi-system/src/main/resources/mapper/system/UsersInvoiceInfoMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/UsersInvoiceInfoMapper.xml @@ -34,12 +34,12 @@ and uid = #{uid} - and invoice_title = #{invoiceTitle} + and invoice_title like concat('%', #{invoiceTitle}, '%') and tax_number = #{taxNumber} and bank_name like concat('%', #{bankName}, '%') and bank_account = #{bankAccount} and address = #{address} - and phone = #{phone} + and phone like concat('%', #{phone}, '%') and email = #{email} and wechat = #{wechat} and type = #{type} @@ -48,7 +48,7 @@ and updated_at = #{updatedAt} and invoicemoney = #{invoicemoney} and invoicetext = #{invoicetext} - and orderid = #{orderid} + and orderid like concat('%', #{orderid}, '%') and status = #{status} and filedata = #{filedata} diff --git a/ruoyi-ui/src/api/system/program.js b/ruoyi-ui/src/api/system/program.js index d784935..c501d3b 100644 --- a/ruoyi-ui/src/api/system/program.js +++ b/ruoyi-ui/src/api/system/program.js @@ -17,6 +17,18 @@ export function getProgram(id) { }) } +// 查询充值类目详细 +export function getconfigconfigone() { + return request({ + url: '/api/public/config/config_one', + method: 'get' + }) +} + + + + + // 新增充值类目 export function addProgram(data) { return request({ diff --git a/ruoyi-ui/src/api/system/users.js b/ruoyi-ui/src/api/system/users.js index b0a11c0..4740976 100644 --- a/ruoyi-ui/src/api/system/users.js +++ b/ruoyi-ui/src/api/system/users.js @@ -9,6 +9,8 @@ export function listUsers(query) { }) } + + // 查询用户列表详细 export function getUsers(id) { return request({ @@ -53,3 +55,21 @@ export function delUsers(id) { method: 'delete' }) } + +// 新增:获取用户消费金/服务金变动记录 +export function getBenefitLogs(userId, type, params) { + return request({ + url: `/system/users/benefitLogs/${userId}/${type}`, + method: 'get', + params + }) +} + +// 获取用户余额变动记录 +export function getBalanceLogs(userId, params) { + return request({ + url: `/system/users/balanceLogs/${userId}`, + method: 'get', + params + }) +} diff --git a/ruoyi-ui/src/utils/moneyFormat.js b/ruoyi-ui/src/utils/moneyFormat.js new file mode 100644 index 0000000..155d8b4 --- /dev/null +++ b/ruoyi-ui/src/utils/moneyFormat.js @@ -0,0 +1,41 @@ +/** + * 金额格式化工具 + */ + +// 金额格式化方法 +export function formatMoney(value) { + if (value === null || value === undefined || value === '') { + return '0.00'; + } + return parseFloat(value).toFixed(2); +} + +// 金额样式配置 +export const moneyStyles = { + // 主要金额样式(橙色) + primary: 'color: #E6A23C; font-weight: bold; font-size: 14px;', + // 收入/正数样式(绿色) + income: 'color: #67C23A; font-weight: bold; font-size: 14px;', + // 支出/负数样式(红色) + expense: 'color: #F56C6C; font-weight: bold; font-size: 14px;', + // 禁用/空值样式(灰色) + disabled: 'color: #C0C4CC;', + // 小号金额样式 + small: 'color: #E6A23C; font-weight: bold; font-size: 12px;' +}; + +// Vue混入对象 +export const moneyMixin = { + methods: { + formatMoney(value) { + return formatMoney(value); + }, + // 获取金额显示样式 + getMoneyStyle(value, type = 'primary') { + if (type === 'balance') { + return value > 0 ? moneyStyles.income : (value < 0 ? moneyStyles.expense : moneyStyles.primary); + } + return moneyStyles[type] || moneyStyles.primary; + } + } +}; \ No newline at end of file diff --git a/ruoyi-ui/src/views/system/IntegralOrder/index.vue b/ruoyi-ui/src/views/system/IntegralOrder/index.vue index 57736c6..bb80376 100644 --- a/ruoyi-ui/src/views/system/IntegralOrder/index.vue +++ b/ruoyi-ui/src/views/system/IntegralOrder/index.vue @@ -115,8 +115,21 @@ - - + + + + + + @@ -196,25 +209,47 @@ /> - +
+ + ¥ +
+
+ + 商品市场价格 +
- +
+ + + + +
+
+ + 用户需要支付的积分数量 +
+
+ + + 积分抵扣 ¥{{ formatMoney(form.price) }} + +
@@ -265,9 +300,13 @@ diff --git a/ruoyi-ui/src/views/system/Users/index.vue b/ruoyi-ui/src/views/system/Users/index.vue index 2feb0b9..2a002fc 100644 --- a/ruoyi-ui/src/views/system/Users/index.vue +++ b/ruoyi-ui/src/views/system/Users/index.vue @@ -83,21 +83,53 @@
+ + + + + + + + + + + + + + + + + + - + - - - + + + diff --git a/ruoyi-ui/src/views/system/program/index.vue b/ruoyi-ui/src/views/system/program/index.vue index 89a5461..9c7b26f 100644 --- a/ruoyi-ui/src/views/system/program/index.vue +++ b/ruoyi-ui/src/views/system/program/index.vue @@ -95,8 +95,25 @@ - - + + + + + +