ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt单机软件试用期设置:防改时、原子计数与授权验签

Qt单机软件试用期设置:防改时、原子计数与授权验签

简介:这份资源围绕软件试用期限制与授权管理展开,面向从事桌面软件开发的程序员、产品经理及需要设计收费策略的Qt开发者,帮助解决试用天数与使用次数控制、授权信息防篡改等实际问题。压缩包共38个文件、约18.8MB,以25个dll动态库、9个qm多语言翻译文件、3个exe可执行程序和1个xml配置文件为主,dll与exe构成可运行的Qt演示程序,qm提供多语言界面支持,usage.xml则用于记录和读取试用状态,构成一套完整的可编译参考实例。已有1506人浏览学习。资源将试用期机制落到具体代码层面,读者可据此了解如何借助XML配置文件而非注册表保存剩余天数与使用次数,并通过Base64等编码手段对配置内容做基础保护,同时结合Qt框架的跨平台特性扩展授权模型,便于在自己的项目中快速搭建可验证的试用授权雏形。

1. 试用期设置不是加个 if:先看清这个 rar 里到底有什么

包发出去第三天,用户甩来一张截图:把系统时间往前调两天,试用期重新开始计时了。这类返工在桌面小工具里太常见,试用期设置这件事,难点从来不是写个 if 判断到期,而是把「用户能想到的所有绕过手法」提前堵上。这个叫「试用期设置.rar」的资源包,做的就是把这堆容易出问题的事收进一个 Qt 模块:首启时间写入磁盘、使用次数原子计数、功能权限按位下发、机器码绑定、授权文件验签,试用到期或次数用尽后统一走一套状态机,全程不依赖在线服务。适合做单机工具、内网部署客户端、要发试用版给客户试的付费软件。如果你正被「用户改时间绕过限制」或者「试用版和正式版两套代码分支」折腾,它的价值在于边界情况已经替你踩过一遍。

2. 时间基准:试用期天数怎么算才不被系统时间牵着走

试用期设置最容易踩的坑,是把「到期」定义得太随意。同一个 30 天,在不同实现里可能是 30 个自然日、30×24 小时、30 个有效使用日,三种口径对用户的心理预期完全不同,也决定了你能不能用单调时间兜底。先把口径定死,再谈怎么存。

2.1 三种口径的取舍:自然日、运行时长、有效使用日

自然日算法最简单:首启当天记一个日期,之后每次QDate::currentDate()减一下,超过 30 就到期。副作用是用户装完放着不用也会过期,对「先装后试」的用户不友好,但商业上最常见的也是它。

运行时长算法累计程序真正跑过的秒数,装完放一个月也依然有 30 天额度,用户体验好,代价是你必须有一个不受墙钟影响的时间源,否则用户挂机就能刷时长。

有效使用日介于两者之间:一天内启动过就算消耗一天,同一台机器反复启动不重复扣。实现上要维护一个「最后一次计费日期」,跨天时自增,多实例并发时又要回到原子写的问题。

我一般的做法是双轨:以自然日为主口径判定到期,同时累计运行时长做交叉校验。两个值出现明显矛盾(比如自然日只过了 2 天,累计运行时长却记了 20 天),基本可以判定墙钟被动过,这时候走人工复核分支而不是直接封死,避免误伤。

口径落盘字段抗改时间能力用户接受度适用场景
自然日first_run 时间戳弱,需交叉校验中商业试用、限时活动
累计运行时长acc_uptime 秒强高工具类、按用量计费
有效使用日last_bill_date中高SaaS 客户端本地缓存

2.2 三个时间源交叉:首启时间、最后可见时间、累计运行时长

只存一个「首启时间」是不够的,因为它是只写一次的常量,用户只要找到它改掉就重置了。真正有用的组合是三个字段:

  • first_run:首次运行写入,之后只读,用于算自然日。
  • last_seen:每次退出或心跳时更新,用于判断「本次墙钟比上次记录还早」。
  • acc_uptime:累计运行秒数,用单调时钟增量累加,用于兜底。

判定逻辑分三步。第一步看now < last_seen - 容差,墙钟倒退,直接标记异常。第二步看now < first_run - 容差,连起点都被改到未来,同样异常。第三步用now - last_seen的差值累加到acc_uptime,但单次增量要设上限(我给 86400 秒),防止用户把时间往前调一个月然后跑一次,一次性刷进 30 天。

容差给多少合适?我习惯给 120 秒。笔记本电池耗尽、虚拟机快照恢复、NTP 校时、跨时区出差,都会造成几秒到几十秒的墙钟回退,容差太小会误杀正常用户,太大又给绕过留下窗口。

