Administrator
5 hours ago 0bd44afe3417454c5247c10b70897331e586536b
feat(payment): 集成BSPAY巴西PIX支付功能

- 新增BSPAY支付配置管理接口和前端页面
- 实现BSPAY下单、支付、回调处理完整流程
- 添加BSPAY配置DTO和订单创建DTO
- 集成BSPAY支付服务和签名验证机制
- 添加BSPAY支付类型常量和参数配置
- 实现BSPAY回调通知接口和安全验证
- 添加BSPAY配置缓存和数据库存储支持
- 实现BRL货币转换和金额计算逻辑
4 files added
6 files modified
676 ■■■■■ changed files
src/main/java/cc/mrbird/febs/common/configure/WebMvcConfigure.java 1 ●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/AdminSystemController.java 22 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java 16 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/dependentStation/ApiMallOrderController.java 118 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/controller/dependentStation/constant/OrderConstants.java 3 ●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/dto/ApiOrderPayDto.java 2 ●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/dto/BsPayConfigDto.java 21 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/mall/dto/BsPayCreateOrderDto.java 23 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/pay/service/BsPayService.java 382 ●●●●● patch | view | raw | blame | history
src/main/resources/templates/febs/views/modules/system/bsPayConfig.html 88 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/common/configure/WebMvcConfigure.java
@@ -39,6 +39,7 @@
        registration.excludePathPatterns("/api/tokenview/**");
        registration.excludePathPatterns("/api/order/lwPayNotify");
        registration.excludePathPatterns("/api/order/lwPayReturn");
        registration.excludePathPatterns("/api/order/bsPayNotify");
        // 添加Swagger UI相关路径
        registration.excludePathPatterns("/api/swagger-ui.html");
