| sql/xc_mall_pay_config.sql | ●●●●● patch | view | raw | blame | history | |
| src/main/java/cc/mrbird/febs/common/configure/WebMvcConfigure.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/cc/mrbird/febs/mall/controller/dependentStation/ApiMallOrderController.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/cc/mrbird/febs/mall/controller/dependentStation/TokenviewWebhookController.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/cc/mrbird/febs/mall/dto/LwPayCreateOrderDto.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/cc/mrbird/febs/pay/service/LwPayService.java | ●●●●● patch | view | raw | blame | history |
sql/xc_mall_pay_config.sql
New file @@ -0,0 +1,30 @@ -- ============================================================ -- 支付 & Webhook 配置数据 -- 插入 data_dictionary_custom 表 -- ============================================================ -- ---------------------------- -- 1. LWPAY 支付配置 -- ---------------------------- INSERT INTO data_dictionary_custom (REVISION, CREATED_BY, CREATED_TIME, UPDATED_BY, UPDATED_TIME, type, code, value, description) VALUES (1, 'system', NOW(), 'system', NOW(), 'LWPAY_CONFIG', 'MEMBER_ID', '请替换为商户号', 'LWPAY商户号'), (1, 'system', NOW(), 'system', NOW(), 'LWPAY_CONFIG', 'SECRET_KEY', '请替换为签名密钥', 'LWPAY签名密钥'), (1, 'system', NOW(), 'system', NOW(), 'LWPAY_CONFIG', 'NOTIFY_URL', 'https://your-domain/api/order/lwPayNotify', 'LWPAY异步回调地址'), (1, 'system', NOW(), 'system', NOW(), 'LWPAY_CONFIG', 'RETURN_URL', 'https://your-domain/api/order/lwPayReturn', 'LWPAY支付完成跳转地址'); -- ---------------------------- -- 2. Tokenview Webhook 配置(签名密钥) -- ---------------------------- INSERT INTO data_dictionary_custom (REVISION, CREATED_BY, CREATED_TIME, UPDATED_BY, UPDATED_TIME, type, code, value, description) VALUES (1, 'system', NOW(), 'system', NOW(), 'TOKENVIEW_CONFIG', 'WEBHOOK_SECRET', 'dd12521274e434115df5c4277755839766349007fb57936d9d5be0a7a4f0e42f', 'Tokenview Webhook HMAC-SHA256 签名密钥'), (1, 'system', NOW(), 'system', NOW(), 'TOKENVIEW_CONFIG', 'TRC20_USDT_ADDRESS', '请替换为TRC20收款地址', 'TRC20 USDT 收款地址'); -- ---------------------------- -- 说明: -- 1. MEMBER_ID / SECRET_KEY:从 LWPAY 商户后台获取 -- 2. NOTIFY_URL / RETURN_URL:替换 your-domain 为实际域名 -- 3. WEBHOOK_SECRET:需与 Tokenview 后台配置的 Secret Key 一致 -- 4. TRC20_USDT_ADDRESS:系统接收 USDT 的 TRC20 钱包地址(同时用于 SALES_SERVICE/TRC_ADDRESS) -- ============================================================ src/main/java/cc/mrbird/febs/common/configure/WebMvcConfigure.java
@@ -37,6 +37,8 @@ registration.excludePathPatterns("/api/fuPayReturn/payment/callback"); registration.excludePathPatterns("/api/fuPay/notify"); registration.excludePathPatterns("/api/tokenview/**"); registration.excludePathPatterns("/api/order/lwPayNotify"); registration.excludePathPatterns("/api/order/lwPayReturn"); // 添加Swagger UI相关路径 registration.excludePathPatterns("/api/swagger-ui.html"); src/main/java/cc/mrbird/febs/mall/controller/dependentStation/ApiMallOrderController.java
@@ -4,11 +4,13 @@ import cc.mrbird.febs.common.entity.FebsResponse; import cc.mrbird.febs.common.entity.LimitType; import cc.mrbird.febs.mall.dto.*; import cc.mrbird.febs.mall.entity.MallOrderInfo; import cc.mrbird.febs.mall.service.IApiMallOrderInfoService; import cc.mrbird.febs.mall.vo.ApiOrderPayVo; import cc.mrbird.febs.mall.vo.OrderDetailVo; import cc.mrbird.febs.mall.vo.OrderListVo; import cc.mrbird.febs.pay.service.IXcxPayService; import cc.mrbird.febs.pay.service.LwPayService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiResponse; @@ -18,6 +20,8 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; @@ -35,6 +39,7 @@ private final IApiMallOrderInfoService mallOrderInfoService; private final IXcxPayService iXcxPayService; private final LwPayService lwPayService; @ApiOperation(value = "创建订单--验证是否允许创建", notes = "创建订单--验证是否允许创建") @PostMapping(value = "/createOrderVerify") @@ -164,4 +169,89 @@ return new FebsResponse().success().data(iXcxPayService.getTemplateId()); } // ==================== LWPAY 支付 ==================== /** * 创建订单并通过 LWPAY 支付 * <p> * 流程:创建订单 → 调 LWPAY 代收接口 → 返回支付 URL * <p> * 参数说明: * - bankCode: 银行编码,968=USDT-TRC20 * - network: 网络链,TRX/ETH/MATIC/BSC/SOL/ARBEVM(bankCode=968 时必填) */ @ApiOperation(value = "创建订单-LWPAY支付", notes = "创建订单并返回LWPAY支付URL") @PostMapping(value = "/createOrderByLwPay") @Limit(key = "createOrderByLwPay", period = 1, count = 1, name = "LWPAY下单", prefix = "limit", limitType = LimitType.IP) public FebsResponse createOrderByLwPay(@RequestBody @Validated LwPayCreateOrderDto lwPayDto) { // 1. 创建订单 AddOrderDto addOrderDto = lwPayDto.getOrder(); Long orderId = mallOrderInfoService.createOrder(addOrderDto); // 2. 获取订单详情 MallOrderInfo order = mallOrderInfoService.getById(orderId); if (order == null) { return new FebsResponse().fail().message("订单创建失败"); } // 3. 调用 LWPAY 代收接口 try { String payUrl = lwPayService.createPayment( order, lwPayDto.getBankCode(), lwPayDto.getNetwork() ); Map<String, Object> result = new HashMap<>(); result.put("orderNo", order.getOrderNo()); result.put("amount", order.getAmount()); result.put("payUrl", payUrl); return new FebsResponse().success().data(result).message("下单成功"); } catch (Exception e) { log.error("LWPAY 代收下单失败: orderId={}", orderId, e); return new FebsResponse().fail().message("支付通道异常: " + e.getMessage()); } } /** * LWPAY 支付结果通知(服务端回调) * <p> * LWPAY 在支付完成后会 POST 通知到此地址。 * 需在 LWPAY 商户后台配置 pay_notifyurl 为此地址。 * <p> * 注意:收到后必须响应 "OK",LWPAY 才会停止重试。 */ @ApiOperation(value = "LWPAY 支付回调", notes = "接收LWPAY异步通知") @PostMapping(value = "/lwPayNotify") public String lwPayNotify(HttpServletRequest request) { // 解析 form 参数 Map<String, String> params = new HashMap<>(); Enumeration<String> paramNames = request.getParameterNames(); while (paramNames.hasMoreElements()) { String name = paramNames.nextElement(); params.put(name, request.getParameter(name)); } log.info("LWPAY 回调参数: {}", params); boolean success = lwPayService.handleCallback(params); return success ? "OK" : "FAIL"; } /** * LWPAY 支付结果页面跳转 * <p> * 用户在 LWPAY 支付页面完成后跳回此地址。 * 需在 LWPAY 商户后台配置 pay_callbackurl 为此地址。 */ @ApiOperation(value = "LWPAY 支付跳转", notes = "LWPAY支付完成后的页面跳转") @GetMapping(value = "/lwPayReturn") public String lwPayReturn(HttpServletRequest request) { // 简单跳转到前端结果页 String orderId = request.getParameter("orderid"); String returncode = request.getParameter("returncode"); log.info("LWPAY 页面跳转: orderNo={}, returncode={}", orderId, returncode); return "redirect:/pages/payResult?orderNo=" + orderId + "&code=" + returncode; } } src/main/java/cc/mrbird/febs/mall/controller/dependentStation/TokenviewWebhookController.java
@@ -1,5 +1,7 @@ package cc.mrbird.febs.mall.controller.dependentStation; import cc.mrbird.febs.mall.entity.DataDictionaryCustom; import cc.mrbird.febs.mall.mapper.DataDictionaryCustomMapper; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.TypeReference; @@ -32,12 +34,28 @@ @Resource private TokenviewWebhookService tokenviewWebhookService; @Resource private DataDictionaryCustomMapper dataDictionaryCustomMapper; /** 默认 Webhook 签名密钥(数据库未配置时回退使用) */ private static final String DEFAULT_WEBHOOK_SECRET = "dd12521274e434115df5c4277755839766349007fb57936d9d5be0a7a4f0e42f"; /** Webhook 配置字典 type */ private static final String TOKENVIEW_DICT_TYPE = "TOKENVIEW_CONFIG"; private static final String TOKENVIEW_DICT_CODE = "WEBHOOK_SECRET"; /** * Webhook 签名密钥(HMAC-SHA256) * 需与 Tokenview 后台配置的 Secret Key 保持一致 * TODO: 移至配置文件 * 获取 Webhook 签名密钥(HMAC-SHA256) * 优先从数据库 data_dictionary_custom 读取,未配置则回退默认值 */ private static final String WEBHOOK_SECRET = "dd12521274e434115df5c4277755839766349007fb57936d9d5be0a7a4f0e42f"; private String getWebhookSecret() { DataDictionaryCustom dict = dataDictionaryCustomMapper.selectDicDataByTypeAndCode( TOKENVIEW_DICT_TYPE, TOKENVIEW_DICT_CODE); if (dict != null && StrUtil.isNotBlank(dict.getValue())) { return dict.getValue(); } return DEFAULT_WEBHOOK_SECRET; } /** * 接收 Tokenview 地址监控推送 @@ -147,9 +165,10 @@ */ private boolean verifySignature(String payload, String signature) { try { String secret = getWebhookSecret(); Mac sha256Hmac = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKey = new SecretKeySpec( WEBHOOK_SECRET.getBytes(StandardCharsets.UTF_8), secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256" ); sha256Hmac.init(secretKey); src/main/java/cc/mrbird/febs/mall/dto/LwPayCreateOrderDto.java
New file @@ -0,0 +1,31 @@ package cc.mrbird.febs.mall.dto; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import javax.validation.Valid; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; /** * LWPAY 创建订单请求 DTO * * @author auto-generated */ @Data @ApiModel(value = "LwPayCreateOrderDto", description = "LWPAY支付下单参数") public class LwPayCreateOrderDto { @Valid @NotNull(message = "订单信息不能为空") @ApiModelProperty(value = "订单信息", required = true) private AddOrderDto order; @NotBlank(message = "银行编码不能为空") @ApiModelProperty(value = "银行编码:968=USDT-TRC20", example = "968", required = true) private String bankCode; @ApiModelProperty(value = "网络链(bankCode=968时必填):TRX/ETH/MATIC/BSC/SOL/ARBEVM", example = "TRX") private String network; } src/main/java/cc/mrbird/febs/pay/service/LwPayService.java
New file @@ -0,0 +1,281 @@ package cc.mrbird.febs.pay.service; import cc.mrbird.febs.common.enumerates.OrderStatusEnum; import cc.mrbird.febs.common.utils.RedisUtils; import cc.mrbird.febs.mall.controller.dependentStation.constant.OrderConstants; import cc.mrbird.febs.mall.entity.DataDictionaryCustom; import cc.mrbird.febs.mall.entity.MallOrderInfo; import cc.mrbird.febs.mall.mapper.DataDictionaryCustomMapper; import cc.mrbird.febs.mall.mapper.MallOrderInfoMapper; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Date; import java.util.Map; import java.util.TreeMap; /** * LWPAY 支付服务 * <p> * 处理 LWPAY 代收(创建支付订单)和回调通知 * <p> * LWPAY API 基础说明: * - 签名算法:参数按 ASCII 字典序排序 → key=value 拼接 → 末尾追加 &key={secret} → MD5 → 大写 * - 代收接口:POST /Pay_Index (application/x-www-form-urlencoded) * - 支付回调:application/x-www-form-urlencoded,收到后需响应 "OK" * * @author auto-generated */ @Slf4j @Service public class LwPayService { @Resource private MallOrderInfoMapper mallOrderInfoMapper; @Resource private DataDictionaryCustomMapper dataDictionaryCustomMapper; @Resource private RedisUtils redisUtils; /** LWPAY 配置字典 type */ private static final String DICT_TYPE = "LWPAY_CONFIG"; /** LWPAY API 基础地址 */ private static final String LWPAY_BASE_URL = "https://www.lwpay.com"; // ==================== 代收接口 ==================== /** * 代收下单 → 获取 LWPAY 支付跳转 URL * * @param order 订单实体 * @param bankCode 银行编码(如 968 对应 USDT-TRC20) * @param network 网络链(如 TRX,仅 bankCode=968 时需要) * @return LWPAY 支付页面 URL */ public String createPayment(MallOrderInfo order, String bankCode, String network) { String memberId = getConfigValue("MEMBER_ID"); String secretKey = getConfigValue("SECRET_KEY"); if (StrUtil.isBlank(memberId) || StrUtil.isBlank(secretKey)) { throw new RuntimeException("LWPAY 商户号/秘钥未配置"); } TreeMap<String, String> params = new TreeMap<>(); params.put("pay_memberid", memberId); params.put("pay_orderid", order.getOrderNo()); params.put("pay_applydate", cn.hutool.core.date.DateUtil.format(new Date(), "yyyy-MM-dd HH:mm:ss")); params.put("pay_bankcode", bankCode); params.put("pay_notifyurl", getNotifyUrl()); params.put("pay_callbackurl", getReturnUrl()); params.put("pay_amount", order.getAmount().toPlainString()); params.put("pay_productname", "商品订单-" + order.getOrderNo()); // 通道 968 需要 network 参数 if ("968".equals(bankCode) && StrUtil.isNotBlank(network)) { params.put("pay_attach", "network:" + network); } // 生成签名 String sign = generateSign(params, secretKey); params.put("pay_md5sign", sign); log.info("LWPAY 代收请求: memberId={}, orderNo={}, amount={}, bankCode={}", memberId, order.getOrderNo(), order.getAmount(), bankCode); // 发送 POST 请求 String response = postForm(LWPAY_BASE_URL + "/Pay_Index", params); log.info("LWPAY 代收响应: {}", response); if (StrUtil.isBlank(response)) { throw new RuntimeException("LWPAY 响应为空"); } // 解析响应 JSON: {"status": "1", "msg": "success", "payurl": "https://..."} com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSONObject.parseObject(response); if (!"1".equals(json.getString("status"))) { throw new RuntimeException("LWPAY 代收失败: " + json.getString("msg")); } return json.getString("payurl"); } // ==================== 回调处理 ==================== /** * 处理 LWPAY 支付结果通知 * * @param params 回调参数 Map * @return true-处理成功, false-处理失败 */ public boolean handleCallback(Map<String, String> params) { String secretKey = getConfigValue("SECRET_KEY"); if (StrUtil.isBlank(secretKey)) { log.error("LWPAY 秘钥未配置,无法验签"); return false; } // 1. 验证签名 if (!verifySign(params, secretKey)) { log.error("LWPAY 回调签名验证失败: params={}", params); return false; } String returncode = params.get("returncode"); String orderId = params.get("orderid"); String amount = params.get("amount"); String transactionId = params.get("transaction_id"); String refCode = params.get("refCode"); log.info("LWPAY 回调: orderNo={}, returncode={}, refCode={}, amount={}, txid={}", orderId, returncode, refCode, amount, transactionId); // 2. 判断支付状态:"00" 且 refCode="1" 表示支付成功 if (!"00".equals(returncode) || !"1".equals(refCode)) { log.warn("LWPAY 支付未成功: orderNo={}, returncode={}, refCode={}", orderId, returncode, refCode); return true; // 非成功状态也返回 true,避免 LWPAY 重试 } // 3. 查询订单 MallOrderInfo order = mallOrderInfoMapper.selectOne( Wrappers.lambdaQuery(MallOrderInfo.class) .eq(MallOrderInfo::getOrderNo, orderId) ); if (order == null) { log.error("LWPAY 回调订单不存在: orderNo={}", orderId); return false; } // 4. 幂等检查(避免重复回调) if (OrderStatusEnum.WAIT_PAY.getValue() != order.getStatus()) { log.info("LWPAY 订单已处理: orderNo={}, status={}", orderId, order.getStatus()); return true; } // 5. 更新订单状态为待发货 mallOrderInfoMapper.update( null, Wrappers.lambdaUpdate(MallOrderInfo.class) .set(MallOrderInfo::getStatus, OrderStatusEnum.WAIT_SHIPPING.getValue()) .set(MallOrderInfo::getTradeHash, transactionId) .set(MallOrderInfo::getPayTime, new Date()) .set(MallOrderInfo::getPayMethod, "LWPAY支付") .set(MallOrderInfo::getPayResult, "1") .eq(MallOrderInfo::getId, order.getId()) ); // 清理 Redis 中的金额匹配 key(如果有) String amountKey = OrderConstants.TRC20_ORDER_KEY + order.getAmount(); redisUtils.del(amountKey); log.info("LWPAY 支付成功: orderNo={}, txid={}, amount={}", orderId, transactionId, amount); return true; } // ==================== 签名工具 ==================== /** * 生成 MD5 签名(大写) * <p> * 1. 参数按 ASCII 字典序排序 * 2. key=value&key=value 拼接 * 3. 末尾追加 &key={secretKey} * 4. MD5 → 大写 */ public String generateSign(TreeMap<String, String> params, String secretKey) { String signStr = buildSignString(params) + "&key=" + secretKey; log.debug("LWPAY 待签名字符串: {}", signStr); return SecureUtil.md5(signStr).toUpperCase(); } /** * 验证回调签名 */ public boolean verifySign(Map<String, String> params, String secretKey) { String receivedSign = params.get("sign"); if (StrUtil.isBlank(receivedSign)) { return false; } // 剔除 sign 字段本身,构建签名字符串 TreeMap<String, String> signParams = new TreeMap<>(); for (Map.Entry<String, String> entry : params.entrySet()) { if (!"sign".equals(entry.getKey()) && StrUtil.isNotBlank(entry.getValue())) { signParams.put(entry.getKey(), entry.getValue()); } } String expectedSign = generateSign(signParams, secretKey); boolean valid = expectedSign.equalsIgnoreCase(receivedSign); if (!valid) { log.warn("LWPAY 签名不匹配: expected={}, received={}", expectedSign, receivedSign); } return valid; } /** * 将参数拼接为签名字符串(不含 &key=) * key1=value1&key2=value2&... */ private String buildSignString(TreeMap<String, String> params) { StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> entry : params.entrySet()) { if (StrUtil.isNotBlank(entry.getValue())) { if (sb.length() > 0) { sb.append("&"); } sb.append(entry.getKey()).append("=").append(entry.getValue()); } } return sb.toString(); } // ==================== 工具方法 ==================== /** * 从数据字典获取 LWPAY 配置 */ public String getConfigValue(String code) { DataDictionaryCustom dict = dataDictionaryCustomMapper.selectDicDataByTypeAndCode(DICT_TYPE, code); return dict != null ? dict.getValue() : null; } /** * 获取回调通知 URL */ private String getNotifyUrl() { return getConfigValue("NOTIFY_URL"); } /** * 获取页面跳转 URL */ private String getReturnUrl() { return getConfigValue("RETURN_URL"); } /** * POST application/x-www-form-urlencoded 请求 */ private String postForm(String url, Map<String, String> params) { try { // 转换为 String[] 格式(OkHttpUtil2.doPost 接口要求) Map<String, String[]> body = new java.util.HashMap<>(); for (Map.Entry<String, String> entry : params.entrySet()) { body.put(entry.getKey(), new String[]{entry.getValue()}); } byte[] bytes = cc.mrbird.febs.mall.controller.dependentStation.utils.OkHttpUtil2 .doPost(url, null, body, "application/json"); if (bytes != null) { return new String(bytes, java.nio.charset.StandardCharsets.UTF_8); } return null; } catch (Exception e) { log.error("LWPAY POST 请求异常: url={}", url, e); return null; } } }