提示:跨时区场景不要用本地时间做判断,全部换成 UTC 秒。用户从东八区飞到西五区,本地时间倒退 13 小时,用本地时间算就是一次「时间回拨」。

2.3 代码实现:写入磁盘时机和一次完整的判定

QSettings是最省事的存储,但它的sync()必须显式调用,很多人写完setValue就以为数据已经保存到磁盘,程序一崩数据全丢,用户重启后试用期重置。下面这段是工程里的核心判定,可以直接照着改。

// trialclock.cpp #include "trialclock.h" #include <QSettings> #include <QDateTime> #include <QStandardPaths> // 容差:小于这个值的墙钟回退视为校时/时区导致,不算篡改 static constexpr qint64 kSkewTolerance = 120; // 单次最多承认一天的墙钟增量,防止一次性把时间往前调一个月 static constexpr qint64 kMaxStep = 86400; TrialClock::State TrialClock::check() { // IniFormat + UserScope:落到用户配置目录,Windows 下是注册表以外的独立文件 QSettings s(QSettings::IniFormat, QSettings::UserScope, m_key, "trial"); const qint64 now = QDateTime::currentSecsSinceEpoch(); // 全程 UTC 秒 const qint64 first = s.value("first_run", 0).toLongLong(); const qint64 last = s.value("last_seen", 0).toLongLong(); const qint64 uptime = s.value("acc_uptime", 0).toLongLong(); if (first == 0) { // 首次运行:三个字段一次性落地 s.setValue("first_run", now); s.setValue("last_seen", now); s.setValue("acc_uptime", 0); s.sync(); // 关键:不 sync 就是没写 return State::FirstRun; } // 1) 墙钟倒退检测:比上次记录早,或比首启时间还早 if (now + kSkewTolerance < last || now + kSkewTolerance < first) { s.setValue("tamper_flag", true); s.sync(); return State::RollbackDetected; } // 2) 累加增量,单次封顶,负数钳到 0 const qint64 delta = qBound<qint64>(0, now - last, kMaxStep); m_elapsed = uptime + delta; s.setValue("last_seen", now); s.setValue("acc_uptime", m_elapsed); s.sync(); // 3) 双轨判定:自然日超期 或 累计运行时长超期,任一命中即到期 const qint64 daysByWall = (now - first) / 86400; if (daysByWall >= m_trialDays || m_elapsed >= m_trialDays * 86400LL) return State::Expired; return State::Normal; }

代码里有三个参数值得单独说。kSkewTolerance决定误杀率,我给 120 秒;如果你的用户里有大量虚拟机用户,可以放宽到 300 秒。kMaxStep决定单次刷时长的上限,一天是折中值,设太大等于把漏洞留着,设太小则长时间关机再开机的用户会少算时长。m_key是产品标识,QSettings用它拼路径,换产品必须换 key,否则两个产品会互相覆盖试用数据。

还有一个细节:State::RollbackDetected不要直接弹「检测到作弊」然后退出。真实环境里误判不少,正确做法是降级——限制高级功能,提示用户联网校验或者联系支持,同时把first_run、last_seen、now三个值写进日志,方便后面排查。

3. 次数与权限:把「能做几次」和「能做什么」拆成两个模型

试用期和试用次数经常被混成一个东西,结果代码里到处是if (daysLeft > 0 && used < 50),越写越乱。这两件事的判定周期完全不同:到期是按时间判,次数是按动作扣,权限是按功能点查。拆成「计数 + 权限位」两个独立模型,后面加功能才不至于改一处崩三处。

3.1 次数计数为什么必须原子写

最直觉的写法是「读文件 → int + 1 → 写回文件」,本地单实例跑起来没问题,一遇到多开就翻车。两个实例同时启动,都读到 19,都写回 20,实际用了两次却只扣了一次。更隐蔽的问题是掉电:写到一半断电,文件变成空或者半截数字,toInt()返回 0,用户的次数直接清零,这种问题在工控机上一个月能遇到好几次。

解决分两层。写层面用QSaveFile,它内部是写临时文件再rename,rename在同一文件系统上是原子的,要么是新内容要么是旧内容,不会出现半截文件。读改写层面用QLockFile做进程间互斥,锁的粒度覆盖整个「读-判-写」区间。

