智能客服
你问我答,随时在线为你解决问题
在开发ASCF元服务时,开发者需要接入应用内支付(IAP Kit)以实现虚拟物品(如消耗型测试次数、游戏币等)的购买交易。由于ASCF元服务环境的特殊性,无法直接套用ArkTS元服务的IAP Kit示例代码。
ASCF(Atomic Service Cross Framework)是元服务为小程序生态定制的一套解决方案,能够使用类似于小程序的开发技术,高效开发元服务。在ASCF环境下,元服务不能直接引用ArkTS的@kit.IAPKit模块,而是必须调用封装在has命名空间下的IAP开放接口。
接入IAP Kit分为平台配置与代码实现两个阶段。请先参考IAP Kit的开发准备完成基本准备工作、配置商品信息、配置签名以及配置应用身份信息。以此为基础,开发者可进一步遵循以下核心逻辑,在ASCF元服务中实现包含防掉单机制的完整支付闭环:
在拉起支付前,必须先调用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()
- }
- })
- })
- }
通过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()
- }
- }
- })
- }
无论是正常支付成功还是查到了掉单记录,获取到的原始购买数据均是一个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)
- }
- }
利用步骤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>
Q:在进行本地沙盒测试时,调用has.isIapSandboxActivated报1001860001系统内部错误?
A:此报错通常源于应用身份验证失败或者商户服务未激活,请检查“开发准备”中是否遗漏了某个步骤。若确定AGC后台的商户服务已激活,请检查module.json5中是否配置了正确的client_id(App ID),并确保本地运行的包已配置调试签名(必须为手动签名),且该签名的SHA256指纹已同步至AGC后台的项目设置中。
Q:购买消耗型商品时报错1001860051(Failed to purchase because the user already owns the product)如何处理?
A:该错误由于上一笔交易付款成功但未执行has.finishIap消耗接口导致。系统认为用户仍持有该商品,故拦截重复购买。解决方案是在页面初始化或buy失败回调中,通过has.queryIap查询未消耗订单,并强制执行补发货及finishIap逻辑,也就是解决方案步骤一中的异常掉单恢复。
ASCF元服务接入应用内购买服务的核心在于构建一个稳健的闭环状态机。开发者不应仅仅关注支付成功的success回调,更应将“异常掉单恢复”作为流程的第一优先级。通过本文提供的方案,开发者可以快速实现ASCF框架下元服务支付能力的平滑接入,在保证交易安全性的同时,提升支付成功率与用户体验。