S6网络验证
这是公开的对接文档,任何客户端都可以按下面的说明接入。
页面中的 AppKey 已用 x 打码,真实密钥请登录你的账号在「程序管理 / 对接文档」里查看。

对接文档

按语言分开的接入示例。左侧切换平台,右侧跳转页面内小节。

接口地址与凭据
POST https://yihaiyun.cn/Verifyfile/api.php
响应加密: 已开启 AES-256-GCM 请求加密: 强制 AES-256-CBC+HMAC
请求参数 / 响应结构
明文请求
{
  "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 秒
encrypted_data 不能是空字符串。传了这个字段但值是空串,服务端直接返回 400 请求密文为空 —— 这是为了防止「清空密文 → 服务端退回明文解析」的降级攻击。 要么带上完整密文走加密通道,要么整个字段都不传(明文模式,需后台没强制加密)。
action 必须是下面 16 个之一,拼错会被直接拒绝(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
// —— PHP ——
$bin = base64_decode($enc);
$iv  = substr($bin, 0, 12);
$tag = substr($bin, 12, 16);
$ct  = substr($bin, 28);
$pt  = openssl_decrypt($ct, 'aes-256-gcm', hex2bin($appKey), OPENSSL_RAW_DATA, $iv, $tag);
{
  "code": 200,
  "msg":  "OK",
  "data": {
    "expire_time": "2026-11-30 23:59:59",
    "card_type":   "month",
    "custom_data": "",
    "variables":   { "hb_interval": "300" },
    "notice":      "应用公告内容"          // 可选:后台没填公告时为 null
  }
}
关于 data.notice:后台「程序管理 → 公告管理」里填的内容,验证和心跳都会带下来。 留空则字段为 null。客户端拿到非空字符串后自己弹窗展示即可,不需要额外请求接口。
错误码
code含义客户端处理
200验证通过放行,缓存 expire_time
400参数错误 / 设备码格式非法 / 签名不符不重试,查代码
403卡密无效、封禁、过期、绑定冲突立即失效,重新弹授权
429IP 限频退避重试
500服务端异常退避重试
403 和网络错误必须分开处理。403 是"这台机器不该再用",网络错误是"暂时连不上"。混在一起,被封的用户还能继续用一段时间。
应用公告(可选)
请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json不传卡密,只传 app_key

后台「程序管理 → 公告管理」里填的文本,会在验证成功和心跳的响应里一并下发,字段名 data.notice。 后台没填就返回 null,不需要额外请求接口,也不用改请求参数。

// 响应里的位置
{
  "code": 200,
  "data": {
    "expire_time": "2026-11-30 23:59:59",
    "notice":      "【重要】服务迁移通知,请重新登录"   // 没公告时是 null
  }
}
读取公告
平台取公告并判空
PHP$n = $r['data']['notice'] ?? null; if ($n !== null && $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 分钟)收到
不要在公告里放 HTML。服务端不做过滤,也不做转义——它只是把字符串原样塞进 JSON。 如果客户端用 innerHTML 渲染,别人只要能在后台填公告就能让所有客户端执行任意脚本。 一律按纯文本处理。
独立调用(不验证也能拿)

只传 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 }
  }
}
封装成函数(可直接抄)
// —— PHP ——
function getNotice(string $api, string $appKey): array {
    $raw = http_post_json($api, ['app_key' => $appKey]);
    $r = json_decode((string)$raw, true);
    if (!is_array($r))            return ['success' => false, 'notice' => null, 'error' => '解析失败'];
    if ((int)$r['code'] !== 200) return ['success' => false, 'notice' => null, 'error' => $r['msg']];
    return ['success' => true, 'notice' => $r['data']['notice'] ?? null, 'error' => null];
}
心跳也会带公告。所以公告改了之后,已经在用的客户端最迟一个心跳周期(默认 5 分钟)就能收到,不用重启软件。
如果不想每次心跳都弹,客户端自己记一个「已看过的最新公告 hash」即可——比较内容变化再弹。
版本更新(可选)
请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json不传卡密,只传 app_key

