@clink-test/js
v1.0.0
Published
Clink hosted checkout JS SDK (V1)
Downloads
19
Readme
Clink Embedded Checkout SDK
Clink 的 Checkout SDK:
- V1:
redirectToCheckout托管页跳转 - V2:
initEmbeddedCheckout嵌入式 checkout
安装
# 未来发布后
npm install @clink/js
# 或 pnpm add @clink/js
# 当前仓库内本地开发
npm --prefix packages/clink-js run test
npm --prefix packages/clink-js run buildAPI
loadClink(publicKey, options?)
import { loadClink } from '@clink/js';
const clink = await loadClink('pk_uat_xxx', {
checkoutEnvironment: 'uat',
locale: 'zh-CN',
});publicKey必须匹配:pk_test_*、pk_uat_*或pk_prod_*- 推荐通过
checkoutEnvironment让 SDK 自动请求对应环境的 Clink bootstrap,或直接传checkoutBaseUrl。 checkoutEnvironment可选:uat/live。checkoutBaseUrl可选:显式指定 Clink checkout 根地址,例如https://checkout.clinkbill.com。
clink.redirectToCheckout(params)(V1)
await clink.redirectToCheckout({
sessionParam: 'sess_123#token',
replace: false,
});行为规则:
sessionParam与sessionId至少传一个。- 如果两者都传,优先使用
sessionParam。 - 跳转地址规则:
{checkoutBaseUrl}/pay/{encodeURIComponent(sessionParam)}。 replace: true使用location.replace;默认location.assign。
clink.initEmbeddedCheckout(options)(V2)
import { loadClink } from '@clink/js';
const clink = await loadClink('pk_uat_xxx');
// 推荐显式传 checkoutEnvironment 或 checkoutBaseUrl
// const clink = await loadClink('pk_uat_xxx', { checkoutEnvironment: 'uat' });
const embedded = await clink.initEmbeddedCheckout({
fetchSession: async () => {
const resp = await fetch('/api/checkout/session', { method: 'POST' });
const data = await resp.json();
return {
checkoutUrl: data.checkoutUrl as string,
sessionId: data.sessionId as string,
orderId: data.orderId as string,
};
},
pollStatus: async ({ orderId }) => {
if (!orderId) return null;
const resp = await fetch(`/api/topup/status?order_id=${orderId}`);
const data = await resp.json();
if (data.credited) return 'success';
if (data.status === 'failed') return 'error';
if (data.status === 'refunded') return 'cancelled';
return 'pending';
},
onEvent(event) {
console.log('[embedded event]', event.type, event.payload);
},
// default true
autoDestroyOnComplete: true,
});
embedded.mount('#checkout');React 接入模板:
import { useEffect, useRef, useState } from 'react';
import {
CLINK_ERROR_CODES,
ClinkError,
loadClink,
type EmbeddedCheckout,
} from '@clink/js';
interface CheckoutPageProps {
publicKey: string;
}
export function CheckoutPage({ publicKey }: CheckoutPageProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const embeddedRef = useRef<EmbeddedCheckout | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function setup() {
try {
const clink = await loadClink(publicKey, {
checkoutEnvironment: 'live',
locale: 'en-US',
});
const embedded = await clink.initEmbeddedCheckout({
fetchSession: async () => {
const resp = await fetch('/api/checkout/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok) {
throw new Error('failed to create checkout session');
}
const data = (await resp.json()) as {
checkoutUrl: string;
sessionId: string;
orderId?: string;
};
return data;
},
pollStatus: async ({ orderId }) => {
if (!orderId) {
return null;
}
const resp = await fetch(`/api/topup/status?order_id=${orderId}`);
if (!resp.ok) {
return null;
}
const data = (await resp.json()) as {
credited?: boolean;
status?: string;
};
if (data.credited || data.status === 'paid') {
return 'success';
}
if (data.status === 'failed') {
return 'error';
}
if (data.status === 'refunded') {
return 'cancelled';
}
return 'pending';
},
onEvent(event) {
if (event.type === 'hosted_return') {
window.location.assign('/payment/result');
return;
}
if (event.type === 'complete' && event.payload?.state === 'success') {
window.location.assign('/payment/success');
return;
}
if (event.type === 'error') {
setError('Payment failed. Please try again.');
}
},
});
if (cancelled || !containerRef.current) {
embedded.destroy();
return;
}
embedded.mount(containerRef.current);
embeddedRef.current = embedded;
setLoading(false);
} catch (err) {
if (cancelled) {
return;
}
if (
err instanceof ClinkError &&
err.code === CLINK_ERROR_CODES.SESSION_ID_FETCH_FAILED
) {
setError('Unable to create checkout session.');
} else {
setError('Unable to load checkout.');
}
setLoading(false);
}
}
void setup();
return () => {
cancelled = true;
embeddedRef.current?.destroy();
embeddedRef.current = null;
};
}, [publicKey]);
return (
<div>
{loading ? <div>Loading checkout...</div> : null}
{error ? <div role="alert">{error}</div> : null}
<div ref={containerRef} id="checkout" />
</div>
);
}React 接入建议:
loadClink()和initEmbeddedCheckout()放在useEffect里做一次初始化。- 用
ref保存EmbeddedCheckout实例,组件卸载时调用destroy()。 - 业务终态建议监听
complete事件;商户自定义成功页/取消页回跳后的 UI 收口建议监听hosted_return。 fetchSession()必须返回{ checkoutUrl, sessionId, orderId? },其中checkoutUrl直接使用服务端createCheckoutSession()返回的url。- 默认情况下,支付成功触发
complete后 SDK 会自动销毁 iframe;如需保留 iframe,传autoDestroyOnComplete: false。 fetchSession()只调用你自己的后端,不要把 secret key 放到前端。
options:
fetchSession: 商户前端调用自己后端,必须返回{ checkoutUrl, sessionId, orderId? }onEvent(optional): 统一事件回调autoResize(optional, defaulttrue): 自动按resize事件更新 iframe 高度autoDestroyOnComplete(optional, defaulttrue): 成功完成后自动销毁 iframe,由宿主页面接管成功态pollStatus(optional): 商户后端确认型支付兜底,SDK 会按pollIntervalMs轮询并在终态时自动发出completepollIntervalMs(optional, default2000)
实例 API:
mount(container: string | HTMLElement)unmount()destroy()on(type, handler)(返回取消监听函数)getState() -> { mounted, destroyed }
事件类型:
readyresizestate_changecompletehosted_returnerror
推荐心智模型:
complete: 支付终态事件。来自 checkout 页面本身,或pollStatus兜底确认后的终态。业务是否真正成功应以这个事件为准。hosted_return: 商户自定义successUrl/cancelUrl页面回跳后的 UI 收口事件。适合关闭 iframe、返回宿主页面、展示商户自己的结果页。error: SDK 或轮询过程错误,不等于支付失败终态。
V2 错误处理示例
import { ClinkError, CLINK_ERROR_CODES } from '@clink/js';
try {
const embedded = await clink.initEmbeddedCheckout({
fetchSession: async () => ({ checkoutUrl: '', sessionId: 'sess_xxx' }),
});
embedded.mount('#checkout');
} catch (error) {
if (error instanceof ClinkError) {
if (error.code === CLINK_ERROR_CODES.INVALID_SESSION_ID) {
console.error('checkoutUrl 或 sessionId 无效');
}
}
}bootstrap 环境控制
SDK 支持通过环境变量固定远端 bootstrap 环境:
CLINK_ENV=sandbox->https://uat-api.clinkbill.com/api/sdk/bootstrapCLINK_ENV=production->https://api.clinkbill.com/api/sdk/bootstrap
优先级(高 -> 低):
loadClink(..., { checkoutBaseUrl })loadClink(..., { checkoutEnvironment })CLINK_ENV
远端 bootstrap 请求格式:
POST /api/sdk/bootstrap
X-API-Key: pk_uat_xxx / pk_prod_xxx
X-Timestamp: <unix_ms>
Accept-Language: zh-CN, en-US;q=0.9
Content-Type: application/json请求体:
{
"origin": "https://merchant.example.com",
"sdkVersion": "1.0.0",
"locale": "zh_CN"
}成功响应:
{
"code": 200,
"msg": "Success",
"data": {
"checkoutBaseUrl": "https://checkout.clinkbill.com",
"merchantId": "mcht_xxx",
"merchantName": "Demo Merchant",
"environment": "Uat",
"mode": "uat",
"features": {
"embeddedCheckout": true
},
"allowedParentOrigins": ["https://merchant.example.com"]
}
}错误处理
SDK 会抛出 ClinkError,包含 code 字段:
import { ClinkError, CLINK_ERROR_CODES, loadClink } from '@clink/js';
try {
const clink = await loadClink('pk_prod_xxx');
await clink.redirectToCheckout({ sessionId: 'sess_001' });
} catch (error) {
if (error instanceof ClinkError) {
if (error.code === CLINK_ERROR_CODES.INVALID_PUBLIC_KEY) {
console.error('public key 格式不正确');
}
}
}常见错误码:
INVALID_PUBLIC_KEYINVALID_CHECKOUT_ENVBOOTSTRAP_REQUEST_FAILEDINVALID_BOOTSTRAP_RESPONSEINVALID_REDIRECT_PARAMSINVALID_EMBEDDED_OPTIONSINVALID_SESSION_IDSESSION_ID_FETCH_FAILEDEMBEDDED_CHECKOUT_DISABLEDCONTAINER_NOT_FOUNDNOT_IN_BROWSER
安全边界
- SDK 不处理卡号、CVV 等敏感支付信息。
- SDK 不包含任何 secret key 逻辑。
- SDK 仅使用
publicKey调用 bootstrap。 - V2 推荐由商户后端创建 checkout session,并返回
checkoutUrl、sessionId等元数据给前端。 complete只表示 checkout / 后端确认过的终态;商户回跳页使用hosted_return做 UI 收口,不混用支付确认语义。
V1 -> V2 迁移说明
- V1 代码可保持不变:
loadClink + redirectToCheckout仍可用。 - 新接入嵌入式时,新增
initEmbeddedCheckout + mount。 - 业务终态建议监听
complete。 - 宿主页面收起 iframe、返回商户页面等 UI 收口逻辑,建议监听
hosted_return。
后端契约
bootstrap 接口契约见:
/Users/kanwu/IdeaProjects/customer-app/docs/sdk-v1-backend-contract.md
V2 增量契约见:
/Users/kanwu/IdeaProjects/customer-app/docs/sdk-v2-backend-contract.md
