HarmonyOS 会员订阅权益治理实战:套餐、状态、过期与跨端一致

会员订阅最怕的不是“按钮点不动”,而是用户已经付费,页面仍提示未开通;手机端显示会员有效,平板端却没有同步;订阅到期以后部分高级能力还能继续使用。真正上线后,客服收到的不是技术名词,而是“我为什么不能用”“我为什么还在扣费”“我换设备怎么没权益”。这篇文章把会员订阅拆成套餐、购买、权益、过期、跨端同步五个环节,重点讲 ArkTS 侧如何把状态解释清楚,并把最终确认交给服务端和官方支付链路。

请添加图片描述

本文会解决四个落地问题:

  1. 怎么把会员套餐和权益拆成可维护的数据结构。
  2. 怎么避免购买成功但权益没有发放。
  3. 怎么处理过期、取消续费、跨设备同步这类边界状态。
  4. 怎么给测试和运营一套可复查的验收清单。

1. 会员订阅先拆状态和权益

订阅不是一个布尔值。只用 isVip: true 写业务,很快会遇到几个问题:月卡和年卡不能区分,试用权益无法表达,订阅取消后当期仍有效无法解释,后台退款后前端还在展示会员。更稳定的做法是把“套餐”和“权益”分开:套餐描述用户买了什么,权益描述用户现在能用什么。

请添加图片描述

对象 负责内容 不能承担的职责
PlanCatalog 展示套餐、周期、权益说明、限制条件 不直接判断用户是否已经拥有权益
SubscriptionOrder 保存一次购买请求和平台订单标识 不代表最终权益已生效
EntitlementSnapshot 表示当前可用权益、到期时间、来源 不负责拉起支付
SubscriptionSync 从服务端刷新订阅状态并修正本地缓存 不在页面里散落调用

这里的核心原则很简单:页面只展示状态,购买模块只创建交易,权益模块只判断可用性。职责边界越清晰,后面处理续费、退款、跨端同步时越不容易互相污染。

2. 官方资料边界和工程落点

华为开发者文档里,IAP Kit 支持消耗型商品、非消耗型商品、自动续期订阅商品和非续期订阅商品;订阅型商品购买后,开发者需要在订阅处于生效状态时及时发放权益。订阅状态还可以通过服务端能力查询最新状态。工程实现时不要把文档里的每个接口都直接塞进页面,而是先映射到模块边界。

资料入口 文章里的工程落点
IAP Kit 应用内支付服务 确认订阅商品、购买体验、异常订单补发方向
权益发放-自动续期订阅商品购买 购买成功后不要只改本地状态,要走权益发放闭环
IAP 发起购买参考 页面只传商品 ID、类型、订单上下文,不保存敏感支付逻辑
订阅状态查询 服务端同步订阅状态,用于续费、过期和退款后的修正

建议的工程目录可以这样拆:

entry/src/main/ets/
  common/subscription/PlanCatalog.ets
  common/subscription/SubscriptionOrder.ets
  common/subscription/EntitlementStore.ets
  common/subscription/SubscriptionSync.ets
  pages/member/MemberCenterPage.ets

这不是为了增加目录,而是为了防止会员页既写 UI、又拉支付、又改权益、又处理过期。一旦这些逻辑挤在一个页面里,后续定位“付费后未生效”会非常慢。

3. PlanCatalog 定义套餐和权益

会员套餐要先变成稳定的数据结构,页面才可以可靠展示。套餐字段至少包含商品 ID、周期、权益列表、展示价格、限制说明和状态。注意:展示价格不是最终支付凭据,最终金额仍以收银台和服务端订单为准。

export type PlanPeriod = 'MONTH' | 'QUARTER' | 'YEAR'

export interface MemberPlan {
  productId: string
  title: string
  period: PlanPeriod
  displayPrice: string
  benefits: string[]
  limitation: string
  recommended: boolean
}

