目录

ASCF元服务如何接入IAP Kit

问题现象

在开发ASCF元服务时,开发者需要接入应用内支付(IAP Kit)以实现虚拟物品(如消耗型测试次数、游戏币等)的购买交易。由于ASCF元服务环境的特殊性,无法直接套用ArkTS元服务的IAP Kit示例代码。

背景知识

ASCF(Atomic Service Cross Framework)是元服务为小程序生态定制的一套解决方案,能够使用类似于小程序的开发技术,高效开发元服务。在ASCF环境下,元服务不能直接引用ArkTS的@kit.IAPKit模块,而是必须调用封装在has命名空间下的IAP开放接口。

解决方案

接入IAP Kit分为平台配置与代码实现两个阶段。请先参考IAP Kit的开发准备完成基本准备工作、配置商品信息、配置签名以及配置应用身份信息。以此为基础,开发者可进一步遵循以下核心逻辑,在ASCF元服务中实现包含防掉单机制的完整支付闭环:

  1. 初始化支付环境与异常掉单恢复:

    在拉起支付前,必须先调用has.queryIapEnvStatus确认当前帐号与系统环境是否支持支付。紧接着,为了防止用户此前付款后因断网、杀进程等原因未能完成商品消耗(即产生“掉单”),必须调用has.queryIap查询历史未完结订单。传入productType: 0(代表消耗型商品),若返回的purchaseDataList数组不为空,则需要遍历该数组,将每一笔坏账重新送入后续的“解码与消耗”流程中,这是避免60051错误的核心机制。

    /**
     * 查环境 (对应 ArkTS: queryEnv)
     */
    const queryEnv = () => {
      return new Promise((resolve) => {
        has.queryIapEnvStatus({
          success: (res) => {
            console.log(`[${TAG}] Succeeded in querying environment status.`)
            resolve(true)
          },
          fail: (err) => {
            console.error(`[${TAG}] Failed to query environment status.`, err)
            resolve(false)
          }
        })
      })
    }
    
    /**
     * 查询已购未消耗的订单/补单 (对应 ArkTS: queryPurchases)
     */
    const queryPurchases = () => {
      return new Promise((resolve) => {
        has.queryIap({
          productType: PRODUCT_TYPE_CONSUMABLE,
          success: (res) => {
            console.log(`[${TAG}] Succeeded in querying purchases.`)
            const purchaseDataList = res.purchaseDataList
    
            if (purchaseDataList.length === 0) {
              console.log(`[${TAG}] queryPurchases, purchaseDataList empty`)
              resolve()
              return
            }
    
            // 遍历处理所有未消耗订单
            purchaseDataList.forEach(dataStr => {
              dealPurchaseData(dataStr)
            })
            resolve()
          },
          fail: (err) => {
            console.error(`[${TAG}] Failed to query purchases.`, err)
            resolve()
          }
        })
      })
    }
  2. 获取商品信息并拉起支付:

    通过has.queryIapProducts获取后台配置的商品真实价格并在UI上渲染。用户点击购买时,调用has.createIap拉起收银台。若支付因网络或系统原因报错(如60051已拥有商品),应主动触发步骤1中的补单机制。

    /**
     * 查商品详情并展示 (对应 ArkTS: queryProducts)
     */
    const queryProducts = () => {
      has.queryIapProducts({
        productType: PRODUCT_TYPE_CONSUMABLE,
        productIds: [TARGET_PRODUCT_ID],
        success: (result) => {
          console.log(`[${TAG}] Succeeded in querying products.`)
          productInfoArray.value = Array.isArray(result) ? result : (result.productList || result.products || []);
          showNormalPage()
        },
        fail: (err) => {
          console.error(`[${TAG}] Failed to query products.`, err)
          showFailedPage('Query products failed')
        }
      })
    }
    
    /**
     * 点击购买 (对应 ArkTS: buy)
     */
    const buy = (item) => {
      const id = item.id || item.productId;
    
      has.createIap({
        productId: id,
        productType: PRODUCT_TYPE_CONSUMABLE,
        success: (result) => {
          const msg = 'Succeeded in creating purchase.'
          console.log(`[${TAG}] ${msg}`)
          uni.showToast({
            title: '支付成功',
            icon: 'success'
          })
    
          // result.purchaseData 是个 JSON 字符串
          dealPurchaseData(result.purchaseData)
        },
        fail: (err) => {
          const msg = `Failed to create purchase. Code: ${err.code}`
          console.error(`[${TAG}] ${msg}`, err)
          uni.showToast({
            title: '支付未完成',
            icon: 'none'
          })
    
          // 如果报错已拥有商品或系统错误,则触发补单
          if (err.code === 60051 || err.code === 60054) {
            queryPurchases()
          }
        }
      })
    }
  3. 处理JWS格式交易凭证:

    无论是正常支付成功还是查到了掉单记录,获取到的原始购买数据均是一个JSON字符串。将其解析后,提取jwsPurchaseOrder字段。

    /**
     * 处理购买数据 (对应 ArkTS: dealPurchaseData)
     */
    const dealPurchaseData = (purchaseDataStr) => {
      try {
        const purchaseData = JSON.parse(purchaseDataStr) // PurchaseData:包含jws格式的订单信息、订阅状态信息。
        const jwsPurchaseOrder = purchaseData.jwsPurchaseOrder // jwsPurchaseOrder 是 PurchaseOrderPayload 订单信息模型
    
        if (!jwsPurchaseOrder) {
          console.error(`[${TAG}] dealPurchaseData, jwsPurchaseOrder invalid`)
          return
        }
    
        // 解析 JWS 拿到 Payload
        const purchaseOrderPayload = decodeJwsObj(jwsPurchaseOrder)
    
        if (!purchaseOrderPayload) {
          return
        }
    
        // TODO: 【核心业务区】在这里执行您的发货逻辑,如解锁 MBTI 报告
        console.log(`[${TAG}] 准备发放权益, 订单号: ${purchaseOrderPayload.purchaseOrderId}`)
    
        // 状态 2(UNFINISHED) 或未带状态时,调用消耗接口
        // ArkTS示例中 FinishStatus.FINISHED = '1', UNFINISHED = '2'
        if (purchaseOrderPayload.finishStatus !== '1') {
          finishPurchase(purchaseOrderPayload)
        }
    
      } catch (e) {
        console.error(`[${TAG}] dealPurchaseData json error`, e)
      }
    }
  4. 业务侧发货与调用接口确认消耗:

    利用步骤3解码得到的对象,我们可以直接提取出purchaseToken(交易令牌)和purchaseOrderId(订单号)。此时业务侧可以发放虚拟权益(如在本地缓存解锁测试报告)。权益发放确认无误后,必须且仅能调用has.finishIap接口,传入刚刚解析出的参数。只有收到finishIap的成功回调,服务器才会真正核销该商品,该笔交易才算彻底闭环,用户方可进行下一次购买。

    /**
     * 确认消耗订单 (对应 ArkTS: finishPurchase)
     */
    const finishPurchase = (purchaseOrder) => {
      if (!purchaseOrder.purchaseToken) {
        console.error(`[${TAG}] finishPurchase but purchaseToken is empty`)
        return
      }
    
      has.finishIap({
        productType: PRODUCT_TYPE_CONSUMABLE,
        purchaseOrderId: purchaseOrder.purchaseOrderId,
        purchaseToken: purchaseOrder.purchaseToken,
        success: () => {
          console.log(`[${TAG}] Succeeded in finishing purchase.`)
          // 如果是补单进来的,消耗完可以给用户个提示
        },
        fail: (err) => {
          console.error(`[${TAG}] Failed to finish purchase.`, err)
        }
      })
    }

