Просмотр исходного кода

feat: CFC-DanShop集成 - 支付代理+批量迁移

- DanshopPaymentController: CFC端支付代理控制器,供danshop调用
- DanshopPaymentRecord: 支付代理记录实体+Mapper
- DanshopMigrationService: 批量迁移用户/商品到DanShop
- DanshopMigrationController: 迁移触发端点
- application.yml: 新增wechat.danshop-notify-url配置
User 4 месяцев назад
Родитель
Сommit
366b33c0c5

+ 544 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DanshopPaymentController.java

@@ -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);
+        }
+    }
+}

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/DanshopMigrationController.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.DanshopMigrationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * DanShop 批量数据迁移控制器
+ * <p>
+ * 将 CFC 现有数据(用户、商品等)批量同步到 DanShop。
+ * 首次集成后执行一次全量迁移,之后增量数据通过业务操作自动同步。
+ */
+@Tag(name = "DanShop迁移", description = "CFC → DanShop 数据迁移")
+@RestController
+@RequestMapping("/api/admin/danshop-migrate")
+public class DanshopMigrationController {
+
+    @Resource
+    private DanshopMigrationService danshopMigrationService;
+
+    @Operation(summary = "批量迁移用户到 DanShop")
+    @PostMapping("/users")
+    public Result<Map<String, Object>> migrateUsers() {
+        Map<String, Object> result = danshopMigrationService.migrateUsers();
+        return Result.success(result);
+    }
+
+    @Operation(summary = "批量迁移商品到 DanShop")
+    @PostMapping("/products")
+    public Result<Map<String, Object>> migrateProducts() {
+        Map<String, Object> result = danshopMigrationService.migrateProducts();
+        return Result.success(result);
+    }
+
+    @Operation(summary = "全量迁移(用户 + 商品)")
+    @PostMapping("/all")
+    public Result<Map<String, Object>> migrateAll() {
+        Map<String, Object> result = danshopMigrationService.migrateAll();
+        return Result.success(result);
+    }
+}

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DanshopPaymentRecord.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * Danshop 支付代理记录
+ * 记录由 danshop 委托 cfc 处理的支付请求
+ */
+@Data
+@TableName("danshop_payment_records")
+public class DanshopPaymentRecord implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** CFC 内部订单号(用作 WeChat Pay out_trade_no) */
+    private String outTradeNo;
+
+    /** Danshop 订单编号 */
+    private String danshopOrderSn;
+
+    /** Danshop 订单类型 (ORDER/TRADE) */
+    private String danshopOrderType;
+
+    /** 金额(分) */
+    private Integer amount;
+
+    /** 用户微信 openid */
+    private String openid;
+
+    /** 支付状态: pending / paid / refunded */
+    private String status;
+
+    /** 微信支付交易号 */
+    private String transactionId;
+
+    /** Danshop 回调通知 URL */
+    private String notifyUrl;
+
+    /** 退款单号 */
+    private String refundNo;
+
+    /** 创建时间 */
+    private Date createdAt;
+
+    /** 更新时间 */
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DanshopPaymentRecordMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.DanshopPaymentRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface DanshopPaymentRecordMapper extends BaseMapper<DanshopPaymentRecord> {
+}

+ 175 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanshopMigrationService.java