export class PlanCatalog {
  private readonly plans: MemberPlan[] = [
    {
      productId: 'vip_month_001',
      title: '月度会员',
      period: 'MONTH',
      displayPrice: '18 元/月',
      benefits: ['高级路线规划', '离线地图包', '专属主题'],
      limitation: '适合短期体验,自动续期前可在账号中心管理',
      recommended: false
    },
    {
      productId: 'vip_year_001',
      title: '年度会员',
      period: 'YEAR',
      displayPrice: '168 元/年',
      benefits: ['高级路线规划', '离线地图包', '多端同步', '优先客服'],
      limitation: '适合长期使用,权益按账号维度发放',
      recommended: true
    }
  ]

  getVisiblePlans(): MemberPlan[] {
    return this.plans.filter(plan => plan.productId.length > 0)
  }

  findByProductId(productId: string): MemberPlan | undefined {
    return this.plans.find(plan => plan.productId === productId)
  }
}

这段代码只做一件事:给页面提供可展示的套餐信息。它不会判断用户是不是会员,也不会直接拉起购买。这样做的好处是运营改套餐文案时,不会影响权益判断;支付链路出现异常时,也不会污染套餐定义。

4. SubscriptionOrder 保存一次购买上下文

购买行为必须有本地上下文。用户点击年卡后,应用应记录当前选择的商品、业务订单号、页面来源和创建时间。即使收银台返回失败,后续也可以通过这条上下文判断是否需要查单或提示用户重试。

export type SubscriptionOrderState = 'CREATED' | 'PAYING' | 'PAID' | 'DELIVERED' | 'CLOSED'

export interface SubscriptionOrder {
  localOrderNo: string
  productId: string
  productType: 'AUTO_RENEWABLE_SUBSCRIPTION' | 'NON_RENEWABLE_SUBSCRIPTION'
  source: 'member_center' | 'route_detail' | 'offline_map'
  state: SubscriptionOrderState
  createdAt: number
  updatedAt: number
}

export class SubscriptionOrderFactory {
  create(productId: string, source: SubscriptionOrder['source']): SubscriptionOrder {
    const now = Date.now()
    return {
      localOrderNo: `sub_${now}_${Math.floor(Math.random() * 10000)}`,
      productId,
      productType: 'AUTO_RENEWABLE_SUBSCRIPTION',
      source,
      state: 'CREATED',
      createdAt: now,
      updatedAt: now
    }
  }
}

这里的 localOrderNo 用来串起页面、服务端预单、支付返回和日志。它不是平台订单号,也不能替代服务端订单。读者落地时要把本地订单号和服务端订单号都保留,否则客服根据用户截图定位问题会很难。

5. PurchaseService 拉起购买但不发放权益

客户端可以发起购买,但不要在“收银台返回成功”这一刻直接解锁所有会员能力。更稳的链路是:创建订单、拉起购买、拿到结果、交给服务端验签与发货、客户端刷新权益。

interface PurchaseResult {
  success: boolean
  platformOrderId?: string
  purchaseToken?: string
  code?: string
  message?: string
}

export class PurchaseService {
  async createPurchase(order: SubscriptionOrder): Promise<PurchaseResult> {
    try {
      // 实际项目中这里接入 IAP Kit createPurchase,并传入 AppGallery Connect 配置的 productId。
      const result: PurchaseResult = {
        success: true,
        platformOrderId: `hw_${order.localOrderNo}`,
        purchaseToken: `token_${order.localOrderNo}`
      }
      return result
    } catch (err) {
      return {
        success: false,
        code: 'PURCHASE_LAUNCH_FAILED',
        message: `${err}`
      }
    }
  }
}

这段示例故意没有写“开通会员”。购买服务的输出只是支付结果线索,后续还要走订单验签、权益发放和状态刷新。把发放动作拆出去,才能处理重复回调、网络中断、页面关闭后恢复等真实场景。