// counter.cpp #include <QFile> #include <QSaveFile> #include <QLockFile> #include <QDir> // limit <= 0 表示不限次数(正式授权) bool bumpCounter(const QString &dir, const QString &name, int limit, int *used) { QDir().mkpath(dir); QLockFile lock(QDir(dir).filePath(name + ".lock")); lock.setStaleLockTime(5000); // 5 秒没释放视为僵尸锁 if (!lock.tryLock(3000)) // 等 3 秒,拿不到就当并发冲突 return false; const QString path = QDir(dir).filePath(name + ".cnt"); // 1) 读当前值,文件不存在按 0 处理 int n = 0; QFile in(path); if (in.open(QIODevice::ReadOnly)) { n = in.readAll().trimmed().toInt(); // 内容损坏时 toInt 返回 0 in.close(); } // 2) 先判上限,再自增,顺序不能反 if (limit > 0 && n >= limit) { if (used) *used = n; return false; } // 3) QSaveFile:写临时文件 + rename,掉电不会写出半截文件 QSaveFile out(path); out.setDirectWriteFallback(true); // 网络盘不支持 rename 时降级 if (!out.open(QIODevice::WriteOnly)) return false; out.write(QByteArray::number(n + 1)); if (!out.commit()) return false; if (used) *used = n + 1; return true; }

setStaleLockTime(5000)是血泪经验:程序被任务管理器强杀时锁文件留在磁盘上,不设过期时间的话用户从此再也打不开。tryLock(3000)的超时也要给,否则第二个实例会卡死在这里。setDirectWriteFallback(true)是为网络盘和某些国产文件系统准备的,它们不支持原子 rename,宁可牺牲一点原子性也要能写进去。

计数文件放哪也有讲究。放程序安装目录,普通用户没写权限,QStandardPaths::AppDataLocation是更稳妥的默认位置;要防止用户删配置目录重置次数,就得再往注册表或者某个业务数据文件里写一份副本,启动时比对两个值,取大的那个。这是典型的「防君子不防小人」,但对绝大多数用户够用。

3.2 权限位设计:一份 Policy 覆盖试用版和正式版

权限别用一堆 bool 堆着,加功能就要改结构体、改序列化、改所有读取点。用位掩码,一个quint32能装 32 个功能点,序列化成一个整数,存授权文件、存注册表、走网络协议都方便。

// policy.h #include <QString> enum class Feature : quint32 { None = 0, ExportPdf = 1u << 0, BatchRun = 1u << 1, PluginLoad = 1u << 2, ApiAccess = 1u << 3, CustomTheme = 1u << 4, }; struct Policy { int trialDays = 30; int maxRuns = 20; // <=0 表示不限 quint32 mask = 0; // 当前生效的权限位 bool has(Feature f) const { return (mask & static_cast<quint32>(f)) != 0; } void grant(Feature f) { mask |= static_cast<quint32>(f); } void revoke(Feature f){ mask &= ~static_cast<quint32>(f); } // 试用版策略:只给非核心功能,次数受限 static Policy trial() { Policy p; p.trialDays = 30; p.maxRuns = 20; p.grant(Feature::ExportPdf); p.grant(Feature::CustomTheme); return p; } // 正式版策略:全开、不限次 static Policy pro() { Policy p; p.maxRuns = 0; p.mask = 0xFFFFFFFFu; return p; } };

用位掩码有个隐性好处:授权文件里只存一个整数mask,以后新增功能点只要占一个还没用过的位,老授权文件依然能读,不会因为结构体字段变多而解析失败。这一点在给客户做了几十份授权之后再改需求时,能省下大量售后。

调用侧统一成一个入口,别在每个功能点里各写各的判断:

// 业务代码里唯一的权限判断入口 if (!g_policy.has(Feature::BatchRun)) { ui->batchBtn->setEnabled(false); ui->batchBtn->setToolTip(QStringLiteral("当前授权不含批量处理")); return; }

注意 UI 禁用只是体验层,真正的拦截必须在执行函数的第一行再做一次。用户能改配置文件把mask改大,所以授权文件必须验签(第 4 章),而 UI 层的灰化只是减少无效点击。

3.3 机器码生成与绑定的边界

机器码的作用是把授权锁到一台设备上。常见做法是取几个硬件标识拼起来做哈希,取前 16 位十六进制。Qt 里有现成的QSysInfo::machineUniqueId(),Windows 下取的是 MachineGuid,Linux 下读/etc/machine-id,macOS 走 IOPlatformUUID,跨平台基本够用。

