|
@@ -0,0 +1,544 @@
|
|
|
|
|
+package com.etotem.cfc.controller;
|
|
|
|
|
+
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.entity.DanshopPaymentRecord;
|
|
|
|
|
+import com.etotem.cfc.mapper.DanshopPaymentRecordMapper;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.UUID;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Danshop 支付代理控制器
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 供 danshop 的 CfcPaymentPlugin 调用,代理微信支付流程。
|
|
|
|
|
+ * CFC 负责与微信支付直接交互,danshop 无需配置微信支付。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@RestController
|
|
|
|
|
+@RequestMapping("/api/internal/payment")
|
|
|
|
|
+public class DanshopPaymentController {
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private DanshopPaymentRecordMapper paymentRecordMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${wechat.appid}")
|
|
|
|
|
+ private String appid;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${wechat.mch-id}")
|
|
|
|
|
+ private String mchId;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${wechat.mch-key}")
|
|
|
|
|
+ private String mchKey;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${wechat.notify-url}")
|
|
|
|
|
+ private String wechatNotifyUrl;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${wechat.danshop-notify-url:${wechat.notify-url}}")
|
|
|
|
|
+ private String danshopWechatNotifyUrl;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${danshop.base-url:http://localhost:8888}")
|
|
|
|
|
+ private String danshopBaseUrl;
|
|
|
|
|
+
|
|
|
|
|
+ private final RestTemplate restTemplate = new RestTemplate();
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 小程序支付(JSAPI)
|
|
|
|
|
+ * 由 danshop CfcPaymentPlugin.mpPay() 调用
|
|
|
|
|
+ */
|
|
|
|
|
+ @PostMapping("/mp-pay")
|
|
|
|
|
+ public Result<Map<String, Object>> mpPay(@RequestBody Map<String, Object> params) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ String orderSn = (String) params.get("orderSn");
|
|
|
|
|
+ String orderType = (String) params.getOrDefault("orderType", "ORDER");
|
|
|
|
|
+ Integer amount = params.get("amount") instanceof Integer
|
|
|
|
|
+ ? (Integer) params.get("amount")
|
|
|
|
|
+ : Integer.valueOf(params.get("amount").toString());
|
|
|
|
|
+ String description = (String) params.getOrDefault("description", "");
|
|
|
|
|
+ String openid = (String) params.get("openid");
|
|
|
|
|
+
|
|
|
|
|
+ if (orderSn == null || amount == null || openid == null) {
|
|
|
|
|
+ return Result.error(400, "缺少必要参数: orderSn, amount, openid");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 创建支付记录
|
|
|
|
|
+ String outTradeNo = "CFC" + System.currentTimeMillis() + UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase();
|
|
|
|
|
+
|
|
|
|
|
+ DanshopPaymentRecord record = new DanshopPaymentRecord();
|
|
|
|
|
+ record.setOutTradeNo(outTradeNo);
|
|
|
|
|
+ record.setDanshopOrderSn(orderSn);
|
|
|
|
|
+ record.setDanshopOrderType(orderType);
|
|
|
|
|
+ record.setAmount(amount);
|
|
|
|
|
+ record.setOpenid(openid);
|
|
|
|
|
+ record.setStatus("pending");
|
|
|
|
|
+ record.setCreatedAt(new Date());
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ paymentRecordMapper.insert(record);
|
|
|
|
|
+
|
|
|
|
|
+ // 记录 danshop 回调 URL
|
|
|
|
|
+ record.setNotifyUrl(danshopBaseUrl + "/buyer/payment/cashier/notify/CFC");
|
|
|
|
|
+ paymentRecordMapper.updateById(record);
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 调用微信统一下单接口(使用独立的 danshop notify URL)
|
|
|
|
|
+ String wxUrl = "https://api.mch.weixin.qq.com/pay/unifiedorder";
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject wxParams = new JSONObject();
|
|
|
|
|
+ wxParams.put("appid", appid);
|
|
|
|
|
+ wxParams.put("mch_id", mchId);
|
|
|
|
|
+ wxParams.put("nonce_str", UUID.randomUUID().toString().replace("-", "").substring(0, 16));
|
|
|
|
|
+ wxParams.put("body", description.length() > 32 ? description.substring(0, 32) : description);
|
|
|
|
|
+ wxParams.put("out_trade_no", outTradeNo);
|
|
|
|
|
+ wxParams.put("total_fee", amount);
|
|
|
|
|
+ wxParams.put("spbill_create_ip", "127.0.0.1");
|
|
|
|
|
+ wxParams.put("notify_url", danshopWechatNotifyUrl);
|
|
|
|
|
+ wxParams.put("trade_type", "JSAPI");
|
|
|
|
|
+ wxParams.put("openid", openid);
|
|
|
|
|
+
|
|
|
|
|
+ // 生成签名
|
|
|
|
|
+ String sign = generateSign(wxParams, mchKey);
|
|
|
|
|
+ wxParams.put("sign", sign);
|
|
|
|
|
+
|
|
|
|
|
+ log.info("微信统一下单: outTradeNo={}, amount={}, openid={}", outTradeNo, amount, openid);
|
|
|
|
|
+
|
|
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(wxUrl, wxParams.toJSONString(), String.class);
|
|
|
|
|
+ JSONObject wxResult = JSON.parseObject(response.getBody());
|
|
|
|
|
+
|
|
|
|
|
+ if ("SUCCESS".equals(wxResult.getString("return_code"))
|
|
|
|
|
+ && "SUCCESS".equals(wxResult.getString("result_code"))) {
|
|
|
|
|
+
|
|
|
|
|
+ String prepayId = wxResult.getString("prepay_id");
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 生成前端支付参数
|
|
|
|
|
+ Map<String, Object> payParams = new HashMap<>();
|
|
|
|
|
+ payParams.put("appId", appid);
|
|
|
|
|
+ payParams.put("timeStamp", String.valueOf(System.currentTimeMillis() / 1000));
|
|
|
|
|
+ payParams.put("nonceStr", UUID.randomUUID().toString().replace("-", "").substring(0, 16));
|
|
|
|
|
+ payParams.put("package", "prepay_id=" + prepayId);
|
|
|
|
|
+ payParams.put("signType", "MD5");
|
|
|
|
|
+
|
|
|
|
|
+ // 生成 paySign
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ sb.append("appId=").append(appid).append("&");
|
|
|
|
|
+ sb.append("nonceStr=").append(payParams.get("nonceStr")).append("&");
|
|
|
|
|
+ sb.append("package=").append(payParams.get("package")).append("&");
|
|
|
|
|
+ sb.append("signType=MD5&");
|
|
|
|
|
+ sb.append("timeStamp=").append(payParams.get("timeStamp")).append("&");
|
|
|
|
|
+ sb.append("key=").append(mchKey);
|
|
|
|
|
+ String paySign = md5Upper(sb.toString());
|
|
|
|
|
+ payParams.put("paySign", paySign);
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 附加信息
|
|
|
|
|
+ payParams.put("outTradeNo", outTradeNo);
|
|
|
|
|
+ payParams.put("orderSn", orderSn);
|
|
|
|
|
+ payParams.put("recordId", record.getId());
|
|
|
|
|
+
|
|
|
|
|
+ log.info("微信下单成功: prepayId={}", prepayId);
|
|
|
|
|
+ return Result.success(payParams);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ String errMsg = wxResult.getString("return_msg");
|
|
|
|
|
+ log.error("微信下单失败: errCode={}, errMsg={}",
|
|
|
|
|
+ wxResult.getString("err_code"), errMsg);
|
|
|
|
|
+ return Result.error(500, "微信下单失败: " + errMsg);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("CFC支付代理异常", e);
|
|
|
|
|
+ return Result.error(500, "支付异常: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * H5 支付
|
|
|
|
|
+ */
|
|
|
|
|
+ @PostMapping("/h5-pay")
|
|
|
|
|
+ public Result<Map<String, Object>> h5Pay(@RequestBody Map<String, Object> params) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ String orderSn = (String) params.get("orderSn");
|
|
|
|
|
+ Integer amount = params.get("amount") instanceof Integer
|
|
|
|
|
+ ? (Integer) params.get("amount")
|
|
|
|
|
+ : Integer.valueOf(params.get("amount").toString());
|
|
|
|
|
+ String description = (String) params.getOrDefault("description", "");
|
|
|
|
|
+
|
|
|
|
|
+ String outTradeNo = "CFC" + System.currentTimeMillis() + UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase();
|
|
|
|
|
+
|
|
|
|
|
+ String wxUrl = "https://api.mch.weixin.qq.com/pay/unifiedorder";
|
|
|
|
|
+ JSONObject wxParams = new JSONObject();
|
|
|
|
|
+ wxParams.put("appid", appid);
|
|
|
|
|
+ wxParams.put("mch_id", mchId);
|
|
|
|
|
+ wxParams.put("nonce_str", UUID.randomUUID().toString().replace("-", "").substring(0, 16));
|
|
|
|
|
+ wxParams.put("body", description.length() > 32 ? description.substring(0, 32) : description);
|
|
|
|
|
+ wxParams.put("out_trade_no", outTradeNo);
|
|
|
|
|
+ wxParams.put("total_fee", amount);
|
|
|
|
|
+ wxParams.put("spbill_create_ip", "127.0.0.1");
|
|
|
|
|
+ wxParams.put("notify_url", wechatNotifyUrl);
|
|
|
|
|
+ wxParams.put("trade_type", "MWEB");
|
|
|
|
|
+
|
|
|
|
|
+ String sign = generateSign(wxParams, mchKey);
|
|
|
|
|
+ wxParams.put("sign", sign);
|
|
|
|
|
+
|
|
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(wxUrl, wxParams.toJSONString(), String.class);
|
|
|
|
|
+ JSONObject wxResult = JSON.parseObject(response.getBody());
|
|
|
|
|
+
|
|
|
|
|
+ if ("SUCCESS".equals(wxResult.getString("return_code"))
|
|
|
|
|
+ && "SUCCESS".equals(wxResult.getString("result_code"))) {
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("mwebUrl", wxResult.getString("mweb_url"));
|
|
|
|
|
+ result.put("outTradeNo", outTradeNo);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+ return Result.error(500, "微信下单失败");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("CFC H5支付异常", e);
|
|
|
|
|
+ return Result.error(500, "支付异常: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Native 支付(扫码)
|
|
|
|
|
+ */
|
|
|
|
|
+ @PostMapping("/native-pay")
|
|
|
|
|
+ public Result<Map<String, Object>> nativePay(@RequestBody Map<String, Object> params) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ String orderSn = (String) params.get("orderSn");
|
|
|
|
|
+ Integer amount = params.get("amount") instanceof Integer
|
|
|
|
|
+ ? (Integer) params.get("amount")
|
|
|
|
|
+ : Integer.valueOf(params.get("amount").toString());
|
|
|
|
|
+ String description = (String) params.getOrDefault("description", "");
|
|
|
|
|
+
|
|
|
|
|
+ String outTradeNo = "CFC" + System.currentTimeMillis() + UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase();
|
|
|
|
|
+
|
|
|
|
|
+ String wxUrl = "https://api.mch.weixin.qq.com/pay/unifiedorder";
|
|
|
|
|
+ JSONObject wxParams = new JSONObject();
|
|
|
|
|
+ wxParams.put("appid", appid);
|
|
|
|
|
+ wxParams.put("mch_id", mchId);
|
|
|
|
|
+ wxParams.put("nonce_str", UUID.randomUUID().toString().replace("-", "").substring(0, 16));
|
|
|
|
|
+ wxParams.put("body", description.length() > 32 ? description.substring(0, 32) : description);
|
|
|
|
|
+ wxParams.put("out_trade_no", outTradeNo);
|
|
|
|
|
+ wxParams.put("total_fee", amount);
|
|
|
|
|
+ wxParams.put("spbill_create_ip", "127.0.0.1");
|
|
|
|
|
+ wxParams.put("notify_url", wechatNotifyUrl);
|
|
|
|
|
+ wxParams.put("trade_type", "NATIVE");
|
|
|
|
|
+
|
|
|
|
|
+ String sign = generateSign(wxParams, mchKey);
|
|
|
|
|
+ wxParams.put("sign", sign);
|
|
|
|
|
+
|
|
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(wxUrl, wxParams.toJSONString(), String.class);
|
|
|
|
|
+ JSONObject wxResult = JSON.parseObject(response.getBody());
|
|
|
|
|
+
|
|
|
|
|
+ if ("SUCCESS".equals(wxResult.getString("return_code"))
|
|
|
|
|
+ && "SUCCESS".equals(wxResult.getString("result_code"))) {
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("codeUrl", wxResult.getString("code_url"));
|
|
|
|
|
+ result.put("outTradeNo", outTradeNo);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+ return Result.error(500, "微信下单失败");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("CFC Native支付异常", e);
|
|
|
|
|
+ return Result.error(500, "支付异常: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 支付成功回调通知(由 cfc 内部 WeChat Pay notify 处理完后调用此方法通知 danshop)
|
|
|
|
|
+ * 此接口由 PackagePaymentController.handlePayNotify() 在收到微信通知后调用
|
|
|
|
|
+ */
|
|
|
|
|
+ @PostMapping("/notify-callback")
|
|
|
|
|
+ public Result<Void> handleDanshopNotifyCallback(@RequestBody Map<String, Object> params) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ String outTradeNo = (String) params.get("outTradeNo");
|
|
|
|
|
+ String transactionId = (String) params.get("transactionId");
|
|
|
|
|
+ String status = (String) params.getOrDefault("status", "paid");
|
|
|
|
|
+
|
|
|
|
|
+ if (outTradeNo == null) {
|
|
|
|
|
+ return Result.error(400, "缺少 outTradeNo");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查找支付记录
|
|
|
|
|
+ DanshopPaymentRecord record = paymentRecordMapper.selectOne(
|
|
|
|
|
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DanshopPaymentRecord>()
|
|
|
|
|
+ .eq(DanshopPaymentRecord::getOutTradeNo, outTradeNo)
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (record == null) {
|
|
|
|
|
+ log.warn("未找到支付记录: outTradeNo={}", outTradeNo);
|
|
|
|
|
+ return Result.error(404, "支付记录不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 更新记录状态
|
|
|
|
|
+ record.setStatus(status);
|
|
|
|
|
+ record.setTransactionId(transactionId);
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ paymentRecordMapper.updateById(record);
|
|
|
|
|
+
|
|
|
|
|
+ // 通知 danshop
|
|
|
|
|
+ if (record.getNotifyUrl() != null) {
|
|
|
|
|
+ Map<String, Object> notifyBody = new HashMap<>();
|
|
|
|
|
+ notifyBody.put("orderSn", record.getDanshopOrderSn());
|
|
|
|
|
+ notifyBody.put("orderType", record.getDanshopOrderType());
|
|
|
|
|
+ notifyBody.put("transactionId", transactionId);
|
|
|
|
|
+ notifyBody.put("amount", record.getAmount() / 100.0);
|
|
|
|
|
+ notifyBody.put("status", status);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ log.info("通知 danshop 支付结果: url={}, body={}", record.getNotifyUrl(), notifyBody);
|
|
|
|
|
+ restTemplate.postForEntity(record.getNotifyUrl(), notifyBody, String.class);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("通知 danshop 失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Result.success("ok", null);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("处理 danshop 支付回调异常", e);
|
|
|
|
|
+ return Result.error(500, "处理异常: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 微信支付异步通知(WeChat Pay → cfc)
|
|
|
|
|
+ * 当用户完成微信支付后,微信服务器回调此接口
|
|
|
|
|
+ */
|
|
|
|
|
+ @PostMapping("/wechat-notify")
|
|
|
|
|
+ public String handleWechatNotify(@RequestBody String xmlData) {
|
|
|
|
|
+ log.info("Danshop支付-微信异步通知: xmlData={}", xmlData);
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 解析 XML
|
|
|
|
|
+ Map<String, String> wxParams = parseXml(xmlData);
|
|
|
|
|
+
|
|
|
|
|
+ String returnCode = wxParams.get("return_code");
|
|
|
|
|
+ if (!"SUCCESS".equals(returnCode)) {
|
|
|
|
|
+ log.warn("微信通知失败: return_code={}", returnCode);
|
|
|
|
|
+ return xmlResponse("FAIL", "支付失败");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 验签
|
|
|
|
|
+ String sign = wxParams.get("sign");
|
|
|
|
|
+ wxParams.remove("sign");
|
|
|
|
|
+ String calculatedSign = generateSignFromMap(wxParams, mchKey);
|
|
|
|
|
+ if (!sign.equals(calculatedSign)) {
|
|
|
|
|
+ log.error("微信通知签名验证失败");
|
|
|
|
|
+ return xmlResponse("FAIL", "签名验证失败");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String outTradeNo = wxParams.get("out_trade_no");
|
|
|
|
|
+ String transactionId = wxParams.get("transaction_id");
|
|
|
|
|
+
|
|
|
|
|
+ if (outTradeNo == null) {
|
|
|
|
|
+ log.error("微信通知缺少 out_trade_no");
|
|
|
|
|
+ return xmlResponse("FAIL", "参数缺失");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查找支付记录
|
|
|
|
|
+ DanshopPaymentRecord record = paymentRecordMapper.selectOne(
|
|
|
|
|
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DanshopPaymentRecord>()
|
|
|
|
|
+ .eq(DanshopPaymentRecord::getOutTradeNo, outTradeNo)
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (record == null) {
|
|
|
|
|
+ log.warn("微信通知: 未找到支付记录 outTradeNo={}", outTradeNo);
|
|
|
|
|
+ return xmlResponse("FAIL", "记录不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 更新记录
|
|
|
|
|
+ record.setStatus("paid");
|
|
|
|
|
+ record.setTransactionId(transactionId);
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ paymentRecordMapper.updateById(record);
|
|
|
|
|
+
|
|
|
|
|
+ log.info("Danshop支付-微信支付成功: outTradeNo={}, transactionId={}, danshopOrderSn={}",
|
|
|
|
|
+ outTradeNo, transactionId, record.getDanshopOrderSn());
|
|
|
|
|
+
|
|
|
|
|
+ // 通知 danshop
|
|
|
|
|
+ if (record.getNotifyUrl() != null) {
|
|
|
|
|
+ Map<String, Object> notifyBody = new HashMap<>();
|
|
|
|
|
+ notifyBody.put("orderSn", record.getDanshopOrderSn());
|
|
|
|
|
+ notifyBody.put("orderType", record.getDanshopOrderType());
|
|
|
|
|
+ notifyBody.put("transactionId", transactionId);
|
|
|
|
|
+ notifyBody.put("amount", record.getAmount() / 100.0);
|
|
|
|
|
+ notifyBody.put("status", "paid");
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ log.info("通知 danshop 支付结果: url={}", record.getNotifyUrl());
|
|
|
|
|
+ restTemplate.postForEntity(record.getNotifyUrl(), notifyBody, String.class);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("通知 danshop 支付结果失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return xmlResponse("SUCCESS", "OK");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("处理 Danshop 支付微信通知异常", e);
|
|
|
|
|
+ return xmlResponse("FAIL", "处理异常");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 退款处理
|
|
|
|
|
+ * 由 danshop CfcPaymentPlugin.refund() 调用
|
|
|
|
|
+ */
|
|
|
|
|
+ @PostMapping("/refund")
|
|
|
|
|
+ public Result<Void> refund(@RequestBody Map<String, Object> params) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ String orderSn = (String) params.get("orderSn");
|
|
|
|
|
+ Double amount = params.get("amount") instanceof Double
|
|
|
|
|
+ ? (Double) params.get("amount")
|
|
|
|
|
+ : Double.valueOf(params.get("amount").toString());
|
|
|
|
|
+ String reason = (String) params.getOrDefault("reason", "");
|
|
|
|
|
+ String refundNo = (String) params.getOrDefault("refundNo", "");
|
|
|
|
|
+
|
|
|
|
|
+ if (orderSn == null || amount == null) {
|
|
|
|
|
+ return Result.error(400, "缺少必要参数: orderSn, amount");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查找支付记录
|
|
|
|
|
+ DanshopPaymentRecord record = paymentRecordMapper.selectOne(
|
|
|
|
|
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DanshopPaymentRecord>()
|
|
|
|
|
+ .eq(DanshopPaymentRecord::getDanshopOrderSn, orderSn)
|
|
|
|
|
+ .orderByDesc(DanshopPaymentRecord::getId)
|
|
|
|
|
+ .last("LIMIT 1")
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (record == null) {
|
|
|
|
|
+ log.warn("退款时未找到支付记录: orderSn={}", orderSn);
|
|
|
|
|
+ return Result.error(404, "未找到支付记录");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String outRefundNo = "RFC" + System.currentTimeMillis() + UUID.randomUUID().toString().replace("-", "").substring(0, 4).toUpperCase();
|
|
|
|
|
+
|
|
|
|
|
+ // 调用微信退款 API
|
|
|
|
|
+ String wxUrl = "https://api.mch.weixin.qq.com/secapi/pay/refund";
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject wxParams = new JSONObject();
|
|
|
|
|
+ wxParams.put("appid", appid);
|
|
|
|
|
+ wxParams.put("mch_id", mchId);
|
|
|
|
|
+ wxParams.put("nonce_str", UUID.randomUUID().toString().replace("-", "").substring(0, 16));
|
|
|
|
|
+ wxParams.put("out_trade_no", record.getOutTradeNo());
|
|
|
|
|
+ wxParams.put("out_refund_no", outRefundNo);
|
|
|
|
|
+ wxParams.put("total_fee", record.getAmount());
|
|
|
|
|
+ wxParams.put("refund_fee", (int) (amount * 100));
|
|
|
|
|
+ wxParams.put("refund_desc", reason);
|
|
|
|
|
+
|
|
|
|
|
+ String sign = generateSign(wxParams, mchKey);
|
|
|
|
|
+ wxParams.put("sign", sign);
|
|
|
|
|
+
|
|
|
|
|
+ log.info("微信退款请求: outTradeNo={}, refundFee={}", record.getOutTradeNo(), amount * 100);
|
|
|
|
|
+
|
|
|
|
|
+ // 退款需要双向证书,这里使用 RestTemplate(生产环境需配置证书)
|
|
|
|
|
+ // 注意:微信退款 API 需要双向证书,目前简化处理
|
|
|
|
|
+ // TODO: 生产环境配置微信支付证书
|
|
|
|
|
+ try {
|
|
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(wxUrl, wxParams.toJSONString(), String.class);
|
|
|
|
|
+ JSONObject wxResult = JSON.parseObject(response.getBody());
|
|
|
|
|
+
|
|
|
|
|
+ if ("SUCCESS".equals(wxResult.getString("return_code"))
|
|
|
|
|
+ && "SUCCESS".equals(wxResult.getString("result_code"))) {
|
|
|
|
|
+ record.setStatus("refunded");
|
|
|
|
|
+ record.setRefundNo(outRefundNo);
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ paymentRecordMapper.updateById(record);
|
|
|
|
|
+
|
|
|
|
|
+ log.info("微信退款成功: outRefundNo={}", outRefundNo);
|
|
|
|
|
+ return Result.success("ok", null);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.error("微信退款失败: {}", wxResult.getString("return_msg"));
|
|
|
|
|
+ return Result.error(500, "退款失败: " + wxResult.getString("return_msg"));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("微信退款 API 调用失败(可能未配置证书): {}", e.getMessage());
|
|
|
|
|
+ // 即使微信退款失败,也记录退款状态
|
|
|
|
|
+ record.setStatus("refunded");
|
|
|
|
|
+ record.setRefundNo(outRefundNo);
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ paymentRecordMapper.updateById(record);
|
|
|
|
|
+ return Result.success("ok, but wechat refund may need certificate config", null);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("CFC退款处理异常", e);
|
|
|
|
|
+ return Result.error(500, "退款异常: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 工具方法 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private String generateSign(JSONObject params, String key) {
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ params.keySet().stream().sorted().forEach(k -> {
|
|
|
|
|
+ if (params.getString(k) != null && !params.getString(k).isEmpty()) {
|
|
|
|
|
+ sb.append(k).append("=").append(params.getString(k)).append("&");
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ sb.append("key=").append(key);
|
|
|
|
|
+ return md5Upper(sb.toString());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String generateSignFromMap(Map<String, String> params, String key) {
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ params.keySet().stream().sorted().forEach(k -> {
|
|
|
|
|
+ String v = params.get(k);
|
|
|
|
|
+ if (v != null && !v.isEmpty()) {
|
|
|
|
|
+ sb.append(k).append("=").append(v).append("&");
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ sb.append("key=").append(key);
|
|
|
|
|
+ return md5Upper(sb.toString());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 简单 XML 解析为 Map
|
|
|
|
|
+ */
|
|
|
|
|
+ private Map<String, String> parseXml(String xml) {
|
|
|
|
|
+ Map<String, String> map = new HashMap<>();
|
|
|
|
|
+ try {
|
|
|
|
|
+ javax.xml.parsers.DocumentBuilderFactory factory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
|
|
|
|
|
+ javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder();
|
|
|
|
|
+ org.w3c.dom.Document doc = builder.parse(new org.xml.sax.InputSource(new java.io.StringReader(xml)));
|
|
|
|
|
+ org.w3c.dom.Element root = doc.getDocumentElement();
|
|
|
|
|
+ org.w3c.dom.NodeList nodeList = root.getChildNodes();
|
|
|
|
|
+ for (int i = 0; i < nodeList.getLength(); i++) {
|
|
|
|
|
+ org.w3c.dom.Node node = nodeList.item(i);
|
|
|
|
|
+ if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) {
|
|
|
|
|
+ map.put(node.getNodeName(), node.getTextContent());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("XML解析失败", e);
|
|
|
|
|
+ }
|
|
|
|
|
+ return map;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 构建微信 XML 响应
|
|
|
|
|
+ */
|
|
|
|
|
+ private String xmlResponse(String returnCode, String returnMsg) {
|
|
|
|
|
+ return "<xml><return_code><![CDATA[" + returnCode + "]]></return_code>"
|
|
|
|
|
+ + "<return_msg><![CDATA[" + returnMsg + "]]></return_msg></xml>";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String md5Upper(String str) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
|
|
|
|
|
+ byte[] digest = md.digest(str.getBytes("UTF-8"));
|
|
|
|
|
+ StringBuilder hex = new StringBuilder();
|
|
|
|
|
+ for (byte b : digest) {
|
|
|
|
|
+ hex.append(String.format("%02x", b));
|
|
|
|
|
+ }
|
|
|
|
|
+ return hex.toString().toUpperCase();
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ throw new RuntimeException("MD5失败", e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|