卡密客户端 WebSocket 对接文档

卡密客户端统一使用 /ws/card 完成激活、登录、心跳、查询、解绑、软件信息、公告和退出。本文是该协议的完整对接说明,用于卡密验证与代理项目。

管理后台账号登录、后台管理及文件上传下载继续使用 HTTP/HTTPS。卡密客户端无需先调用后台登录接口,也不使用后台 JWT。

1. 接入流程

  1. 从软件管理取得该软件的 AppKey 和 AppSecret,使用该软件下的卡密。
  2. 准备持久、稳定的设备标识 deviceId。同一设备重连保持相同值,不能每次生成新的随机值。
  3. 连接 ws://服务器地址:端口/ws/card;线上使用 wss://域名/ws/card。
  4. 收到 card_challenge 后,用挑战值和登录字段计算 HMAC,发送 card_login。用户主动登录使用 resume: false。
  5. 匹配该请求的 card_login_response,仅 code === 0 表示业务登录成功。成功连接或收到挑战值均不代表已登录。
  6. 保存当前授权有效期,按 result.heartbeat_gap 定期发送应用层心跳,同时处理状态推送和授权撤销。
  7. 任务结束发送 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:仅恢复已经存在且有效的设备绑定。
sign64 位小写十六进制字符串以下算法的输出;不要把 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 / messagecode === 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
}

软件或卡密的相关后台操作会触发复查推送;其他修改会在下一次心跳重新检查。不要把“没有推送”当成永久授权,也不要仅依赖服务端关闭连接来停止任务。

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_responsestatus(中文状态)、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_responsename、multi_device_limit、device_bind、description。此处 multi_device_limit 是软件默认值,卡密实际值优先用登录响应。不会返回 AppSecret。
software_announcement_responsesoftware_id、software_name、title、content、updated_at。没有公告时标题、内容为空字符串,updated_at 为 null。
card_logout_responsecode: 0、success: true、requestId;无 result/nonce/sign。服务器释放在线状态后以 1000 关闭。激活时间、到期时间及有效设备绑定保留。

客户端请求应设置超时并清理等待中的 requestId,关闭连接时统一结束未完成请求。超时不等于操作失败;例如解绑可能已提交,不能无限重发。查询、解绑、软件信息和公告执行前都会复查当前授权,失败可能返回对应响应并撤销会话。

7. 断线、重连与名额接管

  1. 网络断开时清理旧连接的心跳和未完成请求。可以立即暂停任务;即使允许短暂断线,也必须在原授权窗口或卡密期限到达时停止。
  2. 重新建立连接,等待新的 card_challenge,用相同 AppKey、卡密、deviceId 和新 requestId 重新签名;自动恢复使用 resume: true。
  3. 仅恢复成功并建立新授权窗口后继续任务。建议退避 1、2、4、8、15 秒并加少量随机抖动;每次失败关闭本次连接再重试,避免多个连接同时抢占。
  4. 被顶号、解绑、到期、冻结、删除、软件禁用、密钥变化,以及收到 card_revoked 或关闭码 4003 时,停止自动恢复并提示用户。
  5. 需要用户主动重新登录时,再由明确操作使用 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 返回码判断。

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 是否可由目标设备访问。

仓库内 npm run test:ws 可在临时数据库中验证真实协议、已有激活数据、签名拒绝、心跳、恢复、接管、后台更新、冻结、到期、删除与解绑;它不代表你的目标客户端已完成迁移或线上反向代理已验证。