后台「程序管理 → 管理 → 更新配置」里可以给每个程序单独填新版本下载地址、强制更新开关。 这些值会在验证成功、心跳、以及只传 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=可选
    }
  }
}
四个字段的含义
字段类型说明
versionstring服务端记录的最新版本号,可能为空字符串
urlstring新版本下载地址,为空表示没配更新,客户端应直接跳过
logstring更新说明,取自程序的备注字段
forceint1 = 强制更新(低于 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)
            // 继续正常业务
        }
    }
}
版本号比较(别用字符串比)
"2.10.0" 按字符串比会小于 "2.9.0"(因为字符 '1' < '9')。必须按段转成整数再逐段比较。
当前语言写法:PHP: version_compare($a, $b, '<')
读取更新信息
平台取更新信息
PHP$u = $data['update'] ?? null; $url = $u['url'] ?? '';
心跳也会带更新信息。所以后台把 force 改成 1 之后,正在运行的客户端最迟一个心跳周期就能收到并强制更新,不用重新发版。
只传 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 能不能用、扣多少时间)
参数取值对接口的影响
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_unbind
web_unbind_ip
0 / 1 控制网页端解绑入口是否开放,不影响 API 的 unbind。 web_unbind_ip=1 时网页解绑会同时清掉 IP 绑定。
三、限频(决定会不会被 429)
参数取值对接口的影响
reg_limit_window
reg_limit_count
秒数 / 次数 两个都填 0 = 不限频(默认)。
填了之后,同一 IP 在 window 秒内最多 count 次请求,超了返回 code=429「请求过于频繁,请稍后再试」,并带 Retry-After 头。
注意:这两个字段不只管注册,对 verifyCard 的所有请求都生效(含验证、心跳、登录)。多人共用出口 IP(公司网、代理池、NAT)时必须填 0 或调大。
四、传输格式(决定请求怎么发、响应长什么样)
参数取值对接口的影响
encrypt_req 0 / 1 / 2 请求体是否必须加密。
0 = 跟随全局(默认,看后台「全局配置 → 请求加密」)
1 = 强制加密 —— 不带 encrypted_data 返回 code=400
2 = 强制明文 —— 带了密文也按明文处理
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_hour
price_day
price_week
price_month
price_season
price_year
price_permanent
price_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 区分。
单卡密模式的接口一览
action用途必填参数
(不传 / verify)卡密验证card_code device_hash
heartbeat心跳续期card_code device_hash
unbind解除设备绑定card_code
ip_unbind解绑卡密绑定的 IPcard_code(可选 device_hash)

卡密验证 card_code + device_hash

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]应用的 64 位 AppKey,外层明文
card_codechar[]用户输入的卡密,不区分大小写
device_hashchar[]设备唯一标识,16–64 位。见「设备指纹」章节
actionchar[]可不传,等同于 verify
返回值:code / msg / data
字段类型说明
codeint200 成功,403 卡密无效/被封/换设备,其他见「错误码」
msgchar[]提示信息,失败时可直接展示给用户
data.expire_timechar[]到期时刻 YYYY-MM-DD HH:MM:SS;永久卡是 2099-12-31 23:59:59
data.card_typechar[]hour / day / week / month / season / year / permanent
data.custom_datachar[]生成卡密时写的自定义内容,没写就是空串
data.noticechar[]程序公告,后台没填是 null
data.updateobject{ version, url, log, force },版本更新用
data.variablesarray程序变量(键值对数组),没配就是空数组
最小可用代码
PHP
if (($r['code'] ?? 0) === 200) {
    $expire = $r['data']['expire_time'];
    // 通过:进主界面,把卡密和到期时间存起来
} else {
    die('验证失败:' . ($r['msg'] ?? '网络错误'));
}
把 card_code 和 device_hash 存到本地。 下次启动直接用 heartbeat 续期,不用让用户每次重新输卡密。
续费就是再买一张卡、在同一台设备上再验证一次 —— 服务端会发现这台设备已经绑过卡, 直接把新卡时长累加到原来那张卡上,不用用户做任何额外操作。

