授权码最容易在一开始就做错的地方,是把它当成一串随机字符存进数据库。等客户换主机、续费、退款,才发现每个场景都要人工介入:换域名要改记录,续费要重新发一串新码,退款之后旧的码还在客户手里能用。
授权码要能回答:这串字符是谁的、能用在几个地方、什么时候到期、能不能作废。这几件事分开设计,后面大部分工单就不会来。
自校验的 key:把声明(订单号、产品、到期时间、可激活站点数)拼成 JSON,服务端用密钥签一个 HMAC,拼进 key 里。客户端拿到 key 不联网也能验真伪、能读到到期时间。缺点是不能作废——签名有效就是有效。
服务端状态的 key:key 本身只是一串索引,所有信息查库。能作废、能算激活数、能续期,缺点是每次校验都要联网。
卖模板、素材这类一次性交付的东西,用签名下载链接就够了。卖需要持续运行的软件(插件、桌面工具、本地客户端),用签名 key 加服务端状态:签名负责离线验真,数据库负责吊销和计数。
<?php
declare(strict_types=1);
const LICENSE_SECRET = 'change-me'; // 从环境变量读,不要提交进仓库
function b64url( string $bin ): string {
return rtrim( strtr( base64_encode( $bin ), '+/', '-_' ), '=' );
}
function b64url_decode( string $s ): string {
return (string) base64_decode( strtr( $s, '-_', '+/' ) );
}
function issue_license( array $claims ): string {
$body = b64url( json_encode( $claims, JSON_UNESCAPED_SLASHES ) );
$sig = b64url( hash_hmac( 'sha256', $body, LICENSE_SECRET, true ) );
return $body . '.' . $sig;
}
function verify_license( string $key ): ?array {
$parts = explode( '.', $key, 2 );
if ( 2 !== count( $parts ) ) {
return null;
}
[ $body, $sig ] = $parts;
$expected = b64url( hash_hmac( 'sha256', $body, LICENSE_SECRET, true ) );
if ( ! hash_equals( $expected, $sig ) ) {
return null;
}
$claims = json_decode( b64url_decode( $body ), true );
return is_array( $claims ) ? $claims : null;
}声明里放这些字段:
$key = issue_license( [
'jti' => 10231, // 授权记录 id,吊销时用它
'pid' => 7, // 产品 id
'tier' => 'pro',
'iat' => time(),
'exp' => time() + 365 * 86400,
'max' => 1, // 可激活站点数
] );base64 不是加密,任何人解出来都能看到内容。邮箱、姓名这类信息不要放进去,放 jti,需要的时候查库。
激活时把站点归一化成一个 hash 存下来。只取 host,去掉协议、www、端口、路径:
function site_hash( string $url ): string {
$host = strtolower( (string) parse_url( $url, PHP_URL_HOST ) );
$host = preg_replace( '/^www\./', '', $host );
return hash( 'sha256', $host );
}绑 IP 会在客户换 CDN、换主机、办公室出口 IP 变化时误伤,绑域名足够。多环境(本地、预发)单独记 env,不占正式额度,但要有上限,否则一个 key 能激活无数个“预发站”。
表结构:
CREATE TABLE license (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
license_key VARCHAR(191) NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
email_hash CHAR(64) NOT NULL,
max_sites SMALLINT UNSIGNED NOT NULL DEFAULT 1,
issued_at DATETIME NOT NULL,
expires_at DATETIME NULL,
revoked_at DATETIME NULL,
UNIQUE KEY uniq_key (license_key),
KEY idx_order (order_id)
);
CREATE TABLE license_activation (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
license_id BIGINT UNSIGNED NOT NULL,
site_hash CHAR(64) NOT NULL,
site_host VARCHAR(191) NOT NULL,
env VARCHAR(16) NOT NULL DEFAULT 'production',
activated_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
deactivated_at DATETIME NULL,
UNIQUE KEY uniq_site (license_id, site_hash, env)
);license_key 存明文还是哈希?要支持“客户把 key 弄丢了,登录后台能重新看到”,就得存明文或者可逆加密。折中方案:存 key 的 sha256 用于查找,另存一份加密后的原文用于展示。小规模直接存明文也能接受,前提是数据库不被拖走。
同一台机器重复点“激活”很常见,第二次不能报“已达上限”。
function activate( PDO $db, string $key, string $site_url, string $env = 'production' ): array {
$claims = verify_license( $key );
if ( null === $claims ) {
return [ 'ok' => false, 'error' => 'invalid_key' ];
}
$stmt = $db->prepare( 'SELECT * FROM license WHERE id = ? LIMIT 1' );
$stmt->execute( [ $claims['jti'] ] );
$lic = $stmt->fetch( PDO::FETCH_ASSOC );
if ( ! $lic ) {
return [ 'ok' => false, 'error' => 'unknown_license' ];
}
if ( null !== $lic['revoked_at'] ) {
return [ 'ok' => false, 'error' => 'revoked' ];
}
if ( null !== $lic['expires_at'] && strtotime( $lic['expires_at'] ) < time() ) {
return [ 'ok' => false, 'error' => 'expired' ];
}
$hash = site_hash( $site_url );
$stmt = $db->prepare(
'SELECT * FROM license_activation
WHERE license_id = ? AND site_hash = ? AND env = ? LIMIT 1'
);
$stmt->execute( [ $lic['id'], $hash, $env ] );
$row = $stmt->fetch( PDO::FETCH_ASSOC );
if ( $row && null === $row['deactivated_at'] ) {
$db->prepare( 'UPDATE license_activation SET last_seen_at = NOW() WHERE id = ?' )
->execute( [ $row['id'] ] );
return [ 'ok' => true, 'expires_at' => $lic['expires_at'] ];
}
$stmt = $db->prepare(
'SELECT COUNT(*) FROM license_activation
WHERE license_id = ? AND env = ? AND deactivated_at IS NULL'
);
$stmt->execute( [ $lic['id'], 'production' ] );
if ( 'production' === $env && (int) $stmt->fetchColumn() >= (int) $lic['max_sites'] ) {
return [ 'ok' => false, 'error' => 'site_limit_reached' ];
}
$db->prepare(
'INSERT INTO license_activation (license_id, site_hash, site_host, env, activated_at, last_seen_at)
VALUES (?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE deactivated_at = NULL, last_seen_at = NOW()'
)->execute( [ $lic['id'], $hash, parse_url( $site_url, PHP_URL_HOST ), $env ] );
return [ 'ok' => true, 'expires_at' => $lic['expires_at'] ];
}ON DUPLICATE KEY UPDATE 是 MySQL 语法,换 SQLite 要改成 INSERT OR REPLACE 或者先查再写。用 PDO 的时候注意这点,跨库测试会踩。
数量限制:max_sites 和 env 计数,靠服务端 COUNT(*)。客户端算的一律不算。
功能限制:tier 放在签名里,客户端能读,也能改。低价值功能用它做开关没问题,高价值功能(导出、批量处理、去水印)在服务端判断。
时间限制:exp 在签名里,客户端离线也能判断过期。但客户端时间可以改,所以离线宽限期不要给太长。
心跳接口不用复杂,能更新 last_seen_at 就够:
GET /license/status?key=...&site=example.com
{ "ok": true, "expires_at": "2027-10-29", "revoked": false }不要因为连续几天没心跳就自动吊销。客户的主机可能被防火墙挡了外网请求,自动吊销会变成一堆投诉。把 last_seen_at 超过 30 天的记录标出来,人工看。
给客户一个自助入口,每年允许换一次,deactivate 旧记录再 activate 新的。超过一次走人工。
后台要能看到这几项:当前激活的站点、最后心跳时间、累计激活次数、到期时间。客服处理工单时不用翻数据库。
续费订单支付完成后:
UPDATE license
SET expires_at = DATE_ADD(
GREATEST( COALESCE( expires_at, NOW() ), NOW() ),
INTERVAL 1 YEAR
)
WHERE id = ? AND revoked_at IS NULL;GREATEST 是为了让提前续费的客户不吃亏——还没到期就续,从原到期日往后加,不从今天加。
key 不变的好处是客户不用重新填,站点不用重新激活。代价是 key 泄露之后没法撤回,只能靠 revoked_at 加服务端校验。
提醒邮件在到期前 30 天、7 天、1 天各发一次。邮件里放续费链接和当前到期日,不要把 key 明文再发一遍。
过期之后给一段宽限期(比如 14 天),期间功能降级但不锁死,宽限期结束再拒绝联网校验。宽限期长度写进文档,不要放在代码里靠猜。
退款、拒付、key 泄露,都要能作废:
UPDATE license SET revoked_at = NOW() WHERE id = ?;
UPDATE license_activation SET deactivated_at = NOW()
WHERE license_id = ? AND deactivated_at IS NULL;重发就是把旧记录作废,用同一个 order_id 签一条新记录,jti 不同。旧 key 在客户端本地仍然能通过签名验证——这是签名式 key 的固有缺陷。要让它立刻失效,客户端必须联网查一次 jti 的状态。这一点写在文档里,别等客户发现。
LICENSE_SECRET 泄露一次,所有 key 都要重签。提前给 secret 加版本:key 前缀带 v1、v2,验证时按前缀选 secret,新旧并行一段时间,再停掉旧的。改 secret 之前先确认客户端支持按前缀选密钥,否则所有在线设备会在同一分钟失效。
这几行写清楚,能挡掉一半的售前咨询。
如果是在 WooCommerce 上卖虚拟商品,结账流程和欧盟 14 天撤回权这两块我们做过一个免费插件 AumDigital,插件页面在这里。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。