#include <QCryptographicHash> #include <QSysInfo> #include <QNetworkInterface> QString machineCode() { // 盐值必须换掉,且只存在于客户端,用于让不同产品的机器码不可互推 const QByteArray salt = QByteArrayLiteral("replace-me-with-your-own-salt"); QByteArray uid = QSysInfo::machineUniqueId(); if (uid.isEmpty()) { // 兜底:部分精简 Linux 没有 machine-id,退到第一块物理网卡 MAC for (const QNetworkInterface &ni : QNetworkInterface::allInterfaces()) { if (ni.flags().testFlag(QNetworkInterface::IsLoopBack)) continue; if (ni.hardwareAddress().isEmpty()) continue; uid = ni.hardwareAddress().toUtf8(); break; } } if (uid.isEmpty()) uid = QSysInfo::prettyProductName().toUtf8(); // 最后的兜底,不推荐 QCryptographicHash h(QCryptographicHash::Sha256); h.addData(salt); h.addData(uid); return QString::fromLatin1(h.result().toHex().left(16)).toUpper(); }

这段代码的边界要清楚。换主板、重装系统(MachineGuid 会变)、克隆虚拟机,都会导致机器码变化,用户的授权直接失效。所以正式产品里必须留一个「授权迁移」通道,比如允许每年免费重置两次,或者后台人工换绑,别把用户堵死在一台机器上,这是售后成本的大头。

另一个坑是虚拟机快照。用户在快照 A 激活,回滚到快照 B 再激活,等于一份授权跑了两台环境。要堵这个,就得在授权里记录激活时间戳并做单调校验,或者干脆限制同一机器码的激活次数。

4. 授权文件与激活:签名校验落到 Qt 工程的四个接口

试用期设置走到正式授权这一步,核心诉求就一个:客户端不能自己造授权。任何把密钥、算法、判定逻辑全放在客户端的方案,本质都是提高门槛而不是绝对防住。所以设计目标是「让改造成本高于购买成本」,而不是追求理论上的不可破解。

4.1 授权文件结构:载荷与签名分离

授权文件用 JSON 做载荷,签名单独一个字段,两者一起序列化。字段顺序必须固定,因为签名算的是规范化之后的字节串,字段顺序一变签名就废。

字段类型参与签名说明
productstring是产品标识,防止 A 产品授权用在 B 产品
machinestring是机器码,空串表示不绑定设备
expirestring是到期日,格式 yyyy-MM-dd,空串表示永久
maskuint32是权限位
maxRunsint是次数上限,0 表示不限
issuedAtint64是签发时间戳,用于防重放
sigstring否Base64 编码的签名值
{ "product": "demo-tool", "machine": "9F3A1C7B2D5E8046", "expire": "2026-12-31", "mask": 31, "maxRuns": 0, "issuedAt": 1767225600, "sig": "5m8Xk...(Base64)" }

issuedAt这个字段很多人会省掉,省掉之后就没法做吊销——用户把系统时间调到签发之前,或者拿一份旧授权覆盖新授权,你都无从判断。有了它,客户端只要记住「本机见过的最大 issuedAt」,比它小的授权文件一律拒绝,回滚攻击就失效了。

4.2 验签实现:为什么必须用常数时间比较

轻量场景用 HMAC-SHA256 就够,Qt 自带QMessageAuthenticationCode,不用引第三方库。但要注意两点:一是规范化函数必须和签发端逐字节一致,二是比较签名时用compare()而不是==。

#include <QMessageAuthenticationCode> #include <QCryptographicHash> #include <QJsonObject> #include <QJsonDocument> // 规范化:字段顺序、分隔符、日期格式全部写死,签发端必须用同一份实现 static QByteArray canonical(const QJsonObject &o) { return QStringLiteral("%1|%2|%3|%4|%5|%6") .arg(o.value("product").toString(), o.value("machine").toString(), o.value("expire").toString(), QString::number(o.value("mask").toInt()), QString::number(o.value("maxRuns").toInt()), QString::number(static_cast<qint64>(o.value("issuedAt").toDouble()))) .toUtf8(); } bool verifyLicense(const QByteArray &fileBytes, const QByteArray &key, QJsonObject *out) { QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(fileBytes, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) return false; const QJsonObject o = doc.object(); const QByteArray sig = QByteArray::fromBase64(o.value("sig").toString().toUtf8()); const QByteArray want = QMessageAuthenticationCode::hash( canonical(o), key, QCryptographicHash::Sha256); // compare 是常数时间比较,避免按字节短路带来的时序侧信道 if (!QMessageAuthenticationCode::compare(sig, want)) return false; if (out) *out = o; return true; }

