9 files added
5 files modified
| New file |
| | |
| | | -- ============================================================ |
| | | -- 支付 & 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) |
| | | -- ============================================================ |
| | |
| | | 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");
|
| | |
| | | import cc.mrbird.febs.pay.model.HeaderDto; |
| | | import cc.mrbird.febs.pay.service.WxFaPiaoService; |
| | | import cn.hutool.core.util.ObjectUtil; |
| | | import cn.hutool.core.util.StrUtil; |
| | | import cn.hutool.json.JSONUtil; |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import lombok.RequiredArgsConstructor; |
| | |
| | | return new FebsResponse().success(); |
| | | } |
| | | |
| | | @PostMapping(value = "/lwPayConfig") |
| | | @ControllerEndpoint(operation = "保存LWPAY配置") |
| | | public FebsResponse lwPayConfig(LwPayConfigDto dto) { |
| | | if (StrUtil.isBlank(dto.getMemberId())) { |
| | | return new FebsResponse().fail().message("商户号不能为空"); |
| | | } |
| | | if (StrUtil.isBlank(dto.getSecretKey())) { |
| | | return new FebsResponse().fail().message("签名密钥不能为空"); |
| | | } |
| | | if (StrUtil.isBlank(dto.getNotifyUrl())) { |
| | | return new FebsResponse().fail().message("回调地址不能为空"); |
| | | } |
| | | if (StrUtil.isBlank(dto.getReturnUrl())) { |
| | | return new FebsResponse().fail().message("跳转地址不能为空"); |
| | | } |
| | | commonService.addDataDic("LWPAY_CONFIG", "MEMBER_ID", dto.getMemberId(), "LWPAY商户号", false); |
| | | commonService.addDataDic("LWPAY_CONFIG", "SECRET_KEY", dto.getSecretKey(), "LWPAY签名密钥", false); |
| | | commonService.addDataDic("LWPAY_CONFIG", "NOTIFY_URL", dto.getNotifyUrl(), "LWPAY异步回调地址", false); |
| | | commonService.addDataDic("LWPAY_CONFIG", "RETURN_URL", dto.getReturnUrl(), "LWPAY支付完成跳转地址", false); |
| | | return new FebsResponse().success().message("保存成功"); |
| | | } |
| | | |
| | | @PostMapping(value = "/tokenviewConfig") |
| | | @ControllerEndpoint(operation = "保存Tokenview配置") |
| | | public FebsResponse tokenviewConfig(TokenviewConfigDto dto) { |
| | | if (StrUtil.isBlank(dto.getWebhookSecret())) { |
| | | return new FebsResponse().fail().message("签名密钥不能为空"); |
| | | } |
| | | if (StrUtil.isBlank(dto.getTrc20UsdtAddress())) { |
| | | return new FebsResponse().fail().message("收款地址不能为空"); |
| | | } |
| | | commonService.addDataDic("TOKENVIEW_CONFIG", "WEBHOOK_SECRET", dto.getWebhookSecret(), "Tokenview Webhook签名密钥", false); |
| | | commonService.addDataDic("TOKENVIEW_CONFIG", "TRC20_USDT_ADDRESS", dto.getTrc20UsdtAddress(), "TRC20 USDT收款地址", false); |
| | | return new FebsResponse().success().message("保存成功"); |
| | | } |
| | | |
| | | @PostMapping(value = "/salesService") |
| | | @ControllerEndpoint(operation = "保存售后服务配置") |
| | | public FebsResponse salesService(SalesServiceDto dto) { |
| | | if (StrUtil.isBlank(dto.getWhatsApp())) { |
| | | return new FebsResponse().fail().message("WhatsApp不能为空"); |
| | | } |
| | | if (StrUtil.isBlank(dto.getPhone())) { |
| | | return new FebsResponse().fail().message("联系电话不能为空"); |
| | | } |
| | | if (StrUtil.isBlank(dto.getEmail())) { |
| | | return new FebsResponse().fail().message("联系邮箱不能为空"); |
| | | } |
| | | if (StrUtil.isBlank(dto.getAddress())) { |
| | | return new FebsResponse().fail().message("联系地址不能为空"); |
| | | } |
| | | if (StrUtil.isBlank(dto.getWorkingHours())) { |
| | | return new FebsResponse().fail().message("工作时间不能为空"); |
| | | } |
| | | commonService.addDataDic("SALES_SERVICE", "WHATSAPP", dto.getWhatsApp(), "WhatsApp号码", false); |
| | | commonService.addDataDic("SALES_SERVICE", "PHONE", dto.getPhone(), "联系电话", false); |
| | | commonService.addDataDic("SALES_SERVICE", "EMAIL", dto.getEmail(), "联系邮箱", false); |
| | | commonService.addDataDic("SALES_SERVICE", "ADDRESS", dto.getAddress(), "联系地址", false); |
| | | commonService.addDataDic("SALES_SERVICE", "WORKINGHOURS", dto.getWorkingHours(), "工作时间", false); |
| | | return new FebsResponse().success().message("保存成功"); |
| | | } |
| | | |
| | | @PostMapping(value = "/agentAmountSetSetting") |
| | | public FebsResponse agentAmountSetSetting(AdminAgentAmountDto adminAgentAmountDto) { |
| | | DataDictionaryCustom dic = dataDictionaryCustomMapper.selectDicDataByTypeAndCode( |
| | |
| | | public String sender() { |
| | | return FebsUtil.view("modules/system/sender"); |
| | | } |
| | | |
| | | @GetMapping("lwPayConfig") |
| | | @RequiresPermissions("lwPayConfig:update") |
| | | public String lwPayConfig(Model model) { |
| | | LwPayConfigDto dto = new LwPayConfigDto(); |
| | | // 逐条读取 LWPAY_CONFIG 下的配置项 |
| | | DataDictionaryCustom memberId = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("LWPAY_CONFIG", "MEMBER_ID"); |
| | | DataDictionaryCustom secretKey = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("LWPAY_CONFIG", "SECRET_KEY"); |
| | | DataDictionaryCustom notifyUrl = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("LWPAY_CONFIG", "NOTIFY_URL"); |
| | | DataDictionaryCustom returnUrl = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("LWPAY_CONFIG", "RETURN_URL"); |
| | | if (memberId != null) dto.setMemberId(memberId.getValue()); |
| | | if (secretKey != null) dto.setSecretKey(secretKey.getValue()); |
| | | if (notifyUrl != null) dto.setNotifyUrl(notifyUrl.getValue()); |
| | | if (returnUrl != null) dto.setReturnUrl(returnUrl.getValue()); |
| | | model.addAttribute("lwPayConfig", dto); |
| | | return FebsUtil.view("modules/system/lwPayConfig"); |
| | | } |
| | | |
| | | @GetMapping("tokenviewConfig") |
| | | @RequiresPermissions("tokenviewConfig:update") |
| | | public String tokenviewConfig(Model model) { |
| | | TokenviewConfigDto dto = new TokenviewConfigDto(); |
| | | DataDictionaryCustom secret = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("TOKENVIEW_CONFIG", "WEBHOOK_SECRET"); |
| | | DataDictionaryCustom address = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("TOKENVIEW_CONFIG", "TRC20_USDT_ADDRESS"); |
| | | if (secret != null) dto.setWebhookSecret(secret.getValue()); |
| | | if (address != null) dto.setTrc20UsdtAddress(address.getValue()); |
| | | model.addAttribute("tokenviewConfig", dto); |
| | | return FebsUtil.view("modules/system/tokenviewConfig"); |
| | | } |
| | | |
| | | @GetMapping("salesService") |
| | | @RequiresPermissions("salesService:update") |
| | | public String salesService(Model model) { |
| | | SalesServiceDto dto = new SalesServiceDto(); |
| | | DataDictionaryCustom whatsApp = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("SALES_SERVICE", "WHATSAPP"); |
| | | DataDictionaryCustom phone = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("SALES_SERVICE", "PHONE"); |
| | | DataDictionaryCustom email = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("SALES_SERVICE", "EMAIL"); |
| | | DataDictionaryCustom address = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("SALES_SERVICE", "ADDRESS"); |
| | | DataDictionaryCustom workingHours = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("SALES_SERVICE", "WORKINGHOURS"); |
| | | if (whatsApp != null) dto.setWhatsApp(whatsApp.getValue()); |
| | | if (phone != null) dto.setPhone(phone.getValue()); |
| | | if (email != null) dto.setEmail(email.getValue()); |
| | | if (address != null) dto.setAddress(address.getValue()); |
| | | if (workingHours != null) dto.setWorkingHours(workingHours.getValue()); |
| | | model.addAttribute("salesService", dto); |
| | | return FebsUtil.view("modules/system/salesService"); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |
| | |
| | | |
| | | private final IApiMallOrderInfoService mallOrderInfoService; |
| | | private final IXcxPayService iXcxPayService; |
| | | private final LwPayService lwPayService; |
| | | |
| | | @ApiOperation(value = "创建订单--验证是否允许创建", notes = "创建订单--验证是否允许创建") |
| | | @PostMapping(value = "/createOrderVerify") |
| | |
| | | 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; |
| | | } |
| | | |
| | | } |
| | |
| | | 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; |
| | |
| | | @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 地址监控推送 |
| | |
| | | */ |
| | | 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); |
| New file |
| | |
| | | package cc.mrbird.febs.mall.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | /** |
| | | * LWPAY 支付配置 DTO |
| | | * 对应 data_dictionary_custom 表 type='LWPAY_CONFIG' 的多条记录 |
| | | * |
| | | * @author auto-generated |
| | | */ |
| | | @Data |
| | | public class LwPayConfigDto { |
| | | /** LWPAY 商户号 */ |
| | | private String memberId; |
| | | /** LWPAY 签名密钥 */ |
| | | private String secretKey; |
| | | /** 异步回调通知地址 */ |
| | | private String notifyUrl; |
| | | /** 支付完成跳转地址 */ |
| | | private String returnUrl; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.mall.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | /** |
| | | * 售后服务配置 DTO |
| | | * 对应 data_dictionary_custom 表 type='SALES_SERVICE' 的多条记录 |
| | | * |
| | | * @author auto-generated |
| | | */ |
| | | @Data |
| | | public class SalesServiceDto { |
| | | /** WhatsApp 号码 */ |
| | | private String whatsApp; |
| | | /** 联系电话 */ |
| | | private String phone; |
| | | /** 联系邮箱 */ |
| | | private String email; |
| | | /** 联系地址 */ |
| | | private String address; |
| | | /** 工作时间 */ |
| | | private String workingHours; |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.mall.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | /** |
| | | * Tokenview Webhook 配置 DTO |
| | | * 对应 data_dictionary_custom 表 type='TOKENVIEW_CONFIG' 的多条记录 |
| | | * |
| | | * @author auto-generated |
| | | */ |
| | | @Data |
| | | public class TokenviewConfigDto { |
| | | /** Webhook HMAC-SHA256 签名密钥 */ |
| | | private String webhookSecret; |
| | | /** TRC20 USDT 收款地址 */ |
| | | private String trc20UsdtAddress; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <div class="layui-fluid layui-anim febs-anim" id="lwpay-config" lay-title="LWPAY支付设置"> |
| | | <div class="layui-row layui-col-space8 febs-container"> |
| | | <form class="layui-form" action="" lay-filter="lwpay-config-form"> |
| | | <div class="layui-card"> |
| | | <div class="layui-card-header">LWPAY 支付参数配置</div> |
| | | <div class="layui-card-body"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">商户号:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="memberId" data-th-id="${lwPayConfig.memberId}" |
| | | lay-verify="required" autocomplete="off" class="layui-input" placeholder="LWPAY 商户号(MEMBER_ID)"> |
| | | </div> |
| | | </div> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">签名密钥:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="secretKey" data-th-id="${lwPayConfig.secretKey}" |
| | | lay-verify="required" autocomplete="off" class="layui-input" placeholder="LWPAY 签名密钥(SECRET_KEY)"> |
| | | </div> |
| | | </div> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">回调地址:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="notifyUrl" data-th-id="${lwPayConfig.notifyUrl}" |
| | | lay-verify="required|url" autocomplete="off" class="layui-input" placeholder="异步回调通知地址(NOTIFY_URL)"> |
| | | </div> |
| | | <div class="layui-word-aux" style="margin-left: 150px;">LWPAY 支付成功后异步通知的地址,必须以 http(s):// 开头</div> |
| | | </div> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">跳转地址:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="returnUrl" data-th-id="${lwPayConfig.returnUrl}" |
| | | lay-verify="required|url" autocomplete="off" class="layui-input" placeholder="支付完成页面跳转地址(RETURN_URL)"> |
| | | </div> |
| | | <div class="layui-word-aux" style="margin-left: 150px;">用户支付完成后跳转的页面地址,必须以 http(s):// 开头</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="layui-card-footer"> |
| | | <button class="layui-btn layui-btn-normal" lay-submit="" lay-filter="lwpay-config-form-submit" id="submit">保存</button> |
| | | </div> |
| | | </div> |
| | | </form> |
| | | </div> |
| | | </div> |
| | | <style> |
| | | .layui-form-label { |
| | | width: 120px; |
| | | } |
| | | |
| | | .layui-form-item .layui-input-block { |
| | | margin-left: 150px; |
| | | } |
| | | |
| | | .layui-table-form .layui-form-item { |
| | | margin-bottom: 20px !important; |
| | | } |
| | | </style> |
| | | <script data-th-inline="javascript" type="text/javascript"> |
| | | layui.use(['dropdown', 'jquery', 'validate', 'febs', 'form'], function () { |
| | | var $ = layui.jquery, |
| | | febs = layui.febs, |
| | | form = layui.form, |
| | | lwPayConfig = [[${lwPayConfig}]], |
| | | validate = layui.validate, |
| | | $view = $('#lwpay-config'); |
| | | |
| | | form.verify(validate); |
| | | |
| | | if (lwPayConfig) { |
| | | form.val("lwpay-config-form", { |
| | | "memberId": lwPayConfig.memberId, |
| | | "secretKey": lwPayConfig.secretKey, |
| | | "notifyUrl": lwPayConfig.notifyUrl, |
| | | "returnUrl": lwPayConfig.returnUrl |
| | | }); |
| | | } |
| | | |
| | | form.render(); |
| | | |
| | | form.on('submit(lwpay-config-form-submit)', function (data) { |
| | | febs.post(ctx + 'admin/system/lwPayConfig', data.field, function (res) { |
| | | febs.alert.success('保存成功'); |
| | | }); |
| | | return false; |
| | | }); |
| | | }); |
| | | </script> |
| New file |
| | |
| | | <div class="layui-fluid layui-anim febs-anim" id="sales-service" lay-title="售后服务设置"> |
| | | <div class="layui-row layui-col-space8 febs-container"> |
| | | <form class="layui-form" action="" lay-filter="sales-service-form"> |
| | | <div class="layui-card"> |
| | | <div class="layui-card-header">售后服务信息配置</div> |
| | | <div class="layui-card-body"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">WhatsApp:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="whatsApp" data-th-id="${salesService.whatsApp}" |
| | | lay-verify="required" autocomplete="off" class="layui-input" placeholder="WhatsApp 号码"> |
| | | </div> |
| | | </div> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">联系电话:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="phone" data-th-id="${salesService.phone}" |
| | | lay-verify="required" autocomplete="off" class="layui-input" placeholder="联系电话"> |
| | | </div> |
| | | </div> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">联系邮箱:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="email" data-th-id="${salesService.email}" |
| | | lay-verify="required|email" autocomplete="off" class="layui-input" placeholder="联系邮箱"> |
| | | </div> |
| | | </div> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">联系地址:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="address" data-th-id="${salesService.address}" |
| | | lay-verify="required" autocomplete="off" class="layui-input" placeholder="联系地址"> |
| | | </div> |
| | | </div> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">工作时间:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="workingHours" data-th-id="${salesService.workingHours}" |
| | | lay-verify="required" autocomplete="off" class="layui-input" placeholder="工作时间,如:Mon-Fri 9:00-18:00"> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="layui-card-footer"> |
| | | <button class="layui-btn layui-btn-normal" lay-submit="" lay-filter="sales-service-form-submit" id="submit">保存</button> |
| | | </div> |
| | | </div> |
| | | </form> |
| | | </div> |
| | | </div> |
| | | <style> |
| | | .layui-form-label { |
| | | width: 120px; |
| | | } |
| | | |
| | | .layui-form-item .layui-input-block { |
| | | margin-left: 150px; |
| | | } |
| | | |
| | | .layui-table-form .layui-form-item { |
| | | margin-bottom: 20px !important; |
| | | } |
| | | </style> |
| | | <script data-th-inline="javascript" type="text/javascript"> |
| | | layui.use(['dropdown', 'jquery', 'validate', 'febs', 'form'], function () { |
| | | var $ = layui.jquery, |
| | | febs = layui.febs, |
| | | form = layui.form, |
| | | salesService = [[${salesService}]], |
| | | validate = layui.validate, |
| | | $view = $('#sales-service'); |
| | | |
| | | form.verify(validate); |
| | | |
| | | if (salesService) { |
| | | form.val("sales-service-form", { |
| | | "whatsApp": salesService.whatsApp, |
| | | "phone": salesService.phone, |
| | | "email": salesService.email, |
| | | "address": salesService.address, |
| | | "workingHours": salesService.workingHours |
| | | }); |
| | | } |
| | | |
| | | form.render(); |
| | | |
| | | form.on('submit(sales-service-form-submit)', function (data) { |
| | | febs.post(ctx + 'admin/system/salesService', data.field, function (res) { |
| | | febs.alert.success('保存成功'); |
| | | }); |
| | | return false; |
| | | }); |
| | | }); |
| | | </script> |
| New file |
| | |
| | | <div class="layui-fluid layui-anim febs-anim" id="tokenview-config" lay-title="Tokenview配置"> |
| | | <div class="layui-row layui-col-space8 febs-container"> |
| | | <form class="layui-form" action="" lay-filter="tokenview-config-form"> |
| | | <div class="layui-card"> |
| | | <div class="layui-card-header">Tokenview Webhook 配置</div> |
| | | <div class="layui-card-body"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">签名密钥:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="webhookSecret" data-th-id="${tokenviewConfig.webhookSecret}" |
| | | lay-verify="required" autocomplete="off" class="layui-input" placeholder="Webhook HMAC-SHA256 签名密钥"> |
| | | </div> |
| | | <div class="layui-word-aux" style="margin-left: 150px;">用于验证 Tokenview 推送签名的密钥,需与 Tokenview 后台配置的 Secret Key 一致</div> |
| | | </div> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label required">收款地址:</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="trc20UsdtAddress" data-th-id="${tokenviewConfig.trc20UsdtAddress}" |
| | | lay-verify="required" autocomplete="off" class="layui-input" placeholder="TRC20 USDT 收款地址"> |
| | | </div> |
| | | <div class="layui-word-aux" style="margin-left: 150px;">TRC20 链上的 USDT 收款钱包地址</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="layui-card-footer"> |
| | | <button class="layui-btn layui-btn-normal" lay-submit="" lay-filter="tokenview-config-form-submit" id="submit">保存</button> |
| | | </div> |
| | | </div> |
| | | </form> |
| | | </div> |
| | | </div> |
| | | <style> |
| | | .layui-form-label { |
| | | width: 120px; |
| | | } |
| | | |
| | | .layui-form-item .layui-input-block { |
| | | margin-left: 150px; |
| | | } |
| | | |
| | | .layui-table-form .layui-form-item { |
| | | margin-bottom: 20px !important; |
| | | } |
| | | </style> |
| | | <script data-th-inline="javascript" type="text/javascript"> |
| | | layui.use(['dropdown', 'jquery', 'validate', 'febs', 'form'], function () { |
| | | var $ = layui.jquery, |
| | | febs = layui.febs, |
| | | form = layui.form, |
| | | tokenviewConfig = [[${tokenviewConfig}]], |
| | | validate = layui.validate, |
| | | $view = $('#tokenview-config'); |
| | | |
| | | form.verify(validate); |
| | | |
| | | if (tokenviewConfig) { |
| | | form.val("tokenview-config-form", { |
| | | "webhookSecret": tokenviewConfig.webhookSecret, |
| | | "trc20UsdtAddress": tokenviewConfig.trc20UsdtAddress |
| | | }); |
| | | } |
| | | |
| | | form.render(); |
| | | |
| | | form.on('submit(tokenview-config-form-submit)', function (data) { |
| | | febs.post(ctx + 'admin/system/tokenviewConfig', data.field, function (res) { |
| | | febs.alert.success('保存成功'); |
| | | }); |
| | | return false; |
| | | }); |
| | | }); |
| | | </script> |