From 318dc24d351c7a6b28af54f92c3a888b130b677e Mon Sep 17 00:00:00 2001 From: John Niang Date: Thu, 17 Sep 2026 18:05:34 +0800 Subject: [PATCH 1/4] 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)。 ## 验证签名 From c7b635169917ecf305a107fed2dff6c61bbf4229 Mon Sep 17 00:00:00 2001 From: John Niang Date: Thu, 15 Oct 2026 18:12:59 +0800 Subject: [PATCH 2/4] docs: add shop subscription user guide and refine developer docs - Add subscription management guide covering tiers, plans, change rules, policies, console management and customer self-service - Document guest checkout and subscription menu settings - Note subscription product type, fulfillment and UC subscription section in products, orders and customer center pages - Add periodDelta field, reminder scope and repurchase reuse conditions to developer docs --- docs/developer-guide/shop/index.md | 3 +- .../shop/subscription-lifecycle.md | 8 +- .../shop/subscription-webhook.md | 1 + docs/guide/shop/_meta.json | 1 + docs/guide/shop/customer-center.md | 15 ++- docs/guide/shop/orders.mdx | 1 + docs/guide/shop/products.mdx | 3 +- docs/guide/shop/settings.md | 14 ++- docs/guide/shop/subscriptions.md | 109 ++++++++++++++++++ 9 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 docs/guide/shop/subscriptions.md diff --git a/docs/developer-guide/shop/index.md b/docs/developer-guide/shop/index.md index 28c3accf..54ff61e0 100644 --- a/docs/developer-guide/shop/index.md +++ b/docs/developer-guide/shop/index.md @@ -70,7 +70,7 @@ sequenceDiagram ## 最小闭环 -1. 运营在控制台配置 `productType=SUBSCRIPTION` 的产品线、档次与计划,详见[订阅生命周期](./subscription-lifecycle.md#数据模型)。 +1. 运营在控制台配置 `productType=SUBSCRIPTION` 的产品线、档次与计划,操作步骤见[商城 / 订阅管理](../../guide/shop/subscriptions.md),数据模型见[订阅生命周期](./subscription-lifecycle.md#数据模型)。 2. 接入方订阅 `SUBSCRIPTION_*` 事件与 `FULFILLMENT_REQUESTED`。 3. 客户下单支付后,接入方按 `SUBSCRIPTION_CREATED` / `SUBSCRIPTION_TRIAL_STARTED` 载荷中的 `effectiveEntitlements` 开通权益。 4. 收到 `FULFILLMENT_REQUESTED` 后完成交付,并调用[履约上报接口](./fulfillment-callback.md)回执;**不上报,订单会一直停留在待发货**。 @@ -87,6 +87,7 @@ sequenceDiagram 相关文档: - [商城 / Webhook](../../guide/shop/webhooks.md) +- [商城 / 订阅管理](../../guide/shop/subscriptions.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 index eb230e48..cc5bbba5 100644 --- a/docs/developer-guide/shop/subscription-lifecycle.md +++ b/docs/developer-guide/shop/subscription-lifecycle.md @@ -9,7 +9,7 @@ description: Halo 商城订阅的完整周期:数据模型、状态机、试 ## 数据模型 -订阅由四层对象组成,由运营在控制台 **商店 → 商品** 中配置: +订阅由四层对象组成,由运营在控制台 **商店 → 商品** 中配置(操作说明见[商城 / 订阅管理](../../guide/shop/subscriptions.md)): | 对象 | 说明 | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -62,6 +62,10 @@ stateDiagram-v2 CANCELLED --> ACTIVE: 重新购买(复用同一订阅行) ``` +:::note 重新购买的复用条件 +仅当产品线策略 `singleSubscription=true` 时,重新购买会复用 `EXPIRED` / `CANCELLED` 的历史订阅行;为 `false` 时每次购买都会新建订阅。此外,如果客户重新购买时仍满足试用资格(`trialOncePerCustomer=false`),订阅会回到 `TRIALING` 而非 `ACTIVE`。 +::: + | 状态 | 含义 | 是否仍在履约 | | ----------- | ------------------------------------ | ------------ | | `TRIALING` | 试用中,只收了试用价 | 是 | @@ -96,7 +100,7 @@ stateDiagram-v2 Halo **不保存支付凭据,也不会自动扣款**,任何续费都必须由客户主动发起: -- 到期前 `renewalLeadDays` 天,Halo 发送一次 `SUBSCRIPTION_RENEWAL_REMINDER` 并给客户发提醒邮件;同一个到期时点只发一次,已勾选到期取消的订阅不发提醒。 +- 到期前 `renewalLeadDays` 天,Halo 发送一次 `SUBSCRIPTION_RENEWAL_REMINDER` 并给客户发提醒邮件;同一个到期时点只发一次,已勾选到期取消的订阅不发提醒。**只有连续计费(`billingMode=SUBSCRIPTION`)的计划会发送提醒**,预付一期到期直接过期、不提醒,买断没有到期。 - 客户在客户中心点击续费,生成一张续费订单(连续计费)或转正订单(试用中),支付成功后周期与状态更新。 - 续费、转正、变更订单与普通订单一致,**24 小时未支付会自动过期**;价格按下单时的现价计算,订阅上不保存价格快照。 - 试用到期后不会自动生成转正订单;超过 `trialEndAt` 加宽限期(`gracePeriodDays`,最少 1 天)仍未支付即 `EXPIRED`。 diff --git a/docs/developer-guide/shop/subscription-webhook.md b/docs/developer-guide/shop/subscription-webhook.md index ee9f5bae..e8a9a69a 100644 --- a/docs/developer-guide/shop/subscription-webhook.md +++ b/docs/developer-guide/shop/subscription-webhook.md @@ -136,6 +136,7 @@ Halo 等待响应的超时时间为 10 秒。请先完成验签与持久化, | `planId` | 相关计划 ID | | `changeId` | 关联的变更 ID(变更单) | | `fromQuantity` / `toQuantity` | 变更前后的数量 | +| `periodDelta` | 周期偏移(预留字段,当前版本不写入) | | `feeType` | 费用类型,如 `TRIAL` / `FREE` / `FIXED_FEE` / `PRORATED` | | `planName` / `billingPeriod` / `billingMode` | 下单时的计划快照 | diff --git a/docs/guide/shop/_meta.json b/docs/guide/shop/_meta.json index 8ac50fee..c4c776e4 100644 --- a/docs/guide/shop/_meta.json +++ b/docs/guide/shop/_meta.json @@ -7,6 +7,7 @@ "products", "marketing", "orders", + "subscriptions", "customer-center", "virtual-delivery", "webhooks", diff --git a/docs/guide/shop/customer-center.md b/docs/guide/shop/customer-center.md index 1a8d9b65..32f4be0f 100644 --- a/docs/guide/shop/customer-center.md +++ b/docs/guide/shop/customer-center.md @@ -1,13 +1,13 @@ --- title: 商城客户中心 -description: Halo 商城顾客在个人中心管理收货地址、优惠券和订单的操作说明。 +description: Halo 商城顾客在个人中心管理收货地址、优惠券、订单和订阅的操作说明。 --- :::note 仅限 Halo 商城版 限 [Halo 商城版](../prepare.md#发行版本) 可用。 ::: -顾客登录后,可以访问 `/uc`,从个人中心管理自己的收货地址、优惠券和订单。 +顾客登录后,可以访问 `/uc`,从个人中心管理自己的收货地址、优惠券、订单和订阅。 ## 收货地址 @@ -40,4 +40,15 @@ description: Halo 商城顾客在个人中心管理收货地址、优惠券和 虚拟产品订单还会显示可下载资源或卡密。相关交付方式参见[虚拟交付](./virtual-delivery.mdx)。 +## 订阅 + +进入 **订阅** 可以查看自己购买的订阅产品及其状态、周期和变更记录。根据订阅当前状态,顾客可以执行: + +- 续费:创建续费订单并完成支付后,当前周期顺延。 +- 转正:试用中的订阅支付首期后进入正式计费周期。 +- 变更:切换到同计费周期的更高或平级套餐,支付差价后立即生效。 +- 取消:默认到期取消,当前周期内仍可使用;仅订阅已逾期时可立即取消。 + +订阅的配置与限制由商家设置,参见[订阅管理](./subscriptions.md)。如果顾客在个人中心看不到订阅菜单,请检查[商店设置](./settings.md#订阅菜单)中的订阅菜单开关。 + 需要联系商家时,订单页面会使用[商店设置](./settings.md#联系方式)中的联系方式。 diff --git a/docs/guide/shop/orders.mdx b/docs/guide/shop/orders.mdx index 43dc71b2..3acd53c7 100644 --- a/docs/guide/shop/orders.mdx +++ b/docs/guide/shop/orders.mdx @@ -46,5 +46,6 @@ description: Halo 商城订单:查看详情、填写物流发货与订单处 1. 目前仅支持填写物流信息,没有内置物流轨迹查询平台。建议填写物流查询地址,方便顾客自行查询。 2. 虚拟产品发货可参考:[虚拟交付](./virtual-delivery.mdx) +3. 订阅产品的订单行不能在控制台手动发货:订单支付后需要接入方系统完成交付并上报履约结果,详见[订阅管理](./subscriptions.md#限制)。 ::: diff --git a/docs/guide/shop/products.mdx b/docs/guide/shop/products.mdx index e341b93b..b303b08f 100644 --- a/docs/guide/shop/products.mdx +++ b/docs/guide/shop/products.mdx @@ -26,11 +26,12 @@ description: Halo 商城产品:查询和创建产品,管理分类、属性 这里主要配置产品的类型、属性、规格、价格等数据。 -产品类型目前支持三种: +产品类型目前支持四种: 1. 实体:需要配送的产品类型 2. 虚拟:不需要实体配送,使用线上发货的产品类型 3. 链接:直接跳转到第三方链接的产品类型 +4. 订阅:按周期售卖的产品类型,创建后不可修改类型,详见 [订阅管理](./subscriptions.md) :::info 虚拟产品交付方式 虚拟产品支持文件资源下载、卡密发货,可参考 [虚拟交付](./virtual-delivery.mdx) 进行发货配置。 diff --git a/docs/guide/shop/settings.md b/docs/guide/shop/settings.md index b39f4322..85e43112 100644 --- a/docs/guide/shop/settings.md +++ b/docs/guide/shop/settings.md @@ -1,6 +1,6 @@ --- title: 商店设置 -description: 配置 Halo 商城的联系方式、询价跳转、小程序首页内容和订单号生成规则。 +description: 配置 Halo 商城的匿名购买、订阅菜单、联系方式、询价跳转、小程序首页内容和订单号生成规则。 --- :::note 仅限 Halo 商城版 @@ -11,6 +11,18 @@ description: 配置 Halo 商城的联系方式、询价跳转、小程序首页 ## 店铺 +### 允许匿名购买 + +开启后,访客无需登录即可在商城前台结账,订单绑定结账时填写的邮箱。 + +:::note 订阅产品仍需登录 +即使开启匿名购买,订阅产品的购买仍要求客户登录。订阅功能参见[订阅管理](./subscriptions.md)。 +::: + +### 订阅菜单 + +**显示个人中心订阅菜单**:开启后(默认),客户可以在个人中心看到 **订阅** 菜单并管理自己的订阅;关闭后隐藏该菜单并限制订阅列表页访问,不影响已有订阅。 + ### 联系方式 联系方式会展示在商城前台需要联系商家的位置。可以添加多项联系方式,并设置: diff --git a/docs/guide/shop/subscriptions.md b/docs/guide/shop/subscriptions.md new file mode 100644 index 00000000..85651cd4 --- /dev/null +++ b/docs/guide/shop/subscriptions.md @@ -0,0 +1,109 @@ +--- +title: 订阅管理 +description: Halo 商城订阅产品:创建订阅产品线,配置档次、计划、变更规则与订阅策略,以及管理订阅和客户自助续费、变更、取消的操作说明。 +--- + +:::note 适用范围 +本页适用于 **Halo 商城版 2.27.0 及以上版本**,限 [Halo 商城版](../prepare.md#发行版本) 可用。 +::: + +订阅产品用于销售按周期提供的服务,例如 SaaS 套餐、会员服务或许可证。与一次性售卖的商品不同,订阅有自己的生命周期:试用、生效、续费、变更、取消与过期。 + +:::warning 手动续费模式 +Halo **不保存客户的支付凭据,也不会自动扣款**。连续购买的订阅到期前会向客户发送提醒,客户需要在个人中心手动完成续费。 +::: + +## 创建订阅产品 + +登录 Halo 控制台,进入 **商店 -> 产品**,点击右上角的新建按钮,将产品类型选择为 **订阅**。 + +- 订阅产品表示一条产品线,创建后**不可修改产品类型**。 +- 订阅产品不配置规格价格,价格与周期在订阅计划中配置。 +- 创建保存后,在产品编辑页面进入 **订阅设置**,按 **添加档次 -> 添加计划** 的顺序完成配置;上架前请至少添加一个可售计划。 + +## 订阅档次 + +档次是产品线下的商业等级(例如基础版、专业版),是权益契约的归属单位。同一档次下的月付、年付、买断计划共享同一份权益。 + +每个档次可以配置: + +- **名称**:档次的显示名称。 +- **标识**:接入方识别用的稳定键,同一产品线下不可重复,例如 `professional`。 +- **排序**:数字越小越靠前。档次排序同时决定变更方向:目标档次排序值更大为升级,相同为平移;**降级不被支持**。 +- **接入契约**:给接入方系统读取的键值数据,支持字符串、数字、布尔三种类型。契约不会展示给客户,键名需与接入方约定。 + +## 订阅计划 + +计划是客户实际购买的套餐,由档次、计费模式和计费周期组成。 + +| 配置项 | 说明 | +| ------------ | ------------------------------------------------------------------------------------------------------ | +| 档次 | 计划所属的档次。 | +| 计费模式 | 连续购买(到期前提醒,客户手动续费)、单期购买(到期自动停用,需要重新购买)、永久买断(一次性付费)。 | +| 计费周期 | 按月或按年,配合周期数可以实现季付(按月 × 3)等组合。 | +| 价格 / 原价 | 计划的实际价格与可选的划线价。 | +| 状态 | 草稿、已发布、已归档;只有已发布的计划可以被购买。 | +| 启用试用 | 开启后可配置试用天数和试用价格,客户可按试用价开通。 | +| 数量上下限 | 单个订阅允许购买的最小与最大数量。 | +| 宽限期(天) | 仅连续购买有效:到期未续费后,订阅进入逾期状态的容忍天数,宽限期结束后订阅过期。 | + +## 变更规则 + +变更规则决定客户的现有订阅可以切换到哪些计划: + +- 只允许**同一计费周期内**的变更,且目标档次不得低于来源计划(升级或平移);跨周期(月付 ↔ 年付)与降级都无法配置。 +- 每个允许的变更方向都需要单独配置一条规则并启用,未配置规则的方向一律拒绝变更。 +- 费用模式: + - **按剩余价值折算**(推荐):新一期全价减去当前计划未使用部分的价值。 + - **固定费用(按数量)**:按设置的单价 × 订阅数量收取一期费用。 + - **免费**:变更不收费。 +- 变更支付完成后立即生效,并从生效时刻起开一期完整新周期;变更不修改订阅数量。 + +## 订阅策略 + +- **每客户仅允许一个订阅**:开启后(默认),客户同时只能持有一个生效订阅,重复购买会被拦下并引导去变更或续费;关闭后每次购买都会新建独立订阅,适用于许可证类产品。 + +## 管理订阅 + +进入 **商店 -> 订阅**(需要超级管理员权限),可以查看所有客户订阅: + +- 列表支持关键词搜索,并可按状态(试用中、生效中、逾期、已取消、已过期)和产品线筛选。 +- 订阅详情展示摘要、当前计划、有效权益、订阅周期与变更记录(新购、续费、变更、转正、取消、过期)。 +- 可以对未终止的订阅执行 **立即取消**:订阅立即变为已取消,不会退款。 + +## 客户自助管理 + +客户登录后,可以在个人中心的 **订阅** 菜单中管理自己的订阅: + +- 查看订阅状态、周期与变更记录。 +- **立即续费**:按当前价格创建续费订单,支付后周期顺延;续费订单 24 小时未支付会自动过期。 +- **立即转正**:试用中的订阅通过续费入口创建转正订单,支付首期后进入正式计费周期。 +- **变更**:在变更页面选择目标计划并查看报价,确认支付后立即生效。 +- **取消**:默认可选择到期取消(当前周期结束后取消,周期内仍可使用);仅当订阅已逾期时才允许立即取消,立即取消不会退款。 + +## 续费提醒邮件 + +连续购买的订阅在到期前会向客户发送一次提醒邮件(试用中的订阅则提醒转正),已设置到期取消的订阅不发送。 + +提醒邮件依赖系统邮件通知配置,请先在 **设置 -> 通知设置** 中配置好[邮件通知](../use/settings.md#邮件通知),否则客户无法收到提醒。 + +:::info 单期购买不发提醒 +只有连续购买的计划会发送到期提醒;单期购买的订阅到期后直接过期。 +::: + +## 相关设置 + +在 **商店 -> 设置** 中可以配置 **显示个人中心订阅菜单**:关闭后隐藏个人中心的订阅菜单并限制订阅列表页访问,不影响已有订阅。参见[商店设置](./settings.md#订阅菜单)。 + +## 限制 + +- 不支持自动扣款与自动续费,续费必须由客户手动发起。 +- 不支持降级、跨计费周期变更与订阅暂停。 +- 订阅产品要求客户登录后购买;即使开启[匿名购买](./settings.md#允许匿名购买),订阅产品也不支持游客结账。 +- 订阅订单行不会在控制台手动发货,也不会自动发放卡密或数字资源,需要接入方系统对接交付,详见[商城订阅对接](../../developer-guide/shop/index.md)。 + +## 相关文档 + +- [商城订阅对接](../../developer-guide/shop/index.md):面向接入方的 Webhook 与履约上报对接说明。 +- [订阅生命周期](../../developer-guide/shop/subscription-lifecycle.md):订阅状态机与周期语义。 +- [商城客户中心](./customer-center.md):客户在个人中心的其他自助能力。 From 706c31a231bdffe46e1c919fdc9f835154b0f122 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 16 Oct 2026 14:34:48 +0800 Subject: [PATCH 3/4] Fix shop integration retry and subscription guidance --- .../shop/integration/fulfillment-callback.md | 21 ++++++++++++------- .../shop/integration/overview.md | 4 ++-- .../integration/subscription-lifecycle.md | 2 +- .../shop/integration/subscription-webhook.md | 17 +++++++++------ docs/guide/shop/subscriptions.md | 2 +- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/developer-guide/shop/integration/fulfillment-callback.md b/docs/developer-guide/shop/integration/fulfillment-callback.md index 2fc495d5..f3e24f32 100644 --- a/docs/developer-guide/shop/integration/fulfillment-callback.md +++ b/docs/developer-guide/shop/integration/fulfillment-callback.md @@ -54,7 +54,7 @@ X-Halo-Webhook-Id: {uuid} `data.order` 与 `ORDER_PAID` 完全一致,包含订单行列表;订阅行的标识字段见[订阅 Webhook](./subscription-webhook.md#关联的订单事件)。 - 收到该事件表示「Halo 希望你交付这个订单」,**不代表订单已经发货**。 -- 该事件在支付后自动发送一次,重试与手动重发共享同一个 `X-Halo-Webhook-Id`。 +- 该事件在支付后自动发送一次。自动重试与 Webhook **投递记录**中的手动重新投递保留原 `X-Halo-Webhook-Id`;在**订单详情页**重新发送履约请求会产生新事件和新 ID,不能仅凭投递 ID 避免重复交付。 - 漏收时可以请运营在控制台订单详情页重新发送,也可以核对订单后直接上报,Halo 不要求必须先收到通知。 ## 发货上报(入站) @@ -106,7 +106,7 @@ curl -X POST \ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `fulfillmentStatus` | 上报后的订单履约状态:`PENDING`(还有未履约行)/ `PROCESSING` / `FULFILLED`。该状态按订单的全部订单行(实物与订阅)计算 | | `items[].quantity` | 本次上报的数量 | -| `items[].appliedQuantity` | 本次实际记账的数量;重复上报已履约的行时为 `0` | +| `items[].appliedQuantity` | 本次实际记账的数量;重复上报已全部履约的行时为 `0` | | `items[].fulfilledQuantity` | 记账后该行的累计已履约数量 | 整单上报(省略 `items`)时,目标为该订单所有还有剩余数量的订阅行,已经履约完成的行不会出现在响应中;显式上报的行都会出现在响应里,本次未记账的行 `appliedQuantity` 为 `0`。 @@ -116,15 +116,20 @@ curl -X POST \ | 场景 | 行为 | | ---------------------------------------------------- | ------------------------------------------------------------------ | | 订单行已全部履约,再次上报相同或更少数量(网络重试) | 返回 `200`,`appliedQuantity` 为 `0`,不重复计数,也不写订单时间线 | -| 订单行还有剩余,上报数量不超过剩余数量 | 按差额记账,返回 `200` | -| 订单行还有剩余,上报数量超过剩余数量 | 返回 `409`,请按剩余数量修正后重报 | +| 订单行还有剩余,上报数量不超过剩余数量 | 累加本次 `quantity`,返回 `200`;相同参数再次上报仍会计数 | +| 订单行还有剩余,上报数量超过剩余数量 | 返回 `409`,需核对实际交付与已记账数量后再决定是否补报 | | 并发上报同一订单 | 服务端串行化记账,不会超额 | +:::warning 部分上报不是幂等操作 +`quantity` 表示本次新增的交付数量,不是累计数量。例如订单行购买了 3 份,首次上报 1 份已经记账但响应丢失,再次上报 1 份会把累计已履约数量变为 2。`externalReference` 只用于审计,不是服务端幂等键;并发串行化也只能防止超额,不能识别重复报告。 +::: + 建议: -- 交付成功后立即上报,网络失败可以使用相同参数安全重试。 -- 请用「订单 ID + 订单行 ID」作为接入方侧的幂等键,避免重复交付。 -- 收到 `409` 时不要盲目重试,先查询订单核对剩余数量。 +- 交付成功后立即上报。超时、断网等结果不确定的情况,不要直接重发原参数;先[查询订单](#对账与运维),用 `items[].fulfilledQuantity` 与本地实际交付记录核对,只补报确认尚未记账的数量。 +- 以「订单 ID + 订单行 ID」组织本地交付与上报记录,同一订单行串行处理;分批交付时分别记录每批数量和上报结果,避免重复交付或重复记账。 +- 查询时若前一次上报仍在处理中,或有其他上报方并发写入,暂时读到的数量不能证明首次请求未生效;应先确认请求结果或交由运营对账,不要根据一次查询立即重试。 +- 收到 `409` 时不要盲目重试,也不要直接把数量改成剩余数量;先核对实际交付与已记账数量。 ## 错误处理 @@ -141,7 +146,7 @@ curl -X POST \ | `403` | 令牌缺少上报角色 | 否,补齐角色 | | `404` | 订单不存在 | 否 | | `409` | 订单未支付或已取消 | 否 | -| `409` | 上报数量超过剩余可履约数量 | 修正后重试 | +| `409` | 上报数量超过剩余可履约数量 | 对账确认后补报 | | `409` | 并发记账冲突(订单行可能已被超额上报) | 查询订单后重试 | ## 对账与运维 diff --git a/docs/developer-guide/shop/integration/overview.md b/docs/developer-guide/shop/integration/overview.md index 4ad72d34..6605dedd 100644 --- a/docs/developer-guide/shop/integration/overview.md +++ b/docs/developer-guide/shop/integration/overview.md @@ -74,9 +74,9 @@ sequenceDiagram 1. 运营在控制台配置 `productType=SUBSCRIPTION` 的产品线、档次与计划,操作步骤见[商城 / 订阅管理](../../../guide/shop/subscriptions.md),数据模型见[订阅生命周期](./subscription-lifecycle.md#数据模型)。 2. 接入方订阅 `SUBSCRIPTION_*` 事件与 `FULFILLMENT_REQUESTED`。 -3. 客户下单支付后,接入方按 `SUBSCRIPTION_CREATED` / `SUBSCRIPTION_TRIAL_STARTED` 载荷中的 `effectiveEntitlements` 开通权益。 +3. 客户下单支付后,接入方收到 `SUBSCRIPTION_CREATED` / `SUBSCRIPTION_TRIAL_STARTED`,按[幂等与顺序](./subscription-webhook.md#幂等与顺序)查询确认当前订阅后,使用其 `effectiveEntitlements` 开通权益。 4. 收到 `FULFILLMENT_REQUESTED` 后完成交付,并调用[履约上报接口](./fulfillment-callback.md)回执;**不上报,订单会一直停留在待发货**。 -5. 收到 `SUBSCRIPTION_CANCELLED` / `SUBSCRIPTION_EXPIRED` 后停用权益;收到 `SUBSCRIPTION_PLAN_CHANGED` / `SUBSCRIPTION_RENEWED` 后按新快照刷新。 +5. 收到取消、过期、变更或续费事件后,按同一流程查询当前订阅,决定停用或刷新权益,避免迟到的旧事件覆盖新状态。 ## 本组文档 diff --git a/docs/developer-guide/shop/integration/subscription-lifecycle.md b/docs/developer-guide/shop/integration/subscription-lifecycle.md index 27195d60..9321ab1e 100644 --- a/docs/developer-guide/shop/integration/subscription-lifecycle.md +++ b/docs/developer-guide/shop/integration/subscription-lifecycle.md @@ -156,7 +156,7 @@ function coveredUntilAt(s: Subscription): string | null { - `TRIALING` 期间 `currentPeriodStartAt` / `currentPeriodEndAt` 描述的是转正后的第一个周期,**不能当作当前有效周期**,直接使用会把到期时间显示成「试用结束加一个周期」。 - 权益快照在**购买、变更应用、续费**时重算。运营修改档次的 `entitlements` 不会立即推送给已有订阅,要等下一次续费或变更。 -- 收到快照后请整体覆盖本地权益,不要在本地累加周期或额度。 +- 使用查询确认的当前快照整体更新本地权益,不要在本地累加周期或额度;Webhook 可能乱序,不能直接用迟到的事件快照覆盖当前状态,处理流程见[幂等与顺序](./subscription-webhook.md#幂等与顺序)。 ## 查询与对账接口 diff --git a/docs/developer-guide/shop/integration/subscription-webhook.md b/docs/developer-guide/shop/integration/subscription-webhook.md index 332ab03c..68c697fc 100644 --- a/docs/developer-guide/shop/integration/subscription-webhook.md +++ b/docs/developer-guide/shop/integration/subscription-webhook.md @@ -54,7 +54,11 @@ if not hmac.compare_digest(received_signature, expected): ``` :::warning 注意 -`X-Halo-Delivery-Timestamp` 不参与签名,签名本身不提供防重放能力;如需防重放,请自行基于该请求头做时间窗校验。另外不要先解析再重新序列化 JSON,字节变化会导致验签失败。 +签名只覆盖原始请求体,`X-Halo-Delivery-Timestamp` 与 `X-Halo-Webhook-Id` 均不参与签名。获得有效旧请求的人可以修改这两个请求头,因此请求头时间窗与投递 ID 去重不能作为防重放保障。 + +如需防重放,可在验签后持久化原始请求体的摘要以识别重复载荷,并基于正文中已签名的 `timestamp` 制定接受时间窗。时间窗需要考虑自动重试约 10 小时及更晚的人工重投,超出窗口的通知应通过查询当前业务状态对账处理。业务操作仍须保持幂等,不能因为请求头改变就再次交付或增加额度。 + +不要先解析再重新序列化 JSON,字节变化会导致验签失败。 ::: ## 返回状态与重试 @@ -69,9 +73,10 @@ Halo 等待响应的超时时间为 10 秒。请先完成验签与持久化, ## 幂等与顺序 -- 采用**至少一次**投递:自动重试与手动重投都可能产生重复请求。请以 `X-Halo-Webhook-Id` 作为幂等键,处理过的 ID 直接返回 `2xx`。 -- 同一订单(或同一订阅)的事件通常按发布顺序投递,但重试不受顺序约束,可能出现交错。请以订阅的当前快照收敛本地状态,不要假设事件严格有序。 -- 同一逻辑事件的多次投递共享同一个 `X-Halo-Webhook-Id`;手动重新投递时 `X-Halo-Delivery-Attempt` 会重置为 1。 +- 采用**至少一次**投递:自动重试与手动重投都可能产生重复请求。正常投递去重可使用 `X-Halo-Webhook-Id`,处理过的 ID 直接返回 `2xx`;它不替代防重放校验与业务幂等。 +- 同一订单(或同一订阅)的事件通常按发布顺序投递,但重试不受顺序约束,可能出现交错。重试使用保存的原始载荷,不会重新查询订阅;不同事件的 ID 去重也不能阻止旧快照覆盖新状态。 +- 当前载荷没有订阅状态的单调递增版本号,正文 `timestamp` 是载荷生成时间,也不能作为状态版本。更新权益时,应按订阅 ID 串行执行「查询当前订阅 → 更新本地快照与权益」,不要直接用每次收到的事件快照覆盖本地状态。查询接口和令牌权限见[查询与对账接口](./subscription-lifecycle.md#查询与对账接口);仅有履约上报权限时,需要由有查询权限的运营侧服务完成对账。 +- 自动重试与 Webhook **投递记录**中的手动重新投递共享原 `X-Halo-Webhook-Id`,后者的 `X-Halo-Delivery-Attempt` 会重置为 1。订单详情页重新发送履约请求会产生新事件和新 ID,详见[交付请求通知](./fulfillment-callback.md#交付请求通知出站)。 ## 订阅事件参考 @@ -101,7 +106,7 @@ Halo 等待响应的超时时间为 10 秒。请先完成验签与持久化, ## 载荷字段 -所有订阅事件的 `data.subscription` 结构一致,是**事件发生之后**的订阅快照,不包含订单号与变更前后明细: +所有订阅事件的 `data.subscription` 结构一致,是载荷生成时查询到的订阅快照,不包含订单号与变更前后明细。延迟或重试投递中的快照可能已经过时: | 字段 | 类型 | 说明 | | --------------------------------------------- | -------- | ------------------------------------------------------------ | @@ -142,7 +147,7 @@ Halo 等待响应的超时时间为 10 秒。请先完成验签与持久化, ## 处理建议 -1. 验签 → 用 `X-Halo-Webhook-Id` 去重 → 用 `data.subscription` 覆盖本地快照 → 按新的 `status` 与 `entitlements` 调整限额。 +1. 验签并校验重放 → 投递去重并持久化待处理任务 → 按 `data.subscription.id` 串行查询当前订阅 → 用查询结果更新本地快照 → 按当前 `status` 与 `effectiveEntitlements` 调整限额。事件载荷中的权益字段同样是 `data.subscription.effectiveEntitlements`。 2. 开通与关闭权益必须幂等,重复投递不得叠加额度。 3. 以 Halo 下发的快照为准,不要在本地自行推算周期,详见[权益如何判定](./subscription-lifecycle.md#权益如何判定)。 4. 控制台的**发送测试事件**会投递 `WEBHOOK_TEST`,载荷是订单结构且字段与真实事件不完全一致(例如收货地址使用的是测试字段),请勿写入业务数据。 diff --git a/docs/guide/shop/subscriptions.md b/docs/guide/shop/subscriptions.md index 54a65d6d..7f611e24 100644 --- a/docs/guide/shop/subscriptions.md +++ b/docs/guide/shop/subscriptions.md @@ -42,7 +42,7 @@ Halo **不保存客户的支付凭据,也不会自动扣款**。连续购买 | ------------ | ------------------------------------------------------------------------------------------------------ | | 档次 | 计划所属的档次。 | | 计费模式 | 连续购买(到期前提醒,客户手动续费)、单期购买(到期自动停用,需要重新购买)、永久买断(一次性付费)。 | -| 计费周期 | 按月或按年,配合周期数可以实现季付(按月 × 3)等组合。 | +| 计费周期 | 控制台可选择按月或按年,周期数固定为 1,暂不支持配置季付等多周期组合。 | | 价格 / 原价 | 计划的实际价格与可选的划线价。 | | 状态 | 草稿、已发布、已归档;只有已发布的计划可以被购买。 | | 启用试用 | 开启后可配置试用天数和试用价格,客户可按试用价开通。 | From dacc35fccc40d983f6ba4c849125d8821a19c657 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 16 Oct 2026 14:45:10 +0800 Subject: [PATCH 4/4] Clarify subscription grace periods and billing cycles --- .../integration/subscription-lifecycle.md | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/docs/developer-guide/shop/integration/subscription-lifecycle.md b/docs/developer-guide/shop/integration/subscription-lifecycle.md index 9321ab1e..b9f93941 100644 --- a/docs/developer-guide/shop/integration/subscription-lifecycle.md +++ b/docs/developer-guide/shop/integration/subscription-lifecycle.md @@ -33,7 +33,7 @@ description: Halo 商城订阅的完整周期:数据模型、状态机、试 | `billingMode=SUBSCRIPTION` | 连续计费:到期前提醒,到期未付进入 `PAST_DUE` 宽限期,客户可手动续费。 | | `billingMode=ONE_TIME` | 预付一期:到期直接 `EXPIRED`,需要重新购买。 | | `billingMode=LIFETIME` | 买断:周期字段为空,永不过期。 | -| `billingPeriod=MONTHLY` / `YEARLY` | 周期单位;季度付是 `MONTHLY` 加 `periodCount=3`。 | +| `billingPeriod=MONTHLY` / `YEARLY` | 周期单位为月或年;2.27 控制台创建计划时 `periodCount` 固定为 `1`。 | | `gracePeriodDays` | 仅连续计费有效:到期未付后的容忍天数。 | | `entitlements` | 档次上的 JSON 契约,在购买、变更、续费时快照到订阅的 `effectiveEntitlements`。 | @@ -127,30 +127,25 @@ Halo **不保存支付凭据,也不会自动扣款**,任何续费都必须 ## 权益如何判定 -`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:停用权益 - } -} -``` +`entitlements` 是运营与接入方约定的契约,Halo 只负责快照与下发,不解释也不强制执行。**已付费覆盖终点不等于宽限期截止时间**,不能仅用 `paidThroughAt` 或 `currentPeriodEndAt` 决定是否停用权益: + +| 状态 | 时间与权益处理 | +| ------------------------------------ | ------------------------------------------------------------------------------------------------- | +| `TRIALING` | `trialEndAt` 是试用覆盖终点;转正宽限内是否继续提供试用权益由接入方决定 | +| `ACTIVE` | 已付费覆盖终点为 `paidThroughAt`,为空时回退 `currentPeriodEndAt`;到期时需核对当前状态与宽限策略 | +| `PAST_DUE` | 已进入续费宽限期;保留宽限期访问时,截止时间为已付费覆盖终点加计划的 `gracePeriodDays` 天 | +| 买断(`ACTIVE` 且周期字段为 `null`) | 无时间终点,但取消后仍须停用权益 | +| `CANCELLED` / `EXPIRED` | 停用权益,`paidThroughAt` 只是历史覆盖记录 | + +### 宽限期内的访问 + +`PAST_DUE` 仍是生效订阅,权益查询会继续返回其契约。接入方可以保留访问或按业务规则限流;不要仅因为 `paidThroughAt` 已过就把它当作 `EXPIRED`。 + +2.27 调度器按 `(paidThroughAt ?? currentPeriodEndAt) + gracePeriodDays × 24 小时` 计算续费宽限截止时间,达到截止时间后转为 `EXPIRED`。`gracePeriodDays` 为空时按 `0` 计算;它来自订阅当前计划,并非订阅快照字段。需要计算截止时间时,先通过 `planId` 查询计划,不能假设 Webhook 或订阅详情已经包含宽限天数。 + +例如已付费覆盖到 10 月 16 日 00:00 UTC、计划宽限期为 3 天,保留宽限期访问的截止时间是 10 月 19 日 00:00 UTC。试用转正宽限的规则不同,为 `trialEndAt + max(gracePeriodDays, 1) × 24 小时`;在此期间状态仍为 `TRIALING`。 + +状态转换由定时任务执行,Webhook 也可能延迟或乱序。接入方应查询当前订阅及计划核对状态和截止时间,不要仅等待过期通知,也不要使用旧快照延长已取消订阅的权益。 需要注意: @@ -166,6 +161,7 @@ Console API(前缀 `https://{host}/apis/console.api.ecommerce.halo.run/v1alpha | ---- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | GET | `/subscriptions?customerId=&productId=&status=&page=&size=` | 订阅列表 | | GET | `/subscriptions/{id}` | 订阅详情,含 `effectiveEntitlements` 与 `upcomingRenewal` | +| GET | `/subscription-plans/{id}` | 订阅当前计划,用 `planId` 查询,包含 `gracePeriodDays` 等计费配置 | | GET | `/customers/{customerId}/entitlements?productId=` | **权益二次确认**:返回 `{ customerId, productId, entitlements }`,没有生效订阅时 `entitlements` 为 `{}` | | GET | `/subscription-changes?subscriptionId=` | 变更流水,含 `orderId`、计价结果与前后快照 | | POST | `/subscriptions/{id}/cancel` | 运营立即取消 |