简介:面向软件开发者和产品经理的试用期授权控制示例包,围绕试用天数、使用次数与永久授权等常见策略,结合 Qt 应用与配置文件演示权限限制思路。压缩包共 38 个文件,约 18.8MB,包含 25 个 dll、9 个 qm、3 个 exe 和 1 个 xml:dll 与 exe 构成可运行的 Qt 程序及依赖,qm 提供多语言界面资源,xml 用于承载试用或授权配置。已有 1506 人学习下载,适合需要理解桌面软件试用机制、授权校验与多语言部署的读者参考。包内可直接查看程序结构、依赖组织与配置方式,帮助梳理试用期状态记录、次数限制、Base64 等编码保护及 Qt 授权模型的应用脉络,也可作为课程设计或内部原型的对照材料。
1. 试用期设置不是加个 if:一份 Qt 授权模块能省下的返工
上周有个做桌面工具的朋友来问我,客户提的要求是「先试用 30 天,每天最多导出 5 次,到期后只能看不能改」。他一开始只在导出按钮的槽函数里塞了一句if (QDate::currentDate() > m_expire) return;,结果用户把系统时间往前拨了一个月,试用期永远走不完,改完还回头找他退钱。这类需求在 Qt 桌面项目里非常普遍:试用期倒计时、功能权限分级、使用次数上限、离线授权校验,这四件事必须成套做才站得住,少一样就是一个洞。
这份资源就是一个把上述四件事拆干净的 Qt 示例工程,包含状态存储、次数计数、机器码生成、授权文件验签和一套可以直接嵌进业务的判定接口。适合正在给桌面软件加商业化限制的独立开发者,也适合接手了别人留下的「只有一行 if」的授权逻辑、准备重写的人。
2. 工程骨架与技术选型:五个目录把试用期、权限、次数拆开
2.1 目录分层:core / storage / license / ui / tests
拿到压缩包解压之后,先别急着点开 .pro 或者 CMakeLists,先看目录。这套工程的分层思路很直接:谁负责判定、谁负责落盘、谁负责展示,各自一个目录,互相之间只走头文件。
TrialLicenseDemo/ ├── CMakeLists.txt ├── app/ │ ├── main.cpp # 程序入口,负责初始化与异常兜底 │ └── mainwindow.h/.cpp/.ui # 主界面,只做 UI 拦截 ├── core/ │ ├── licensestate.h # LicenseState / Feature / GateResult 枚举 │ ├── trialmanager.h/.cpp # 试用期与次数的门面类(单例) │ └── featuregate.h/.cpp # 规则表与权限判定 ├── storage/ │ ├── statestore.h/.cpp # 状态读写 + 加解密 + 完整性校验 │ └── integrity.h/.cpp # HMAC-SHA256 与定长比较 ├── license/ │ ├── machinecode.h/.cpp # 机器码采集与容错比对 │ └── licensefile.h/.cpp # 授权文件解析与验签 ├── ui/ │ ├── trialbanner.h/.cpp # 顶部「剩余 X 天」提示条 │ └── activationdialog.h/.cpp # 激活码/授权文件导入对话框 └── tests/ └── tst_trial.cpp # QtTest 用例:回拨、超限、过期core里没有任何 UI 头文件,这是刻意的:判定逻辑要能被单元测试直接调,也要能在以后换成命令行工具或者托盘程序时原样搬走。storage只认QVariantMap,不认业务字段,换存储介质时改这一个目录就够。license单独拆出来,是因为机器码和验签属于「一旦发出去就不好改」的部分,改动成本最高,隔离起来最安全。
技术栈是 C++17 + Qt 5.15 或 Qt 6.x,构建用 CMake,依赖只有 QtCore、QtWidgets、QtNetwork 三个模块。QtNetwork 不是为了联网,而是为了QNetworkInterface取网卡地址。整套东西没有第三方库,离线可用,这也是这类需求最常见的落地形态——客户的甲方环境往往连不了外网。
2.2 存储选型:QSettings 和 SQLite 各自的坑
试用状态存在哪,是这套工程里第一个要拍板的事。QSettings的优点是零依赖、跨平台封装好了路径:Windows 写注册表HKCU\Software\<组织名>\<应用名>,Linux 写~/.config/<组织名>/<应用名>.conf,macOS 写 plist 文件。缺点是这些位置用户自己就能打开改,卸载重装还经常残留。
SQLite 的优点是能存结构化的明细,比如「按功能、按天」的次数记录,事务能保证并发下的原子性;缺点是文件一样能被删,而且多引一个 QtSql 模块。我的判断标准是:状态量只有几个整数加一个时间戳,用 QSettings 加一层加密和 HMAC 就够;需要按天、按功能留明细账单,直接上 SQLite。这份工程默认走 QSettings,但StateStore是个纯虚接口的形态,换成 SQLite 只要再写一个实现类。
不管走哪条路,落盘前必须做两件事:加密(挡住随手改注册表的人)和完整性校验(挡住用十六进制编辑器改数值的人)。
// storage/statestore.cpp QByteArray StateStore::pack(const QVariantMap &data) const { const QByteArray plain = QJsonDocument(QObject::fromVariantMap(data)) .toJson(QJsonDocument::Compact); // 1) 加密:用固定密钥做密钥流异或。挡普通用户够用,别当成 AES QByteArray cipher = plain; for (int i = 0; i < cipher.size(); ++i) cipher[i] = cipher[i] ^ m_key.at(i % m_key.size()); // 2) 完整性:对明文做 HMAC,取前 16 字节,拼在密文前面 QMessageAuthenticationCode mac(QCryptographicHash::Sha256, m_key); mac.addData(plain); const QByteArray tag = mac.result().left(16); return (tag + cipher).toBase64(); // 整体 base64,方便塞进 QSettings }pack的入参是一个QVariantMap,可以直接来自QJsonObject::fromVariantMap,字段随便加,不用改存储层。m_key是编译期字符串再掺一个固定的盐算出来的,长度 32 字节;x86 反编译能看到,所以它只能算「提高门槛」,真正防破解靠的是服务端下发和代码混淆,这一点在最后一章会再提。
对应的unpack反过来走一遍,先取前 16 字节算 HMAC 比对,比对失败直接返回空 map,由上层判定为Tampered。这里有个细节:比较时必须用定长比较,不能直接用==,否则有被时序攻击的空间。本地校验场景下影响有限,但习惯要正。
// storage/statestore.cpp QVariantMap StateStore::load() const { QSettings s; s.beginGroup(m_group); const QByteArray blob = s.value(QStringLiteral("blob")).toByteArray(); s.endGroup(); if (blob.isEmpty()) return {}; // 首次运行,交给上层初始化 const QByteArray raw = QByteArray::fromBase64(blob); if (raw.size() <= 16) return { { "__tampered", true } }; const QByteArray tag = raw.left(16); const QByteArray cipher = raw.mid(16); QByteArray plain = cipher; for (int i = 0; i < plain.size(); ++i) plain[i] = plain[i] ^ m_key.at(i % m_key.size()); QMessageAuthenticationCode mac(QCryptographicHash::Sha256, m_key); mac.addData(plain); if (!constantTimeEquals(mac.result().left(16), tag)) return { { "__tampered", true } }; // 被改过或换了密钥 return QJsonDocument::fromJson(plain).object().toVariantMap(); }返回{ "__tampered": true }而不是直接抛异常,是为了让上层统一处理:主窗口构造的时候先看这个标记,命中就弹一次提示、进只读模式,同时把原始 blob 备份一份到日志目录,方便排查是用户改的还是自己的密钥变更导致的。m_group是QSettings的分组名,一般写成应用名 + 版本号,版本号进组名以后,将来格式升级可以直接换组,旧数据留着做降级兼容。
2.3 状态机:五个状态决定 UI 与业务的走向
拆完存储,接下来要把「当前是什么状态」这件事定死。这套工程用了五个状态:NotActivated(首次运行、还在试用期内)、Trial(试用中且有剩余天数)、TrialExpired(试用到期未授权)、Licensed(已授权)、Tampered(数据异常或时间回拨)。
很多人会把NotActivated和Trial合成一个,实际用起来会难受:首次运行需要弹一次「您有 30 天试用期」的引导,之后每次启动不该再弹。分开之后,引导逻辑只认NotActivated,其余分支也就干净了。
Tampered单独列出来是因为它的处理策略和TrialExpired不一样。到期是正常生命周期,给用户一个激活入口就行;Tampered是要留证据的,至少写一条本地日志,记录发生时间、涉及的字段和当时的系统时间,将来客服对线的时候有据可查。这里不需要搞成黑匣子级别的取证,一条带时间戳的文本日志足够。
状态流转只有一条主线:NotActivated→Trial→(到期)TrialExpired→(激活成功)Licensed。任何一步检测异常都直接跳到Tampered,Tampered不允许自己回到正常状态,必须重新导入一份合法授权文件。这条规则写死在TrialManager::refresh()里,全工程只有这一个入口能改状态,避免各处随手赋值。
3. 试用期与使用次数:从首次运行锚点到跨天重置
3.1 首次运行初始化:firstRun 只写一次
试用期最容易翻车的地方是「起点怎么定」。用安装时间?用户拷个绿色版就没有安装记录。用文件创建时间?复制粘贴会保留原时间,也可能被改。这份工程的做法是:第一次成功启动时把当前时间写进firstRun,之后永不复写,除非整个状态被清空。
// core/trialmanager.cpp void TrialManager::ensureInitialized() { if (m_loaded) return; QVariantMap d = m_store.load(); if (d.contains(QStringLiteral("__tampered"))) { enterTamperedState(); m_loaded = true; return; } if (d.isEmpty()) { // 真正的首次运行 const qint64 now = QDateTime::currentSecsSinceEpoch(); d["schema"] = 1; // 存储格式版本,升级时用 d["installId"] = QUuid::createUuid().toString(QUuid::WithoutBraces); d["firstRun"] = now; // 试用期锚点,只写这一次 d["lastRun"] = now; // 每次启动更新,用于回拨检测 d["runSeconds"] = 0; // 累计运行秒数,回拨兜底用 d["trialDays"] = 30; // 默认试用天数,可被授权文件覆盖 d["activated"] = false; d["usage"] = QVariantMap{}; // feature -> {day, dayCount, total} m_store.save(d); m_state = LicenseState::NotActivated; } else { d["lastRun"] = QDateTime::currentSecsSinceEpoch(); m_store.save(d); m_state = LicenseState::Trial; } m_data = d; m_loaded = true; }firstRun用的是QDateTime::currentSecsSinceEpoch(),秒级时间戳,跨平台一致,序列化也简单。installId是每次安装唯一的 UUID,在对接客服查问题时比机器码好用——机器码会随硬件变动,installId不会。
trialDays存在状态里而不是写死在代码里,是为了让授权文件能覆盖它:客户买的是 15 天试用版,发一份带trialDays: 15的授权文件,程序读到就以授权文件为准。这种「默认值在代码里、覆盖值在授权里」的模式,比编译多个版本省事得多。
剩下的remainingDays()就是一道减法:firstRun + trialDays * 86400 - now,结果小于等于 0 进TrialExpired。注意别用QDate::daysTo()直接减日期,那样会把「今天 23:59 到期」算成「今天 00:00 到期」,用户少用一天。
3.2 次数累加:先判后写,跨天清零
使用次数这块,需求通常是「每天最多 N 次」。所以存的不是单一计数器,而是「哪一天 + 当天用了几次 + 历史总计」三个字段。跨天判断只比对日期字符串,比时间戳简单,也不会有时区问题(本地日期就是用户看到的日期)。
// core/trialmanager.cpp bool TrialManager::consumeUsage(Feature f, int limitPerDay) { if (m_state == LicenseState::Licensed) return true; // 已授权不限次 QVariantMap d = m_store.load(); if (d.isEmpty()) return false; const QString key = featureKey(f); // 如 "batch_export" const QString today = QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd")); QVariantMap usage = d.value("usage").toMap(); QVariantMap u = usage.value(key).toMap(); if (u.value("day").toString() != today) { // 跨天重置 u["day"] = today; u["dayCount"] = 0; } const int used = u.value("dayCount", 0).toInt(); if (limitPerDay > 0 && used >= limitPerDay) return false; // 超限:不写盘直接拒绝 u["dayCount"] = used + 1; u["total"] = u.value("total", 0).toInt() + 1; usage[key] = u; d["usage"] = usage; return m_store.save(d); // save 内部调用 QSettings::sync() 保证落盘 }关键点是「先判后写」:超限时直接返回 false,不动存储,这样不会有「点了没反应但次数被扣了」的情况。save里必须显式sync(),因为QSettings默认是延迟写,程序被任务管理器强杀时最后几次计数可能丢。这个丢次数的问题在测试阶段几乎发现不了,上线后被用户投诉「我明明只用了 4 次」才追得到,属于典型的血泪经验。
还有一处并发要注意:用户完全可能双击图标开两个实例,两个进程各读一份usage,各加 1,各写回,最后只加了 1 次。解决办法是在consumeUsage外面套一层QLockFile,锁文件放QStandardPaths::TempLocation,拿不到锁就提示「程序已在运行」。这套工程用的是 3 秒超时的tryLock,超时后走只读模式,宁可少算也不多算。
3.3 功能权限分级:一张规则表压住所有 if
次数管的是「做多少次」,权限管的是「能不能做」。两者经常被混在一起写成一堆 if,最后没人敢改。这套工程用的是规则表 + 判定函数,所有限制集中在featuregate.cpp一个文件里。
// core/featuregate.cpp struct FeatureRule { Feature feature; const char *key; // 与存储里的 usage key 一致 int trialLimitPerDay; // 0 表示试用期不限次 bool trialAllowed; // false 表示试用期完全禁用 }; static const FeatureRule kRules[] = { { Feature::BasicEdit, "basic_edit", 0, true }, { Feature::BatchExport, "batch_export", 5, true }, { Feature::ScriptRunner, "script_runner", 0, false }, // 试用期不给用 }; GateResult FeatureGate::check(Feature f) { const LicenseState st = TrialManager::instance()->state(); if (st == LicenseState::Licensed) return GateResult::Allow; if (st == LicenseState::Tampered) return GateResult::DenyTampered; if (st == LicenseState::TrialExpired) return GateResult::DenyExpired; const FeatureRule *rule = findRule(f); if (!rule || !rule->trialAllowed) return GateResult::DenyNeedLicense; if (rule->trialLimitPerDay <= 0) return GateResult::Allow; const int used = TrialManager::instance()->todayUsage(f); if (used >= rule->trialLimitPerDay) return GateResult::DenyTrialLimit; return GateResult::Allow; }GateResult是个枚举,不是 bool,因为不同拒绝原因要弹不同文案:「试用期次数用完」引导去激活,「功能需要授权」引导去看版本对比,「数据异常」直接给客服联系方式。用 bool 的话,UI 层只能统一弹一句「操作失败」,转化率会很难看。
调用点在业务入口,不在按钮的槽函数第一行。比如导出功能,判定要放在ExportService::run()的开头,因为菜单、快捷键、右键菜单三个入口都会走到这里;只在按钮上拦,用户按 Ctrl+E 就绕过去了。
4. 授权校验链路:机器码、授权文件与时间回拨检测
4.1 机器码生成:多因子采集与容错比对
机器码的作用是把授权文件绑到一台机器上。理想情况是取一个永不变的硬件 ID,现实是没有:QSysInfo::machineUniqueId()在部分 Linux 上依赖/etc/machine-id,容器里每次重建都变;网卡 MAC 在虚拟机上可以随便改;磁盘序列号要平台相关 API,还要管理员权限。
所以这份工程用的是多因子采集 + 容错比对:采集 3 个因子,授权文件里除了存最终机器码,还存两个因子的哈希,校验时允许其中一个对不上。
// license/machinecode.cpp QStringList MachineCode::collectFactors() { QStringList f; // 因子1:Qt 提供的机器唯一 ID(Qt 5.11+,可能为空) const QString uid = QSysInfo::machineUniqueId(); f << (uid.isEmpty() ? QStringLiteral("nouid") : uid); // 因子2:第一块物理网卡的 MAC,排序后取,避免枚举顺序抖动 QStringList macs; for (const QNetworkInterface &ni : QNetworkInterface::allInterfaces()) { const auto flags = ni.flags(); if (flags.testFlag(QNetworkInterface::IsLoopBack)) continue; if (!flags.testFlag(QNetworkInterface::IsUp)) continue; const QString mac = ni.hardwareAddress(); if (!mac.isEmpty() && mac != QStringLiteral("00:00:00:00:00:00")) macs << mac; } macs.sort(); f << (macs.isEmpty() ? QStringLiteral("nomac") : macs.first()); // 因子3:系统与内核标识,作为弱因子兜底 f << QSysInfo::productType() + QLatin1Char('/') + QSysInfo::kernelVersion(); return f; } QString MachineCode::compute() { QMessageAuthenticationCode mac(QCryptographicHash::Sha256, QByteArrayLiteral("mc-salt-v1")); for (const QString &s : collectFactors()) mac.addData(s.toUtf8()); return QString::fromLatin1(mac.result().toHex().left(16)).toUpper(); }collectFactors返回的三项在授权文件里各存一个短哈希(取 SHA-256 前 8 位),verify时只要有两项匹配就放行。这个容错率的取舍是:允许换网卡、换系统补丁,不允许整机复制。真要更严,可以把容错降到「三项全中」,代价是用户升级系统后要重新激活,客服压力会明显上升。
取 MAC 时排序再取第一个,是因为QNetworkInterface::allInterfaces()的返回顺序在不同机器上不稳定,不排序会导致同一台机器重启后机器码变化——这个 bug 我见过不止一次,现象是「用户昨天激活成功,今天开机提示未授权」,排查起来非常玄学。
4.2 授权文件格式与 HMAC 验签
授权文件是一个扩展名自定义的 JSON 文本,用户可以用记事本打开看内容,但改一个字节就验不过。里面分两段:payload是 base64 的业务数据,sig是对payload原文的 HMAC-SHA256。
| 字段 | 类型 | 说明 |
|---|---|---|
v | int | 授权格式版本,当前为 1 |
machine | string | 完整机器码(16 位十六进制) |
factors | array | 三个因子的短哈希,用于容错比对 |
issued | string | 签发日期yyyy-MM-dd |
expire | string | 到期日期,空串表示永久 |
features | array | 授权的功能 key 列表 |
trialDays | int | 覆盖默认试用天数,0 表示不覆盖 |
serial | string | 授权流水号,用于客服对账 |
// license/licensefile.cpp LicenseResult LicenseFile::verify(const QByteArray &raw, const QByteArray &secret) { const QJsonObject root = QJsonDocument::fromJson(raw).object(); const QByteArray payloadB64 = root.value("payload").toString().toLatin1(); const QByteArray sigHex = root.value("sig").toString().toLatin1(); if (payloadB64.isEmpty() || sigHex.isEmpty()) return LicenseResult::Malformed; // 1) 先验签,再解析业务字段,顺序不能反 QMessageAuthenticationCode mac(QCryptographicHash::Sha256, secret); mac.addData(payloadB64); if (!constantTimeEquals(mac.result().toHex(), sigHex)) return LicenseResult::BadSignature; const QJsonObject p = QJsonDocument::fromJson( QByteArray::fromBase64(payloadB64)).object(); // 2) 绑定校验:机器码全中,或因子至少中两项 if (!MachineCode::match(p.value("machine").toString(), p.value("factors").toArray())) return LicenseResult::MachineMismatch; // 3) 有效期校验:本地时间只是参考,时钟异常时降级为宽限模式 const QDate expire = QDate::fromString(p.value("expire").toString(), QStringLiteral("yyyy-MM-dd")); if (expire.isValid() && QDate::currentDate() > expire) return LicenseResult::Expired; return LicenseResult::Ok; }顺序很重要:先验签再解析业务字段。如果先解析再验签,攻击者可以构造一个超大 JSON 或者带恶意字段的 payload,在验签之前就让解析器跑起来。这类顺序问题在本地校验里危害有限,但写成习惯以后,将来挪到服务端就是真漏洞。
secret不要硬编码在源码里做字面量,至少拆成几段、在初始化时拼起来,或者从资源文件里读。x86 上一个断点就能看到,但能挡住绝大多数「改个字段试试」的人。真正要防破解,还是得走在线激活 + 服务端签发。
4.3 时间回拨:墙钟靠不住,用累计运行时长兜底
防改系统时间,是所有本地授权方案里最难做干净的一环。核心矛盾是:本地能拿到的所有时间源,用户都有办法影响。QDateTime::currentSecsSinceEpoch()直接读系统时钟;QElapsedTimer是单调的,但只在进程内有效,重启就归零。
这套工程的做法是双轨记录:每次启动记lastRun,每次正常退出把本次运行秒数累加进runSeconds。判定回拨时同时看两条线。
// core/trialmanager.cpp bool TrialManager::detectClockRollback(const QVariantMap &d) { const qint64 now = QDateTime::currentSecsSinceEpoch(); const qint64 lastRun = d.value("lastRun", 0).toLongLong(); const qint64 runTotal = d.value("runSeconds", 0).toLongLong(); // 条件1:墙钟比上次运行倒退超过 10 分钟(容忍时区/校时抖动) if (now + 600 >= lastRun) return false; // 条件2:累计运行时长比上次记录还少,说明状态被回滚过 const qint64 rollback = lastRun - now; if (runTotal < d.value("prevRunSeconds", 0).toLongLong()) return true; // 条件3:倒退量远超累计运行时长,基本可以确定是手动改表 if (rollback > runTotal + 3600) return true; return false; }三条条件里,条件 1 是入口,条件是「时间确实倒退了才继续查」;条件 2 抓的是「状态文件被换成旧备份」这种情况;条件 3 抓的是「把时间调到 2000 年」这种粗暴操作。三条都命不中的,走宽限模式:允许继续用,但把异常次数记进anomalyCount,超过 3 次转Tampered。
这套逻辑防不住决心破解的人,这是实话。它的目标用户是「听说改时间能白嫖,试一下」的那批人。真要更硬,就得引入服务端时间戳,或者用一个只增不减的计数器配合定期联网校准,成本会高一个量级。
5. 编译运行与避坑:从 qmake 到 Release 包的翻车记录
5.1 构建、首次运行与手动验证清单
工程同时给了 CMakeLists 和 .pro,CMake 是主路径。Qt 6 的写法:
cmake_minimum_required(VERSION 3.16) project(TrialLicenseDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network) add_executable(TrialLicenseDemo app/main.cpp app/mainwindow.cpp core/trialmanager.cpp core/featuregate.cpp storage/statestore.cpp storage/integrity.cpp license/machinecode.cpp license/licensefile.cpp ui/trialbanner.cpp ui/activationdialog.cpp ) target_link_libraries(TrialLicenseDemo PRIVATE Qt6::Core Qt6::Widgets Qt6::Network)用 Qt 5.15 的话,把find_package(Qt6 ...)换成find_package(Qt5 5.15 REQUIRED COMPONENTS ...),链接目标相应地换成Qt5::Core Qt5::Widgets Qt5::Network。用QT_VERSION_MAJOR变量做个条件分支,一份 CMakeLists 就能同时兼容两代,不用维护两份。
构建和运行:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j 8 # Windows 下把 Qt 的 bin 目录加进 PATH,或者用 windeployqt 拷依赖 ./build/TrialLicenseDemo第一次跑起来应该看到顶部有一条「试用期剩余 30 天,导出今日剩余 5 次」的提示条,主界面上的「脚本运行」按钮是灰的。手动验证按这个顺序走一遍,比看文档快:
- 断网启动,确认不卡、不弹网络错误。
- 点 5 次导出,第 6 次应该弹「今日次数已用完」。
- 关掉程序,把系统日期往后拨 40 天,重启,应该进只读模式。
- 再往前拨回去,重启,应该转
Tampered而不是恢复正常。 - 导入一份合法授权文件,所有功能解锁,提示条消失。
- 用记事本改授权文件里的
expire一个字符,导入应该报签名错误。
这六步跑完,基本功能就验证完了。工程里的tests/tst_trial.cpp把第 3、4 步做成了自动化用例,但它只能测逻辑,测不了真实的系统时间改动,所以第 3 步还是得手动来。
5.2 五个高频问题:现象、原因、解决
问题一:开发机上怎么点都不触发试用限制,同事的机器上一装就提示到期。
现象是同一个 Release 包,在自己电脑上试用期永远是满的,换台机器立刻到期。原因是QSettings用组织名 + 应用名定位存储位置,开发过程中反复调试,这台机器的状态里firstRun早就写进去了。解决方法是给调试留个后门:main.cpp里解析命令行参数,带--reset-trial就调StateStore::reset()清空后重启;同时把组织名后面拼一个构建类型后缀,Debug 和 Release 各用一套状态,互不干扰。
问题二:装到 Program Files 下,激活失败,日志里是「无法写入」。
现象是导入授权文件后提示成功,但重启又回到未授权。原因是代码里把授权文件往QCoreApplication::applicationDirPath()写,那个目录对普通用户是只读的。解决方法是统一用QStandardPaths::writableLocation(QStandardPaths::AppDataLocation),拿到路径后先QDir().mkpath()建目录再写。这个坑在开发阶段完全暴露不出来,因为开发机上一般用的是用户目录。
问题三:Qt 5.15 编译通过,换 Qt 6 报toTime_t找不到。
原因是 Qt 6 移除了QDateTime::toTime_t()和fromTime_t(),统一成toSecsSinceEpoch()/fromSecsSinceEpoch()。解决方法是全部改用新 API(Qt 5.8 起就有),CMake 里加个版本判断只影响find_package那几行,业务代码不用分叉。顺带一提,QDateTime::currentDateTime().toSecsSinceEpoch()和QDateTime::currentSecsSinceEpoch()结果一样,后者少一次对象构造,高频调用的地方用后者。
问题四:用户把注册表项删了,试用期重新开始。
现象是老用户卸载重装后又能白嫖 30 天。原因是状态只存在一个位置。解决方法是做双写:QSettings写一份,同时在AppDataLocation下再写一份加密文件,启动时两处都读,firstRun取两者中更早的那个。删除注册表的用户通常不知道还有文件,反过来也一样。
问题五:机器码在同一台机器上偶发变化。
现象是用户昨天激活成功,今天开机提示未授权,客服查下来机器码最后一位变了。原因有两个:一是取 MAC 时没排序,枚举顺序抖动;二是用了QSysInfo::machineUniqueId(),某些 Linux 发行版上这个值依赖/etc/machine-id,系统更新时会被重新生成。解决办法就是 4.1 里那套:因子排序、多因子采集、容错比对。这个坑属于排查成本极高、修复成本极低的那类,早点做进去。
6. 二次开发:把这套试用逻辑接进自己的 Qt 工程
接到自己的项目里,最小改动清单是五步:把core/、storage/、license/三个目录整体拷过来;main.cpp里在QApplication构造之后立刻调TrialManager::instance()->init();主窗口构造完成后调一次自己的updateUiByLicense()按状态刷新按钮和菜单;所有受限动作在业务入口(不是按钮槽函数)调FeatureGate::check();启动时调一次LicenseFile::verify()处理已有的授权文件。
改造点我整理成一张表,按风险从低到高排:
| 改造点 | 改动位置 | 风险 |
|---|---|---|
| 调整试用天数 | ensureInitialized()里的trialDays默认值 | 低,老用户已写入的不受影响 |
| 新增受限功能 | kRules加一行 +Feature枚举加一项 | 低,注意 key 不能和已有的重名 |
| 换存储介质 | 新写一个StateStore子类 | 中,load()的返回约定不能变 |
| 换加密算法 | pack/unpack | 高,密钥一变老用户状态全变Tampered,要先做迁移 |
| 加在线激活 | LicenseFile旁边加一个ActivationClient | 高,要考虑离线宽限期,不然断网用户直接卡死 |
验证方法上,我习惯准备三个脚本化场景,每次改完授权相关代码都跑一遍:一是把系统时间往后拨一年再拨回来,看状态机是否按预期进Tampered且不可自愈;二是把状态 blob 复制一份、改中间四个字节再写回,看是否被 HMAC 拦住;三是把授权文件的features数组删掉一项,看是否报BadSignature。第三个特别容易被忽略,因为很多人验签只验了 payload 的 base64 串,忘了 payload 本身是 JSON,字段被删了签名照样对——不对,签名对不上,因为 payload 变了。真正容易漏的是「验签通过但没做机器码绑定」,那样一份授权文件能传遍全网。
从那以后我每次给项目加授权模块,都强制先把「改时间、删状态、改授权文件、换机器」这四个动作在发布前手动走一遍,哪怕工期再紧也不跳。这套流程挡下过两次差点发出去的版本。希望这堆东西能帮你少走点弯路,工程里能直接用的部分尽管拿走,改的时候记得先把secret和m_key换成自己的。
本文还有配套的精品资源,点击获取