文档管理中心
FAQS技术ASCF元服务如何接入IAP Kit

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错误的核心机制。

    收起
    自动换行
    深色代码主题
    复制
    1. /**
    2. * 查环境 (对应 ArkTS: queryEnv)
    3. */
    4. const queryEnv = () => {
    5. return new Promise((resolve) => {
    6. has.queryIapEnvStatus({
    7. success: (res) => {
    8. console.log(`[${TAG}] Succeeded in querying environment status.`)
    9. resolve(true)
    10. },
    11. fail: (err) => {
    12. console.error(`[${TAG}] Failed to query environment status.`, err)
    13. resolve(false)
    14. }
    15. })
    16. })
    17. }
    18. /**
    19. * 查询已购未消耗的订单/补单 (对应 ArkTS: queryPurchases)
    20. */
    21. const queryPurchases = () => {
    22. return new Promise((resolve) => {
    23. has.queryIap({
    24. productType: PRODUCT_TYPE_CONSUMABLE,
    25. success: (res) => {
    26. console.log(`[${TAG}] Succeeded in querying purchases.`)
    27. const purchaseDataList = res.purchaseDataList
    28. if (purchaseDataList.length === 0) {
    29. console.log(`[${TAG}] queryPurchases, purchaseDataList empty`)
    30. resolve()
    31. return
    32. }
    33. // 遍历处理所有未消耗订单
    34. purchaseDataList.forEach(dataStr => {
    35. dealPurchaseData(dataStr)
    36. })
    37. resolve()
    38. },
    39. fail: (err) => {
    40. console.error(`[${TAG}] Failed to query purchases.`, err)
    41. resolve()
    42. }
    43. })
    44. })
    45. }
  2. 获取商品信息并拉起支付

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

    收起
    自动换行
    深色代码主题
    复制
    1. /**
    2. * 查商品详情并展示 (对应 ArkTS: queryProducts)
    3. */
    4. const queryProducts = () => {
    5. has.queryIapProducts({
    6. productType: PRODUCT_TYPE_CONSUMABLE,
    7. productIds: [TARGET_PRODUCT_ID],
    8. success: (result) => {
    9. console.log(`[${TAG}] Succeeded in querying products.`)
    10. productInfoArray.value = Array.isArray(result) ? result : (result.productList || result.products || []);
    11. showNormalPage()
    12. },
    13. fail: (err) => {
    14. console.error(`[${TAG}] Failed to query products.`, err)
    15. showFailedPage('Query products failed')
    16. }
    17. })
    18. }
    19. /**
    20. * 点击购买 (对应 ArkTS: buy)
    21. */
    22. const buy = (item) => {
    23. const id = item.id || item.productId;
    24. has.createIap({
    25. productId: id,
    26. productType: PRODUCT_TYPE_CONSUMABLE,
    27. success: (result) => {
    28. const msg = 'Succeeded in creating purchase.'
    29. console.log(`[${TAG}] ${msg}`)
    30. uni.showToast({
    31. title: '支付成功',
    32. icon: 'success'
    33. })
    34. // result.purchaseData 是个 JSON 字符串
    35. dealPurchaseData(result.purchaseData)
    36. },
    37. fail: (err) => {
    38. const msg = `Failed to create purchase. Code: ${err.code}`
    39. console.error(`[${TAG}] ${msg}`, err)
    40. uni.showToast({
    41. title: '支付未完成',
    42. icon: 'none'
    43. })
    44. // 如果报错已拥有商品或系统错误,则触发补单
    45. if (err.code === 60051 || err.code === 60054) {
    46. queryPurchases()
    47. }
    48. }
    49. })
    50. }
  3. 处理JWS格式交易凭证

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

    收起
    自动换行
    深色代码主题
    复制
    1. /**
    2. * 处理购买数据 (对应 ArkTS: dealPurchaseData)
    3. */
    4. const dealPurchaseData = (purchaseDataStr) => {
    5. try {
    6. const purchaseData = JSON.parse(purchaseDataStr) // PurchaseData:包含jws格式的订单信息、订阅状态信息。
    7. const jwsPurchaseOrder = purchaseData.jwsPurchaseOrder // jwsPurchaseOrder 是 PurchaseOrderPayload 订单信息模型
    8. if (!jwsPurchaseOrder) {
    9. console.error(`[${TAG}] dealPurchaseData, jwsPurchaseOrder invalid`)
    10. return
    11. }
    12. // 解析 JWS 拿到 Payload
    13. const purchaseOrderPayload = decodeJwsObj(jwsPurchaseOrder)
    14. if (!purchaseOrderPayload) {
    15. return
    16. }
    17. // TODO: 【核心业务区】在这里执行您的发货逻辑,如解锁 MBTI 报告
    18. console.log(`[${TAG}] 准备发放权益, 订单号: ${purchaseOrderPayload.purchaseOrderId}`)
    19. // 状态 2(UNFINISHED) 或未带状态时,调用消耗接口
    20. // ArkTS示例中 FinishStatus.FINISHED = '1', UNFINISHED = '2'
    21. if (purchaseOrderPayload.finishStatus !== '1') {
    22. finishPurchase(purchaseOrderPayload)
    23. }
    24. } catch (e) {
    25. console.error(`[${TAG}] dealPurchaseData json error`, e)
    26. }
    27. }
  4. 业务侧发货与调用接口确认消耗

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

    收起
    自动换行
    深色代码主题
    复制
    1. /**
    2. * 确认消耗订单 (对应 ArkTS: finishPurchase)
    3. */
    4. const finishPurchase = (purchaseOrder) => {
    5. if (!purchaseOrder.purchaseToken) {
    6. console.error(`[${TAG}] finishPurchase but purchaseToken is empty`)
    7. return
    8. }
    9. has.finishIap({
    10. productType: PRODUCT_TYPE_CONSUMABLE,
    11. purchaseOrderId: purchaseOrder.purchaseOrderId,
    12. purchaseToken: purchaseOrder.purchaseToken,
    13. success: () => {
    14. console.log(`[${TAG}] Succeeded in finishing purchase.`)
    15. // 如果是补单进来的,消耗完可以给用户个提示
    16. },
    17. fail: (err) => {
    18. console.error(`[${TAG}] Failed to finish purchase.`, err)
    19. }
    20. })
    21. }

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