@@ -0,0 +1,175 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Danshop 批量数据迁移服务
+ * <p>
+ * 将 CFC 现有数据(用户、商品等)批量同步到 DanShop。
+ * 用于首次集成时的全量迁移。
+ */
+@Slf4j
+@Service
+public class DanshopMigrationService {
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private DanshopSyncService danshopSyncService;
+
+    /**
+     * 批量迁移用户到 DanShop
+     *
+     * @return 迁移结果统计
+     */
+    public Map<String, Object> migrateUsers() {
+        List<User> users = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .in(User::getRole, "parent", "teacher")
+                        .orderByAsc(User::getId)
+        );
+
+        List<Map<String, Object>> results = new ArrayList<>();
+        int success = 0;
+        int failed = 0;
+
+        log.info("开始迁移用户到 DanShop,共 {} 人", users.size());
+
+        for (User user : users) {
+            try {
+                Map<String, String> token = danshopSyncService.syncMember(user);
+                if (token != null) {
+                    success++;
+                    results.add(Map.of(
+                            "cfcUserId", user.getId(),
+                            "nickname", user.getNickname(),
+                            "status", "success"
+                    ));
+                } else {
+                    failed++;
+                    results.add(Map.of(
+                            "cfcUserId", user.getId(),
+                            "nickname", user.getNickname(),
+                            "status", "failed",
+                            "error", "sync returned null"
+                    ));
+                }
+            } catch (Exception e) {
+                failed++;
+                log.error("迁移用户失败: id={}, nickname={}", user.getId(), user.getNickname(), e);
+                results.add(Map.of(
+                        "cfcUserId", user.getId(),
+                        "nickname", user.getNickname(),
+                        "status", "failed",
+                        "error", e.getMessage()
+                ));
+            }
+
+            // 打印进度
+            if ((success + failed) % 50 == 0) {
+                log.info("用户迁移进度: {}/{}", success + failed, users.size());
+            }
+        }
+
+        log.info("用户迁移完成: 成功={}, 失败={}, 总计={}", success, failed, users.size());
+
+        return Map.of(
+                "total", users.size(),
+                "success", success,
+                "failed", failed,
+                "results", results
+        );
+    }
+
+    /**
+     * 批量迁移商品到 DanShop
+     *
+     * @return 迁移结果统计
+     */
+    public Map<String, Object> migrateProducts() {
+        List<Product> products = productMapper.selectList(
+                new LambdaQueryWrapper<Product>()
+                        .in(Product::getStatus, "on_shelf", "approved")
+                        .orderByAsc(Product::getId)
+        );
+
+        List<Map<String, Object>> results = new ArrayList<>();
+        int success = 0;
+        int failed = 0;
+
+        log.info("开始迁移商品到 DanShop,共 {} 个", products.size());
+
+        for (Product product : products) {
+            try {
+                Map<String, Object> result = danshopSyncService.syncProduct(product);
+                if (result != null && "success".equals(result.get("syncStatus"))) {
+                    success++;
+                    results.add(Map.of(
+                            "cfcProductId", product.getId(),
+                            "name", product.getName(),
+                            "status", "success",
+                            "goodsId", result.get("goodsId")
+                    ));
+                } else {
+                    failed++;
+                    results.add(Map.of(
+                            "cfcProductId", product.getId(),
+                            "name", product.getName(),
+                            "status", "failed",
+                            "error", result != null ? result.toString() : "sync returned null"
+                    ));
+                }
+            } catch (Exception e) {
+                failed++;
+                log.error("迁移商品失败: id={}, name={}", product.getId(), product.getName(), e);
+                results.add(Map.of(
+                        "cfcProductId", product.getId(),
+                        "name", product.getName(),
+                        "status", "failed",
+                        "error", e.getMessage()
+                ));
+            }
+
+            if ((success + failed) % 50 == 0) {
+                log.info("商品迁移进度: {}/{}", success + failed, products.size());
+            }
+        }
+
+        log.info("商品迁移完成: 成功={}, 失败={}, 总计={}", success, failed, products.size());
+
+        return Map.of(
+                "total", products.size(),
+                "success", success,
+                "failed", failed,
+                "results", results
+        );
+    }
+
+    /**
+     * 全量迁移(用户 + 商品)
+     */
+    public Map<String, Object> migrateAll() {
+        Map<String, Object> userResult = migrateUsers();
+        Map<String, Object> productResult = migrateProducts();
+
+        return Map.of(
+                "users", userResult,
+                "products", productResult
+        );
+    }
+}

+ 1 - 0
cfc-backend/src/main/resources/application.yml

@@ -63,6 +63,7 @@ wechat:
   mch-id: YOUR_MCH_ID
   mch-id: YOUR_MCH_ID
   mch-key: YOUR_MCH_KEY
   mch-key: YOUR_MCH_KEY
   notify-url: http://your-domain.com/api/payment/notify
   notify-url: http://your-domain.com/api/payment/notify
+  danshop-notify-url: http://localhost:9082/api/internal/payment/wechat-notify
   login-url: https://api.weixin.qq.com/sns/jscode2session
   login-url: https://api.weixin.qq.com/sns/jscode2session
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   test-mode: true  # 测试模式:跳过微信API调用,使用模拟数据
   test-mode: true  # 测试模式:跳过微信API调用,使用模拟数据