简介:面向软件试用期与授权管理场景的实践示例包,适合使用 Qt 开发桌面应用的开发者、产品经理及需要设计试用策略的团队参考。内容围绕试用天数与启动次数限制、永久授权管理展开,涉及注册表与 XML 配置文件两种记录方式,并通过 Base64 等编码手段保护授权数据,帮助读者理解从策略设计到落地实现的完整链路。压缩包共 38 个文件,约 18.8MB,以 25 个 dll 动态库和 9 个 qm 多语言资源为主,另含 3 个 exe 可执行程序与 1 个 xml 配置文件,分别承担运行依赖、界面语言切换、试用测试入口和授权信息存储等作用。目前已有 1506 人学习下载。借助其中的可运行测试程序与配置样例,读者可对照梳理 Qt 授权模块的组织方式、试用信息读写流程及多语言资源加载思路,也可作为自身项目试用期功能设计的参考骨架,快速验证天数限制、次数限制与授权状态判断等关键环节。
1. 一个压缩包丢过来,需求只有三句话
上个月接了个活,客户要把一套桌面工具发出去试用,交付物里除了源码,还有个压缩包,名字直白得不像话——试用期设置.rar。需求压缩成三句话:装完能跑 30 天;几个高级功能在试用期内锁着;每天启动有次数上限,到期弹窗让用户填授权码。听着不难,动手才发现试用期设置这事卡的全是边角:时间怎么记、机器怎么认、用户把配置删了怎么办、授权码怎么防伪造。这套 Qt 实现把「期限 + 使用次数 + 权限」三把锁抽成了独立模块,和业务代码几乎不耦合,适合要发试用版、又不想从零攒一套授权体系的人。下面按工程结构一层层拆,从目录到核心代码,再到我在这种模块上翻过的车。
2. 工程结构:策略、状态、授权校验、授权码四块各管一摊
2.1 目录划分与每个文件夹的职责
先给结论:这份代码最值得学的,是它没有把「这个功能能不能用」散落在几十个按钮的槽函数里。散落的后果是改一次试用规则要动半个工程,漏掉一个入口就白送。它做的是四层拆分,各自只干一件事:
- TrialPolicy 管策略:试用多少天、启动多少次、试用期哪些功能锁死。只读,来自
res/trial.json,编进 qrc 里跟程序走。 - LicenseManager 管状态:安装时间、上次运行时间、累计次数、当日次数、授权信息。可写,落在用户目录。
- FeatureGate 管授权校验:业务代码只问
gate.isAllowed(Feature::ExportPdf),不关心背后是试用到期还是没买。 - LicenseCodec 管授权码:编码、解码、验签,只依赖一把公钥。
这个切法的好处很实在。产品和运营要调试用天数,改 JSON 就行,不用重编译逻辑代码;以后要把 HMAC 换成 RSA,只动 codec,FeatureGate 一行不改;要把状态从本地文件改成服务端下发,重写 LicenseManager 的loadState/saveState两个函数,上层无感。
TrialGuard/ ├── TrialGuard.pro # qmake 工程文件 ├── src/ │ ├── main.cpp # 启动顺序全在这几十行里 │ ├── license/ │ │ ├── trialpolicy.h/.cpp # 只读策略,JSON -> 内存 │ │ ├── licensemanager.h/.cpp # 状态落盘、次数计数、时间回拨检测 │ │ ├── licensecodec.h/.cpp # 授权码编解码 + 验签 │ │ ├── machineid.h/.cpp # 机器码生成 │ │ └── cryptoutil.h/.cpp # SHA-256 / HMAC / RSA 封装 │ ├── app/ │ │ ├── featuregate.h/.cpp # 门禁,业务层唯一入口 │ │ └── appconfig.h/.cpp # 程序级配置(日志路径等) │ └── ui/ │ ├── mainwindow.h/.cpp │ └── activationdialog.h/.cpp # 授权码输入窗口 ├── res/ │ ├── trial.json # 策略文件,编译进 qrc │ ├── public.pem # 验签公钥 │ └── qrc.qrc └── tools/ └── licgen/ # 发证工具,命令行生成授权码| 文件 | 谁调用 | 改它会影响什么 |
|---|---|---|
trial.json | TrialPolicy | 试用天数、次数上限、锁哪些功能,改完重编译 qrc |
licensemanager.cpp | main、FeatureGate | 计数规则、回拨判定,改动风险最高 |
public.pem | LicenseCodec | 换密钥对必须同步换发证工具的私钥 |
featuregate.cpp | 所有业务窗口 | 新增功能位要同时改枚举和策略 JSON |
tools/licgen单独一个子工程,不参与主程序编译,发证机器上留着私钥,客户端只有公钥。物理分离比什么加密都管用——私钥压根没出现在用户能拿到的安装包里。
2.2 启动链路:main 里那二十来行决定了判定的顺序
顺序错了就是安全漏洞。我见过把FeatureGate构造放在LicenseManager::beginSession()之前的写法,结果门禁读到的是空状态,试用期直接当正式版放行。正确的顺序是策略 → 状态 → 授权校验 → UI:
int main(int argc, char *argv[]) { QApplication app(argc, argv); QApplication::setOrganizationName("DemoSoft"); QApplication::setApplicationName("DemoTool"); // 1. 策略先加载。后面所有判定都依赖天数/次数上限,缺了宁可不让跑 TrialPolicy policy; if (!policy.load(":/res/trial.json")) { qCritical() << "trial policy load failed"; return 2; // 策略缺失直接退出,不降级放行 } // 2. 状态初始化:首次安装落盘、当日计数自增、时间回拨检测全在这里 LicenseManager manager(policy); if (!manager.beginSession()) { QMessageBox::critical(nullptr, "启动失败", "程序已在运行,或授权状态异常,请联系发行方"); return 3; } // 3. 授权校验对象,业务层只拿 gate 的引用 FeatureGate gate(manager); if (!gate.checkOnStartup()) { // 到期弹窗、只读模式、强制退出都在这 return 0; } MainWindow w(gate); w.show(); return app.exec(); }几个参数值得说。return 2和return 3用了不同的退出码,方便在安装器或运维脚本里区分「包损坏」和「授权异常」。beginSession()返回 false 有两种可能:一是QLockFile抢锁失败,说明已经开着一个实例;二是状态文件的校验和不对,被判成篡改。这两种情况弹同一个提示,但对用户来说处理方式不一样,所以后面在日志里要分开记。
checkOnStartup()的返回值语义是「要不要继续启动」。到期之后它并不直接exit,而是弹激活窗口,用户点「继续试用(只读)」也允许进主界面,只是功能位全锁。这个设计比一到期就锁死整个程序好得多——用户还能看到自己的历史文件,购买意愿反而高。
2.3 编译和第一次跑通
工程是标准 qmake,依赖只有 Qt Core / Gui / Widgets / Network(Network 用来枚举网卡取 MAC)。版本上没有特殊要求,Qt 5.12 以上都能编,MSVC 2019 和 MinGW 64 位我都试过。
# 生成 Makefile,release 模式 qmake TrialGuard.pro CONFIG+=release # Linux / macOS make -j8 # Windows + MSVC nmake # Windows + MinGW mingw32-make -j8第一次运行会在用户数据目录写下状态文件。Windows 是%APPDATA%/DemoSoft/DemoTool/,Linux 是~/.local/share/DemoSoft/DemoTool/,macOS 是~/Library/Application Support/DemoSoft/DemoTool/。目录里会有state.dat和app.lock两个文件。
state.dat不是纯文本 ini,是「混淆后的字节流 + 尾部 16 字节摘要」。用记事本打开是一堆乱码,用户想手改totalLaunches得先搞清楚格式,成本比直接卸载重装高。当然这不叫安全,只是抬高门槛,真正的防线在机器码绑定和服务端校验上,后面第 5 章会讲。
调试阶段建议加一个环境变量开关,让程序在--dev参数下走内存态、不落盘、不计数。否则你一天能把自己 30 天的试用期用完三次,血泪经验。
3. 试用期判定与次数统计:三把锁怎么落在一个状态文件上
3.1 首次运行落盘:把「第一次」钉死
试用期的核心不是「还剩几天」,而是「第一次是哪天」。这个值必须只写一次,之后任何代码路径都不能改它。做法很简单,但坑在于写盘时机——如果在main末尾写、或者等用户点「同意协议」再写,程序中途崩了就没记上,用户重启一次相当于续命一天。所以要在beginSession()最前面写。
QVariantMap LicenseManager::createInitialState() const { const QDateTime now = QDateTime::currentDateTimeUtc(); // 全程用 UTC,避开时区 QVariantMap s; s["installTime"] = now.toString(Qt::ISODate); s["lastRunTime"] = now.toString(Qt::ISODate); s["totalLaunches"] = 0; s["dayStamp"] = now.date().toString("yyyyMMdd"); s["dayLaunches"] = 0; s["tamperFlag"] = 0; // 回拨/篡改累计计数,超过阈值直接判异常 return s; }时间统一用currentDateTimeUtc()。用本地时间的话,用户飞到另一个时区,日期可能倒退一天,回拨检测会误报;更糟的是有些系统时区配置本身就不对。UTC 存、UTC 比,只在给用户显示「还剩 N 天」的时候转成本地日期。
dayStamp存成yyyyMMdd字符串而不是QDate,是为了序列化省事,也比较抗格式变动。tamperFlag是后悔药——用户第一次改系统时间,你可能只想警告;改到第五次就直接把状态标成异常,让他联系客服。降级策略比一刀切友好。
3.2 双重限制:剩余天数和启动次数同时卡
两个维度的限制必须都判,而且取最严格的那个。有些实现只判天数,用户一天开两百次把试用价值榨干;有些只判次数,用户把程序开着不关,三十天当三年用。
bool LicenseManager::beginSession() { // 1. 单实例锁。两个进程同时改计数,totalLaunches 必然写丢 const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(dir); m_lock = std::make_unique<QLockFile>(dir + "/app.lock"); m_lock->setStaleLockTime(0); // 不自动清理,防止误判 if (!m_lock->tryLock(200)) return false; // 2. 读状态。loadState 内部会校验尾部摘要,摘要不对返回空 -> 按首次安装处理 QVariantMap s = loadState(); if (s.isEmpty()) s = createInitialState(); // 3. 时间回拨检测:当前时间比上次运行还早,超出容差就算一次异常 const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime last = QDateTime::fromString(s["lastRunTime"].toString(), Qt::ISODate); if (last.isValid() && now < last.addSecs(-m_policy.rollbackToleranceSec())) { s["tamperFlag"] = s["tamperFlag"].toInt() + 1; } // 4. 跨天重置当日次数 const QString today = now.date().toString("yyyyMMdd"); if (s["dayStamp"].toString() != today) { s["dayStamp"] = today; s["dayLaunches"] = 0; } // 5. 计数自增并立刻落盘。先写后判,进程被 kill 也不丢这一次 s["totalLaunches"] = s["totalLaunches"].toInt() + 1; s["dayLaunches"] = s["dayLaunches"].toInt() + 1; s["lastRunTime"] = now.toString(Qt::ISODate); m_state = s; saveState(s); return true; }第 5 步的顺序是重点。「先判断是否超限、再自增」看着更合理,但存在窗口期:判断通过后程序崩溃,这一次就没记上,反复构造崩溃就能无限用。「先自增落盘、再用自增后的值判断」虽然会多扣一次(崩溃也给算上了),但对发行方有利,而且用户感知不到——正常退出不会有这个差异。
setStaleLockTime(0)是个容易被忽略的参数。默认值 30 秒,意味着程序被任务管理器杀掉后,30 秒内重启会抢不到锁,用户看到「程序已在运行」的提示但任务管理器里明明没有进程,会直接投诉。设成 0 表示永不自动清理,靠QLockFile自己记录的 PID 判断进程是否还活着,进程真没了就能拿到锁。
3.3 状态评估:把所有判定收进一个函数
判定逻辑集中在一个地方,只返回结构体,不弹窗不退出,UI 层自己决定怎么展示。
LicenseStatus LicenseManager::evaluate() const { LicenseStatus st; // 已授权且没过期,天数次数全不看 if (m_license.isValid() && m_license.expireDate() >= QDate::currentDate()) { st.level = Level::Licensed; st.reason = QStringLiteral("授权有效"); return st; } const QDateTime install = QDateTime::fromString(m_state["installTime"].toString(), Qt::ISODate); const int usedDays = install.date().daysTo(QDateTime::currentDateTimeUtc().date()); const int usedTotal = m_state["totalLaunches"].toInt(); const int usedToday = m_state["dayLaunches"].toInt(); st.remainDays = qMax(0, m_policy.trialDays() - usedDays); st.remainLaunches = qMax(0, m_policy.maxLaunches() - usedTotal); st.remainToday = qMax(0, m_policy.dailyLaunches()- usedToday); st.tamperFlag = m_state["tamperFlag"].toInt(); // 任意一个维度归零,就降级成只读 if (st.remainDays == 0 || st.remainLaunches == 0 || st.remainToday == 0) { st.level = Level::Expired; st.reason = (st.remainDays == 0) ? QStringLiteral("试用期已结束") : QStringLiteral("试用次数已用完"); } else if (st.tamperFlag >= m_policy.tamperThreshold()) { st.level = Level::Tampered; // 单独一档,UI 上给不同提示 st.reason = QStringLiteral("检测到系统时间异常"); } else { st.level = Level::Trial; } return st; }remainToday这个字段我建议一定要暴露给 UI。用户不知道今天还能开几次,第五次打开被拦下来会很不爽;提前在标题栏显示「今日剩余 2 次」就没什么投诉。Tampered单独一档是因为它和自然到期性质不同,到期引导购买,异常应该引导联系客服,两套文案不能混。
3.4 落盘时的防篡改:摘要不是加密
state.dat的结构是「明文混淆字节 + 16 字节 SHA-256 截断」。混淆用的是固定密钥的 XOR,纯粹防手改;摘要用来发现修改。
QByteArray LicenseManager::serialize(const QVariantMap &s) { QByteArray plain = QJsonDocument(QJsonObject::fromVariantMap(s)) .toJson(QJsonDocument::Compact); QByteArray body = CryptoTool::xorBytes(plain, m_policy.stateKey()); // 摘要 = SHA256(混淆后的字节 + 固定盐),取前 16 字节 const QByteArray mac = CryptoTool::sha256(body + m_policy.stateSalt()).left(16); return body + mac; }stateKey和stateSalt编在 qrc 里,反编译能拿到,这是这类方案的固有天花板。想再高一点,就得把关键判定放到服务端,本地只缓存一个有时效的令牌。不过对多数工具类软件,本地方案已经能挡住 90% 的用户了——真正会逆向的本来也不是你的付费客户。
4. 权限分级与授权码:功能位掩码怎么和机器码绑一起
4.1 功能位掩码:试用版和正式版到底差在哪
用枚举加位运算表示功能权限,比一堆 bool 字段好扩展得多。加一个新功能只需加一个位,老授权码不受影响。
enum class Feature : quint32 { BasicEdit = 1u << 0, // 基础编辑,试用版就给 ExportPdf = 1u << 1, // 试用期锁 BatchProc = 1u << 2, // 试用期锁 ApiAccess = 1u << 3 // 只有企业版给 }; bool FeatureGate::isAllowed(Feature f) const { // 正式授权:看授权里带的位 if (m_status.level == Level::Licensed) return (m_license.features() & static_cast<quint32>(f)) != 0; // 试用期:策略里 block 掉的一律不给,其余放行 if (m_status.level == Level::Trial) return (m_policy.trialFeatures() & static_cast<quint32>(f)) != 0; // 到期 / 异常:全部锁死,只保留打开文件之类的最低能力 return f == Feature::BasicEdit ? false : false; }最后那行写得有点笨,实际代码里 Expired 状态允许「只读打开」,所以BasicEdit在只读语义下是放行的。真正要强调的是:业务代码里所有功能入口必须走这个函数,包括菜单项禁用、工具栏按钮置灰、右键菜单隐藏,甚至连快捷键绑定都要判。漏一个地方,用户按 Ctrl+E 照样能导出。
策略侧对应trial.json:
{ "productId": "demo-tool", "trialDays": 30, "maxLaunches": 50, "dailyLaunches": 5, "trialFeatures": 1, "rollbackToleranceSec": 600, "tamperThreshold": 3 }trialFeatures写1就是只给BasicEdit。运营要搞个「七天全功能体验」,把它改成15(1+2+4+8)重新打包即可,逻辑代码一行不动。rollbackToleranceSec给 600 秒容差,是因为 NTP 校时会让系统时间往回跳几十秒,容差太小会误伤正常用户。
4.2 授权码的编码与验签
授权码是用户从邮件里复制的一串字符,格式上我建议payload + 签名拼一起再做 Base64URL,用户看到的就是一个连续字符串,不用记两段。
QByteArray LicenseCodec::makePayload(const QString &machineId, const QDate &expire, quint32 features) { QJsonObject o; o["m"] = machineId; // 绑定的机器码 o["e"] = expire.toString("yyyyMMdd"); // 到期日 o["f"] = static_cast<double>(features); // 功能位 o["v"] = 1; // 授权格式版本,以后加字段靠它 return QJsonDocument(o).toJson(QJsonDocument::Compact); } QString LicenseCodec::issue(const QByteArray &payload) const { // 发证端:私钥签名,只有发行方持有私钥 const QByteArray sig = CryptoTool::rsaSign(payload, m_privateKey); return QString::fromLatin1((payload + sig).toBase64(QByteArray::Base64UrlEncoding)); }f用double存是因为QJsonValue没有无符号整型重载,quint32直接塞会隐式转换出警告。功能位最多 32 个,double的 53 位尾数完全够用,读回来再static_cast<quint32>就行。
验证端要做两件事:洗干净用户输入,然后验签。
bool LicenseCodec::verify(const QString &userInput, LicenseInfo *out) const { // 用户从邮件复制常带换行、空格、分组短横线,先全部去掉 QString clean = userInput; clean.remove(QRegularExpression("[\\s\\-]")); const QByteArray raw = QByteArray::fromBase64( clean.toLatin1(), QByteArray::Base64UrlEncoding); const int sigLen = CryptoTool::signatureLength(m_publicKey); // RSA-2048 -> 256 if (raw.size() <= sigLen) return false; const QByteArray payload = raw.left(raw.size() - sigLen); const QByteArray sig = raw.right(sigLen); // 验签失败直接返回,不要 "容错" 去猜字段 if (!CryptoTool::rsaVerify(payload, sig, m_publicKey)) return false; const QJsonObject o = QJsonDocument::fromJson(payload).object(); out->setMachineId(o["m"].toString()); out->setExpireDate(QDate::fromString(o["e"].toString(), "yyyyMMdd")); out->setFeatures(static_cast<quint32>(o["f"].toDouble())); return true; }洗输入那步千万别省。我第一版没做,测试同学从邮件里复制粘贴带着换行,fromBase64遇到\n直接返回空,弹「授权码无效」,来回扯了半小时才发现是换行。现在的做法是白名单过滤,只保留[A-Za-z0-9_-],其余字符一律丢弃再解码。
验签之后还有一步机器码比对,放在FeatureGate::activate()里:
bool FeatureGate::activate(const QString &code, QString *err) { LicenseInfo info; if (!m_codec.verify(code, &info)) { *err = QStringLiteral("授权码格式错误或已被篡改"); return false; } if (info.machineId() != MachineId::compute()) { *err = QStringLiteral("该授权码绑定了其他设备"); return false; } if (info.expireDate() < QDate::currentDate()) { *err = QStringLiteral("授权码已过期,请联系发行方续期"); return false; } m_manager.saveLicense(info); return true; }三种失败给三句不同的提示,用户才知道下一步该干嘛。全都回「激活失败」,客服会被问爆。
4.3 机器码怎么取才不飘
机器码最容易翻车的地方不是强度,是稳定性。同一台机器今天取出来一个值、明天换一个值,用户昨天买的授权今天就失效,退款率直接起飞。
QString MachineId::compute() { QStringList parts; // 网卡 MAC:跳过回环和虚拟网卡 for (const QNetworkInterface &ni : QNetworkInterface::allInterfaces()) { const auto flags = ni.flags(); if (flags.testFlag(QNetworkInterface::IsLoopBack)) continue; if (flags.testFlag(QNetworkInterface::IsVirtual)) continue; // 常见做法,过滤虚拟网卡 const QString mac = ni.hardwareAddress(); if (!mac.isEmpty()) parts << mac; } parts.sort(); // 排序!枚举顺序变了也不会导致机器码漂移 const QByteArray seed = (parts.join('|') + QSysInfo::machineUniqueId()).toUtf8(); return QString::fromLatin1( CryptoTool::sha256(seed).toHex().left(16)).toUpper(); }三个关键点。第一,parts.sort()不能省,多网卡机器上枚举顺序不保证,不排序的话插拔一次网线机器码就变了。第二,用QSysInfo::machineUniqueId()兜底,这个值在 Windows 上来自注册表 MachineGuid,重装系统才变,比 MAC 稳。第三,过滤虚拟网卡,不然用户装个虚拟机软件机器码就漂了。取 16 位十六进制够用,太长了用户报客服时抄着累。
5. 常见问题排查:这五类翻车我基本每次都遇到
5.1 用户改系统时间,试用期永远用不完
现象:试用期写着 30 天,有用户用了三个月还在用,日志显示每天都是「剩余 29 天」。
原因:installTime存下来了,但剩余天数是用「当前系统时间 - 安装时间」算的。用户把系统时间往回拨到安装当天,差值永远是 0。更粗暴的做法是把系统时间锁在某一天不动,一样有效。
解决:不能只靠单向差值。一是加回拨检测,把lastRunTime也存下来,每次启动比对,当前时间早于上次运行时间超过容差就累加tamperFlag;二是改算法,不存安装时间,只存「剩余天数」这一个递减值,每次启动减一,跟系统时间彻底脱钩。第二种更硬,但用户如果一天开十次就掉十天,体验差。我现在的做法是两者结合:以「最后运行日期」为锚点,记录「累计使用天数」,只要日期往前跳一律不算,往后跳一天加一天,跳太多天(比如一次跳 100 天)就只加一天并记一次异常。用户改时间最多是拖延,赚不到便宜。
5.2 卸载重装,试用期重置
现象:用户发现删掉%APPDATA%下的目录,或者卸载重装,30 天试用又能重来一遍。
原因:所有状态都放在用户可写的应用数据目录,卸载程序按惯例不清这个目录(怕误删用户数据),删了等于全新安装。这是客户端授权的通病,没有服务端就是无解。
解决:分场景处理。如果软件允许联网,首次运行去服务端领一个「设备指纹 + 首次激活时间」的记录,本地只是缓存,删了也能拉回来。如果必须离线,就把关键标记写到多个位置:应用数据目录、注册表(Windows)、系统临时目录下一个带随机名的文件,三处交叉校验,缺一处就按篡改处理。这仍然挡不住会逆向的人,但把「删个文件夹就能重置」变成「得知道要删三个地方」,白嫖成本上去了。别指望做到绝对,做到「比隔壁那家麻烦」就够了。
5.3 计数偶尔少一次,用户反复触发
现象:批量测试时发现totalLaunches偶尔比实际启动次数少 1,用户疯狂重启能把次数耗完。
原因:计数写盘放在了aboutToQuit信号里。程序崩溃、任务管理器强杀、断电,这个信号根本发不出来,这一次启动就没记上。
解决:挪到beginSession()里,先自增写盘再返回。代价是「启动到一半崩了也算一次」,对用户略亏,但计数只会多不会少,方向上是对的。写盘用QSaveFile保证原子性,别用裸QFile直接 open-write,写一半断电会留下半截文件,下次读摘要对不上,被判成篡改更麻烦。
5.4 授权码明明对的,用户粘进去就说无效
现象:用户把邮件里的授权码复制到激活窗口,提示「格式错误」,反复试都不行。
原因:邮件客户端自动折行插入了\r\n;有些用户手动输入时看到分组短横线,也跟着敲进去;还有中文输入法下混进了全角空格。QByteArray::fromBase64遇到非法字符直接返回空。
解决:解码前做一次白名单过滤,只保留[A-Za-z0-9_-],其余全删。另外激活输入框用QPlainTextEdit而不是QLineEdit,因为授权码可能有两三百字符,单行输入框里用户看不见全貌,以为自己复制少了。再加一个「粘贴」按钮,从剪贴板读的时候顺手trimmed()。
5.5 换台电脑就失效,用户觉得被坑
现象:用户换了新笔记本,用同一个授权码提示「绑定了其他设备」,投诉说买的是软件不是设备。
原因:机器码绑死了单台设备,这是设计如此,但销售页面上没写清楚。
解决:产品层面在购买页明确写「单机授权,一码一机」,并提供「换机解绑」流程——用户提工单,后台把旧机器码从授权库里删掉,重新发一个。技术层面可以留一个后门开关:授权码里带一个maxActivations字段,允许同一码在 N 台设备上激活,服务端记录已激活列表。离线版做不了这个,所以离线版的销售话术必须提前说清楚,别等用户装完了才发现。这一条严格说不算代码问题,但它是这类模块收到投诉最多的地方,写在这里提醒一句。
6. 二次开发:三个改造方向和一套验证清单
拿到这个模块之后,多数人第一件想改的事是「把本地状态换成服务端」。改法不难,LicenseManager里的loadState/saveState两个函数就是全部接口边界,把读写文件换成一次 HTTP 请求,其余代码不用动。要注意的是必须做本地缓存 + 超时降级:服务端挂了不能把用户锁在门外,常见做法是缓存最近一次成功校验结果,允许离线宽限 7 天,超过才要求重新联网。
第二个方向是功能位扩展。现在只有 4 个位,加到 32 个也够用,但建议同时在trial.json里把功能位写成命名映射,别让后来的人对着15猜是哪几个功能:
{ "features": { "basic_edit": 1, "export_pdf": 2, "batch_proc": 4, "api_access": 8 }, "trialFeatures": ["basic_edit"] }加载时把数组求和成掩码,策略文件自解释,运营改起来也不用问开发。
第三个方向是发证工具。tools/licgen现在是个命令行,接受机器码、到期日、功能列表,吐一串授权码。如果销售要用,建议包一层最简单的 GUI 或者做成内部网页,把「生成授权码」和「记录到客户台账」合成一步,否则时间一长就没人记得某个码发给谁了。
改造完必须跑一遍验证清单,这几步我每次发版前都强制走一遍,比单元测试管用:
| 验证项 | 操作 | 期望结果 |
|---|---|---|
| 首次安装 | 清空用户数据目录后启动 | 生成新状态,剩余 30 天 |
| 时间回拨 | 系统时间往前调 3 天再启动 | 记一次异常,天数不减 |
| 删配置 | 删掉state.dat后启动 | 按首次安装处理(这是已知天花板) |
| 机器码稳定性 | 插拔网线、开关虚拟网卡后重启 | 机器码不变 |
| 授权码粘贴 | 带换行和短横线粘贴 | 正常激活 |
| 换机激活 | 在另一台机器用同一码 | 提示绑定其他设备 |
从那以后我每次接这类活,都会先把「首次安装时间存哪、时间回拨怎么判、机器码取哪些字段」这三个问题写在设计文档第一页,写完再动手写代码——这三处只要有一处想岔了,后面全是返工。希望帮到你。
本文还有配套的精品资源,点击获取