跳到文章正文
无限聚核WUXIAN JUHE
阅读 Markdown 版

微信小程序接入美团、抖音团购核销实战:从门店授权到核销与撤销

顾客买好团购券,打开你的微信小程序,选门店、输入券码、确认套餐。核销成功后,系统给他一份三小时的门店服务权益,接着就能预约或使用服务。

手机打开自助小程序,经业务后端网络调用无限聚核接口的技术动漫示意
技术场景示意:手机中的自助小程序经业务后端调用无限聚核,凭据由服务端保存。

微信小程序接入美团、抖音团购核销,可以通过无限聚核V2 服务零售聚合接口串起门店授权、预核销、核销与撤销。本文面向已有小程序和业务后端的开发团队,用 Java 示例把接口调用接回自己的预约或权益系统。

阅读导航
  1. 一、先把完整效果串起来
  2. 二、把自己的门店对应到聚核子门店
  3. 三、完成商家授权,把凭据放到服务端
  4. 四、接口按用途接,不必一次全部铺开
  5. 五、先认识套餐,再决定发什么权益
  6. 六、预核销后展示确认页,保存原始凭证
  7. 七、确认核销;超时就用原参数重试
  8. 八、先保存成功结果,再完成自己的业务
  9. 九、需要撤销时,使用首次成功的原凭证
  10. 十、联调按业务结果验收
  11. 十一、微信小程序接入团购核销常见问题

一、先把完整效果串起来

顾客侧需要选店、输码、确认和结果页。后台则要知道:这是谁的券、在哪家门店使用、对应什么套餐,以及成功后发什么权益。尚未确定接口申请路径,可以先看微信小程序怎么接入美团、抖音团购核销?申请步骤、材料与成本对比

建议采用“小程序 → 自己的业务后端 → 无限聚核”的调用方式。小程序交互围绕自己的业务门店和确认单展开,后端负责权限、门店映射和接口调用,再把核销结果接回预约或会员系统。

小程序、业务后端和核销接口的调用流程,以及原参数重试的处理分支
开发流程示意:先预核销,顾客确认后核销;超时等非业务错误使用原参数重试。

下文使用 Java 11+ 与 Jackson 写接入示例,保留真实 HTTP 请求、JSON 参数和关键控制流,省略 import、完整服务类和框架配置。代码片段可放在同一服务类中,使用第三章的 postJson 封装;这些是接入示例,不是聚核 SDK,本文未编译代码或调用真实业务接口。

business 是接入方的 BusinessPort 业务接口,负责权限、门店和商品映射、确认上下文、核销记录及权益操作;PreparedRedemptionReceipt 是自己的业务对象,只使用代码中所示访问器。adapterString productId(int platform, JsonNode prepareData) 负责商品识别,errorsFailureKind classify(ApiResponse response) 负责按已核对的接口语义分类,二者也由接入方实现。

二、把自己的门店对应到聚核子门店

先分清三个层次:自己的业务门店、聚核主子门店、美团或抖音门店。聚核主门店用于组织一组子门店;本文把每家实际经营的业务门店对应到一个聚核子门店,再核对它关联的平台门店。

业务调用使用保存下来的聚核子门店 shopId 别把自己系统的门店 ID、聚核主门店 ID或平台门店 ID混在同一个字段里。

业务门店、聚核主子门店与美团抖音门店的对应关系
门店关系示意:业务门店保存与聚核子门店的映射,平台门店分别核对。

无限聚核开发者后台的门店管理页,可以看到主门店分组,以及子门店对应的美团、抖音名称、授权状态和授权入口。

无限聚核真实门店管理页,展示主门店分组、子门店和双平台授权入口
真实后台截图,已裁切脱敏:用户名、菜单已裁除,门店 ID和平台账号已遮蔽;本次仅查看界面。

使用当前控制台准备门店时,先新建主门店,再在该主门店下添加子门店。主门店表单填写名称并选择业务类目,本文使用服务零售;子门店表单会提示所属主门店,再填写具体门店名称。

