SDK 使用方法
SDK 负责两件事:上报(指纹/页面/表单/订单号与购物车)与接收指令(轮询管理端下发的验证命令并渲染弹窗)。本文覆盖全部公开方法与工程化接入的最佳实践。
核心接入原则与网络架构
1. 同源反向代理要求(必读)
SDK 内部请求路径统一为相对路径:
- 上报端点:
/api/shopper/session/* - 动态脚本分发端点:
/api/sdk/:apiKey.js
必须由商城侧网关或开发服务器将 /api/shopper/** 与 /api/sdk/** 反向代理到 Session Control 服务端(如 http://localhost:3000)。
⚠️ 常见反代踩坑(前缀剥离问题)
很多现代框架(Nuxt Nitro、Vite、Next.js、Nginx)的代理配置默认会**剥离(Strip)**匹配的路径前缀。因为 Session Control 服务端默认自带全局 /api 前缀,如果代理剥离了前缀,请求到后端会变成 /session/init,从而引发 404 Not Found (Cannot POST /session/init)。
各环境反向代理配置示例
typescript
// nuxt.config.ts
const scTarget = process.env.NUXT_SESSION_CONTROL_TARGET || 'http://localhost:3000'
const mallApiTarget = process.env.NUXT_API_PROXY_TARGET || 'http://localhost:18000'
export default defineNuxtConfig({
nitro: {
devProxy: {
// ⚠️ target 必须补全完整前缀 /api/shopper 与 /api/sdk,防止前缀被剥离
'/api/shopper': { target: `${scTarget}/api/shopper`, changeOrigin: true },
'/api/sdk': { target: `${scTarget}/api/sdk`, changeOrigin: true },
'/api': { target: mallApiTarget, changeOrigin: true, prependPath: false },
},
},
routeRules: {
'/api/shopper/**': { proxy: `${scTarget}/api/shopper/**` },
'/api/sdk/**': { proxy: `${scTarget}/api/sdk/**` },
'/api/**': { proxy: `${mallApiTarget}/**` },
},
})typescript
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api/shopper': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/api/sdk': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:18000',
changeOrigin: true,
},
},
},
})javascript
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/shopper/:path*',
destination: 'http://localhost:3000/api/shopper/:path*',
},
{
source: '/api/sdk/:path*',
destination: 'http://localhost:3000/api/sdk/:path*',
},
{
source: '/api/:path*',
destination: 'http://localhost:18000/api/:path*',
},
]
},
}nginx
# nginx.conf
location /api/shopper/ {
proxy_pass http://session_control_upstream/api/shopper/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/sdk/ {
proxy_pass http://session_control_upstream/api/sdk/;
proxy_set_header Host $host;
}
location /api/ {
proxy_pass http://mall_backend_upstream/;
proxy_set_header Host $host;
}2. 真实订单号与建单时序机制
为了让会话控制管理端记录的订单号、购物车商品清单与商城实际生成的订单100% 对齐,必须遵循以下建单时序:
买家在结算页点击「提交订单」
│
▼
调用 SessionControl.verify()
│
├─► 1. SDK 内部自动执行传入的 orderProvider()
│ └─► 调用商城后端建单接口 (POST /order/create 或 POST /member/checkout)
│ └─► 获取商城真实订单号 (如 OR20260831095817834816774) 及商品清单
│
├─► 2. SDK 自动发送 POST /api/shopper/session/order
│ └─► 将真实商户订单号与商品清单绑定到指纹会话
│
├─► 3. SDK 自动发送 POST /api/shopper/session/verify
│ └─► 会话进入 PENDING_REVIEW 待审核态,SDK 自动弹出等待/验证弹窗
▼
操作员在管理端接管并核验真实订单号、金额与卡信息,并下发验证指令 (OTP/手机/邮箱/App)
│
▼
操作员在管理端点击「完成订单」
│
▼
SDK 触发 onComplete({ orderNo }) 回调
│
└─► 商城前端清空购物车,直接跳转真实订单详情页 (如 /order/:orderNo 或 /account/orders)⚠️ 严禁在
orderProvider里传递本地临时 UUID!必须调用商城真实建单接口获取真正的订单编号,确保两端订单号完全一致。
3. 表单字段与金额格式规范
支付方式必填字段矩阵
调用 verify() 进入审核流前,后端会对表单字段进行完整性校验。如果缺失关键字段,接口将返回 400 校验错误:
支付方式 (paymentMethod) | 必填字段 | 推荐补充字段 | 校验规则说明 |
|---|---|---|---|
信用卡 (credit_card) | cardNumber (卡号) | expiryDate (MM/YY)、cvv、cardHolder、billingAddress | 必须提供卡号且不能全为空格;弹窗与操作员核验需依据卡组织与卡信息。 |
PayPal (paypal) | paypalEmail (PayPal 邮箱) | paypalAccount、shippingAddress | 必须提供有效的 PayPal 邮箱。 |
⚠️ 金额格式规范 (Amount Format)
- 推荐格式:标准货币符号数值字符串(如
"$159.00"、"¥2,228.00")或纯数字(如159.00、2228)。 - 避坑事项:请勿附加英文币种单词后缀(例如:
"$159.00 USD"或"159 CNY")。- 原因:管理端金额解析引擎会过滤货币符号(
$,¥,€,£),若带有USD英文单词会导致数值转换结果为NaN,使历史订单详情中的「订单金额」回退显示为—。
- 原因:管理端金额解析引擎会过滤货币符号(
typescript
// ✅ 正确示例
{
amount: `$${total.toFixed(2)}`, // "$159.00"
total: `$${total.toFixed(2)}`, // "$159.00"
orderTotal: `$${total.toFixed(2)}`, // "$159.00"
}
// ❌ 错误示例 (带有英文字母后缀)
{
amount: "$159.00 USD", // 导致管理端解析为 NaN 显示为 '—'
}4. 加载方式与 API 方法总览
加载方式(二选一)
方式 A:平台分发(推荐,免构建)
html
<script src="/api/sdk/{site_api_key}.js"></script>方式 B:npm 包(模块化构建)
typescript
import { SessionControl } from '@session/sdk'公开方法一览 (7 个核心方法)
| 方法 | 作用 | 必调 | 调用时机 |
|---|---|---|---|
init(config) | 初始化:生成指纹、建立会话、自动上报当前页面 | ✅ | 页面加载完成时,await 后再调其他方法 |
reportPageView(url, pageName?) | 手动上报页面 | SPA 路由变更 / location.href 跳转时(SDK 大部分场景自动监听) | |
reportFormData(data) | 上报表单数据(内置防抖 500ms) | ✅ | 表单 input/change 事件直接调 |
reportCart(orderNo, items) | 上报订单号与购物车 | 配了 orderProvider 时 SDK 自动调;未配则建单后自行调 | |
verify() | 提交订单,进入人工验证流程 | ✅ | 用户点击「提交订单」时 |
submitVerification(commandId, value) | 提交验证码/整值 | 自绘验证 UI 时;内置弹窗自动调用 | |
destroy() | 销毁:停止上报与轮询 | SPA 离开结算页/组件卸载时 |
5. 前端框架最佳实践示例
Vue 3 / Nuxt 3/4 接入 Composable
typescript
import { SessionControl, type ActiveCommand, type CartItem, type FormDataPayload, type SDKError } from '@session/sdk'
export interface SessionControlOptions {
siteKey?: string
pageName?: string
merchantName?: string
orderProvider?: () => Promise<{ orderNo: string; cartItems?: CartItem[] } | string>
formProvider?: () => FormDataPayload | Promise<FormDataPayload>
formDebounceMs?: number
onComplete?: (result: { orderNo?: string }) => void
onCommand?: (command: ActiveCommand) => void
onError?: (error: SDKError) => void
}
export function useSessionControl() {
const config = useRuntimeConfig()
const defaultSiteKey = (config.public.sessionControlSiteKey as string) || ''
const isReady = useState<boolean>('sc_ready', () => false)
const isReviewing = useState<boolean>('sc_reviewing', () => false)
const init = async (options: SessionControlOptions = {}) => {
// ⚠️ SSR 安全防守:严禁在服务端运行
if (import.meta.server) return
const siteKey = options.siteKey || defaultSiteKey
if (!siteKey) return
try {
await SessionControl.init({
siteKey,
pageName: options.pageName ?? 'Checkout',
merchantName: options.merchantName ?? 'My Store',
orderProvider: options.orderProvider,
formProvider: options.formProvider,
formDebounceMs: options.formDebounceMs,
onComplete: (result) => {
isReviewing.value = false
options.onComplete?.(result)
},
onCommand: (command) => {
options.onCommand?.(command)
},
onError: (err) => {
options.onError?.(err)
},
})
isReady.value = true
} catch {
// 容错降级:初始化失败不阻塞商城业务
isReady.value = false
}
}
const reportFormData = (data: FormDataPayload) => {
if (import.meta.server || !isReady.value) return
try { SessionControl.reportFormData(data) } catch {}
}
const verify = async (): Promise<{ action: string }> => {
if (import.meta.server || !isReady.value) {
return { action: 'fallback' }
}
try {
const res = await SessionControl.verify()
if (res?.action === 'loading') {
isReviewing.value = true
}
return res || { action: 'fallback' }
} catch {
return { action: 'fallback' }
}
}
const destroy = () => {
if (import.meta.server || !isReady.value) return
try {
SessionControl.destroy()
isReady.value = false
isReviewing.value = false
} catch {}
}
return { isReady, isReviewing, init, reportFormData, verify, destroy }
}vue
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useSessionControl } from '~/composables/useSessionControl'
// 1. 响应式表单状态声明(注意定义顺序,避免 TDZ 错误)
const form = ref({
email: '',
firstName: '',
lastName: '',
address1: '',
city: '',
zipcode: '',
country: 'US',
payWay: 'credit_card',
cardNumber: '',
expiryDate: '',
cvv: '',
})
const submitting = ref(false)
const createdOrderResult = ref<{ orderNo: string } | null>(null)
const { init: initSC, reportFormData: reportSCForm, verify: verifySC, destroy: destroySC } = useSessionControl()
// 2. 收集规范化表单数据
function collectFormData() {
const totalStr = `$${totalAmount.value.toFixed(2)}`
return {
paymentMethod: form.value.payWay === 'paypal' ? 'paypal' as const : 'credit_card' as const,
cardNumber: form.value.cardNumber.replace(/\s+/g, ''),
cardHolder: `${form.value.firstName} ${form.value.lastName}`.trim(),
expiryDate: form.value.expiryDate,
cvv: form.value.cvv,
amount: totalStr,
total: totalStr,
email: form.value.email,
shippingAddress: {
line1: form.value.address1,
city: form.value.city,
postalCode: form.value.zipcode,
country: form.value.country,
},
}
}
// 3. 真实建单接口(供 orderProvider 调用)
async function createStoreOrder() {
if (createdOrderResult.value?.orderNo) {
return {
orderNo: createdOrderResult.value.orderNo,
cartItems: cartLines.value.map(l => ({ name: l.name, price: l.price, quantity: l.qty })),
}
}
const res = await callStoreBackendCheckoutApi(...)
createdOrderResult.value = res.data
return {
orderNo: res.data.orderNo,
cartItems: cartLines.value.map(l => ({ name: l.name, price: l.price, quantity: l.qty })),
}
}
// 4. 挂载时初始化 SDK
onMounted(async () => {
await initSC({
pageName: 'Checkout',
merchantName: 'My Store',
formProvider: collectFormData,
orderProvider: createStoreOrder,
onComplete: async (res) => {
// 审核完成:跳转真实订单详情页
const finalOrderNo = res?.orderNo || createdOrderResult.value?.orderNo
await navigateTo(`/order/${finalOrderNo}`)
},
})
})
// 5. 监听表单输入变化并上报(SDK 内置 500ms 防抖)
watch(form, () => { reportSCForm(collectFormData()) }, { deep: true })
// 6. 提交订单入口
async function submitOrder() {
if (submitting.value) return
submitting.value = true
try {
const verifyRes = await verifySC()
// 情况 1: 进入人工审核,SDK 弹窗接管,等待 onComplete
if (verifyRes.action === 'loading') return
// 情况 2: 被拒卡,SDK 已弹出提示
if (verifyRes.action === 'rejected') { submitting.value = false; return }
// 情况 3: 降级/直接放行
if (!createdOrderResult.value) await createStoreOrder()
await navigateTo(`/order/${createdOrderResult.value.orderNo}`)
} catch {
// 异常安全降级
if (!createdOrderResult.value) await createStoreOrder()
await navigateTo(`/order/${createdOrderResult.value.orderNo}`)
} finally {
submitting.value = false
}
}
onUnmounted(() => { destroySC() })
</script>6. 常见错误排查速查表 (Troubleshooting)
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
Cannot POST /session/init (404) | 前端反向代理剥离了前缀,请求发送到了 /session/init 而非 /api/shopper/session/init | 检查 nitro.devProxy / vite.proxy / nginx,确保 proxy 目标保留完整的 /api/shopper 路径。 |
400: 请先上报表单数据(卡号)再提交订单 | 支付方式为 credit_card 但表单中未填 cardNumber;或数据未在 verify() 前上报 | 确保表单传递了 cardNumber;在 init 中配置 formProvider 兜底拉取表单。 |
管理端订单详情中「订单金额」显示为 — | amount / total 字段传递了 $159.00 USD 等带有英文字母后缀的字符串 | 将金额格式化为 $159.00 或纯数字 159.00,去除多余的英文币种后缀。 |
| 管理端购物车弹窗「订单号有、商品为空」 | orderProvider 只返回了 { orderNo },没有返回 cartItems 数组 | 在 orderProvider 中返回 { orderNo, cartItems: [{ name, price, quantity }] }。 |
| 管理端订单号与商城后端订单号对不上 | orderProvider 传递了前端临时生成的 UUID,或者未调用商城真实建单接口 | orderProvider 必须调用商城后端的真实建单端点,返回真实商户订单号。 |
ReferenceError: Cannot access 'form' before initialization | Vue 3 setup 脚本中 watch(form, ...) 被写在了 const form = ref(...) 声明之前 (TDZ) | 调整变量声明顺序,确保响应式变量在 watch 或函数调用前完成初始化。 |