| 1 |
拿到你的 AppKey 后台「程序管理」→ 选中程序 → 复制 AppKey(64 位十六进制)。公开页看不到真实密钥,登录你的账号后在同一页面能看到。 |
| 2 |
选语言,复制那段代码 直接点,跳过去就是那一节的完整实现: |
| 3 |
只改两个常量,然后调用 代码段顶部一般有两个常量: Api(接口地址)、AppKey(你的密钥)。改完调 Call("卡密", "机器码"),返回 code = 200 就通了。
|
请求 Base64( hmac[32] ‖ iv[16] ‖ ciphertext ) — AES-256-CBC + PKCS7
响应 Base64( iv[12] ‖ tag[16] ‖ ciphertext ) — AES-256-GCM
密钥
encKey = HMAC-SHA256(master, "enc") macKey = HMAC-SHA256(master, "mac")
① IV 长度:请求 16 字节,响应 12 字节,别用同一个常量
② HMAC 拼的顺序:必须
iv ‖ ciphertext,反过来就过不了③ AES 的 key:必须是
HMAC(master,"enc") 派生值,不能直接拿 master
如果你自己的代码解不开,拿这里的中间值逐项对比就能定位。
AppKey 只在你的浏览器内存里,不会发到服务器,也不会被记录。
{
"app_key": "你的64位AppKe...",
"card_code": "用户输入的卡密",
"device_hash": "16-64位设备指纹(少于16位会被拒)",
"action": "verify" // 可选,心跳传 heartbeat
}
encrypted_data 的加密代码,
在下面切到对应语言标签就能看到(C# / PHP / Python / Node / Java / Go / Swift / Kotlin / C++ / 易语言)。
{
"app_key": "你的64位AppKe...",
"encrypted_data": "Base64( hmac[32] ‖ iv[16] ‖ ciphertext )"
}
// 明文内容里必须带 _ts(Unix 秒,10 位),服务端允许 ±300 秒
400 请求密文为空 —— 这是为了防止「清空密文 → 服务端退回明文解析」的降级攻击。
要么带上完整密文走加密通道,要么整个字段都不传(明文模式,需后台没强制加密)。
400 未知的 action):
verify ·
heartbeat ·
unbind ·
ip_unbind ·
user_register ·
user_login ·
user_heartbeat ·
recharge ·
ip_unbind_user ·不传 action 等同于 verify。
// 应用开了「响应加密」时,整个 JSON 只剩这一个字段, // 解密前拿不到 code / msg / data,连错误信息都是密文 { "encrypted_data": "Base64( iv[12] ‖ tag[16] ‖ ciphertext )" } // 解密后才是正常的 { code, msg, data }
请求 Base64( hmac[32] ‖ iv[16] ‖ ciphertext ) + AES-256-CBC
响应 Base64( iv[12] ‖ tag[16] ‖ ciphertext ) + AES-256-GCM
// —— Java ——
byte[] bin = Base64.getDecoder().decode(enc);
byte[] iv = Arrays.copyOfRange(bin, 0, 12);
byte[] tag = Arrays.copyOfRange(bin, 12, 28);
byte[] ct = Arrays.copyOfRange(bin, 28, bin.length);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(hexToBytes(appKey), "AES"), new GCMParameterSpec(128, iv));
c.updateAAD(new byte[0]);
byte[] all = new byte[ct.length + tag.length];
System.arraycopy(ct, 0, all, 0, ct.length);
System.arraycopy(tag, 0, all, ct.length, tag.length);
byte[] pt = c.doFinal(all);
{
"code": 200,
"msg": "OK",
"data": {
"expire_time": "2026-11-30 23:59:59",
"card_type": "month",
"custom_data": "",
"variables": { "hb_interval": "300" },
"notice": "应用公告内容" // 可选:后台没填公告时为 null
}
}
| code | 含义 | 客户端处理 |
|---|---|---|
| 200 | 验证通过 | 放行,缓存 expire_time |
| 400 | 参数错误 / 参数类型不对(传了数组或对象)/ 设备码格式非法 / 签名不符 / 缺 _ts | 不重试,查代码 |
| 403 | 卡密无效、封禁、过期、绑定冲突 | 立即失效,重新弹授权 |
| 429 | 限频(三种来源:① 程序设的验证限频 ② 注册限频 ③ verify 连续失败 30 次/分钟) | 退避重试 |
| 500 | 服务端异常 | 退避重试 |
后台「程序管理 → 公告管理」里填的文本,会在验证成功和心跳的响应里一并下发,字段名 data.notice。 后台没填就返回 null,不需要额外请求接口,也不用改请求参数。
// 响应里的位置 { "code": 200, "data": { "expire_time": "2026-11-30 23:59:59", "notice": "【重要】服务迁移通知,请重新登录" // 没公告时是 null } }
| 平台 | 取公告并判空 |
|---|---|
| Java | String n = data.optString("notice", null); if (n != null && !n.isEmpty()) { /* 弹窗 */ } |
| 项目 | 说明 |
|---|---|
| 内容格式 | 纯文本。支持换行(JSON 里是 \n),不支持 Markdown 语法 |
| 字数上限 | 2000 字(服务端强制截断,超出部分不保存)。建议控制在 200 字内,弹窗好显示 |
| 编码 | UTF-8,中文、Emoji、英文混排都支持 |
| HTML 标签 | 不解析。客户端请按纯文本渲染(textContent / UILabel 的 stringValue),别用 innerHTML,否则等于把 XSS 入口交给任何人 |
| 是否加密 | 跟整个响应一起加密,不需要单独处理。后台开了「API 通讯加密」时,客户端按正常流程解密整个 data 后就能拿到明文 notice |
| 空值约定 | 后台留空 → 字段为 null(不是空字符串)。客户端用「非 null 且长度 > 0」判断 |
| 长度参考 | 2000 个中文字 ≈ 6 KB,加密后约 8 KB —— 对一次验证响应来说可以接受,但别往上堆 |
| 更新时机 | 后台保存后立即生效。已运行的客户端最迟一个心跳周期(默认 5 分钟)收到 |
只传 app_key、不传卡密,就是「拉公告 / 变量 / 版本」接口。启动软件时先调一次,用户还没输卡密也能看到公告。
带了 "action":"verify" 却卡密为空,服务端会当成「你漏传了卡密」直接返回
400「缺少卡密(card_code)参数」,拿不到公告。正确:{"app_key":"…"} 错误:{"app_key":"…","action":"verify"}
// 请求(不需要 card_code,也不需要 device_hash) { "app_key": "你的64位AppKe..." } // 响应 { "code": 200, "data": { "notice": "【公告】本软件已更新到 2.1.0", "variables": { "kf_qq": "123456789", "min_version": "2.0.0" }, "update": { "version": "2.1.0", "url": "", "force": 0 } } }
// —— Java —— public static String[] getNotice(String api, String appKey) { // [ok, notice, error] try { String body = "{\"app_key\":\"" + appKey + "\"}"; java.net.http.HttpClient http = java.net.http.HttpClient.newHttpClient(); var req = java.net.http.HttpRequest.newBuilder().uri(java.net.URI.create(api)) .header("Content-Type", "application/json") .POST(java.net.http.HttpRequest.BodyPublishers.ofString(body)).build(); String resp = http.send(req, java.net.http.HttpResponse.BodyHandlers.ofString()).body(); var o = javax.json.Json.createReader(new java.io.StringReader(resp)).readObject(); if (o.getInt("code") != 200) return new String[]{"0", null, o.getString("msg")}; var d = o.getJsonObject("data"); return new String[]{"1", d.containsKey("notice") && !d.isNull("notice") ? d.getString("notice") : null, null}; } catch (Exception e) { return new String[]{"0", null, e.getMessage()}; } }
如果不想每次心跳都弹,客户端自己记一个「已看过的最新公告 hash」即可——比较内容变化再弹。
后台「程序管理 → 管理 → 更新配置」里可以给每个程序单独填新版本下载地址、强制更新开关。 这些值会在验证成功、心跳、以及只传 app_key 的轻量调用三种响应里一并下发,字段名 data.update。 不填就不下发(update.url 为空字符串),客户端可以完全忽略这个字段。
// 响应里的位置 { "code": 200, "data": { "expire_time": "2026-11-30 23:59:59", "update": { "version": "2.2.0", // 后台「程序管理 → 编辑」里填的版本号 "url": "https://dl.example.com/app-v2.2.0.apk", // 更新配置里填的下载地址,没填是 "" "log": "修复若干问题", // 程序备注,可当更新日志 "force": 1 // 1=强制更新,0=可选 } } }
| 字段 | 类型 | 说明 |
|---|---|---|
| version | string | 服务端记录的最新版本号,可能为空字符串 |
| url | string | 新版本下载地址,为空表示没配更新,客户端应直接跳过 |
| log | string | 更新说明,取自程序的备注字段 |
| force | int | 1 = 强制更新(低于 version 应拒绝使用),0 = 仅提示 |
// 伪代码:拿到响应后先做这一步,再走正常业务 update = data.update if (update.url != "") { // 后台没配置就什么都不做 remote = version_tuple(update.version) // "2.2.0" -> [2,2,0] current = version_tuple(LOCAL_VERSION) // 客户端自己的版本 if (remote > current) { if (update.force == 1) { // 强制:弹不可取消的对话框,只能点「去更新」 show_blocking_dialog("必须更新到 " + update.version, update.url, update.log) return // 不继续业务 } else { // 可选:弹一个能关闭的提示 show_optional_dialog("有新版本 " + update.version, update.url, update.log) // 继续正常业务 } } }
当前语言写法:Java: 自己 split("\\.") 后 Integer.parseInt 逐段比
| 平台 | 取更新信息 |
|---|---|
| Java | JSONObject u = data.optJSONObject("update"); |
只传 app_key 不传卡密也能拿到(data.update),适合启动时先查一次版本再让用户输卡。
同一个 AppKey,后台这些开关改一下,接口的校验规则和返回结构就会变。 客户端如果写死了某种行为,开关一改就会出问题 —— 所以对接前先确认下表里的值。
要让某个程序临时停服,请用「运行状态 → 停用」,该程序的所有验证请求一律
code=403。那才是正确的关停方式。
| 参数 | 取值 | 对接口的影响 |
|---|---|---|
machine_check |
0 / 1 / 2 |
卡密模式的设备策略(账号模式看 single_login)。三种都是「一张卡同时能用几台设备」的区别:1 = 独占绑定(默认)—— 只认第一台。换设备返回 403「卡密已绑定其他设备」,新设备进不来、旧设备继续用。
(这里的「换设备」指同一张卡换一台机器调 verify)2 = 单点在线 —— 换设备放行,但会清掉旧设备的授权记录,并把 cards.device_hash 覆盖成新设备。
旧设备下一次 heartbeat 收到 403
「该设备没有有效的授权记录,请重新验证卡密」被踢下线。0 = 多点在线 —— 不校验设备绑定,多台设备各记一条 active_devices,同时在线互不影响,都不踢。三种模式都受「一台机器只能激活一张卡」限制 —— active_devices 上 (device_hash, app_id) 是唯一键,
同一台机器换用另一张卡会顶掉原来那张(并把旧卡解绑)。这是防共享的核心规则,
不因「多点在线」而放开。客户端配合:单点在线下,被踢的设备靠 heartbeat 的 403 自己退回验证页;多点在线下不需要任何额外处理。关于 device_hash 本身:传了就必须是
16–64 位字母数字(含 _ -),否则返回 400;
完全不传时服务端会用 md5("ip:" + 客户端IP) 兜底当设备码 ——
这样极简接入也能跑,但同一出口 IP 的所有用户会被视为同一台设备。
正式接入请一定自己生成并持久化 device_hash
(建议用主板 SMBIOS UUID 之类不会因重装系统而变的硬件指纹)。
|
ip_check |
0 / 1 |
1 = 绑定 IP —— 首次验证时记下 IP,之后换 IP 返回 code=403。0 = 不绑 IP(默认)。 |
ip_precision |
0 / 1 |
只在 ip_check=1 时生效。0 = 精确到 IP(默认, 1.2.3.4 完全一致才放行)1 = 精确到网段 —— IPv4 按 /24(1.2.3.x 算同一台),IPv6 按 /64。没有第 3 种精度,填其它值一律当 0 处理。 |
single_login |
0 / 1 |
账号同时只能有一台在线,区别在「新设备能不能登进来」(卡密的设备绑定看 machine_check)。1 = 独占绑定(默认)—— 账号锁死在首次登录的设备。换设备 user_login 直接返回
code=403「该账号已绑定其他设备」,新设备进不来、旧设备继续用。适合严格防共享:一个号只能在一台机器,换机器必须走 rebind(扣时长)或找管理员解绑。0 = 单点在线 · 免费换设备 —— 任意设备都能登,新设备一登,旧设备的 statecode 立刻被覆盖,
旧设备下一次 user_heartbeat 收到 code=403「账号已在其他设备登录…」被踢回登录页。适合多设备轮换、不额外收费:手机电脑都能登,但同一时刻只有一个在线,像微信/QQ 那样「后登顶前登」。 2 = 单点在线 · 换设备扣时长 —— 行为同上,但每次换设备自动扣 unbind_penalty 小时。扣费发生在 user_login 内部,不需要客户端额外调 rebind ——
一次登录完成「踢掉旧设备 + 扣时长 + 新设备生效」。响应里带 data.charged_hours(扣了几小时)与
data.device_changed="1";剩余时长不够扣时返回
code=402 并附 need_hours / remain_hours,
此时不扣时长、设备码也不改。永久卡(2099 到期)不扣。适合严格控成本:换设备有代价,但用户不用找管理员。 3 = 多点在线 —— 多台设备同时挂着,谁都不掉。 登录时不再覆盖会话,而是给每个设备单独记一条( end_user_sessions 表,一设备一行)。
手机、家里电脑、公司电脑可以同时在线,各发各的心跳,互不影响。此模式不扣时长、不踢人;同一台设备重复登录会替换它自己那条会话 (不会堆记录,但会让该设备的旧令牌失效)。 响应里带 data.online_devices(当前几台在线)。
管理员可单独踢掉某一台,不影响其它设备。四种模式一句话区别 1 独占绑定 —— 只认第一台,后来的进不来 0 单点在线·免费 —— 只保留最后一台,老的被踢 2 单点在线·扣时长 —— 只保留最后一台,老的被踢,换一次扣 N 小时 3 多点在线 —— 所有设备都留着,同时在线 客户端配合:单点在线模式下,新设备登录响应带 data.kicked_previous="1",可提示「原设备已下线」;被踢的设备靠
user_heartbeat 的 403 自己退回登录页。
|
hb_grace |
小时数 |
这个值不参与服务端校验,只是原样下发给客户端。 它会出现在验证/心跳响应的 data.variables.hb_grace 里(大于 0 才有这个 key)。用法:客户端自己决定「心跳失败后再宽限多久才真正拒绝用户」。 服务端只按 expire_time 判过期,到点就是 code=403。
|
| 参数 | 取值 | 对接口的影响 |
|---|---|---|
unbind_enabled |
0 / 1 |
0 = 禁止自助解绑 —— unbind 返回 code=400「该应用未开启自助解绑」。1 = 允许(默认)。 注意是 400 不是 403 —— 这是「功能没开」,不是「卡密失效」, 客户端不该按 403 那样清缓存踢用户下线。 |
unbind_window |
小时数 |
0 = 不限时(默认)。大于 0 时,同一张卡在这个时间窗内最多解绑 unbind_limit 次,超了返回 code=429。
|
unbind_limit |
次数 |
0 = 不限次数(默认)。配合 unbind_window 一起用。
|
unbind_penalty |
小时数(可带小数) |
0 = 不扣时(默认)。大于 0 时,每次解绑从剩余时长里扣掉这么多小时; 扣完会变成过期状态( expire_time 提前)。
|
web_unbindweb_unbind_ip |
0 / 1 |
控制网页端解绑入口是否开放,不影响 API 的 unbind。
web_unbind_ip=1 时网页解绑会同时清掉 IP 绑定。
|
| 参数 | 取值 | 对接口的影响 |
|---|---|---|
reg_limit_windowreg_limit_count |
秒数 / 次数 |
验证接口限频。两个都填 0 = 不限(默认)。 填了之后,同一 IP 在 window 秒内最多 count 次验证请求,超了返回 code=429「请求过于频繁,请稍后再试」,并带 Retry-After 头。管的是卡密验证链路: verify、heartbeat、user_login、user_heartbeat、recharge。不影响注册(注册走下面那组)。多人共用出口 IP(公司网、代理池、NAT)时必须填 0 或调大。 |
register_limit_windowregister_limit_count |
秒数 / 次数 |
注册接口限频(独立于上一组)。两个都填 0 = 不限(默认)。 填了之后,同一 IP 在 window 秒内最多注册 count 次,超了返回 code=429「注册过于频繁」。只作用于 action=user_register,对验证、心跳、登录、充值完全没有影响。建议:防批量注册小号可以把这组调严(如 3600 秒 / 3 次),验证那组留 0 —— 两者互不牵连。 |
| 参数 | 取值 | 对接口的影响 |
|---|---|---|
encrypt_req |
0 / 1 / 2 |
请求体是否必须加密。 0 = 跟随全局(默认,看后台「全局配置 → 请求加密」) 1 = 强制加密 —— 不带 encrypted_data 返回 code=4002 = 强制明文 —— 带了密文也按明文处理
返回
code=400 时,msg 里会写着
"请在客户端开启 S6_ENCRYPT_REQUEST"。S6_ENCRYPT_REQUEST 指的是客户端 SDK 里的那个开关,不是服务端参数 —— 它对应的就是这张表里的 encrypt_req:
值为 1(或 0 且后台「请求加密」开着)时,
客户端必须把请求体加密成 encrypted_data 再发。看到这个提示,不代表接口地址或 AppKey 错了,是请求没加密。 |
encrypt_resp |
0 / 1 / 2 |
响应是否加密。 0 = 跟随全局(默认,看后台「全局配置 → 响应加密」) 1 = 强制加密 —— 响应是 {"encrypted_data":"..."}2 = 强制不加密 —— 响应是明文 JSON |
ts_check |
0 / 1 |
1 = 校验时间戳(默认)—— 请求里的 _ts 与服务器相差超过 300 秒返回 code=400「请求已过期」。0 = 不校验 —— 客户端时间不准也能用。 |
ts_strict |
0 / 1 |
只在 ts_check=1 时生效。1 = 严格模式 —— 额外拒绝超前的时间戳( _ts > 服务器时间+5秒 直接拒),防止用户把手机时间调快绕过。0 = 只判过期,不判超前(默认)。 |
sign_check |
0 / 1 |
1 = 校验签名 —— 密文里必须带 sign 字段,值是 md5(card_code + device_hash + _ts + app_key),不对返回 code=400。0 = 不校验(默认)。 开了签名就必须走加密通道 —— 明文请求签发不出 _ts,会直接返回 code=400。
|
resp_format |
json / text |
json = 标准 JSON(默认)—— {"code":"200","msg":"OK","data":{...}}text = 极简文本 —— 只回两行 200\nOK,给老客户端兼容用。注意:text 模式下响应不加密,也不返回 data,拿不到 expire_time 等字段。
|
code_type |
string / int |
string = 返回码是字符串 "code":"200"(默认)int = 返回码是数字 "code":200弱类型语言(PHP / JS / Python)两种都能直接用; 强类型语言(Java / C# / Swift / Go)必须跟这个开关对上,否则反序列化会失败。 |
resp_ts |
0 / 1 |
1 = 响应里多一个 time 字段(Unix 时间戳,秒),可以拿来校准客户端时间。0 = 不返回(默认)。 |
| 参数 | 取值 | 对接口的影响 |
|---|---|---|
notice |
文本 |
公告内容。会出现在验证、心跳、用户登录、用户心跳的 data.notice 里。为空时是 "notice": null ——
key 一定存在,只是值是 null。客户端按「可能为 null」处理即可。
|
app_icon |
图片地址 | 图标地址,部分客户端会拿来做启动页。不影响任何校验逻辑。 |
agent_key |
字符串 | 代理专用标识,用于区分「这张卡是哪个代理发的」。不影响 API 校验。 |
1. 两组限频各管各的,别混着看。
reg_limit_* 管验证链路(verify / heartbeat / user_login / user_heartbeat / recharge),
register_limit_* 只管 user_register。
填其中一组不会影响另一组 —— 所以「注册卡得很严但用卡不受影响」是能配出来的,把注册那组调小、验证那组留 0 即可。升级提示:老版本这两组是同一个字段,升级后如果原来
reg_limit_* 填了窄值想继续限制注册,
需要把值再填一遍到 register_limit_*,否则注册会变成不限。2.
code_type 跟客户端的反序列化类型必须对上。
Java 的 getInt("code") 拿到字符串 "200" 会抛异常,C# 的 (int)JsonConvert 同理。
强类型语言建议后台把这个值设成 int,或者在客户端做兼容解析。3.
resp_format=text 会丢掉整个 data。
只回 200\nOK,拿不到 expire_time、notice、update。
除非客户端是纯「能过就行」的判断,否则别开。
后台 →「程序管理」→ 点应用名进「程序设置」/「传输设置」,每个开关旁边都有说明。 改完立即生效,不需要客户端重新发版。
不注册账号,用户直接输卡密就能用。这是最省事的模式 —— 一把卡密对应一台设备, 换设备要解绑。后台可以按小时 / 天 / 周 / 月 / 季 / 年 / 永久卖卡,也可以在「生成卡密」里 指定任意小时数。
| 对比项 | 单卡密模式 | 账号模式 |
|---|---|---|
| 用户要做什么 | 输一次卡密,之后自动登录 | 先注册账号 → 买卡密充值 → 账号登录 |
| 适合 | 挂机软件、辅助工具、一次性授权、卖时长卡 | 需要用户体系、多设备漫游、在线续费、找回密码的场景 |
| 换设备 | 调 unbind 或让管理员解绑,可配扣时/限次 | 账号跟着人走,换设备只需重新登录 |
| 续费 | 再买一张卡密,绑定同一设备会自动叠加时长 | 用 recharge 给账号充值 |
| 要不要注册 | 不用 | 要 |
| action | 用途 | 必填参数 |
|---|---|---|
(不传 / verify) | 卡密验证 | card_code device_hash |
heartbeat | 心跳续期 | card_code device_hash |
unbind | 解除设备绑定 | card_code |
ip_unbind | 解绑卡密绑定的 IP | card_code(可选 device_hash) |
卡密验证 card_code + device_hash
| 字段 | 类型 | 说明 |
|---|---|---|
app_key | char[] | 必填应用的 64 位 AppKey,外层明文 |
card_code | char[] | 必填用户输入的卡密,不区分大小写 |
device_hash | char[] | 选填设备唯一标识,16–64 位。见「设备指纹」章节 |
action | char[] | 可不传,等同于 verify |
「一张卡能不能在多台设备上用、换设备会不会让原设备掉线」由程序后台的 「机器码验证」决定。客户端只要正常传
device_hash,行为自动跟随配置:独占绑定 → 换设备返回
403「卡密已绑定其他设备」,原设备继续用单点在线 → 换设备返回
200,原设备下一次 heartbeat 收到
403「该设备没有有效的授权记录」被踢多点在线 → 多台设备同时可用,各自独立心跳,互不影响
三种模式都受「一台机器只能激活一张卡」限制 —— 同一台机器换用另一张卡会顶掉原来那张(并把旧卡解绑)。
code / msg / data| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功,403 卡密无效/被封/换设备,其他见「错误码」 |
msg | char[] | 提示信息,失败时可直接展示给用户 |
data.expire_time | char[] | 到期时刻 YYYY-MM-DD HH:MM:SS;永久卡是 2099-12-31 23:59:59 |
data.card_type | char[] | hour / day / week / month / season / year / permanent |
data.custom_data | char[] | 验证时由客户端通过 custom_data 参数上传的自定义内容(如用户账号、机器信息),服务端会存进这张卡并在后续验证/心跳里原样回传;没传过就是空串。注意:不是生成卡密时写的 |
data.device_changed | char[] |
'1' = 本次验证发现这张卡原来绑的是别的设备,已换绑到当前设备;'' = 没换。只有「机器码验证 = 单点在线」才会出现 '1',独占绑定模式换设备直接 403,多点在线模式不绑设备。卡密模式换设备不扣时长 —— 所以这里没有 charged_hours,
那个字段只有账号模式(user_login)在 single_login=2 时才有。
|
data.notice | char[] | 程序公告,后台没填是 null |
data.update | object | { version, url, log, force },版本更新用 |
data.variables | object |
程序变量(键值对)。没配任何变量时是空对象 {},
不是 null 也不是 [] —— 客户端按「有没有 key」判断,
不要拿它和 [] 或 null 比。
这个字段在 verify / heartbeat / 只读公告三个响应里形态一致。
|
sign | char[64] |
服务端签名(后端配了签名密钥就有)。算法:
HMAC-SHA256(code + "|" + msg + "|" + json_encode(data), server_sign_key)。客户端拿不到 server_sign_key,只需校验它 ——
这样即使 app_key 泄露,伪造的响应也过不了校验。
|
sign_algo | char[] | 签名算法标识,当前固定 hmac-sha256 |
encrypted_data 的密钥就是 app_key,
而 app_key 必须下发到客户端才能调用接口。所以「能解密」不等于「来自真服务器」——
持有 app_key 的人可以造出任何内容。加上
sign 之后,客户端用服务端密钥校验,就能分辨真假。
强烈建议客户端校验 sign,否则整条链路的可信度只剩 TLS 一层。
sign / sign_algo
是 output_json() 统一加的,所以每一个接口的每一个响应都带,
包括:heartbeat、user_register、user_login、
user_heartbeat、recharge、unbind、
ip_unbind、ip_unbind_user,
以及所有失败响应(400 / 403 / 404 / 429 / 500)。验签只需要三步:
payload = str(code) + "|" + msg + "|" + json_encode(data) // data 为 null 时用 "null"
expect = HMAC_SHA256(payload, server_sign_key) // 输出小写十六进制
if (expect != resp.sign) { 拒绝这个响应 }
| 注意点 | 说明 |
|---|---|
server_sign_key 从哪来 |
后台「程序管理 → 传输配置 → 响应签名密钥」。只放在你自己的服务端,不要打包进客户端。 |
data 为 null 时 |
拼字符串用字面量 null(不是空串)。 |
| JSON 序列化顺序 | 服务端用 PHP 的 json_encode($data),字段顺序=数据库列顺序。
客户端不要自己重新排序,直接对收到的 data 原文做序列化即可;
若语言差异导致顺序不一致,就改成「先比对 sign,再解析 data」——
即签名时用收到的原始 JSON 片段。 |
没有 sign 字段 |
说明该程序还没配签名密钥(后台可点「重新生成」补上)。 此时只能靠 TLS,建议尽快补。 |
| 换了密钥之后 | 旧客户端会全部验签失败 —— 必须和新版客户端一起切换。 |
if (new JSONObject(resp).getInt("code") == 200) {
String expire = new JSONObject(resp).getJSONObject("data").getString("expire_time");
// 通过:进主界面,把卡密和到期时间存起来
} else {
System.out.println("验证失败:" + new JSONObject(resp).getString("msg"));
}
续费就是再买一张卡、在同一台设备上再验证一次 —— 服务端会发现这台设备已经绑过卡, 直接把新卡时长累加到原来那张卡上,不用用户做任何额外操作。
卡密心跳 heartbeat(card_code, device_hash)
| 字段 | 类型 | 说明 |
|---|---|---|
action | char[] | 固定 heartbeat |
card_code | char[] | 必填已激活的卡密 |
device_hash | char[] | 条件必填必须是当初激活时那台设备 |
code / msg / data| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 表示心跳正常(类型随程序配置 code_type 变化) |
msg | char[] | 提示信息 |
data.expire_time | char[] | 最新到期时间(YYYY-MM-DD HH:MM:SS) |
data.notice | char[] | 应用公告,后台没填时为 null |
data.update | object | { version, url, log, force },版本更新用 |
data.variables | object | 程序变量(键值对)。没配任何变量时是空对象 {} |
sign | char[64] | 响应签名,HMAC-SHA256 |
sign_algo | char[] | 固定 hmac-sha256 |
心跳比 verify 少两个字段:不返回 data.card_type 和
data.custom_data —— 这两个是首次验证才下发的一次性信息,心跳不重复给。
其余四个 data 字段和 verify 完全一致。
失败时 code=403,客户端应该立刻退回输卡密界面,把本地存的卡密清掉。
掉线判定时间在后台「全局配置 → 心跳超时」里调,建议设为客户端心跳间隔的 3 倍。
解除设备绑定 unbind(card_code)
| 字段 | 类型 | 说明 |
|---|---|---|
action | char[] | 固定 unbind |
card_code | char[] | 必填要解绑的卡密 |
| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功;400 程序关闭了自助解绑 / 次数超限 |
msg | char[] | 成功时会说明扣了多少小时代价 |
data.penalty_hours | float | 本次解绑扣掉的时长,没配惩罚就是 0 |
unbind_enabled)关掉后,客户端调这个接口会直接返回 400 该程序已关闭自助解绑,请联系管理员,
用户只能找你手动解绑。还能配「N 小时内最多解绑 M 次」和「每次解绑扣 X 小时」,防止有人拿解绑当免费换机工具。
除了卡密,还支持注册账号 → 用卡密充值 → 账号登录。两种模式可以同时用,老用户继续输卡密,新用户注册账号。 所有请求都发往同一个 /Verifyfile/api.php,只用 action 区分。
| action | 用途 | 必填参数 |
|---|---|---|
user_register | 注册账号 | username password confirm_password |
rebind |
账号换绑(换机器自动解绑 + 扣时长) | |
user_login | 账号登录 | username password device_hash |
user_heartbeat | 心跳续期 | username password statecode |
recharge | 卡密充值 | username card_code |
ip_unbind_user | 账号解绑 IP | username password |
ip_unbind | 卡密解绑 IP | card_code(可选 device_hash) |
用户注册 user_register(username, password, confirm_password)
| 字段 | 类型 | 说明 |
|---|---|---|
app_key | char[] | 必填应用的 64 位 AppKey,外层明文 |
action | char[] | 固定 user_register |
username | char[] | 必填2–50 位,只允许字母、数字、下划线、点、横线。同一应用内唯一 |
password | char[] | 必填至少 6 位 |
confirm_password | char[] | 必填必须与 password 完全一致 |
code / msg / data| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功,其他为失败 |
msg | char[] | 提示信息,失败时可直接展示给用户 |
data.username | char[] | 注册成功的用户名 |
data.id | int | 用户 ID |
data.expire_time | char[] |
注册后立刻到期的时刻(YYYY-MM-DD HH:MM:SS)。
注册时带 card_code 或后台给了试用时长才有值,否则为 null。
客户端可直接用它显示「剩余 N 天」。
|
// 请求 { "app_key": "你的64位AppKe...", "action": "user_register", "username": "zhangsan", "password": "abc123456", "confirm_password": "abc123456" } // 响应 { "code": 200, "msg": "注册成功", "data": { "username": "zhangsan", "id": 2 } }
卡密充值 recharge(username, card_code)
| 字段 | 类型 | 说明 |
|---|---|---|
app_key | char[] | 必填应用的 64 位 AppKey |
action | char[] | 固定 recharge |
username | char[] | 必填要充值的账号 |
card_code | char[] | 必填一张未使用过的卡密 |
code / msg / data| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功 |
data.expire_time | char[] | 充值后的到期时间,格式 YYYY-MM-DD HH:MM:SS |
data.added_hours | float | 本次增加的时长(小时) |
data.card_type | char[] | 卡密类型名称,如「月卡」 |
// 请求 { "app_key": "你的64位AppKe...", "action": "recharge", "username": "zhangsan", "card_code": "TK3S2UF79TZ2WYSSJE" } // 响应 { "code": 200, "msg": "充值成功", "data": { "expire_time": "2026-11-29 14:54:24", "added_hours": 720, "card_type": "月卡" } }
用户登录 user_login(username, password, device_hash)
| 字段 | 类型 | 说明 |
|---|---|---|
app_key | char[] | 必填应用的 64 位 AppKey |
action | char[] | 固定 user_login |
username | char[] | 必填注册时的用户名 |
password | char[] | 必填注册时的密码 |
device_hash | char[] | 选填16–64 位设备指纹,见下方 GetMachineCode() |
「能不能换设备、换设备会不会让原设备掉线、要不要扣时长」全部由程序后台的 「账号设备策略」决定。客户端只要正常传
device_hash,行为自动跟随配置:独占绑定 → 换设备返回
403,原设备继续用、不掉线单点在线 → 换设备返回
200,原设备下一次 user_heartbeat 收到
403 被踢单点在线 · 扣时长 → 同上,另外响应里
data.charged_hours > 0 表示扣了几小时多点在线 → 多台设备同时在线,都不掉,
data.online_devices 显示当前几台判断「是否被顶下线」看
user_heartbeat 的 code,
不要看 online_devices —— 多点在线下它是 2、3 也属正常,不代表掉线。
code / msg / data| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功,403 凭据无效 / 未充值 / 已到期 / 设备冲突 |
data.expire_time | char[] | 当前到期时间 |
data.statecode | char[] | 会话令牌,心跳必须带上。每次登录都会刷新。 本身没有过期时间 —— 只有重新登录、自助换绑、管理员解绑、封禁、踢下线这几种情况才会变, 所以长时间挂机不会因为它掉线。心跳响应里也会回传同一个值,客户端可随时刷新本地副本。 |
data.notice | char[] | 应用公告,后台没填时为 null |
data.kicked_previous | char[] | 本次登录是否顶掉了别的设备:"1" = 有,"" = 没有。判据:登录前账号的旧 statecode 非空,且 last_login(登录和每次心跳都会刷新)在最近 15 分钟内 —— 两个都满足才算「有活跃会话」。只看 statecode 非空会把「昨天登录过、客户端早关了」也当成在线,用户一登录就弹提示,那是误报,所以加了活跃时间窗。客户端拿到 "1" 就提示「该账号已在其他设备登录,原设备已下线」;被顶的那台最迟下一次心跳收到 403 后自己退回登录页。两边合起来才是完整的顶号体验。 |
data.charged_hours | float |
本次登录因「换设备」扣了几小时(single_login=2 时才有值)。0 = 没扣(首次登录、同设备登录、或程序没开「换设备扣时长」)。客户端可据此提示「换设备已扣除 24 小时」。扣多少由后台「账号设备策略」里的 unbind_penalty 决定。
|
data.device_changed | char[] |
设备码是否变了:"1" = 变了,"" = 没变。和 kicked_previous 不是一回事:kicked_previous 看「旧会话还活着吗」,
device_changed 看「设备码变了吗」。旧设备早关了(15 分钟没心跳)时,前者是 "",后者仍是 "1"。
|
data.online_devices | int |
该账号当前在线设备数。single_login=3(多点在线)时会大于 1,
其它模式恒为 1(同一时刻只有一台)。客户端可用它显示「当前 N 台设备在线」。 |
// 请求 { "app_key": "你的64位AppKe...", "action": "user_login", "username": "zhangsan", "password": "abc123456", "device_hash": "设备指纹" } // 响应 { "code": 200, "msg": "登录成功", "data": { "expire_time": "2026-11-29 14:54:24", "statecode": "eba30d6d6a058bc4f1a2...", "notice": "【公告】账号模式也能收到公告" } }
用户心跳 user_heartbeat(username, password, statecode)
403 就是当前会话已失效(在其它设备登录 / 被管理员解绑 / 被踢)。
收到后应直接退回登录页,不要自动重登(重登会把对方顶掉,两台来回弹)。多点在线(
single_login=3)下不会因为别人登录而收到 403,
只有被管理员主动踢掉某一台才会。
403 就是当前会话已失效(在其它设备登录 / 被管理员解绑 / 被踢)。
收到后应直接退回登录页,不要自动重登(重登会把对方顶掉,两台来回弹)。多点在线(
single_login=3)下不会因为别人登录而收到 403,
只有被管理员主动踢掉某一台才会。
| 字段 | 类型 | 说明 |
|---|---|---|
app_key | char[] | 必填应用的 64 位 AppKey |
action | char[] | 固定 user_heartbeat |
username | char[] | 必填用户名 |
password | char[] | 必填密码 |
statecode | char[] | 必填登录时拿到的会话令牌 |
code / msg / data| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功,403 登录态失效 / 已到期 / 已禁用 |
data.expire_time | char[] | 最新到期时间 |
data.notice | char[] | 应用公告,后台没填时为 null |
data.statecode | char[] | 原样回传登录时的 statecode,不刷新;客户端可继续用同一个 |
data.online_devices | int | 该账号当前在线设备数(1 = 只有自己) |
// 请求 { "app_key": "你的64位AppKe...", "action": "user_heartbeat", "username": "zhangsan", "password": "abc123456", "statecode": "eba30d6d6a058bc4f1a2..." } // 响应 { "code": 200, "msg": "心跳成功", "data": { "expire_time": "2026-11-29 14:54:24", "notice": "【公告】..." } }
和卡密模式一样,账号登录和心跳也都会带 data.notice(应用公告),非空就弹给用户看。
心跳还会回传 data.statecode,值和本次请求带上来的一模一样(服务端不换)。 客户端拿到后直接覆盖本地存的令牌即可,不必再多调一次 user_login。
用户公告(跟着登录/心跳下发)
| 接口 | 字段 | 说明 |
|---|---|---|
user_login | data.notice | 登录成功时下发 |
user_heartbeat | data.notice | 每次心跳都下发,公告改了最迟一个心跳周期就能收到 |
user_heartbeat | data.statecode | 原样回传本次请求的令牌(服务端不重新生成),可直接覆盖本地值 |
verify / heartbeat | data.notice | 卡密模式同样下发 |
// 登录 / 心跳的 data 里都会带这个字段 { "code": 200, "data": { "expire_time": "2026-11-29 14:54:24", "statecode": "eba30d6d6a058bc4f1a2...", "notice": "【公告】服务器今晚 23:00 迁移" // 没公告时为 null } }
账号解绑 IP ip_unbind_user(username, password)
| 字段 | 类型 | 说明 |
|---|---|---|
app_key | char[] | 必填应用的 64 位 AppKey |
action | char[] | 固定 ip_unbind_user |
username | char[] | 必填用户名 |
password | char[] | 选填密码。选填但强烈建议传:不传会走到「用户名或密码错误」,只有 username 完全没传时才报「缺少用户名」 |
后台「程序配置 → 防护」里的
ip_check 为 0(默认)时,
服务端从头到尾不记录 IP,所以调用本接口会一律返回
403「当前没有绑定 IP」—— 这不是出错,是本来就没有可解绑的东西。要让它生效:先把
ip_check 设为 1,
之后客户端每次 verify / user_login 会把当时来源 IP 记下来
(精度由 ip_precision 决定:0=完整 IP,1=前三段),
这时再调本接口才会返回 200 和 data.old_ip。
| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功,403 密码错误 / 未绑定 IP / 已禁用 |
data.old_ip | char[] | 解绑前绑定的 IP,便于展示 |
{
"app_key": "你的64位AppKe...",
"action": "ip_unbind_user",
"username": "zhangsan",
"password": "abc123456"
}
// 响应
{ "code": 200, "msg": "IP 解绑成功", "data": { "old_ip": "112.19.228.181" } }
卡密解绑 IP ip_unbind(card_code, device_hash?)
| 字段 | 类型 | 说明 |
|---|---|---|
app_key | char[] | 必填应用的 64 位 AppKey |
action | char[] | 固定 ip_unbind |
card_code | char[] | 必填要解绑的卡密 |
device_hash | char[] | 选填可选。传了会校验这张卡是不是绑在这台设备上 |
后台「程序配置 → 防护」里的
ip_check 为 0(默认)时,
服务端从头到尾不记录 IP,所以调用本接口会一律返回
403「当前没有绑定 IP」—— 这不是出错,是本来就没有可解绑的东西。要让它生效:先把
ip_check 设为 1,
之后客户端每次 verify / user_login 会把当时来源 IP 记下来
(精度由 ip_precision 决定:0=完整 IP,1=前三段),
这时再调本接口才会返回 200 和 data.old_ip。
| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功,403 卡密无效 / 没绑定记录 / 设备不匹配 |
data.old_ip | char[] | 解绑前的 IP |
{
"app_key": "你的64位AppKe...",
"action": "ip_unbind",
"card_code": "TK3S2UF79TZ2WYSSJE",
"device_hash": "可选的设备指纹"
}
// 响应
{ "code": 200, "msg": "IP 解绑成功", "data": { "old_ip": "112.19.228.181" } }
single_login=1)时,账号绑死在第一个登录的设备上。
用户换电脑会收到 403「该账号已绑定其他设备」——这时候调 rebind,
就能用账号密码 + 新设备码自动换绑,服务端按程序的「解绑扣时」配置扣时长。和卡密模式的
unbind 是两套:unbind 按 card_code 解卡密↔设备,
rebind 按 username+password 解账号↔设备。
| 参数 | 类型 | 说明 |
|---|---|---|
action | char[] | 固定 rebind |
username | char[] | 必填账号 |
password | char[] | 必填密码(会校验,不是只传用户名就行) |
device_hash | char[] | 必填新机器的设备码,16–64 位 |
400 | 程序关闭了自助换绑(后台「自助解绑 / 换绑」选了「禁止」) | 把 msg 显示给用户:「该程序已关闭自助换绑,请联系管理员」。
不要当 403 处理 —— 这不是凭据失效,不用清缓存或踢用户下线 |
402 | 剩余时长不够扣(需 need_hours,剩 remain_hours) |
提示「请先充值再换绑」,此时不扣时长、设备码也不改 |
| 字段 | 类型 | 说明 |
|---|---|---|
data.expire_time | char[] | 扣完时长后的新到期时间 |
data.penalty_hours | float | 本次扣了多少小时(后台配 unbind_penalty,0 表示不扣) |
data.notice | char[] | 应用公告,没配就 null |
| code | msg 示例 | 客户端该做什么 |
|---|---|---|
402 | 剩余时长不够换绑 示例:「剩余时长不够换绑(需 24 小时,当前剩 10 小时)。换绑会扣除时长且不能扣到 0,请先充值再换绑」 边界要记住:剩余时长 ≤ 惩罚时长时一律拒绝。 天卡(24 小时)碰上「扣 24 小时」,换一次绑就会把卡扣成 0 小时 —— 这种「刚好够扣完」也算不够,直接 402 让你去充值, 而不是扣完剩 0 让用户的卡凭空消失。 响应的 data 里带 need_hours(需要多少)和 remain_hours(当前剩多少),可以直接拿来提示。永久卡(到期时间 2099-12-31)不受这条限制 —— 扣时对它没有意义,按 0 惩罚处理。 |
引导充值。返回里带 remain_hours 和 need_hours |
429 | 换绑次数已达上限(累计最多 2 次) | 提示联系管理员。返回里带 limit 和 used |
403 | 密码错 / 账号禁用 / 当前设备就是绑定设备 / 没绑过设备 | 当成普通失败,不要弹「充值」 |
服务端会清空
statecode,旧设备的下一次 user_heartbeat 直接返回
code=403「账号已在其他设备登录(或管理员已解绑),当前会话已失效,请重新登录」。这是故意的,不然旧机器还能一直用。
user_login 的 device_hash 是必填的。不传会返回
400「该应用已开启单点登录,必须提供设备标识(device_hash)参数」,
而不是悄悄放行。
| 配置 | 默认 | 作用 |
|---|---|---|
unbind_enabled | 1 | 是否允许自助换绑。设 0 后调 rebind 一律 403 |
unbind_penalty | 0 | 扣多少小时。0 = 不扣,直接换绑 |
unbind_limit | 0 | 次数上限。0 = 不限,>0 = 最多这么多次 |
unbind_window | 0 | 0 = 永久累计;>0 = 按小时窗口计数 |
// 登录被拒时自动换绑 var r = gy.Call(card, device, "user_login"); if (r.code == "403" && r.msg.Contains("已绑定其他设备")) { // 先弹确认框,别静默扣时长 if (MessageBox.Show("换电脑需要换绑,将扣除时长,继续吗?", "确认", MessageBoxButtons.YesNo) == DialogResult.Yes) { var rb = gy.Call2("rebind", new { username = user, password = pass, device_hash = device }); if (rb.code == "200") { MessageBox.Show("换绑成功,新到期时间:" + rb.data.expire_time); gy.Call(card, device, "user_login"); // 重新登录 } else if (rb.code == "402") { MessageBox.Show("时长不足,请先充值"); } else { MessageBox.Show("换绑失败:" + rb.msg); } } }
三个最容易踩的格式坑
- _ts 必须是秒,不是毫秒:10 位整数(如
1761912000)。传 13 位毫秒(如1761912000123)会被判成「请求已过期」,因为差值直接超出 ±300 秒窗口 - device_hash 长度必须 16–64 字符:短于 16 或长于 64 返回
设备码格式不正确。用 SHA-256 十六进制串(64 位)或 UUID(36 位)都合适 - app_key 必须 64 位十六进制:不是 Base64,也不是截断的短串。长度不对会直接判
应用身份标识无效 - verify 连续失败会限频:同一 IP 1 分钟内失败 30 次,后续请求返回
429 验证失败次数过多,请 1 分钟后再试。成功一次就清零。 调试时注意:别用同一个 IP 连续试错卡号,很容易把自己锁 1 分钟。
时间字段说明(expire_time / added_hours)
yyyy-MM-dd HH:mm:ss,不带时区后缀。
客户端直接按本地时间解析即可;若服务器与用户不在同一时区,以「剩余秒数」为准,不要拿绝对时间去比。
| 返回示例 | 含义 | 客户端建议处理 |
|---|---|---|
"2026-11-30 23:59:59" |
普通到期时间 | 正常解析并显示剩余天数/小时 |
"2099-12-31 23:59:59" |
永久有效 | 当年份 ≥ 2099 就当永久卡,别去算剩余天数(否则会显示「还有 26000 多天」) |
null |
账号未充值 / 未激活 | 注册后还没充过值。提示用户去充值,不要当成「已过期」 |
// —— 通用判断:年份 >= 2099 即永久 —— // Java boolean isPermanent = expireStr != null && expireStr.startsWith("2099-");
- 接口返回的是到期时刻,不是剩余秒数;需要剩余时间就用「到期时刻 − 当前时刻」自己算
- 永久卡(2099)算出来的剩余天数没有意义,界面上直接显示「永久」
expire_time为null时不要参与任何时间计算,按「未充值」分支处理recharge接口额外返回data.added_hours(本次充值的时长小时数),可用于提示「已增加 N 小时」- 多张卡可以叠加:同一个账号连续充多张卡,时长累加,不会覆盖。 例:天卡(24h)→ 周卡(168h)→ 月卡(720h),最终有效期 = 24+168+720 = 912 小时。
- 账号未过期时从当前到期时间往后加;已过期时从当下开始算(不会把过期的空档也补上)。
- 每张卡只能充一次(
cards.status置 1),同一张卡重复调recharge返回400「该卡密已被使用或不处于可充值状态」。 - 永久卡不能用于
recharge(时长无限无法累加),文档前面已说明;账号体系里请改用月卡/年卡
错误码速查
400— 参数错误 / 参数类型不对(传了数组或对象)/ 加密格式不对 / 时间戳过期 / 业务前置条件不满足403— 鉴权失败 / 卡密被封 / 程序被禁用 / 账号到期 / 卡密已冻结 / 解绑被拒429— 触发频率限制,客户端应退避重试500— 服务端异常
card_code 传成数组 ["a","b"]
或对象 {"$ne":null} 时,服务端返回
400 参数 card_code 格式无效:必须是字符串;card_code 缺失时返回 400 缺少卡密(card_code)参数,
不会被"当成空值"继续往下走。这是防止类型混淆攻击。这条对所有接口的所有参数都适用,不只
card_code:device_hash、
device_hash、username、password、statecode、
note、pre、reason 传数组或对象时,一律返回
400 参数 xxx 格式无效:必须是字符串。整数/浮点/布尔/空值会被宽松处理(分别转成字符串 /
1·0 / 空串),
只有数组和对象会被明确拒绝。
sign 时,签的内容是
code|msg|json(data) 三段拼接,用
server_sign_key 做 HMAC-SHA256。有两处很容易踩:1.
data.variables 必须按对象序列化。服务端下发的是
{}(空对象),但很多语言把它解析成空数组/空字典,
再序列化就变成 [] —— 差一个括号,签名立刻对不上。复算前先把
variables 强制成对象
(PHP 用 (object),Python 用 dict(),
JS 直接就是对象不用转)。2. 三段之间用竖线
| 拼接,不是拼字符串也不是拼整包 JSON。json(data) 里的 key 顺序必须和服务端一致 —— 服务端按 PHP 数组
原始顺序序列化,所以最省事的做法是直接从解密后的明文里截 data 那段原文,
不要解码再编码。验不过就先打印自己的拼接串和服务端的比,多数情况是上面第 1 条。
400,文案会说清缺哪个参数。选填 可以不传,服务端有默认值或按其它规则处理。
条件必填 看程序配置决定,具体条件写在这一行的说明里。
所有参数都必须是字符串或数字,传数组或对象一律
400「参数 xxx 格式无效」,
不会静默转成 "Array"。数字型参数(num / len /
app_id / custom_hours)传数组也是 400。
code 不一定是数字,由程序配置里的
code_type 决定:code_type = string(默认)→ 返回 "code": "200",是字符串code_type = int → 返回 "code": 200,是数字客户端必须用「弱比较」判断(PHP 用
== 不用 ===,
其它语言先转成数字再比),否则程序换成 int 之后
code === "200" 会失效,看起来像「所有接口都失败」。同理
resp_format = text 时响应只有两行纯文本 code\nmsg,
没有 JSON 结构也没有加密;resp_ts = 1 时会多一个
time 字段(服务器当前秒级时间戳)。这三项都在「程序配置 → 返回值配置」里改。
msg)| 返回 msg | code | 触发原因 |
|---|---|---|
| 首次激活成功 / OK | 200 | 卡密有效,已完成设备绑定 |
| 程序已经禁用,请联系开发者 | 403 | 程序被管理员手动禁用,或开发者账号到期自动禁用。续费后自动恢复 |
| 这张卡暂时无法使用,请联系卖家 | 403 | 卡密所属代理到期,未激活的卡被系统冻结。代理续费后自动解冻 |
| 此卡密已被管理员封禁 | 403 | 管理员手动封禁(优先级高于冻结) |
| 访问受限:设备或IP已被云端封禁 | 403 | 命中黑名单。app_id=0 的全站黑名单对所有程序生效,程序专属黑名单只对该程序生效 |
| 无效的卡密 (或不属于当前应用) | 403 | 卡密不存在,或卡密属于别的程序(app_key 与卡密不匹配) |
| 该设备已绑定其他卡密,一个设备只能使用一张卡 | 403 | 一机一卡策略(bind_policy=strict),需先解绑 |
| 账号未充值,请充值后登录 | 403 | 账号体系里注册后还没充过值 |
| 账号已到期,请充值后登录 | 403 | 账号时长用完 |
| 解绑次数已达上限(N 小时内最多 M 次) | 400 | 超出程序配置的自助解绑次数 |
| 请求过于频繁,请稍后再试 | 429 | 触发限频,退避后重试 |
调用示例
// —— Java ——
String r1 = callRaw("{\"action\":\"ip_unbind_user\",\"username\":\"" + u + "\",\"password\":\"" + p + "\"}");
String r2 = callRaw("{\"action\":\"ip_unbind\",\"card_code\":\"" + card + "\"}");
错误处理(客户端要做的分流)
| code | msg | 客户端动作 |
|---|---|---|
403 | 账号未充值,请充值后登录 | 弹充值窗口,让用户输卡密调 recharge |
403 | 账号已到期,请充值后登录 | 同上,文案可换成「已到期,请续费」 |
403 | 用户名或密码错误 | 提示检查输入,不要清空已填的用户名 |
403 | 该账号已绑定其他设备,如需更换请联系管理员解绑 | 显示联系方式,或调 ip_unbind_user 自助解绑 |
403 | 账号已被禁用 | 提示联系管理员,不要循环重试 |
400 | 该用户名已被注册 / 密码至少 6 位 / 用户名长度需在 2–50 位之间 | 在注册表单上就地提示 |
// —— Java ——
String lg = callRaw("{\"action\":\"user_login\",\"username\":\"" + u + "\",\"password\":\"" + p
+ "\",\"device_hash\":\"" + mc + "\"}");
JSONObject o = new JSONObject(lg);
int code = o.getInt("code");
String msg = o.optString("msg", "");
if (code == 200) {
JSONObject d = o.getJSONObject("data");
enterMain(d.getString("expire_time"), d.getString("statecode"));
} else if (msg.contains("未充值") || msg.contains("已到期")) showRecharge();
else if (msg.contains("其他设备")) showUnbind();
else showToast(msg.isEmpty() ? "登录失败" : msg);
| 场景 | code | msg |
|---|---|---|
| 用户名重复 | 400 | 该用户名已被注册 |
| 两次密码不一致 | 400 | 两次输入的密码不一致 |
| 用户名格式不合规 | 400 | 用户名长度需在 2–50 位之间 |
| 密码太短 | 400 | 密码至少 6 位 |
| 账号密码错误 | 403 | 用户名或密码错误 |
| 未充值就登录 | 403 | 账号未充值,请充值后登录 |
| 账号到期 | 403 | 账号已到期,请充值后登录 |
| 换设备登录 | 403 | 该账号已绑定其他设备,如需更换请联系管理员解绑 |
| 卡密无效 / 已用 | 403 | 该卡密已被使用或不处于可充值状态 |
| statecode 失效 | 403 | 账号已在其他设备登录(或管理员已解绑),当前会话已失效,请重新登录 同一账号在别处登录成功时,服务端会生成新 statecode 覆盖旧的,旧设备下一次心跳必然收到这条 —— 客户端收到就应当直接退到登录页并关掉核心功能。 |
| 账号被禁用 | 403 | 账号已被禁用 |
// —— Java ——
String reg = callRaw("{\"action\":\"user_register\",\"username\":\"" + u + "\",\"password\":\"" + p + "\",\"confirm_password\":\"" + p + "\"}");
String rc = callRaw("{\"action\":\"recharge\",\"username\":\"" + u + "\",\"card_code\":\"" + card + "\"}");
String lg = callRaw("{\"action\":\"user_login\",\"username\":\"" + u + "\",\"password\":\"" + p + "\",\"device_hash\":\"" + mc + "\"}");
String sc = new JSONObject(lg).getJSONObject("data").getString("statecode");
String hb = callRaw("{\"action\":\"user_heartbeat\",\"username\":\"" + u + "\",\"password\":\"" + p + "\",\"statecode\":\"" + sc + "\"}");
直接,点 「下载 Java SDK(.zip)」 —— Demo.java 的接口地址和 AppKey 已经填好了:
s6sdk-java/ S6Verify.java 核心 SDK Demo.java 示例(AppKey 已注入) README.md 加密协议 + 方法速查
- Android 应用(原生,不用第三方库)
- Java 桌面程序(Swing / JavaFX)
- 服务端中转(Spring Boot / Servlet)
- 命令行工具
只用 JDK 自带的 javax.crypto(AES / HMAC)、 HttpURLConnection(HTTP)、手写 JSON 解析。
不需要 Gson,不需要 Jackson,不需要 OkHttp。Java 8u161+ / Android API 21+ 都能跑。
S6Verify v = new S6Verify( "https://你的域名/Verifyfile/api.php", "你的64位AppKey"); v.setDevice(getMachineCode()); v.setTimeout(15000); // 毫秒,默认 15000
| 方法 | 说明 |
|---|---|
S6Verify.version() | SDK 版本号 |
v.lastError | 最近一次本地失败原因。业务错误在 r.msg |
v.setDevice(h) | 设置设备码,返回 this |
v.setTimeout(ms) | HTTP 超时毫秒数 |
v.setVerifyTls(false) | 关 TLS 校验,仅自签证书调试 |
S6Verify v = new S6Verify(API_URL, APP_KEY); v.setDevice(getMachineCode()); // ① 先拉公告和版本(不需要卡密) S6Verify.S6Result n = v.getNotice(); if (n.ok) { if (!n.notice.isEmpty()) showNotice(n.notice); if (!n.updateVer.isEmpty()) askUpdate(n.updateUrl, n.forceUpdate == 1); } // ② 卡密验证 S6Verify.S6Result r = v.verify(cardCode); if (!r.ok) { System.out.println(r.msg); return; } enterMain(r.expireTime);
| 字段 | 类型 | 说明 |
|---|---|---|
ok | bool | true = 业务成功(code == 200) |
code | int | 服务端 code,或 -1(本地失败) |
msg | String | 服务端提示,可直接展示 |
expireTime | String | 到期时间 YYYY-MM-DD HH:MM:SS |
statecode | String | 账号模式会话令牌 |
kickedPrevious | String | "1" = 顶掉了别的设备 |
chargedHours | double | 换设备扣了几小时 |
deviceChanged | String | "1" = 设备码变了 |
onlineDevices | int | 当前在线设备数 |
cardType | String | 卡类型(hour/day/week/month/…) |
customData | String | 自定义数据 |
notice | String | 应用公告 |
updateVer / updateUrl / forceUpdate | 混合 | 版本更新 |
variables | Map | 程序变量,没配时是空 Map |
raw | String | 原始 JSON |
getNotice()S6Verify.S6Result n = v.getNotice(); if (n.ok) { if (!n.notice.isEmpty()) System.out.println("公告: " + n.notice); for (Map.Entry<String,String> e : n.variables.entrySet()) System.out.println("变量 " + e.getKey() + " = " + e.getValue()); }
verify(cardCode)S6Verify.S6Result r = v.verify("KFF669EJ92NVKZYS"); if (r.ok) System.out.println("验证通过,到期: " + r.expireTime); else System.out.println("验证失败[" + r.code + "]: " + r.msg);
heartbeat(cardCode)S6Verify.S6Result h = v.heartbeat(cardCode); if (!h.ok) forceBackToLogin(h.msg); // 403 = 被踢,别自动重登
unbind(cardCode)S6Verify.S6Result u = v.unbind(cardCode); if (u.ok) System.out.println("已解绑"); else if (u.code == 402) System.out.println("剩余时长不够扣"); else System.out.println(u.msg);
userRegister(user, pwd)S6Verify.S6Result r = v.userRegister("玩家昵称", "密码"); System.out.println(r.ok ? "注册成功" : r.msg);
userLogin(user, pwd)S6Verify.S6Result r = v.userLogin(user, pwd); if (r.ok) { String statecode = r.statecode; // 心跳必须带上 if ("1".equals(r.kickedPrevious)) tip("已在其他设备登录,原来的被挤下线"); if (r.chargedHours > 0) tip("换设备扣了 " + r.chargedHours + " 小时"); if (r.onlineDevices > 1) tip("当前 " + r.onlineDevices + " 台设备在线"); enterMain(r.expireTime); } else if (r.code == 402) { tip("换设备要扣时长,你剩余不够"); } else { tip(r.msg); }
独占绑定 → 换设备
403,原设备继续用单点在线 → 换设备
200,原设备下次心跳 403多点在线 → 多台同时在线,
onlineDevices 显示几台判断「是否被顶下线」看心跳
code,不要看 onlineDevices。
userHeartbeat(user, pwd, sc)S6Verify.S6Result h = v.userHeartbeat(user, pwd, statecode); if (!h.ok) forceBackToLogin(h.msg); else statecode = h.statecode; // 可能刷新
recharge(user, card)S6Verify.S6Result r = v.recharge(user, card); System.out.println(r.ok ? "充值成功,到期 " + r.expireTime : r.msg);
rebind(user, pwd)S6Verify.S6Result r = v.rebind(user, pwd); if (r.ok) System.out.println("换绑成功"); else if (r.code == 400) System.out.println("管理员关闭了自助换绑"); else if (r.code == 402) System.out.println("剩余时长不够扣"); else System.out.println(r.msg);
ipUnbind(card) / ipUnbindUser(u, p)S6Verify.S6Result a = v.ipUnbind(card); // 卡密 S6Verify.S6Result b = v.ipUnbindUser(user, pwd); // 账号 System.out.println(a.ok + " " + a.msg);
public class Demo { static final String API_URL = "https://你的域名/Verifyfile/api.php"; static final String APP_KEY = "你的64位AppKey"; public static void main(String[] args) { S6Verify v = new S6Verify(API_URL, APP_KEY); v.setDevice(getMachineCode()); S6Verify.S6Result n = v.getNotice(); if (n.ok && !n.notice.isEmpty()) System.out.println("公告: " + n.notice); String card = args.length > 0 ? args[0] : "你的卡密"; S6Verify.S6Result r = v.verify(card); if (!r.ok) { System.out.println("验证失败[" + r.code + "]: " + r.msg); return; } System.out.println("验证通过,到期: " + r.expireTime); for (int i = 0; i < 3; i++) { try { Thread.sleep(2000); } catch (InterruptedException e) { break; } S6Verify.S6Result h = v.heartbeat(card); if (!h.ok) { System.out.println("心跳失败[" + h.code + "]: " + h.msg); return; } } System.out.println("心跳正常"); } // 设备码:主板 UUID(Windows);Android 用 Settings.Secure.ANDROID_ID static String getMachineCode() { try { Process p = Runtime.getRuntime().exec(new String[]{ "cmd", "/c", "wmic csproduct get UUID"}); java.io.BufferedReader br = new java.io.BufferedReader( new java.io.InputStreamReader(p.getInputStream())); String line; while ((line = br.readLine()) != null) { line = line.trim(); if (line.isEmpty() || line.startsWith("UUID")) continue; return sha256(line).substring(0, 32); } } catch (Exception ignore) {} return sha256(System.getProperty("user.name")).substring(0, 32); } static String sha256(String s) { try { java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); byte[] b = md.digest(s.getBytes("UTF-8")); StringBuilder sb = new StringBuilder(); for (byte x : b) sb.append(String.format("%02x", x)); return sb.toString(); } catch (Exception e) { return s; } } }
| 现象 | 原因 / 处理 |
|---|---|
编译报 NoClassDefFoundError | 没把 S6Verify.java 放同一包下,或包名不一致 |
code = -1,lastError 有值 | 本地失败:网络 / DNS / 证书 / 解密 |
Android 上 NetworkOnMainThreadException | 同步调用了。放进 Thread / AsyncTask / 协程 |
403 但卡是好的 | 设备码变了。Android 用 Settings.Secure.ANDROID_ID,别用随机 UUID |
| 心跳一直 403 | 账号模式 statecode 过期。重新 userLogin |
| 方案 | 稳定性 |
|---|---|
| SMBIOS UUID(推荐) | Windows wmic csproduct get UUID;Android 用 Settings.Secure.ANDROID_ID |
| 主板序列号 + 主机名 | 次选,组装机可能是空串要兜底 |
| MAC 地址(不推荐) | 换网卡 / 虚拟机迁移就变 |
| 随机 UUID 存文件 | 用户删了就锁死,会被判「换设备」扣时长 |
① 到期时间只在本地当缓存用,真正判断永远以服务端响应为准。 客户端时间随便改,别拿
expire_time 当唯一依据。② 心跳别省。默认 5 分钟一次,服务端能实时踢掉被封的卡; 只在启动时验一次,用户挂一晚都不知道卡已经被封了。
| 平台 | 取什么 | 注意 |
|---|---|---|
| iOS | 越狱:MGCopyAnswer(@"UniqueDeviceID");非越狱:Keychain UUID | 擦机才变,删 App 不变 |
| Windows EXE | 注册表 MachineGuid + 主板/硬盘序列号 | 重装系统才变;别用随机 GUID 存文件 |
| Android | Settings.Secure.ANDROID_ID + 序列号 | 恢复出厂才变 |
| macOS | IOPlatformUUID | 重装不变 |
| Linux | /etc/machine-id + 网卡 MAC | 重装才变 |
SHA256,取前 32 位 hex。
不要包含网卡 MAC 单独一位——用户换个网卡就锁死了。
机器码由客户端本地计算,服务端只做字符串比对,没有「查机器码」的接口。 要求:同一台机器每次算出来完全一样;不同机器不撞。
// —— Java —— static String getMachineCode(String appKey) throws Exception { String host = java.net.InetAddress.getLocalHost().getHostName(); String src = System.getProperty("os.name") + "|" + System.getProperty("os.arch") + "|" + host + "|" + appKey; byte[] d = MessageDigest.getInstance("SHA-256").digest(src.getBytes("UTF-8")); StringBuilder sb = new StringBuilder(); for (int i = 0; i < 16; i++) sb.append(String.format("%02x", d[i])); return sb.toString(); }
也不要只用 MAC 地址——换个网卡就换了机器码。