客户端对接 API

接口直发 · JSON over HTTPS · 卡密激活 / 心跳续期 / 授权校验

一、通用约定

项目说明
接口地址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(必填,客户端只认它) → 在「我的卡密」生成卡密用于测试。三者(接口地址 / 应用标识 / 卡密)齐全就能调通激活
安全提示:请始终使用 HTTPS;卡密与令牌属于机密,不要写死在会被反编译明文暴露的位置,也不要上传到日志/错误上报系统。

二、令牌(token)与设备标识

token 由服务器签发,是「卡密 + 设备」的签名凭证。客户端只需原样保存并回传,无需解析、不要自行拼装(签名密钥保存在服务器,客户端无法伪造)。 首次激活拿到 token 后请持久化(注册表 / 配置文件 / 本地库均可),此后每次 activate、heartbeat、verify 都带上它。

device_id 是设备唯一标识:客户端首次运行生成一次(UUID / 机器码哈希等均可)并持久化,之后必须保持不变。 换机或重置 device_id 会被服务器视为新设备,需要重新激活:若设备数已满且未开启互顶,服务器会返回 device_limit,需先在经销商后台 / 门户「设备列表」解绑旧设备。

三、status 与 HTTP 状态码

statusHTTP含义与客户端处理建议
ok正常200授权有效。保存返回的新 token,按 heartbeat_interval 秒后发起心跳。
locked已锁定403被禁用/吊销/过期/黑名单/设备超额(device_limit)等。停止功能并提示 reason,不要继续重试。
error400/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 校准本地时间,不要只信系统时钟