Administrator
6 hours ago 0bd44afe3417454c5247c10b70897331e586536b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
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;
        }
    }
}