6. EntitlementStore 统一解释权益状态

页面最终应该读取一个权益快照,而不是到处判断订单状态。权益快照需要能表达未开通、生效中、宽限期、已过期和待同步。

请添加图片描述

export type EntitlementState = 'NONE' | 'ACTIVE' | 'GRACE' | 'EXPIRED' | 'SYNCING'

export interface EntitlementSnapshot {
  state: EntitlementState
  productId?: string
  expiredAt?: number
  benefits: string[]
  lastSyncedAt: number
}

export class EntitlementStore {
  private snapshot: EntitlementSnapshot = {
    state: 'NONE',
    benefits: [],
    lastSyncedAt: 0
  }

  updateFromServer(next: EntitlementSnapshot): void {
    this.snapshot = {
      ...next,
      benefits: [...next.benefits],
      lastSyncedAt: Date.now()
    }
  }

  canUseBenefit(benefit: string): boolean {
    if (this.snapshot.state !== 'ACTIVE' && this.snapshot.state !== 'GRACE') {
      return false
    }
    return this.snapshot.benefits.includes(benefit)
  }

  getReadableState(): string {
    const map: Record<EntitlementState, string> = {
      NONE: '未开通',
      ACTIVE: '会员生效中',
      GRACE: '等待同步确认',
      EXPIRED: '会员已过期',
      SYNCING: '正在同步权益'
    }
    return map[this.snapshot.state]
  }
}

GRACE 很重要。移动网络下,用户付款后马上回到 App,服务端通知可能还没有到。直接显示“未开通”会让用户误以为扣款失败;直接显示“永久有效”又可能造成越权。宽限态给页面一个解释空间:告诉用户正在同步,并提供刷新入口。

7. SubscriptionSync 处理跨端和过期

跨端一致不能靠本地缓存。用户在手机购买会员,平板端打开应用时必须刷新服务端权益;用户取消续费,当期结束后也要同步到过期状态。同步模块需要有触发时机和失败策略。

interface RemoteEntitlementDTO {
  status: 'none' | 'active' | 'grace' | 'expired'
  productId?: string
  expiredAt?: number
  benefits?: string[]
}

export class SubscriptionSync {
  constructor(private readonly store: EntitlementStore) {}

  async syncWhenAppForeground(accountId: string): Promise<void> {
    if (accountId.length === 0) {
      this.store.updateFromServer({ state: 'NONE', benefits: [], lastSyncedAt: Date.now() })
      return
    }

    const remote = await this.queryRemoteEntitlement(accountId)
    this.store.updateFromServer(this.toSnapshot(remote))
  }

  private async queryRemoteEntitlement(accountId: string): Promise<RemoteEntitlementDTO> {
    // 实际项目中由应用服务器调用订阅状态查询能力,再返回给客户端。
    return {
      status: 'active',
      productId: 'vip_year_001',
      expiredAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
      benefits: ['高级路线规划', '离线地图包', '多端同步']
    }
  }

  private toSnapshot(dto: RemoteEntitlementDTO): EntitlementSnapshot {
    const stateMap: Record<RemoteEntitlementDTO['status'], EntitlementState> = {
      none: 'NONE',
      active: 'ACTIVE',
      grace: 'GRACE',
      expired: 'EXPIRED'
    }
    return {
      state: stateMap[dto.status],
      productId: dto.productId,
      expiredAt: dto.expiredAt,
      benefits: dto.benefits ?? [],
      lastSyncedAt: Date.now()
    }
  }
}

同步动作建议放在三个时机:应用启动后、用户进入会员中心时、收到支付完成或服务端通知后的下一次前台刷新。不要在每次页面渲染都请求远端,否则会制造不必要的网络压力。

8. 会员页面只展示四类结果

会员页不要暴露复杂状态机,只给用户四类结果:未开通、同步中、已生效、已过期。每类状态都有明确按钮和说明,客服也能根据页面文案反向判断当前链路走到哪一步。