卡密心跳 heartbeat(card_code, device_hash)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
actionchar[]固定 heartbeat
card_codechar[]已激活的卡密
device_hashchar[]必须是当初激活时那台设备
返回值

结构和验证接口一样(code / msg / data.expire_time / data.notice / data.update)。 失败时 code=403,客户端应该立刻退回输卡密界面,把本地存的卡密清掉。

心跳别省。默认 5 分钟一次,服务端靠它判断设备在线,也能实时踢掉被封的卡。 纯靠启动时验证一次,用户挂一整晚都不知道卡已经被封了。
掉线判定时间在后台「全局配置 → 心跳超时」里调,建议设为客户端心跳间隔的 3 倍。

解除设备绑定 unbind(card_code)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
actionchar[]固定 unbind
card_codechar[]要解绑的卡密
返回值
字段类型说明
codeint200 成功;400 程序关闭了自助解绑 / 次数超限
msgchar[]成功时会说明扣了多少小时代价
data.penalty_hoursfloat本次解绑扣掉的时长,没配惩罚就是 0
解绑是可以在后台关掉的。「程序配置 → 允许自助解绑」关掉后,客户端调这个接口会直接返回 400, 用户只能找你手动解绑。
还能配「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账号解绑 IPusername password
ip_unbind卡密解绑 IPcard_code(可选 device_hash)
顺序不能反:先 user_register 注册,再 recharge 充值,之后才能 user_login。 新注册的账号到期时间为空,直接登录会返回「账号未充值,请充值后登录」。