canonical()是整套方案里最容易出问题的地方。服务端用 Python 拼的字符串,客户端用 Qt 拼,中间多一个空格、日期格式从2026-12-31变成2026-12-31T00:00:00,签名就永远对不上。我的做法是把规范化函数单独抽成一个文件,服务端和客户端各自实现一份,然后用同一组固定测试向量交叉验证,两边输出必须逐字节相同。

compare()和==的区别在于前者不会在第一个不同字节处提前返回,时间开销恒定。本地单机场景这个风险不高,但既然是现成 API,没有理由不用。

4.3 激活流程的四个接口与状态机

工程里把授权相关的逻辑收在一个LicenseManager里,对外只暴露四个接口,业务代码不碰文件路径和 JSON:

class LicenseManager { public: // 1) 启动时调用,决定本次运行跑试用还是正式 Policy boot(); // 内部调 verify + trialClock.check // 2) 用户输入激活码/导入授权文件 bool activate(const QString &codeOrPath, QString *errMsg); // 3) 每次消耗性动作前调用,扣次数 bool consumeRun(int *left); // 4) 导出诊断信息,给售后用 QString diagnostics() const; };

boot()里的判定顺序不能乱:先验签授权文件,通过就以授权文件为准;不通过再看试用状态;试用状态里如果带篡改标记,降级到受限模式。反过来先判试用就会出现「用户已购买但试用期过了仍被拦」的投诉。

状态机一共五个状态:Unlicensed、Trial、TrialExpired、Licensed、Tampered。前四个是正常流转,Tampered是异常兜底,它不锁死程序,只关掉高价值功能并提示联系支持,原因是误判太常见了。

4.4 离线激活码怎么防重放

没有服务端的场景,激活码只能离线算。常见做法是让用户把机器码念给你,你用工具生成一串「机器码 + 到期 + 权限」的签名,用户粘回去。这里要防的是同一串激活码被贴到第二台机器上,所以在签名载荷里必须包含机器码,客户端验签后再比对本地机器码,不一致直接拒绝。

更省事的做法是让激活码自带校验位:把机器码前 8 位、到期日、一个随机盐拼起来做 HMAC,取前 12 位当校验段。这样即使激活码被转发,第二台机器算出来的校验段也对不上。代价是激活码会比较长(一般 40 字符以上),用户手输容易错,所以工程里同时提供「粘贴导入」和「文件导入」两条路。

5. 避坑与排查:五条踩过的记录

下面五条都是真实翻车现场,按「现象 → 原因 → 解决」整理,遇到同类问题时可以对照着查。

5.1 用户改系统时间,试用期直接重置

现象:用户把时间往前调两天,程序启动后试用天数回到第 1 天。 原因:只存了first_run一个字段,程序每次启动都用now - first_run算天数,时间往前调,差值变小。 解决:加last_seen做单调校验,now < last_seen - 120判定为回拨;同时累计acc_uptime,用单调累加值做第二道判定。两个值取「更严格」的那个作为最终状态,改时间只能让它更早到期,不能延后。

5.2 优化软件清一次垃圾,试用次数自动重置

现象:用户装了清理工具,清完缓存后本来剩 3 次的试用次数变回 20 次。 原因:计数文件放在AppDataLocation,被清理工具当成缓存目录整个删掉,读不到文件就按 0 处理。 解决:多处副本交叉。主副本放AppDataLocation,备份副本写进注册表(或者业务数据文件的一个隐藏字段),启动时读两处取较大值,并记录「本次是否检测到副本丢失」。副本丢失本身就是一个信号,可以降级到受限模式。另外把计数文件改成带校验位的格式(次数|HMAC),删了文件重建也会因为校验不过而暴露。

5.3 多开程序,次数少扣了一次

现象:用户双击两次图标开了两个窗口,各跑一次批量任务,次数只减了 1。 原因:读改写没有互斥,两个进程都读到同一个旧值。 解决:QLockFile把「读-判-写」整段包住,锁文件放同一目录,setStaleLockTime设 5 秒防止强杀留下僵尸锁。另外用QSharedMemory做单实例也行,但要注意共享内存段在某些容器环境下会被清理掉,反而不如文件锁可控。

5.4 授权文件明明是对的,验签就是不过

现象:服务端生成的授权文件,用户在客户端导入提示「授权无效」,但用文本编辑器打开看内容完全正常。 原因:三种情况最常见。一是 Windows 记事本保存时加了 UTF-8 BOM,JSON 解析之后字符串前面多了三个不可见字节;二是用户在聊天软件里复制粘贴,末尾多了换行或空格;三是服务端序列化时字段顺序变了。 解决:读文件后先做一次清洗,去掉 BOM、去掉首尾空白、把\r\n统一成\n。规范化函数单独抽出来,服务端和客户端用同一组固定向量交叉验证。导入入口同时支持文件和粘贴,粘贴路径额外做一次 Base64 解码前的空白字符清理。

5.5 Debug 构建里打了密钥,Release 忘了关

现象:逆向分析发现二进制里能直接搜到 HMAC 密钥,用户据此自己签了一份永久授权。 原因:开发阶段为了排查方便,在验签失败分支里qDebug() << key,这个分支在 Release 里依然存在。 解决:密钥不进客户端——离线方案里客户端只放公钥,用非对称签名(RSA 或 Ed25519)校验,私钥只在签发工具里。如果规模小只能用 HMAC,那就在.pro里加DEFINES += QT_NO_DEBUG_OUTPUT,并且把所有含密钥的日志用#ifdef QT_DEBUG包起来。上线前用strings或十六进制编辑器搜一遍可执行文件,确认搜不到密钥片段,这个动作我每次都做。

6. 上线前一周:把授权模块当黑匣子做回归

授权模块的特点是「平时不出声,出事就是一片投诉」。它不像普通功能那样有明确的输入输出,用户报障时你拿到的是「打不开了」四个字,没有现场数据就只能靠猜。所以在发版前留一周,把它当黑匣子跑一遍回归,比事后救火划算得多。

回归清单不用复杂,关键是覆盖状态迁移而不是功能点。我习惯按下面这几项走一遍,每项都手动验证:

测试项操作手法期望结果
首次启动清空配置目录后启动写入 first_run,状态 Trial
时间回拨系统时间往前调 1 天再启动状态 Tampered 或受限,不重置
时间前调系统时间往前调 30 天再启动到期,acc_uptime 单次增量不超过 1 天
次数耗尽连续跑满 20 次第 21 次被拦,计数文件内容为 20
并发计数同时启动两个实例各跑一次次数减 2,锁文件正常释放
授权导入导入正确授权文件状态 Licensed,权限位全开
授权篡改手工改 JSON 里的 mask 后导入验签失败,回落试用
授权回滚导入一份 issuedAt 更小的旧授权被拒,提示授权已失效
强杀恢复计数过程中任务管理器强杀重启后计数不丢不乱
机器迁移换一块硬盘后启动机器码变化,授权失效,提示迁移流程

清单里最容易被跳过的是「强杀恢复」和「机器迁移」。前者要么在虚拟机里手动杀进程,要么在计数函数里加一个可配置的延迟开关,写完文件后QThread::msleep几百毫秒,方便在这个窗口期杀掉。后者至少要在一台备份机上验证一次,否则用户换硬件时你才知道自己的迁移通道根本没写。

再补一个实用技巧:给授权模块加一个诊断串导出。用户报问题时让他点一下「复制诊断信息」,把这一串贴过来,你就能还原大半现场。

QString LicenseManager::diagnostics() const { // 单行、可粘贴、不含敏感密钥;末尾带 8 位校验,防止用户手改后你误判 const QString body = QStringLiteral( "v=1;product=%1;machine=%2;state=%3;first=%4;last=%5;uptime=%6;runs=%7;mask=%8") .arg(m_product, machineCode(), stateName(m_state)) .arg(m_firstRun).arg(m_lastSeen).arg(m_uptime) .arg(m_usedRuns).arg(m_policy.mask); const QByteArray sum = QCryptographicHash::hash( body.toUtf8(), QCryptographicHash::Sha256).toHex().left(8); return body + ";crc=" + QString::fromLatin1(sum); }

这段代码有两点值得注意。一是诊断串里绝对不能带密钥或完整授权文件,否则等于把攻击材料送给用户;二是末尾的crc是给自己看的,用户改了state想让你以为他是正式版,校验位就对不上,一眼能看出来。诊断串长度控制在 200 字符以内,方便用户在聊天窗口里一次性贴完。

最后说个习惯。我以前觉得授权模块就那么几百行,写完随手测两下就发了,结果有一次acc_uptime的单位写成了毫秒,30 天的试用期 43 分钟就到期,一晚上收到十几条投诉。从那以后我每次发版前都强制走一遍上面那张表,哪怕只改了一行日志代码,也要把「时间回拨」和「次数耗尽」两项手动跑一次。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进