OnePage Checkout
约 9157 字大约 31 分钟
2025-03-07
接入摘要
OnePage Checkout 是适用于一页式结账场景的站内支付组件方案。商户服务端先调用 获取 sdkAccessToken 接口 获取初始化凭证,商户前端再在现有结账页面中引入 OnePage JavaScript-SDK 并渲染 PingPong 支付组件。买家点击支付后,商户前端通常还需要通过 beforeCheckoutHook 调用商户后端创建订单,由商户后端进一步调用 prePay(预下单)接口 获取支付 token,并返回给 SDK 发起支付。该方案能够让地址、配送、订单摘要与支付区域在同一页面联动,减少页面跳转,同时保留商户站内结账体验。
适用场景
适合已经采用 OnePage Checkout 结账页,且需要在同一页面内联动金额、国家、商品信息与支付组件的团队;若只需要通用站内收银台,可参考内嵌 SDK。
支付流程
获取初始化凭证
- 商户服务端调用 获取 sdkAccessToken 接口。
- 获取用于初始化 OnePage Checkout 的
sdkAccessToken,并返回给前端页面。
初始化收银台
- 商户前端引入与环境匹配的 OnePage JavaScript-SDK。
- 在结账页插入
pp-checkout组件,并调用PingPong.Checkout.create()传入amount、currency、tradeCountry、sdkAccessToken等参数。
渲染并联动支付组件
- PingPong 支付组件在商户结账页内完成渲染并展示可用支付方式。
- 当金额、国家或商品信息变化时,可调用
updateCheckoutHook对收银台做局部更新。
创建支付订单
- 买家在当前结账页内选择支付方式并点击支付按钮。
- SDK 触发
beforeCheckoutHook后,商户前端可调用商户后端创建订单。 - 商户后端再调用 prePay(预下单)接口 获取支付 token,并将该 token 返回给前端。
- 前端在
beforeCheckoutHook中将支付 token 返回给 SDK。
提交支付并处理结果
- SDK 拿到支付 token 后,才会正式向 PingPong 发起支付请求。
- 支付成功后,商户前端可更新当前结账页状态或进入后续结果展示流程。
- 支付失败时,可使用
checkoutFailedHook自定义错误提示或重试逻辑。
关键注意事项
sdkAccessToken仅用于初始化 OnePage Checkout,不等同于支付时使用的订单 token;- 支付 token 需要在买家点击支付后,由商户后端调用 prePay(预下单)接口 创建订单并返回给前端 SDK;
- 请确保引入的 OnePage JavaScript-SDK 地址与当前运行环境一致,避免沙箱与生产环境脚本混用;
updateCheckoutHook采用局部更新机制,只传入发生变化的字段即可,无需重复传未变化参数;- 当
originalPay设置为false时,需要由商户自定义支付按钮并手动调用PingPong.Checkout.pay.run()触发支付; - 若配置
paymentMethods参数,实际展示结果会与当前accId已开通的支付方式取交集,展示顺序以传入数组顺序为准。
集成流程
1. 获取 sdkAccessToken
商户后端系统通过获取 sdkAccessToken 接口 得到 JS-SDK 访问凭证(token),该凭证用于前端 JS-SDK 的初始化调用。
2. 引入 JavaScript-SDK
复制以下代码,通过 CDN 地址引入 PingPong OnePage JavaScript-SDK。
<script type="module" src="https://payssr-cdn.pingpongx.com/production-fra/acquirer-checkout-onepage/sandbox/pp-checkout.js"></script><script type="module" src="https://payssr-cdn.pingpongx.com/production-fra/acquirer-checkout-onepage/pp-checkout.js"></script><script type="module" src="https://acquirer-cdn.pingpongx.com/acquirer/checkout-onepage/production-sg/pp-checkout.js"></script><script type="module" src="https://acquirer-cdn.pingpongx.com/acquirer/checkout-onepage/production-us/pp-checkout.js"></script>3. 初始化收银台
将 pp-checkout 标签插入 html body 中
<!-- 插入 PingPong 收银台组件 -->
<pp-checkout locale="en"></pp-checkout>调用 PingPong.Checkout.create 完成初始化
入参
amountRequiredString
交易金额
该交易仅用于收银台展示,后续金额变更需调用 updateCheckoutHook 函数进行更新
currencyRequiredString
交易币种
tradeCountryRequiredString
用于指定 PingPong 收银台国家
后续国家变更需调用 updateCheckoutHook 函数进行更新
sdkAccessTokenRequiredString
JS-SDK 访问凭证
originalPayOptionalBoolean
true
控制是否使用默认的 PingPong 支付按钮
若将其设置为 false,则表示不使用内置支付按钮。此时,需要在页面内自定义支付按钮点击事件中调用 PingPong.Checkout.pay.run() 方法来触发支付流程。
paymentMethodsOptionalString[]
指定支付方式列表,内嵌收银台会根据指定的支付方式进行展示
传入的支付方式将与该 accId 下配置的支付方式进行交集运算。支付方式的展示顺序将根据传入数组的顺序进行排列。
useTabModeOptionalBoolean
true
控制是否展示支付方式选项框
当设置为 false 时,如果商户仅有卡支付配置,将不展示选项框,直接显示支付表单。适用于简化用户体验的场景。
goodsOptionalArray
商品列表,包含以下子字段
goods.descriptionOptionalString
商品描述
goods.imgUrlOptionalString
商品图片 URL
goods.nameRequiredString
商品名称
goods.numberRequiredString
商品数量
goods.skuOptionalString
商品 SKU 编码
goods.unitPriceRequiredString
商品单价
goods.virtualProductOptionalString
N
是否为虚拟商品
Y - 虚拟商品,N - 实物商品
bizTypeOptionalString
ApplePay 绑卡业务参数
ApplePay 交易必传,固定值: CodeGrant
recurringInfoDTOOptionalObject
配置 Recurring 付款(ApplePay 交易必传),包含以下子字段
recurringInfoDTO.recurringPaymentStartDateOptionalDate
首次付款日期
eg: "2024-06-01 00:00:00"
recurringInfoDTO.recurringPaymentIntervalUnitOptionalString
表示年、月、日、时等日历单位的类型
enum: year / month / day / hour / minute,eg: "month"
recurringInfoDTO.recurringPaymentIntervalCountOptionalString
构成总支付间隔的间隔单位数
eg: "6"
recurringInfoDTO.recurringPaymentEndDateOptionalDate
最后付款日期
eg: "2024-12-01 00:00:00"
// 创建 PingPong 收银台
PingPong.Checkout.create({
amount: '1.08', // 交易金额
currency: 'USD', // 交易币种
tradeCountry: 'US', // 交易国家
originalPay: true,
useTabMode: false, // 当仅有卡支付时,隐藏选项框
sdkAccessToken, // SDK访问令牌
paymentMethods: ['VISA', 'Klarna'],
goods: [{
description: 'short legs',
imgUrl: 'http://pic.bizhi360.com/bpic/30/5230.jpg',
name: '한국어/English',
number: '1',
sku: '20230524001',
unitPrice: '1',
virtualProduct: 'N'
}],
// ApplePay 支付参数
bizType: 'CodeGrant', // ApplePay 交易必传
recurringInfoDTO: {
recurringPaymentStartDate: "2024-06-01 00:00:00",
recurringPaymentIntervalUnit: "month",
recurringPaymentIntervalCount: "6",
recurringPaymentEndDate: "2024-12-01 00:00:00"
},
})自定义支付按钮(可选)
// 自定义支付按钮配置
// 初始化参数中 originalPay 为 false 时, 需自定义支付按钮点击事件
document.querySelector('#pay').onclick = function () {
PingPong.Checkout.pay.run() // 手动触发支付
}事件监听(可选)
SDK 支持监听初始化过程中的 ready 和 error 事件,方便外部进行状态管理和错误处理。
// 监听 SDK 初始化事件
// 监听初始化成功事件
document.querySelector('pp-checkout').addEventListener('ready', (e) => {
console.log('SDK初始化成功'); // 初始化成功回调
// 可以在此处执行初始化完成后的逻辑
});
// 监听初始化失败事件
document.querySelector('pp-checkout').addEventListener('error', (e) => {
console.log('SDK初始化失败', e.detail); // 错误信息
// 可以在此处执行错误处理逻辑,如显示错误提示、重试等
});事件说明:
ready: 当 SDK 初始化成功时触发,表示收银台已准备就绪error: 当 SDK 初始化失败时触发,事件详情包含在e.detail中
4. 修改金额、国家和商品
在使用全局变量前,请确保Javascript-SDK 加载完成。
updateCheckoutHook(可选)
updateCheckoutHook 用来更新收银台要素。
ts 类型
// updateCheckoutHook 类型定义
PingPong.Checkout.updateCheckoutHook:
({ amount?: string, tradeCountry?: string, goods?: Goods[] }) => void使用说明
⚠️ 重要:该方法采用部分更新机制,只需传入发生变化的字段即可,无需传入其他字段。
| 场景 | 传入参数 | 示例代码 |
|---|---|---|
| 国家发生变化 | tradeCountry | PingPong.Checkout.updateCheckoutHook({ tradeCountry: newCountry }); |
| 金额发生变化 | amount | PingPong.Checkout.updateCheckoutHook({ amount: newAmount }); |
| 商品发生变化 | goods | PingPong.Checkout.updateCheckoutHook({ goods: newGoods }); |
正确示例
// 场景1: 用户切换国家 - 只传 tradeCountry
const newCountry = 'US';
PingPong.Checkout.updateCheckoutHook({ tradeCountry: newCountry });
// 场景2: 用户使用优惠券后金额变化 - 只传 amount
const newAmount = '99.99';
PingPong.Checkout.updateCheckoutHook({ amount: newAmount });
// 场景3: 商品信息变更 - 只传 goods
const newGoods = [{
description: 'updated description',
imgUrl: 'http://pic.bizhi360.com/bpic/30/5230.jpg',
name: 'Updated Product Name',
number: '2',
sku: '20230524001',
unitPrice: '2',
virtualProduct: 'N'
}];
PingPong.Checkout.updateCheckoutHook({ goods: newGoods });❌ 错误示例
以下方式不推荐,传入不必要的字段或 null 值:
// ❌ 错误: 不需要传入未变化的字段,也不需要传入 null
PingPong.Checkout.updateCheckoutHook({
amount: newAmount ? newAmount : null,
tradeCountry: newCountry ? newCountry : null,
goods: newGoods ? newGoods : null
});
// ❌ 错误: 只更新金额时,不需要同时传入 country 和 goods
PingPong.Checkout.updateCheckoutHook({
amount: '99.99',
tradeCountry: 'US', // 未变化的字段,不需要传
goods: [] // 未变化的字段,不需要传
});5. 下单前检验
beforeCheckoutHook(可选)
type:
// beforeCheckoutHook 类型定义
(({payMethod}) => void) | (({payMethod}) => Promise<void>)beforeCheckoutHook 用来设置发起支付请求前的钩子函数。
当你在用户点击支付按钮,发起支付请求前,需要执行你自己的业务逻辑,如:上报埋点、检查库存等,可以设置该钩子函数。
该函数接收一个包含 payMethod 的对象参数,表示用户选择的支付方式。
该函数可以返回一个Promise,后续的支付流程会等待该 Promise 状态变为 Fulfilled 后才会继续执行。如果你想在 Promise 状态为 Rejected 或者异步结果不满足你的业务条件时,可以抛出异常,SDK在捕获到异常后中断支付流程。
典型接入方式
OnePage Checkout 的常见接法是:商户前端在 beforeCheckoutHook 中调用商户后端创建订单,商户后端再调用 prePay(预下单)接口 获取支付 token,最后由 beforeCheckoutHook 将该 token 返回给 SDK,SDK 再继续提交支付。
PingPong.Checkout.beforeCheckoutHook = async ({payMethod}) => {
console.log('用户选择的支付方式:', payMethod);
const inventoryRes = await fetch('/api/requestInventory');
const {inventoryQuantity} = await inventoryRes.json();
if (inventoryQuantity < MIN_QUANTITY) {
throw new Error('库存不足,需中断交易');
}
// 调用商户后端创建订单,由商户后端进一步调用 prePay(预下单)接口
const orderRes = await fetch('/api/orders/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ payMethod })
});
const { token } = await orderRes.json();
if (!token) {
throw new Error('订单创建失败,未获取到支付 token');
}
return token;
};ts 类型
// beforeCheckoutHook 返回类型定义
PingPong.Checkout.beforeCheckoutHook:
(({payMethod}) => string) | (({payMethod}) => Promise<string>)6. 发起支付
买家点击支付按钮后,SDK 会先等待 beforeCheckoutHook 返回支付 token;在拿到由商户后端调用prePay(预下单)接口创建的支付 token 后,才会继续发起支付请求并完成支付处理。
7. 错误处理
checkoutFailedHook(可选)
type:
// checkoutFailedHook 类型定义
(() => void) | (() => Promise<void>)checkoutFailedHook 接收以下参数:
// checkoutFailedHook 参数类型定义
(code: string, message: string) => void | Promise<void>;
// code: string - 错误码
// message: string - 错误消息checkoutFailedHook 用来自定义错误逻辑
当用户支付失败时,PingPong 默认会弹窗提示用户失败原因。如果你想自定义弹窗 UI 或文本,可以设置该钩子函数。
该函数可以返回一个 Promise。如果返回 Promise,后续的流程会等待该 Promise 状态变为 Fulfilled 后才继续执行
PingPong.Checkout.checkoutFailedHook = (code: string, message: string) => {
notification.open({
message: 'Error title',
description: `${code}: ${message}`
})
};使用示例
examples
onepage
pages
checkout.html
assets
css
checkout.css
js
config
app-config.js
services
pingpong-service.js
utils
ui-utils.js
main.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PingPong Checkout Demo</title>
<link rel="stylesheet" href="../assets/css/checkout.css">
</head>
<body>
<div class="checkout-container">
<div class="checkout-section">
<!-- billing 表单 -->
<form class="billing-form">
<h3>Billing Information</h3>
<div class="form-group">
<label for="amount">Amount:</label>
<input type="number" id="amount" value="1.08" step="0.01" min="0">
</div>
<div class="form-group">
<label for="country">Country:</label>
<select id="country">
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
</select>
</div>
<!-- billing 表单字段 -->
</form>
<!-- PingPong 内嵌收银台 -->
<div class="pingpong-checkout">
<pp-checkout locale="en"></pp-checkout>
</div>
<!-- shipping 表单 -->
<form class="shipping-form">
<h3>Shipping Information</h3>
<!-- shipping 表单字段 -->
</form>
</div>
<div class="payment-section">
<!-- 优惠券码 -->
<div class="coupon-section">
<label for="coupon">Coupon Code:</label>
<input type="text" id="coupon" placeholder="Enter coupon code">
</div>
<!-- 自定义支付按钮 -->
<button id="customPay" class="pay-button">Pay Now</button>
</div>
</div>
<!-- 加载 PingPong JS-SDK -->
<script type="module" src="https://payssr-cdn.pingpongx.com/production-fra/acquirer-checkout-onepage/sandbox/pp-checkout.js"></script>
<!-- 加载应用脚本 -->
<script type="module" src="../js/main.js"></script>
</body>
</html>/**
* PingPong Checkout 样式文件
* 负责结账页面的所有样式定义
*/
/* ========== 变量定义 ========== */
:root {
--primary-color: #007bff;
--primary-hover: #0056b3;
--success-color: #28a745;
--error-color: #dc3545;
--warning-color: #ffc107;
--border-color: #e1e5e9;
--background-light: #f8f9fa;
--text-primary: #333;
--text-secondary: #666;
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
--shadow-md: 0 2px 8px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 12px rgba(0,123,255,0.3);
--border-radius: 8px;
--border-radius-sm: 4px;
--border-radius-lg: 12px;
--transition: all 0.3s ease;
}
/* ========== 基础样式重置 ========== */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--background-light);
color: var(--text-primary);
line-height: 1.6;
}
/* ========== 主容器 ========== */
.checkout-container {
max-width: 800px;
margin: 0 auto;
padding: 24px;
background: white;
border-radius: var(--border-radius);
box-shadow: var(--shadow-md);
}
/* ========== 结账区域 ========== */
.checkout-section {
margin-bottom: 32px;
}
.billing-form,
.shipping-form {
margin-bottom: 24px;
}
.billing-form h3,
.shipping-form h3 {
margin: 0 0 16px 0;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
}
/* ========== 表单组件 ========== */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
}
.form-group input,
.form-group select {
width: 100%;
max-width: 320px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
background: white;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
}
.form-group input::placeholder {
color: #999;
}
/* ========== PingPong 收银台 ========== */
.pingpong-checkout {
margin: 24px 0;
padding: 20px;
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
}
/* ========== 支付区域 ========== */
.payment-section {
text-align: center;
padding: 24px;
background: linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
}
/* ========== 优惠券区域 ========== */
.coupon-section {
margin-bottom: 20px;
}
.coupon-section label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
}
.coupon-section input {
width: 100%;
max-width: 280px;
padding: 12px 16px;
border: 2px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 14px;
transition: var(--transition);
background: white;
}
.coupon-section input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
}
/* ========== 支付按钮 ========== */
.pay-button {
background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%);
color: white;
border: none;
padding: 16px 48px;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 16px;
font-weight: 700;
transition: var(--transition);
min-width: 200px;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.pay-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
background: linear-gradient(135deg, #0056b3 0%, var(--primary-hover) 100%);
}
.pay-button:active {
transform: translateY(0);
box-shadow: var(--shadow-md);
}
.pay-button:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
box-shadow: none;
opacity: 0.7;
}
.pay-button.loading {
color: transparent;
pointer-events: none;
}
.pay-button.loading::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
top: 50%;
left: 50%;
margin-left: -10px;
margin-top: -10px;
border: 2px solid white;
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s ease-in-out infinite;
}
/* ========== 消息提示 ========== */
.message {
padding: 12px 16px;
border-radius: var(--border-radius-sm);
margin: 16px 0;
font-size: 14px;
font-weight: 500;
border-left: 4px solid;
}
.message.error {
background: #f8d7da;
border-left-color: var(--error-color);
color: #721c24;
}
.message.success {
background: #d4edda;
border-left-color: var(--success-color);
color: #155724;
}
.message.warning {
background: #fff3cd;
border-left-color: var(--warning-color);
color: #856404;
}
.message.info {
background: #d1ecf1;
border-left-color: var(--primary-color);
color: #0c5460;
}
/* ========== 加载状态 ========== */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-overlay .spinner {
width: 40px;
height: 40px;
border: 4px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* ========== 动画定义 ========== */
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* ========== 响应式设计 ========== */
@media (max-width: 768px) {
body {
padding: 10px;
}
.checkout-container {
padding: 16px;
border-radius: var(--border-radius-sm);
}
.form-group input,
.form-group select {
max-width: 100%;
}
.coupon-section input {
max-width: 100%;
}
.pay-button {
width: 100%;
min-width: unset;
padding: 14px 24px;
}
.pingpong-checkout {
padding: 16px;
}
}
@media (max-width: 480px) {
.checkout-container {
padding: 12px;
}
.payment-section {
padding: 20px 16px;
}
}
/* ========== 打印样式 ========== */
@media print {
.pay-button,
.loading-overlay {
display: none !important;
}
.checkout-container {
box-shadow: none;
border: 1px solid #ddd;
}
}
/* ========== 可访问性增强 ========== */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
:root {
--border-color: #000;
--text-primary: #000;
--background-light: #fff;
}
}
/* ========== 工具类 ========== */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
.text-center {
text-align: center !important;
}
.text-left {
text-align: left !important;
}
.text-right {
text-align: right !important;
}
.mb-0 { margin-bottom: 0 !important; }
.mb-1 { margin-bottom: 8px !important; }
.mb-2 { margin-bottom: 16px !important; }
.mb-3 { margin-bottom: 24px !important; }
.mb-4 { margin-bottom: 32px !important; }/**
* 应用配置文件
* 包含所有应用级别的配置常量
*/
/**
* PingPong SDK 配置
*/
export const PINGPONG_CONFIG = {
// SDK 基础配置
amount: '1.08',
currency: 'USD',
tradeCountry: 'US',
originalPay: false,
useTabMode: true,
sdkAccessToken: 'your-sdk-access-token-here',
paymentMethods: ['VISA', 'MasterCard', 'Klarna'],
goods: [{
description: 'short legs',
imgUrl: 'http://pic.bizhi360.com/bpic/30/5230.jpg',
name: '한국어/English',
number: '1',
sku: '20230524001',
unitPrice: '1',
virtualProduct: 'N'
}],
// ApplePay 配置(可选)
applePay: {
bizType: 'CodeGrant',
recurringInfoDTO: {
recurringPaymentStartDate: '',
recurringPaymentIntervalUnit: 'month',
recurringPaymentIntervalCount: '',
recurringPaymentEndDate: ''
}
}
};
/**
* API 端点配置
*/
export const API_ENDPOINTS = {
// PingPong API
getAccessToken: '/api/auth/sdk-token',
createOrder: '/api/orders/create',
validateInventory: '/api/inventory/validate',
// 业务 API
validateCoupon: '/api/coupons/validate',
logError: '/api/logs/error',
// 支付相关
paymentStatus: '/api/payments/status',
refundPayment: '/api/payments/refund'
};
/**
* 国家货币映射配置
*/
export const COUNTRY_CURRENCY_MAP = {
'US': 'USD',
'GB': 'GBP',
'DE': 'EUR',
'FR': 'EUR',
'IT': 'EUR',
'ES': 'EUR',
'CA': 'CAD',
'AU': 'AUD',
'JP': 'JPY',
'CN': 'CNY'
};
/**
* 错误代码映射配置
*/
export const ERROR_MESSAGES = {
'PAYMENT_DECLINED': '支付被拒绝,请检查卡片信息或尝试其他支付方式',
'INSUFFICIENT_FUNDS': '余额不足,请使用其他支付方式',
'INVALID_CARD': '卡片信息无效,请检查后重试',
'CARD_EXPIRED': '卡片已过期,请使用其他卡片',
'NETWORK_ERROR': '网络连接异常,请检查网络后重试',
'TIMEOUT': '支付超时,请重试',
'FRAUD_DETECTED': '安全验证失败,请联系客服',
'CURRENCY_NOT_SUPPORTED': '不支持该货币,请选择其他货币',
'AMOUNT_INVALID': '支付金额无效',
'AMOUNT_TOO_LOW': '支付金额过低',
'AMOUNT_TOO_HIGH': '支付金额过高',
'MERCHANT_ERROR': '商户系统错误,请联系客服',
'SDK_TOKEN_EXPIRED': '访问凭证已过期,请刷新页面重试',
'SDK_TOKEN_INVALID': '访问凭证无效,请联系客服'
};
/**
* 应用常量配置
*/
export const APP_CONSTANTS = {
// 防抖延迟时间(毫秒)
DEBOUNCE_DELAY: {
AMOUNT_INPUT: 500,
COUNTRY_CHANGE: 0,
COUPON_INPUT: 1000
},
// 验证规则
VALIDATION: {
MIN_AMOUNT: 0.01,
MAX_AMOUNT: 999999.99,
COUPON_LENGTH_MIN: 3,
COUPON_LENGTH_MAX: 50
},
// 超时配置(毫秒)
TIMEOUTS: {
PAYMENT: 300000, // 5分钟
API_CALL: 30000, // 30秒
MESSAGE_DISPLAY: {
ERROR: 5000,
SUCCESS: 3000,
WARNING: 4000
}
},
// 重试配置
RETRY: {
MAX_ATTEMPTS: 3,
DELAY_BASE: 1000
}
};
/**
* 开发环境配置
*/
export const DEV_CONFIG = {
isDevelopment: process.env.NODE_ENV === 'development',
enableDebugLogs: true,
mockAPI: false,
mockCouponCode: 'SAVE10' // 模拟有效的优惠券代码
};
/**
* 默认导出所有配置
*/
export default {
pingpong: PINGPONG_CONFIG,
api: API_ENDPOINTS,
countryCurrency: COUNTRY_CURRENCY_MAP,
errorMessages: ERROR_MESSAGES,
constants: APP_CONSTANTS,
dev: DEV_CONFIG
};/**
* PingPong SDK 服务
* 负责与 PingPong SDK 的所有交互
*/
import { PINGPONG_CONFIG, ERROR_MESSAGES } from '../config/app-config.js';
/**
* PingPong 服务类
*/
export class PingPongService {
constructor() {
this.isInitialized = false;
this.eventListeners = new Map();
}
/**
* 初始化 PingPong Checkout
* @param {Object} config - 配置对象
* @returns {Promise<void>}
*/
async initialize(config = {}) {
try {
// 检查 SDK 是否已加载
if (typeof PingPong === 'undefined') {
throw new Error('PingPong SDK 未加载');
}
// 合并配置
const finalConfig = { ...PINGPONG_CONFIG, ...config };
// 初始化收银台
await PingPong.Checkout.create(finalConfig);
this.isInitialized = true;
this.setupEventListeners();
console.log('✅ PingPong Checkout 初始化成功');
} catch (error) {
console.error('❌ PingPong Checkout 初始化失败:', error);
throw error;
}
}
/**
* 更新收银台参数
* @param {Object} updates - 更新的参数
* @returns {void}
*/
updateCheckout(updates) {
try {
if (!this.isInitialized) {
throw new Error('PingPong Checkout 未初始化');
}
if (!PingPong?.Checkout?.updateCheckoutHook) {
throw new Error('PingPong SDK updateCheckoutHook 方法不可用');
}
// 验证参数
const validUpdates = this.validateUpdateParams(updates);
// 调用 SDK 更新方法
PingPong.Checkout.updateCheckoutHook(validUpdates);
console.log('✅ 收银台更新成功:', validUpdates);
} catch (error) {
console.error('❌ 更新收银台失败:', error);
throw error;
}
}
/**
* 开始支付流程
* @returns {Promise<void>}
*/
async startPayment() {
try {
if (!this.isInitialized) {
throw new Error('PingPong Checkout 未初始化');
}
if (!PingPong?.Checkout?.pay?.run) {
throw new Error('PingPong 支付方法不可用');
}
await PingPong.Checkout.pay.run();
console.log('💰 支付流程已启动');
} catch (error) {
console.error('❌ 启动支付失败:', error);
throw error;
}
}
/**
* 设置支付前 Hook
* @param {Function} hook - Hook 函数
*/
setBeforeCheckoutHook(hook) {
if (typeof hook !== 'function') {
throw new Error('beforeCheckoutHook 必须是函数');
}
PingPong.Checkout.beforeCheckoutHook = hook;
console.log('✅ beforeCheckoutHook 设置成功');
}
/**
* 设置支付失败 Hook
* @param {Function} hook - Hook 函数
*/
setCheckoutFailedHook(hook) {
if (typeof hook !== 'function') {
throw new Error('checkoutFailedHook 必须是函数');
}
PingPong.Checkout.checkoutFailedHook = hook;
console.log('✅ checkoutFailedHook 设置成功');
}
/**
* 设置事件监听器
*/
setupEventListeners() {
const checkoutElement = document.querySelector('pp-checkout');
if (checkoutElement) {
// 初始化成功事件
checkoutElement.addEventListener('ready', (e) => {
console.log('🚀 SDK初始化成功,收银台准备就绪');
this.emit('ready', e.detail);
});
// 初始化失败事件
checkoutElement.addEventListener('error', (e) => {
console.error('💥 SDK初始化失败:', e.detail);
this.emit('error', e.detail);
});
console.log('✅ 事件监听器设置完成');
}
}
/**
* 添加自定义事件监听器
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
on(event, callback) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, []);
}
this.eventListeners.get(event).push(callback);
}
/**
* 移除事件监听器
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
off(event, callback) {
if (this.eventListeners.has(event)) {
const listeners = this.eventListeners.get(event);
const index = listeners.indexOf(callback);
if (index > -1) {
listeners.splice(index, 1);
}
}
}
/**
* 触发事件
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*/
emit(event, data) {
if (this.eventListeners.has(event)) {
this.eventListeners.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`事件处理器执行失败 [${event}]:`, error);
}
});
}
}
/**
* 验证更新参数
* @param {Object} updates - 更新参数
* @returns {Object} 验证后的参数
*/
validateUpdateParams(updates) {
const validUpdates = {};
// 验证金额
if (updates.amount !== undefined) {
const amount = parseFloat(updates.amount);
if (isNaN(amount) || amount <= 0) {
throw new Error('金额必须是大于0的数字');
}
validUpdates.amount = amount.toFixed(2);
}
// 验证国家
if (updates.tradeCountry !== undefined) {
if (!updates.tradeCountry || typeof updates.tradeCountry !== 'string') {
throw new Error('国家代码必须是有效的字符串');
}
validUpdates.tradeCountry = updates.tradeCountry;
}
// 验证货币
if (updates.currency !== undefined) {
if (!updates.currency || typeof updates.currency !== 'string') {
throw new Error('货币代码必须是有效的字符串');
}
validUpdates.currency = updates.currency;
}
return validUpdates;
}
/**
* 获取 SDK 版本信息
* @returns {string} SDK 版本
*/
getVersion() {
return typeof PingPong !== 'undefined' ? PingPong.version : 'unknown';
}
/**
* 检查 SDK 是否可用
* @returns {boolean} SDK 是否可用
*/
isSDKAvailable() {
return typeof PingPong !== 'undefined' &&
typeof PingPong.Checkout !== 'undefined';
}
/**
* 销毁服务
*/
destroy() {
this.eventListeners.clear();
this.isInitialized = false;
console.log('🔧 PingPong 服务已销毁');
}
}
// 创建单例实例
export const pingpongService = new PingPongService();
export default pingpongService;/**
* UI 工具函数
* 负责界面操作和状态管理
*/
import { ERROR_MESSAGES, APP_CONSTANTS } from '../config/app-config.js';
/**
* 消息管理器
*/
export class MessageManager {
constructor() {
this.activeMessages = new Set();
}
/**
* 显示消息
* @param {string} message - 消息内容
* @param {string} type - 消息类型 (error, success, warning, info)
* @param {number} duration - 显示时长(毫秒)
*/
show(message, type = 'info', duration = null) {
// 移除现有消息
this.clear();
// 创建消息元素
const messageEl = document.createElement('div');
messageEl.className = `message ${type}`;
messageEl.textContent = message;
// 设置 ARIA 属性
messageEl.setAttribute('role', 'alert');
messageEl.setAttribute('aria-live', 'polite');
// 插入到页面
const container = document.querySelector('.checkout-container');
if (container) {
container.insertBefore(messageEl, container.firstChild);
} else {
document.body.insertBefore(messageEl, document.body.firstChild);
}
this.activeMessages.add(messageEl);
// 自动移除
const timeout = duration || this.getDefaultDuration(type);
setTimeout(() => {
this.remove(messageEl);
}, timeout);
return messageEl;
}
/**
* 移除消息
* @param {HTMLElement} messageEl - 消息元素
*/
remove(messageEl) {
if (messageEl && messageEl.parentNode) {
messageEl.parentNode.removeChild(messageEl);
this.activeMessages.delete(messageEl);
}
}
/**
* 清除所有消息
*/
clear() {
this.activeMessages.forEach(messageEl => {
this.remove(messageEl);
});
}
/**
* 获取默认显示时长
* @param {string} type - 消息类型
* @returns {number} 时长(毫秒)
*/
getDefaultDuration(type) {
return APP_CONSTANTS.TIMEOUTS.MESSAGE_DISPLAY[type.toUpperCase()] || 3000;
}
/**
* 显示错误消息
* @param {string} message - 错误消息
* @param {number} duration - 显示时长
*/
error(message, duration) {
return this.show(message, 'error', duration);
}
/**
* 显示成功消息
* @param {string} message - 成功消息
* @param {number} duration - 显示时长
*/
success(message, duration) {
return this.show(message, 'success', duration);
}
/**
* 显示警告消息
* @param {string} message - 警告消息
* @param {number} duration - 显示时长
*/
warning(message, duration) {
return this.show(message, 'warning', duration);
}
/**
* 显示信息消息
* @param {string} message - 信息消息
* @param {number} duration - 显示时长
*/
info(message, duration) {
return this.show(message, 'info', duration);
}
}
/**
* 加载状态管理器
*/
export class LoadingManager {
constructor() {
this.loadingOverlay = null;
this.loadingButtons = new Map();
}
/**
* 显示全屏加载
* @param {string} message - 加载消息
*/
showGlobal(message = 'Loading...') {
if (this.loadingOverlay) return;
this.loadingOverlay = document.createElement('div');
this.loadingOverlay.className = 'loading-overlay';
this.loadingOverlay.innerHTML = `
<div class="loading-content">
<div class="spinner"></div>
<p>${message}</p>
</div>
`;
document.body.appendChild(this.loadingOverlay);
}
/**
* 隐藏全屏加载
*/
hideGlobal() {
if (this.loadingOverlay && this.loadingOverlay.parentNode) {
this.loadingOverlay.parentNode.removeChild(this.loadingOverlay);
this.loadingOverlay = null;
}
}
/**
* 显示按钮加载状态
* @param {HTMLElement} button - 按钮元素
* @param {string} loadingText - 加载时显示的文本
*/
showButtonLoading(button, loadingText = 'Processing...') {
if (!button || this.loadingButtons.has(button)) return;
// 保存原始状态
const originalText = button.textContent;
const originalDisabled = button.disabled;
// 设置加载状态
button.disabled = true;
button.textContent = loadingText;
button.classList.add('loading');
// 保存状态
this.loadingButtons.set(button, {
originalText,
originalDisabled,
loadingText
});
}
/**
* 隐藏按钮加载状态
* @param {HTMLElement} button - 按钮元素
*/
hideButtonLoading(button) {
if (!button || !this.loadingButtons.has(button)) return;
const savedState = this.loadingButtons.get(button);
// 恢复原始状态
button.disabled = savedState.originalDisabled;
button.textContent = savedState.originalText;
button.classList.remove('loading');
// 清除保存的状态
this.loadingButtons.delete(button);
}
/**
* 隐藏所有按钮加载状态
*/
hideAllButtonLoading() {
this.loadingButtons.forEach((_, button) => {
this.hideButtonLoading(button);
});
}
}
/**
* 表单验证工具
*/
export class FormValidator {
constructor() {
this.rules = new Map();
}
/**
* 添加验证规则
* @param {string} fieldId - 字段ID
* @param {Array} rules - 验证规则数组
*/
addRule(fieldId, rules) {
this.rules.set(fieldId, rules);
}
/**
* 验证单个字段
* @param {string} fieldId - 字段ID
* @returns {Object} 验证结果
*/
validateField(fieldId) {
const field = document.getElementById(fieldId);
if (!field) {
return { valid: false, error: '字段不存在' };
}
const rules = this.rules.get(fieldId) || [];
const value = field.value.trim();
for (const rule of rules) {
const result = rule(value, field);
if (!result.valid) {
return result;
}
}
return { valid: true };
}
/**
* 验证整个表单
* @param {Array} fieldIds - 要验证的字段ID数组
* @returns {Object} 验证结果
*/
validateForm(fieldIds = []) {
const errors = [];
const validFields = [];
for (const fieldId of fieldIds) {
const result = this.validateField(fieldId);
if (result.valid) {
validFields.push(fieldId);
} else {
errors.push(`${fieldId}: ${result.error}`);
}
}
return {
isValid: errors.length === 0,
errors,
validFields
};
}
/**
* 清除字段错误状态
* @param {string} fieldId - 字段ID
*/
clearFieldError(fieldId) {
const field = document.getElementById(fieldId);
if (field) {
field.classList.remove('error');
const errorEl = field.parentNode.querySelector('.field-error');
if (errorEl) {
errorEl.remove();
}
}
}
/**
* 显示字段错误
* @param {string} fieldId - 字段ID
* @param {string} error - 错误信息
*/
showFieldError(fieldId, error) {
const field = document.getElementById(fieldId);
if (!field) return;
// 添加错误样式
field.classList.add('error');
// 移除现有错误信息
this.clearFieldError(fieldId);
// 添加错误信息
const errorEl = document.createElement('div');
errorEl.className = 'field-error';
errorEl.textContent = error;
errorEl.setAttribute('role', 'alert');
field.parentNode.appendChild(errorEl);
}
}
/**
* 通用验证规则
*/
export const ValidationRules = {
required: (message = '此字段为必填项') => (value) => ({
valid: value.length > 0,
error: message
}),
min: (min, message) => (value) => {
const num = parseFloat(value);
return {
valid: !isNaN(num) && num >= min,
error: message || `值不能小于 ${min}`
};
},
max: (max, message) => (value) => {
const num = parseFloat(value);
return {
valid: !isNaN(num) && num <= max,
error: message || `值不能大于 ${max}`
};
},
email: (message = '请输入有效的邮箱地址') => (value) => ({
valid: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
error: message
}),
minLength: (min, message) => (value) => ({
valid: value.length >= min,
error: message || `长度不能少于 ${min} 个字符`
}),
maxLength: (max, message) => (value) => ({
valid: value.length <= max,
error: message || `长度不能超过 ${max} 个字符`
}),
pattern: (regex, message) => (value) => ({
valid: regex.test(value),
error: message || '格式不正确'
})
};
/**
* 防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function} 防抖后的函数
*/
export function debounce(func, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
/**
* 格式化货币
* @param {number} amount - 金额
* @param {string} currency - 货币代码
* @param {string} locale - 地区代码
* @returns {string} 格式化后的货币
*/
export function formatCurrency(amount, currency = 'USD', locale = 'en-US') {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(amount);
}
// 创建单例实例
export const messageManager = new MessageManager();
export const loadingManager = new LoadingManager();
export const formValidator = new FormValidator();
export default {
messageManager,
loadingManager,
formValidator,
ValidationRules,
debounce,
formatCurrency
};/**
* 主入口文件
* 负责应用初始化和模块协调
*/
import { pingpongService } from './services/pingpong-service.js';
import {
messageManager,
loadingManager,
formValidator,
debounce,
ValidationRules
} from './utils/ui-utils.js';
import {
PINGPONG_CONFIG,
API_ENDPOINTS,
COUNTRY_CURRENCY_MAP,
ERROR_MESSAGES,
APP_CONSTANTS,
DEV_CONFIG
} from './config/app-config.js';
/**
* 主应用类
*/
class CheckoutApp {
constructor() {
this.isInitialized = false;
this.currentOrder = null;
this.paymentInProgress = false;
}
/**
* 初始化应用
*/
async initialize() {
try {
console.log('🚀 开始初始化 Checkout 应用...');
// 设置表单验证规则
this.setupFormValidation();
// 初始化 PingPong SDK
await this.initializePingPong();
// 设置事件监听器
this.setupEventListeners();
// 设置支付 Hooks
this.setupPaymentHooks();
this.isInitialized = true;
messageManager.success('收银台初始化完成');
console.log('✅ Checkout 应用初始化完成');
} catch (error) {
console.error('❌ 应用初始化失败:', error);
messageManager.error(`初始化失败: ${error.message}`);
}
}
/**
* 设置表单验证规则
*/
setupFormValidation() {
// 金额验证
formValidator.addRule('amount', [
ValidationRules.required('请输入支付金额'),
ValidationRules.min(APP_CONSTANTS.VALIDATION.MIN_AMOUNT, '金额不能小于 0.01'),
ValidationRules.max(APP_CONSTANTS.VALIDATION.MAX_AMOUNT, '金额不能大于 999,999.99')
]);
// 国家验证
formValidator.addRule('country', [
ValidationRules.required('请选择国家')
]);
// 优惠券验证(可选)
formValidator.addRule('coupon', [
ValidationRules.minLength(APP_CONSTANTS.VALIDATION.COUPON_LENGTH_MIN, '优惠券代码至少3个字符'),
ValidationRules.maxLength(APP_CONSTANTS.VALIDATION.COUPON_LENGTH_MAX, '优惠券代码不能超过50个字符')
]);
}
/**
* 初始化 PingPong SDK
*/
async initializePingPong() {
try {
// 获取 SDK 访问令牌
const accessToken = await this.getSDKAccessToken();
// 初始化配置
const config = {
...PINGPONG_CONFIG,
sdkAccessToken: accessToken
};
await pingpongService.initialize(config);
} catch (error) {
console.error('PingPong 初始化失败:', error);
throw new Error('收银台初始化失败,请刷新页面重试');
}
}
/**
* 设置事件监听器
*/
setupEventListeners() {
// 金额输入事件
const amountInput = document.getElementById('amount');
if (amountInput) {
amountInput.addEventListener('input',
debounce(this.handleAmountChange.bind(this),
APP_CONSTANTS.DEBOUNCE_DELAY.AMOUNT_INPUT)
);
}
// 国家选择事件
const countrySelect = document.getElementById('country');
if (countrySelect) {
countrySelect.addEventListener('change',
this.handleCountryChange.bind(this)
);
}
// 优惠券输入事件
const couponInput = document.getElementById('coupon');
if (couponInput) {
couponInput.addEventListener('input',
debounce(this.handleCouponChange.bind(this),
APP_CONSTANTS.DEBOUNCE_DELAY.COUPON_INPUT)
);
}
// 支付按钮点击事件
const payButton = document.getElementById('customPay');
if (payButton) {
payButton.addEventListener('click',
this.handlePaymentClick.bind(this)
);
}
// PingPong 事件监听
pingpongService.on('ready', this.handlePingPongReady.bind(this));
pingpongService.on('error', this.handlePingPongError.bind(this));
console.log('✅ 事件监听器设置完成');
}
/**
* 设置支付 Hooks
*/
setupPaymentHooks() {
// 支付前 Hook
pingpongService.setBeforeCheckoutHook(async ({payMethod}) => {
console.log('🔍 开始支付前验证...');
console.log('💳 用户选择的支付方式:', payMethod);
// 表单验证
const validation = formValidator.validateForm(['amount', 'country']);
if (!validation.isValid) {
throw new Error(validation.errors.join('; '));
}
// 显示加载状态
this.paymentInProgress = true;
loadingManager.showButtonLoading(
document.getElementById('customPay'),
'Processing Payment...'
);
try {
// 库存检查
await this.checkInventory();
// 创建订单
const orderToken = await this.createOrder();
console.log('✅ 支付前验证完成');
return orderToken;
} catch (error) {
this.paymentInProgress = false;
loadingManager.hideAllButtonLoading();
throw error;
}
});
// 支付失败 Hook
pingpongService.setCheckoutFailedHook((code, message) => {
console.error(`💳 支付失败 [${code}]: ${message}`);
this.paymentInProgress = false;
loadingManager.hideAllButtonLoading();
// 显示用户友好的错误信息
const userMessage = ERROR_MESSAGES[code] || message || '支付失败,请重试';
messageManager.error(userMessage);
// 记录错误日志
this.logPaymentError(code, message);
});
console.log('✅ 支付 Hooks 设置完成');
}
/**
* 处理金额变更
* @param {Event} event - 输入事件
*/
handleAmountChange(event) {
const amount = event.target.value;
try {
pingpongService.updateCheckout({ amount });
// 清除字段错误
formValidator.clearFieldError('amount');
} catch (error) {
console.error('金额更新失败:', error);
formValidator.showFieldError('amount', '金额格式不正确');
}
}
/**
* 处理国家变更
* @param {Event} event - 选择事件
*/
handleCountryChange(event) {
const country = event.target.value;
const currency = COUNTRY_CURRENCY_MAP[country];
try {
pingpongService.updateCheckout({
tradeCountry: country,
currency: currency
});
// 更新货币显示
this.updateCurrencyDisplay(currency);
// 清除字段错误
formValidator.clearFieldError('country');
} catch (error) {
console.error('国家更新失败:', error);
formValidator.showFieldError('country', '国家选择不正确');
}
}
/**
* 处理优惠券变更
* @param {Event} event - 输入事件
*/
async handleCouponChange(event) {
const couponCode = event.target.value.trim();
if (!couponCode) {
formValidator.clearFieldError('coupon');
return;
}
try {
await this.validateCoupon(couponCode);
formValidator.clearFieldError('coupon');
} catch (error) {
console.error('优惠券验证失败:', error);
formValidator.showFieldError('coupon', '优惠券无效');
}
}
/**
* 处理支付按钮点击
* @param {Event} event - 点击事件
*/
async handlePaymentClick(event) {
event.preventDefault();
if (this.paymentInProgress) {
return;
}
try {
// 表单验证
const validation = formValidator.validateForm(['amount', 'country']);
if (!validation.isValid) {
messageManager.error(validation.errors.join('; '));
return;
}
// 启动支付
await pingpongService.startPayment();
} catch (error) {
console.error('支付启动失败:', error);
messageManager.error('支付启动失败,请重试');
}
}
/**
* 处理 PingPong 初始化完成
*/
handlePingPongReady() {
console.log('🎉 PingPong SDK 准备就绪');
// 启用支付按钮
const payButton = document.getElementById('customPay');
if (payButton) {
payButton.disabled = false;
}
}
/**
* 处理 PingPong 初始化错误
* @param {*} error - 错误信息
*/
handlePingPongError(error) {
console.error('💥 PingPong SDK 初始化错误:', error);
// 禁用支付按钮
const payButton = document.getElementById('customPay');
if (payButton) {
payButton.disabled = true;
}
messageManager.error(`收银台初始化失败: ${error.message || '未知错误'}`);
}
/**
* 获取 SDK 访问令牌
* @returns {Promise<string>} 访问令牌
*/
async getSDKAccessToken() {
try {
const response = await fetch(API_ENDPOINTS.getAccessToken, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`获取访问令牌失败: ${response.status}`);
}
const result = await response.json();
return result.token;
} catch (error) {
console.error('获取 SDK 访问令牌失败:', error);
// 开发环境使用模拟令牌
if (DEV_CONFIG.isDevelopment) {
return 'mock-sdk-access-token';
}
throw error;
}
}
/**
* 验证优惠券
* @param {string} couponCode - 优惠券代码
*/
async validateCoupon(couponCode) {
try {
const response = await fetch(API_ENDPOINTS.validateCoupon, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ couponCode })
});
if (!response.ok) {
throw new Error('优惠券验证失败');
}
const result = await response.json();
if (result.valid) {
messageManager.success(`优惠券有效,已享受${result.discount * 100}%折扣`);
// 更新金额
const currentAmount = parseFloat(document.getElementById('amount').value);
const newAmount = (currentAmount * (1 - result.discount)).toFixed(2);
pingpongService.updateCheckout({ amount: newAmount });
document.getElementById('amount').value = newAmount;
} else {
throw new Error(result.message || '优惠券无效');
}
} catch (error) {
// 开发环境模拟
if (DEV_CONFIG.isDevelopment && couponCode === DEV_CONFIG.mockCouponCode) {
messageManager.success('优惠券有效,已享受10%折扣');
const currentAmount = parseFloat(document.getElementById('amount').value);
const newAmount = (currentAmount * 0.9).toFixed(2);
pingpongService.updateCheckout({ amount: newAmount });
document.getElementById('amount').value = newAmount;
return;
}
throw error;
}
}
/**
* 检查库存
*/
async checkInventory() {
try {
const response = await fetch(API_ENDPOINTS.validateInventory, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
items: this.getCartItems()
})
});
if (!response.ok) {
throw new Error('库存检查失败');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '库存不足');
}
console.log('📦 库存检查通过');
} catch (error) {
console.error('库存检查异常:', error);
// 开发环境跳过库存检查
if (DEV_CONFIG.isDevelopment) {
console.log('📦 开发环境跳过库存检查');
return;
}
throw error;
}
}
/**
* 创建订单
* @returns {Promise<string>} 订单令牌
*/
async createOrder() {
try {
const orderData = {
amount: document.getElementById('amount').value,
currency: COUNTRY_CURRENCY_MAP[document.getElementById('country').value],
tradeCountry: document.getElementById('country').value,
couponCode: document.getElementById('coupon').value,
items: this.getCartItems(),
customerInfo: this.getCustomerInfo()
};
const response = await fetch(API_ENDPOINTS.createOrder, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(orderData)
});
if (!response.ok) {
throw new Error('订单创建失败');
}
const result = await response.json();
if (result.success && result.token) {
this.currentOrder = result;
console.log('📝 订单创建成功');
return result.token;
} else {
throw new Error(result.message || '订单创建失败');
}
} catch (error) {
console.error('订单创建异常:', error);
// 开发环境返回模拟令牌
if (DEV_CONFIG.isDevelopment) {
return 'mock-order-token-' + Date.now();
}
throw error;
}
}
/**
* 获取购物车商品信息
* @returns {Array} 商品列表
*/
getCartItems() {
// 这里应该从实际的购物车组件获取商品信息
return [
{
id: 'item_001',
name: '示例商品',
quantity: 1,
price: '1.08'
}
];
}
/**
* 获取客户信息
* @returns {Object} 客户信息
*/
getCustomerInfo() {
// 这里应该从实际的表单获取客户信息
return {
email: 'customer@example.com',
name: 'Customer Name'
};
}
/**
* 更新货币显示
* @param {string} currency - 货币代码
*/
updateCurrencyDisplay(currency) {
const currencyElements = document.querySelectorAll('[data-currency]');
currencyElements.forEach(element => {
element.textContent = currency;
element.setAttribute('data-currency', currency);
});
}
/**
* 记录支付错误日志
* @param {string} code - 错误代码
* @param {string} message - 错误信息
*/
async logPaymentError(code, message) {
const errorLog = {
timestamp: new Date().toISOString(),
errorCode: code,
errorMessage: message,
userAgent: navigator.userAgent,
orderId: this.currentOrder?.id,
amount: document.getElementById('amount')?.value,
currency: COUNTRY_CURRENCY_MAP[document.getElementById('country')?.value],
url: window.location.href
};
try {
await fetch(API_ENDPOINTS.logError, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(errorLog)
});
} catch (error) {
console.error('日志记录失败:', error);
}
}
/**
* 销毁应用
*/
destroy() {
this.paymentInProgress = false;
pingpongService.destroy();
messageManager.clear();
loadingManager.hideAllButtonLoading();
this.isInitialized = false;
console.log('🔧 Checkout 应用已销毁');
}
}
// 创建应用实例
const app = new CheckoutApp();
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', async () => {
try {
await app.initialize();
} catch (error) {
console.error('应用启动失败:', error);
messageManager.error('应用启动失败,请刷新页面重试');
}
});
// 页面卸载时清理
window.addEventListener('beforeunload', () => {
app.destroy();
});
// 导出应用实例(用于调试)
if (DEV_CONFIG.isDevelopment) {
window.checkoutApp = app;
window.pingpongService = pingpongService;
window.messageManager = messageManager;
}常见问题
Q1: 为什么我的结账流程无法启动,系统提示 beforeCheckoutHook 相关错误?
A: 当系统提示 "The beforeCheckoutHook is not defined or is not a function" 时,表明您的 one-page sdk 配置中缺少有效的 beforeCheckoutHook 函数。请确保在配置中正确定义此函数,它是启动结账流程的必要条件。
Q2: 我已定义 beforeCheckoutHook 函数,但仍收到 token 相关错误,如何解决?
A: 错误 "Invalid or missing token returned by beforeCheckoutHook" 表明您的 beforeCheckoutHook 函数未返回有效的 token。请确保该函数返回一个非空字符串类型的 token,这是支付处理的必要凭证。
Q3: 创建支付时,系统提示 amount 参数错误,应如何处理?
A: 当系统显示 "Invalid or missing amount: must be a non-empty string" 时,表明您提供的 amount 参数不符合要求。请确保 amount 参数是非空字符串类型,例如 "10.99"。
Q4: 如何确保我使用的货币代码有效?
A: 系统支持特定的货币代码列表。如果收到 "Invalid or missing currency" 错误,请检查您提供的货币代码是否在支持列表中。常用的代码包括 "USD"、"EUR"、"CNY" 等。完整列表请参考 API 文档。
Q5: sdkAccessToken 验证失败的原因是什么?
A: 错误 "Invalid or missing sdkAccessToken" 表明您提供的 sdkAccessToken 无效或缺失。请确保提供一个有效的、非空字符串类型的 sdkAccessToken,这是身份验证的关键凭证。
Q6: 配置 paymentMethods 时需要注意什么?
A: 如果您选择配置 paymentMethods 参数,必须确保它是数组类型。若收到 "Invalid paymentMethods: must be an array or undefined" 错误,请检查您提供的值是否为数组,或考虑完全移除此参数以使用默认支付方式。
Q7: 如何确保 one-page sdk 配置的正确性?
A: 建议在实施前全面测试您的配置。特别注意:
- beforeCheckoutHook 函数必须正确定义并返回有效 token
- 所有必需参数(amount、currency、sdkAccessToken)必须符合类型要求
- 可选参数如 paymentMethods 必须符合预期格式
Q8: 支付流程中断时如何快速排查问题?
A: 请检查控制台错误信息,它通常会精确指出问题所在。根据错误信息对照本 FAQ 进行相应调整。如果问题持续,请确保您使用的是最新版本的 SDK,并查阅完整的技术文档。