收起
自动换行
深色代码主题
复制
  1. <template>
  2. <view class="container">
  3. <view v-if="querying" class="status-page">
  4. <text>加载中...</text>
  5. </view>
  6. <view v-else-if="queryingFailed" class="status-page" @click="onCase">
  7. <text class="failed-text">{{ queryFailedText }}</text>
  8. <text class="retry-tips">点击屏幕重试</text>
  9. </view>
  10. <view v-else class="main-page">
  11. <text class="page-title">消耗型商品 (Consumables)</text>
  12. <view class="list-container">
  13. <view class="list-item" v-for="(item, index) in productInfoArray" :key="index">
  14. <view class="item-info">
  15. <view class="icon-placeholder"></view>
  16. <text class="item-name">{{ item.name || item.productName }}</text>
  17. </view>
  18. <button class="buy-btn" @click="buy(item)">
  19. {{ item.localPrice || item.price || 'Buy' }}
  20. </button>
  21. </view>
  22. <view v-if="productInfoArray.length === 0" style="padding: 20px; text-align: center; color: #999;">
  23. 暂无商品数据
  24. </view>
  25. </view>
  26. </view>
  27. </view>
  28. </template>
  29. <script setup>
  30. import {
  31. ref
  32. } from 'vue'
  33. import {
  34. onLoad
  35. } from '@dcloudio/uni-app'
  36. // --- 状态变量 (对应 ArkTS @State) ---
  37. const querying = ref(true)
  38. const queryingFailed = ref(false)
  39. const productInfoArray = ref([])
  40. const queryFailedText = ref('Query failed')
  41. // --- 常量 ---
  42. const TAG = 'ConsumablesPage'
  43. const PRODUCT_TYPE_CONSUMABLE = 0 // ASCF中消耗型对应数字 0
  44. const TARGET_PRODUCT_ID = '123456' // 替换为你的真实商品ID
  45. /**
  46. * 页面加载时触发
  47. */
  48. onLoad(() => {
  49. onCase()
  50. })
  51. /**
  52. * 主流程入口
  53. */
  54. const onCase = async () => {
  55. showLoadingPage()
  56. if (typeof has === 'undefined') {
  57. showFailedPage('当前环境非ascf元服务,无法调用 has API')
  58. return
  59. }
  60. const isEnvOk = await queryEnv()
  61. if (!isEnvOk) {
  62. showFailedPage('This app does not support iap or Account not logged in.')
  63. return
  64. }
  65. // 环境正常后,先查补单,再查商品信息展示
  66. await queryPurchases()
  67. queryProducts()
  68. }
  69. /**
  70. * 查环境 (对应 ArkTS: queryEnv)
  71. */
  72. const queryEnv = () => {
  73. return new Promise((resolve) => {
  74. has.queryIapEnvStatus({
  75. success: (res) => {
  76. console.log(`[${TAG}] Succeeded in querying environment status.`)
  77. resolve(true)
  78. },
  79. fail: (err) => {
  80. console.error(`[${TAG}] Failed to query environment status.`, err)
  81. resolve(false)
  82. }
  83. })
  84. })
  85. }
  86. /**
  87. * 查询已购未消耗的订单/补单 (对应 ArkTS: queryPurchases)
  88. */
  89. const queryPurchases = () => {
  90. return new Promise((resolve) => {
  91. has.queryIap({
  92. productType: PRODUCT_TYPE_CONSUMABLE,
  93. success: (res) => {
  94. console.log(`[${TAG}] Succeeded in querying purchases.`)
  95. const purchaseDataList = res.purchaseDataList
  96. if (purchaseDataList.length === 0) {
  97. console.log(`[${TAG}] queryPurchases, purchaseDataList empty`)
  98. resolve()
  99. return
  100. }
  101. // 遍历处理所有未消耗订单
  102. purchaseDataList.forEach(dataStr => {
  103. dealPurchaseData(dataStr)
  104. })
  105. resolve()
  106. },
  107. fail: (err) => {
  108. console.error(`[${TAG}] Failed to query purchases.`, err)
  109. resolve()
  110. }
  111. })
  112. })
  113. }
  114. /**
  115. * 查商品详情并展示 (对应 ArkTS: queryProducts)
  116. */
  117. const queryProducts = () => {
  118. has.queryIapProducts({
  119. productType: PRODUCT_TYPE_CONSUMABLE,
  120. productIds: [TARGET_PRODUCT_ID],
  121. success: (result) => {
  122. console.log(`[${TAG}] Succeeded in querying products.`)
  123. productInfoArray.value = Array.isArray(result) ? result : (result.productList || result.products || []);
  124. showNormalPage()
  125. },
  126. fail: (err) => {
  127. console.error(`[${TAG}] Failed to query products.`, err)
  128. showFailedPage('Query products failed')
  129. }
  130. })
  131. }
  132. /**
  133. * 点击购买 (对应 ArkTS: buy)
  134. */
  135. const buy = (item) => {
  136. const id = item.id || item.productId;
  137. has.createIap({
  138. productId: id,
  139. productType: PRODUCT_TYPE_CONSUMABLE,
  140. success: (result) => {
  141. const msg = 'Succeeded in creating purchase.'
  142. console.log(`[${TAG}] ${msg}`)
  143. uni.showToast({
  144. title: '支付成功',
  145. icon: 'success'
  146. })
  147. // result.purchaseData 是个 JSON 字符串
  148. dealPurchaseData(result.purchaseData)
  149. },
  150. fail: (err) => {
  151. const msg = `Failed to create purchase. Code: ${err.code}`
  152. console.error(`[${TAG}] ${msg}`, err)
  153. uni.showToast({
  154. title: '支付未完成',
  155. icon: 'none'
  156. })
  157. // 如果报错已拥有商品或系统错误,则触发补单
  158. if (err.code === 60051 || err.code === 60054) {
  159. queryPurchases()
  160. }
  161. }
  162. })
  163. }
  164. /**
  165. * 处理购买数据 (对应 ArkTS: dealPurchaseData)
  166. */
  167. const dealPurchaseData = (purchaseDataStr) => {
  168. try {
  169. const purchaseData = JSON.parse(purchaseDataStr) // PurchaseData:包含jws格式的订单信息、订阅状态信息。
  170. const jwsPurchaseOrder = purchaseData.jwsPurchaseOrder // jwsPurchaseOrder 是 PurchaseOrderPayload 订单信息模型
  171. if (!jwsPurchaseOrder) {
  172. console.error(`[${TAG}] dealPurchaseData, jwsPurchaseOrder invalid`)
  173. return
  174. }
  175. // 解析 JWS 拿到 Payload
  176. const purchaseOrderPayload = decodeJwsObj(jwsPurchaseOrder)
  177. if (!purchaseOrderPayload) {
  178. return
  179. }
  180. // TODO: 【核心业务区】在这里执行您的发货逻辑,如解锁 MBTI 报告
  181. console.log(`[${TAG}] 准备发放权益, 订单号: ${purchaseOrderPayload.purchaseOrderId}`)
  182. // 状态 2(UNFINISHED) 或未带状态时,调用消耗接口
  183. // ArkTS示例中 FinishStatus.FINISHED = '1', UNFINISHED = '2'
  184. if (purchaseOrderPayload.finishStatus !== '1') {
  185. finishPurchase(purchaseOrderPayload)
  186. }
  187. } catch (e) {
  188. console.error(`[${TAG}] dealPurchaseData json error`, e)
  189. }
  190. }
  191. /**
  192. * 确认消耗订单 (对应 ArkTS: finishPurchase)
  193. */
  194. const finishPurchase = (purchaseOrder) => {
  195. if (!purchaseOrder.purchaseToken) {
  196. console.error(`[${TAG}] finishPurchase but purchaseToken is empty`)
  197. return
  198. }
  199. has.finishIap({
  200. productType: PRODUCT_TYPE_CONSUMABLE,
  201. purchaseOrderId: purchaseOrder.purchaseOrderId,
  202. purchaseToken: purchaseOrder.purchaseToken,
  203. success: () => {
  204. console.log(`[${TAG}] Succeeded in finishing purchase.`)
  205. // 如果是补单进来的,消耗完可以给用户个提示
  206. },
  207. fail: (err) => {
  208. console.error(`[${TAG}] Failed to finish purchase.`, err)
  209. }
  210. })
  211. }
  212. /**
  213. * --- 工具类与视图控制逻辑 ---
  214. */
  215. // 对应 ArkTS: JWSUtil.decodeJwsObj
  216. const decodeJwsObj = (jwsString) => {
  217. try {
  218. const parts = jwsString.split('.');
  219. if (parts.length < 2) return null;
  220. const payloadBase64Url = parts[1];
  221. // 1. Base64Url 转换为标准 Base64
  222. const base64 = payloadBase64Url.replace(/-/g, '+').replace(/_/g, '/');
  223. // 2. 纯 JS 实现的 Base64 转字节数组 (不使用 atob)
  224. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  225. const bytes = [];
  226. let buffer = 0;
  227. let bitsCollected = 0;
  228. for (let i = 0; i < base64.length; i++) {
  229. const char = base64.charAt(i);
  230. const value = chars.indexOf(char);
  231. if (value === -1) continue;
  232. buffer = (buffer << 6) | value;
  233. bitsCollected += 6;
  234. if (bitsCollected >= 8) {
  235. bitsCollected -= 8;
  236. bytes.push((buffer >> bitsCollected) & 0xFF);
  237. }
  238. }
  239. // 3. 字节数组转 UTF-8 字符串 (替代 decodeURIComponent(escape(...)))
  240. // 这种方式兼容性最强,能处理中文字符且不会抛出 URI malformed
  241. let utf8Str = '';
  242. let i = 0;
  243. while (i < bytes.length) {
  244. const c = bytes[i];
  245. if (c < 128) {
  246. utf8Str += String.fromCharCode(c);
  247. i++;
  248. } else if (c > 191 && c < 224) {
  249. utf8Str += String.fromCharCode(((c & 31) << 6) | (bytes[i + 1] & 63));
  250. i += 2;
  251. } else if (c > 223 && c < 240) {
  252. utf8Str += String.fromCharCode(((c & 15) << 12) | ((bytes[i + 1] & 63) << 6) | (bytes[i + 2] & 63));
  253. i += 3;
  254. } else {
  255. i++; // 忽略更高位的 UTF-8 字符或错误字节
  256. }
  257. }
  258. return JSON.parse(utf8Str);
  259. } catch (e) {
  260. console.error(`[${TAG}] JWS decode failed:`, e);
  261. return null;
  262. }
  263. }
  264. const showLoadingPage = () => {
  265. queryingFailed.value = false
  266. querying.value = true
  267. }
  268. const showFailedPage = (text) => {
  269. if (text) {
  270. queryFailedText.value = text
  271. }
  272. queryingFailed.value = true
  273. querying.value = false
  274. }
  275. const showNormalPage = () => {
  276. queryingFailed.value = false
  277. querying.value = false
  278. }
  279. </script>
  280. <style scoped>
  281. .container {
  282. width: 100%;
  283. height: 100vh;
  284. background-color: #F1F3F5;
  285. display: flex;
  286. flex-direction: column;
  287. }
  288. .status-page {
  289. flex: 1;
  290. display: flex;
  291. flex-direction: column;
  292. justify-content: center;
  293. align-items: center;
  294. }
  295. .failed-text {
  296. font-size: 24px;
  297. font-weight: bold;
  298. color: #333;
  299. margin-bottom: 10px;
  300. }
  301. .retry-tips {
  302. font-size: 14px;
  303. color: #666;
  304. }
  305. .main-page {
  306. padding: 16px;
  307. }
  308. .page-title {
  309. font-size: 24px;
  310. font-weight: bold;
  311. margin-bottom: 20px;
  312. display: block;
  313. }
  314. .list-container {
  315. background-color: #FFF;
  316. border-radius: 16px;
  317. overflow: hidden;
  318. }
  319. .list-item {
  320. display: flex;
  321. flex-direction: row;
  322. align-items: center;
  323. justify-content: space-between;
  324. padding: 12px 16px;
  325. border-bottom: 1px solid #eee;
  326. }
  327. .list-item:last-child {
  328. border-bottom: none;
  329. }
  330. .item-info {
  331. display: flex;
  332. flex-direction: row;
  333. align-items: center;
  334. }
  335. .icon-placeholder {
  336. width: 40px;
  337. height: 40px;
  338. background-color: #f0f0f0;
  339. border-radius: 8px;
  340. display: flex;
  341. justify-content: center;
  342. align-items: center;
  343. font-size: 20px;
  344. margin-right: 12px;
  345. }
  346. .item-name {
  347. font-size: 16px;
  348. color: #333;
  349. }
  350. .buy-btn {
  351. width: auto;
  352. min-width: 80px;
  353. height: 32px;
  354. line-height: 32px;
  355. font-size: 14px;
  356. background-color: #007DFF;
  357. color: #FFF;
  358. border-radius: 16px;
  359. margin: 0;
  360. }
  361. </style>

常见FAQ

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框架下元服务支付能力的平滑接入,在保证交易安全性的同时,提升支付成功率与用户体验。

在 FAQ 中进行搜索
请输入您想要搜索的关键词