src/main/java/cc/mrbird/febs/mall/controller/AdminSystemController.java
@@ -95,6 +95,28 @@
        return new FebsResponse().success().message("保存成功");
    }
    @PostMapping(value = "/bsPayConfig")
    @ControllerEndpoint(operation = "保存BSPAY配置")
    public FebsResponse bsPayConfig(BsPayConfigDto 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.getApiBaseUrl())) {
            return new FebsResponse().fail().message("API基础地址不能为空");
        }
        commonService.addDataDic("BSPAY_CONFIG", "MEMBER_ID", dto.getMemberId(), "BSPAY商户号", false);
        commonService.addDataDic("BSPAY_CONFIG", "SECRET_KEY", dto.getSecretKey(), "BSPAY签名密钥", false);
        commonService.addDataDic("BSPAY_CONFIG", "NOTIFY_URL", dto.getNotifyUrl(), "BSPAY异步回调地址", false);
        commonService.addDataDic("BSPAY_CONFIG", "API_BASE_URL", dto.getApiBaseUrl(), "BSPAY API基础地址", false);
        return new FebsResponse().success().message("保存成功");
    }
    @PostMapping(value = "/tokenviewConfig")
    @ControllerEndpoint(operation = "保存Tokenview配置")
    public FebsResponse tokenviewConfig(TokenviewConfigDto dto) {
src/main/java/cc/mrbird/febs/mall/controller/ViewSystemController.java
@@ -147,6 +147,22 @@
        return FebsUtil.view("modules/system/lwPayConfig");
    }
    @GetMapping("bsPayConfig")
    @RequiresPermissions("bsPayConfig:update")
    public String bsPayConfig(Model model) {
        BsPayConfigDto dto = new BsPayConfigDto();
        DataDictionaryCustom memberId = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("BSPAY_CONFIG", "MEMBER_ID");
        DataDictionaryCustom secretKey = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("BSPAY_CONFIG", "SECRET_KEY");
        DataDictionaryCustom notifyUrl = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("BSPAY_CONFIG", "NOTIFY_URL");
        DataDictionaryCustom apiBaseUrl = dataDictionaryCustomMapper.selectDicDataByTypeAndCode("BSPAY_CONFIG", "API_BASE_URL");
        if (memberId != null) dto.setMemberId(memberId.getValue());
        if (secretKey != null) dto.setSecretKey(secretKey.getValue());
        if (notifyUrl != null) dto.setNotifyUrl(notifyUrl.getValue());
        if (apiBaseUrl != null) dto.setApiBaseUrl(apiBaseUrl.getValue());
        model.addAttribute("bsPayConfig", dto);
        return FebsUtil.view("modules/system/bsPayConfig");
    }
    @GetMapping("tokenviewConfig")
    @RequiresPermissions("tokenviewConfig:update")
    public String tokenviewConfig(Model model) {
src/main/java/cc/mrbird/febs/mall/controller/dependentStation/ApiMallOrderController.java
@@ -14,8 +14,10 @@
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.BsPayService;
import cc.mrbird.febs.pay.service.IXcxPayService;
import cc.mrbird.febs.pay.service.LwPayService;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -46,6 +48,7 @@
    private final IApiMallOrderInfoService mallOrderInfoService;
    private final IXcxPayService iXcxPayService;
    private final LwPayService lwPayService;
    private final BsPayService bsPayService;
    private final IMallCountryDeliveryService countryDeliveryService;
    @ApiOperation(value = "创建订单--验证是否允许创建", notes = "创建订单--验证是否允许创建")
@@ -353,6 +356,121 @@
        return "redirect:/pages/payResult?orderNo=" + orderId + "&code=" + returncode;
    }
    // ==================== BSPAY (巴西PIX) 支付 ====================
    /**
     * 创建订单并通过 BSPAY (巴西PIX) 支付
     * <p>
     * 流程:创建订单 → 调 BSPAY 下单接口 → 返回支付 URL (mweb_url)
     * <p>
     * 交易类型:trade_type=201 (巴西PIX),货币:BRL
     */
    @ApiOperation(value = "创建订单-BSPAY巴西PIX支付", notes = "创建订单并返回BSPAY PIX支付URL")
    @PostMapping(value = "/createOrderByBsPay")
    @Limit(key = "createOrderByBsPay", period = 1, count = 1, name = "BSPAY下单", prefix = "limit", limitType = LimitType.IP)
    public FebsResponse createOrderByBsPay(@RequestBody @Validated BsPayCreateOrderDto bsPayDto) {
        // 1. 创建订单
        AddOrderDto addOrderDto = bsPayDto.getOrder();
        Long orderId = mallOrderInfoService.createOrder(addOrderDto);
        // 2. 获取订单详情
        MallOrderInfo order = mallOrderInfoService.getById(orderId);
        if (order != null
                && OrderStatusEnum.WAIT_PAY.getValue() == order.getStatus()
        ) {
            // 3. 调用 BSPAY 下单接口
            try {
                String payUrl = bsPayService.createPayment(order);
                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);
            } catch (Exception e) {
                log.error("BSPAY 下单失败: orderId={}", orderId, e);
                return new FebsResponse().fail().message("Payment channel exception: " + e.getMessage());
            }
        }
        return new FebsResponse().fail().message("Payment channel exception");
    }
    @ApiOperation(value = "BSPAY巴西PIX支付", notes = "BSPAY巴西PIX支付")
    @ApiResponses({
            @ApiResponse(code = 200, message = "success", response = ApiOrderPayVo.class)
    })
    @PostMapping(value = "/payOrderByBsPay", produces = "application/json")
    public FebsResponse payOrderByBsPay(@RequestBody @Validated ApiOrderPayDto payDto) {
        Long orderId = payDto.getOrderId();
        Integer payType = payDto.getPayType();
        // 2. 获取订单详情
        MallOrderInfo order = mallOrderInfoService.getById(orderId);
        if (order != null
                && OrderConstants.PAY_TYPE_BS == payType
                && OrderStatusEnum.WAIT_PAY.getValue() == order.getStatus()
        ) {
            // 3. 调用 BSPAY 下单接口
            try {
                String payUrl = bsPayService.createPayment(order);
                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("success");
            } catch (Exception e) {
                log.error("BSPAY 下单失败: orderId={}", orderId, e);
                return new FebsResponse().fail().message("Payment channel exception: " + e.getMessage());
            }
        }
        return new FebsResponse().fail().message("Payment channel exception");
    }
    /**
     * BSPAY 支付结果通知(服务端回调)
     * <p>
     * BSPAY 在支付完成后会 POST JSON 通知到此地址。
     * 需在 BSPAY 下单时传 notify_url 为此地址。
     * <p>
     * 注意:收到后必须响应 "SUCCESS",BSPAY 才会停止重试(最多5次)。
     */
    @ApiOperation(value = "BSPAY 支付回调", notes = "接收BSPAY异步通知")
    @PostMapping(value = "/bsPayNotify")
    public String bsPayNotify(HttpServletRequest request) {
        // 读取 JSON body
        try {
            java.io.BufferedReader reader = request.getReader();
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
            String body = sb.toString();
            log.info("BSPAY 回调原始数据: {}", body);
            if (StrUtil.isBlank(body)) {
                log.error("BSPAY 回调数据为空");
                return "FAIL";
            }
            // 解析 JSON 为 Map
            Map<String, String> params = new HashMap<>();
            com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSONObject.parseObject(body);
            for (String key : json.keySet()) {
                params.put(key, json.getString(key));
            }
            log.info("BSPAY 回调参数: {}", params);
            boolean success = bsPayService.handleCallback(params);
            return success ? "SUCCESS" : "FAIL";
        } catch (Exception e) {
            log.error("BSPAY 回调处理异常", e);
            return "FAIL";
        }
    }
    @ApiOperation(value = "根据国家编码查询运费", notes = "根据国家编码查询对应运费")
    @ApiResponses({
            @ApiResponse(code = 200, message = "success")
src/main/java/cc/mrbird/febs/mall/controller/dependentStation/constant/OrderConstants.java
@@ -29,13 +29,14 @@
    public static final String ORDER_STATE = "order_state";
    /**
     * 支付方式 1-微信 2-支付宝 3-USDT 4-LWPAY支付 5-XT支付
     * 支付方式 1-微信 2-支付宝 3-USDT 4-LWPAY支付 5-XT支付 6-BSPAY支付(巴西PIX)
     */
    public static final Integer PAY_TYPE_WECHAT = 1;
    public static final Integer PAY_TYPE_ALIPAY = 2;
    public static final Integer PAY_TYPE_USDT = 3;
    public static final Integer PAY_TYPE_SYSTEM = 4;
    public static final Integer PAY_TYPE_XT = 5;
    public static final Integer PAY_TYPE_BS = 6;
    public static final String ORDER_PAY_TYPE = "order_pay_type";
    public static final String TRC20_SYSTEM_ADDRESS = "pay.trc20.address";
src/main/java/cc/mrbird/febs/mall/dto/ApiOrderPayDto.java
@@ -17,6 +17,6 @@
    private Long orderId;
    @NotNull(message = "支付方式不能为空")
    @ApiModelProperty(value = "支付方式 1-微信 2-支付宝 3-USDT 4-LwPAY 5-XTPAY", example = "you_ke_*****")
    @ApiModelProperty(value = "支付方式 1-微信 2-支付宝 3-USDT 4-LwPAY 5-XTPAY 6-BSPAY", example = "you_ke_*****")
    private Integer payType;
}
src/main/java/cc/mrbird/febs/mall/dto/BsPayConfigDto.java
New file
@@ -0,0 +1,21 @@
package cc.mrbird.febs.mall.dto;
import lombok.Data;
/**
 * BSPAY (巴西PIX) 支付配置 DTO
 * 对应 data_dictionary_custom 表 type='BSPAY_CONFIG' 的多条记录
 *
 * @author auto-generated
 */
@Data
public class BsPayConfigDto {
    /** BSPAY 商户号 */
    private String memberId;
    /** BSPAY 签名密钥 */
    private String secretKey;
    /** 异步回调通知地址 */
    private String notifyUrl;
    /** BSPAY API 基础地址 */
    private String apiBaseUrl;
}
src/main/java/cc/mrbird/febs/mall/dto/BsPayCreateOrderDto.java
New file
@@ -0,0 +1,23 @@
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.NotNull;
/**
 * BSPAY (巴西PIX) 创建订单请求 DTO
 *
 * @author auto-generated
 */
@Data
@ApiModel(value = "BsPayCreateOrderDto", description = "BSPAY巴西PIX支付下单参数")
public class BsPayCreateOrderDto {
    @Valid
    @NotNull(message = "订单信息不能为空")
    @ApiModelProperty(value = "订单信息", required = true)
    private AddOrderDto order;
}
src/main/java/cc/mrbird/febs/pay/service/BsPayService.java
New file
@@ -0,0 +1,382 @@
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.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.extern.slf4j.Slf4j;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Date;
import java.util.Map;
import java.util.TreeMap;
/**
 * BSPAY (巴西PIX) 支付服务
 * <p>
 * 处理 BSPAY 下单(PIX支付)和回调通知
 * <p>
 * BSPAY API 说明:
 * - 传输方式:HTTPS,POST,JSON
 * - 签名算法:参数按 ASCII 字典序排序 → key=value 拼接 → 末尾追加 &key={secret} → MD5 → 大写
 * - 下单接口:POST /api/pay/applyOrder (Content-Type: application/json)
 * - 支付回调:JSON POST,收到后需响应 "SUCCESS"
 * - 交易类型:trade_type=201 (巴西PIX)
 * - 货币类型:fee_type=BRL
 * - 金额单位:分 (1元=100分)
 *
 * @author auto-generated
 */
@Slf4j
@Service
public class BsPayService {
    @Resource
    private MallOrderInfoMapper mallOrderInfoMapper;
    @Resource
    private DataDictionaryCustomMapper dataDictionaryCustomMapper;
    @Resource
    private RedisUtils redisUtils;
    /** BSPAY 配置字典 type */
    private static final String DICT_TYPE = "BSPAY_CONFIG";
    /** BSPAY 下单接口路径 */
    private static final String APPLY_ORDER_PATH = "/api/pay/applyOrder";
    /** OkHttp JSON MediaType */
    private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json;charset=UTF-8");
    /** 交易类型:巴西PIX */
    private static final String TRADE_TYPE_PIX = "201";
    /** 货币类型:巴西雷亚尔 */
    private static final String FEE_TYPE_BRL = "BRL";
    /** 签名类型 */
    private static final String SIGN_TYPE_MD5 = "MD5";
    // ==================== 下单接口 ====================
    /**
     * 创建 BSPAY PIX 支付订单 → 获取支付跳转 URL
     *
     * @param order 订单实体
     * @return BSPAY 支付页面 URL (mweb_url)
     */
    public String createPayment(MallOrderInfo order) {
        String memberId = getConfigValue("MEMBER_ID");
        String secretKey = getConfigValue("SECRET_KEY");
        String notifyUrl = getConfigValue("NOTIFY_URL");
        String apiBaseUrl = getConfigValue("API_BASE_URL");
        if (StrUtil.isBlank(memberId) || StrUtil.isBlank(secretKey)) {
            throw new RuntimeException("BSPAY 商户号/秘钥未配置");
        }
        if (StrUtil.isBlank(apiBaseUrl)) {
            throw new RuntimeException("BSPAY API基础地址未配置");
        }
        if (StrUtil.isBlank(notifyUrl)) {
            throw new RuntimeException("BSPAY 回调地址未配置");
        }
        // 金额转 BRL 分 (cents)
        String totalFee = getBRLAmountInCents(order.getAmount());
        // 构建参数(TreeMap 自动按 ASCII 字典序排序)
        TreeMap<String, String> params = new TreeMap<>();
        params.put("member_id", memberId);
        params.put("out_trade_no", order.getOrderNo());
        params.put("body", "商品订单-" + order.getOrderNo());
        params.put("total_fee", totalFee);
        params.put("fee_type", FEE_TYPE_BRL);
        params.put("notify_url", notifyUrl);
        params.put("trade_type", TRADE_TYPE_PIX);
        params.put("sign_type", SIGN_TYPE_MD5);
        // 生成签名
        String sign = generateSign(params, secretKey);
        params.put("sign", sign);
        log.info("BSPAY 下单请求: memberId={}, orderNo={}, amount={}, totalFee={}",
                memberId, order.getOrderNo(), order.getAmount(), totalFee);
        // 发送 POST JSON 请求
        String requestUrl = apiBaseUrl + APPLY_ORDER_PATH;
        String response = postJson(requestUrl, params);
        log.info("BSPAY 下单响应: {}", response);
        if (StrUtil.isBlank(response)) {
            throw new RuntimeException("BSPAY 响应为空");
        }
        // 解析响应
        JSONObject json = JSONObject.parseObject(response);
        // 检查通信状态
        String returnCode = json.getString("return_code");
        if (!"SUCCESS".equals(returnCode)) {
            String returnMsg = json.getString("return_msg");
            String errCode = json.getString("err_code");
            String errCodeDes = json.getString("err_code_des");
            throw new RuntimeException("BSPAY 下单失败: " + returnMsg
                    + (errCode != null ? " [" + errCode + ":" + errCodeDes + "]" : ""));
        }
        // 验证签名
        String respSign = json.getString("sign");
        if (StrUtil.isNotBlank(respSign)) {
            TreeMap<String, String> verifyParams = new TreeMap<>();
            for (String key : json.keySet()) {
                if (!"sign".equals(key)) {
                    String val = json.getString(key);
                    if (StrUtil.isNotBlank(val)) {
                        verifyParams.put(key, val);
                    }
                }
            }
            if (!verifySign(verifyParams, secretKey, respSign)) {
                log.warn("BSPAY 响应签名验证失败,但仍继续处理");
            }
        }
        // 检查业务结果
        String resultCode = json.getString("result_code");
        if (!"SUCCESS".equals(resultCode)) {
            throw new RuntimeException("BSPAY 下单业务失败: " + json.getString("err_code_des"));
        }
        String mwebUrl = json.getString("mweb_url");
        if (StrUtil.isBlank(mwebUrl)) {
            throw new RuntimeException("BSPAY 下单未返回支付链接");
        }
        // 记录平台支付订单号
        String prepayId = json.getString("prepay_id");
        if (StrUtil.isNotBlank(prepayId)) {
            mallOrderInfoMapper.update(
                    null,
                    Wrappers.lambdaUpdate(MallOrderInfo.class)
                            .set(MallOrderInfo::getPayOrderNo, prepayId)
                            .eq(MallOrderInfo::getId, order.getId())
            );
        }
        return mwebUrl;
    }
    // ==================== 回调处理 ====================
    /**
     * 处理 BSPAY 支付结果通知
     * <p>
     * 回调参数为 JSON 格式:
     * return_code, return_msg, member_id, sign, result_code, trade_type,
     * total_fee, transaction_id, out_trade_no, time_end
     *
     * @param params 回调参数 Map
     * @return true-处理成功, false-处理失败
     */
    public boolean handleCallback(Map<String, String> params) {
        String secretKey = getConfigValue("SECRET_KEY");
        if (StrUtil.isBlank(secretKey)) {
            log.error("BSPAY 秘钥未配置,无法验签");
            return false;
        }
        // 1. 验证签名(sign 不参与签名)
        String receivedSign = params.get("sign");
        if (StrUtil.isBlank(receivedSign)) {
            log.error("BSPAY 回调缺少 sign 参数");
            return false;
        }
        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());
            }
        }
        if (!verifySign(signParams, secretKey, receivedSign)) {
            log.error("BSPAY 回调签名验证失败: params={}", params);
            return false;
        }
        String returnCode = params.get("return_code");
        String resultCode = params.get("result_code");
        String outTradeNo = params.get("out_trade_no");
        String totalFee = params.get("total_fee");
        String transactionId = params.get("transaction_id");
        String tradeType = params.get("trade_type");
        log.info("BSPAY 回调: out_trade_no={}, return_code={}, result_code={}, total_fee={}, transaction_id={}",
                outTradeNo, returnCode, resultCode, totalFee, transactionId);
        // 2. 判断通信状态
        if (!"SUCCESS".equals(returnCode)) {
            log.warn("BSPAY 回调通信失败: out_trade_no={}, return_code={}", outTradeNo, returnCode);
            return true;
        }
        // 3. 判断支付结果
        if (!"SUCCESS".equals(resultCode)) {
            log.warn("BSPAY 支付未成功: out_trade_no={}, result_code={}", outTradeNo, resultCode);
            return true;
        }
        // 4. 查询订单
        MallOrderInfo order = mallOrderInfoMapper.selectOne(
                Wrappers.lambdaQuery(MallOrderInfo.class)
                        .eq(MallOrderInfo::getOrderNo, outTradeNo)
        );
        if (order == null) {
            log.error("BSPAY 回调订单不存在: out_trade_no={}", outTradeNo);
            return false;
        }
        // 5. 幂等检查(避免重复回调)
        if (OrderStatusEnum.WAIT_PAY.getValue() != order.getStatus()) {
            log.info("BSPAY 订单已处理: out_trade_no={}, status={}", outTradeNo, order.getStatus());
            return true;
        }
        // 6. 更新订单状态为待发货
        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, "BSPAY支付(PIX)")
                        .set(MallOrderInfo::getPayResult, "1")
                        .eq(MallOrderInfo::getId, order.getId())
        );
        // 清理 Redis 中的金额匹配 key
        String amountKey = OrderConstants.TRC20_ORDER_KEY + order.getAmount();
        redisUtils.del(amountKey);
        log.info("BSPAY 支付成功: out_trade_no={}, transaction_id={}, total_fee={}",
                outTradeNo, transactionId, totalFee);
        return true;
    }
    // ==================== 金额转换 ====================
    /**
     * 将订单金额转换为 BRL 分(cents)
     * 1. 先按汇率转换为 BRL
     * 2. 再乘以 100 转换为分
     *
     * @param amount 订单金额
     * @return BRL 金额(分,不含小数)
     */
    private String getBRLAmountInCents(BigDecimal amount) {
        DataDictionaryCustom dataDictionaryCustom = dataDictionaryCustomMapper.selectDicDataByTypeAndCode(
                "MONEY_CHANGE", "BRL"
        );
        BigDecimal rate = new BigDecimal("5.18");
        if (dataDictionaryCustom != null) {
            rate = new BigDecimal(dataDictionaryCustom.getValue());
        }
        // 转 BRL → 转分 → 取整数
        return amount.multiply(rate)
                .multiply(new BigDecimal("100"))
                .setScale(0, RoundingMode.DOWN)
                .toString();
    }
    // ==================== 签名工具 ====================
    /**
     * 生成 MD5 签名(大写)
     * <p>
     * 1. 参数按 ASCII 字典序排序 (TreeMap)
     * 2. key=value&key=value 拼接
     * 3. 末尾追加 &key={secretKey}
     * 4. MD5 → 大写
     */
    private String generateSign(TreeMap<String, String> params, String secretKey) {
        String signStr = buildSignString(params) + "&key=" + secretKey;
        log.info("BSPAY 待签名字符串: {}", signStr);
        return SecureUtil.md5(signStr).toUpperCase();
    }
    /**
     * 验证签名
     */
    private boolean verifySign(TreeMap<String, String> params, String secretKey, String expectedSign) {
        String calculatedSign = generateSign(params, secretKey);
        boolean valid = calculatedSign.equalsIgnoreCase(expectedSign);
        if (!valid) {
            log.warn("BSPAY 签名不匹配: expected={}, calculated={}", expectedSign, calculatedSign);
        }
        return valid;
    }
    /**
     * 将参数拼接为 key=value&key=value 格式(不含 &key=)
     */
    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();
    }
    // ==================== 配置读取 ====================
    /**
     * 从数据字典获取 BSPAY 配置
     */
    public String getConfigValue(String code) {
        DataDictionaryCustom dict = dataDictionaryCustomMapper.selectDicDataByTypeAndCode(DICT_TYPE, code);
        return dict != null ? dict.getValue() : null;
    }
    // ==================== HTTP 请求工具 ====================
    /**
     * POST JSON 请求
     */
    private String postJson(String url, TreeMap<String, String> params) {
        try {
            String jsonBody = JSONObject.toJSONString(params);
            log.info("BSPAY POST JSON: url={}, body={}", url, jsonBody);
            OkHttpClient client = new OkHttpClient();
            RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, jsonBody);
            Request request = new Request.Builder()
                    .url(url)
                    .post(body)
                    .build();
            try (Response response = client.newCall(request).execute()) {
                if (response.body() != null) {
                    return response.body().string();
                }
                return null;
            }
        } catch (Exception e) {
            log.error("BSPAY POST JSON 异常: url={}", url, e);
            return null;
        }
    }
}
src/main/resources/templates/febs/views/modules/system/bsPayConfig.html
New file
@@ -0,0 +1,88 @@
<div class="layui-fluid layui-anim febs-anim" id="bspay-config" lay-title="BSPAY巴西PIX支付设置">
    <div class="layui-row layui-col-space8 febs-container">
        <form class="layui-form" action="" lay-filter="bspay-config-form">
            <div class="layui-card">
                <div class="layui-card-header">BSPAY 巴西PIX支付参数配置</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="${bsPayConfig.memberId}"
                                   lay-verify="required" autocomplete="off" class="layui-input" placeholder="BSPAY 商户号(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="${bsPayConfig.secretKey}"
                                   lay-verify="required" autocomplete="off" class="layui-input" placeholder="BSPAY 签名密钥/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="${bsPayConfig.notifyUrl}"
                                   lay-verify="required|url" autocomplete="off" class="layui-input" placeholder="异步回调通知地址(notify_url)">
                        </div>
                        <div class="layui-word-aux" style="margin-left: 150px;">BSPAY 支付成功后异步通知的地址,必须以 http(s):// 开头</div>
                    </div>
                    <div class="layui-form-item">
                        <label class="layui-form-label required">API基础地址:</label>
                        <div class="layui-input-block">
                            <input type="text" name="apiBaseUrl" data-th-id="${bsPayConfig.apiBaseUrl}"
                                   lay-verify="required|url" autocomplete="off" class="layui-input" placeholder="BSPAY API基础地址">
                        </div>
                        <div class="layui-word-aux" style="margin-left: 150px;">BSPAY API服务器地址,如 https://api.bspay.com,下单路径为 {apiBaseUrl}/api/pay/applyOrder</div>
                    </div>
                </div>
                <div class="layui-card-footer">
                    <button class="layui-btn layui-btn-normal" lay-submit="" lay-filter="bspay-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,
            bsPayConfig = [[${bsPayConfig}]],
            validate = layui.validate,
            $view = $('#bspay-config');
        form.verify(validate);
        if (bsPayConfig) {
            form.val("bspay-config-form", {
                "memberId": bsPayConfig.memberId,
                "secretKey": bsPayConfig.secretKey,
                "notifyUrl": bsPayConfig.notifyUrl,
                "apiBaseUrl": bsPayConfig.apiBaseUrl
            });
        }
        form.render();
        form.on('submit(bspay-config-form-submit)', function (data) {
            febs.post(ctx + 'admin/system/bsPayConfig', data.field, function (res) {
                febs.alert.success('保存成功');
            });
            return false;
        });
    });
</script>