文档栏目
C# 授权对接教程(Web / 非 Web〔Windows & Linux〕)
最近更新: 2026年2月24日
文档导读
目录- C# 授权对接教程(Web / 非 Web〔Windows & Linux〕)
- 0. 你需要准备什么(对接前置条件)
- 0.1 软件凭证(必须)
- 0.2 用户侧授权信息(必须)
- 0.3 重要约束:授权码必须先绑定账号(ContactId)
- 1. 推荐架构(强烈建议)
- 1.1 推荐:你的服务端代调(安全)
- 1.2 直接从客户端调用(不推荐但可用)
- 2. 接口清单(你会用到的 5 个)
- 3. “机器码”到底是什么(必须理解)
- 3.1 machineCode 的定位
- 3.2 Web vs 非 Web 的口径
- 4. Web 形态:域名机器码算法(C#)
- 4.1 规则
- 4.2 参考实现
- 5. 非 Web(Windows/Linux):设备机器码算法(C#)
- 5.1 设计目标
- 5.2 推荐算法(跨软件隔离)
C# 授权对接教程(Web / 非 Web〔Windows & Linux〕)
适用范围:你已经有一个开发完成的 C# 项目(Web 或桌面/服务端程序),需要接入 Qenzum 授权。
代码示例环境:.NET Framework 4.8(Windows,建议 C# 7.3+)。
- 如果你需要在 Linux 上运行:请使用 .NET 6+(协议/字段/流程不变,只有项目框架与依赖不同)。
本文只讲两类:
- Web:按域名绑定(
machineCode = 域名Key)- 非 Web:按设备绑定(
machineCode = WeakDeviceId,可选strongDeviceId)关键词:
activate= 首次激活(占用席位/建立绑定;有效期从首次激活开始计时)verify= 启动校验(建议每次启动一次,不做高频心跳)leaseExpiresAt= 离线宽限租约(通常仅verify且isValid=true时返回;客户端可做离线兜底)expiresAt=null= 永久授权(永不过期,不倒计时;仅针对客户端activate/verify返回值。控制台授权列表请用isPermanent/isActivated区分)
0. 你需要准备什么(对接前置条件)
0.1 软件凭证(必须)
你需要从管理后台的“软件资产”获取:
AppIdAppSecret
注意:
AppSecret属于软件级密钥。不要把它下发到不可控客户端(浏览器前端/可逆向桌面端)。
0.2 用户侧授权信息(必须)
用户实际持有的是授权码 licenseKey,激活成功后你会得到 licenseId:
licenseKey:用于 首次激活(/activate)licenseId:用于 后续校验(/verify)以及查询授权信息
0.3 重要约束:授权码必须先绑定账号(ContactId)
服务端规则:如果授权码还没绑定购买者账号(ContactId 为空),activate/verify 会失败并提示:
- “该授权码尚未绑定账号,请先登录用户控制台 /console → 授权管理 → 注册 Key”
这意味着:
- 你可以在产品流程里引导用户先去控制台完成绑定
- 或者你自己产品的登录/账户体系要能承接这个动作(本系统当前是控制台完成绑定)
1. 推荐架构(强烈建议)
1.1 推荐:你的服务端代调(安全)
- 你的客户端(Web 前端/桌面端)调用 你自己的服务端
- 你的服务端保存
AppSecret并代调 Qenzum 的/api/v1/client/*
优点:
AppSecret不暴露- 你能统一做重试/缓存/审计/风控
1.2 直接从客户端调用(不推荐但可用)
仅当你能接受密钥泄露风险(或你项目本身就是服务端程序)时,才让客户端直连:
- 直接
HttpClient调用 Qenzum - Header 带
X-App-Id+ 签名三件套(X-QZ-Timestamp/X-QZ-Nonce/X-QZ-Signature)
2. 接口清单(你会用到的 5 个)
BaseUrl(按服务端配置为准,示例用
https://api.qenzum.com):
POST/api/v1/client/activate:首次激活POST/api/v1/client/verify:启动校验GET/api/v1/client/features:获取功能目录(code -> name)GET/api/v1/client/licenses/{licenseId}:查询授权信息(不含明文授权码)GET/api/v1/client/releases/latest:查询最新已发布版本(用于自动更新/检查更新)
认证(全部客户端接口都需要):
- Header:
X-App-Id、X-QZ-Timestamp、X-QZ-Nonce、X-QZ-Signature
下载补充(更新分发):
- 若版本使用外链分发(
downloadUrl不为空):客户端可直接下载。- 若版本使用站内托管(
downloadFileId不为空):下载必须走受控接口GET /api/downloads/{releaseId},且需要用户登录并拥有该软件有效授权。
3. “机器码”到底是什么(必须理解)
3.1 machineCode 的定位
machineCode 是你上报给服务端的“绑定因子”。服务端不会替你生成,只会:
- 激活时:用它创建绑定记录(占用席位)
- 校验时:用它找到绑定记录(或判断未激活/黑名单)
3.2 Web vs 非 Web 的口径
- Web:
machineCode = 域名Key(建议做归一化) - 非 Web(Windows/Linux):
machineCode = WeakDeviceId(弱指纹:尽量稳定)strongDeviceId = StrongDeviceId(强指纹:更敏感,用于迁移确认,可选但推荐)
4. Web 形态:域名机器码算法(C#)
4.1 规则
建议规则(与服务端归一化口径一致):
- 去掉
http:///https:// - 去掉路径(只保留 host)
- 去掉端口
- 小写
- 去掉尾部
.
重要:
- 服务端当前只做“host 归一化”,不会自动“同根域名归并”。
- 如果你希望
a.foo.com与b.foo.com共用授权,你必须自己把它们都归一到foo.com(这属于你的产品策略)。
4.2 参考实现
public static class QzWebMachineCode
{
/// <summary>
/// 归一化域名 Key:去协议/端口/路径,统一小写。
/// - 入参:domainOrHost 可传 host,也可传完整 URL
/// - 返回:用于 machineCode 的稳定字符串
/// </summary>
public static string NormalizeDomainKey(string domainOrHost)
{
var s = (domainOrHost ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(s)) return string.Empty;
if (s.StartsWith("http://")) s = s.Substring(7);
if (s.StartsWith("https://")) s = s.Substring(8);
var slash = s.IndexOf('/');
if (slash >= 0) s = s.Substring(0, slash);
var colon = s.IndexOf(':');
if (colon >= 0) s = s.Substring(0, colon);
return s.TrimEnd('.');
}
}
5. 非 Web(Windows/Linux):设备机器码算法(C#)
5.1 设计目标
WeakDeviceId(弱指纹)尽量稳定:- 换网卡/改主机名不应变化
- 重装系统是否变化取决于你采集的字段
StrongDeviceId(强指纹)更敏感:- 硬盘/关键硬件变化时应变化
- 服务端将触发“迁移确认/重绑”流程
5.2 推荐算法(跨软件隔离)
为避免同一设备在不同软件之间可被关联,推荐把 AppId 做盐:
weak = SHA256_HEX(weakRaw + "|" + appId)strong = SHA256_HEX(strongRaw + "|" + appId)
5.3 Windows 采集建议(稳定/可解释)
建议优先顺序:
Win32_ComputerSystemProduct.UUIDWin32_BaseBoard.SerialNumber-(Strong 可选)Win32_PhysicalMedia.SerialNumber
注意:不同品牌机器 WMI 字段可能为空或不稳定。你的实现应当允许字段缺失并降级。
Windows 参考实现(WMI + SHA256)
using System;
using System.Management;
using System.Security.Cryptography;
using System.Text;
public static class QzWindowsDeviceFingerprint
{
/// <summary>
/// 获取设备指纹(Windows)。
/// - 入参:appId 作为盐,用于“跨软件隔离”
/// - 返回:weak 用于 machineCode(弱指纹);strong 用于 strongDeviceId(强指纹)
/// - 返回值:两者均为 SHA256 hex
/// </summary>
public static (string weakDeviceId, string strongDeviceId) GetDeviceIds(string appId)
{
var uuid = QueryWmiValue("Win32_ComputerSystemProduct", "UUID");
var board = QueryWmiValue("Win32_BaseBoard", "SerialNumber");
var disk = QueryWmiValue("Win32_PhysicalMedia", "SerialNumber");
var weakRaw = $"UUID={uuid};BOARD={board}";
var strongRaw = $"UUID={uuid};BOARD={board};DISK={disk}";
return (Sha256Hex(weakRaw + "|" + appId), Sha256Hex(strongRaw + "|" + appId));
}
/// <summary>
/// 查询 WMI 单字段(失败返回空字符串)。
/// - 入参:klass WMI 类名;prop 字段名
/// - 返回:字段值(可能为空)
/// - 失败:WMI 不可用/权限不足/字段为空时返回空字符串
/// </summary>
private static string QueryWmiValue(string klass, string prop)
{
try
{
using (var searcher = new ManagementObjectSearcher($"SELECT {prop} FROM {klass}"))
{
foreach (var mo in searcher.Get())
{
var v = mo?[prop]?.ToString();
if (!string.IsNullOrWhiteSpace(v)) return v.Trim();
}
}
}
catch
{
// ignore
}
return string.Empty;
}
/// <summary>
/// 计算 SHA256 十六进制。
/// - 入参:s 原始字符串
/// - 返回:小写 hex 字符串
/// </summary>
private static string Sha256Hex(string s)
{
using (var sha = SHA256.Create())
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(s ?? string.Empty));
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
}
依赖提醒:WMI 需要
System.Management,且只适用于 Windows。
5.4 Linux 采集建议(稳定/可解释)
Linux 上推荐优先读取:
/etc/machine-id(通常稳定,但重装系统可能变化)/sys/class/dmi/id/product_uuid(若权限/发行版支持) -(Strong 可选)磁盘序列号/文件系统 UUID(实现复杂、权限敏感,按需)
Linux 参考实现(machine-id + DMI + SHA256)
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
public static class QzLinuxDeviceFingerprint
{
/// <summary>
/// 获取设备指纹(Linux)。
/// - 入参:appId 作为盐,用于“跨软件隔离”
/// - 返回:weak 用于 machineCode;strong 用于 strongDeviceId(更敏感)
/// - 说明:示例读取 machine-id 与 DMI 信息;不同发行版/权限下可能为空,需要允许降级
/// </summary>
public static (string weakDeviceId, string strongDeviceId) GetDeviceIds(string appId)
{
var machineId = ReadFirstLine("/etc/machine-id");
var productUuid = ReadFirstLine("/sys/class/dmi/id/product_uuid");
var weakRaw = $"MID={machineId};DMI={productUuid}";
// strongRaw 你可以加入更多因子(例如磁盘信息),这里先给保守版本
var strongRaw = $"MID={machineId};DMI={productUuid}";
return (Sha256Hex(weakRaw + "|" + appId), Sha256Hex(strongRaw + "|" + appId));
}
/// <summary>
/// 读取文本文件的内容(失败返回空字符串)。
/// - 入参:path 文件路径
/// - 返回:去掉首尾空白的文本
/// - 失败:文件不存在/无权限/IO 异常时返回空字符串
/// </summary>
private static string ReadFirstLine(string path)
{
try
{
if (!File.Exists(path)) return string.Empty;
var s = File.ReadAllText(path).Trim();
return s;
}
catch
{
return string.Empty;
}
}
/// <summary>
/// 计算 SHA256 十六进制。
/// - 入参:s 原始字符串
/// - 返回:小写 hex 字符串
/// </summary>
private static string Sha256Hex(string s)
{
using (var sha = SHA256.Create())
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(s ?? string.Empty));
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
}
6. HttpClient 调用封装(.NET Framework 4.8 可直接用)
依赖(NuGet):
System.Text.Json(.NET 6+ 内置;.NET Framework 4.8 可通过 NuGet 引入)说明:本文示例统一按服务端返回的 camelCase JSON 处理;如果你使用
System.Text.Json,请确保反序列化选项开启PropertyNameCaseInsensitive=true(见下方封装)。
6.1 DTO(建议你自己项目内定义,便于强类型)
6.1.1 字段必填/选填规则(重要)
说明:以下“必填/选填”口径以服务端实际 DTO 为准(
ActivationRequest/VerificationRequest)。
- 必填:传
null/空字符串将导致服务端无法绑定或无法校验,通常返回200 + success/isValid=false(兼容历史),并在message中给出原因。- 选填:不传不影响主流程,但可能影响安全性/迁移确认等分支。
POST /api/v1/client/activate(首次激活)- 必填:
licenseKey、machineCode - 选填:
machineName、strongDeviceId
- 必填:
POST /api/v1/client/verify(启动校验)- 必填:
licenseId、machineCode - 选填:
strongDeviceId
- 必填:
6.1.2 machineCode 与 strongDeviceId 到底是什么
machineCode(必填):绑定因子。- Web:建议传“域名 Key”(见 4.2 的
NormalizeDomainKey)。服务端会按 Web 口径做归一化后再比对。 - 非 Web:建议传“弱指纹”(
WeakDeviceId,见 5.x)。激活与校验必须一致,否则会被判定为“该设备未激活”。
- Web:建议传“域名 Key”(见 4.2 的
strongDeviceId(选填但推荐,非 Web 场景):强指纹。- 用途:在设备硬件发生明显变化时,用于触发“迁移确认/重绑”流程(服务端会返回
409且code=REBIND_REQUIRED,并带rebindRequestId)。 - 行为:
- 兼容旧客户端:如果激活记录里还没有
strongDeviceId,你第一次上报时服务端会把它补齐。 - 若你上报了
strongDeviceId且与历史值不一致:服务端要求用户去/console确认迁移后才允许继续使用。 - 若你不传
strongDeviceId:服务端不会做强指纹一致性校验(更宽松,但迁移风险更高)。
- 兼容旧客户端:如果激活记录里还没有
- 用途:在设备硬件发生明显变化时,用于触发“迁移确认/重绑”流程(服务端会返回
using System;
using System.Collections.Generic;
using System.Net;
public sealed class ActivationRequestDto
{
public string licenseKey { get; set; }
public string machineCode { get; set; }
public string machineName { get; set; }
public string strongDeviceId { get; set; }
}
/// <summary>
/// 已授权功能条目(用于 UI 展示)。
/// - 说明:服务端只保证 code/name;不输出 description。
/// </summary>
public sealed class AuthorizedFeatureDto
{
public string code { get; set; }
public string name { get; set; }
}
public sealed class ActivationResultDto
{
public bool success { get; set; }
public string message { get; set; }
public string licenseId { get; set; }
public string softwareName { get; set; }
public string productName { get; set; }
// 兼容字段:仅包含功能码(老客户端可继续使用)。
public List<string> features { get; set; }
// 推荐字段:包含功能名称(用于 UI 展示)。
public List<AuthorizedFeatureDto> featureDetails { get; set; }
public DateTimeOffset? expiresAt { get; set; }
}
public sealed class VerificationRequestDto
{
public string licenseId { get; set; }
public string machineCode { get; set; }
public string strongDeviceId { get; set; }
}
public sealed class VerificationResultDto
{
public bool isValid { get; set; }
public string message { get; set; }
public DateTimeOffset? expiresAt { get; set; }
public DateTimeOffset? leaseExpiresAt { get; set; }
// 兼容字段:仅包含功能码(老客户端可继续使用)。
public List<string> features { get; set; }
// 新增:用于展示“授权归属规格”。
public string softwareName { get; set; }
public string productName { get; set; }
// 推荐字段:包含功能名称(用于 UI 展示)。
public List<AuthorizedFeatureDto> featureDetails { get; set; }
}
public sealed class FeatureDto
{
public string id { get; set; }
public string name { get; set; }
public string code { get; set; }
public int sortOrder { get; set; }
}
public sealed class ReleaseDto
{
public string id { get; set; }
public string softwareId { get; set; }
public string softwareName { get; set; }
public string version { get; set; }
public string downloadUrl { get; set; }
public string downloadFileId { get; set; }
public string changelog { get; set; }
public bool isForceUpdate { get; set; }
public string checksum { get; set; }
public bool isPublished { get; set; }
public DateTimeOffset? publishedAt { get; set; }
public DateTimeOffset createdAt { get; set; }
}
public sealed class ProblemDetailsLite
{
public string title { get; set; }
public string detail { get; set; }
public string code { get; set; }
public string rebindRequestId { get; set; }
}
public sealed class VerifyResponse
{
public HttpStatusCode Status { get; set; }
public VerificationResultDto Ok { get; set; }
public string ErrorCode { get; set; }
public string RebindRequestId { get; set; }
public string ProblemTitle { get; set; }
public string ProblemDetail { get; set; }
}
6.2 统一客户端(带认证 Header)
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public sealed class QenzumLicensingClient
{
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
// 服务端返回为 camelCase;本文 DTO 也按 camelCase 命名,可直接反序列化。
// 仍建议开启不区分大小写,以兼容你项目里已有的 PascalCase DTO。
PropertyNameCaseInsensitive = true,
// 请求也用 camelCase,避免对接方/中间件做了严格校验。
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly HttpClient _http;
private readonly string _appId;
private readonly string _appSecret;
private readonly string _baseUrl; // e.g. https://api.qenzum.com
/// <summary>
/// 构造 Qenzum 授权客户端。
/// - 入参:http 建议复用单例(避免 Socket 耗尽)
/// - 入参:appId/appSecret 软件凭证(不要下发到不可控客户端)
/// - 入参:baseUrl 服务端地址(例如 https://api.qenzum.com)
/// </summary>
public QenzumLicensingClient(HttpClient http, string appId, string appSecret, string baseUrl)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
_appId = appId ?? string.Empty;
_appSecret = appSecret ?? string.Empty;
_baseUrl = (baseUrl ?? string.Empty).TrimEnd('/');
}
/// <summary>
/// 构造 canonical string(待签名文本)。
/// - 约定:以 \n 连接;空字段用空字符串占位。
/// - 注意:客户端与服务端必须完全一致,否则签名必然校验失败。
/// </summary>
private static string BuildCanonical(string method, string path, string appId, long timestampUnix, string nonce, params string[] parts)
{
var sb = new StringBuilder();
sb.Append(method.ToUpperInvariant()).Append('\n');
sb.Append(path).Append('\n');
sb.Append(appId).Append('\n');
sb.Append(timestampUnix).Append('\n');
sb.Append(nonce);
if (parts != null)
{
foreach (var p in parts)
{
sb.Append('\n').Append(p ?? string.Empty);
}
}
return sb.ToString();
}
/// <summary>
/// 计算签名:Base64(HMACSHA256(AppSecret, canonical))。
/// - 副作用:无。
/// </summary>
private static string ComputeSignatureBase64(string appSecret, string canonical)
{
using (var hmac = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(appSecret ?? string.Empty)))
{
var bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical ?? string.Empty));
return Convert.ToBase64String(bytes);
}
}
/// <summary>
/// 创建带签名的请求(推荐:每次请求都显式带 Header,避免并发/复用时 DefaultRequestHeaders 被覆盖)。
/// - 入参:method HTTP 方法;path 以 /api/v1/client 开头的相对路径;content 可为空
/// - 入参:signParts 为参与签名的字段(按协议顺序)
/// - 返回:HttpRequestMessage(调用方负责 Dispose)
/// - 说明:Header 只传 `X-App-Id` + 签名三件套,不再明文传 `X-App-Secret`
/// </summary>
private HttpRequestMessage CreateSignedRequest(HttpMethod method, string path, string[] signParts, HttpContent content = null)
{
var url = _baseUrl + path;
var msg = new HttpRequestMessage(method, url);
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var nonce = Guid.NewGuid().ToString("N");
var canonical = BuildCanonical(method.Method, path, _appId, ts, nonce, signParts);
var sig = ComputeSignatureBase64(_appSecret, canonical);
msg.Headers.TryAddWithoutValidation("X-App-Id", _appId);
msg.Headers.TryAddWithoutValidation("X-QZ-Timestamp", ts.ToString());
msg.Headers.TryAddWithoutValidation("X-QZ-Nonce", nonce);
msg.Headers.TryAddWithoutValidation("X-QZ-Signature", sig);
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (content != null)
{
msg.Content = content;
}
return msg;
}
/// <summary>
/// 首次激活:占用席位/建立绑定。
/// - 返回:ActivationResult(失败也可能是 200 + Success=false)
/// - 失败:401 表示 AppId/AppSecret 不正确
/// </summary>
public async Task<ActivationResultDto> ActivateAsync(ActivationRequestDto req, CancellationToken ct = default(CancellationToken))
{
using (var content = CreateJsonContent(req))
using (var msg = CreateSignedRequest(
HttpMethod.Post,
"/api/v1/client/activate",
new[]
{
(req.licenseKey ?? string.Empty).Trim(),
(req.machineCode ?? string.Empty).Trim(),
(req.machineName ?? string.Empty).Trim(),
(req.strongDeviceId ?? string.Empty).Trim()
},
content))
using (var resp = await _http.SendAsync(msg, ct).ConfigureAwait(false))
{
// 注意:服务端在 401/404/500 等非 200 场景,通常会返回 RFC7807(ProblemDetails)。
// 如果你把 ProblemDetails 反序列化成 ActivationResultDto,就会得到“全是默认值”的 DTO(Success=false,其余全 null)。
var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
// 401:软件凭证不正确
if (resp.StatusCode == HttpStatusCode.Unauthorized)
{
var pd = TryDeserialize<ProblemDetailsLite>(json);
return new ActivationResultDto
{
success = false,
message = (pd != null ? (pd.detail ?? pd.title) : null) ?? "AppId 无效或签名失败"
};
}
// 非 200:把 RFC7807 归一成“激活失败 + message”,避免误当作 ActivationResultDto。
if (!resp.IsSuccessStatusCode)
{
var pd = TryDeserialize<ProblemDetailsLite>(json);
var msgText = (pd != null ? (pd.detail ?? pd.title) : null)
?? ("请求失败:" + (int)resp.StatusCode + " " + (resp.ReasonPhrase ?? string.Empty));
return new ActivationResultDto
{
success = false,
message = msgText
};
}
// 200:ActivationResult(失败也可能是 200 + Success=false)
var data = TryDeserialize<ActivationResultDto>(json);
return data ?? new ActivationResultDto { success = false, message = "服务端返回无法解析" };
}
}
/// <summary>
/// 启动校验:建议启动时调用一次(不做高频心跳)。
/// - 返回:Status=200 时 Ok 有值;Status=409 时给出 ErrorCode/RebindRequestId;Status=401 表示认证失败
/// </summary>
public async Task<VerifyResponse> VerifyAsync(VerificationRequestDto req, CancellationToken ct = default(CancellationToken))
{
using (var content = CreateJsonContent(req))
using (var msg = CreateSignedRequest(
HttpMethod.Post,
"/api/v1/client/verify",
new[]
{
(req.licenseId ?? string.Empty).Trim(),
(req.machineCode ?? string.Empty).Trim(),
(req.strongDeviceId ?? string.Empty).Trim()
},
content))
using (var resp = await _http.SendAsync(msg, ct).ConfigureAwait(false))
{
var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
// 409:RFC7807(需要迁移确认/重绑)
if (resp.StatusCode == HttpStatusCode.Conflict)
{
var pd = TryDeserialize<ProblemDetailsLite>(json);
return new VerifyResponse
{
Status = resp.StatusCode,
ErrorCode = pd != null ? pd.code : "REBIND_REQUIRED",
RebindRequestId = pd != null ? pd.rebindRequestId : null,
ProblemTitle = pd != null ? pd.title : "需要确认迁移",
ProblemDetail = pd != null ? pd.detail : "强指纹发生变化,需要用户确认迁移/重绑",
};
}
// 401:软件凭证不正确
if (resp.StatusCode == HttpStatusCode.Unauthorized)
{
var pd = TryDeserialize<ProblemDetailsLite>(json);
return new VerifyResponse
{
Status = resp.StatusCode,
ErrorCode = pd != null ? (pd.code ?? "UNAUTHORIZED") : "UNAUTHORIZED",
ProblemTitle = pd != null ? (pd.title ?? "未授权") : "未授权",
ProblemDetail = pd != null ? (pd.detail ?? "AppId 无效或签名失败") : "AppId 无效或签名失败",
};
}
// 非 200:返回 ProblemDetails 信息(不要误当作 VerificationResult)。
if (!resp.IsSuccessStatusCode)
{
var pd = TryDeserialize<ProblemDetailsLite>(json);
return new VerifyResponse
{
Status = resp.StatusCode,
ErrorCode = pd != null ? pd.code : null,
ProblemTitle = pd != null ? pd.title : "请求失败",
ProblemDetail = pd != null ? pd.detail : ("请求失败:" + (int)resp.StatusCode + " " + (resp.ReasonPhrase ?? string.Empty)),
};
}
// 200:VerificationResult(失败也可能是 200 + IsValid=false)
var ok = TryDeserialize<VerificationResultDto>(json);
return new VerifyResponse { Status = resp.StatusCode, Ok = ok };
}
}
/// <summary>
/// 获取功能目录(code->name),用于 UI 映射。
/// - 返回:功能列表;失败抛异常(优先透传 RFC7807 的 title/detail)
/// </summary>
public async Task<List<FeatureDto>> GetFeatureCatalogAsync(CancellationToken ct = default(CancellationToken))
{
using (var msg = CreateSignedRequest(HttpMethod.Get, "/api/v1/client/features", Array.Empty<string>()))
using (var resp = await _http.SendAsync(msg, ct).ConfigureAwait(false))
{
var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
if (resp.StatusCode == HttpStatusCode.Unauthorized)
{
throw new InvalidOperationException("AppId 无效或签名失败");
}
if (!resp.IsSuccessStatusCode)
{
var pd = TryDeserialize<ProblemDetailsLite>(json);
var msgText = (pd != null ? (pd.detail ?? pd.title) : null)
?? ("请求失败:" + (int)resp.StatusCode + " " + (resp.ReasonPhrase ?? string.Empty));
throw new InvalidOperationException(msgText);
}
var list = TryDeserialize<List<FeatureDto>>(json);
return list ?? new List<FeatureDto>();
}
}
/// <summary>
/// 获取当前软件的最新已发布版本(用于更新分发/检查更新)。
/// - 返回:ReleaseDto;若无发布版本返回 null
/// - 失败:401 表示 AppId/AppSecret 不正确
/// </summary>
public async Task<ReleaseDto> GetLatestReleaseAsync(CancellationToken ct = default(CancellationToken))
{
using (var msg = CreateSignedRequest(HttpMethod.Get, "/api/v1/client/releases/latest", Array.Empty<string>()))
using (var resp = await _http.SendAsync(msg, ct).ConfigureAwait(false))
{
if (resp.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
if (resp.StatusCode == HttpStatusCode.Unauthorized)
{
var pd = TryDeserialize<ProblemDetailsLite>(json);
throw new InvalidOperationException((pd != null ? (pd.detail ?? pd.title) : null) ?? "AppId 无效或签名失败");
}
if (!resp.IsSuccessStatusCode)
{
var pd = TryDeserialize<ProblemDetailsLite>(json);
var msgText = (pd != null ? (pd.detail ?? pd.title) : null)
?? ("请求失败:" + (int)resp.StatusCode + " " + (resp.ReasonPhrase ?? string.Empty));
throw new InvalidOperationException(msgText);
}
var data = TryDeserialize<ReleaseDto>(json);
if (data == null) throw new InvalidOperationException("服务端返回无法解析");
return data;
}
}
/// <summary>
/// 创建 JSON 请求体。
/// - 入参:obj 任意对象
/// - 返回:StringContent(application/json; charset=utf-8)
/// </summary>
private static StringContent CreateJsonContent(object obj)
{
var json = JsonSerializer.Serialize(obj ?? new object(), JsonOptions);
return new StringContent(json, Encoding.UTF8, "application/json");
}
/// <summary>
/// 安全反序列化:JSON 无法解析时返回 null。
/// - 说明:依赖 JsonOptions.PropertyNameCaseInsensitive=true 以兼容服务端 camelCase JSON。
/// </summary>
private static T TryDeserialize<T>(string json) where T : class
{
try
{
if (string.IsNullOrWhiteSpace(json)) return null;
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch
{
return null;
}
}
}
6.3 更新分发:检查最新版本(自动更新/提示更新)
你可以在启动时(或每天 1 次)调用:
GET /api/v1/client/releases/latest
返回 ReleaseDto(包含 version/downloadUrl/downloadFileId/changelog/isForceUpdate/checksum 等)。
using System;
using System.Security.Cryptography;
using System.IO;
public static class QzUpdateHelper
{
/// <summary>
/// 判断是否需要更新(仅示例:假设版本号为纯数字格式,例如 1.2.3)。
/// - 入参:currentVersion 当前版本(来自你自己的程序集/配置)
/// - 入参:latestVersion 服务端返回的最新版本
/// - 返回:是否需要更新
/// - 失败:若版本格式无法解析,则返回 false(你也可以改成“强制更新”策略)
/// </summary>
public static bool IsUpdateAvailable(string currentVersion, string latestVersion)
{
try
{
var curr = new Version(currentVersion ?? "0.0.0");
var latest = new Version(latestVersion ?? "0.0.0");
return latest > curr;
}
catch
{
return false;
}
}
/// <summary>
/// 计算文件 SHA256(hex,小写)。
/// - 用途:对比服务端返回的 release.checksum,防止下载被篡改
/// - 入参:filePath 已下载文件路径
/// - 返回:sha256 hex 字符串
/// </summary>
public static string ComputeFileSha256Hex(string filePath)
{
using (var sha = SHA256.Create())
using (var fs = File.OpenRead(filePath))
{
var bytes = sha.ComputeHash(fs);
return BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant();
}
}
}
6.4 更新分发:下载策略(外链 vs 受控下载)
外链分发(推荐用于自动更新):
- 条件:
release.downloadUrl不为空 - 做法:客户端直接下载该 URL;下载完成后若
release.checksum有值,建议做 SHA256 校验。
- 条件:
站内托管(受控下载):
- 条件:
release.downloadFileId不为空 - 约束:安装包下载必须走
GET /api/downloads/{releaseId},并且需要用户登录且拥有该软件有效授权。 - 建议做法:
- 最简单:引导用户到
/console → 下载页面下载。 - 若你要做“应用内自动更新”:请把安装包放到你自己的可控下载服务(需要鉴权),并将该地址写入
downloadUrl(或由你的服务端代下发临时 URL)。
- 最简单:引导用户到
- 条件:
7. 你在客户端需要实现的“业务流程”
7.1 首次激活流程(用户输入 licenseKey)
- 用户输入
licenseKey - 你生成
machineCode:- Web:
NormalizeDomainKey(domainOrHost) - 非 Web:
WeakDeviceId
- Web:
- 调用
ActivateAsync - 若成功:保存
licenseId(以及features/expiresAt便于展示) - 若失败:按
message提示用户(常见:未绑定账号、黑名单、席位已满、已过期)
7.2 启动校验流程(建议:每次启动一次)
- 读取本地保存的
licenseId - 生成本机
machineCode(以及可选strongDeviceId) - 调用
VerifyAsync - 分支处理:
200 且
isValid=true:- 缓存
features(功能开关) - 缓存
leaseExpiresAt(离线兜底) - 若
expiresAt=null:UI 显示“永不过期”
- 缓存
200 且
isValid=false:- 根据
message做提示(例如未激活/未绑定账号/过期/撤销等) - 建议进入受限模式
- 根据
409 且
code=REBIND_REQUIRED(非 Web 强指纹变化):- 进入受限模式
- 提示用户到
/console的授权详情页确认迁移
7.3 离线兜底(基于 leaseExpiresAt)
- 你应该把最近一次校验成功的结果落地缓存:
featuresleaseExpiresAtexpiresAt(可能为null)
- 当启动时网络不可用:
- 如果
now < leaseExpiresAt:允许继续运行 - 否则:进入受限模式并提示用户联网校验
- 如果
注意:
leaseExpiresAt是“离线宽限”而不是授权到期。授权到期看expiresAt(永久则为null)。
8. 功能开关怎么用(Feature.Code)
8.1 服务端口径
服务端返回的是 Feature.Code 列表(例如 base_monitor),客户端用它做开关:
if (features.Contains("base_monitor")) { ... }
8.2 UI 映射(可选)
如果你要显示“中文名/说明”,调用:
GET /api/v1/client/features
然后建立字典:
Dictionary<string, FeatureDto>:code -> dto
9. 常见错误与客户端应对(你必须实现)
9.1 认证失败(401)
原因:
X-App-Id不正确,或签名头(timestamp/nonce/signature)不正确客户端处理:
- 立即停止重试
- 记录日志(便于排障)
9.2 授权码未绑定账号
activate/verify会返回失败信息- 客户端处理:
- 引导用户到
/console完成“注册 Key”(绑定账号)
- 引导用户到
9.3 设备未激活(verify 返回无效)
- 原因:
licenseId存在,但该machineCode没激活 - 客户端处理:
- 提示用户先执行激活
9.4 席位已满
- 激活时可能失败:设备数达上限
- 客户端处理:
- 提示用户到控制台解绑旧设备再激活
9.5 黑名单(设备被剔除)
- 激活/校验会返回失败信息
- 客户端处理:
- 提示用户到控制台解除黑名单
9.6 迁移确认(409 REBIND_REQUIRED)
- 原因:非 Web 强指纹变化
- 客户端处理:
- 进入受限模式
- 引导用户到控制台确认迁移
10. 最小验收(你对接完按此自测)
10.1 成功链路
首次激活成功:
- 输入
licenseKey→activate成功 → 得到licenseId
- 输入
启动校验成功:
verify返回isValid=true且返回features与leaseExpiresAt
永久授权:
- 返回
expiresAt=null,UI 显示“永不过期”
- 返回
10.2 失败链路
断网:
- 在
leaseExpiresAt之前可用,之后进入受限模式
- 在
强指纹变化:
verify返回 409REBIND_REQUIRED
11. 你下一步该怎么做(落地建议)
- 先决定:你是否用“你的服务端代调”来保护
AppSecret - 按你的应用形态实现
machineCode:- Web:域名 Key
- 非 Web:Weak/Strong 指纹
- 只做启动校验(不要做高频心跳),并使用
leaseExpiresAt做离线兜底 - 用
Feature.Code做功能开关,并按需拉取功能目录做 UI 映射