真实新建主门店表单,包含门店名称和业务类目
真实表单截图,已裁切脱敏;仅查看,未提交创建。
真实新建子门店表单,显示所属主门店和名称输入框
真实表单截图,已裁切脱敏;仅查看,未提交创建。

如果要把门店入驻放进自己的 SaaS 后台,可以接 V2 创建门店接口。这里与控制台有一个区别:API 不传上级时,会创建主门店及首个同名子门店,返回的 shopId 是子门店 ID,parentShopId 是主门店 ID。已有主门店时,传 masterShopId 添加子门店。

String createStore(String localStoreId, String shopName, String masterShopId)
        throws IOException, InterruptedException {
    business.requireStoreAdmin(localStoreId);
    boolean firstStore = masterShopId == null || masterShopId.isBlank();
    ObjectNode request = json.createObjectNode().put("shopName", shopName);
    if (!firstStore) {
        request.put("masterShopId", masterShopId);
    }
    JsonNode data = postJson("store/create", request.toString()).requireData();
    String shopId = data.path("shopId").asText();
    String parentId = firstStore ? data.path("parentShopId").asText() : masterShopId;
    if (shopId.isBlank() || parentId.isBlank()) {
        throw new IllegalStateException("Missing store identifiers");
    }
    business.saveStoreMapping(localStoreId, shopId, parentId);
    return shopId;
}

示例中 masterShopId 为空时创建主门店和首个子门店;已有主门店时传入保存的主门店 ID。创建出来的门店还要完成平台授权。建议在映射记录旁保留平台门店的核对结果,方便后续发现“顾客选的是 A 店,实际关联了 B 店”这类配置问题。

三、完成商家授权,把凭据放到服务端

门店创建、获取授权链接、商家完成授权,是三个步骤。先在控制台从对应子门店发起美团或抖音授权,商家完成后,再核对平台门店与授权状态。拿到一个链接,还不能把门店标成已授权。

自建入驻流程时,文档中的“获取美团/抖音授权链接”接受 shopIdplatform;平台值为 1 美团、2 抖音,成功时 data 是授权 URL。

String authorizationUrl(String localStoreId, int platform)
        throws IOException, InterruptedException {
    business.requireStoreAdmin(localStoreId);
    String shopId = business.shopId(localStoreId);
    ObjectNode request = json.createObjectNode()
            .put("shopId", shopId).put("platform", platform);
    JsonNode data = postJson("get/auth/url", request.toString()).requireData();
    if (!data.isTextual() || data.textValue().isBlank()) {
        throw new IllegalStateException("Missing authorization URL");
    }
    return data.textValue(); // 展示给商家;授权完成后另行核对门店和状态
}

从开发者后台的开发者中心获取当前账号 API key,保存在自己的服务端。本轮快速开始文档给出的 API Host 是 https://newopen.elys.cn,请求头示例为 Authorization: Bearer <key>

private final ObjectMapper json = new ObjectMapper();
private final HttpClient http = HttpClient.newHttpClient();
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();
    }
    JsonNode requireData() {
        if (!isSuccess()) throw new IllegalStateException("API result requires handling");
        return body.path("data");
    }
}