用户注册 user_register(username, password, confirm_password)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]应用的 64 位 AppKey,外层明文
actionchar[]固定 user_register
usernamechar[]2–50 位,只允许字母、数字、下划线、点、横线。同一应用内唯一
passwordchar[]至少 6 位
confirm_passwordchar[]必须与 password 完全一致
返回值:code / msg / data
字段类型说明
codeint200 成功,其他为失败
msgchar[]提示信息,失败时可直接展示给用户
data.usernamechar[]注册成功的用户名
data.idint用户 ID
JSON
// 请求
{
  "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)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]应用的 64 位 AppKey
actionchar[]固定 recharge
usernamechar[]要充值的账号
card_codechar[]一张未使用过的卡密
返回值:code / msg / data
字段类型说明
codeint200 成功
data.expire_timechar[]充值后的到期时间,格式 YYYY-MM-DD HH:MM:SS
data.added_hoursfloat本次增加的时长(小时)
data.card_typechar[]卡密类型名称,如「月卡」
JSON
// 请求
{
  "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)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]应用的 64 位 AppKey
actionchar[]固定 user_login
usernamechar[]注册时的用户名
passwordchar[]注册时的密码
device_hashchar[]16–64 位设备指纹,见下方 GetMachineCode()
返回值:code / msg / data
字段类型说明
codeint200 成功,403 凭据无效 / 未充值 / 已到期 / 设备冲突
data.expire_timechar[]当前到期时间
data.statecodechar[]会话令牌,心跳必须带上。每次登录都会刷新
data.noticechar[]应用公告,后台没填时为 null
JSON
// 请求
{
  "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":      "【公告】账号模式也能收到公告"
  }
}
首次登录会绑定 device_hash,之后换设备登录会被拒(403)。需要换设备请在后台「用户管理」里解绑。

用户心跳 user_heartbeat(username, password, statecode)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
请求参数
字段类型说明
app_keychar[]应用的 64 位 AppKey
actionchar[]固定 user_heartbeat
usernamechar[]用户名
passwordchar[]密码
statecodechar[]登录时拿到的会话令牌
返回值:code / msg / data
字段类型说明
codeint200 成功,403 登录态失效 / 已到期 / 已禁用
data.expire_timechar[]最新到期时间
data.noticechar[]应用公告,后台没填时为 null
JSON
// 请求
{
  "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(应用公告),非空就弹给用户看。

用户公告(跟着登录/心跳下发)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json没有独立 action,跟着 user_login / user_heartbeat 一起返回
和卡密模式完全一致:后台「程序管理 → 公告管理」填的内容,会在 user_login 和 user_heartbeat 的响应里以 data.notice 一起返回。 后台没填就是 null。客户端拿到非空字符串直接弹窗,不需要单独调接口。
返回位置
接口字段说明
user_logindata.notice登录成功时下发
user_heartbeatdata.notice每次心跳都下发,公告改了最迟一个心跳周期就能收到
verify / heartbeatdata.notice卡密模式同样下发
JSON
// 登录 / 心跳的 data 里都会带这个字段
{
  "code": 200,
  "data": {
    "expire_time": "2026-11-29 14:54:24",
    "statecode":   "eba30d6d6a058bc4f1a2...",
    "notice":      "【公告】服务器今晚 23:00 迁移"   // 没公告时为 null
  }
}

账号解绑 IP ip_unbind_user(username, password)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
用户换了网络(IP 变了)登录被拒时,用这个接口自己解绑。解绑后可以在任意网络登录,下次登录会重新绑定当前 IP。 只有应用开启了「IP 校验」时才需要,没开的话 IP 不影响登录。
请求参数
字段类型说明
app_keychar[]应用的 64 位 AppKey
actionchar[]固定 ip_unbind_user
usernamechar[]用户名
passwordchar[]密码(必须正确,防止别人替你解绑)
返回值
字段类型说明
codeint200 成功,403 密码错误 / 未绑定 IP / 已禁用
data.old_ipchar[]解绑前绑定的 IP,便于展示
JSON
{
  "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?)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json
卡密模式下,这张卡绑定的 IP 不对了(换网络、换机房),用这个接口解绑。 只能解绑还没过期的卡。传了 device_hash 会额外校验归属,防止别人拿泄露的卡密给你解绑。
请求参数
字段类型说明
app_keychar[]应用的 64 位 AppKey
actionchar[]固定 ip_unbind
card_codechar[]要解绑的卡密
device_hashchar[]可选。传了会校验这张卡是不是绑在这台设备上
返回值
字段类型说明
codeint200 成功,403 卡密无效 / 没绑定记录 / 设备不匹配
data.old_ipchar[]解绑前的 IP
JSON
{
  "app_key":     "你的64位AppKe...",
  "action":      "ip_unbind",
  "card_code":   "TK3S2UF79TZ2WYSSJE",
  "device_hash": "可选的设备指纹"
}

// 响应
{ "code": 200, "msg": "IP 解绑成功", "data": { "old_ip": "112.19.228.181" } }

管理端接口(发卡网 / 后台对接)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json以下 5 个接口都要额外带 api_token

这一组接口用 api_token 鉴权,不走 AppKey 签名,也不绑定某个程序。至少还要传一个合法 app_key 用于加密通道,请求体同样要 encrypted_data 加密。

鉴权字段
  • api_token — 后台「系统配置」里的对接通信密钥。没传或错了直接 403。
  • app_key — 任取一个已存在程序的 AppKey,只为走加密通道。

generate — 批量发卡

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json需带 api_token
JSON
{
  "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 — 封卡 / 解封

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json需带 api_token
JSON
{"action":"ban","api_token":"...","card_code":"ABC123"}
{"action":"unban","api_token":"...","card_code":"ABC123"}
  • 封禁后客户端验证返回「此卡密已被管理员封禁」,解封恢复可用

del_card — 彻底删卡

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json需带 api_token
JSON
{"action":"del_card","api_token":"...","card_code":"ABC123"}
  • 删除后不可恢复,卡的设备绑定记录一并清除

kick — 强制下线

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json需带 api_token
JSON
{"action":"kick","api_token":"...","card_code":"ABC123"}
  • 踢掉当前绑定的设备并解绑,卡还能在别的设备上重新激活

ban_machine / blacklist — 拉黑机器码

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json需带 api_token
JSON
{
  "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)

请求方式:POST请求地址:/Verifyfile/api.php数据格式:application/json这个不需要 api_token
JSON
{"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,不带时区后缀。 客户端直接按本地时间解析即可;若服务器与用户不在同一时区,以「剩余秒数」为准,不要拿绝对时间去比。
expire_time 的三种形态
返回示例含义客户端建议处理
"2026-11-30 23:59:59" 普通到期时间 正常解析并显示剩余天数/小时
"2099-12-31 23:59:59" 永久有效 当年份 ≥ 2099 就当永久卡,别去算剩余天数(否则会显示「还有 26000 多天」)
null 账号未充值 / 未激活 注册后还没充过值。提示用户去充值,不要当成「已过期」
永久卡判断
PHP
// —— 通用判断:年份 >= 2099 即永久 ——

// PHP
$isPermanent = strpos($expireStr, '2099-') === 0;
剩余时长计算
  • 接口返回的是到期时刻,不是剩余秒数;需要剩余时间就用「到期时刻 − 当前时刻」自己算
  • 永久卡(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)

返回 msgcode触发原因
首次激活成功 / OK200卡密有效,已完成设备绑定
程序已经禁用,请联系开发者403程序被管理员手动禁用,或开发者账号到期自动禁用。续费后自动恢复
这张卡暂时无法使用,请联系卖家403卡密所属代理到期,未激活的卡被系统冻结。代理续费后自动解冻
此卡密已被管理员封禁403管理员手动封禁(优先级高于冻结)
访问受限:设备或IP已被云端封禁403命中黑名单。app_id=0 的全站黑名单对所有程序生效,程序专属黑名单只对该程序生效
无效的卡密 (或不属于当前应用)403卡密不存在,或卡密属于别的程序(app_key 与卡密不匹配)
该设备已绑定其他卡密,一个设备只能使用一张卡403一机一卡策略(bind_policy=strict),需先解绑
账号未充值,请充值后登录403账号体系里注册后还没充过值
账号已到期,请充值后登录403账号时长用完
解绑次数已达上限(N 小时内最多 M 次)400超出程序配置的自助解绑次数
请求过于频繁,请稍后再试429触发限频,退避后重试

调用示例

PHP
// —— PHP ——
$r1 = gy_call($api, $appKey, ['action'=>'ip_unbind_user', 'username'=>$u, 'password'=>$p]);
$r2 = gy_call($api, $appKey, ['action'=>'ip_unbind', 'card_code'=>$card]);
IP 校验要不要开?默认是关的。开了之后用户换网络就登不上,必须调解绑接口或找管理员。 家用宽带 IP 会变、手机 4G/5G 换基站也会变,除非你做的是固定 IP 的网吧/公司内网场景,否则不建议开。 IP 精度可以在「程序管理 → 验证配置」里调成网段级,减少误伤。

错误处理(客户端要做的分流)

账号模式下最常见的四类失败,建议按 code + msg 分流处理。 不要只看 code:403 有四种含义,靠 msg 区分。
分流对照
codemsg客户端动作
403账号未充值,请充值后登录弹充值窗口,让用户输卡密调 recharge
403账号已到期,请充值后登录同上,文案可换成「已到期,请续费」
403用户名或密码错误提示检查输入,不要清空已填的用户名
403该账号已绑定其他设备,如需更换请联系管理员解绑显示联系方式,或调 ip_unbind_user 自助解绑
403账号已被禁用提示联系管理员,不要循环重试
400该用户名已被注册 / 密码至少 6 位 / 用户名长度需在 2–50 位之间在注册表单上就地提示
PHP
// —— PHP ——
$lg = gy_call($api, $appKey, ['action'=>'user_login', 'username'=>$u, 'password'=>$p, 'device_hash'=>$mc]);
if (($lg['code'] ?? 0) === 200) { enterMain($lg['data']['expire_time'], $lg['data']['statecode']); }
elseif (strpos($lg['msg'] ?? '', '未充值') !== false || strpos($lg['msg'] ?? '', '已到期') !== false) { showRecharge(); }
elseif (strpos($lg['msg'] ?? '', '其他设备') !== false) { showUnbind(); }
else { showToast($lg['msg'] ?? '登录失败'); }
心跳里也会返回同样的 msg。如果用户在使用过程中到期,user_heartbeat 会返回 403, 客户端应该立刻退回登录页并提示续费,而不是继续静默失败。
错误码对照
场景codemsg
用户名重复400该用户名已被注册
两次密码不一致400两次输入的密码不一致
用户名格式不合规400用户名长度需在 2–50 位之间
密码太短400密码至少 6 位
账号密码错误403用户名或密码错误
未充值就登录403账号未充值,请充值后登录
账号到期403账号已到期,请充值后登录
换设备登录403该账号已绑定其他设备,如需更换请联系管理员解绑
卡密无效 / 已用403该卡密已被使用或不处于可充值状态
statecode 失效403登录态已失效,请重新登录
账号被禁用403账号已被禁用
调用示例(复用上面的加解密函数)
PHP
// —— PHP ——
$reg = gy_call($api, $appKey, ['action'=>'user_register', 'username'=>$u, 'password'=>$p, 'confirm_password'=>$p]);
$rc  = gy_call($api, $appKey, ['action'=>'recharge', 'username'=>$u, 'card_code'=>$card]);
$lg  = gy_call($api, $appKey, ['action'=>'user_login', 'username'=>$u, 'password'=>$p, 'device_hash'=>$mc]);
$hb  = gy_call($api, $appKey, ['action'=>'user_heartbeat', 'username'=>$u, 'password'=>$p, 'statecode'=>$lg['data']['statecode']]);
用加密时 action 要放在密文里面,跟 card_code 一样。外层 app_key 保持明文,服务端靠它选密钥。
当前语言: PHP · 左侧「SDK 快速接入」可切换其它语言
PHP
服务端同语言,最容易对照。需要 openssl 和 curl 扩展。
<?php
$api    = 'https://yihaiyun.cn/Verifyfile/api.php';
$appKey = '你的64位AppKey';

// 请求加密:Base64( hmac[32] ‖ iv[16] ‖ ciphertext )
function gy_encrypt(array $params, string $appKey): string {
    $params['_ts'] = time();
    $master = hex2bin($appKey);
    $encKey = hash_hmac('sha256', 'enc', $master, true);
    $macKey = hash_hmac('sha256', 'mac', $master, true);

    $iv = random_bytes(16);
    $ct = openssl_encrypt(json_encode($params, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT),
                         'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
    $mac = hash_hmac('sha256', $iv . $ct, $macKey, true);
    return base64_encode($mac . $iv . $ct);
}

// 响应解密:Base64( iv[12] ‖ tag[16] ‖ ciphertext )
function gy_decrypt(string $raw, string $appKey): ?array {
    $j = json_decode($raw, true);
    if (!is_array($j)) return null;
    if (!isset($j['encrypted_data'])) return $j;   // 明文直接用

    $bin = base64_decode($j['encrypted_data']);
    $plain = openssl_decrypt(substr($bin, 28), 'aes-256-gcm',
                            hex2bin($appKey), OPENSSL_RAW_DATA,
                            substr($bin, 0, 12), substr($bin, 12, 16));
    return $plain === false ? null : json_decode($plain, true);
}

// 发起请求
function gy_call(string $url, string $appKey, array $params, bool $encrypt = true): ?array {
    $body = $encrypt
        ? ['app_key' => $appKey, 'encrypted_data' => gy_encrypt($params, $appKey)]
        : array_merge($params, ['app_key' => $appKey]);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($body),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
    ]);
    $raw = curl_exec($ch);
    curl_close($ch);
    return $raw === false ? null : gy_decrypt($raw, $appKey);
}

// 用法
$r = gy_call($api, $appKey, [
    'card_code'   => '用户输入的卡密',
    'device_hash' => '16-64位设备指纹',
]);

if (!$r)                die("响应解析失败\n");
if ((int)$r['code'] !== 200)  die("验证失败:" . $r['msg'] . "\n");
echo "到期时间:{$r['data']['expire_time']}\n";

// 公告:后台填了才有,非空就弹
$notice = $r['data']['notice'] ?? null;
if ($notice !== null && $notice !== '') {
    echo "公告:{$notice}\n";
}
不要用 openssl_public_encrypt 或 MD5 拼签名,服务端只认上面这套。
心跳:同样的函数,参数里加 'action' => 'heartbeat',响应里同样有 expire_time。
场景化接入(全屏 / 弹窗 / 命令行)
同一套接口,按你的使用场景选不同的 UI 呈现方式。下面只写验证通过后怎么展示, 加密和请求部分复用上面「当前语言示例」里的 gyCall()。
场景适合行为
全屏验证启动即验证盖住整个界面,验证通过才进去;不通过不给操作
弹窗验证功能内验证只在点某个按钮时弹,验证完自动关闭,不打断主流程
命令行服务端 / 挂机 / CI没有界面,验证通过打印到期时间,失败返回非 0 退出码
① Android 全屏验证(Kotlin · Activity)
// 放在启动页或主页的 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()
}
② Android 弹窗验证(Kotlin · Dialog)
// 用户点「解锁高级功能」时才弹,不打断正常使用
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()
}
③ iOS 全屏验证(Swift · ViewController)
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) ?? "网络错误"
                }
            }
        }
    }
}
④ iOS 弹窗验证(Swift · Alert)
// 在任意界面调用,弹完就关,不影响其他功能
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)
}
⑤ Android JNI 命令行(C++ · NDK)
// 场景:把验证做成 .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
}
⑥ C++ 命令行(Windows · Console)
// 场景:挂机脚本、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 分钟一次,服务端能实时踢掉被封的卡;纯靠启动时验证一次,用户挂一晚都不知道卡被封了。
设备指纹生成(各平台)
服务端只校验格式(16–64 位字母数字),内容由客户端决定。要求同一台机器每次都一样。
平台取什么注意
iOS越狱:MGCopyAnswer(@"UniqueDeviceID");非越狱:Keychain UUID擦机才变,删 App 不变
Windows EXE注册表 MachineGuid + 主板/硬盘序列号重装系统才变;别用随机 GUID 存文件
AndroidSettings.Secure.ANDROID_ID + 序列号恢复出厂才变
macOSIOPlatformUUID重装不变
Linux/etc/machine-id + 网卡 MAC重装才变
推荐做法:把取到的硬件信息拼起来做 SHA256,取前 32 位 hex。 不要包含网卡 MAC 单独一位——用户换个网卡就锁死了。
GetMachineCode() 各平台实现

机器码由客户端本地计算,服务端只做字符串比对,没有「查机器码」的接口。 要求:同一台机器每次算出来完全一样;不同机器不撞。

// —— PHP ——
function getMachineCode(): string {
    $src = php_uname('n') . '|' . php_uname('m') . '|' . APP_KEY;
    return substr(hash('sha256', $src), 0, 32);
}
不要用「随机生成一次然后存本地文件」当机器码。用户删了配置文件就变成新机器,卡直接锁死,还得找你解绑。
也不要只用 MAC 地址——换个网卡就换了机器码。
S6网络验证 · 公开接口文档