一、通用约定
| 项目 | 说明 |
|---|---|
| 接口地址 | https://k.mdduo.cn/api/activate.php · heartbeat.php · verify.php · unbind.php(自助解绑) · info.php(展示信息,GET/POST 均可) |
| 请求方式 | POST,请求体为 JSON:Content-Type: application/json |
| 字符编码 | UTF-8 |
| 响应格式 | JSON,含 status 字段;同时返回语义化 HTTP 状态码 |
| 跨域 | 已开启 Access-Control-Allow-Origin: *,支持浏览器直连 |
| 超时建议 | 连接/读取均 10 秒;心跳失败按退避重试,勿无限快速重试 |
| 对接前准备 | 经销商自助即可,无需申请:注册经销商账号 → 在「我的应用」新建应用拿到应用标识 code(必填,客户端只认它) → 在「我的卡密」生成卡密用于测试。三者(接口地址 / 应用标识 / 卡密)齐全就能调通激活 |
二、令牌(token)与设备标识
token 由服务器签发,是「卡密 + 设备」的签名凭证。客户端只需原样保存并回传,无需解析、不要自行拼装(签名密钥保存在服务器,客户端无法伪造)。
首次激活拿到 token 后请持久化(注册表 / 配置文件 / 本地库均可),此后每次 activate、heartbeat、verify 都带上它。
device_id 是设备唯一标识:客户端首次运行生成一次(UUID / 机器码哈希等均可)并持久化,之后必须保持不变。
换机或重置 device_id 会被服务器视为新设备,需要重新激活:若设备数已满且未开启互顶,服务器会返回 device_limit,需先在经销商后台 / 门户「设备列表」解绑旧设备。
三、status 与 HTTP 状态码
| status | HTTP | 含义与客户端处理建议 |
|---|---|---|
ok正常 | 200 | 授权有效。保存返回的新 token,按 heartbeat_interval 秒后发起心跳。 |
locked已锁定 | 403 | 被禁用/吊销/过期/黑名单/设备超额(device_limit)等。停止功能并提示 reason,不要继续重试。 |
error | 400/500 | 参数缺失、卡密不存在或服务异常。修正参数或稍后重试。message 是可直接展示给用户的中文(如「卡密不存在,请检查卡密是否输入正确」),code 是给程序判断用的机器码:invalid_card / card_not_found / app_required / app_not_found / missing_params / insufficient_balance / server_error。例: {"status":"error","code":"invalid_card","message":"卡密不存在,请检查卡密是否输入正确"}接口自身不会返回 404:nginx(宝塔默认配置)常带 error_page 404,会把 404 的 JSON 体替换成 HTML 页,客户端就看不到 message 了,故业务「不存在」一律用 400 |
locked 的 reason 取值
| reason | 含义 |
|---|---|
card_disabled | 卡密被禁用(可恢复,联系发卡方) |
card_revoked | 卡密被吊销:停用并自动加入黑名单,不能续费 / 换绑;可恢复(点「恢复」时自动移出黑名单) |
expired | 卡密已过期 |
blacklisted | 命中黑名单(卡密 / 设备 / IP / 经销商 / 代理) |
device_disabled | 该设备被禁用 |
device_revoked | 该设备被吊销 |
device_banned | 该设备被封禁(比禁用更重,需后台解封) |
device_limit | 设备数已达上限且系统未开启「设备互顶」→ 提示用户联系发卡方解绑旧设备后重试(开启互顶时会顶掉最早的旧设备并直接激活成功) |
invalid_token | 令牌无效或已损坏。心跳 / 校验接口返回时 → 清空本地 token 再重新激活;activate 接口不会返回它(会自动转为完整激活并换发新令牌) |
device_mismatch | 令牌与当前设备不匹配(疑似换机)→ 重新激活 |
no_activation | 该设备无激活记录(被解绑 / 换绑 / 记录被删)→ 清除本地 token 后重新激活,会重走完整校验与设备额度(额度满且未开互顶返回 device_limit) |
app_disabled / app_revoked / app_missing | 卡密所属应用被禁用 / 吊销 / 不存在 → 该应用下全部卡密停用 |
app_mismatch | 传入的 app 与卡密所属应用不一致(拿 A 应用的卡去激活 B 应用) |
app_required | 客户端没传 app(必传项)→ 补上应用标识后重试 |
四、接口详情
1. 激活 / 续期 POST /api/activate.php
| 参数 | 必填 | 说明 |
|---|---|---|
card_key | 是 | 卡密,如 YK-XXXXX-XXXXX-XXXXX(前缀随卡类型:TK 天卡 / ZK 周卡 / YK 月卡 / JK 季卡 / BK 半年卡 / NK 年卡 / YJ 永久 / MDT 未识别或历史卡) |
device_id | 是 | 设备唯一标识,需持久化 |
device_name | 否 | 设备名(如机器名),便于后台识别 |
client_version | 否 | 客户端版本号,便于排查 |
token | 否 | 已有令牌时传入即为续期,不会重复占用设备额度。续期与首次激活走同一套校验(卡状态 / 过期 / 黑名单 / 应用 / 设备状态),不会「先成功后被心跳锁」;若该设备记录已被解绑 / 换绑删除,则按重新激活处理并重建记录。 令牌不可用不会报错:令牌过期 / 换过卡密 / 后台重置过密钥 / 与本机 device_id 不符时,服务端记日志后自动转为完整激活并换发新令牌,客户端本地留着坏令牌也能自愈 |
app | 是 | 应用标识(code,如 mdduotw)或应用 id。服务端强制校验:不传返回 app_required,与卡密所属应用不一致返回 app_mismatch,保证一张卡只被指定应用使用 |
发卡时必须选择所属应用,客户端激活时必须固定带上
"app":"应用标识",卡密就只会被该应用使用。不传
app → app_required;应用的卡拿去激活另一个应用 → app_mismatch;应用被禁用/吊销 → 该应用下全部卡密停用。
expired_at = 激活时间 + N 天,所以卡放着不卖、客户拿到不激活都不会自然过期;
续费 / 解绑 / 换绑不会重置已开始的计时;有效期与扣费都由卡类型决定,不支持手写固定到期时间。
响应里的 card.expired_at 就是最终到期时间,null 表示永久。
响应
{
"status": "ok",
"token": "eyJjYXJkX2lkIjoxLCJkZXZpY2VfaWQiOiJiN2YxIn0.9f2c1a...",
"renew": false,
"server_time": 1789000000,
"heartbeat_interval": 300,
"lock_on_invalid": 1,
"offline_grace": 0,
"card": { "type": "month", "app_id": 3, "app_code": "mdduotw", "expired_at": "2026-12-31 23:59:59", "device_limit": 2 }
}
{ "status": "locked", "reason": "device_limit", "message": "设备数已达上限,请联系发卡方解绑旧设备" }
{ "status": "locked", "reason": "card_revoked" }
卡密不存在 / 参数缺失 / 服务异常时返回 error(HTTP 400 / 500):
{ "status": "error", "code": "invalid_card", "message": "卡密不存在,请检查卡密是否输入正确" }
2. 心跳续期 POST /api/heartbeat.php
| 参数 | 必填 | 说明 |
|---|---|---|
card_key | 是 | 卡密 |
device_id | 是 | 设备唯一标识 |
token | 是 | 激活时获取的令牌 |
client_version | 否 | 客户端版本号 |
每次心跳会刷新服务器记录的在线时间并签发新 token;请用返回的 token 覆盖本地旧值。
{
"status": "ok",
"token": "eyJjYXJkX2lkIjoxLCJkZXZpY2VfaWQiOiJiN2YxIn0.7a1b3c...",
"server_time": 1789000300,
"heartbeat_interval": 300
}
3. 只读校验 POST /api/verify.php
| 参数 | 必填 | 说明 |
|---|---|---|
card_key | 是 | 卡密 |
device_id | 是 | 设备唯一标识 |
token | 是 | 激活时获取的令牌 |
不续期、不写心跳时间,适合启动前或敏感操作前快速判断。
{
"status": "ok",
"server_time": 1789000300,
"card": { "type": "month", "expired_at": null, "device_limit": 2 }
}
4. 客户端自助解绑(注销本机) POST /api/unbind.php无需令牌
软件里点「注销本机」时调用:删除「该卡密 + 该设备」这一条激活记录,立刻释放一个设备名额。
适合换机 / 重装前主动退位,或设备数已满(device_limit)时自己腾名额,不用再找发卡方。
| 参数 | 必填 | 说明 |
|---|---|---|
card_key | 是 | 卡密 |
device_id | 是 | 本机设备标识(与激活时同一个值) |
app | 否 | 应用标识(传了就校验它与卡密所属应用是否一致,不一致返回 app_mismatch) |
响应
{
"status": "ok",
"server_time": 1789000300,
"card_key": "MDT-XXXXX-XXXXX-XXXXX",
"device_id": "b7f1c0d2e5a94f13",
"unbound": 1,
"device_limit": 2
}
① 只能解自己这一台:按
card_key + device_id 精确匹配删除,解不了别人的设备;② 不校验卡密状态:禁用 / 吊销 / 过期 / 黑名单都能解绑——解绑只是释放名额,不会造成越权或资损;
③ 不需要令牌:换机场景里旧机器的令牌可能已过期,要求令牌会让自助解绑形同虚设(凭卡密 + 设备标识即可)。
开关同其它授权策略一样是「应用级覆盖全局」:先在应用「授权策略 → 客户端自助解绑」里取值(留空则跟随平台「系统设置」的全局默认,默认允许), 关闭后返回
400 code=unbind_disabled。
失败响应:
card_not_found(卡密不存在)、no_activation(本机没绑定,无需解绑)、
app_not_found / app_mismatch、missing_params、unbind_disabled。
客户端成功后应清除本地令牌并回到未激活界面;该机器下次心跳会收到 no_activation。
5. 应用信息与展示信息 GET|POST /api/info.php无需卡密
未激活也能调:只传应用标识即可,用于在软件里显示「谁打包分发的软件」——公告、购买链接、客服二维码、更新下载链接, 以及应用最新版本号。客户端应放在启动时(无需卡密)调用。
| 参数 | 必填 | 说明 |
|---|---|---|
app | 是 | 应用标识(code,如 mdduotw)或应用数字 id。不传返回 400 app_required,应用不存在返回 400 app_not_found,应用被禁用 / 吊销返回 403 locked |
client_version | 否 | 客户端当前版本号,仅原样回显,方便排查 |
响应
① 应用已配置展示信息(含应用自己的联系人 / 联系方式):
{
"status": "ok",
"server_time": 1789000300,
"client_version": "1.0.0",
"app": {
"id": 3, "code": "mdduotw", "name": "米多多图文工具",
"latest_version": "1.2.0", "update_note": "新增公众号直发;修复封面裁剪失败",
"contact": "客服小王", "contact_info": "微信 mdduo01 / 13800000000"
},
"contact": "客服小王", "contact_info": "微信 mdduo01 / 13800000000",
"notice": "购买 / 续费请认准下方联系方式,谨防假冒。",
"buy_url": "https://k.mdduo.cn/buy/zhangsan",
"qrcode": "https://k.mdduo.cn/uploads/qrcode/app7_20260913103000_ab12cd34.png",
"update_url": "https://pan.example.com/mdduotw/1.2.0",
"owner": {
"type": "app", "id": 3, "name": "米多多图文工具",
"contact": "客服小王", "contact_info": "微信 mdduo01 / 13800000000"
}
}
② 应用六项都没配:所有展示字段返回空字符串,owner 仍是应用自身的对象(不再为 null)。客户端把空项隐藏即可:
{
"status": "ok",
"server_time": 1789000300,
"app": { "id": 3, "code": "mdduotw", "name": "米多多图文工具", "latest_version": "", "update_note": "", "contact": "", "contact_info": "" },
"contact": "", "contact_info": "",
"notice": "", "buy_url": "", "qrcode": "", "update_url": "",
"owner": { "type": "app", "id": 3, "name": "米多多图文工具", "contact": "", "contact_info": "" }
}
| 字段 | 说明 |
|---|---|
app.latest_version | 应用最新版本号(未配置为空字符串)。客户端与自身版本比较,见下节 |
app.update_note | 更新说明,可直接展示在「发现新版本」弹窗里 |
contact / contact_info | 该应用自己的联系人与联系方式(手机 / 微信 / QQ),未配置为空字符串;app 对象里也带同名字段。客户端要显示「找谁买卡 / 联系客服」就直接用顶层这两个字段 |
notice / buy_url / qrcode / update_url | 该应用自身配置的展示信息(无回落,未配置即为空字符串);qrcode 已是完整图片地址(可直接加载显示) |
owner | 兼容旧客户端的字段,内容同样来自应用本身(type 固定 app、id=应用 id、name=应用名、contact / contact_info=应用联系人与联系方式)。新客户端请直接读顶层 contact / contact_info |
code 写死进程序的专属包即可,客户零填写、各显示各的。
供应商(经销商)与代理资料里的联系人 / 联系方式只用于后台联系,本接口不查询、不返回;应用不再绑定代理, 要给某个代理单独出包就单独给他建一个应用。卡密是否分给代理,在卡密里单独指定,与展示信息无关。
五、客户端展示信息与版本更新(谁打包的包就显示谁)
目标:客户拿到软件(哪怕还没有卡密)就知道该找谁买卡。做法是「一个渠道一个应用标识 + 一个专属安装包」: 服务端按包里写死的应用标识返回该应用自己的展示信息,客户端只负责展示,不需要客户填任何东西。
| 环节 | 做法 |
|---|---|
| 服务端配置 | 后台「应用管理」或门户「我的应用」中新建应用,应用标识 code 必填(客户端只认标识),在同一个表单的「客户端展示信息」里填该应用的联系人、联系方式、公告、购买链接、客服二维码、更新下载链接。应用不绑定代理:要给某个代理单独出包,就单独给他建一个应用 |
| 打包专属包 | 编译时把该应用的 code 作为默认应用标识注入程序(如 Go 的 -ldflags、Rust 的编译期环境变量、常量文件),并持久化到本地配置。专属包始终以注入值为准(客户机器上装过通用包时,本地旧值不会把它盖掉,避免串到上游经销商);未注入的通用包则读本地配置 |
| 软件启动时 | 调 info.php:展示 contact / contact_info(联系人与联系方式)、notice(公告)、buy_url(「购买卡密」按钮)、qrcode(客服二维码),未激活状态下同样可显示;空字符串的项目隐藏即可 |
| 版本更新提示 | 用 info.latest_version 与本地版本比较(建议按数字段逐位比较,如 1.10.0 > 1.9.0);有新版则弹窗展示 update_note,点击后打开 update_url(都是该应用里配的,正好对应他的专属包) |
| 断网兜底 | 把上次成功拉取的展示信息缓存到本地(含时间戳),请求失败时用缓存;仍无缓存则显示「联系发卡方」通用文案,不影响软件正常使用 |
| 激活之后 | 展示信息不随卡密变化:激活只影响功能是否可用,归属展示仍以包内的应用标识为准(同一份 info.php 数据) |
六、客户端接入建议
| 场景 | 做法 |
|---|---|
| 首次启动(无卡密) | 读本地配置 / 打包注入的应用标识 → 调 info.php 展示该应用的联系人 / 联系方式 / 公告 / 购买链接 / 二维码与版本更新提示(不需要卡密) |
| 首次启动(激活) | 生成并保存 device_id → 无 token 调 activate → 保存返回 token |
| 后续启动 | 带本地 token 调 activate(即续期)。令牌过期 / 换过卡密 / 重装软件导致 device_id 变化时,服务端会自动按重新激活处理并换发新令牌,客户端无需特殊处理;心跳 / 校验返回 invalid_token / device_mismatch / no_activation 时,清空本地 token 后再调 activate 即可恢复 |
| 运行期间 | 按 heartbeat_interval 定时心跳;locked 立即停用功能并提示原因 |
| 设备超额 | 返回 device_limit 时:优先让用户在本机 / 旧机器上点「注销本机」调 unbind.php 自己腾名额(不用找发卡方);或提示联系发卡方解绑;服务端开启「设备互顶」后换机会自动顶掉旧设备并直接激活成功 |
| 用户主动注销本机 | 调 unbind.php(卡密 + 本机 device_id,无需令牌)→ 成功后清除本地 token 并回到未激活界面;适合做在授权页的「注销本机 / 退出授权」按钮上,换机、重装前主动释放名额 |
| 网络异常 | 区分「服务端明确 locked」与「连不上服务器」:前者一律立即停用;后者按 lock_on_invalid / offline_grace 策略处理(见下节),重试建议 30s / 60s / 120s 退避 |
| 时钟安全 | 以 server_time 校准本地时间,避免用户改系统时间绕过有效期判断 |
七、授权策略(应用级,客户端必读)
activate 与 heartbeat 的成功响应会带上当前应用的授权策略(heartbeat_interval / lock_on_invalid / offline_grace),
由经销商在「我的应用」里按应用配置、未配置时使用平台默认值;客户端应以下发值为准(心跳成功时可用新值覆盖本地设置)。
| 字段 | 含义与客户端用法 |
|---|---|
heartbeat_interval | 心跳间隔(秒):定时发起心跳的依据 |
lock_on_invalid | 网络异常是否锁端:1 = 连不上服务器立即停用(适合必须联网的软件);0 = 按 offline_grace 宽限(适合离线软件) |
offline_grace | 离线宽限期(秒):仅 lock_on_invalid=0 时生效。从最后一次成功响应起算,超过该时长仍连不上服务器则停用;0 = 不限时(直到恢复网络) |
locked(禁用 / 吊销 / 过期 / 黑名单 / 设备不匹配 / 设备封禁等)是服务端明确拒绝,必须立即停用,不受上述策略影响;
策略只决定「连不上服务器」时是否放行,用于照顾离线软件。
令牌有效期(token_ttl)同为应用级:令牌到期后心跳 / 校验返回 invalid_token,重新调用 activate 即可换新
(旧 token 可用可不用,服务端会自动按重新激活处理,不额外占用设备额度)。
「设备互顶」(device_replace)与「客户端自助解绑」(self_unbind)也是应用级策略,但属于服务端行为、不下发给客户端:
经销商可在「我的应用 → 授权策略」里按应用覆盖,留空跟随平台「系统设置」的全局默认值(自助解绑默认允许)。
客户端直接调 unbind.php 即可,被禁止时服务端返回 400 code=unbind_disabled,客户端可据此隐藏「注销本机」入口。
八、多语言对接示例
示例均已实现:device_id 生成与持久化、token 落盘、应用展示信息拉取(info.php)、激活 → 心跳循环。
按需复制,替换 BASE、CARD_KEY 与 APP_CODE 即可。
Rust 示例是完整参考实现:含离线宽限 / 锁端策略、no_activation 自动重激活、心跳失败退避。
其余示例为精简版——心跳失败即退出;做离线软件的话请照第七章补齐 lock_on_invalid / offline_grace 逻辑,
否则用户一断网就被踢下线。
# 米多多授权中心 - cURL 对接示例
BASE="https://k.mdduo.cn/api"
CARD="MDT-XXXXX-XXXXX-XXXXX"
DEV="b7f1c0d2e5a94f13"
APP="mdduotw" # 应用标识(后台「应用管理」里的 code,必传)
# 0) 应用信息与展示信息(无需卡密,未激活也能调):
# contact / contact_info / notice / buy_url / qrcode / update_url 六项全部来自「应用自己」的配置
# (一个应用标识 = 一个打包渠道,无回落);另有最新版本号 latest_version 与更新说明 update_note。
# 供应商 / 代理的联系人与联系方式只用于后台联系,不会下发;owner 为兼容字段,内容同样来自应用自身。
curl -s "$BASE/info.php?app=$APP&client_version=1.0.0"
# 也可用 POST:curl -s -X POST "$BASE/info.php" -H "Content-Type: application/json" -d "{\"app\":\"$APP\"}"
# 1) 激活(新设备;设备满额且未开互顶时返回 403 locked / reason=device_limit)
# app = 应用标识(后台「应用管理」里的 code,必传):卡密只能被这个应用使用
curl -s -X POST "$BASE/activate.php" \
-H "Content-Type: application/json" \
-d "{\"card_key\":\"$CARD\",\"device_id\":\"$DEV\",\"device_name\":\"PC-01\",\"client_version\":\"1.0.0\",\"app\":\"$APP\"}"
# 2) 心跳续期(token 用上一步返回值)
curl -s -X POST "$BASE/heartbeat.php" \
-H "Content-Type: application/json" \
-d "{\"card_key\":\"$CARD\",\"device_id\":\"$DEV\",\"token\":\"eyJjYXJkX2lkIjoxfQ.9f2c...\",\"client_version\":\"1.0.0\"}"
# 3) 只读校验(不续期,启动前快速判断)
curl -s -X POST "$BASE/verify.php" \
-H "Content-Type: application/json" \
-d "{\"card_key\":\"$CARD\",\"device_id\":\"$DEV\",\"token\":\"eyJjYXJkX2lkIjoxfQ.9f2c...\"}"
# 4) 客户端自助解绑(「注销本机」,无需令牌):删除「该卡密 + 本机」这一条激活记录,
# 立刻释放一个设备名额;只能解自己这台,卡密任何状态都能解。
# 平台可在系统设置里关闭,关闭后返回 400 code=unbind_disabled
curl -s -X POST "$BASE/unbind.php" \
-H "Content-Type: application/json" \
-d "{\"card_key\":\"$CARD\",\"device_id\":\"$DEV\",\"app\":\"$APP\"}"
<?php
/**
* 米多多授权中心 - PHP 对接示例(依赖 php-curl,宝塔默认已开)
* 令牌落盘持久化,重启后直接续期,不会重复占用设备额度。
*/
class MdLicenseClient
{
private string $base;
private string $cardKey;
private string $deviceId;
private string $app = ''; // 应用标识(后台「应用管理」里的 code,必传):一张卡只对本应用有效
private ?string $token = null;
private int $interval = 300;
public function __construct(string $base, string $cardKey, string $deviceId, string $app = 'mdduotw')
{
$this->base = rtrim($base, '/');
$this->cardKey = $cardKey;
$this->deviceId = $deviceId; // 首次运行生成 UUID 并持久化,之后固定不变
$this->app = $app; // 一张卡只对本应用有效
$this->token = (@file_get_contents($this->tokenFile())) ?: null;
}
private function tokenFile(): string
{
return sys_get_temp_dir() . '/md_license_' . md5($this->cardKey) . '.token';
}
private function post(string $path, array $body): array
{
$ch = curl_init($this->base . '/api/' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
]);
$raw = curl_exec($ch);
curl_close($ch);
return json_decode((string) $raw, true) ?: ['status' => 'error', 'message' => 'bad response'];
}
/** @return string ok | locked | error */
public function activate(string $deviceName = '', string $clientVersion = '1.0.0'): string
{
return $this->handle($this->post('activate.php', [
'card_key' => $this->cardKey,
'device_id' => $this->deviceId,
'device_name' => $deviceName,
'client_version' => $clientVersion,
'app' => $this->app, // 应用隔离:一张卡只对本应用有效
'token' => $this->token, // 有令牌即为续期
]));
}
public function heartbeat(string $clientVersion = '1.0.0'): string
{
return $this->handle($this->post('heartbeat.php', [
'card_key' => $this->cardKey,
'device_id' => $this->deviceId,
'token' => $this->token,
'client_version' => $clientVersion,
]));
}
public function verify(): string
{
return $this->handle($this->post('verify.php', [
'card_key' => $this->cardKey,
'device_id' => $this->deviceId,
'token' => $this->token,
]));
}
/**
* 应用展示信息(无需卡密,未激活也能调)
* 返回:contact / contact_info 应用自己的联系人与联系方式(供应商/代理的后台联系方式不会下发)/
* notice 公告 / buy_url 购买链接 / qrcode 客服二维码 / update_url 更新下载链接 / app.latest_version 最新版本。
* owner 为兼容字段,内容同样来自应用自身
*/
public function appInfo(string $clientVersion = '1.0.0'): array
{
return $this->post('info.php', [
'app' => $this->app,
'client_version' => $clientVersion,
]);
}
private function handle(array $res): string
{
$st = $res['status'] ?? 'error';
if ($st === 'ok') {
if (!empty($res['token'])) {
$this->token = $res['token'];
file_put_contents($this->tokenFile(), $this->token);
}
$this->interval = (int) ($res['heartbeat_interval'] ?? $this->interval); // 别写死间隔
} elseif ($st === 'locked') {
// 已无人工审核:设备满且未开互顶时 reason = device_limit,提示用户联系发卡方解绑
echo '已锁定:' . ($res['reason'] ?? '') . PHP_EOL;
}
return $st;
}
public function interval(): int { return $this->interval; }
}
// ---------------- 使用 ----------------
$cli = new MdLicenseClient('https://k.mdduo.cn', 'MDT-XXXXX-XXXXX-XXXXX', 'b7f1c0d2e5a94f13');
if ($cli->activate(gethostname()) === 'ok') {
while (true) {
sleep($cli->interval());
if (in_array($cli->heartbeat(), ['locked', 'error'], true)) {
break; // 已锁定 / 服务异常:关闭功能或提示续费
}
}
}
# 米多多授权中心 - Python 对接示例
# 依赖:pip install requests
import json
import time
import uuid
import requests
BASE = "https://k.mdduo.cn/api"
CARD_KEY = "MDT-XXXXX-XXXXX-XXXXX"
APP_CODE = "mdduotw" # 应用标识(后台「应用管理」里的 code,必传):一张卡只对本应用有效
DEV_FILE = "device_id.txt"
TOKEN_FILE = "license.token"
def device_id() -> str:
"""设备标识:首次运行生成并持久化,之后固定不变"""
try:
return open(DEV_FILE, encoding="utf-8").read().strip()
except FileNotFoundError:
did = uuid.uuid4().hex
open(DEV_FILE, "w", encoding="utf-8").write(did)
return did
def read_token():
try:
return open(TOKEN_FILE, encoding="utf-8").read().strip() or None
except FileNotFoundError:
return None
def post(path: str, body: dict) -> dict:
r = requests.post(f"{BASE}/{path}", json=body, timeout=10)
try:
res = r.json()
except ValueError:
return {"status": "error", "message": f"http {r.status_code}"}
if res.get("token"):
open(TOKEN_FILE, "w", encoding="utf-8").write(res["token"])
return res
def activate(version: str = "1.0.0") -> dict:
return post("activate.php", {
"card_key": CARD_KEY,
"device_id": device_id(),
"device_name": "PC-01",
"client_version": version,
"app": APP_CODE, # 应用标识(必传):一张卡只对本应用有效
"token": read_token(), # 有令牌即为续期
})
def heartbeat(version: str = "1.0.0") -> dict:
return post("heartbeat.php", {
"card_key": CARD_KEY,
"device_id": device_id(),
"token": read_token(),
"client_version": version,
})
def verify() -> dict:
return post("verify.php", {
"card_key": CARD_KEY,
"device_id": device_id(),
"token": read_token(),
})
def app_info(version: str = "1.0.0") -> dict:
"""应用展示信息(无需卡密,未激活也能调):
contact / contact_info 应用自己的联系人与联系方式(供应商/代理的后台联系方式不会下发)/
notice 公告 / buy_url 购买链接 / qrcode 客服二维码 / update_url 更新下载链接 / app.latest_version 最新版本。
owner 为兼容字段,内容同样来自应用自身"""
return post("info.php", {"app": APP_CODE, "client_version": version})
if __name__ == "__main__":
# 启动先拉展示信息:还没激活(没卡)时也要告诉客户找谁买卡
info = app_info()
if info.get("status") == "ok":
if info.get("contact") or info.get("contact_info"):
print("客服:", info.get("contact", ""), info.get("contact_info", ""))
for label, key in (("公告", "notice"), ("购买卡密", "buy_url"), ("客服二维码", "qrcode")):
if info.get(key):
print(f"{label}:{info[key]}")
latest = (info.get("app") or {}).get("latest_version", "")
if latest and latest != "1.0.0":
print("发现新版本", latest, "→", info.get("update_url"))
res = activate()
print("activate:", json.dumps(res, ensure_ascii=False))
# 已无人工审核;设备满且未开互顶时 reason = "device_limit"(提示用户联系发卡方解绑旧设备)
if res["status"] != "ok":
print("未授权:", res.get("reason") or res.get("message"))
else:
interval = int(res.get("heartbeat_interval", 300)) # 用服务器下发间隔
while True:
time.sleep(interval)
hb = heartbeat()
if hb["status"] != "ok":
print("授权失效:", hb.get("reason"), "- 请降级或退出")
break
// 米多多授权中心 - Node.js 对接示例(Node 18+ 自带 fetch)
const fs = require('fs');
const crypto = require('crypto');
const BASE = 'https://k.mdduo.cn/api';
const CARD_KEY = 'MDT-XXXXX-XXXXX-XXXXX';
const APP_CODE = 'mdduotw'; // 应用标识(后台「应用管理」里的 code,必传):一张卡只对本应用有效
const VER = '1.0.0';
const DEV_FILE = './device_id.txt';
const TOKEN_FILE = './license.token';
// 设备标识:首次运行生成并持久化,之后固定不变
function deviceId() {
if (!fs.existsSync(DEV_FILE)) {
fs.writeFileSync(DEV_FILE, crypto.randomUUID().replace(/-/g, ''));
}
return fs.readFileSync(DEV_FILE, 'utf8').trim();
}
const readToken = () =>
fs.existsSync(TOKEN_FILE) ? fs.readFileSync(TOKEN_FILE, 'utf8').trim() || null : null;
async function post(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({ status: 'error', message: `http ${res.status}` }));
if (data.token) fs.writeFileSync(TOKEN_FILE, data.token);
return data;
}
const activate = () => post('activate.php', {
card_key: CARD_KEY,
device_id: deviceId(),
device_name: 'PC-01',
client_version: VER,
app: APP_CODE, // 应用标识:一张卡只对本应用有效
token: readToken(), // 有令牌即为续期
});
const heartbeat = () => post('heartbeat.php', {
card_key: CARD_KEY,
device_id: deviceId(),
token: readToken(),
client_version: VER,
});
const verify = () => post('verify.php', {
card_key: CARD_KEY,
device_id: deviceId(),
token: readToken(),
});
// 应用展示信息(无需卡密,未激活也能调):
// contact / contact_info 应用自己的联系人与联系方式(供应商/代理的后台联系方式不会下发)/
// notice 公告 / buy_url 购买链接 / qrcode 客服二维码 / update_url 更新下载链接 / app.latest_version 最新版本。
// owner 为兼容字段,内容同样来自应用自身
const appInfo = () => post('info.php', { app: APP_CODE, client_version: VER });
// ---------------- 使用 ----------------
(async () => {
// 启动先拉展示信息:还没激活(没卡)时也要告诉客户找谁买卡
const info = await appInfo();
if (info.status === 'ok') {
if (info.contact || info.contact_info) console.log('客服:', info.contact, info.contact_info);
if (info.notice) console.log('公告:', info.notice);
if (info.buy_url) console.log('购买卡密:', info.buy_url);
if (info.qrcode) console.log('客服二维码:', info.qrcode);
const latest = info.app && info.app.latest_version;
if (latest && latest !== VER) console.log('发现新版本', latest, '→', info.update_url);
}
const res = await activate();
console.log('activate:', res);
// reason=device_limit:设备数已满(经销商后台或门户「设备列表」解绑旧设备后重试)
if (res.status !== 'ok') return console.log('未授权:', res.reason || res.message);
const interval = (res.heartbeat_interval || 300) * 1000; // 用服务器下发间隔
setInterval(async () => {
const hb = await heartbeat();
if (hb.status !== 'ok') console.log('授权失效:', hb.reason, '- 请降级或退出');
}, interval);
})();
// 米多多授权中心 - Go 对接示例(仅标准库)
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
baseURL = "https://k.mdduo.cn/api"
cardKey = "MDT-XXXXX-XXXXX-XXXXX"
appCode = "mdduotw" // 应用标识(后台「应用管理」里的 code,必传):一张卡只对本应用有效
ver = "1.0.0"
devFile = "device_id.txt"
tokFile = "license.token"
)
// 设备标识:首次运行生成并持久化,之后固定不变
func DeviceID() string {
if b, err := os.ReadFile(devFile); err == nil && len(b) > 0 {
return string(b)
}
buf := make([]byte, 16)
_, _ = rand.Read(buf)
id := hex.EncodeToString(buf)
_ = os.WriteFile(devFile, []byte(id), 0600)
return id
}
func readToken() string {
if b, err := os.ReadFile(tokFile); err == nil {
return string(b)
}
return ""
}
type Resp struct {
Status string `json:"status"`
Token string `json:"token"`
Reason string `json:"reason"`
Message string `json:"message"`
HeartbeatInterval int `json:"heartbeat_interval"`
}
func Post(path string, body map[string]any) (*Resp, error) {
b, _ := json.Marshal(body)
res, err := http.Post(baseURL+"/"+path, "application/json", bytes.NewReader(b))
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var r Resp
if err := json.Unmarshal(raw, &r); err != nil {
return &Resp{Status: "error", Message: string(raw)}, nil
}
if r.Token != "" {
_ = os.WriteFile(tokFile, []byte(r.Token), 0600)
}
return &r, nil
}
func Activate() (*Resp, error) {
return Post("activate.php", map[string]any{
"card_key": cardKey, "device_id": DeviceID(),
"device_name": "PC-01", "client_version": ver,
"app": appCode, // 应用标识(必传):一张卡只对本应用有效
"token": readToken(), // 有令牌即为续期
})
}
func Heartbeat() (*Resp, error) {
return Post("heartbeat.php", map[string]any{
"card_key": cardKey, "device_id": DeviceID(),
"token": readToken(), "client_version": ver,
})
}
func Verify() (*Resp, error) {
return Post("verify.php", map[string]any{
"card_key": cardKey, "device_id": DeviceID(), "token": readToken(),
})
}
// InfoResp 应用展示信息(无需卡密,未激活也能调)
type InfoResp struct {
Status string `json:"status"`
App struct {
Name string `json:"name"`
LatestVersion string `json:"latest_version"` // 最新版本号,空 = 未配置
UpdateNote string `json:"update_note"` // 更新说明
} `json:"app"`
// 应用自己的联系人与联系方式:供应商 / 代理的后台联系方式不会下发
Contact string `json:"contact"`
ContactInfo string `json:"contact_info"`
Owner *struct { // 兼容字段:内容同样来自应用自身,type 固定 app
Type string `json:"type"`
Name string `json:"name"`
} `json:"owner"`
Notice string `json:"notice"` // 公告(该应用配置的)
BuyURL string `json:"buy_url"` // 购买链接
QRCode string `json:"qrcode"` // 客服二维码图片地址(完整 URL)
UpdateURL string `json:"update_url"` // 更新下载链接(该应用自己的下载页)
}
// AppInfo 拉取展示信息:未激活(还没有卡密)时也要能告诉客户「找谁买卡」
func AppInfo() (*InfoResp, error) {
url := baseURL + "/info.php?app=" + appCode + "&client_version=" + ver
res, err := http.Get(url)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var r InfoResp
if err := json.Unmarshal(raw, &r); err != nil {
return nil, err
}
return &r, nil
}
func main() {
if info, err := AppInfo(); err == nil && info.Status == "ok" {
if info.Contact != "" || info.ContactInfo != "" {
fmt.Printf("客服:%s %s\n", info.Contact, info.ContactInfo)
}
if info.Notice != "" {
fmt.Println("公告:", info.Notice)
}
if info.BuyURL != "" {
fmt.Println("购买卡密:", info.BuyURL)
}
if info.QRCode != "" {
fmt.Println("客服二维码:", info.QRCode)
}
if info.App.LatestVersion != "" && info.App.LatestVersion != ver {
fmt.Println("发现新版本:", info.App.LatestVersion, info.App.UpdateNote, "→", info.UpdateURL)
}
}
r, err := Activate()
if err != nil {
fmt.Println("网络错误:", err)
return
}
switch r.Status {
case "ok":
fmt.Println("授权成功")
case "locked":
fmt.Println("已锁定:", r.Reason) // device_limit=设备数已满,需在经销商后台解绑旧设备
return
default:
fmt.Println("失败:", r.Message)
return
}
// 心跳间隔用服务器下发值,别写死
for range time.Tick(time.Duration(r.HeartbeatInterval) * time.Second) {
hb, err := Heartbeat()
if err != nil {
continue // 网络抖动:下一轮再试
}
if hb.Status != "ok" {
fmt.Println("授权失效:", hb.Reason)
return
}
}
}
// 米多多授权中心 - Java 对接示例(JDK 11+,仅标准库)
// 说明:为演示简洁用正则取字段,生产建议引入 Jackson / Gson。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MdLicense {
static final String BASE = "https://k.mdduo.cn/api";
static final String CARD_KEY = "MDT-XXXXX-XXXXX-XXXXX";
static final String APP = "mdduotw"; // 应用标识(后台「应用管理」里的 code),必传,留空会返回 app_required
static final String VER = "1.0.0";
static final Path DEV_FILE = Path.of("device_id.txt");
static final Path TOKEN_FILE = Path.of("license.token");
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build();
/** 设备标识:首次运行生成并持久化,之后固定不变 */
static String deviceId() throws Exception {
if (Files.exists(DEV_FILE)) return Files.readString(DEV_FILE).trim();
String id = UUID.randomUUID().toString().replace("-", "");
Files.writeString(DEV_FILE, id);
return id;
}
static String readToken() {
try {
return Files.exists(TOKEN_FILE) ? Files.readString(TOKEN_FILE).trim() : null;
} catch (Exception e) {
return null;
}
}
static String field(String key, String json) {
Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"?([^,\"}]*)\"?").matcher(json);
return m.find() ? m.group(1) : null;
}
static String post(String path, String json) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
String body = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
String token = field("token", body);
if (token != null && !token.isEmpty()) Files.writeString(TOKEN_FILE, token);
return body;
}
static String tokenJson() {
String t = readToken();
return t == null ? "" : t;
}
static String activate() throws Exception {
return post("activate.php", "{"
+ "\"card_key\":\"" + CARD_KEY + "\","
+ "\"device_id\":\"" + deviceId() + "\","
+ "\"device_name\":\"PC-01\","
+ "\"client_version\":\"" + VER + "\","
+ "\"app\":\"" + APP + "\"," // 应用隔离:一张卡只对本应用有效
+ "\"token\":\"" + tokenJson() + "\"}"); // 有令牌即为续期
}
static String heartbeat() throws Exception {
return post("heartbeat.php", "{"
+ "\"card_key\":\"" + CARD_KEY + "\","
+ "\"device_id\":\"" + deviceId() + "\","
+ "\"token\":\"" + tokenJson() + "\","
+ "\"client_version\":\"" + VER + "\"}");
}
static String verify() throws Exception {
return post("verify.php", "{"
+ "\"card_key\":\"" + CARD_KEY + "\","
+ "\"device_id\":\"" + deviceId() + "\","
+ "\"token\":\"" + tokenJson() + "\"}");
}
/**
* 应用展示信息(无需卡密,未激活也能调):
* contact / contact_info 应用自己的联系人与联系方式(供应商/代理的后台联系方式不会下发)/
* notice 公告 / buy_url 购买链接 / qrcode 客服二维码 / update_url 更新下载链接 / app.latest_version 最新版本。
* owner 为兼容字段,内容同样来自应用自身
*/
static String appInfo() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/info.php?app=" + APP + "&client_version=" + VER))
.timeout(Duration.ofSeconds(10))
.GET().build();
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
public static void main(String[] args) throws Exception {
// 启动先拉展示信息:还没激活(没卡)时也要告诉客户找谁买卡
String info = appInfo();
if ("ok".equals(field("status", info))) {
// 联系人 / 联系方式直接读顶层字段(全部来自应用自身)
String contact = field("contact", info), contactInfo = field("contact_info", info);
if ((contact != null && !contact.isEmpty()) || (contactInfo != null && !contactInfo.isEmpty())) {
System.out.println("客服:" + contact + " " + contactInfo);
}
for (String k : new String[]{"notice", "buy_url", "qrcode"}) {
String v = field(k, info);
if (v != null && !v.isEmpty()) System.out.println(k + ": " + v);
}
String latest = field("latest_version", info);
if (latest != null && !latest.isEmpty() && !latest.equals(VER)) {
System.out.println("发现新版本 " + latest + " -> " + field("update_url", info));
}
}
String res = activate();
System.out.println("activate: " + res);
String status = field("status", res);
if (!"ok".equals(status)) {
// reason=device_limit 时表示设备数已满,需在经销商后台解绑旧设备
System.out.println("未授权:" + field("reason", res));
return;
}
String iv = field("heartbeat_interval", res);
long interval = iv == null ? 300 : Long.parseLong(iv);
while (true) {
Thread.sleep(interval * 1000);
String hb = heartbeat();
if (!"ok".equals(field("status", hb))) {
System.out.println("授权失效:" + field("reason", hb));
return;
}
}
}
}
// 米多多授权中心 - C# 对接示例(.NET 6+)
using System.Net.Http.Json;
using System.Text.Json.Nodes;
class MdLicense
{
const string Base = "https://k.mdduo.cn/api";
const string CardKey = "MDT-XXXXX-XXXXX-XXXXX";
const string AppCode = "mdduotw"; // 应用标识(后台「应用管理」里的 code,必传):一张卡只对本应用有效
const string Ver = "1.0.0";
static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) };
static string DevFile => Path.Combine(AppContext.BaseDirectory, "device_id.txt");
static string TokenFile => Path.Combine(AppContext.BaseDirectory, "license.token");
/// 设备标识:首次运行生成并持久化,之后固定不变
static string DeviceId()
{
if (File.Exists(DevFile)) return File.ReadAllText(DevFile).Trim();
var id = Guid.NewGuid().ToString("N");
File.WriteAllText(DevFile, id);
return id;
}
static string? ReadToken() => File.Exists(TokenFile) ? File.ReadAllText(TokenFile).Trim() : null;
static async Task<JsonNode> PostAsync(string path, object body)
{
var res = await Http.PostAsJsonAsync($"{Base}/{path}", body);
var json = await res.Content.ReadAsStringAsync();
var node = JsonNode.Parse(json) ?? new JsonObject { ["status"] = "error" };
var token = node["token"]?.GetValue<string>();
if (!string.IsNullOrEmpty(token)) File.WriteAllText(TokenFile, token);
return node;
}
static Task<JsonNode> ActivateAsync() => PostAsync("activate.php", new
{
card_key = CardKey,
device_id = DeviceId(),
device_name = Environment.MachineName,
client_version = Ver,
app = AppCode, // 应用标识(必传):一张卡只对本应用有效
token = ReadToken(), // 有令牌即为续期
});
static Task<JsonNode> HeartbeatAsync() => PostAsync("heartbeat.php", new
{
card_key = CardKey,
device_id = DeviceId(),
token = ReadToken(),
client_version = Ver,
});
static Task<JsonNode> VerifyAsync() => PostAsync("verify.php", new
{
card_key = CardKey,
device_id = DeviceId(),
token = ReadToken(),
});
/// 应用展示信息(无需卡密,未激活也能调):
/// contact / contact_info 应用自己的联系人与联系方式(供应商/代理的后台联系方式不会下发)/
/// notice 公告 / buy_url 购买链接 / qrcode 客服二维码 / update_url 更新下载链接 / app.latest_version 最新版本。
/// owner 为兼容字段,内容同样来自应用自身
static async Task<JsonNode> AppInfoAsync()
{
var res = await Http.GetAsync($"{Base}/info.php?app={AppCode}&client_version={Ver}");
var json = await res.Content.ReadAsStringAsync();
return JsonNode.Parse(json) ?? new JsonObject { ["status"] = "error" };
}
static async Task Main()
{
// 启动先拉展示信息:还没激活(没卡)时也要告诉客户找谁买卡
var info = await AppInfoAsync();
if (info["status"]?.GetValue<string>() == "ok")
{
var contact = info["contact"]?.GetValue<string>() ?? "";
var contactInfo = info["contact_info"]?.GetValue<string>() ?? "";
if (contact != "" || contactInfo != "") Console.WriteLine($"客服:{contact} {contactInfo}");
foreach (var k in new[] { "notice", "buy_url", "qrcode" })
{
var v = info[k]?.GetValue<string>();
if (!string.IsNullOrEmpty(v)) Console.WriteLine($"{k}: {v}");
}
var latest = info["app"]?["latest_version"]?.GetValue<string>();
if (!string.IsNullOrEmpty(latest) && latest != Ver)
{
Console.WriteLine($"发现新版本 {latest}:{info["app"]?["update_note"]} → {info["update_url"]}");
}
}
var r = await ActivateAsync();
var status = r["status"]?.GetValue<string>();
Console.WriteLine($"activate: {r.ToJsonString()}");
// 已无人工审核;设备满且未开互顶时 reason = "device_limit"(提示用户联系发卡方解绑旧设备)
if (status != "ok")
{
Console.WriteLine($"未授权:{r["reason"] ?? r["message"]}");
return;
}
var interval = r["heartbeat_interval"]?.GetValue<int>() ?? 300; // 用服务器下发间隔
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(interval));
var hb = await HeartbeatAsync();
if (hb["status"]?.GetValue<string>() != "ok")
{
Console.WriteLine($"授权失效:{hb["reason"]} - 请降级或退出");
return;
}
}
}
}
// 米多多授权中心 - Rust 对接示例
// Cargo.toml:
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
// reqwest = { version = "0.11", features = ["blocking", "json"] }
// uuid = { version = "1", features = ["v4"] }
use serde::{Deserialize, Serialize};
use std::fs;
use std::time::Duration;
const BASE: &str = "https://k.mdduo.cn/api";
const CARD_KEY: &str = "MDT-XXXXX-XXXXX-XXXXX";
// 应用标识(后台「应用管理」里的 code,必传):一张卡只对本应用有效,
// 同时也是「展示信息」的查询键 —— 打包时注入(如构建脚本替换本行 / 环境变量),
// 每个渠道一份专属包 → 客户端显示该应用自己的联系人 / 联系方式 / 公告 / 购买链接 / 二维码 / 更新链接。
const APP_CODE: &str = "mdduotw";
const VER: &str = "1.0.0";
const DEV_FILE: &str = "device_id.txt";
const TOKEN_FILE: &str = "license.token";
#[derive(Serialize)]
struct ActivateReq<'a> {
card_key: &'a str,
device_id: &'a str,
device_name: &'a str,
client_version: &'a str,
app: &'a str, // 应用标识(必传):一张卡只对本应用有效
token: Option<String>,
}
#[derive(Serialize)]
struct HeartbeatReq<'a> {
card_key: &'a str,
device_id: &'a str,
token: Option<String>,
client_version: &'a str,
}
#[derive(Serialize)]
struct VerifyReq<'a> {
card_key: &'a str,
device_id: &'a str,
token: Option<String>,
}
// 自助解绑(「注销本机」):无需令牌,删除「该卡密 + 本机」这条激活记录、释放设备名额
#[derive(Serialize)]
struct UnbindReq<'a> {
card_key: &'a str,
device_id: &'a str,
app: &'a str, // 可选:传了服务端会校验它与卡密所属应用一致
}
#[derive(Deserialize, Debug)]
struct Resp {
status: String,
token: Option<String>,
reason: Option<String>,
message: Option<String>,
heartbeat_interval: Option<u64>,
lock_on_invalid: Option<u8>, // 网络异常是否锁端:1=锁 0=按离线宽限期
offline_grace: Option<u64>, // 离线宽限期(秒),0=不限时;仅 lock_on_invalid=0 时生效
}
/* ---------- 应用信息与展示信息(未激活也能调,无需卡密) ---------- */
#[derive(Deserialize, Debug)]
struct InfoResp {
status: String,
app: Option<InfoApp>,
contact: Option<String>, // 应用自己的联系人(客户端展示)
contact_info: Option<String>, // 应用自己的联系方式,手机 / 微信 / QQ
#[allow(dead_code)]
owner: Option<InfoOwner>, // 兼容字段:内容同样来自应用自身,新客户端用上面两个顶层字段
notice: Option<String>, // 公告(该应用配置的)
buy_url: Option<String>, // 购买链接(「购买卡密」按钮)
qrcode: Option<String>, // 客服二维码图片地址(完整 URL,可直接加载)
update_url: Option<String>, // 更新下载链接(该应用自己的下载页)
}
#[derive(Deserialize, Debug)]
struct InfoApp {
code: Option<String>,
name: Option<String>,
latest_version: Option<String>, // 最新版本号,空字符串 = 未配置
update_note: Option<String>, // 更新说明
contact: Option<String>,
contact_info: Option<String>,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct InfoOwner {
#[serde(rename = "type")]
kind: Option<String>, // 固定为 app
name: Option<String>,
contact: Option<String>,
contact_info: Option<String>,
}
/// 拉取展示信息(应用标识需在打包时注入,见常量 APP_CODE)
fn app_info() -> Result<InfoResp, String> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
client
.get(format!("{BASE}/info.php?app={APP_CODE}&client_version={VER}"))
.send()
.map_err(|e| e.to_string())?
.json::<InfoResp>()
.map_err(|e| e.to_string())
}
/// 版本比对:本地版本 < 最新版本 时提示更新(按数字段逐位比较,1.10.0 > 1.9.0)
fn version_less(cur: &str, latest: &str) -> bool {
let parse = |s: &str| -> Vec<u64> {
s.split(|c: char| !c.is_ascii_digit())
.filter(|p| !p.is_empty())
.filter_map(|p| p.parse::<u64>().ok())
.collect()
};
let (a, b) = (parse(cur), parse(latest));
for i in 0..a.len().max(b.len()) {
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
if x != y {
return x < y;
}
}
false
}
/// 启动时调用:未激活也能显示「找谁买卡」+ 新版本提示
fn show_app_info() {
let Ok(info) = app_info() else {
println!("展示信息拉取失败(可用本地缓存兜底)");
return;
};
if info.status != "ok" {
return;
}
// 联系人 / 联系方式全部来自应用自身:供应商 / 代理的后台联系方式不会下发
let contact = info.contact.clone().unwrap_or_default();
let contact_info = info.contact_info.clone().unwrap_or_default();
if !contact.is_empty() || !contact_info.is_empty() {
println!("客服:{contact} {contact_info}");
}
if let Some(n) = &info.notice { if !n.is_empty() { println!("公告:{n}"); } }
if let Some(u) = &info.buy_url { if !u.is_empty() { println!("购买卡密:{u}"); } }
if let Some(q) = &info.qrcode { if !q.is_empty() { println!("客服二维码:{q}"); } }
// 版本更新:latest_version / update_note / update_url 全部配在应用上
if let Some(app) = &info.app {
let latest = app.latest_version.clone().unwrap_or_default();
if !latest.is_empty() && version_less(VER, &latest) {
println!("发现新版本 {latest}:{}", app.update_note.clone().unwrap_or_default());
if let Some(u) = &info.update_url { if !u.is_empty() { println!("下载地址:{u}"); } }
}
}
}
/// 设备标识:首次运行生成并持久化,之后固定不变
fn device_id() -> String {
if let Ok(s) = fs::read_to_string(DEV_FILE) {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
let id = uuid::Uuid::new_v4().simple().to_string();
let _ = fs::write(DEV_FILE, &id);
id
}
fn read_token() -> Option<String> {
fs::read_to_string(TOKEN_FILE)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// 清除本地令牌:收到 no_activation(设备被解绑/换绑)后调用,
/// 否则下次 activate 会带旧令牌走「续期」分支,服务端将按重新激活处理
fn clear_token() {
let _ = fs::remove_file(TOKEN_FILE);
}
fn post(path: &str, body: &impl Serialize) -> Result<Resp, String> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp: Resp = client
.post(format!("{BASE}/{path}"))
.json(body)
.send()
.map_err(|e| e.to_string())?
.json()
.map_err(|e| e.to_string())?;
if let Some(t) = &resp.token {
let _ = fs::write(TOKEN_FILE, t); // 令牌落盘,重启后直接续期
}
Ok(resp)
}
fn activate() -> Result<Resp, String> {
let did = device_id();
post(
"activate.php",
&ActivateReq {
card_key: CARD_KEY,
device_id: &did,
device_name: "PC-01",
client_version: VER,
app: APP_CODE, // 应用标识:一张卡只对本应用有效
token: read_token(), // 有令牌即为续期
},
)
}
fn heartbeat() -> Result<Resp, String> {
let did = device_id();
post(
"heartbeat.php",
&HeartbeatReq {
card_key: CARD_KEY,
device_id: &did,
token: read_token(),
client_version: VER,
},
)
}
fn verify() -> Result<Resp, String> {
let did = device_id();
post(
"verify.php",
&VerifyReq {
card_key: CARD_KEY,
device_id: &did,
token: read_token(),
},
)
}
/// 客户端自助解绑(「注销本机」):删除「该卡密 + 本机」这一条激活记录,立刻释放一个设备名额。
/// 无需令牌、不校验卡密状态(禁用 / 吊销 / 过期都能解),只能解自己这一台;
/// 成功后应回到未激活界面(本例先清掉本地令牌,避免带着旧令牌走续期分支)。
/// 平台在系统设置里关闭该功能时返回 400 code=unbind_disabled。
fn unbind() -> Result<Resp, String> {
let did = device_id();
clear_token();
post(
"unbind.php",
&UnbindReq {
card_key: CARD_KEY,
device_id: &did,
app: APP_CODE,
},
)
}
fn main() {
// 先拉展示信息:未激活(还没有卡密)时也要能告诉客户「找谁买卡」
show_app_info();
match activate() {
Err(e) => println!("网络错误:{e}"),
Ok(r) => match r.status.as_str() {
"ok" => {
// 授权策略:服务端按应用下发,心跳成功时动态更新
let mut interval = r.heartbeat_interval.unwrap_or(300);
let mut lock_on_invalid = r.lock_on_invalid.unwrap_or(1) == 1;
let mut grace = r.offline_grace.unwrap_or(0);
println!(
"授权成功,心跳间隔 {interval}s,网络异常策略:{}",
if lock_on_invalid { "锁端".to_string() } else { format!("宽限 {grace}s(0=不限时)") }
);
let mut last_ok = std::time::Instant::now(); // 最后一次成功响应时刻
loop {
std::thread::sleep(Duration::from_secs(interval));
match heartbeat() {
Ok(hb) if hb.status == "ok" => {
last_ok = std::time::Instant::now();
interval = hb.heartbeat_interval.unwrap_or(interval);
lock_on_invalid = hb.lock_on_invalid.unwrap_or(lock_on_invalid as u8) == 1;
grace = hb.offline_grace.unwrap_or(grace);
}
// 设备被后台解绑 / 换绑(激活记录已删除):清掉本地令牌后重新激活,
// 服务端会重走完整校验(额度满且未开互顶时返回 device_limit)
Ok(hb) if hb.reason.as_deref() == Some("no_activation") => {
println!("设备已被解绑,尝试重新激活…");
clear_token();
match activate() {
Ok(a) if a.status == "ok" => {
last_ok = std::time::Instant::now();
interval = a.heartbeat_interval.unwrap_or(interval);
lock_on_invalid = a.lock_on_invalid.unwrap_or(lock_on_invalid as u8) == 1;
grace = a.offline_grace.unwrap_or(grace);
println!("重新激活成功");
}
Ok(a) => {
println!("重新激活失败:{:?}", a.reason.or(a.message));
break;
}
Err(e) => println!("重新激活网络错误(下轮重试):{e}"),
}
}
// 服务端明确拒绝:一律立即停用(不受网络异常策略影响)
Ok(hb) => {
println!("授权失效:{:?} - 请降级或退出", hb.reason);
break;
}
// 连不上服务器:按 lock_on_invalid / offline_grace 处理
Err(e) => {
if lock_on_invalid {
println!("网络异常且策略为「锁端」:立即停用({e})");
break;
}
if grace > 0 && last_ok.elapsed().as_secs() > grace {
println!("网络异常已超过宽限期 {grace}s:停用({e})");
break;
}
println!("网络异常(宽限中,距上次成功 {}s):{e}", last_ok.elapsed().as_secs());
}
}
}
}
// 已无人工审核:设备满且未开互顶时 reason = "device_limit"(提示用户联系发卡方解绑旧设备)
"locked" => println!("已锁定:{:?}", r.reason),
_ => println!("失败:{:?}", r.message),
},
}
let _ = verify(); // 只读校验:需要时可单独调用
}
九、常见问题排查(FAQ)
| 现象 | 原因与处理 |
|---|---|
| 响应不是 JSON(拿到 HTML 页 / 空响应) | 接口本身只返回 200 / 400 / 403 / 500 且响应体一定是 JSON;拿到 HTML 说明被 nginx 的错误页(error_page 404 / 500)或 PHP 错误输出替换了。
用 curl -i 看原始响应与状态码定位。客户端写法:先看 HTTP 状态码,再尝试解析 JSON,解析失败时提示「服务暂时不可用(HTTP xxx)」并按网络异常处理——不要当成授权失败把用户锁死 |
400 invalid_card(卡密不存在) |
卡密在库里查不到。核对是否复制多了空格、少了字符(服务端比较不区分大小写,但空格敏感);后台「系统日志」会留一条「激活失败:卡密不存在」,可对照日志里的 card_key 确认客户端实际传了什么 |
400 card_not_found(心跳 / 校验) |
卡已被发卡方删除 → 提示用户联系发卡方,或清掉本地凭证回到未激活状态 |
403 app_mismatch |
拿 A 应用的卡去激活 B 应用。核对请求里的 app 与该卡在后台「卡密列表 → 应用」列的值是否一致 |
403 app_required |
请求体没带 app(必传项)→ 补上应用标识后重试 |
403 device_limit |
设备数已满且该应用未开启「设备互顶」。三种解法:① 客户端调 unbind.php 注销本机 / 解绑旧机器自己腾名额(平台默认已开启该功能);
② 发卡方在后台 / 门户解绑旧设备;③ 开启「设备互顶」后新设备自动顶掉最早激活的那台 |
400 unbind_disabled(自助解绑) |
平台在「系统设置 → 客户端自助解绑」里关掉了该功能(默认开启)→ 提示用户联系发卡方解绑,或让平台重新打开 |
400 no_activation(自助解绑 / 心跳) |
自助解绑时表示本机本来就没绑定该卡密,无需处理(可提示「本机未绑定」);心跳遇到它说明本机激活记录已被删除 → 清空本地 token 重新 activate |
403 expired |
卡已到期 → 续费后重新激活(客户端无需改代码) |
403 invalid_token / device_mismatch / no_activation |
本地令牌失效 / 设备被解绑或换绑 → 清空本地 token 后重新调 activate 即可恢复(activate 接口本身不会返回 invalid_token,它会自动转为完整激活并换发新令牌) |
| 刚激活成功,下次心跳就被锁 | 看心跳返回的 reason:多为该设备被后台解绑 / 换绑(no_activation)、卡被禁用 / 吊销、或被新设备互顶。服务端对续期与首次激活走同一套校验,不会出现「先成功后误锁」,以 reason 与后台日志为准 |
400 insufficient_balance |
该卡所属经销商的米粒余额不足。本平台按「激活量」计费:卡密可以随便生成,只在卡密首次激活成功时扣一次米粒, 金额 = 卡类型单价 × 该卡设备上限(如天卡 10 米粒/台、设备上限 2 → 扣 20 米粒)。换设备 / 重新激活 / 心跳续期都不再重复扣。 余额不足时这张卡无法激活,客户端提示「经销商余额不足,请联系经销商!」,经销商充值后重试即可; 已激活过的卡不受余额影响,不会被停用 |
500 server_error |
服务端内部异常(数据库等)→ 退避重试(30s / 60s / 120s),持续出现请联系发卡方查后台日志 |
| 本地时间被改导致有效期判断异常 | 以响应里的 server_time 校准本地时间,不要只信系统时钟 |