卡密客户端 WebSocket 对接文档
卡密客户端统一使用 /ws/card 完成激活、登录、心跳、查询、解绑、软件信息、公告和退出。本文是该协议的完整对接说明,用于卡密验证与代理项目。
管理后台账号登录、后台管理及文件上传下载继续使用 HTTP/HTTPS。卡密客户端无需先调用后台登录接口,也不使用后台 JWT。
1. 接入流程
- 从软件管理取得该软件的
AppKey和AppSecret,使用该软件下的卡密。 - 准备持久、稳定的设备标识
deviceId。同一设备重连保持相同值,不能每次生成新的随机值。 - 连接
ws://服务器地址:端口/ws/card;线上使用wss://域名/ws/card。 - 收到
card_challenge后,用挑战值和登录字段计算 HMAC,发送card_login。用户主动登录使用resume: false。 - 匹配该请求的
card_login_response,仅code === 0表示业务登录成功。成功连接或收到挑战值均不代表已登录。 - 保存当前授权有效期,按
result.heartbeat_gap定期发送应用层心跳,同时处理状态推送和授权撤销。 - 任务结束发送
card_logout并关闭连接。网络断开后的恢复规则见断线与重连。
2. 消息格式与总览
每条消息都是一个 JSON 对象,以 WebSocket 文本消息发送。消息类型字段必须为 msgType,不能使用 type 或 command。登录及查询类请求使用连接内唯一的 requestId 对应响应;推送不能按请求响应顺序匹配。
| 方向 | msgType | 用途 / 对应响应 |
|---|---|---|
| 服务端 → 客户端 | card_challenge | 连接建立后发送登录挑战值。 |
| 客户端 → 服务端 | card_login | 激活或登录 / card_login_response |
| 客户端 → 服务端 | heartbeat | 应用层心跳 / heartbeat_ack,已登录后另有 card_status 或 card_revoked |
| 客户端 → 服务端 | card_query | 当前卡密信息 / card_query_response |
| 客户端 → 服务端 | card_unbind | 解绑当前设备 / card_unbind_response |
| 客户端 → 服务端 | software_info | 软件公开信息 / software_info_response |
| 客户端 → 服务端 | software_announcement | 软件公告 / software_announcement_response |
| 客户端 → 服务端 | card_logout | 退出 / card_logout_response,随后关闭连接 |
| 服务端 → 客户端 | card_status | 心跳或到期复查结果。 |
| 服务端 → 客户端 | card_updated | 后台修改相关卡密、软件配置后重新下发登录数据。 |
| 服务端 → 客户端 | card_revoked | 授权撤销,客户端停止任务,连接随后以 4003 关闭。 |
| 服务端 → 客户端 | error | 消息格式、未知类型、缺少 requestId 或异常;无法解析的请求可能不带 requestId。 |
除登录外的查询、解绑、软件信息和公告必须先登录,身份取当前连接的会话。后续消息不需要再传卡密、设备、AppKey 或签名;添加身份字段不会切换会话。切换卡密应退出并新建连接。
3. 登录、激活与请求签名
连接后服务端发送:
{"msgType":"card_challenge","challenge":"服务端随机字符串","server_time":1800000000}
客户端发送:
{
"msgType": "card_login",
"requestId": "login-1",
"appKey": "your_app_key",
"card": "your_card",
"deviceId": "stable_device_id",
"deviceInfo": "{}",
"resume": false,
"sign": "64位小写十六进制HMAC"
}
| 字段 | 类型 / 限制 | 说明 |
|---|---|---|
| requestId | 非空字符串,≤ 64 字符 | 连接内唯一请求号,响应原样返回。 |
| appKey | 非空字符串,≤ 128 字符 | 软件的 AppKey,字段名大小写固定。 |
| card | 非空字符串,≤ 256 字符 | 该软件的卡密,按原值计算签名和发送。 |
| deviceId | 非空字符串,≤ 128 字符 | 稳定设备标识。重装、迁移设备标识的策略由客户端决定。 |
| deviceInfo | 字符串,≤ 4096 字符 | 例如 JSON.stringify({model: 'test'}),也可以是 "{}",不能传对象。 |
| resume | 布尔值,必填 | false:用户主动登录,可激活、绑定和接管名额;true:仅恢复已经存在且有效的设备绑定。 |
| sign | 64 位小写十六进制字符串 | 以下算法的输出;不要把 AppSecret 放入消息。 |
签名算法
const payload = JSON.stringify([
challenge, requestId, appKey, card, deviceId, deviceInfo, resume
]);
const sign = require('crypto')
.createHmac('sha256', appSecret)
.update(payload, 'utf8')
.digest('hex');
数组字段顺序固定。使用紧凑 JSON,不添加空格;跨语言实现应保持 UTF-8、字符串转义和布尔值的序列化一致。外层登录对象的键顺序不参与签名。deviceInfo 是数组中的字符串,签名时用的字符串必须与发送值完全相同。
挑战值有效期 60 秒;未登录连接空闲约 30 秒会被清理,应收到挑战后立即登录。进入签名校验的登录尝试会消耗挑战并在响应的 challenge 字段返回下一值。参数格式错误、登录冲突等提前返回不保证带新挑战;需要重试时使用最新收到的挑战,或重新建立连接获取新挑战。一个连接只串行发起一次登录。
首次普通登录可激活未使用卡密并记录到期时间;再次登录沿用已有激活和到期数据。普通登录达到多开上限时会接管最久未活动设备的名额;同设备新连接也会替代旧连接。resume: true 不激活未使用卡密,也不抢占已丢失的绑定。
4. 业务响应与响应验签
成功登录响应的结构如下。时间和占位签名仅演示字段形状:
{
"msgType": "card_login_response",
"requestId": "login-1",
"code": 0,
"success": true,
"message": "ok",
"result": {
"expires": "2027-01-15 08:00:00",
"expires_ts": 1800000000,
"card_type": "month",
"duration": 30,
"multi_device_limit": 1,
"device_bind": 1,
"heartbeat_gap": 25,
"lease_seconds": 90
},
"nonce": "响应随机字符串",
"sign": "响应签名",
"challenge": "下一次登录挑战值",
"server_time": 1799999900
}
| 字段 | 含义 |
|---|---|
| code / success / message | code === 0 才是业务成功。失败可能没有 result、nonce 或 sign;不要只判断 WebSocket 是否在线。 |
| result.expires_ts | 卡密到期 Unix 秒时间戳,可能为 null;用于到期判断。expires 是展示文本,当前不同响应的展示时区可能不同,不能依赖解析该文本计算有效期。 |
| result.card_type / duration | 类型代码 day/week/month/quarter/year/permanent;duration 为时长天数。永久卡也可能返回远期到期时间。 |
| result.multi_device_limit / device_bind | 实际多开上限和软件设备解绑开关。 |
| result.heartbeat_gap / lease_seconds | 心跳间隔秒数,当前 25;本次授权窗口秒数,当前 90。仅登录、card_status、card_updated 成功会返回 lease_seconds。 |
| server_time | 服务端发送时的 Unix 秒时间戳;授权窗口和卡密到期是两种不同限制。 |
成功业务响应的签名
带 result、nonce 和 sign 的成功业务响应使用下面的算法。它与登录请求的 JSON 数组签名算法不同:
const sortedResult = Object.keys(message.result || {})
.sort()
.map(key => `${key}=${message.result[key]}`)
.join('&');
const content = `${message.code}${message.message}${sortedResult}${message.nonce}${appSecret}`;
const expected = require('crypto').createHmac('sha256', appSecret)
.update(content, 'utf8').digest('hex');
// 对比 expected 和 message.sign;不一致时拒绝该成功响应。
排序为键名升序,值按 JavaScript 字符串转换规则拼接,例如 null → "null"、布尔值 → "true"/"false"、数字 → 十进制。result 内的 lease_seconds 参与签名;nonce 是该响应的随机值,与登录 challenge 无关。
当前响应签名覆盖 code、message、result 和 nonce,不覆盖顶层 msgType、requestId、server_time 或 challenge。WSS 负责连接上完整消息的传输保护。challenge、heartbeat_ack、退出确认以及部分错误响应不提供上述签名,不能把它们当作授权成功消息。
5. 心跳、授权窗口与主动通知
// 每隔 result.heartbeat_gap 秒发送文本消息
{"msgType":"heartbeat"}
// 服务端先确认收到
{"msgType":"heartbeat_ack","server_time":1799999925}
// 已登录会话随后收到业务校验结果(下例省略 nonce/sign)
{
"msgType": "card_status",
"code": 0,
"success": true,
"message": "ok",
"result": {
"expires": "2027-01-15 08:00:00",
"expires_ts": 1800000000,
"server_time": 1799999925,
"heartbeat_gap": 25,
"lease_seconds": 90
},
"server_time": 1799999925
}
heartbeat_ack只说明消息收到,未登录也能收到。WebSocket 协议 ping/pong 同样不代表卡密有效,不能代替 JSON 心跳。- 只有验签成功且
code === 0的 card_login_response、card_status、card_updated 能刷新授权窗口。card_query 等查询成功不刷新客户端授权窗口。 - 收到成功授权时,用本地单调时钟记录“接收时间 + lease_seconds”;同时参考 expires_ts 与 server_time 的差值限制卡密到期。实际截止时间取两者较早者。重连本身不能延长旧授权。
- 超过授权截止时间还没有新的成功授权,必须停止受保护任务;500、消息异常、心跳确认、网络重试均不能延长授权。
card_status是状态检查;card_updated是包含卡密授权和配置的完整刷新。服务端可能合并重复检查,不能假设每次发送心跳都严格对应一条推送。card_revoked到达时立即停止任务、清理心跳和重连计时器,不等待授权窗口结束。随后通常以 WebSocket 关闭码 4003 断开。
软件或卡密的相关后台操作会触发复查推送;其他修改会在下一次心跳重新检查。不要把“没有推送”当成永久授权,也不要仅依赖服务端关闭连接来停止任务。
6. 查询、解绑、软件信息、公告和退出
下面每行是独立请求:
{"msgType":"card_query","requestId":"query-1"}
{"msgType":"card_unbind","requestId":"unbind-1"}
{"msgType":"software_info","requestId":"info-1"}
{"msgType":"software_announcement","requestId":"notice-1"}
{"msgType":"card_logout","requestId":"logout-1"}
| 响应 | result 字段 / 行为 |
|---|---|
| card_query_response | status(中文状态)、status_code(unused/used/expired 等原状态)、is_disabled、card_type(中文类型)、card_type_code、duration、expires、expires_ts、device_id、created_at、first_login_at、server_time。device_id 是卡密缓存字段,不应作为当前连接身份或全部多设备绑定列表。 |
| card_unbind_response | 成功返回 {message, remaining_devices}。软件须开启 device_bind,且至少保留一个有效绑定;仅剩一个绑定时返回 10255。解绑的是当前会话设备;成功后该连接收到 card_revoked 并停止任务。 |
| software_info_response | name、multi_device_limit、device_bind、description。此处 multi_device_limit 是软件默认值,卡密实际值优先用登录响应。不会返回 AppSecret。 |
| software_announcement_response | software_id、software_name、title、content、updated_at。没有公告时标题、内容为空字符串,updated_at 为 null。 |
| card_logout_response | code: 0、success: true、requestId;无 result/nonce/sign。服务器释放在线状态后以 1000 关闭。激活时间、到期时间及有效设备绑定保留。 |
客户端请求应设置超时并清理等待中的 requestId,关闭连接时统一结束未完成请求。超时不等于操作失败;例如解绑可能已提交,不能无限重发。查询、解绑、软件信息和公告执行前都会复查当前授权,失败可能返回对应响应并撤销会话。
7. 断线、重连与名额接管
- 网络断开时清理旧连接的心跳和未完成请求。可以立即暂停任务;即使允许短暂断线,也必须在原授权窗口或卡密期限到达时停止。
- 重新建立连接,等待新的 card_challenge,用相同 AppKey、卡密、deviceId 和新 requestId 重新签名;自动恢复使用
resume: true。 - 仅恢复成功并建立新授权窗口后继续任务。建议退避 1、2、4、8、15 秒并加少量随机抖动;每次失败关闭本次连接再重试,避免多个连接同时抢占。
- 被顶号、解绑、到期、冻结、删除、软件禁用、密钥变化,以及收到 card_revoked 或关闭码 4003 时,停止自动恢复并提示用户。
- 需要用户主动重新登录时,再由明确操作使用
resume: false。自动恢复失败后不能自行改成 false,否则可能把其他设备重新顶掉。
服务端只在真实登录或恢复成功时建立会话。断线清理保留有效绑定,因此网络恢复可以使用 true;被顶掉或已解绑的旧设备使用 true 会失败。超过多开上限时普通登录会替换名额,并向旧连接撤销授权。
8. 当前返回码与连接限制
下面是 JSON 消息中的业务 code,与 HTTP 状态码和 WebSocket 关闭码不同:
| code | 含义 | 处理 |
|---|---|---|
| 0 | 业务成功 | 按消息类型处理,只有授权消息能续授权。 |
| 400 | 参数、JSON、消息类型或 requestId 错误 | 修正请求格式。 |
| 401 | 该连接尚未登录 | 先完成 card_login。 |
| 403 | 软件不存在、被禁用或密钥改变 | 停止任务;检查软件和密钥,重新由用户登录。 |
| 409 | 已有会话或正在登录 | 避免并发登录;切换卡密时退出并新建连接。 |
| 500 | 服务端暂时错误 | 不续授权;允许在原有效期内受控重试,失败持续时停止。 |
| 10010 | 登录签名无效或挑战超过 60 秒 | 检查字段类型、序列化、密钥;使用新挑战,不能重放旧签名。 |
| 10210 | 卡密过期 | 停止任务和自动恢复。 |
| 10212 | 卡密冻结 | 停止任务和自动恢复。 |
| 10215 | 解绑时未找到该设备的有效绑定 | 检查当前会话和绑定状态。 |
| 10216 | 软件未开启设备解绑功能 | 提示用户,不自动重试解绑。 |
| 10217 | 绑定失效、设备被顶掉或连接被新连接接管 | 停止任务和自动恢复,由用户决定是否重新登录。 |
| 10218 | 卡密尚未激活,无法恢复 | 用户主动登录使用 resume: false。 |
| 10230 | 卡密不属于当前软件 | 核对 AppKey 和卡密。找不到当前软件下卡密时也可能返回 10240。 |
| 10240 | 卡密不存在或已删除 | 停止任务和自动恢复,核对软件及卡密。 |
| 10255 | 只剩一个有效绑定,不允许解绑 | 至少保留一个绑定,提示用户。 |
当前 WS 签名错误与挑战过期统一返回 10010;不使用旧 HTTP 的 10011、10013、10014。多开上限使用名额接管流程,不依赖旧 10213 返回码判断。
- 客户端单条入站消息最大 32768 字节。每个连接在 60 秒计数窗口内最多发送 60 条消息,心跳和业务请求都计数,超过时以 1008 关闭。
- 服务端每 15 秒检查空闲:未登录连接超过 30 秒、已登录连接超过 90 秒没有有效格式的应用消息会被终止。该检查不能替代客户端授权截止计时。
- 关闭码 1000:主动退出;4003:授权撤销或会话接管;1008:超出消息限制;1009:入站消息过大;1006:客户端观察到的异常断开,不是业务成功或失败码。
9. Node.js 可运行的最小客户端
在本项目目录创建 card-ws-demo.js,使用项目已有的 ws 依赖。通过环境变量提供 CARD_WS_URL、CARD_APP_KEY、CARD_APP_SECRET、CARD_CODE、CARD_DEVICE_ID 后运行 node card-ws-demo.js。请使用测试软件和测试卡密。
示例完成普通登录、响应验签、心跳、查询、授权超时停止及退出;遇到断线会停止,不自动重连。正式客户端按第 7 节增加恢复流程,受保护任务必须和示例的停止入口绑定。
const { WebSocket } = require('ws');
const { createHmac, timingSafeEqual } = require('crypto');
const { performance } = require('perf_hooks');
const { CARD_WS_URL, CARD_APP_KEY, CARD_APP_SECRET, CARD_CODE, CARD_DEVICE_ID } = process.env;
if (![CARD_WS_URL, CARD_APP_KEY, CARD_APP_SECRET, CARD_CODE, CARD_DEVICE_ID].every(Boolean)) {
throw new Error('请设置示例所需的五个环境变量');
}
const socket = new WebSocket(CARD_WS_URL, { handshakeTimeout: 10000 });
let heartbeatTimer;
let leaseTimer;
let stopped = false;
let loggedIn = false;
const loginTimer = setTimeout(() => stop('登录超时'), 15000);
/** 停止受保护任务,并清理本示例连接和计时器。 */
function stop(reason) {
if (stopped) return;
stopped = true;
clearTimeout(loginTimer);
clearInterval(heartbeatTimer);
clearTimeout(leaseTimer);
// 将你的任务停止操作绑定到这里。
console.log(reason);
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ msgType: 'card_logout', requestId: 'logout-1' }));
socket.close();
} else if (socket.readyState === WebSocket.CONNECTING) {
socket.terminate();
}
}
/** 验证带 result 的成功业务响应。 */
function verify(message) {
if (!message.result || !message.nonce || !/^[a-f0-9]{64}$/.test(message.sign || '')) return false;
const sorted = Object.keys(message.result).sort()
.map(key => `${key}=${message.result[key]}`).join('&');
const content = `${message.code}${message.message}${sorted}${message.nonce}${CARD_APP_SECRET}`;
const expected = createHmac('sha256', CARD_APP_SECRET).update(content, 'utf8').digest();
return timingSafeEqual(expected, Buffer.from(message.sign, 'hex'));
}
socket.on('message', raw => {
if (stopped) return;
try {
const message = JSON.parse(String(raw));
if (message.msgType === 'card_challenge') {
const request = { msgType: 'card_login', requestId: 'login-1', appKey: CARD_APP_KEY,
card: CARD_CODE, deviceId: CARD_DEVICE_ID, deviceInfo: '{}', resume: false };
const payload = JSON.stringify([message.challenge, request.requestId, request.appKey,
request.card, request.deviceId, request.deviceInfo, request.resume]);
request.sign = createHmac('sha256', CARD_APP_SECRET).update(payload, 'utf8').digest('hex');
socket.send(JSON.stringify(request));
return;
}
if (message.msgType === 'heartbeat_ack') return;
if (message.msgType === 'card_revoked' || message.msgType === 'error') {
return stop(`服务端拒绝:${message.code} ${message.message}`);
}
if (message.code !== 0) return stop(`校验失败:${message.code} ${message.message}`);
if (!verify(message)) return stop('响应验签失败');
if (['card_login_response', 'card_status', 'card_updated'].includes(message.msgType)) {
if (message.msgType === 'card_login_response' && message.requestId !== 'login-1') return stop('登录请求号不匹配');
const result = message.result;
if (!Number.isFinite(result.lease_seconds) || result.lease_seconds <= 0) return stop('授权窗口无效');
const remaining = result.expires_ts == null ? result.lease_seconds
: Math.min(result.lease_seconds, result.expires_ts - message.server_time);
if (!Number.isFinite(remaining) || remaining <= 0) return stop('授权已过期');
const deadline = performance.now() + remaining * 1000;
clearTimeout(leaseTimer);
leaseTimer = setTimeout(() => stop('授权有效期结束'), Math.max(0, deadline - performance.now()));
if (message.msgType === 'card_login_response' && !loggedIn) {
if (!Number.isFinite(result.heartbeat_gap) || result.heartbeat_gap <= 0) return stop('心跳间隔无效');
loggedIn = true;
clearTimeout(loginTimer);
heartbeatTimer = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send('{"msgType":"heartbeat"}');
}, result.heartbeat_gap * 1000);
socket.send('{"msgType":"card_query","requestId":"query-1"}');
console.log('卡密登录成功');
}
// card_updated 在此应用最新卡密授权和配置。
} else if (message.msgType === 'card_query_response') {
console.log('卡密状态:', message.result.status_code);
}
} catch (error) {
stop(`响应处理失败:${error.message}`);
}
});
socket.on('error', error => stop(`连接失败:${error.message}`));
socket.on('close', code => stop(`连接关闭:${code}`));
process.once('SIGINT', () => stop('用户退出'));
Auto.js 可使用其运行环境提供的 WebSocket 客户端,连接和 JSON 消息完全相同;HMAC 使用 Java 的 Mac.getInstance("HmacSHA256")、UTF-8 字节和小写十六进制输出。不同 Auto.js 版本的网络和线程 API 不同,不能直接把 Node.js 的 require('ws') 当作 Auto.js 模块。协议本身不要求存在 module/ws.js。
10. 项目复用
新项目复用这套服务端时,配置独立端口和数据库名称,启动后自动建立卡密与代理表。后台创建软件,客户端配置该软件的 AppKey 和 AppSecret,并使用稳定的设备标识。
客户端通信模块负责登录、验签、心跳、授权截止计时与退出;项目自己的页面和任务监听授权状态。未登录或授权撤销时停止业务任务,登录成功后才启用功能。
用户主动登录使用 resume: false,掉线恢复使用 resume: true;每个客户端只维护一条 WS 会话和一套心跳。软件公告通过 software_announcement 获取。后台 HTTP 账号登录与客户端卡密登录分别使用各自入口。
11. 部署和联调
WS 与后台共用现有服务器端口。局域网手机使用电脑实际 IP,不能用手机自己的 localhost。生产反向代理须转发 /ws/card 的 Upgrade;可在已有 HTTPS server 配置中加入下面的 location,后端端口按实际值替换:
location /ws/card {
proxy_pass http://127.0.0.1:3008;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
生产服务须设置 NODE_ENV=production、BASE_URL=域名 和 TRUST_PROXY=127.0.0.1/32;FORCE_HTTPS=false 使用 HTTP/WS,true 使用 HTTPS/WSS 并强制跳转。TRUST_PROXY 应填写实际代理来源,不能直接信任所有地址。后台 /api 路由也必须转发 X-Forwarded-Proto 与 X-Forwarded-For。
文件下载仍使用服务端返回的 HTTP/HTTPS URL,不需要把下载地址改成 ws://。若涉及下载,配置正确的外部访问地址和代理协议,核对实际 scriptUrl 是否可由目标设备访问。
- 连接失败:核对地址、端口、证书、路径和代理 Upgrade;没有收到 card_challenge 说明还没进入卡密登录阶段。
- 10010:检查 AppSecret、UTF-8、数组顺序、deviceInfo 字符串和 resume 布尔值;重新取得挑战后计算签名。
- 心跳有 ack 却不能运行:核对是否收到 code: 0 的 card_status,不能把 ack 或 socket open 当作授权成功。
- 10217 或 4003:检查是否同时开了多个连接、设备标识是否变化、是否被其他设备接管,停止自动抢回。
- 持续约 90 秒后停任务:检查是否只发送了 ping/pong、心跳任务是否运行、是否正确处理 card_status 与签名。
仓库内 npm run test:ws 可在临时数据库中验证真实协议、已有激活数据、签名拒绝、心跳、恢复、接管、后台更新、冻结、到期、删除与解绑;它不代表你的目标客户端已完成迁移或线上反向代理已验证。