interface MemberViewState {
  title: string
  description: string
  primaryAction: string
  secondaryAction?: string
}

export function buildMemberView(snapshot: EntitlementSnapshot): MemberViewState {
  if (snapshot.state === 'ACTIVE') {
    return {
      title: '会员权益已生效',
      description: `有效期至 ${new Date(snapshot.expiredAt ?? 0).toLocaleDateString()}`,
      primaryAction: '查看权益',
      secondaryAction: '管理订阅'
    }
  }
  if (snapshot.state === 'GRACE' || snapshot.state === 'SYNCING') {
    return {
      title: '正在同步会员状态',
      description: '如果你刚完成支付,请稍后刷新;订单确认后会自动发放权益。',
      primaryAction: '刷新状态'
    }
  }
  if (snapshot.state === 'EXPIRED') {
    return {
      title: '会员已到期',
      description: '续费后可继续使用高级路线规划、离线地图包和跨端同步。',
      primaryAction: '重新开通'
    }
  }
  return {
    title: '开通会员解锁高级能力',
    description: '购买前请确认套餐周期、续费规则和权益说明。',
    primaryAction: '选择套餐'
  }
}

这段代码服务的是用户体验,不是支付安全。安全判断仍然在服务端和权益快照里完成。页面只是把复杂状态翻译成用户看得懂的文案和按钮。

9. 上线前的验收动作

订阅链路必须用真实场景验收,尤其要覆盖网络中断、重复进入页面和跨设备刷新。下面这份表可以直接给测试同学使用。

场景 操作 预期结果
首次购买 未开通账号购买月卡 支付后进入同步态,服务端确认后权益生效
重复点击 连续点击购买按钮 只创建一笔有效订单,页面提示正在处理
跨端同步 手机购买后用同账号登录平板 平板刷新后展示相同权益和到期时间
到期处理 模拟服务端返回过期 高级能力被拦截,页面引导续费
异常恢复 支付成功后断网再打开应用 前台同步后补发权益或展示可解释状态

可以在项目里加入一个轻量断言,防止页面误把未同步状态当成已开通。

export function assertEntitlementReadable(snapshot: EntitlementSnapshot): void {
  if (snapshot.state === 'ACTIVE' && (!snapshot.expiredAt || snapshot.benefits.length === 0)) {
    throw new Error('会员生效态必须包含到期时间和至少一个权益')
  }
  if (snapshot.state === 'NONE' && snapshot.benefits.length > 0) {
    throw new Error('未开通状态不能携带权益列表')
  }
}

这个断言适合放在本地调试或自动化用例里。它不是替代服务端校验,而是尽早发现前端状态组装错误。

10. 会员订阅异常排查表

现象 优先查看 处理建议
用户付款后仍未开通 本地订单号、平台订单号、服务端发货记录 不要让用户重复购买,先查订单是否已发货
手机是会员,平板不是 账号 ID、权益同步接口、缓存更新时间 登录后强制同步一次权益快照
到期后仍能用高级功能 canUseBenefit 调用点、缓存过期策略 高级功能入口统一走权益判断
取消续费后马上显示失效 服务端返回的到期时间 当期有效期内仍应展示可用,只提示续费状态变化
会员页状态来回跳 多处页面直接改缓存 收敛到 EntitlementStore.updateFromServer 一个入口

11. 小结:会员权益要以状态机维护

会员订阅的难点不在页面做得多漂亮,而在状态是否可解释、订单是否可追踪、权益是否可恢复。ArkTS 侧建议只做三件事:展示套餐、记录购买上下文、解释权益快照。最终权益以服务端和 IAP 订阅状态为准,页面用宽限态和同步态承接真实网络环境。只要这条边界守住,后续接入更多套餐、跨设备同步或客服工单时,都不会推翻原来的会员体系。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