# 美团核销 Java 怎么写，签名幂等和 prepare 到 consume 示例, 无限聚核

> 核销服务由无限聚核提供，美团、抖音团购核销一站式接入。

搜「美团核销 Java示例」时，结果里常混着不确定的 SDK 帖、过期字段说明和「直接 msconsume」半截示例。开发真正卡的是：走自研开放平台还是接第三方统一接口？prepare → consume → 撤销怎么串？超时与未知态怎么分支？

**一句话回答：** 自研走美团开放平台「验券准备 → 执行验券 → 撤销」；接第三方则用同一套「预核销 → 核销 → 撤销」，`platform=1` 表示美团。出站请求用 `Authorization: Bearer <API Key>`；成功只认顶层 `success=true`；失败看 `errorCode`；业务侧按券码 + 本地订单去重（聚合核销无需调方传幂等键）。下文用 Java 11+ JDK `HttpClient` + Jackson 写可运行示意，**不是**官方完整 Java SDK 类库。业态以 **V2 服务零售聚合** 为主；到店餐饮请改用餐饮路由，勿照搬本文路径。依据无限聚核公开 API 与接入 FAQ 口径整理，查阅日期 2026-10-20，字段以当前公开文档为准。

- **先选路径。** 自研管商家 token；聚合用具体门店 `shopId` + `platform=1`；
- **出站鉴权是 Bearer。** API Key 只放服务端，不要臆造「官方美团核销 Java SDK」类名去 import；
- **幂等分两层。** 接口避免重复核成多笔；业务仍要自己去重并保存首次成功响应；
- **prepare 不改券状态。** 真正扣券在 consume，`ticketInfo` 必须整段原文回传；
- **撤销用 consumeCredential。** 与消费者退款是两件事；撤销无幂等。

![左栏出站 Bearer 鉴权；中栏首次成功与 10500 已核销、业务去重、可重试白名单；右栏 platform=1 经 prepare 保存 ticketInfo 再到 consume 按 data 与 consumeCredential 下标落库](assets/hero.png)

*示意图：字段与规则摘自无限聚核公开 API（2026-10-20 查阅），非平台后台截图。*

## 一、两条路先选对

| 路径 | 你做什么 | 适用 |
|---|---|---|
| 自研：美团开放平台 | 办服务商／自研资质，管商家 token，对接准备／验券／撤销与查询；要抖音再单独接一套 | 已有多商户能力、要原生能力尽量全开 |
| 第三方统一接口 | 建门店、商家授权、业务后端调统一预核销／核销／撤销；平台差异由服务商消化 | 要同时核美团＋抖音、缩短上线 |

两条路都不要「跳过准备直接核」。自研侧常见准备／执行接口族名如 `receipt.prepare`、对应验券（到店餐饮文档里还会见到 `msprepare`／`msconsume` 等，属另一路由）；聚合侧服务零售对应 `ddzh-tuangou-receipt-prepare` → `ddzh-tuangou-receipt-consume`。

自研时请对照美团开放平台当前文档与官方示例（常见有 Go 等），**不要假设存在一套完整的「官方美团核销 Java SDK 类名」可直接 import**；多数团队用 HTTP + JSON 自封装。下文示意走聚合路径（`platform=1`）。

## 二、最小闭环：prepare → consume → cancel

1. **预核销（输码验券校验）** `POST …/ddzh-tuangou-receipt-prepare`  
   传 `shopId`、`platform=1`、`code`。成功后拿到套餐展示信息与不透明 `ticketInfo`。**不改变券状态，不发权益。**
2. **核销（验券）** `POST …/ddzh-tuangou-receipt-consume`  
   顾客确认后，原样回传同一组 `shopId`、`platform`、`code`、`num`、`ticketInfo`。成功看 `success=true`，保存 `wxdgOrderNo`、`data[]`、`consumeCredential[]`。
3. **撤销** `POST …/ddzh-tuangou-receipt-cancel`  
   传原 `shopId`、`platform` 与 `consumeCredential` 数组（1–10 项）。美团侧撤销窗口常见约 10 分钟（以平台当前说明为准）。撤销与消费者退款是两件事。

`shopId` 必须是聚合侧**具体门店** ID，不是商户 ID、不是你自己系统门店主键、不是美团平台门店码。API Host 与完整 URL **以当前公开 API 文档为准**，下文只写路径后半段。

## 三、HTTP 封装与出站鉴权

示意省略部分 import；`apiHost`、`apiKey` 只放服务端配置。出站统一：

`Authorization: Bearer <API Key>`

需要接收授权结果 Webhook 时，入站验签用当前 API Key 对 `timestamp + "." + rawBody` 做 HMAC-SHA256，结果为小写十六进制并加 `v1=` 前缀；必须用原始 Body 字节，不能先解析再序列化。验签细节以公开文档「Webhook」章节为准，本文重点仍是核销主链路。