完整的示例代码如下(pages/index/index.vue):

<template>
  <view class="container">
    <view v-if="querying" class="status-page">
      <text>加载中...</text>
    </view>

    <view v-else-if="queryingFailed" class="status-page" @click="onCase">
      <text class="failed-text">{{ queryFailedText }}</text>
      <text class="retry-tips">点击屏幕重试</text>
    </view>

    <view v-else class="main-page">
      <text class="page-title">消耗型商品 (Consumables)</text>

      <view class="list-container">
        <view class="list-item" v-for="(item, index) in productInfoArray" :key="index">
          <view class="item-info">
            <view class="icon-placeholder"></view>
            <text class="item-name">{{ item.name || item.productName }}</text>
          </view>

          <button class="buy-btn" @click="buy(item)">
            {{ item.localPrice || item.price || 'Buy' }}
          </button>
        </view>

        <view v-if="productInfoArray.length === 0" style="padding: 20px; text-align: center; color: #999;">
          暂无商品数据
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
import {
  ref
} from 'vue'
import {
  onLoad
} from '@dcloudio/uni-app'

// --- 状态变量 (对应 ArkTS @State) ---
const querying = ref(true)
const queryingFailed = ref(false)
const productInfoArray = ref([])
const queryFailedText = ref('Query failed')

