ソースを参照

fix: 卡包插入改为正确的 API 流程

- 废弃旧的 create-application + card 两步方案
- 新流程: 下载航信 PDF → 上传微信支付获取 media_id → 调用 insert-cards
- insert-cards 使用正确的 fapiao_card_information 请求体

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 4 日 前
コミット
1476b7e6d1

+ 1 - 1
service/src/main/java/com/kym/service/miniapp/HuapiaoerInvoiceService.java

@@ -136,7 +136,7 @@ public class HuapiaoerInvoiceService {
             return;
         }
 
-        boolean success = wechatPayFapiaoService.insertToCard(invoice);
+        boolean success = wechatPayFapiaoService.insertToCard(invoice, result);
 
         if (success) {
             try {

+ 131 - 39
service/src/main/java/com/kym/service/wechat/impl/WechatPayFapiaoService.java

@@ -4,29 +4,37 @@ import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONObject;
 import com.kym.entity.miniapp.Invoice;
 import com.kym.entity.wechat.InvoiceOrderDetail;
+import com.kym.huapiaoer.model.response.InvoiceInfo;
 import com.wechat.pay.java.core.http.*;
 import lombok.extern.slf4j.Slf4j;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
 import org.springframework.stereotype.Service;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 /**
  * 通过微信支付 v3 电子发票 API 将航信发票插入用户微信卡包。
  *
- * @see <a href="https://pay.wechatpay.cn/doc/v3/merchant/4012538301">创建发票申请单</a>
- * @see <a href="https://pay.wechatpay.cn/doc/v3/merchant/4012538365">插入卡包</a>
+ * @see <a href="https://pay.wechatpay.cn/doc/v3/merchant/4012538365">插入卡包 API</a>
  */
 @Service
 @Slf4j
 public class WechatPayFapiaoService {
 
     private static final String API_BASE = "https://api.mch.weixin.qq.com";
-    private static final String CREATE_APPLICATION = "/v3/new-tax-control-fapiao/fapiao-applications";
-    private static final String INSERT_CARD = "/v3/new-tax-control-fapiao/fapiao-applications/%s/card";
-    /** 商户证书序列号 */
+    private static final String UPLOAD_FILE = "/v3/new-tax-control-fapiao/fapiao-applications/%s/fapiao-files";
+    private static final String INSERT_CARDS = "/v3/new-tax-control-fapiao/fapiao-applications/%s/insert-cards";
     private static final String MCH_SERIAL = "6A45EEB068369430B2FFD45EA29F641A8E18165F";
+    private static final String APP_ID = "wx93b0ef1be901bd19";
 
     private static final HttpHeaders JSON_HEADERS;
 
@@ -37,37 +45,63 @@ public class WechatPayFapiaoService {
         JSON_HEADERS.addHeader("Wechatpay-Serial", MCH_SERIAL);
     }
 
+    private final OkHttpClient downloadClient = new OkHttpClient.Builder()
+            .connectTimeout(15, TimeUnit.SECONDS)
+            .readTimeout(30, TimeUnit.SECONDS)
+            .build();
+
     /**
      * 将已开具的航信电子发票插入用户微信卡包。
-     * 通过微信支付 v3 接口创建发票申请单后直接插卡。
+     * 流程:下载航信 PDF → 上传微信支付获取 media_id → 调用 insert-cards
      *
      * @return true 表示调用成功
      */
-    public boolean insertToCard(Invoice invoice) {
+    public boolean insertToCard(Invoice invoice, InvoiceInfo invoiceInfo) {
         if (invoice.getOpenid() == null || invoice.getOpenid().isBlank()) {
             log.info("用户无 openid,跳过卡包插入, applyId:{}", invoice.getApplyId());
             return false;
         }
 
+        Path tempFile = null;
         try {
-            // Step 1: 创建发票申请单
-            var appReq = buildApplicationRequest(invoice);
-            log.info("创建微信发票申请单, applyId:{}, body:{}", invoice.getApplyId(), JSON.toJSONString(appReq));
-
-            HttpResponse<JSONObject> appResp = WxPayServiceImpl.wxHttpClient.post(
-                    JSON_HEADERS, API_BASE + CREATE_APPLICATION,
-                    new JsonRequestBody.Builder().body(JSON.toJSONString(appReq)).build(),
-                    JSONObject.class);
-
-            log.info("微信发票申请单响应, applyId:{}, resp:{}",
-                    invoice.getApplyId(), JSON.toJSONString(appResp.getServiceResponse()));
-
-            // Step 2: 插入卡包
-            var cardReq = new HashMap<String, Object>();
-            cardReq.put("card_appid", "wx93b0ef1be901bd19");
-            cardReq.put("card_openid", invoice.getOpenid());
-
-            String cardUrl = API_BASE + String.format(INSERT_CARD, invoice.getApplyId());
+            // Step 1: 下载航信 PDF
+            byte[] pdfBytes = downloadPdf(invoiceInfo.getInvoiceurl());
+            if (pdfBytes == null || pdfBytes.length == 0) {
+                log.warn("下载航信 PDF 失败, applyId:{}", invoice.getApplyId());
+                return false;
+            }
+
+            // Step 2: 上传 PDF 到微信支付,获取 fapiao_media_id
+            tempFile = Files.createTempFile("fapiao_", ".pdf");
+            Files.write(tempFile, pdfBytes);
+
+            String meta = JSON.toJSONString(Map.of(
+                    "file_type", "FAPIAO_PDF",
+                    "fapiao_apply_id", invoice.getApplyId()
+            ));
+
+            HttpHeaders uploadHeaders = new HttpHeaders();
+            uploadHeaders.addHeader("Accept", "application/json");
+            uploadHeaders.addHeader("Wechatpay-Serial", MCH_SERIAL);
+
+            String uploadUrl = API_BASE + String.format(UPLOAD_FILE, invoice.getApplyId());
+            log.info("上传发票文件, applyId:{}, url:{}", invoice.getApplyId(), uploadUrl);
+
+            HttpResponse<JSONObject> uploadResp = WxPayServiceImpl.wxHttpClient.upload(
+                    uploadHeaders, uploadUrl, meta, tempFile.toString(), JSONObject.class);
+
+            JSONObject uploadBody = uploadResp.getServiceResponse();
+            log.info("上传发票文件响应, applyId:{}, resp:{}", invoice.getApplyId(), JSON.toJSONString(uploadBody));
+
+            String mediaId = uploadBody.getString("fapiao_media_id");
+            if (mediaId == null || mediaId.isBlank()) {
+                log.warn("未获取到 fapiao_media_id, applyId:{}", invoice.getApplyId());
+                return false;
+            }
+
+            // Step 3: 插入卡包
+            var cardReq = buildInsertCardsRequest(invoice, invoiceInfo, mediaId);
+            String cardUrl = API_BASE + String.format(INSERT_CARDS, invoice.getApplyId());
             log.info("插入发票卡包, applyId:{}, body:{}", invoice.getApplyId(), JSON.toJSONString(cardReq));
 
             HttpResponse<JSONObject> cardResp = WxPayServiceImpl.wxHttpClient.post(
@@ -75,17 +109,35 @@ public class WechatPayFapiaoService {
                     new JsonRequestBody.Builder().body(JSON.toJSONString(cardReq)).build(),
                     JSONObject.class);
 
-            log.info("插入发票卡包响应, applyId:{}, resp:{}",
-                    invoice.getApplyId(), JSON.toJSONString(cardResp.getServiceResponse()));
+            log.info("插入发票卡包响应, applyId:{}, httpCode:{}, resp:{}",
+                    invoice.getApplyId(), cardResp.getHttpStatusCode(),
+                    JSON.toJSONString(cardResp.getServiceResponse()));
 
             return true;
         } catch (Exception e) {
             log.error("微信卡包插入异常, applyId:{}, msg:{}", invoice.getApplyId(), e.getMessage());
             return false;
+        } finally {
+            if (tempFile != null) {
+                try { Files.deleteIfExists(tempFile); } catch (Exception ignored) {}
+            }
         }
     }
 
-    private HashMap<String, Object> buildApplicationRequest(Invoice invoice) {
+    private byte[] downloadPdf(String url) {
+        try {
+            var req = new Request.Builder().url(url).get().build();
+            var resp = downloadClient.newCall(req).execute();
+            if (resp.isSuccessful() && resp.body() != null) {
+                return resp.body().bytes();
+            }
+        } catch (Exception e) {
+            log.error("下载 PDF 异常, url:{}", url, e);
+        }
+        return null;
+    }
+
+    private HashMap<String, Object> buildInsertCardsRequest(Invoice invoice, InvoiceInfo info, String mediaId) {
         var orderDetails = invoice.getOrderDetails();
         int elecMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getElecMoney).sum();
         int serviceMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getServiceMoney).sum();
@@ -94,24 +146,65 @@ public class WechatPayFapiaoService {
 
         var items = new ArrayList<HashMap<String, Object>>();
         int totalAmount = 0;
+        int totalTax = 0;
 
         if (elecMoney > 0) {
-            items.add(buildItem("充电电费", elecMoney, "1100101020200000000", 1300));
+            int tax = elecMoney - (int) Math.round(elecMoney / 1.13);
+            int amt = elecMoney - tax;
+            items.add(buildItem("充电电费", amt, tax, elecMoney, "1100101020200000000", 1300));
             totalAmount += elecMoney;
+            totalTax += tax;
         }
         if (netServiceMoney > 0) {
-            items.add(buildItem("充电服务费", netServiceMoney, "1100101020200000000", 1300));
+            int tax = netServiceMoney - (int) Math.round(netServiceMoney / 1.13);
+            int amt = netServiceMoney - tax;
+            items.add(buildItem("充电服务费", amt, tax, netServiceMoney, "1100101020200000000", 1300));
             totalAmount += netServiceMoney;
+            totalTax += tax;
         }
 
-        var fapiaoInfo = new HashMap<String, Object>();
-        fapiaoInfo.put("fapiao_id", invoice.getApplyId());
-        fapiaoInfo.put("total_amount", totalAmount);
-        fapiaoInfo.put("items", items);
+        // 票面时间转 RFC 3339
+        String ticketDate = info.getTicketDate();
+        if (ticketDate != null && ticketDate.length() == 16) {
+            ticketDate += ":00";
+        }
+        String fapiaoTime = ticketDate != null
+                ? LocalDateTime.parse(ticketDate, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
+                        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss+08:00"))
+                : LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss+08:00"));
+
+        // 销售方信息
+        var sellerInfo = new HashMap<String, Object>();
+        sellerInfo.put("name", "深圳市快与慢科技有限公司");
+        sellerInfo.put("taxpayer_id", "91440300MA5HJNDG14");
+        sellerInfo.put("address", "");
+        sellerInfo.put("telephone", "");
+        sellerInfo.put("bank_name", "");
+        sellerInfo.put("bank_account", "");
+
+        // 附加信息
+        var extraInfo = new HashMap<String, Object>();
+        extraInfo.put("payee", "");
+        extraInfo.put("reviewer", "");
+        extraInfo.put("drawer", "");
+
+        // 卡券信息
+        var cardInfo = new HashMap<String, Object>();
+        cardInfo.put("fapiao_media_id", mediaId);
+        cardInfo.put("fapiao_number", info.getInvoicenumber());
+        cardInfo.put("fapiao_code", info.getInvoicecode() != null ? info.getInvoicecode() : "");
+        cardInfo.put("fapiao_time", fapiaoTime);
+        cardInfo.put("total_amount", totalAmount);
+        cardInfo.put("tax_amount", totalTax);
+        cardInfo.put("amount", totalAmount - totalTax);
+        cardInfo.put("seller_information", sellerInfo);
+        cardInfo.put("extra_information", extraInfo);
+        cardInfo.put("items", items);
         if (invoice.getRemark() != null && !invoice.getRemark().isBlank()) {
-            fapiaoInfo.put("remark", invoice.getRemark());
+            cardInfo.put("remark", invoice.getRemark());
         }
 
+        // 购买方
         var buyerInfo = new HashMap<String, Object>();
         buyerInfo.put("type", invoice.getInvoiceType());
         buyerInfo.put("name", invoice.getInvoiceTitle());
@@ -120,22 +213,21 @@ public class WechatPayFapiaoService {
         }
 
         var req = new HashMap<String, Object>();
-        req.put("fapiao_apply_id", invoice.getApplyId());
         req.put("scene", "WITHOUT_WECHATPAY");
         req.put("buyer_information", buyerInfo);
-        req.put("fapiao_information", List.of(fapiaoInfo));
+        req.put("fapiao_card_information", List.of(cardInfo));
 
         return req;
     }
 
-    private HashMap<String, Object> buildItem(String name, int totalAmount, String taxCode, int taxRate) {
+    private HashMap<String, Object> buildItem(String name, int amount, int taxAmount, int totalAmount,
+                                               String taxCode, int taxRate) {
         var item = new HashMap<String, Object>();
         item.put("tax_code", taxCode);
         item.put("goods_name", name);
         item.put("quantity", 100000000);
         item.put("total_amount", totalAmount);
         item.put("tax_rate", taxRate);
-        item.put("tax_prefer_mark", "NO_FAVORABLE");
         item.put("discount", false);
         return item;
     }