9 files added
6 files modified
895 ■■■■■ changed files
sql/xc_mall_pay_config.sql 30 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/common/configure/WebMvcConfigure.java 2 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/AdminSystemController.java 63 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java 47 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/dependentStation/ApiMallOrderController.java 90 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/dependentStation/TokenviewWebhookController.java 29 ●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/dto/LwPayConfigDto.java 21 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/dto/LwPayCreateOrderDto.java 31 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/dto/SalesServiceDto.java 23 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/dto/TokenviewConfigDto.java 17 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/service/impl/ApiMallOrderInfoServiceImpl.java 7 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/pay/service/LwPayService.java 281 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/lwPayConfig.html 88 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/salesService.html 94 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/tokenviewConfig.html 72 ●●●●● 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/AdminSystemController.java
@@ -12,6 +12,7 @@
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;
@@ -62,6 +63,68 @@
        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(
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java
@@ -129,4 +129,51 @@
    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");
    }
}
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/LwPayConfigDto.java
New file
@@ -0,0 +1,21 @@
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;
}
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/mall/dto/SalesServiceDto.java
New file
@@ -0,0 +1,23 @@
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;
}
src/main/java/cc/mrbird/febs/mall/dto/TokenviewConfigDto.java
New file
@@ -0,0 +1,17 @@
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;
}
src/main/java/cc/mrbird/febs/mall/service/impl/ApiMallOrderInfoServiceImpl.java
@@ -116,6 +116,8 @@
        this.baseMapper.insert(orderInfo);
        BigDecimal total = BigDecimal.ZERO;
        //运费
        BigDecimal delivaryAmount = addOrderDto.getDeliveryAmount() == null ? BigDecimal.ZERO : addOrderDto.getDeliveryAmount();
        for (AddOrderItemDto item : addOrderDto.getItems()) {
            MallOrderItem orderItem = new MallOrderItem();
@@ -138,6 +140,8 @@
            if(1 != goodsResult){
                throw new FebsException("Discontinued");
            }
            delivaryAmount = delivaryAmount.add(mallGoods.getCarriageAmount());
            BigDecimal amount = sku.getPresentPrice().multiply(BigDecimal.valueOf(item.getCnt()));
            orderItem.setAmount(amount);
@@ -163,9 +167,6 @@
            }
            mallOrderItemMapper.insert(orderItem);
        }
        //运费
        BigDecimal delivaryAmount = addOrderDto.getDeliveryAmount() == null ? BigDecimal.ZERO : addOrderDto.getDeliveryAmount();
        orderInfo.setCarriage(delivaryAmount);
        total = total.add(delivaryAmount);
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;
        }
    }
}
src/main/resources/templates/febs/views/modules/system/lwPayConfig.html
New file
@@ -0,0 +1,88 @@
<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>
src/main/resources/templates/febs/views/modules/system/salesService.html
New file
@@ -0,0 +1,94 @@
<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>
src/main/resources/templates/febs/views/modules/system/tokenviewConfig.html
New file
@@ -0,0 +1,72 @@
<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>