文档栏目

C# 授权对接教程(Web / 非 Web〔Windows & Linux〕)

最近更新: 2026年2月24日

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 = 离线宽限租约(通常仅 verifyisValid=true 时返回;客户端可做离线兜底)
  • expiresAt=null = 永久授权(永不过期,不倒计时;仅针对客户端 activate/verify 返回值。控制台授权列表请用 isPermanent/isActivated 区分)

0. 你需要准备什么(对接前置条件)

0.1 软件凭证(必须)

你需要从管理后台的“软件资产”获取:

  • AppId
  • AppSecret

注意: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-IdX-QZ-TimestampX-QZ-NonceX-QZ-Signature

下载补充(更新分发):

  • 若版本使用外链分发(downloadUrl 不为空):客户端可直接下载。
  • 若版本使用站内托管(downloadFileId 不为空):下载必须走受控接口 GET /api/downloads/{releaseId},且需要用户登录并拥有该软件有效授权

3. “机器码”到底是什么(必须理解)

3.1 machineCode 的定位

machineCode 是你上报给服务端的“绑定因子”。服务端不会替你生成,只会:

  • 激活时:用它创建绑定记录(占用席位)
  • 校验时:用它找到绑定记录(或判断未激活/黑名单)

3.2 Web vs 非 Web 的口径

  • WebmachineCode = 域名Key(建议做归一化)
  • 非 Web(Windows/Linux)
    • machineCode = WeakDeviceId(弱指纹:尽量稳定)
    • strongDeviceId = StrongDeviceId(强指纹:更敏感,用于迁移确认,可选但推荐)

4. Web 形态:域名机器码算法(C#)

4.1 规则

建议规则(与服务端归一化口径一致):

  • 去掉 http:// / https://
  • 去掉路径(只保留 host)
  • 去掉端口
  • 小写
  • 去掉尾部 .

重要:

  • 服务端当前只做“host 归一化”,不会自动“同根域名归并”。
  • 如果你希望 a.foo.comb.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.UUID
  • Win32_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(首次激活)

    • 必填licenseKeymachineCode
    • 选填machineNamestrongDeviceId
  • POST /api/v1/client/verify(启动校验)

    • 必填licenseIdmachineCode
    • 选填strongDeviceId

6.1.2 machineCodestrongDeviceId 到底是什么

  • machineCode(必填):绑定因子。

    • Web:建议传“域名 Key”(见 4.2 的 NormalizeDomainKey)。服务端会按 Web 口径做归一化后再比对。
    • 非 Web:建议传“弱指纹”(WeakDeviceId,见 5.x)。激活与校验必须一致,否则会被判定为“该设备未激活”。
  • strongDeviceId(选填但推荐,非 Web 场景):强指纹。

    • 用途:在设备硬件发生明显变化时,用于触发“迁移确认/重绑”流程(服务端会返回 409code=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)

  1. 用户输入 licenseKey
  2. 你生成 machineCode
    • Web:NormalizeDomainKey(domainOrHost)
    • 非 Web:WeakDeviceId
  3. 调用 ActivateAsync
  4. 若成功:保存 licenseId(以及 features/expiresAt 便于展示)
  5. 若失败:按 message 提示用户(常见:未绑定账号、黑名单、席位已满、已过期)

7.2 启动校验流程(建议:每次启动一次)

  1. 读取本地保存的 licenseId
  2. 生成本机 machineCode(以及可选 strongDeviceId
  3. 调用 VerifyAsync
  4. 分支处理:
  • 200 且 isValid=true

    • 缓存 features(功能开关)
    • 缓存 leaseExpiresAt(离线兜底)
    • expiresAt=null:UI 显示“永不过期”
  • 200 且 isValid=false

    • 根据 message 做提示(例如未激活/未绑定账号/过期/撤销等)
    • 建议进入受限模式
  • 409 且 code=REBIND_REQUIRED(非 Web 强指纹变化):

    • 进入受限模式
    • 提示用户到 /console 的授权详情页确认迁移

7.3 离线兜底(基于 leaseExpiresAt

  • 你应该把最近一次校验成功的结果落地缓存:
    • features
    • leaseExpiresAt
    • expiresAt(可能为 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 成功链路

  • 首次激活成功:

    • 输入 licenseKeyactivate 成功 → 得到 licenseId
  • 启动校验成功:

    • verify 返回 isValid=true 且返回 featuresleaseExpiresAt
  • 永久授权:

    • 返回 expiresAt=null,UI 显示“永不过期”

10.2 失败链路

  • 断网:

    • leaseExpiresAt 之前可用,之后进入受限模式
  • 强指纹变化:

    • verify 返回 409 REBIND_REQUIRED

11. 你下一步该怎么做(落地建议)

  1. 先决定:你是否用“你的服务端代调”来保护 AppSecret
  2. 按你的应用形态实现 machineCode
    • Web:域名 Key
    • 非 Web:Weak/Strong 指纹
  3. 只做启动校验(不要做高频心跳),并使用 leaseExpiresAt 做离线兜底
  4. Feature.Code 做功能开关,并按需拉取功能目录做 UI 映射