// --- 常量 ---
const TAG = 'ConsumablesPage'
const PRODUCT_TYPE_CONSUMABLE = 0 // ASCF中消耗型对应数字 0
const TARGET_PRODUCT_ID = '123456' // 替换为你的真实商品ID

/**
 * 页面加载时触发
 */
onLoad(() => {
  onCase()
})

/**
 * 主流程入口
 */
const onCase = async () => {
  showLoadingPage()

  if (typeof has === 'undefined') {
    showFailedPage('当前环境非ascf元服务,无法调用 has API')
    return
  }

  const isEnvOk = await queryEnv()
  if (!isEnvOk) {
    showFailedPage('This app does not support iap or Account not logged in.')
    return
  }

  // 环境正常后,先查补单,再查商品信息展示
  await queryPurchases()
  queryProducts()
}

/**
 * 查环境 (对应 ArkTS: queryEnv)
 */
const queryEnv = () => {
  return new Promise((resolve) => {
    has.queryIapEnvStatus({
      success: (res) => {
        console.log(`[${TAG}] Succeeded in querying environment status.`)
        resolve(true)
      },
      fail: (err) => {
        console.error(`[${TAG}] Failed to query environment status.`, err)
        resolve(false)
      }
    })
  })
}

/**
 * 查询已购未消耗的订单/补单 (对应 ArkTS: queryPurchases)
 */
const queryPurchases = () => {
  return new Promise((resolve) => {
    has.queryIap({
      productType: PRODUCT_TYPE_CONSUMABLE,
      success: (res) => {
        console.log(`[${TAG}] Succeeded in querying purchases.`)
        const purchaseDataList = res.purchaseDataList

        if (purchaseDataList.length === 0) {
          console.log(`[${TAG}] queryPurchases, purchaseDataList empty`)
          resolve()
          return
        }

        // 遍历处理所有未消耗订单
        purchaseDataList.forEach(dataStr => {
          dealPurchaseData(dataStr)
        })
        resolve()
      },
      fail: (err) => {
        console.error(`[${TAG}] Failed to query purchases.`, err)
        resolve()
      }
    })
  })
}

/**
 * 查商品详情并展示 (对应 ArkTS: queryProducts)
 */
const queryProducts = () => {
  has.queryIapProducts({
    productType: PRODUCT_TYPE_CONSUMABLE,
    productIds: [TARGET_PRODUCT_ID],
    success: (result) => {
      console.log(`[${TAG}] Succeeded in querying products.`)
      productInfoArray.value = Array.isArray(result) ? result : (result.productList || result.products || []);
      showNormalPage()
    },
    fail: (err) => {
      console.error(`[${TAG}] Failed to query products.`, err)
      showFailedPage('Query products failed')
    }
  })
}

/**
 * 点击购买 (对应 ArkTS: buy)
 */
