身份令牌:告诉 purser 顾客是谁
在你的服务器上用身份签名密钥签一个短时、一次性的令牌,让聊天窗口认出已登录的顾客。
聊天窗口运行在访客的浏览器里,它自己说「我是某某顾客」是不可信的。所以 purser 的聊天接口里没有任何「顾客 ID」参数:只有你的服务器用身份签名密钥签出来的令牌,才能让 purser 相信这位访客是你店里的哪位顾客。
没有令牌、或者令牌验证不过,对话就是匿名的。匿名对话里 AI 不会透露任何订单信息,问「我的订单到哪了」会被请去登录。
认出顾客之后有什么不同
| 匿名访客 | 已验证顾客 | |
|---|---|---|
| 订单和物流 | AI 不给任何订单信息 | AI 直接拿到他最近 5 个订单(状态、商品、金额、物流),不需要再报订单号;收货地址和邮箱不会交给模型 |
| 顾客分层 | 不进任何分层 | 按你定义的分层(VIP 等)享受对应的服务策略,见 顾客与分层 |
| 退款 | AI 不会提出退款 | AI 只会针对他自己的订单、在政策允许时提出退款,见 退款 |
| 上一次对话 | 客服只能看到按设备匹配的记录(标注「可能不是同一个人」),AI 看不到 | 30 天内有过对话的,AI 会拿到上一次对话的摘要当背景;客服在收件箱里能看到上一次对话 |
令牌里的顾客 ID(sub)要和你通过 API 推送订单时用的 customerId 一致,AI 才能找到他的订单。推送方法见 REST API 与 Node SDK。
你需要的两个值
在控制台「接入」页的「4. 识别已登录的顾客」卡片里:
- 「工作区 ID(iss)」:放进令牌的
iss; - 「身份签名密钥」:签名用的密钥。
这张卡片只有工作区的店主和管理员能看到。身份签名密钥和 API 密钥(sk_…)是两回事:API 密钥泄露了也不能拿来冒充顾客。
身份签名密钥泄露时,点卡片里的「更换密钥」。新密钥立即生效,用旧密钥签的令牌马上失效:在你把服务器的环境变量换成新密钥之前,已登录的顾客会被当作匿名访客(能聊天,但看不到订单)。所以先准备好更新服务器,再点更换。
密钥只放在服务器上
身份签名密钥等于「以任何顾客身份登录聊天」的能力。只放在服务器的环境变量里,永远不要写进网页、App 安装包或前端代码。
令牌格式
令牌是一个 HS256 签名的 JWT:
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{
"iss": "<工作区 ID>",
"sub": "<你系统里的顾客 ID>",
"vid": "<访客 ID>",
"jti": "<每个令牌唯一的随机值>",
"iat": 1790000000,
"exp": 1790000300
}| 字段 | 说明 |
|---|---|
iss | 工作区 ID,必须和控制台显示的一致 |
sub | 你系统里的顾客 ID(字符串) |
vid | 访客 ID。网站上是嵌入脚本写在 localStorage 里的 purser_vid;App 客服页是你传给页面的 vid 参数 |
jti | 随机唯一值,例如 UUID |
iat | 签发时间,Unix 秒 |
exp | 过期时间,Unix 秒 |
签名密钥就是「身份签名密钥」这个字符串本身(按 UTF-8 字节)。只接受 HS256,其他算法一律当作无效。
有效期和一次性使用
- 最长 10 分钟:
exp - iat或exp - 当前时间超过 600 秒的令牌会被拒绝,哪怕签名正确。SDK 默认签 300 秒(5 分钟)。 - 只能用一次:每个
jti在工作区里只接受一次,同一个令牌第二次出现会被当作重放,对话变成匿名。 - 绑定访客:
vid必须和发起聊天的那个浏览器(或 App)的访客 ID 一致。从日志或截图里抄走的令牌在别的浏览器里没用。
所以不要在页面渲染时签一个令牌长期放着,而是聊天窗口打开时再去你的服务器要一个新的。下面的写法就是这样做的。聊天窗口和 反馈工具 各自消耗一个令牌,两个都用时要各签一个。
在 Node.js 里签令牌
不依赖任何第三方库,只用 Node 自带的 crypto(Node 20 及以上):
import { createHmac, randomUUID } from "node:crypto";
const b64url = (s) => Buffer.from(s).toString("base64url");
export function signIdentityToken({ secret, workspaceId, customerId, visitorId, ttlSeconds = 300 }) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: "HS256", typ: "JWT" };
const payload = {
iss: workspaceId,
sub: String(customerId),
vid: String(visitorId),
jti: randomUUID(),
iat: now,
exp: now + Math.min(ttlSeconds, 600),
};
const data = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const sig = createHmac("sha256", secret).update(data).digest("base64url");
return `${data}.${sig}`;
}这和 purser 服务端验签的方式完全一致。任何语言的标准 JWT 库也可以,只要用 HS256、字段如上、有效期不超过 10 分钟。
@purser-ai/node 里有同名的 signIdentityToken(参数相同,返回 Promise)。这个包还没有发布到 npm,发布之前请用上面这段代码。
接到网站上
1. 服务器:给已登录的顾客签令牌。 以 Express 为例:
app.get("/purser-token", (req, res) => {
const customer = req.session?.customer; // 你自己的登录态
const vid = req.query.vid;
if (!customer || typeof vid !== "string") return res.status(204).end();
const token = signIdentityToken({
secret: process.env.PURSER_IDENTITY_SECRET,
workspaceId: process.env.PURSER_WORKSPACE_ID,
customerId: customer.id,
visitorId: vid,
});
res.set("cache-control", "no-store").type("text/plain").send(token);
});2. 页面:告诉嵌入脚本去哪里拿令牌。 放在嵌入代码前后都可以:
<script>
async function purserToken() {
const vid = localStorage.getItem("purser_vid");
if (!vid) return null;
const r = await fetch("/purser-token?vid=" + encodeURIComponent(vid), { credentials: "same-origin" });
return r.status === 200 ? r.text() : null;
}
window.Purser = window.Purser || {};
window.Purser.identity = purserToken;
</script>Purser.identity 可以是一个令牌字符串,也可以是返回令牌(或 Promise)的函数。嵌入脚本在聊天窗口打开时才调用它,每次打开都能拿到新令牌。聊天窗口最多等 1.5 秒,超时就按匿名开始,所以这个接口要快。
单页应用里登录、退出时,调用 Purser.identify():
// 顾客登录后
Purser.identify(purserToken);
// 顾客退出后
Purser.identify(null);身份一变(匿名变已登录、换了一个顾客、退出登录),聊天窗口会重新加载,开始一段新对话。不同顾客的对话记录永远不会串在一起。
嵌入代码本身的写法见 网站聊天窗口。
App 客服页
App 里打开 /c/<公开密钥> 这个 H5 页时,把令牌放在链接的 # 后面:
https://askpurser.com/c/pk_…?vid=<访客ID>&lang=zh-CN#token=<身份令牌># 后面的部分不会发给任何服务器,页面读到后立刻从地址栏擦掉。vid 由 App 自己生成一个 UUID 存在本地、每次都用同一个,签令牌时用它作 visitorId。也可以由 App 在页面里注入 window.__PURSER_IDENTITY__ = "<令牌>"。详见 App 客服页。
身份永远不继承
每次开始聊天时,身份都要由页面用新令牌重新声明一次。没有令牌就是匿名,而且是一段新对话:
- 同一台电脑上,上一位顾客已验证的会话不会被下一位访客沿用;
- 已验证的对话和匿名对话不会互相接续;
- 同一位顾客在同一个标签页里刷新页面、带着新令牌回来,才会接着原来的对话。
这是有意的:共用电脑时,只有这样下一个人才看不到上一位顾客的订单和物流链接。
排查令牌问题
令牌出问题时聊天窗口不会报错,只是按匿名处理。要看原因:打开浏览器开发者工具的「网络」面板,找聊天窗口 iframe 发出的 sessions 请求(POST /api/v1/widget/sessions),响应里的 identity 是 verified_hmac 就说明认出来了,否则看 identityError:
identityError | 原因 |
|---|---|
malformed | 不是合法的 JWT,或缺少 sub / jti / exp |
wrong_algorithm | 不是 HS256 |
bad_signature | 签名不对:密钥用错了,或者签的不是这个工作区 |
wrong_issuer | iss 不是这个工作区的 ID |
expired | 已过期(检查服务器时钟) |
lifetime_too_long | 有效期超过 10 分钟 |
visitor_mismatch | vid 和这个浏览器的访客 ID 不一致 |
replayed | 这个令牌已经用过了 |
控制台「接入」页顶部的「上线清单」里,「认出已登录的顾客」这一步会在第一次有已验证的对话后自动打勾。