Skip to content

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 initializationVue 3 setup 脚本中 watch(form, ...) 被写在了 const form = ref(...) 声明之前 (TDZ)调整变量声明顺序,确保响应式变量在 watch 或函数调用前完成初始化。