ApiResponse postJson(String path, String requestJson)
        throws IOException, InterruptedException {
    HttpRequest request = HttpRequest.newBuilder(URI.create(
            "https://newopen.elys.cn/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(); // 保留 HTTP 状态,由调用处分类
    }
    return new ApiResponse(response.statusCode(), body == null ? NullNode.getInstance() : body);
}

小程序请求到达自己的后端时,要先检查用户和业务门店权限,再查映射调用接口。聚核会校验系统门店的账号归属,你自己的用户权限仍由业务后端负责。

四、接口按用途接,不必一次全部铺开

先做顾客确认与核销,再补商品配置、门店入驻和售后页面。下表都使用 POST,路径以 /api/hexiao/v2/ 为前缀;参数与响应详见公开 API 文档中的对应导航。

能力 接口路径后半段 本文如何使用
预核销 ddzh-tuangou-receipt-prepare 必接,检查具体券并取得核销凭证
核销 ddzh-tuangou-receipt-consume 必接,顾客确认使用后执行
团购列表 ddzh-tuangou-deal-queryshopdeal 用于展示套餐、配置本地权益映射
创建门店 store/create 自建门店入驻后台时接
授权链接 get/auth/url 把商家授权整合进自己的后台时接
撤销核销 ddzh-tuangou-receipt-cancel 有撤销售后流程时接

文档把预核销称为“输码验券校验”,把真正执行核销的接口称为“验券”。开发时可以在自己的封装里明确命名为 prepareconsume,避免在顾客尚未确认时误调后者。

五、先认识套餐,再决定发什么权益

商品列表解决的是“这家店卖什么”。例如将某个平台商品配置成“三小时服务权益”,后续识别到这款商品,自己的系统才知道该给顾客什么。

建议用 平台+聚核子门店+平台商品 ID 作为映射条件。套餐名称用于展示,不能只靠名称判断商品;不同平台的同名套餐也应分别配置。

void configureProduct(String localStoreId, int platform, int page, int pageSize,
                      String selectedDealId, String benefitId)
        throws IOException, InterruptedException {
    business.requireStoreAdmin(localStoreId);
    String shopId = business.shopId(localStoreId);
    ObjectNode request = json.createObjectNode().put("shopId", shopId)
            .put("platform", platform).put("offset", page).put("limit", pageSize);
    JsonNode deals = postJson("ddzh-tuangou-deal-queryshopdeal", request.toString()).requireData();
    for (JsonNode deal : deals) {
        if (selectedDealId.equals(deal.path("dealId").asText())) {
            business.saveProductMapping(platform, shopId, selectedDealId, benefitId);
            return;
        }
    }
    throw new IllegalArgumentException("Selected product is not in this page");
}

示例把管理员在当前页选中的 selectedDealId 与本地 benefitId 关联;三小时服务对应哪一个 benefitId,由自己的权益系统配置。

当前“获取团购信息”文档offset 定义为页码,limit 为每页条数;列表中的 pricemarketPrice 单位是元。

套餐配置完成后,可以按自己的业务需要同步或维护。顾客每次用券仍要预核销:列表有这款商品,不等于他输入的这张券可以使用。

六、预核销后展示确认页,保存原始凭证

顾客选好门店、平台并输入券码,后端先找出聚核子门店 shopId,以同一组 shopIdplatformcode 调用预核销。成功后展示套餐和本地对应权益,等顾客确认。

“输码验券校验”文档的统一 data 包含 payAmountshopIdticketNameticketInfoticketData。其中有三处容易接错:

ObjectNode prepare(String localStoreId, int platform, String code, int num)
        throws IOException, InterruptedException {
    String userId = business.requireUserAccess(localStoreId);
    String shopId = business.shopId(localStoreId);
    if (num <= 0) throw new IllegalArgumentException("Invalid quantity");
    ObjectNode request = json.createObjectNode().put("shopId", shopId)
            .put("platform", platform).put("code", code);
    JsonNode data = postJson("ddzh-tuangou-receipt-prepare", request.toString()).requireData();
    String productId = adapter.productId(platform, data.deepCopy());
    if (productId == null || productId.isBlank()) {
        throw new IllegalStateException("Product needs review");
    }
    String benefitId = business.findBenefit(platform, shopId, productId);
    if (benefitId == null) throw new IllegalStateException("Configure product mapping first");
    String ticketInfo = data.path("ticketInfo").textValue();
    if (ticketInfo == null || ticketInfo.isEmpty() || !data.path("payAmount").isNumber()) {
        throw new IllegalStateException("Incomplete prepare response");
    }
    ObjectNode consumeRequest = request.deepCopy().put("num", num).put("ticketInfo", ticketInfo);
    String contextId = business.savePending(userId, consumeRequest.toString(), benefitId);
    return json.createObjectNode().put("contextId", contextId)
            .put("ticketName", data.path("ticketName").asText()).put("benefitId", benefitId)
            .put("payAmountYuan", data.path("payAmount").decimalValue().movePointLeft(2));
}

这里以 deepCopy() 把完整 data 的副本交给商品适配器,约定只读分析,并按各平台已核对的返回语义提取可靠商品标识。ticketData 为空,不等于整个响应没有可识别信息。预核销没有统一的 data.dealId;也不能把兼容字段 data.shopId 一概当成商品 ID,更不能用它覆盖输入的聚核子门店 ID。ticketInfo 只作为不透明凭证原样保存、回传,不拆解它来做商品匹配。仍无法可靠识别商品时,先完善适配与配置,别按套餐名称猜测后发权益。代码中的数量来自本次业务确认,也不能假定统一响应里有可直接读取的数量字段。

返回对象中的 contextIdbenefitIdpayAmountYuan 是自己的小程序接口字段;聚核请求体只有代码中列出的真实参数。savePending 要原样保存 consumeRequest.toString(),核销时直接读取该字符串。确认页只给小程序必要的展示数据与自己的上下文 ID。券码、门店、数量和原始凭证留在服务端,后续从同一个上下文读取。预核销成功时,顾客还没有完成实际核销。

七、确认核销;超时就用原参数重试

顾客点击确认后,小程序提交上下文 ID。后端核对上下文归属,取出已保存的 shopIdplatformcodenumticketInfo,调用核销接口。进入成功处理的依据是顶层 success 为布尔值 true,不能只看 HTTP 状态或某个平台内部字段。核销接口说明

核销幂等由平台保证。调用方遇到超时、连接中断等非业务错误,使用老参数重复请求。 同一次核销的五个参数全部保持不变,包括 ticketInfo;不要为了重试再预核销,也不要换券码、门店或数量。

enum FailureKind { REJECTED, CONFIG_ERROR, RETRYABLE, UNKNOWN, REVIEW }

void consume(String contextId) throws InterruptedException {
    PreparedRedemption context = business.loadOwnedPending(contextId);
    if (business.hasSuccess(context.id())) return;
    final String requestJson = context.requestJson(); // 循环前固定,逐字复用
    ApiResponse last = null;
    for (int attempt = 0; attempt < 3; attempt++) {
        if (attempt > 0) Thread.sleep(500L * attempt); // 本例的有限串行退避
        ApiResponse reply;
        try {
            reply = postJson("ddzh-tuangou-receipt-consume", requestJson);
        } catch (IOException e) { // 包含 HttpTimeoutException;不捕获本地履约异常
            continue;
        }
        last = reply;
        if (reply.isSuccess()) {
            saveFirstSuccess(context, reply.body); // 在网络重试的 catch 之外
            return;
        }
        if (reply.body.path("code").asInt() == 10500) {
            business.markPending(context.id(), reply.body.deepCopy());
            return; // 缺少首次成功记录时,不凭已核销提示发权益
        }
        switch (errors.classify(reply)) {
            case REJECTED:
            case CONFIG_ERROR:
                business.stopAttempt(context.id(), reply.body.deepCopy());
                return;
            case RETRYABLE:
            case UNKNOWN:
                break;
            default:
                business.markPending(context.id(), reply.body.deepCopy());
                return;
        }
    }
    business.markPending(context.id(), last == null ? NullNode.getInstance() : last.body.deepCopy());
}

上述重试上限、间隔与客户端超时是本例的配置值。InterruptedException 向上传播,调用方可终止等待;首次成功的保存和权益处理不在网络异常的 catch 内,本地业务异常不会触发这一循环再次核销。

ErrorPolicy 将明确业务拒绝、配置错误分别归为 REJECTEDCONFIG_ERROR;可重试技术失败与已识别的结果未知归为 RETRYABLEUNKNOWN,无法识别的响应归为 REVIEW。这组枚举是自己的分类,不是聚核响应字段。success=false 不能一概当成业务拒绝。当前结果未知分支会使用 code=10001 并提示原请求重试,但参数、鉴权等错误也可能使用这个码,不能写成“10001 就重试”。分类函数应依据接口的具体语义实现;拿不准的结果进入待核对,不靠中文文案模糊匹配自动发权益。

还有一个边界:幂等不等于每次重复请求都返回同一份成功数据。 当前已成功请求的重试可能返回 success=falsecode=10500,并清空 dataconsumeCredentialplatformResult。如果首次响应丢失,后来只收到已核销提示,应停止自动核销重试、核对原记录;不能凭这个提示直接补发权益,也不能据此编造撤销凭证。

对顾客而言,超时意味着结果还没确定。页面可显示“正在确认核销结果”,避免立刻提示失败并诱导他重新开始一笔核销。

八、先保存成功结果,再完成自己的业务

首次收到明确成功时,保存原 shopIdplatform、关联号、核销明细和顶层 consumeCredential 数组。凭证与 data 明细按下标对应,后续撤销需要使用相应项的原凭证。成功响应与凭证说明

void saveFirstSuccess(PreparedRedemption context, JsonNode response) {
    // 保存完整首次响应:data、consumeCredential、关联号,以及上下文中的原请求
    Receipt receipt = business.saveFirstSuccess(context, response.deepCopy());
    try {
        business.grantOnce(receipt.id(), context.benefitId());
        business.markFulfilled(receipt.id());
    } catch (RuntimeException e) {
        business.enqueueFulfillment(receipt.id()); // 只补处理自己的权益
    }
}

saveFirstSuccess 要保存首次完整响应,并把 data[i]consumeCredential[i] 对应起来,同时保留上下文中的原请求;后续调用不能覆盖掉这份首次记录。这里的权益控制属于自己的业务设计。平台保证核销幂等,并不会代替你的预约、时长或会员系统完成发放。

如果核销已明确成功,写入权益时发生本地异常,就根据保存的成功记录补处理本地业务。不要重新核销已成功的券;页面可以分别表达“核销成功”和“权益正在处理”,让顾客知道当前进度。

九、需要撤销时,使用首次成功的原凭证

顾客选错套餐或取消使用时,先按自己的规则检查权益能否撤回。已使用的服务如何处理,由业务规则决定。撤销核销与消费者付款退款要分开处理,调用撤销接口不等于完成退款。

统一撤销支持美团、抖音,传原核销的 shopIdplatformconsumeCredential 字符串数组。它适用于有有效凭证且符合平台规则的记录,不能承诺所有券、所有历史核销都能撤销。“撤销核销”接口说明

void cancel(String receiptId) throws InterruptedException {
    Receipt receipt = business.loadCancellableOwned(receiptId); // 校验归属及权益可撤回
    List<String> credentials = business.selectedOriginalCredentials(receipt);
    if (credentials.isEmpty()) throw new IllegalStateException("No original credentials");
    ObjectNode request = json.createObjectNode().put("shopId", receipt.shopId())
            .put("platform", receipt.platform());
    ArrayNode values = request.putArray("consumeCredential");
    credentials.forEach(values::add);
    ApiResponse reply;
    try {
        reply = postJson("ddzh-tuangou-receipt-cancel", request.toString());
    } catch (IOException e) {
        for (String credential : credentials) business.markCancellationPending(receiptId, credential);
        return;
    }
    Map<String, JsonNode> items = new HashMap<>();
    if (reply.body.path("data").isArray()) {
        for (JsonNode item : reply.body.path("data")) {
            items.put(item.path("consumeCredential").asText(), item);
        }
    }
    for (String credential : credentials) {
        JsonNode item = items.get(credential);
        String status = item == null ? "unknown" : item.path("status").asText("unknown");
        switch (status) {
            case "succeeded":
                business.saveCancellationSucceeded(receiptId, credential);
                try {
                    business.revokeOnce(receiptId, credential);
                } catch (RuntimeException e) {
                    business.enqueueRevocation(receiptId, credential);
                }
                break;
            case "rejected":
                business.recordCancellationRejected(receiptId, credential, item.path("message").asText());
                break;
            case "unknown":
            default:
                business.markCancellationPending(receiptId, credential);
        }
    }
}

selectedOriginalCredentials 只取已保存的首次成功凭证,按本次选定明细组成字符串数组;无法找到某项响应时,示例会将该项记为待核对。撤销响应要逐项看:succeeded 表示该项成功,rejected 表示拒绝,unknown 表示结果未知。只有全部成功时顶层 success 才是 true;部分成功时,即使顶层为 false,也要处理已明确成功的项。若撤销已成功但本地权益撤回失败,同样留下本地补处理记录。

十、联调按业务结果验收

接口能返回 JSON,还要确认页面、门店和权益串得上。下面是供你的团队执行的联调清单,本文未运行这些示例,也未进行真实券预核销、核销或撤销测试。

检查场景 预期处理
基础配置 核对服务端凭据、小程序后端 HTTPS 和微信请求合法域名配置
门店与授权 业务门店对应正确子门店,美团、抖音门店和授权状态分别核对
商品配置 平台、门店、商品映射正确;同名商品不会误发权益
预核销成功 只展示确认页,原 ticketInfo 已保存,尚未发权益
核销明确成功 首次结果、明细及凭证保存完整,自己的权益只发放一次
明确业务拒绝 展示原因,停止该次自动核销重试
超时或结果未知 复用原五个参数,有间隔、有上限;未确定前不报成功或确定失败
已核销提示但缺首次结果 停止自动重试,核对原记录,不直接补发权益
本地履约异常 根据已保存的成功记录补处理自己的业务
撤销与部分成功 按原凭证逐项处理,保留未知项,分别更新对应权益

十一、微信小程序接入团购核销常见问题

微信小程序接入美团团购核销怎么做?

本文的服务零售接入流程是:将业务门店映射到无限聚核子门店,完成美团门店授权并核对绑定关系;业务后端使用 platform=1 调用预核销,取得原始 ticketInfo。顾客确认后,再带上同一门店、平台、券码和核销数量完成核销,保存首次成功结果后处理自己的权益。具体请求见预核销示例核销示例

微信小程序接入抖音团购核销怎么做?

先完成无限聚核子门店对应的抖音门店授权,并核对抖音商品与自己业务权益的映射。小程序通过自己的业务后端调用接口,后端使用 platform=2,按“预核销、顾客确认、核销”的顺序处理,原样保存和回传 ticketInfo。美团与抖音的门店及授权状态分别核对,不能用美团授权代替抖音授权;步骤见门店授权商品映射

微信小程序团购核销必须接哪些接口?

本文采用的无限聚核 V2 服务零售两阶段流程中,预核销与核销是核心接口。门店创建和商家授权可先在控制台完成,也可按业务需要接入相应接口;需要在自己的后台配置套餐权益时接团购商品列表,需要撤回核销时再接撤销接口。具体取舍见接口清单,不是把所有接口都列为必接。

微信小程序接入团购核销,为什么要先预核销?

在本文使用的无限聚核 V2 服务零售聚合接口中,美团和抖音的新核销请求都要先调用“输码验券校验”,取得同一 shopIdplatformcode 对应的 ticketInfo。预核销用于检查和准备确认上下文,不代表核销成功,也不发放权益;顾客确认后才核销。核销超时后的重试复用原请求,不重新预核销,见预核销流程

团购核销接口超时后怎么重试?

核销幂等由平台保证。超时等非业务错误、可重试技术失败或已识别的结果未知,由业务后端有间隔、有上限地重复首次请求的 shopIdplatformcodenumticketInfo,不换参数。明确业务拒绝应停止自动重试;若仅收到已核销提示但缺少首次成功记录,转入核对,不直接补发权益。处理边界见原参数重试示例

开始开发时,打开无限聚核 API 文档,按门店映射、授权、预核销、核销、撤销的顺序联调,再把结果接回自己的预约或权益系统。