```java
private final ObjectMapper json = new ObjectMapper();
private final HttpClient http = HttpClient.newHttpClient();
private String apiHost; // 从服务端配置注入，以当前公开文档为准
private String apiKey;

static final class ApiResponse {
    final int httpStatus;
    final JsonNode body;
    ApiResponse(int httpStatus, JsonNode body) {
        this.httpStatus = httpStatus;
        this.body = body;
    }
    boolean isSuccess() {
        return body.path("success").isBoolean() && body.path("success").booleanValue();
    }
    String errorCode() {
        return body.path("errorCode").asText("");
    }
}

ApiResponse postJson(String path, String requestJson)
        throws IOException, InterruptedException {
    HttpRequest request = HttpRequest.newBuilder(
            URI.create(apiHost + "/api/hexiao/v2/" + path))
            .timeout(Duration.ofSeconds(10))
            .header("Authorization", "Bearer " + apiKey)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(requestJson))
            .build();
    HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
    JsonNode body;
    try {
        body = json.readTree(response.body());
    } catch (JsonProcessingException e) {
        body = NullNode.getInstance();
    }
    return new ApiResponse(response.statusCode(), body == null ? NullNode.getInstance() : body);
}
```

也可用 OkHttp 等价替换；关键是不要假装有官方完整 Java SDK，并固定超时、日志脱敏、`traceId` 落库。

## 四、预核销：保存 ticketInfo

请求体关键字段：

```json
{
  "shopId": 123,
  "platform": 1,
  "code": "顾客输入的美团券码"
}
```

```java
ObjectNode prepare(long shopId, String code, int num) throws Exception {
    if (num < 1 || num > 10) throw new IllegalArgumentException("num 1–10");
    ObjectNode req = json.createObjectNode()
            .put("shopId", shopId).put("platform", 1).put("code", code);
    ApiResponse reply = postJson("ddzh-tuangou-receipt-prepare", req.toString());
    if (!reply.isSuccess()) throw new IllegalStateException("prepare failed: " + reply.errorCode());
    JsonNode data = reply.body.path("data");
    String ticketInfo = data.path("ticketInfo").asText("");
    if (ticketInfo.isEmpty()) throw new IllegalStateException("missing ticketInfo");
    // 美团预核销常用 ticketName + dealPrice（元）与已配置商品预匹配；无统一商品 ID
    // ticketInfo 当不透明串整段保存，禁止解析重组
    ObjectNode consumeReq = req.deepCopy().put("num", num).put("ticketInfo", ticketInfo);
    // 业务侧：落 pending / 确认单，保存 consumeReq 原文与 prepare data
    return json.createObjectNode()
            .put("ticketName", data.path("ticketName").asText())
            .put("payAmountFen", data.path("payAmount").asLong())
            .set("consumeRequest", consumeReq);
}
```

注意：`payAmount`／`actualPayAmount` 多为**分**，`dealPrice` 多为**元**；响应里没有门店 ID，后续始终用你保存的 `shopId`。prepare 成功不等于已核销。

## 五、核销：原参数 + errorCode 分支

```java
static final Set<String> RETRYABLE = Set.of(
        "CONSUME_IN_PROGRESS", "RESULT_UNKNOWN",
        "SERVICE_UNAVAILABLE", "PROVIDER_UNAVAILABLE");

void consume(String pendingConsumeRequestJson) throws InterruptedException {
    // 发请求前业务侧写 pending / 加锁：门店+平台+券码+本地订单
    final int maxAttempts = 3;
    JsonNode last = NullNode.getInstance();
    for (int i = 0; i < maxAttempts; i++) {
        if (i > 0) Thread.sleep(1000L * i);
        ApiResponse reply;
        try {
            reply = postJson("ddzh-tuangou-receipt-consume", pendingConsumeRequestJson);
        } catch (IOException e) {
            continue; // 超时／断连：原样重试
        }
        last = reply.body;
        if (reply.isSuccess()) {
            // 保存首次完整响应：wxdgOrderNo、data[]、consumeCredential[]
            return;
        }
        String ec = reply.errorCode();
        if ("ALREADY_CONSUMED".equals(ec)) {
            // code 常见 10500；保存 wxdgOrderNo，停重试，转核对；勿补发、勿编造凭证
            return;
        }
        if (!RETRYABLE.contains(ec)) {
            // PLATFORM_REJECTED、参数／授权等：停，展示原因
            return;
        }
    }
    // 仍未知：标记 unknown，查已验券信息或对账
}
```

核销请求体示例：

```json
{
  "shopId": 123,
  "platform": 1,
  "code": "同一券码",
  "num": 1,
  "ticketInfo": "预核销返回的原值，整段回传"
}
```

