在开发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:此报错通常源于应用身份验证失败或者商户服务未激活,请检查“开发准备”中是否遗漏了某个步骤。若确定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逻辑,也就是解决方案步骤一中的异常掉单恢复。