{
"app_key": "你的64位AppKe...",
"card_code": "用户输入的卡密",
"device_hash": "16-64位设备指纹(少于16位会被拒)",
"action": "verify" // 可选,心跳传 heartbeat
}
{
"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 ·
generate ·
ban ·
unban ·
del_card ·
kick ·
ban_machine ·
blacklist不传 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
// —— Node.js ——
const bin = Buffer.from(enc, 'base64');
const iv = bin.subarray(0, 12), tag = bin.subarray(12, 28), ct = bin.subarray(28);
const d = crypto.createDecipheriv('aes-256-gcm', Buffer.from(appKey, 'hex'), iv);
d.setAuthTag(tag);
const pt = Buffer.concat([d.update(ct), d.final()]);
{
"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 | 参数错误 / 设备码格式非法 / 签名不符 | 不重试,查代码 |
| 403 | 卡密无效、封禁、过期、绑定冲突 | 立即失效,重新弹授权 |
| 429 | IP 限频 | 退避重试 |
| 500 | 服务端异常 | 退避重试 |
后台「程序管理 → 公告管理」里填的文本,会在验证成功和心跳的响应里一并下发,字段名 data.notice。 后台没填就返回 null,不需要额外请求接口,也不用改请求参数。
// 响应里的位置 { "code": 200, "data": { "expire_time": "2026-11-30 23:59:59", "notice": "【重要】服务迁移通知,请重新登录" // 没公告时是 null } }
| 平台 | 取公告并判空 |
|---|---|
| Web JS | const n = r?.data?.notice; if (n) { /* 弹窗 */ } |
| 项目 | 说明 |
|---|---|
| 内容格式 | 纯文本。支持换行(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、不传卡密,就是「拉公告 / 变量 / 版本」接口。启动软件时先调一次,用户还没输卡密也能看到公告。
// 请求(不需要 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 } } }
// —— Node.js —— async function getNotice(appKey) { try { const res = await fetch(API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app_key: appKey }) }); const r = await res.json(); if (Number(r.code) !== 200) return { success: false, notice: null, error: r.msg }; return { success: true, notice: r?.data?.notice ?? null, error: null }; } catch (e) { return { success: false, notice: null, error: e.message }; } }
如果不想每次心跳都弹,客户端自己记一个「已看过的最新公告 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) // 继续正常业务 } } }
当前语言写法:Web JS: v.split(".").map(Number)
| 平台 | 取更新信息 |
|---|---|
| Web JS | const u = data?.update || {}; const url = u.url || ''; |
只传 app_key 不传卡密也能拿到(data.update),适合启动时先查一次版本再让用户输卡。
同一个 AppKey,后台这些开关改一下,接口的校验规则和返回结构就会变。 客户端如果写死了某种行为,开关一改就会出问题 —— 所以对接前先确认下表里的值。
| 参数 | 取值 | 对接口的影响 |
|---|---|---|
verify_mode |
0 / 1 / 2 |
0 = 正常校验(默认)—— 查卡密、查设备、查有效期。 1 = 关闭验证 —— 任何请求都返回 code=200,不查卡密。
为了让客户端不弹过期窗,响应里固定给 expire_time="2099-12-31 23:59:59"、
card_type="permanent"、custom_data=""。卡本身不会被激活。注意:封禁仍然有效 —— 卡状态是 2(已封禁)时照样返回 code=403。2 = 不验有效期 —— 卡密和设备照常校验,但过期也放行( code=200)。
|
machine_check |
0 / 1 |
1 = 绑定设备(默认)—— 换设备返回 code=403「已在其他设备登录」。0 = 不绑设备 —— 同一张卡可以在任意设备上用,不返回 403。 |
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「该账号已绑定其他设备,如需更换请联系管理员解绑」。0 = 允许多设备 —— 不绑设备,任意设备都能登录。 statecode 以后登录的为准,旧设备调 user_heartbeat 返回
code=403「登录态已失效,请重新登录」。系统里没有 401 —— 「登录态失效」统一用 403。 |
reg_need_invite |
0 / 1 |
1 = 注册必须带邀请码 —— user_register 不带 invite_code 返回 code=400。0 = 不需要(默认)。 |
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 头。注意:这两个字段不只管注册,对 verifyCard 的所有请求都生效(含验证、心跳、登录)。多人共用出口 IP(公司网、代理池、NAT)时必须填 0 或调大。 |
| 参数 | 取值 | 对接口的影响 |
|---|---|---|
encrypt_req |
0 / 1 / 2 |
请求体是否必须加密。 0 = 跟随全局(默认,看后台「全局配置 → 请求加密」) 1 = 强制加密 —— 不带 encrypted_data 返回 code=4002 = 强制明文 —— 带了密文也按明文处理 |
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 |
图片地址 | 图标地址,部分客户端会拿来做启动页。不影响任何校验逻辑。 |
price_hourprice_dayprice_weekprice_monthprice_seasonprice_yearprice_permanentprice_minute |
金额 | 只在网页和后台展示用,不影响任何 API 行为。填 0 表示不卖这种卡。 |
agent_key |
字符串 | 代理专用标识,用于区分「这张卡是哪个代理发的」。不影响 API 校验。 |
1.
reg_limit_window / reg_limit_count 不只管注册。
填了值之后,同一个出口 IP 上所有验证、心跳、登录请求都会被限频,超了返回 429。多人共用 IP 时必须填 0。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 |
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[] | 生成卡密时写的自定义内容,没写就是空串 |
data.notice | char[] | 程序公告,后台没填是 null |
data.update | object | { version, url, log, force },版本更新用 |
data.variables | array | 程序变量(键值对数组),没配就是空数组 |
if (r.code === 200) {
const expire = r.data.expire_time;
// 通过:进主界面,把卡密和到期时间存起来
} else {
console.error('验证失败:' + r.msg);
}
续费就是再买一张卡、在同一台设备上再验证一次 —— 服务端会发现这台设备已经绑过卡, 直接把新卡时长累加到原来那张卡上,不用用户做任何额外操作。
卡密心跳 heartbeat(card_code, device_hash)
| 字段 | 类型 | 说明 |
|---|---|---|
action | char[] | 固定 heartbeat |
card_code | char[] | 已激活的卡密 |
device_hash | char[] | 必须是当初激活时那台设备 |
结构和验证接口一样(code / msg / data.expire_time / data.notice / data.update)。
失败时 code=403,客户端应该立刻退回输卡密界面,把本地存的卡密清掉。
掉线判定时间在后台「全局配置 → 心跳超时」里调,建议设为客户端心跳间隔的 3 倍。
解除设备绑定 unbind(card_code)
| 字段 | 类型 | 说明 |
|---|---|---|
action | char[] | 固定 unbind |
card_code | char[] | 要解绑的卡密 |
| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功;400 程序关闭了自助解绑 / 次数超限 |
msg | char[] | 成功时会说明扣了多少小时代价 |
data.penalty_hours | float | 本次解绑扣掉的时长,没配惩罚就是 0 |
还能配「N 小时内最多解绑 M 次」和「每次解绑扣 X 小时」,防止有人拿解绑当免费换机工具。
除了卡密,还支持注册账号 → 用卡密充值 → 账号登录。两种模式可以同时用,老用户继续输卡密,新用户注册账号。 所有请求都发往同一个 /Verifyfile/api.php,只用 action 区分。
| action | 用途 | 必填参数 |
|---|---|---|
user_register | 注册账号 | username password confirm_password;invite_code 可选(填了按码内时长直接赋有效期) |
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 |
// 请求 { "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() |
code / msg / data| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 200 成功,403 凭据无效 / 未充值 / 已到期 / 设备冲突 |
data.expire_time | char[] | 当前到期时间 |
data.statecode | char[] | 会话令牌,心跳必须带上。每次登录都会刷新 |
data.notice | char[] | 应用公告,后台没填时为 null |
// 请求 { "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)
| 字段 | 类型 | 说明 |
|---|---|---|
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 |
// 请求 { "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(应用公告),非空就弹给用户看。
用户公告(跟着登录/心跳下发)
| 接口 | 字段 | 说明 |
|---|---|---|
user_login | data.notice | 登录成功时下发 |
user_heartbeat | data.notice | 每次心跳都下发,公告改了最迟一个心跳周期就能收到 |
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[] | 密码(必须正确,防止别人替你解绑) |
| 字段 | 类型 | 说明 |
|---|---|---|
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[] | 可选。传了会校验这张卡是不是绑在这台设备上 |
| 字段 | 类型 | 说明 |
|---|---|---|
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" } }
管理端接口(发卡网 / 后台对接)
这一组接口用 api_token 鉴权,不走 AppKey 签名,也不绑定某个程序。至少还要传一个合法 app_key 用于加密通道,请求体同样要 encrypted_data 加密。
api_token— 后台「系统配置」里的对接通信密钥。没传或错了直接 403。app_key— 任取一个已存在程序的 AppKey,只为走加密通道。
generate — 批量发卡
{
"action": "generate",
"api_token": "你的对接密钥",
"app_id": 12,
"count": 5,
"card_type": "month",
"prefix": "VIP",
"notes": "发卡网订单 #1234"
}
card_type— hour / day / week / month / season / year / permanent- 成功返回
data.cards(卡密数组)和data.card_string(换行拼接整串)
ban / unban — 封卡 / 解封
{"action":"ban","api_token":"...","card_code":"ABC123"}
{"action":"unban","api_token":"...","card_code":"ABC123"}
- 封禁后客户端验证返回「此卡密已被管理员封禁」,解封恢复可用
del_card — 彻底删卡
{"action":"del_card","api_token":"...","card_code":"ABC123"}
- 删除后不可恢复,卡的设备绑定记录一并清除
kick — 强制下线
{"action":"kick","api_token":"...","card_code":"ABC123"}
- 踢掉当前绑定的设备并解绑,卡还能在别的设备上重新激活
ban_machine / blacklist — 拉黑机器码
{
"action": "ban_machine",
"api_token": "你的对接密钥",
"device_hash": "客户端上报的设备特征码",
"reason": "外挂检测",
"client_ip": "1.2.3.4"
}
device_hash必填,client_ip选填(PHP 中转时代理真实访客 IP)- 拉黑后该设备的所有验证请求直接返回「访问受限」
- 拉黑记录归属到
app_key对应的程序,开发者能在「防御与拉黑」里看到自己程序的
unbind — 解绑设备(不需要 api_token)
{"action":"unbind","app_key":"...","card_code":"ABC123"}
- 和
kick的区别:unbind由用户自助发起,会按「程序配置 → 解绑扣时」扣掉相应时长;kick是管理员强制,不扣时间 - 扣多少由程序配置的
unbind_penalty决定,配 0 表示不扣时;支持小数(如 2.5 小时) - 程序若关闭了「机器码 / IP 解绑」(
unbind_enabled=0),unbind会被拒绝:该程序已关闭自助解绑,请联系管理员 - 配了「解绑次数限制」时,超出会返回:
解绑次数已达上限(N 小时内最多 M 次) - 成功响应里的
data.penalty_hours是本次实际扣掉的小时数,可用于提示用户
三个最容易踩的格式坑
- _ts 必须是秒,不是毫秒:10 位整数(如
1761912000)。传 13 位毫秒(如1761912000123)会被判成「请求已过期」,因为差值直接超出 ±300 秒窗口 - device_hash 长度必须 16–64 字符:短于 16 或长于 64 返回
设备码格式不正确。用 SHA-256 十六进制串(64 位)或 UUID(36 位)都合适 - app_key 必须 64 位十六进制:不是 Base64,也不是截断的短串。长度不对会直接判
应用身份标识无效
时间字段说明(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 即永久 —— // Node.js / JavaScript const isPermanent = typeof expireStr === 'string' && expireStr.startsWith('2099-');
- 接口返回的是到期时刻,不是剩余秒数;需要剩余时间就用「到期时刻 − 当前时刻」自己算
- 永久卡(2099)算出来的剩余天数没有意义,界面上直接显示「永久」
expire_time为null时不要参与任何时间计算,按「未充值」分支处理recharge接口额外返回data.added_hours(整数,本次充值的时长小时数),可用于提示「已增加 N 小时」- 永久卡不能用于
recharge(时长无限无法累加),文档前面已说明;账号体系里请改用月卡/年卡
错误码速查
400— 参数错误 / 参数类型不对(传了数组或对象)/ 加密格式不对 / 时间戳过期 / 业务前置条件不满足403— 鉴权失败 / 卡密被封 / 程序被禁用 / 账号到期 / 卡密已冻结 / 解绑被拒429— 触发频率限制,客户端应退避重试500— 服务端异常
card_code 传成数组 ["a","b"]
或对象 {"$ne":null} 时,服务端返回
400 参数 card_code 格式无效:必须是字符串;card_code 缺失时返回 400 缺少卡密(card_code)参数,
不会被"当成空值"继续往下走。这是防止类型混淆攻击。
状态文案对照(客户端可直接展示 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 | 触发限频,退避后重试 |
调用示例
// —— Node.js —— const r1 = await callRaw({ action:'ip_unbind_user', username:u, password:p }); const r2 = await callRaw({ action:'ip_unbind', card_code:card });
错误处理(客户端要做的分流)
| code | msg | 客户端动作 |
|---|---|---|
403 | 账号未充值,请充值后登录 | 弹充值窗口,让用户输卡密调 recharge |
403 | 账号已到期,请充值后登录 | 同上,文案可换成「已到期,请续费」 |
403 | 用户名或密码错误 | 提示检查输入,不要清空已填的用户名 |
403 | 该账号已绑定其他设备,如需更换请联系管理员解绑 | 显示联系方式,或调 ip_unbind_user 自助解绑 |
403 | 账号已被禁用 | 提示联系管理员,不要循环重试 |
400 | 该用户名已被注册 / 密码至少 6 位 / 用户名长度需在 2–50 位之间 | 在注册表单上就地提示 |
// —— Node.js —— const lg = await callRaw({ action:'user_login', username:u, password:p, device_hash:mc }); if (lg.code === 200) enterMain(lg.data.expire_time, lg.data.statecode); else if (/未充值|已到期/.test(lg.msg || '')) showRecharge(); else if (/其他设备/.test(lg.msg || '')) showUnbind(); else showToast(lg.msg || '登录失败');
| 场景 | code | msg |
|---|---|---|
| 用户名重复 | 400 | 该用户名已被注册 |
| 两次密码不一致 | 400 | 两次输入的密码不一致 |
| 用户名格式不合规 | 400 | 用户名长度需在 2–50 位之间 |
| 密码太短 | 400 | 密码至少 6 位 |
| 账号密码错误 | 403 | 用户名或密码错误 |
| 未充值就登录 | 403 | 账号未充值,请充值后登录 |
| 账号到期 | 403 | 账号已到期,请充值后登录 |
| 换设备登录 | 403 | 该账号已绑定其他设备,如需更换请联系管理员解绑 |
| 卡密无效 / 已用 | 403 | 该卡密已被使用或不处于可充值状态 |
| statecode 失效 | 403 | 登录态已失效,请重新登录 |
| 账号被禁用 | 403 | 账号已被禁用 |
// —— Node.js —— const reg = await callRaw({ action:'user_register', username:u, password:p, confirm_password:p }); const rc = await callRaw({ action:'recharge', username:u, card_code:card }); const lg = await callRaw({ action:'user_login', username:u, password:p, device_hash:mc }); const hb = await callRaw({ action:'user_heartbeat', username:u, password:p, statecode:lg.data.statecode });
WebCrypto,零依赖。注意:浏览器端 AppKey 是暴露的,只适合 H5 试玩版或内部工具。// 请求加密:Base64( hmac[32] ‖ iv[16] ‖ ciphertext ) async function gyEncrypt(params, appKey) { const hexToBuf = h => new Uint8Array(h.match(/../g).map(x => parseInt(x, 16))); const master = hexToBuf(appKey); async function hmac(key, msg) { const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); return new Uint8Array(await crypto.subtle.sign('HMAC', k, msg)); } const encKey = await hmac(master, new TextEncoder().encode('enc')); const macKey = await hmac(master, new TextEncoder().encode('mac')); const iv = crypto.getRandomValues(new Uint8Array(16)); const ck = await crypto.subtle.importKey('raw', encKey, { name: 'AES-CBC' }, false, ['encrypt']); params._ts = Math.floor(Date.now() / 1000); const ct = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-CBC', iv }, ck, new TextEncoder().encode(JSON.stringify(params)) )); // MAC 覆盖 iv ‖ ciphertext const buf = new Uint8Array(16 + ct.length); buf.set(iv); buf.set(ct, 16); const mac = await hmac(macKey, buf); const all = new Uint8Array(32 + buf.length); all.set(mac); all.set(buf, 32); return btoa(String.fromCharCode(...all)); } // 响应解密:Base64( iv[12] ‖ tag[16] ‖ ciphertext ) async function gyDecrypt(raw, appKey) { const outer = JSON.parse(raw); if (!outer.encrypted_data) return outer; // 没加密直接用 const bin = Uint8Array.from(atob(outer.encrypted_data), c => c.charCodeAt(0)); const master = new Uint8Array(appKey.match(/../g).map(x => parseInt(x, 16))); const ck = await crypto.subtle.importKey('raw', master, { name: 'AES-GCM' }, false, ['decrypt']); // WebCrypto 的 AES-GCM 要求 tag 拼在密文末尾(ciphertext ‖ tag), // 而服务端返回的是 tag 在前、密文在后,所以必须先拆开再重排。 const iv = bin.slice(0, 12); const tag = bin.slice(12, 28); const ct = bin.slice(28); const ctWithTag = new Uint8Array(ct.length + tag.length); ctWithTag.set(ct, 0); ctWithTag.set(tag, ct.length); const plain = await crypto.subtle.decrypt( { name: 'AES-GCM', iv, tagLength: 128 }, ck, ctWithTag ); return JSON.parse(new TextDecoder().decode(plain)); } // 发起请求 const API = 'https://yihaiyun.cn/Verifyfile/api.php'; const APP_KEY = '你的64位AppKey'; async function gyCall(params) { const res = await fetch(API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app_key: APP_KEY, encrypted_data: await gyEncrypt(params, APP_KEY) }) }); return await gyDecrypt(await res.text(), APP_KEY); } // 用法 (async () => { try { const r = await gyCall({ card_code: '用户输入的卡密', device_hash: '16-64位设备指纹' }); if (r.code == 200) console.log('到期:', r.data.expire_time); else console.error('失败:', r.msg); } catch (e) { console.error('网络错误', e); } })();
① AppKey 一定会被用户看到。前端只放试玩版,正式版走你自己的后端中转。
②
WebCrypto 只在 HTTPS 或 localhost 下可用,http 站点直接 crypto.subtle 为 undefined。③ 服务端没配 CORS 的话,跨域请求会被浏览器拦。同域部署最省事。
gyCall()。
| 场景 | 适合 | 行为 |
|---|---|---|
| 全屏验证 | 启动即验证 | 盖住整个界面,验证通过才进去;不通过不给操作 |
| 弹窗验证 | 功能内验证 | 只在点某个按钮时弹,验证完自动关闭,不打断主流程 |
| 命令行 | 服务端 / 挂机 / CI | 没有界面,验证通过打印到期时间,失败返回非 0 退出码 |
// 放在启动页或主页的 onCreate 里,验证通过前先显示加载层 class VerifyActivity : AppCompatActivity() { override fun onCreate(s: Bundle?) { super.onCreate(s) setContentView(R.layout.activity_verify) // 本地没存过卡密 → 先让用户输,别拿空串去验证 val saved = prefs.getString("card", "").orEmpty() if (saved.isEmpty()) { showBlockingDialog("还没有绑定卡密,请先输入"); return } lifecycleScope.launch(Dispatchers.IO) { val r = gyCall(JSONObject().apply { put("card_code", saved) put("device_hash", deviceHash()) }) withContext(Dispatchers.Main) { if (r != null && r.optInt("code") == 200) { // ✅ 通过:把到期时间存一份,下次启动先看缓存 val d = r.optJSONObject("data") prefs.edit() .putString("expire", d?.optString("expire_time")) .apply() startActivity(Intent(this@VerifyActivity, MainActivity::class.java)) finish() } else { // ❌ 不通过:停在当前页,只留「重新输入卡密」和「退出」 showBlockingDialog(r?.optString("msg") ?: "网络错误") } } } } } // 阻塞式提示:只有「重新输入」和「退出」,没有「取消」 // 这个页面由输卡界面 startActivity 进来;「重新输入」清掉本地卡密后退回,让输卡界面重新收 private fun showBlockingDialog(msg: String) { AlertDialog.Builder(this) .setTitle("验证未通过") .setMessage(msg) .setCancelable(false) .setPositiveButton("重新输入") { _, _ -> prefs.edit().remove("card").remove("expire").apply() finish() } .setNegativeButton("退出") { _, _ -> finishAffinity() } .show() }
// 用户点「解锁高级功能」时才弹,不打断正常使用 fun requireVerify(onPass: () -> Unit) { if (prefs.getLong("expire", 0) > System.currentTimeMillis()) { onPass(); return } if (isFinishing || isDestroyed) return val dlg = AlertDialog.Builder(this) .setTitle("请输入卡密") .setView(R.layout.dialog_card_input) .setCancelable(true) // 弹窗模式允许取消 .setPositiveButton("验证", null) .create() dlg.setOnShowListener { dlg.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { val code = dlg.findViewById<EditText>(R.id.etCard).text.toString().trim() if (code.isEmpty()) { toast("请输入卡密"); return@setOnClickListener } lifecycleScope.launch(Dispatchers.IO) { val r = gyCall(JSONObject().apply { put("card_code", code); put("device_hash", deviceHash()) }) withContext(Dispatchers.Main) { if (r != null && r.optInt("code") == 200) { val data = r.getJSONObject("data") prefs.edit() .putString("card", code) .putLong("expire", parseTime(data.optString("expire_time"))) .apply() // 顺便把公告弹出来(后台填了才有) data.optString("notice").takeIf { it.isNotBlank() }?.let { n -> AlertDialog.Builder(this@MainActivity).setTitle("公告").setMessage(n).setPositiveButton("知道了", null).show() } dlg.dismiss() onPass() } else { toast(r?.optString("msg") ?: "网络错误") } } } } } dlg.show() }
final class VerifyViewController: UIViewController { private let cardField = UITextField() private let tipLabel = UILabel() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground // 两块 UI:输入框 + 按钮,简单摆一下 cardField.placeholder = "请输入卡密" cardField.borderStyle = .roundedRect let go = UIButton(type: .system) go.setTitle("验证", for: .normal) go.addTarget(self, action: #selector(doVerify), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [cardField, go, tipLabel]) stack.axis = .vertical; stack.spacing = 16 stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), ]) } @objc private func doVerify() { let code = cardField.text?.trimmingCharacters(in: .whitespaces) ?? "" guard !code.isEmpty else { tipLabel.text = "请输入卡密"; return } tipLabel.text = "验证中…" cardField.isEnabled = false DispatchQueue.global().async { let r = gyCall(["card_code": code, "device_hash": DeviceHash.machine()]) DispatchQueue.main.async { if let r, (r["code"] as? Int) == 200 { // ✅ 通过:换成主界面 let data = r["data"] as? [String: Any] UserDefaults.standard.set(data?["expire_time"], forKey: "expire") UserDefaults.standard.set(code, forKey: "card") self.view.window?.rootViewController = MainViewController() } else { self.cardField.isEnabled = true self.tipLabel.text = (r?["msg"] as? String) ?? "网络错误" } } } } }
// 在任意界面调用,弹完就关,不影响其他功能 func requireVerify(from vc: UIViewController, onPass: @escaping () -> Void) { let alert = UIAlertController(title: "需要验证", message: "请输入卡密解锁此功能", preferredStyle: .alert) alert.addTextField { $0.placeholder = "卡密"; $0.autocapitalizationType = .allCharacters } let go = UIAlertAction(title: "验证", style: .default) { _ in let code = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? "" guard !code.isEmpty else { return } DispatchQueue.global().async { let r = gyCall(["card_code": code, "device_hash": DeviceHash.machine()]) DispatchQueue.main.async { if let r, (r["code"] as? Int) == 200 { let d = r["data"] as? [String: Any] UserDefaults.standard.set(code, forKey: "card") // 到期时间也存一份,下次进来先看本地缓存,不用每次都弹 UserDefaults.standard.set(d?["expire_time"], forKey: "expire") onPass() // 公告:有就再弹一个 if let n = d?["notice"] as? String, !n.isEmpty { let notice = UIAlertController(title: "公告", message: n, preferredStyle: .alert) notice.addAction(UIAlertAction(title: "知道了", style: .default)) vc.present(notice, animated: true) } } else { let fail = UIAlertController( title: "验证失败", message: (r?["msg"] as? String) ?? "网络错误", preferredStyle: .alert) fail.addAction(UIAlertAction(title: "好", style: .default)) vc.present(fail, animated: true) } } } } alert.addAction(go) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) vc.present(alert, animated: true) }
// 场景:把验证做成 .so,Java/Kotlin 层不碰密钥,防止反编译直接读字符串 // ---- native-lib.cpp ---- #include <jni.h> #include <string> // 复用上面 C/C++ 示例里的 gyEncrypt / gyDecrypt / httpPost extern "C" JNIEXPORT jboolean JNICALL Java_com_yourapp_Gy_nativeVerify(JNIEnv* env, jclass, jstring jCard, jstring jDevice) { const char* card = env->GetStringUTFChars(jCard, nullptr); const char* device = env->GetStringUTFChars(jDevice, nullptr); std::string json = std::string("{\"card_code\":\"") + card + "\",\"device_hash\":\"" + device + "\"}"; std::string body = "{\"app_key\":\"" + std::string(APP_KEY) + "\",\"encrypted_data\":\"" + gyEncrypt(json, g_master) + "\"}"; std::string resp = httpPost(API, body); env->ReleaseStringUTFChars(jCard, card); env->ReleaseStringUTFChars(jDevice, device); return resp.find("\"code\":200") != std::string::npos ? JNI_TRUE : JNI_FALSE; } // ---- CMakeLists.txt ---- # find_package(CURL REQUIRED) # find_package(OpenSSL REQUIRED) # target_link_libraries(native-lib CURL::libcurl OpenSSL::SSL OpenSSL::Crypto) // ---- Kotlin 侧 ---- object Gy { init { System.loadLibrary("native-lib") } external fun nativeVerify(card: String, device: String): Boolean }
// 场景:挂机脚本、CI 流水线、服务端定时校验 // 返回码约定:0 = 验证通过,1 = 验证失败,2 = 网络错误 int wmain(int argc, wchar_t** argv) { if (argc < 2) { std::cout << "用法: demo.exe <卡密>\n"; return 1; } std::string card = toUtf8(argv[1]); std::string device = machineCode(); // 见「设备指纹」章节 std::string json = std::string("{\"_ts\":") + std::to_string(time(nullptr)) + ",\"card_code\":\"" + card + "\",\"device_hash\":\"" + device + "\"}"; std::string body = "{\"app_key\":\"" + std::string(APP_KEY) + "\",\"encrypted_data\":\"" + gyEncrypt(json, g_master) + "\"}"; std::string resp = httpPost(API, body); if (resp.empty()) { std::cerr << "网络错误\n"; return 2; } auto b64 = extractEncrypted(resp); // 从 {"encrypted_data":"..."} 里取出 std::string plain = gyDecrypt(b64, g_master); if (plain.find("\"code\":200") == std::string::npos) { std::cerr << "验证失败: " << plain << "\n"; return 1; } std::cout << "验证通过\n"; std::cout << "响应: " << plain << "\n"; return 0; }
// 全屏:盖住整页,验证通过才移除遮罩 function showFullscreen(failed) { const mask = document.createElement('div'); mask.id = 'gy-mask'; mask.style.cssText = 'position:fixed;inset:0;background:#fff;z-index:99999;display:flex;' + 'align-items:center;justify-content:center;flex-direction:column;gap:12px;'; mask.innerHTML = '<h3>请输入卡密</h3><input id="gy-card"><button id="gy-go">验证</button><p id="gy-tip"></p>'; document.body.appendChild(mask); document.getElementById('gy-go').onclick = async () => { const code = document.getElementById('gy-card').value.trim(); const r = await gyCall({ card_code: code, device_hash: deviceHash() }); if (r.code == 200) { mask.remove(); initApp(); } else { document.getElementById('gy-tip').textContent = r.msg; } }; } // 弹窗:只在点按钮时弹 async function requireVerify() { const code = prompt('请输入卡密'); if (!code) return false; const r = await gyCall({ card_code: code, device_hash: deviceHash() }); if (r.code == 200) { localStorage.setItem('gy_card', code); localStorage.setItem('gy_expire', r.data.expire_time); if (r.data.notice) alert(r.data.notice); return true; } alert('验证失败:' + r.msg); return false; }
① 到期时间只在本地当缓存用,真正判断永远以服务端响应为准。客户端时间随便改,别拿
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 单独一位——用户换个网卡就锁死了。
机器码由客户端本地计算,服务端只做字符串比对,没有「查机器码」的接口。 要求:同一台机器每次算出来完全一样;不同机器不撞。
// —— Node.js —— function getMachineCode(appKey) { const os = require('os'), crypto = require('crypto'); const src = [os.hostname(), os.cpus()[0]?.model ?? '', os.arch(), appKey].join('|'); return crypto.createHash('sha256').update(src).digest('hex').slice(0, 32); }
也不要只用 MAC 地址——换个网卡就换了机器码。