const buy = (item) => {
  const id = item.id || item.productId;

  has.createIap({
    productId: id,
    productType: PRODUCT_TYPE_CONSUMABLE,
    success: (result) => {
      const msg = 'Succeeded in creating purchase.'
      console.log(`[${TAG}] ${msg}`)
      uni.showToast({
        title: '支付成功',
        icon: 'success'
      })

      // result.purchaseData 是个 JSON 字符串
      dealPurchaseData(result.purchaseData)
    },
    fail: (err) => {
      const msg = `Failed to create purchase. Code: ${err.code}`
      console.error(`[${TAG}] ${msg}`, err)
      uni.showToast({
        title: '支付未完成',
        icon: 'none'
      })

      // 如果报错已拥有商品或系统错误,则触发补单
      if (err.code === 60051 || err.code === 60054) {
        queryPurchases()
      }
    }
  })
}

/**
 * 处理购买数据 (对应 ArkTS: dealPurchaseData)
 */
const dealPurchaseData = (purchaseDataStr) => {
  try {
    const purchaseData = JSON.parse(purchaseDataStr) // PurchaseData:包含jws格式的订单信息、订阅状态信息。
    const jwsPurchaseOrder = purchaseData.jwsPurchaseOrder // jwsPurchaseOrder 是 PurchaseOrderPayload 订单信息模型

    if (!jwsPurchaseOrder) {
      console.error(`[${TAG}] dealPurchaseData, jwsPurchaseOrder invalid`)
      return
    }

    // 解析 JWS 拿到 Payload
    const purchaseOrderPayload = decodeJwsObj(jwsPurchaseOrder)

    if (!purchaseOrderPayload) {
      return
    }

    // TODO: 【核心业务区】在这里执行您的发货逻辑,如解锁 MBTI 报告
    console.log(`[${TAG}] 准备发放权益, 订单号: ${purchaseOrderPayload.purchaseOrderId}`)

    // 状态 2(UNFINISHED) 或未带状态时,调用消耗接口
    // ArkTS示例中 FinishStatus.FINISHED = '1', UNFINISHED = '2'
    if (purchaseOrderPayload.finishStatus !== '1') {
      finishPurchase(purchaseOrderPayload)
    }

  } catch (e) {
    console.error(`[${TAG}] dealPurchaseData json error`, e)
  }
}

/**
 * 确认消耗订单 (对应 ArkTS: finishPurchase)
 */
const finishPurchase = (purchaseOrder) => {
  if (!purchaseOrder.purchaseToken) {
    console.error(`[${TAG}] finishPurchase but purchaseToken is empty`)
    return
  }

  has.finishIap({
    productType: PRODUCT_TYPE_CONSUMABLE,
    purchaseOrderId: purchaseOrder.purchaseOrderId,
    purchaseToken: purchaseOrder.purchaseToken,
    success: () => {
      console.log(`[${TAG}] Succeeded in finishing purchase.`)
      // 如果是补单进来的,消耗完可以给用户个提示
    },
    fail: (err) => {
      console.error(`[${TAG}] Failed to finish purchase.`, err)
    }
  })
}

/**
 * --- 工具类与视图控制逻辑 ---
 */