### 幂等键怎么理解

公开 FAQ 口径：当前**聚合核销无需调方传 `idempotencyKey`**（旧客户端传入也会被忽略）。接口侧保证同一业务请求不会核成多笔；**不保证**重复调用返回同一份首次成功明细。业务系统必须：

- 按「门店 + 平台 + 券码 + 本地订单」去重加锁；
- 保存首次完整成功响应，并按 `data[i]` 与 `consumeCredential[i]` 下标对应；
- 收到 `ALREADY_CONSUMED`（常见 `code=10500`）时用 `wxdgOrderNo` 核对，而不是再点一次当补偿。

## 六、撤销

```java
void cancel(long shopId, List<String> credentials) throws Exception {
    if (credentials.isEmpty() || credentials.size() > 10) {
        throw new IllegalArgumentException("consumeCredential 1–10");
    }
    ObjectNode req = json.createObjectNode()
            .put("shopId", shopId).put("platform", 1);
    ArrayNode arr = req.putArray("consumeCredential");
    credentials.forEach(arr::add);
    ApiResponse reply = postJson("ddzh-tuangou-receipt-cancel", req.toString());
    // 整笔拒绝：CREDENTIAL_INVALID / CANCEL_UNSUPPORTED 等
    // 到达平台后看 data[].status：succeeded / rejected / unknown
    // 撤销无幂等：未知时先查券状态，勿盲目重发
}
```

请求体示例：

```json
{
  "shopId": 123,
  "platform": 1,
  "consumeCredential": ["核销成功时保存的凭证"]
}
```

## 七、上线前验收

- 预核销成功只进确认页，未发权益；
- 核销 `success=true` 后 `wxdgOrderNo`、明细、凭证齐全，权益只发一次；
- 超时用原请求原参数有限重试，不重新预核销、不换 `ticketInfo`；
- `PLATFORM_REJECTED` 停；`ALREADY_CONSUMED` 核对；
- 撤销在时限内可回退本地权益；未知项先查已验券信息。

## 八、与自研美团路径对照

| 环节 | 自研美团开放平台 | 聚合（platform=1，服务零售） |
|---|---|---|
| 准备 | receipt.prepare 等（以当期文档为准） | ddzh-tuangou-receipt-prepare |
| 执行 | 对应验券接口 | ddzh-tuangou-receipt-consume |
| 撤销 | 以开放平台当期字段为准 | consumeCredential 数组 |
| 门店 | 商家 token＋门店绑定 | 具体门店 shopId |
| 成功判断 | 以开放平台当期说明为准 | success 布尔值 |
| Java | HTTP／自封装；勿臆造 SDK 类名 | HttpClient＋Jackson 示意 |

到店餐饮是另一套接口族，本文不展开；餐厅请另查餐饮路由文档，勿把上表服务零售路径直接套过去。

## 常见问题

### 美团核销的 Java 示例要自己算请求签名吗？

出站调用无限聚核 V2 聚合接口时，请求头使用 `Authorization: Bearer <API Key>` 即可，调用方不必再拼美团开放平台那套出站 `sign`，也不要臆造官方完整 Java SDK 类名。若配置了 Webhook，入站才需要按公开文档用 API Key 做 HMAC-SHA256 验签。

### prepare 和 consume 可以合并成一次调用吗？

不可以。每次新的核销前都要先调输码验券校验确认券仍可用，再把返回的 `ticketInfo` 原文交给验券接口。跳过 prepare 或篡改 `ticketInfo` 会导致核销失败或凭证非法。

### 接口说幂等，为什么业务还要按券码去重？

接口幂等保证同一业务请求不会核成多笔，但重复请求命中已成功订单时只返回 10500 提示和 `wxdgOrderNo`，不会再次给出首次成功明细与撤销凭证。权益发放必须以本地保存的首次成功记录为准，并按券码与业务订单去重。聚合核销无需调方传 `idempotencyKey`。

### 失败后怎样安全重试？

仅当 `errorCode` 属于 `CONSUME_IN_PROGRESS`、`RESULT_UNKNOWN`、`SERVICE_UNAVAILABLE`、`PROVIDER_UNAVAILABLE`，或请求超时未拿到响应时，使用完全相同的原 JSON 有间隔、有上限地重试。`PLATFORM_REJECTED` 与 `ALREADY_CONSUMED` 停止自动重试。

### 本文和「小程序接入 Java 实战」怎么分工？

小程序全流程接入（授权、商品映射、确认页、权益）见开发实战里的小程序集成文；本文专打检索意图「美团核销 Java示例」，聚焦服务零售下鉴权、幂等与 prepare → consume → cancel。两篇互补，不要互相改写成一篇。
