From 318dc24d351c7a6b28af54f92c3a888b130b677e Mon Sep 17 00:00:00 2001 From: John Niang Date: Thu, 17 Sep 2026 18:05:34 +0800 Subject: [PATCH] Document shop subscription integration and fulfillment callback Add a developer guide section for integrating third-party services with the shop subscription system: lifecycle, webhooks, and the fulfillment report callback. Also correct three inaccuracies in the shop webhook page. --- docs/developer-guide/_meta.json | 6 + docs/developer-guide/shop/_meta.json | 6 + .../shop/fulfillment-callback.md | 156 +++++++++++++++ docs/developer-guide/shop/index.md | 92 +++++++++ .../shop/subscription-lifecycle.md | 187 ++++++++++++++++++ .../shop/subscription-webhook.md | 147 ++++++++++++++ docs/guide/shop/webhooks.md | 6 +- 7 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 docs/developer-guide/shop/_meta.json create mode 100644 docs/developer-guide/shop/fulfillment-callback.md create mode 100644 docs/developer-guide/shop/index.md create mode 100644 docs/developer-guide/shop/subscription-lifecycle.md create mode 100644 docs/developer-guide/shop/subscription-webhook.md diff --git a/docs/developer-guide/_meta.json b/docs/developer-guide/_meta.json index f72e04ef..cf7ad524 100644 --- a/docs/developer-guide/_meta.json +++ b/docs/developer-guide/_meta.json @@ -28,6 +28,12 @@ "label": "RESTful API", "collapsed": true }, + { + "type": "dir", + "name": "shop", + "label": "商城对接", + "collapsed": true + }, { "type": "dir", "name": "app-store", diff --git a/docs/developer-guide/shop/_meta.json b/docs/developer-guide/shop/_meta.json new file mode 100644 index 00000000..7ec7389c --- /dev/null +++ b/docs/developer-guide/shop/_meta.json @@ -0,0 +1,6 @@ +[ + "index", + "subscription-lifecycle", + "subscription-webhook", + "fulfillment-callback" +] diff --git a/docs/developer-guide/shop/fulfillment-callback.md b/docs/developer-guide/shop/fulfillment-callback.md new file mode 100644 index 00000000..d7c72659 --- /dev/null +++ b/docs/developer-guide/shop/fulfillment-callback.md @@ -0,0 +1,156 @@ +--- +title: 履约回调 +description: 订阅订单的交付请求通知与发货上报接口:鉴权准备、请求与响应字段、幂等语义、错误处理与对账。 +--- + +:::note 适用范围 +本页适用于 **Halo 商城版 2.27.0 及以上版本**。 +::: + +订阅订单行不走物流发货,Halo **不会自行判定订阅权益是否已经交付**:订单支付后 Halo 通知接入方交付,接入方交付完成后调用 Halo 的接口回执,Halo 据此记账。 + +```mermaid +sequenceDiagram + participant H as Halo 商城 + participant I as 接入方服务 + H->>I: ① Webhook FULFILLMENT_REQUESTED(请求交付) + I->>I: 完成交付(开通权益、发放授权等) + I->>H: ② POST /orders/{id}/fulfillment-reports(回执) + H->>H: 记账并刷新订单履约状态 +``` + +- ① 在订单支付成功后自动发出一次,运营也可以在控制台订单详情页重新发送。 +- ② 只接受**订阅订单行**。实物行与虚拟商品行沿用原有发货流程,上报会被拒绝。 +- 上报不会创建发货单,也不会发放卡密或数字资源;虚拟交付见[商城 / 虚拟交付](../../guide/shop/virtual-delivery.mdx)。 + +## 鉴权准备 + +1. 在控制台创建一个专用 Halo 用户,并绑定角色**「订单发货上报」**(`role-template-report-ecommerce-fulfillments`)。 +2. 用该用户登录用户中心,创建个人令牌,只勾选该角色。 +3. 在接入方系统中配置令牌,请求时携带 `Authorization: Bearer pat_xxx`。 + +该角色只包含两项权限: + +| API 分组 | 资源 | 权限 | +| -------------------------------- | ---------------------------- | ------------- | +| `console.api.ecommerce.halo.run` | `orders` | `get`、`list` | +| `console.api.ecommerce.halo.run` | `orders/fulfillment-reports` | `create` | + +:::warning 先授角色,再签令牌 +个人令牌只能申请签发者已有的角色,因此必须先在控制台给该用户授权,再用它创建令牌。请勿把管理员令牌交给接入方服务。 +::: + +## 交付请求通知(出站) + +订单支付成功后,只要订单中还有未履约的订阅行,Halo 就会投递一次 `FULFILLMENT_REQUESTED`: + +``` +POST {你的回调地址} +X-Halo-Event: FULFILLMENT_REQUESTED +X-Halo-Signature-256: sha256={hex} +X-Halo-Webhook-Id: {uuid} +``` + +`data.order` 与 `ORDER_PAID` 完全一致,包含订单行列表;订阅行的标识字段见[订阅 Webhook](./subscription-webhook.md#关联的订单事件)。 + +- 收到该事件表示「Halo 希望你交付这个订单」,**不代表订单已经发货**。 +- 该事件在支付后自动发送一次,重试与手动重发共享同一个 `X-Halo-Webhook-Id`。 +- 漏收时可以请运营在控制台订单详情页重新发送,也可以核对订单后直接上报,Halo 不要求必须先收到通知。 + +## 发货上报(入站) + +``` +POST /apis/console.api.ecommerce.halo.run/v1alpha1/orders/{id}/fulfillment-reports +Authorization: Bearer pat_xxx +Content-Type: application/json +``` + +`{id}` 是订单 ID,即 Webhook 载荷中的 `data.order.id`,不是订单编号。 + +| 字段 | 类型 | 必填 | 说明 | +| --------------------- | ------ | ---- | ------------------------------------------------------------------------------ | +| `items` | 数组 | 否 | 上报的订单行。**省略、传空数组或使用空请求体表示上报该订单全部未履约的订阅行** | +| `items[].orderItemId` | 数字 | 是 | 订单行 ID,取自载荷中的 `data.order.items[].id`,必须大于 0 | +| `items[].quantity` | 数字 | 是 | 本次交付数量,必须大于 0 | +| `externalReference` | 字符串 | 否 | 接入方单据号,最长 128 字符,仅用于审计留痕 | + +请求示例: + +```bash +curl -X POST \ + 'https://demo.halo.run/apis/console.api.ecommerce.halo.run/v1alpha1/orders/10/fulfillment-reports' \ + -H 'Authorization: Bearer pat_1234567890abcdef' \ + -H 'Content-Type: application/json' \ + -d '{"items":[{"orderItemId":1001,"quantity":1}],"externalReference":"SUB-2026-0001"}' +``` + +响应 `200`: + +```json +{ + "orderId": 10, + "orderCode": "ORD-20260917-001", + "fulfillmentStatus": "FULFILLED", + "items": [ + { + "orderItemId": 1001, + "quantity": 1, + "appliedQuantity": 1, + "fulfilledQuantity": 1 + } + ] +} +``` + +| 字段 | 说明 | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `fulfillmentStatus` | 上报后的订单履约状态:`PENDING`(还有未履约行)/ `PROCESSING` / `FULFILLED`。该状态按订单的全部订单行(实物与订阅)计算 | +| `items[].quantity` | 本次上报的数量 | +| `items[].appliedQuantity` | 本次实际记账的数量;重复上报已履约的行时为 `0` | +| `items[].fulfilledQuantity` | 记账后该行的累计已履约数量 | + +整单上报(省略 `items`)时,目标为该订单所有还有剩余数量的订阅行,已经履约完成的行不会出现在响应中;显式上报的行都会出现在响应里,本次未记账的行 `appliedQuantity` 为 `0`。 + +## 幂等与重试 + +| 场景 | 行为 | +| ---------------------------------------------------- | ------------------------------------------------------------------ | +| 订单行已全部履约,再次上报相同或更少数量(网络重试) | 返回 `200`,`appliedQuantity` 为 `0`,不重复计数,也不写订单时间线 | +| 订单行还有剩余,上报数量不超过剩余数量 | 按差额记账,返回 `200` | +| 订单行还有剩余,上报数量超过剩余数量 | 返回 `409`,请按剩余数量修正后重报 | +| 并发上报同一订单 | 服务端串行化记账,不会超额 | + +建议: + +- 交付成功后立即上报,网络失败可以使用相同参数安全重试。 +- 请用「订单 ID + 订单行 ID」作为接入方侧的幂等键,避免重复交付。 +- 收到 `409` 时不要盲目重试,先查询订单核对剩余数量。 + +## 错误处理 + +错误响应遵循 RFC 7807(`application/problem+json`),`detail` 为可读原因并会随 `Accept-Language` 变化,请按 HTTP 状态码判断处理方式。 + +| HTTP | 含义 | 是否可重试 | +| ----- | --------------------------------------------------------------------------------------------- | -------------- | +| `400` | 请求体校验失败(`orderItemId` 与 `quantity` 必须大于 0,`externalReference` 不超过 128 字符) | 否,修正请求 | +| `400` | 订单中没有订阅行 | 否 | +| `400` | `items` 中同一订单行重复出现 | 否 | +| `400` | `orderItemId` 不属于该订单 | 否 | +| `400` | `orderItemId` 不是订阅行(实物或虚拟商品行) | 否 | +| `401` | 个人令牌无效、已撤销或已过期 | 否,更换令牌 | +| `403` | 令牌缺少上报角色 | 否,补齐角色 | +| `404` | 订单不存在 | 否 | +| `409` | 订单未支付或已取消 | 否 | +| `409` | 上报数量超过剩余可履约数量 | 修正后重试 | +| `409` | 并发记账冲突(订单行可能已被超额上报) | 查询订单后重试 | + +## 对账与运维 + +- **查询订单**:`GET /apis/console.api.ecommerce.halo.run/v1alpha1/orders/{id}`(同一令牌可读),用 `items[].quantity`、`items[].fulfilledQuantity` 与 `fulfillmentStatus` 核对是否还有未履约行。 +- **查看时间线**:运营可以在控制台订单详情页看到「请求发货」「接入方上报发货」「订单履约状态已更新」等记录,上报写入的内容包含接入方的 `externalReference`。 +- **漏收通知**:请运营在订单详情页重新发送发货请求,或核对订单后直接上报。 +- **人工兜底**:订阅订单行不能在控制台手动发货,只能由接入方上报。 + +:::note 上报之后 +上报只会更新订单行的已履约数量、刷新订单履约状态并写入一条订单时间线:**不会创建发货单,不会发放卡密或数字资源,也不会触发 `FULFILLMENT_SHIPPED` / `FULFILLMENT_COMPLETED` 事件**。请以接口响应作为记账结果。 +::: diff --git a/docs/developer-guide/shop/index.md b/docs/developer-guide/shop/index.md new file mode 100644 index 00000000..28c3accf --- /dev/null +++ b/docs/developer-guide/shop/index.md @@ -0,0 +1,92 @@ +--- +title: 商城订阅对接 +description: 面向接入方的 Halo 商城订阅对接总览:职责分工、接入方式、个人令牌准备、端到端时序与最小闭环。 +--- + +:::note 适用范围 +本组文档适用于 **Halo 商城版 2.27.0 及以上版本**。订阅能力仅 [Halo 商城版](../../guide/prepare.md#发行版本) 提供。 +::: + +订阅对接的目标是让 Halo 商城负责**售卖、收款、记录周期**,接入方负责**解释权益、实际交付并回执**。Halo 不解释权益的具体含义,也不会自行判定接入方是否已经交付——这两件事通过 Webhook 与履约上报接口交给接入方。 + +## 职责分工 + +| Halo 商城 | 接入方 | +| ------------------------------------------ | ------------------------------ | +| 产品线、档次(权益契约)、计划、价格与周期 | 定义 `entitlements` 的键与语义 | +| 购物车、结算、支付、订单 | 按事件开通、调整、关闭业务权益 | +| 订阅状态机、续费订单、宽限期、过期 | 用量计量、并发与功能限制 | +| 计划变更报价与折算、变更订单 | 读取订阅快照做鉴权 | +| 订单与订阅事件 Webhook | 交付完成后调用履约上报接口回执 | +| 客户中心(客户自助续费、变更、取消) | 通知、风控、退款等业务规则 | + +接入方**不需要**自己实现支付页:店面购买,以及客户中心的续费、变更、取消都由 Halo 提供。 + +## 接入方式 + +推荐的接入形态是一个**独立的外部服务**:接收 Halo 的出站 Webhook,并在需要时调用 Halo 的 Console API。这也是当前完整支持订阅业务流程的形态。 + +| 方向 | 通道 | 用途 | +| --------------------- | -------------------------------------- | ------------------------------------ | +| 出站(Halo → 接入方) | Webhook(HTTP POST,HMAC-SHA256 签名) | 订阅生命周期事件、订单事件、交付请求 | +| 入站(接入方 → Halo) | Console REST API + 个人令牌 | 履约上报、订单与订阅查询 | + +### 关于 Halo 插件 + +订阅对接**不需要也不支持**通过 Halo 插件扩展商城模块:商城与订阅模块当前不提供插件扩展点(extension point),也不会把订单、订阅事件派发给插件。插件可以自行提供 REST API、自定义模型和角色,这是 Halo 的通用能力,但订阅业务流程仍然只能通过上面的两种通道完成。 + +因此,除为 Halo 增加与订阅无关的自定义功能外,请把接入方实现为独立的外部服务。 + +## 端到端时序 + +```mermaid +sequenceDiagram + participant C as 客户 + participant H as Halo 商城 + participant I as 接入方服务 + C->>H: 购买订阅计划并完成支付 + H->>H: 开通订阅,写入周期与权益快照 + H->>I: Webhook SUBSCRIPTION_CREATED / ORDER_PAID + H->>I: Webhook FULFILLMENT_REQUESTED(请求交付) + I->>I: 为客户开通权益并完成交付 + I->>H: POST /orders/{id}/fulfillment-reports(回执) + H->>H: 记账,订单履约状态变为 FULFILLED + Note over H,I: 到期前发送 SUBSCRIPTION_RENEWAL_REMINDER + C->>H: 在客户中心手动续费并支付 + H->>I: Webhook SUBSCRIPTION_RENEWED +``` + +## 接入准备 + +1. **创建专用用户与角色**:在控制台新建一个 Halo 用户(建议只用于对接),并绑定角色**「订单发货上报」**(`role-template-report-ecommerce-fulfillments`)。 +2. **签发个人令牌**:用该用户登录用户中心,进入**个人令牌**创建令牌,只勾选上一步的角色。令牌只在创建时显示一次,请妥善保存。 +3. **确认访问地址**:Console API 前缀为 `https://{host}/apis/console.api.ecommerce.halo.run/v1alpha1`,请求头携带 `Authorization: Bearer pat_xxx`。令牌创建方式见[个人中心 / 个人令牌](../../guide/use/user-center.md#个人令牌),认证方式说明见 [RESTful API 介绍](../restful-api/introduction.md#认证方式)。 +4. **配置 Webhook**:在控制台 **Webhook** 中新建配置,填写回调 URL 与密钥,并订阅需要的[订阅事件](./subscription-webhook.md)。操作步骤见[商城 / Webhook](../../guide/shop/webhooks.md)。 +5. **记录对接标识**:产品线的 `productId`、档次的 `handle`、计划的 `planId` 与 `variantId`。后续所有对账都依赖这些标识。 + +:::warning 令牌权限范围 +「订单发货上报」角色只有读取订单与上报发货两项权限。控制台的订阅查询、权益查询等接口属于 Console 分组,需要管理员权限的个人令牌;请勿把管理员令牌交给第三方服务。 +::: + +## 最小闭环 + +1. 运营在控制台配置 `productType=SUBSCRIPTION` 的产品线、档次与计划,详见[订阅生命周期](./subscription-lifecycle.md#数据模型)。 +2. 接入方订阅 `SUBSCRIPTION_*` 事件与 `FULFILLMENT_REQUESTED`。 +3. 客户下单支付后,接入方按 `SUBSCRIPTION_CREATED` / `SUBSCRIPTION_TRIAL_STARTED` 载荷中的 `effectiveEntitlements` 开通权益。 +4. 收到 `FULFILLMENT_REQUESTED` 后完成交付,并调用[履约上报接口](./fulfillment-callback.md)回执;**不上报,订单会一直停留在待发货**。 +5. 收到 `SUBSCRIPTION_CANCELLED` / `SUBSCRIPTION_EXPIRED` 后停用权益;收到 `SUBSCRIPTION_PLAN_CHANGED` / `SUBSCRIPTION_RENEWED` 后按新快照刷新。 + +## 本组文档 + +| 文档 | 内容 | +| ------------------------------------------- | ---------------------------------------------------------------- | +| [订阅生命周期](./subscription-lifecycle.md) | 数据模型、状态机、周期与试用、续费与变更、权益截止判定、查询接口 | +| [订阅 Webhook](./subscription-webhook.md) | 投递格式、验签、重试与去重、订阅事件参考、载荷字段与处理建议 | +| [履约回调](./fulfillment-callback.md) | 交付请求通知、发货上报接口、幂等语义、错误处理与对账 | + +相关文档: + +- [商城 / Webhook](../../guide/shop/webhooks.md) +- [商城 / 订单管理](../../guide/shop/orders.mdx) +- [商城 / 虚拟交付](../../guide/shop/virtual-delivery.mdx) +- [RESTful API 介绍](../restful-api/introduction.md) diff --git a/docs/developer-guide/shop/subscription-lifecycle.md b/docs/developer-guide/shop/subscription-lifecycle.md new file mode 100644 index 00000000..eb230e48 --- /dev/null +++ b/docs/developer-guide/shop/subscription-lifecycle.md @@ -0,0 +1,187 @@ +--- +title: 订阅生命周期 +description: Halo 商城订阅的完整周期:数据模型、状态机、试用与续费、计划变更、取消与过期,以及权益截止时间的判定方式。 +--- + +:::note 适用范围 +本页适用于 **Halo 商城版 2.27.0 及以上版本**。传输与鉴权约定见[商城订阅对接](./index.md),事件推送见[订阅 Webhook](./subscription-webhook.md)。 +::: + +## 数据模型 + +订阅由四层对象组成,由运营在控制台 **商店 → 商品** 中配置: + +| 对象 | 说明 | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 产品线 Product | `productType=SUBSCRIPTION` 的商品,例如「示例 Coding 套餐」。 | +| 档次 Tier | 产品线下的商业等级,是**权益契约的归属单位**。`entitlements` 是任意 JSON,由接入方解释;同一档次下的月付与年付共享同一份契约。 | +| 计划 Plan | 可购买的套餐,由档次 × 计费模式 × 周期组成,包含 `billingMode`、`billingPeriod`、`periodCount`、`price`、试用配置与数量上下限。计划与商品规格(`variantId`)一一对应。 | +| 订阅 Subscription | 某客户在某条产品线下的实例,保存状态、周期与 `effectiveEntitlements` 快照。 | + +产品线策略决定同一客户能否并存多条订阅、试用是否只发一次、到期前提前几天提醒: + +| 策略 | 说明 | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `singleSubscription` | 为 `true` 时同一客户在同一产品线同时只有一条生效订阅,再次购买会作用于已有订阅;为 `false` 时每次购买都会新建一条独立订阅,各自续费、变更与取消。 | +| `trialOncePerCustomer` | 试用是否按客户只发一次(以该客户在该产品线是否有历史订阅判断)。 | +| `renewalLeadDays` | 到期前多少天发送续费提醒,默认 5 天。 | + +计划字段中与周期相关的取值: + +| 取值 | 说明 | +| ---------------------------------- | ------------------------------------------------------------------------------ | +| `billingMode=SUBSCRIPTION` | 连续计费:到期前提醒,到期未付进入 `PAST_DUE` 宽限期,客户可手动续费。 | +| `billingMode=ONE_TIME` | 预付一期:到期直接 `EXPIRED`,需要重新购买。 | +| `billingMode=LIFETIME` | 买断:周期字段为空,永不过期。 | +| `billingPeriod=MONTHLY` / `YEARLY` | 周期单位;季度付是 `MONTHLY` 加 `periodCount=3`。 | +| `gracePeriodDays` | 仅连续计费有效:到期未付后的容忍天数。 | +| `entitlements` | 档次上的 JSON 契约,在购买、变更、续费时快照到订阅的 `effectiveEntitlements`。 | + +接入方需要长期依赖的关联键: + +- `customerId`:商城客户 ID,用于与接入方账号对齐(控制台可通过顾客接口换取 `userId`、邮箱)。 +- `productId`:产品线 ID。 +- `planId`:档次内的具体套餐,请以此判断档位,不要用价格或名称。 +- `variantId`:计划对应的商品规格 ID,订单行使用它标识订阅行。 + +## 状态机 + +```mermaid +stateDiagram-v2 + [*] --> TRIALING: 按试用价购买 + [*] --> ACTIVE: 标准购买(含买断) + TRIALING --> ACTIVE: 试用转正支付 + TRIALING --> EXPIRED: 试用结束且超过宽限仍未转正 + ACTIVE --> PAST_DUE: 到期未续费(连续计费) + ACTIVE --> EXPIRED: 到期未续费(预付一期) + ACTIVE --> CANCELLED: 到期取消或立即取消 + PAST_DUE --> ACTIVE: 续费支付 + PAST_DUE --> EXPIRED: 宽限期结束 + PAST_DUE --> CANCELLED: 立即取消 + EXPIRED --> ACTIVE: 重新购买(复用同一订阅行) + CANCELLED --> ACTIVE: 重新购买(复用同一订阅行) +``` + +| 状态 | 含义 | 是否仍在履约 | +| ----------- | ------------------------------------ | ------------ | +| `TRIALING` | 试用中,只收了试用价 | 是 | +| `ACTIVE` | 生效中 | 是 | +| `PAST_DUE` | 周期已到期,仍在宽限期内(连续计费) | 是 | +| `CANCELLED` | 已取消 | 否 | +| `EXPIRED` | 已过期 | 否 | + +`TRIALING`、`ACTIVE`、`PAST_DUE` 统称为**生效订阅**。只有生效订阅会阻止同一客户重复购买(`singleSubscription=true` 时),权益查询也只返回这些订阅。 + +## 周期与时间字段 + +| 字段 | 含义 | +| ----------------------------- | ---------------------------------------------------------------------------------- | +| `trialStartAt` / `trialEndAt` | 试用起止;没有试用时为 `null`。 | +| `currentPeriodStartAt` | 当前周期起点。 | +| `currentPeriodEndAt` | 当前周期终点。**试用期间它表示「转正后第一个周期」的终点**,此时首期费用尚未收取。 | +| `paidThroughAt` | 已付费覆盖到的终点。买断(`LIFETIME`)为 `null`。 | + +周期长度按 UTC 计算:`MONTHLY` 使用 `plusMonths(periodCount)`,`YEARLY` 使用 `plusYears(periodCount)`。 + +一条订阅通常经历以下阶段: + +1. **购买(PURCHASE)**:支付成功后开通订阅,写入首个周期与权益快照。命中试用时为 `TRIALING`,否则为 `ACTIVE`;买断订阅的周期字段为 `null`。 +2. **试用转正(TRIAL_CONVERT)**:客户在客户中心续费(试用期使用同一入口)并支付后转为 `ACTIVE`,首期从 `trialEndAt` 起算。 +3. **续费(RENEWAL)**:客户手动续费并支付后,覆盖终点顺延一个周期,状态保持或恢复为 `ACTIVE`。 +4. **到期**:连续计费进入 `PAST_DUE` 并开始宽限期;预付一期直接 `EXPIRED`;已勾选到期取消则转为 `CANCELLED`。 +5. **计划变更(PLAN_CHANGE)**:支付完成(或免费变更)后立即生效,并**从生效时刻重新开一期**。 +6. **取消(CANCEL)**:客户勾选到期取消后 `cancelAtPeriodEnd=true`,当期继续有效,到期转为 `CANCELLED`。 + +## 续费与到期 + +Halo **不保存支付凭据,也不会自动扣款**,任何续费都必须由客户主动发起: + +- 到期前 `renewalLeadDays` 天,Halo 发送一次 `SUBSCRIPTION_RENEWAL_REMINDER` 并给客户发提醒邮件;同一个到期时点只发一次,已勾选到期取消的订阅不发提醒。 +- 客户在客户中心点击续费,生成一张续费订单(连续计费)或转正订单(试用中),支付成功后周期与状态更新。 +- 续费、转正、变更订单与普通订单一致,**24 小时未支付会自动过期**;价格按下单时的现价计算,订阅上不保存价格快照。 +- 试用到期后不会自动生成转正订单;超过 `trialEndAt` 加宽限期(`gracePeriodDays`,最少 1 天)仍未支付即 `EXPIRED`。 +- 到期转移顺序:`cancelAtPeriodEnd=true` 优先转为 `CANCELLED`;否则连续计费进入 `PAST_DUE`,预付一期直接 `EXPIRED`。 + +:::tip 代付 +如需为客户钱包自动扣款,需要在客户登录态下发起续费(客户中心接口)取得订单,再由运营侧令牌调用 `POST /orders/{id}/mark-as-paid` 完成支付。Halo 当前没有面向接入方的「代客户创建续费单」接口。 +::: + +## 计划变更 + +- 只支持**升级与平移**,且两端必须是相同的 `billingPeriod`;降级与跨周期变更不被支持。 +- 每个方向都必须由运营显式配置一条**启用中的变更规则**(`fromPlanId → toPlanId`)。没有规则的方向一律拒绝,即使是升级。 +- 费用模式由规则决定:`FREE`(免费,立即生效且不产生订单)、`FIXED_FEE`(按固定单价 × 数量)、`PRORATED`(新一期全价减去当前计划未使用的剩余价值,基数为计划标价)。 +- **变更会重置周期**:生效后 `currentPeriodStartAt` 为生效时刻,`currentPeriodEndAt` 与 `paidThroughAt` 为生效时刻加目标计划的周期。接入方必须使用事件或查询返回的新的 `paidThroughAt` 更新到期时间,不要沿用旧到期日,也不要按剩余天数顺延。 +- 变更不修改数量,数量沿用订阅当前值。 +- 订阅已过期或已取消、仍在试用期、存在待支付变更单时,变更会被拒绝。 + +## 取消 + +- 客户在客户中心取消(默认到期取消):写入 `cancelAtPeriodEnd=true`,当期权益继续有效,到期时转为 `CANCELLED`。 +- 立即取消仅在该订阅已进入 `PAST_DUE` 时允许客户自助发起;运营可以在控制台对未终止的订阅立即取消。 +- 试用中的订阅勾选到期取消后,到期会按试用过期处理并进入 `EXPIRED`。 + +## 权益如何判定 + +`entitlements` 是运营与接入方约定的契约,Halo 只负责快照与下发,不解释也不强制执行。**权益截止时间必须按状态分支判断**,不能直接使用 `currentPeriodEndAt`: + +| 状态 | 权益截止时间 | +| ------------------------------------ | ------------------------------------------------ | +| `TRIALING` | `trialEndAt`(试用期只收了试用价) | +| `ACTIVE` / `PAST_DUE` | `paidThroughAt`,为空时回退 `currentPeriodEndAt` | +| 买断(`ACTIVE` 且周期字段为 `null`) | 无终点,永不过期 | +| `CANCELLED` / `EXPIRED` | 已无权益,`paidThroughAt` 只是历史覆盖记录 | + +```ts +// 按状态分支计算权益截止时间 +function coveredUntilAt(s: Subscription): string | null { + switch (s.status) { + case "TRIALING": + return s.trialEndAt; + case "ACTIVE": + case "PAST_DUE": + // 买断订阅两者均为 null,返回 null 表示永不过期 + return s.paidThroughAt ?? s.currentPeriodEndAt; + default: + return null; // CANCELLED / EXPIRED:停用权益 + } +} +``` + +需要注意: + +- `TRIALING` 期间 `currentPeriodStartAt` / `currentPeriodEndAt` 描述的是转正后的第一个周期,**不能当作当前有效周期**,直接使用会把到期时间显示成「试用结束加一个周期」。 +- 权益快照在**购买、变更应用、续费**时重算。运营修改档次的 `entitlements` 不会立即推送给已有订阅,要等下一次续费或变更。 +- 收到快照后请整体覆盖本地权益,不要在本地累加周期或额度。 + +## 查询与对账接口 + +Console API(前缀 `https://{host}/apis/console.api.ecommerce.halo.run/v1alpha1`,需要管理员令牌): + +| 方法 | 路径 | 用途 | +| ---- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| GET | `/subscriptions?customerId=&productId=&status=&page=&size=` | 订阅列表 | +| GET | `/subscriptions/{id}` | 订阅详情,含 `effectiveEntitlements` 与 `upcomingRenewal` | +| GET | `/customers/{customerId}/entitlements?productId=` | **权益二次确认**:返回 `{ customerId, productId, entitlements }`,没有生效订阅时 `entitlements` 为 `{}` | +| GET | `/subscription-changes?subscriptionId=` | 变更流水,含 `orderId`、计价结果与前后快照 | +| POST | `/subscriptions/{id}/cancel` | 运营立即取消 | +| POST | `/orders/{id}/mark-as-paid` | 代付:把续费或变更订单标记为已支付 | +| POST | `/orders/{id}/cancel` | 取消未支付的续费或变更订单 | + +客户中心 API(前缀 `https://{host}/apis/uc.api.ecommerce.halo.run/v1alpha1`,需要客户登录态)由 Halo 页面使用,接入方通常不需要直接调用;如需代客户发起,必须在客户登录态下调用: + +| 方法 | 路径 | 说明 | +| ---- | ------------------------------------------------------------------ | ------------------------------------------------ | +| GET | `/subscriptions`、`/subscriptions/{id}` | 我的订阅与详情 | +| POST | `/subscriptions/{id}/changes/quote`、`/subscriptions/{id}/changes` | 变更报价与执行 | +| POST | `/subscriptions/{id}/cancel` | 取消,请求体 `{"immediate": false}` 表示到期取消 | +| POST | `/subscriptions/{id}/renew` | 手动续费出单,试用期为转正单 | +| GET | `/subscription-changes?subscriptionId=` | 变更时间线 | + +:::warning 客户中心接口路径没有 `/uc` 段 +客户中心接口的路径是 `/apis/uc.api.ecommerce.halo.run/v1alpha1/subscriptions`,**不要**再加一层 `/uc`。写成 `/v1alpha1/uc/subscriptions` 会因权限校验把首段路径当作资源名而返回 `403`。 +::: + +:::note 完整契约 +以上仅为常用接口。字段与状态码的完整定义以运行实例的 API 文档为准(在线文档:[https://api.halo.run](https://api.halo.run),分组为 `console.api.ecommerce.halo.run` 与 `uc.api.ecommerce.halo.run`)。 +::: diff --git a/docs/developer-guide/shop/subscription-webhook.md b/docs/developer-guide/shop/subscription-webhook.md new file mode 100644 index 00000000..ee9f5bae --- /dev/null +++ b/docs/developer-guide/shop/subscription-webhook.md @@ -0,0 +1,147 @@ +--- +title: 订阅 Webhook +description: 订阅 Webhook 的投递格式、签名验证、重试与去重,以及各订阅事件的触发时机、载荷字段与处理建议。 +--- + +:::note 适用范围 +本页适用于 **Halo 商城版 2.27.0 及以上版本**。Webhook 的创建与投递记录查看见[商城 / Webhook](../../guide/shop/webhooks.md),订阅业务语义见[订阅生命周期](./subscription-lifecycle.md)。 +::: + +## 投递格式 + +Halo 以 `POST` 请求把事件投递到接入方配置的回调 URL,请求体是统一信封: + +```json +{ + "eventType": "SUBSCRIPTION_CREATED", + "timestamp": "2026-09-17T10:00:00Z", + "webhookId": 1, + "data": { + "subscription": {} + } +} +``` + +| 字段 | 说明 | +| ----------- | -------------------------------------------------- | +| `eventType` | 事件类型,例如 `SUBSCRIPTION_CREATED`。 | +| `timestamp` | 载荷生成时间(ISO-8601 UTC)。 | +| `webhookId` | Webhook **配置**的 ID(数字),不是本次投递的 ID。 | +| `data` | 事件数据,订阅事件固定为 `data.subscription`。 | + +请求头: + +| 请求头 | 说明 | +| --------------------------- | -------------------------------------------------- | +| `X-Halo-Event` | 事件类型,便于路由 | +| `X-Halo-Signature-256` | `sha256=`,对**原始请求体**计算的 HMAC-SHA256 | +| `X-Halo-Delivery-Timestamp` | 本次投递时间(Unix 秒),**不参与签名** | +| `X-Halo-Webhook-Id` | 本次投递的 ID(UUID),重试与手动重投保持不变 | +| `X-Halo-Delivery-Attempt` | 当前投递次数,从 1 开始 | +| `User-Agent` | `Halo-Webhook/1.0` | + +## 验证签名 + +使用创建 Webhook 时填写的密钥,对未经解析的原始请求体验签,并使用常量时间比较: + +```python +import hashlib +import hmac + +expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() +if not hmac.compare_digest(received_signature, expected): + raise ValueError("invalid webhook signature") +``` + +:::warning 注意 +`X-Halo-Delivery-Timestamp` 不参与签名,签名本身不提供防重放能力;如需防重放,请自行基于该请求头做时间窗校验。另外不要先解析再重新序列化 JSON,字节变化会导致验签失败。 +::: + +## 返回状态与重试 + +| 端点返回 | Halo 的处理 | +| ----------------------------- | ------------------------------------------------------------------------------------- | +| `2xx` | 投递成功 | +| `4xx` | 判定为请求或配置错误,立即终止,不再重试 | +| `5xx`、`3xx` 或网络错误、超时 | 重试,间隔约 1 分钟、5 分钟、30 分钟、2 小时、8 小时,最多投递 6 次(约 10 小时窗口) | + +Halo 等待响应的超时时间为 10 秒。请先完成验签与持久化,尽快返回 `2xx`,耗时处理放到后台任务。投递失败后可以在控制台的**投递记录**中查看请求与响应,并手动重新投递。 + +## 幂等与顺序 + +- 采用**至少一次**投递:自动重试与手动重投都可能产生重复请求。请以 `X-Halo-Webhook-Id` 作为幂等键,处理过的 ID 直接返回 `2xx`。 +- 同一订单(或同一订阅)的事件通常按发布顺序投递,但重试不受顺序约束,可能出现交错。请以订阅的当前快照收敛本地状态,不要假设事件严格有序。 +- 同一逻辑事件的多次投递共享同一个 `X-Halo-Webhook-Id`;手动重新投递时 `X-Halo-Delivery-Attempt` 会重置为 1。 + +## 订阅事件参考 + +| 事件 | 触发时机 | 建议动作 | +| ------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------- | +| `SUBSCRIPTION_CREATED` | 购买支付成功后开通订阅 | 按 `effectiveEntitlements` 开通权益 | +| `SUBSCRIPTION_TRIAL_STARTED` | 按试用价开通订阅 | 开通试用权益,记录 `trialEndAt` | +| `SUBSCRIPTION_TRIAL_CONVERTED` | 试用转正支付完成 | 切换为付费档次权益 | +| `SUBSCRIPTION_RENEWAL_REMINDER` | 到期前 `renewalLeadDays` 天 | 用自有渠道提醒客户续费;同一到期时点只发一次 | +| `SUBSCRIPTION_RENEWAL_ORDER_CREATED` | 客户发起续费并生成续费单 | 如需代付,可通过变更流水取到 `orderId` | +| `SUBSCRIPTION_RENEWED` | 续费支付完成,覆盖终点顺延 | 刷新周期与到期时间,按自有规则重置额度 | +| `SUBSCRIPTION_PLAN_CHANGED` | 计划变更已生效 | 用新快照替换本地权益;**周期已重置**,按新的 `paidThroughAt` 更新到期时间 | +| `SUBSCRIPTION_QUANTITY_CHANGED` | 订阅数量发生变化 | 按新快照刷新权益 | +| `SUBSCRIPTION_CHANGE_APPLIED` | 变更单已应用 | 审计与对账;常与 `PLAN_CHANGED` 一同出现 | +| `SUBSCRIPTION_CANCEL_SCHEDULED` | 客户勾选到期取消 | 当期仍然有效;标记本地「不再续费」 | +| `SUBSCRIPTION_CANCELLED` | 订阅已取消 | **立即停用**业务权益 | +| `SUBSCRIPTION_PAST_DUE` | 到期未续费,进入宽限期 | 可降级为只读或限流,或等待宽限结束 | +| `SUBSCRIPTION_EXPIRED` | 已过期(宽限结束、预付一期到期或试用过期) | **立即停用**业务权益 | + +:::note 暂不投递的事件 +`SUBSCRIPTION_RENEWAL_FAILED` 与 `SUBSCRIPTION_CHANGE_FAILED` 会出现在控制台的事件列表中,但当前版本不会产生投递:支付失败不会改变订阅状态,`PAST_DUE` 仅由到期时间驱动。 +::: + +:::tip 试用转正 +试用中的订阅调用续费入口时生成的是**转正单**,不会触发 `SUBSCRIPTION_RENEWAL_ORDER_CREATED`;转正支付完成后会触发 `SUBSCRIPTION_TRIAL_CONVERTED`。 +::: + +## 载荷字段 + +所有订阅事件的 `data.subscription` 结构一致,是**事件发生之后**的订阅快照,不包含订单号与变更前后明细: + +| 字段 | 类型 | 说明 | +| --------------------------------------------- | -------- | ------------------------------------------------------------ | +| `id` | 数字 | 订阅 ID | +| `customerId` | 数字 | 客户 ID | +| `productId` | 数字 | 产品线 ID | +| `planId` | 数字 | 当前计划 ID | +| `variantId` | 数字 | 当前计划对应的商品规格 ID | +| `status` | 字符串 | `TRIALING` / `ACTIVE` / `PAST_DUE` / `CANCELLED` / `EXPIRED` | +| `quantity` | 数字 | 订阅数量 | +| `cancelAtPeriodEnd` | 布尔 | 是否已勾选到期取消 | +| `trialStartAt` / `trialEndAt` | 时间或空 | 试用起止 | +| `currentPeriodStartAt` / `currentPeriodEndAt` | 时间或空 | 当前周期起止 | +| `paidThroughAt` | 时间或空 | 已付费覆盖终点;买断为 `null` | +| `effectiveEntitlements` | 对象或空 | 权益契约快照 | + +需要订单号、差价明细或变更前后的周期时,请用 Console 查询接口补全:续费单、转正单与变更单本身也是订单,会同时触发订单域事件。 + +## 关联的订单事件 + +订阅的续费单、转正单与变更单都是普通订单,因此还会触发订单域 Webhook: + +- `ORDER_CREATED`:订单创建。 +- `ORDER_PAID`:订单支付完成。应付金额为 0 的订单在创建时即视为已支付,会同时触发 `ORDER_CREATED` 与 `ORDER_PAID`。 +- `FULFILLMENT_REQUESTED`:需要接入方交付订阅行时的交付请求,见[履约回调](./fulfillment-callback.md)。 + +订单载荷中的 `data.order.items[].subscriptionMetadata` 可以区分订单来源: + +| 字段 | 说明 | +| -------------------------------------------- | -------------------------------------------------------- | +| `type` | `PURCHASE` / `RENEWAL` / `PLAN_CHANGE` / `TRIAL_CONVERT` | +| `planId` | 相关计划 ID | +| `changeId` | 关联的变更 ID(变更单) | +| `fromQuantity` / `toQuantity` | 变更前后的数量 | +| `feeType` | 费用类型,如 `TRIAL` / `FREE` / `FIXED_FEE` / `PRORATED` | +| `planName` / `billingPeriod` / `billingMode` | 下单时的计划快照 | + +## 处理建议 + +1. 验签 → 用 `X-Halo-Webhook-Id` 去重 → 用 `data.subscription` 覆盖本地快照 → 按新的 `status` 与 `entitlements` 调整限额。 +2. 开通与关闭权益必须幂等,重复投递不得叠加额度。 +3. 以 Halo 下发的快照为准,不要在本地自行推算周期,详见[权益如何判定](./subscription-lifecycle.md#权益如何判定)。 +4. 控制台的**发送测试事件**会投递 `WEBHOOK_TEST`,载荷是订单结构且字段与真实事件不完全一致(例如收货地址使用的是测试字段),请勿写入业务数据。 diff --git a/docs/guide/shop/webhooks.md b/docs/guide/shop/webhooks.md index ac5d1dcc..0d63ed97 100644 --- a/docs/guide/shop/webhooks.md +++ b/docs/guide/shop/webhooks.md @@ -29,7 +29,7 @@ Webhook 会把订单、支付和发货事件以 HTTP `POST` 请求发送到外 { "eventType": "ORDER_PAID", "timestamp": "2026-08-31T08:00:00Z", - "webhookId": "webhook-config-name", + "webhookId": 1, "data": {} } ``` @@ -51,10 +51,12 @@ Webhook 会把订单、支付和发货事件以 HTTP `POST` 请求发送到外 - `ORDER_CANCELLED` - `PAYMENT_FAILED` - `PAYMENT_CANCELLED` +- `FULFILLMENT_REQUESTED` - `FULFILLMENT_SHIPPED` - `FULFILLMENT_COMPLETED` +- `SUBSCRIPTION_*` 订阅事件 -零元订单只触发 `ORDER_CREATED`,不会触发 `ORDER_PAID`。当前没有退款事件。 +零元订单在创建时即视为已支付,会同时触发 `ORDER_CREATED` 和 `ORDER_PAID`。当前没有退款事件。订阅事件的含义、载荷与处理方式见[订阅 Webhook](../../developer-guide/shop/subscription-webhook.md)。 ## 验证签名