// 对应 ArkTS: JWSUtil.decodeJwsObj
const decodeJwsObj = (jwsString) => {
  try {
    const parts = jwsString.split('.');
    if (parts.length < 2) return null;

    const payloadBase64Url = parts[1];
    // 1. Base64Url 转换为标准 Base64
    const base64 = payloadBase64Url.replace(/-/g, '+').replace(/_/g, '/');

    // 2. 纯 JS 实现的 Base64 转字节数组 (不使用 atob)
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    const bytes = [];
    let buffer = 0;
    let bitsCollected = 0;

    for (let i = 0; i < base64.length; i++) {
      const char = base64.charAt(i);
      const value = chars.indexOf(char);
      if (value === -1) continue;

      buffer = (buffer << 6) | value;
      bitsCollected += 6;

      if (bitsCollected >= 8) {
        bitsCollected -= 8;
        bytes.push((buffer >> bitsCollected) & 0xFF);
      }
    }

    // 3. 字节数组转 UTF-8 字符串 (替代 decodeURIComponent(escape(...)))
    // 这种方式兼容性最强,能处理中文字符且不会抛出 URI malformed
    let utf8Str = '';
    let i = 0;
    while (i < bytes.length) {
      const c = bytes[i];
      if (c < 128) {
        utf8Str += String.fromCharCode(c);
        i++;
      } else if (c > 191 && c < 224) {
        utf8Str += String.fromCharCode(((c & 31) << 6) | (bytes[i + 1] & 63));
        i += 2;
      } else if (c > 223 && c < 240) {
        utf8Str += String.fromCharCode(((c & 15) << 12) | ((bytes[i + 1] & 63) << 6) | (bytes[i + 2] & 63));
        i += 3;
      } else {
        i++; // 忽略更高位的 UTF-8 字符或错误字节
      }
    }

    return JSON.parse(utf8Str);
  } catch (e) {
    console.error(`[${TAG}] JWS decode failed:`, e);
    return null;
  }
}

const showLoadingPage = () => {
  queryingFailed.value = false
  querying.value = true
}

const showFailedPage = (text) => {
  if (text) {
    queryFailedText.value = text
  }
  queryingFailed.value = true
  querying.value = false
}

const showNormalPage = () => {
  queryingFailed.value = false
  querying.value = false
}
</script>

<style scoped>
.container {
  width: 100%;
  height: 100vh;
  background-color: #F1F3F5;
  display: flex;
  flex-direction: column;
}

.status-page {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.failed-text {
  font-size: 24px;
  font-weight: bold;
  color: #333;
  margin-bottom: 10px;
}

.retry-tips {
  font-size: 14px;
  color: #666;
}

.main-page {
  padding: 16px;
}

.page-title {
  font-size: 24px;
  font-weight: bold;
  margin-bottom: 20px;
  display: block;
}

.list-container {
  background-color: #FFF;
  border-radius: 16px;
  overflow: hidden;
}

.list-item {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
}

.list-item:last-child {
  border-bottom: none;
}

.item-info {
  display: flex;
  flex-direction: row;
  align-items: center;
}

.icon-placeholder {
  width: 40px;
  height: 40px;
  background-color: #f0f0f0;
  border-radius: 8px;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 20px;
  margin-right: 12px;
}

.item-name {
  font-size: 16px;
  color: #333;
}

.buy-btn {
  width: auto;
  min-width: 80px;
  height: 32px;
  line-height: 32px;
  font-size: 14px;
  background-color: #007DFF;
  color: #FFF;
  border-radius: 16px;
  margin: 0;
}
</style>

常见FAQ

Q:在进行本地沙盒测试时,调用has.isIapSandboxActivated报1001860001系统内部错误?

A:此报错通常源于应用身份验证失败或者商户服务未激活,请检查“开发准备”中是否遗漏了某个步骤。若确定HarmonyOS 开发者中心的商户服务已激活,请检查module.json5中是否配置了正确的client_id(App ID),并确保本地运行的包已配置调试签名(必须为手动签名),且该签名的SHA256指纹已同步至HarmonyOS 开发者中心->应用管理->基本信息->SHA256证书/公钥指纹。

Q:购买消耗型商品时报错1001860051(Failed to purchase because the user already owns the product)如何处理?

A:该错误由于上一笔交易付款成功但未执行has.finishIap消耗接口导致。系统认为用户仍持有该商品,故拦截重复购买。解决方案是在页面初始化或buy失败回调中,通过has.queryIap查询未消耗订单,并强制执行补发货及finishIap逻辑,也就是解决方案步骤一中的异常掉单恢复。

总结

ASCF元服务接入应用内购买服务的核心在于构建一个稳健的闭环状态机。开发者不应仅仅关注支付成功的success回调,更应将“异常掉单恢复”作为流程的第一优先级。通过本文提供的方案,开发者可以快速实现ASCF框架下元服务支付能力的平滑接入,在保证交易安全性的同时,提升支付成功率与用户体验。