first commit

This commit is contained in:
wood911010 committed 2026-02-27 14:29:48 +08:00
commit 5ee52f09bc
52 files changed
+29721

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
import time
import logging
from license_manager.client import Client, ClientOptions, Callbacks
from license_manager.config import Config
from license_manager.models import LicensePayload
def main():
logging.basicConfig(level=logging.INFO)
cfg = Config(
server="https://license.example.com",
product="edge-gateway",
version="2.3.1",
authorization_code_path="license_code/authorization_code.txt",
public_key_path="license_code/rsa_public_key.pem",
hardware_fields=["cpu", "memory", "mac"],
heartbeat_interval_seconds=10,
offline=False,
)
def on_license_updated(lic: LicensePayload):
logging.info(f"许可证更新,将于 {lic.expires_at} 过期")
def on_heartbeat_error(err: Exception):
logging.error(f"心跳异常: {err}")
def on_activation_required(reason: str):
logging.warning(f"需要重新激活: {reason}")
callbacks = Callbacks(
on_license_updated=on_license_updated,
on_heartbeat_error=on_heartbeat_error,
on_activation_required=on_activation_required
)
opts = ClientOptions(callbacks=callbacks)
try:
# Client initialization validates configuration, gathers hardware fingerprint,
# performs offline local validation or online activation, and spins up heartbeat threads
client = Client(cfg, opts)
except Exception as e:
logging.error(f"初始化 SDK 失败: {e}")
return
lic = client.current_license()
if lic:
logging.info(f"状态={lic.status} 过期={lic.expires_at}")
logging.info(f"最大激活={lic.max_activations} key={lic.license_key}")
logging.info("主线程执行日常业务任务中...")
try:
time.sleep(35)
except KeyboardInterrupt:
pass
finally:
logging.info("关闭 SDK 客户端以终止后台心跳.")
client.close()
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
# 机器视觉软件授权管理系统 Demo设计文档
客户要求我们做一个测试demo,调用python sdk,模拟产品端实现授权对接。该 Demo 需要桌面端运行。
本设计基于针对“机器视觉应用软件”客户提炼出的核心商业需求和接入流程,通过一个界面直观地向客户呈现 SDK (全自动脱机/在线混合鉴权、SaaS平台设备管控、后台静默心跳)的实际运行效果。
---
## 1. 技术栈选择
- **UI 框架**: `PyQt5` 或 `PySide6`(机器视觉领域的工程师最为熟悉的跨平台桌面 UI 框架之一,也可以用 `tkinter` 以保证最小化无依赖运行)。
- **SDK 层**: `license-manager-sdk-python`(本地调用的核心依赖)。
- **打包工具**: `PyInstaller` (预期输出:可验证交付的独立 `.exe` 执行文件)。
## 2. 界面设计布局 (UI)
Demo 界面将分为 3 个核心区域,确保授权状态的透明和实时性。
### 区域一:设备识别与状态显示牌 (Top Panel)
- **设备指纹 (Hardware ID)**: 只读文本框,直观展示底层 `HardwareProvider` 给当前机器生成的唯一 Hash(如:`a8f5c3...`),旁边有一键复制按钮方便客户测试。
- **当前授权状态**: 醒目的红/绿状态灯及文字(未激活 / 试用中 / 已授权 / 授权已过期 或 被封禁)。
- **到期时间**: 倒计时或日期字符串显示。
- **设备额度使用状况**: 如 `Max Activations: 3` 等透传字段。
### 区域二:授权码输入与操作区 (Middle Panel)
- **产品激活码输入框**: 支持长字符串输入(提示用户可以输入纯 `LIC-xxxx` 在线激活码,也可以输入带 `&` 的完整离线鉴权码)。
- **激活/验证 按钮**:
- 点击后,首先利用逻辑判断是否含有 `&` 进行分流。
- 含有 `&`:提取后半段作为离线证书写入磁盘,触发 `Client` 本地验证,成功后立即变绿。
- 不含 `&`:触发 `Client` 在线激活 (`Activate`)。
- **清理本地许可 按钮 (清空缓存测试用)**: 用于删除 `license.lic` 触发未授权状态,以便于客户在一台机器上反复测试。
### 区域三:业务模拟与底座运行日志 (Bottom Panel)
- **启动机器视觉检测 (模拟按钮)**:
- 每次点击前判断 `client.validate()`。如果在授权期内容,弹出“成功启动 AI 模板检测”弹窗;如果鉴权失败或心跳回调判定锁定,弹出阻挡警告。
- **心跳/运行控制台日志 (Text Area)**:
- 绑定 SDK 的 `logger`。
- 实时打印:初始化成功、读取本地证书、**后台 30分钟(演示可改为30秒) 心跳尝试上报的记录**、网络失败退避的信息。
- 特别监控:打印 `on_activation_required` 钩子的触发日志,向客户演示“一旦服务器封禁,SDK 如何立刻通过回调锁死软件”。
## 3. 核心代码交互逻辑流程
1. **应用启动 (App Init)**
- UI 加载时,立刻实例化 `Client` 并传入一个 `30 秒`(Demo 演示专用)的 `heartbeat_interval_seconds`。
- 配置好两个重要的回调:
- `on_license_updated`: 接收到新证书时间,刷新界面的状态和过期时间。
- `on_activation_required`: 接收到违规被踢下线的消息,强制将“当前授权状态”标红,并置灰“启动机器视觉检测”按钮。
- 尝试执行 `client.current_license()`。有返回值则更新状态并放行,无返回值则提示“请绑定产品激活码”。
2. **激活交互处理 (Activation Workflow)**
- 当用户点击“激活”:
```python
user_input = ui.line_edit_auth_code.text()
if "&" in user_input:
# 脱机侧载演示逻辑
auth_code, envelope = user_input.split("&", 1)
write_envelope_to_local_disk(envelope) # 将离线证书落地
self.init_sdk_client(auth_code) # 使用 SDK 直接拉起脱机验证
else:
# 在线激活演示逻辑
self.init_sdk_client(user_input)
# SDK 将在底层自动跑完 Activate() 网络调用
```
## 4. 给客户演示的核心高光场景 (Showcases)
为获得客户(老板或技术总监)认可,做完 Demo 后建议如下演示:
1. **纯隔离环境发码秒跑测试**:断开本机网络。在输入框粘贴带 `&` 的产品激活码。点击激活,即可看到毫无卡顿地验证通过并允许点击“AI 检测”。**证明离线下发的秒级极佳体验**。
2. **连接网络立刻防盗稽核测试**:重新插上网络。在后台开着 SaaS 管理端(或 Postman 模拟),将这台设备在后台强制踢出/解绑。
3. **心跳钩子秒杀演示**:无需重启 Demo。等待最多 30 秒(Demo 心跳周期),控制台上会立刻打出错误日志,并且瞬间触发 `on_activation_required` 回调,UI 面板变红并拒绝点击核心业务功能。**证明防白嫖/超发管控的有效闭环。**
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,58 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), netrc (delayed, conditional), getpass (delayed)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional)
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
missing module named fcntl - imported by subprocess (optional)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named termios - imported by getpass (optional)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named posix - imported by os (conditional, optional), shutil (conditional), importlib._bootstrap_external (conditional), posixpath (optional)
missing module named resource - imported by posix (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named pyimod02_importers - imported by C:\Users\42104\AppData\Local\Programs\Python\Python312\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
missing module named collections.Callable - imported by collections (optional), socks (optional)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named 'java.lang' - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named _winreg - imported by platform (delayed, optional)
missing module named cryptography.x509.UnsupportedExtension - imported by cryptography.x509 (optional), urllib3.contrib.pyopenssl (optional)
missing module named simplejson - imported by requests.compat (conditional, optional)
missing module named 'OpenSSL.crypto' - imported by urllib3.contrib.pyopenssl (delayed, conditional)
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
missing module named annotationlib - imported by typing_extensions (conditional)
missing module named OpenSSL - imported by urllib3.contrib.pyopenssl (top-level)
missing module named 'pyodide.ffi' - imported by urllib3.contrib.emscripten.fetch (delayed, optional)
missing module named pyodide - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named js - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named 'h2.events' - imported by urllib3.http2.connection (top-level)
missing module named 'h2.connection' - imported by urllib3.http2.connection (top-level)
missing module named h2 - imported by urllib3.http2.connection (top-level)
missing module named zstandard - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named compression - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotli - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named dummy_threading - imported by requests.cookies (optional)
missing module named win_inet_pton - imported by socks (conditional, optional)
missing module named chardet - imported by requests (optional)
File diff suppressed because it is too large. Load diff
+315
View File
@@ -0,0 +1,315 @@
import base64
import os
import sys
import threading
import time
import tkinter as tk
from tkinter import messagebox, scrolledtext
# Ensure the SDK root is in PYTHONPATH to allow running demo.py standalone
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
from license_manager.client import Client, ClientOptions, Callbacks
from license_manager.config import Config
from license_manager.models import LicensePayload
from license_manager.hardware import DefaultHardwareProvider
class DemoApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("License Manager SDK Demo")
self.geometry("700x650")
self.client = None
# 提取当前设备指纹(仅做界面展示)
hw = DefaultHardwareProvider(["mac", "cpu", "hostname"])
try:
self.fingerprint, _ = hw.fingerprint()
except Exception:
self.fingerprint = "Unknown"
self._build_ui()
self._log("系统初始化完毕,准备加载授权...")
# 延迟100ms去试着加载本地授权
self.after(100, self.init_sdk_offline)
def _build_ui(self):
# 区域一:设备识别与状态显示牌
top_frame = tk.LabelFrame(self, text="设备指纹与当前授权状态", padx=10, pady=10)
top_frame.pack(fill="x", padx=10, pady=5)
tk.Label(top_frame, text="设备指纹 (Hardware ID):").grid(row=0, column=0, sticky="w")
self.entry_fp = tk.Entry(top_frame, width=35)
self.entry_fp.insert(0, self.fingerprint)
self.entry_fp.config(state="readonly")
self.entry_fp.grid(row=0, column=1, padx=5, pady=2)
tk.Button(top_frame, text="复制", command=self._copy_fp).grid(row=0, column=2, padx=5)
tk.Label(top_frame, text="授权状态:").grid(row=1, column=0, sticky="w", pady=(5, 0))
self.lbl_status = tk.Label(top_frame, text="未激活", fg="red", font=("Helvetica", 12, "bold"))
self.lbl_status.grid(row=1, column=1, sticky="w", pady=(5, 0))
tk.Label(top_frame, text="到期时间:").grid(row=2, column=0, sticky="w")
self.lbl_expire = tk.Label(top_frame, text="-")
self.lbl_expire.grid(row=2, column=1, sticky="w")
# 区域二:授权码输入与操作区
mid_frame = tk.LabelFrame(self, text="管控与激活设置", padx=10, pady=10)
mid_frame.pack(fill="x", padx=10, pady=5)
tk.Label(mid_frame, text="SaaS 服务地址:").grid(row=0, column=0, sticky="w", pady=2)
self.entry_server = tk.Entry(mid_frame, width=60)
self.entry_server.insert(0, "http://43.136.177.43:18081")
self.entry_server.grid(row=0, column=1, padx=5, pady=2)
tk.Label(mid_frame, text="RSA 公钥 (PEM):").grid(row=1, column=0, sticky="nw", pady=2)
self.txt_pubkey = scrolledtext.ScrolledText(mid_frame, width=58, height=4, font=("Consolas", 9))
self.txt_pubkey.insert("1.0", (
"-----BEGIN PUBLIC KEY-----\n"
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5hGz3hws+L2MUW7CaRQI\n"
"t7Vxk0KAdPBUuNdkJcs2kpgkDmiT/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCx\n"
"gN/5bKSeLTPXT0VvvSkfB5FUUQABHFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw\n"
"6oMDI7MHVRfRZTe3rTM3T0qi3Ed6qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iY\n"
"j8ZJP7iU2vKYvWIG9TNv0FK/dXQk7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhO\n"
"BumOLXXBdlwgCVtejBTqDmlQpiJYADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69sso\n"
"ZQIDAQAB\n"
"-----END PUBLIC KEY-----"
))
self.txt_pubkey.grid(row=1, column=1, padx=5, pady=2)
tk.Label(mid_frame, text="产品激活码:").grid(row=2, column=0, sticky="w", pady=2)
self.entry_code = tk.Entry(mid_frame, width=60)
self.entry_code.grid(row=2, column=1, padx=5, pady=2)
btn_frame = tk.Frame(mid_frame)
btn_frame.grid(row=3, column=0, columnspan=2, pady=10, sticky="w")
tk.Button(btn_frame, text="激活 / 验证", bg="#4CAF50", fg="white", width=15, command=self.on_activate_click).pack(side="left", padx=5)
tk.Button(btn_frame, text="清理本地许可缓存", width=15, command=self.on_clear_click).pack(side="left", padx=5)
# 区域三:业务模拟与底座运行日志
bot_frame = tk.LabelFrame(self, text="机器视觉核心业务模拟与日志", padx=10, pady=10)
bot_frame.pack(fill="both", expand=True, padx=10, pady=5)
self.btn_run_ai = tk.Button(bot_frame, text="[业务] 开始 AI 模板检测", bg="#2196F3", fg="white", font=("Helvetica", 14), command=self.on_run_ai_click)
self.btn_run_ai.pack(fill="x", pady=5)
self.txt_log = scrolledtext.ScrolledText(bot_frame, height=10, bg="black", fg="white")
self.txt_log.pack(fill="both", expand=True, pady=5)
def _copy_fp(self):
self.clipboard_clear()
self.clipboard_append(self.fingerprint)
messagebox.showinfo("复制成功", "机器指纹已复制")
def _log(self, msg: str):
now = time.strftime("%H:%M:%S")
self.txt_log.insert(tk.END, f"[{now}] {msg}\n")
self.txt_log.see(tk.END)
def _update_status(self, payload: LicensePayload):
if payload and payload.license_key:
self.lbl_status.config(text="验证通过 (合法)", fg="green")
exp_str = payload.expires_at.strftime("%Y-%m-%d %H:%M:%S") if payload.expires_at else "永久"
self.lbl_expire.config(text=exp_str)
else:
self.lbl_status.config(text="未激活 / 被锁定", fg="red")
self.lbl_expire.config(text="-")
def _safe_update_status(self, payload: LicensePayload):
self.after(0, self._update_status, payload)
def _safe_log(self, msg: str):
self.after(0, self._log, msg)
def _safe_alert(self, title, msg):
self.after(0, lambda: messagebox.showwarning(title, msg))
# --- SDK 钩子定义 ---
def on_license_updated(self, lic: LicensePayload):
self._safe_log(f"-> 钩子触发: [on_license_updated] 下发了有效的新证书!到期: {lic.expires_at}")
self._safe_update_status(lic)
def on_activation_required(self, reason: str):
self._safe_log(f"-> 钩子触发: [on_activation_required] 授权已被驳回/封禁!原因: {reason}")
self.after(0, lambda: self.lbl_status.config(text=f"锁定: {reason}", fg="red"))
self._safe_alert("授权已失效", f"您的授权已被服务器拒绝或锁定,请联系管理员。\n底层原因: {reason}")
def on_heartbeat_error(self, err: Exception):
msg = str(err)
self._safe_log(f"[后台轮询] 心跳无法连接服务器/遭遇异常: {msg}")
# 如果后台返回了明确的 300006 (许可证不存在),说明管理后台删除了授权或该机器被强制解绑
if "300006" in msg:
self._safe_log("[安全策略阻断] 服务端反馈许可不存在!您的授权已被撤销或转移。正在清理本地许可凭证...")
# 因为错误回调是在独立的守护线程里执行的,我们需要借用 after 以 UI 线程安全的方式清理并阻断
self.after(500, self.on_clear_click)
def on_heartbeat_ping(self):
self._safe_log(f"[后台轮询] 成功向服务器上报设备在线心跳!")
# --- 核心业务连贯逻辑 ---
def get_sdk_config(self, auth_code: str = "") -> Config:
server_url = self.entry_server.get().strip()
if not server_url:
server_url = "http://43.136.177.43:18081"
pub_key_str = self.txt_pubkey.get("1.0", tk.END).strip()
pub_key_bytes = pub_key_str.encode('utf-8') if pub_key_str else b""
return Config(
server=server_url,
product="vision-vm-studio",
version="1.0.0",
authorization_code=auth_code,
license_file_path="license.lic",
heartbeat_interval_seconds=10, # 演示专用:10秒一次高频心跳
public_key_pem=pub_key_bytes
)
def init_sdk_offline(self):
"""尝试不带激活码纯本地启动"""
self._log("尝试加载本地 'license.lic' 验证授权...")
cfg = self.get_sdk_config()
# 对于真正从硬盘拉取历史授权记录的“冷启动”:
# 我们不知道用户当时填的激活码是啥,只知道他本地有合法生成的证。
# 为了不破坏联网心跳能力(不设置 cfg.offline),我们塞入一个占位激活码让 Config 通过验证,
# 只要本地有合规授权,SDK Bootstrap 阶段就不会向服务器发起"空行"激活,而是直接拉起心跳。
cfg.authorization_code = "LOADED_FROM_LOCAL_CACHE"
def _boot():
try:
self._init_client_impl(cfg)
self._safe_log("-> 历史本地授权自动加载且验证通过!无需重复输入激活码。")
except Exception as e:
# 初次启动时如果没有激活码和本地授权文件是正常的,这里静默处理
msg = str(e)
if "authorization code" not in msg and "missing" not in msg and "no loaded license" not in msg:
self._safe_log(f"-> 本地授权预加载检测未通过: {msg}")
self.after(0, lambda: self._update_status(None))
threading.Thread(target=_boot, daemon=True).start()
def on_activate_click(self):
user_input = self.entry_code.get().strip()
if not user_input:
messagebox.showwarning("错误", "请输入产品激活码")
return
self._log(f"用户发起激活请求,识别码长度: {len(user_input)}")
cfg = self.get_sdk_config()
envelope_b64 = None
if "&" in user_input:
auth_part, envelope_b64 = user_input.split("&", 1)
cfg.authorization_code = auth_part
self._log("-> 识别到产品激活码中包含脱机包验证参数。")
else:
cfg.authorization_code = user_input
self._log("-> 识别为纯在线授权码。")
# 异步启动防止卡死 UI
threading.Thread(target=self._activate_logic_thread, args=(cfg, envelope_b64), daemon=True).start()
def _activate_logic_thread(self, cfg: Config, envelope_b64: str):
# 先清理本地遗留许可,强制优先走“在线激活”流程
if os.path.exists(cfg.license_file_path):
os.remove(cfg.license_file_path)
self._safe_log("-> [策略] 优先尝试连接 SaaS 平台进行在线激活/绑定...")
try:
self._init_client_impl(cfg)
self._safe_log("-> 在线激活成功!设备已绑定并在后台可见。")
return
except Exception as e:
msg = str(e)
self._safe_log(f"-> 在线激活遭遇异常/网络不通: {msg}")
# 如果存在脱机包,则降级为离线侧载
if envelope_b64:
self._safe_log("-> [策略] 检测到网络无法完成激活,正在自动降级使用脱机包进行完全物理隔离验证...")
try:
payload_bytes = base64.b64decode(envelope_b64)
with open(cfg.license_file_path, "wb") as f:
f.write(payload_bytes)
self._safe_log("-> 脱机包落地成功!正在二次验证...")
self._init_client_impl(cfg)
self._safe_log("-> 脱机验证成功!(系统此时完全离线运行)")
except Exception as ex:
self._safe_log(f"-> 脱机包降级验证失败: {ex}")
self.after(0, lambda: self._update_status(None))
else:
self._safe_log("-> 致命错误:因为没有脱机包且当前无法在线激活,授权流程被阻断。")
self.after(0, lambda: self._update_status(None))
def _init_client_impl(self, cfg: Config):
# 内部真实初始化 Client
# 实战中不建议这么做,此处为了 Demo 演示快节奏,强行忽略后台 SaaS 返回的 600s/3600s 下发间隔限制
from license_manager.models import HeartbeatResponse
original_from_dict = HeartbeatResponse.from_dict
def patched_from_dict(data: dict) -> HeartbeatResponse:
resp = original_from_dict(data)
resp.heartbeat_interval = 10 # 强制覆盖服务端配置
return resp
HeartbeatResponse.from_dict = staticmethod(patched_from_dict)
# 若是外部调用跑进此函数发生异常,会向外抛出被 _activate_logic_thread 捕获
if self.client:
self.client.close()
opts = ClientOptions(
callbacks=Callbacks(
on_license_updated=self.on_license_updated,
on_activation_required=self.on_activation_required,
on_heartbeat_error=self.on_heartbeat_error,
on_heartbeat_ping=self.on_heartbeat_ping
)
)
self.client = Client(cfg, opts=opts)
lic = self.client.current_license()
if lic:
self._safe_log(">>> SDK 验证成功 <<<")
self._safe_update_status(lic)
else:
self.after(0, lambda: self._update_status(None))
def on_clear_click(self):
if self.client:
self.client.close()
self.client = None
if os.path.exists("license.lic"):
os.remove("license.lic")
self._log("已物理删除 'license.lic' 缓存文件。")
else:
self._log("未发现 'license.lic' 文件。")
self._update_status(None)
def on_run_ai_click(self):
if not self.client:
messagebox.showerror("阻断", "SDK 未初始化,请先提供激活码!")
return
# 实时验证
try:
self.client.validate()
# 通过后模拟业务逻辑
self._log("【业务端】实时合规性检测: 通过。")
messagebox.showinfo("业务层", "授权正常!\nAI 模板匹配算法已成功启动,正在处理图像帧...")
except Exception as e:
self._log(f"【业务端】授权拦截: {e}")
messagebox.showerror("越权阻断", f"由于未授权或授权已被封禁,算法引擎拒绝启动。\n\n原因: {e}")
if __name__ == "__main__":
app = DemoApp()
app.mainloop()
@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['demo.py'],
pathex=['../../..'],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='demo',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+1
View File
@@ -0,0 +1 @@
{"algorithm":"RSA-PSS-SHA256","data":"{\"activated_at\":\"2026-02-27T01:11:00.938011568+08:00\",\"authorization_code\":\"LIC-207E-tEOWEY54X3tL-zSAA\",\"authorization_code_id\":\"939569ef-38c6-401a-8e26-7cf00e225ccd\",\"config_updated_at\":null,\"deployment_type\":\"cloud\",\"end_date\":\"3026-02-25T00:00:00+08:00\",\"generated_at\":\"2026-02-27T01:11:00+08:00\",\"hardware_fingerprint\":\"48aaf5fbd3aa4056462b94d30c38c83991acd46ae3631f0958c12432f862018b\",\"license_key\":\"LIC-DEVICE-E3CB989494260205500B37A3\",\"max_activations\":2,\"start_date\":\"2026-02-25T00:00:00+08:00\",\"status\":\"active\",\"usage_limits\":{\"type\":\"default\"}}","signature":"E+fFyhPa4b4UAPuBeTdctDINkG013f4hRbNhwaKPjMDUi7fLaPGxBgke7BnWLCt6fpWS3thTIv9HjhC6lpDzQe92Gkm/Suk52424zrVVkvjENvF7LopK0PMBpBA4Yx1zkbHwftxXg3Y0olqNakGXh8em9imGbH9AwTMMnAia9aCSN4dXqfgzWkfm0Nt+OynKnCDBijkOwNi6yToctSNO02t3+xQiBXmDut9H+3RqCNIESSsg9+4ILVBi76xpuYAxyEUksDnTFUu365NTmbeuEqLkYQtk0Pr3KE78xGBNd9AWAg5FTMn4A7pNKoo62GfWt7aSDIbgSzVj+Es1JtNZXw=="}
@@ -0,0 +1 @@
{"algorithm":"RSA-PSS-SHA256","data":"{\"activated_at\":\"2026-02-27T01:08:30.548730878+08:00\",\"authorization_code\":\"LIC-207E-tEOWEY54X3tL-zSAA\",\"authorization_code_id\":\"939569ef-38c6-401a-8e26-7cf00e225ccd\",\"config_updated_at\":null,\"deployment_type\":\"cloud\",\"end_date\":\"3026-02-25T00:00:00+08:00\",\"generated_at\":\"2026-02-27T01:08:30+08:00\",\"hardware_fingerprint\":\"48aaf5fbd3aa4056462b94d30c38c83991acd46ae3631f0958c12432f862018b\",\"license_key\":\"LIC-DEVICE-E3CB989494260205500B37A3\",\"max_activations\":2,\"start_date\":\"2026-02-25T00:00:00+08:00\",\"status\":\"active\",\"usage_limits\":{\"type\":\"default\"}}","signature":"WgnPQyNBaSvroZEuk4yy3IGXWCiIGBF9ADBHW24Sl61D0Jc5a9q73UiuQRu012cgApC4fKV+JeRft6msxUtsn0Dw39057N7mK196H8uIjWJ7Xy1rjC6Aceu/kD+BCRZ6K9t7dfnWWOGzz6ixljnsgfLmK/cDalhjpdopZHN1lr8TNUIIlQ/JKRMYr4HFgXlywbTW01/rkuZp6AYgSyZvxkNJW/TjdOCrncTkr4/LnkfJLaeqJ56P9PH1PEnDba5azzOH3OB9y2nN6+HsuIzOe78yxzTr+Nk9tVqTOGIbDOr9Ps2RPC/s6u8ot80frPJ1zprPWck7ZxAeTY82+1TUTw=="}
@@ -0,0 +1,75 @@
# 产品激活码解析与使用说明
本文档面向客户端开发人员,旨在说明 SaaS 平台发放给最终用户的“产品激活码”的数据结构及其在 SDK 中的鉴权原理。
## 什么是产品激活码?
用户在 SaaS 管理端购买或获取的凭证通常被称为**“产品激活码” (Product Activation Code)**。
为了满足工业场景中**纯离线/脱机**的鉴权需求,平台生成的产品激活码不仅仅是一个简单的随机字符串,它实际上是一个**复合字符串**,包含了在线激活所需的凭证以及离线放行所需的鉴权信息。
## 数据结构原理
产品激活码使用 `&` 符号作为分隔符,分为两部分:
```text
[授权码 (Authorization Code)] & [离线鉴权信息 (License Envelope Base64)]
```
### 示例解析
假设用户输入了以下产品激活码:
`LIC-207E-tEOWEY54X3tL-zSAA&eyJhbGdv... (后略)`
1. **`&` 之前的部分:授权码 (Authorization Code)**
- **内容**: `LIC-207E-tEOWEY54X3tL-zSAA`
- **作用**: 这是用于**在线激活**的凭证。当终端设备处于**联网**状态时,客户端 SDK 的 `Activate` 接口会使用此字符串向服务端发起请求。服务端会进行校验,扣减装机配额,并与该机器的硬件指纹进行绑定,最终全自动下发专属于这台机器的许可证文件。
2. **`&` 之后的部分:鉴权信息 (Authentication Information)**
- **内容**: `eyJhbGdv...` (这是一个经过 Base64 编码的 JSON 字符串,即 `License Envelope`)
- **作用**: 这是用于**离线验证**的凭证包。当终端设备处于**完全无网/隔离部署**的状态,无法连接到远程 SaaS 平台完成激活时,这就是客户端的“越权通行证”。这个 Base64 结构内部包含了被服务端 RSA 私钥数字签名过的数据(如过期时间、最高并发台数等配额信息)。
## 客户端接入与解析伪代码
客户端开发者在获取到用户框输入的“产品激活码”时,只需要做一个简单的字符串分割处理,将其分离为在线激活部分和离线兜底部分,再交给 SDK。
```python
import base64
import os
from license_manager.client import Client, ClientOptions
from license_manager.config import Config
# 这是用户填入的产品激活码
product_activation_code = "LIC-xxxx&eyJhbGdo..."
# 默认将其视作 Auth_Code
auth_code = product_activation_code
offline_auth_data = None
# 【第一步:解析分离】判断是否存在离线鉴权信息
if "&" in product_activation_code:
parts = product_activation_code.split("&", 1)
auth_code = parts[0] # 真正的在线授权码
offline_auth_data = parts[1] # 离线鉴权数据 (Envelope)
cfg = Config(
server="https://xxx.com",
product="you_product",
version="1.0.0",
authorization_code=auth_code, # 将分离出的 Auth_Code 交给 SDK
)
# 【第二步:离线侧载】如果存在离线数据,且本地尚无合法的证书,直接将其落地
if offline_auth_data:
# 直接将 SaaS 发放的公版离线鉴权包保存到目标证书路径
# SDK 初始化时会自动读取、验签、生效,从而实现"物理断网"状态下的秒级试用体验
with open(cfg.license_file_path, "wb") as f:
f.write(base64.b64decode(offline_auth_data))
# 【第三步:启动 SDK】
# SDK 会自动优先寻找本地证书(即我们刚刚侧载进去的离线鉴权信息)
# 若本地校验合法将直接启动;若机器具备网络环境,自带的 Heartbeat 还会自动联网嗅探对齐状态!
client_facade = Client(cfg, opts=ClientOptions(...))
```
## 运行机制总结
这种 `授权码 & 鉴权信息` 的二合一产品激活码设计,极大降低了对用户的解释成本。用户只需复制这一个字符串,无论他们身处有网络的办公室还是物理隔离的车间产线,开发者开发的软件结合 License SDK 都可以做到“离线直接跑、在线强管控”。
@@ -0,0 +1,128 @@
# 客户案例:机器视觉检测软件的灵活授权方案
## 客户背景
客户研发了一款基于深度学习技术的高性能机器视觉应用软件。该软件专为工业制造场景设计,能够准确识别工件表面的细小划痕、斑点,并精确检测工件的形态和轮廓缺陷,可有效克服复杂纹理、毛刺、颜色和各种噪点的干扰。此外,产品内置了 VM 算法开发平台,提供图像分割、分类、模板检测、字符定位与识别、异常检测等算法模块,支持独立训练工具以高效完成打标和训练。
**商业模式:**
- 软件单设备许可售价约为 300 元,定价亲民。
- 由于现代工业环境大部分已经实现联网,且目标客户群体(工厂、集成商等)大多具有付费能力,客户希望通过极具吸引力的“免费体验”策略来进行广泛的产品线宣传。
- 同时客户希望建立一套完整的生态,通过自服务的 **SaaS 授权管理平台**,直接完成交易、授权发放、设备分配甚至额度流转功能。
## 核心需求与业务痛点
1. **具有时效性的免费下载试用**:允许用户免费下载获取试用**产品激活码**,但该验证仅限定在每个月的 **1号 至 25号** 之间才能被验证使用。
2. **多终端授权统管与自助解绑机制**:
- 用户在 SaaS 用户端可以直接线上购买**产品激活码**,**一个产品激活码可包含多个设备数量许可**。
- 可以在规定的终端许可数量内激活不同机台。对于已激活的设备,**若绑定时间不满 3 个月,允许用户在客户端/SaaS端自助解绑以更换老旧机器**;若绑定超过 3 个月,强制转入人工审计,必须联系官方客服解绑。
- 未使用的授权额度甚至 **可以由用户转让给其他用户**,这一特性变相也成为了一种社交化裂变宣传的方式。
3. **彻底的断网可用性支持 (Offline Side-loading)**:
- 工业环境中存在纯隔离内网,客户需要**即便在完全不联网的机器上也能立即使用产品激活码**完成试用和投产。
- **巧妙的离线鉴权设计**:用户在管理平台拿到的是包含授权与鉴权的**产品激活码**。对于无网环境,激活码采用特殊格式:在纯授权码后拼接 `&` 及一个 Base64 编码的签名数据(即鉴权信息:完整的许可证 Envelope),形如:
`LIC-207E-tEOWEY54X3tL-zSAA&eyJhbGdv...`
- 当终端软件完全无法联网进行 `Activate` 获取凭证时,要求软件能自动截取 `&` 后的鉴权信息,当作合法的离线许可证,直接通过公钥鉴权放行。
4. **一旦联网即刻校验的混合模型**:优先保证离线可用;但**一旦侦测到设备联网,就必须立刻触发在线激活和心跳上报机制**。如果服务端判定该激活状态存在异常(如已被官方封禁、吊销、超出装机数),需强制清除本地授权并要求重新输入新的**产品激活码**。
5. **轻量接入(业务侵入低)**:客户原业务流程为:“程序打开后调用一次授权检测(返回 OK/NG及对应信息),然后程序内设有一个独立线程,每隔 10/30 分钟做一次授权检测。程序里支持输入**产品激活码**执行在线/离线激活。”客户希望这套机制不仅能被满足,还要开发门槛极低。
## 解决方案设计
基于 `license-manager-sdk` 及配合 SaaS 管理端,我们为客户设计了无缝接入的如下体系:
1. **时效及并发管控交由平台**:利用 License Manager 服务端后台,可以轻松发行为“一码多端”赋能的带有指定有效期(如自然月 1-25日)的复合**产品激活码**,且配合解绑策略 API 完美实现 3 个月审计风控。
2. **“开箱即用”的高级功能封装**:
- **指纹绑定防篡改**:SDK 内建的 `HardwareProvider` 会自动提取设备指纹(MAC/CPU/内存),在线下发许可证或离线颁发时均强制与该硬件绑定,阻断倒卖。
- **灵活的脱机解析 (Offline Side-loading)**:对于含有 `&` 和离线数据的后缀,开发者仅需在获取到用户输入字符串时做一个简单的 `split("&")`。如果存在第二部分,则直接将其传入 SDK 存储层 `Store.save(data)` 落盘。后续直接调用 `Validate`,SDK 内部无状态的 RSA `Validator` 即刻放行,从而在**物理网线拔除的状态下,软件也能顺利投产**。
3. **“先离线侧载,一旦联网则握手对齐”与混合心跳策略 (Client Facade)**:
- **启动阶段**:无论前置条件如何,调用 `NewClient` 加密恢复本地缓存并尝试 `Validate`。如果是通过 `&` 方式侧载进去的凭据,这一步将秒解脱机痛点。
- **后台轮询与联网嗅探**:对于“每隔 10/30 分钟轮询”的任务,初始化 `Client` 时启动 SDK 防阻塞心跳即可。当机器意外接入互联网时,心跳请求将送达服务端。如果此时服务端审查出该离线证存在套用、超额、甚至违规转让的情况,服务将拒绝心跳并下达指令。
4. **状态回调与异常自清理机制 (Callbacks)**:
- 利用 `on_activation_required` 钩子,当后台心跳侦测到服务端因为状态异常拒绝提供新票据时,钩子实时触发。客户只需在此处弹出一个悬浮窗,清除主界面逻辑即可,形成从脱机到联网的完美威慑闭环。
## 实施过程示例
基于新增的纯离线后缀导入要求,客户的初始化代码(Python 版示意)只需添加极少量几行处理逻辑即可兼容两大场景:
```python
import base64
from license_manager.client import Client, ClientOptions, Callbacks
from license_manager.config import Config
# 第一步:定义 SDK 后台处理的独立回调事件
def on_license_updated(lic):
print("后台心跳成功接通服务器,不仅保持了在线监控,还顺延了过期时间。")
def on_activation_required(reason):
print(f"警告:心跳发现此授权被封禁(可能是超额或异地重发),立即锁死软件UI!原因: {reason}")
# 强制弹出要求输入授权码及在线激活的 UI 界面,或者强制软件退出
callbacks = Callbacks(
on_license_updated=on_license_updated,
on_activation_required=on_activation_required
)
# 模拟用户在安装程序框填入的“产品激活码”(带 & 后缀包含鉴权信息)
user_input = "LIC-207E-tEOWEY54X3tL-zSAA&eyJhbGdv..."
product_activation_code = user_input
auth_code = product_activation_code
offline_envelope_payload = None
if "&" in product_activation_code:
# 利用产品激活码的特性剥离纯在线授权码与离线鉴权 JSON 包
parts = product_activation_code.split("&", 1)
auth_code = parts[0]
offline_envelope_payload = parts[1]
# 第二步:配置基础信息
cfg = Config(
server="https://customers-license-server.example.com",
product="vision-vm-studio",
version="1.0.0",
authorization_code=auth_code,
heartbeat_interval_seconds=1800, # 配合客户要求 30 分钟轮询
)
# 如果有离线 Envelope 侧载包(且当前本地尚无 license),将其预先存入文件
opts = ClientOptions(callbacks=callbacks)
client_facade = None
try:
if offline_envelope_payload: # 当场验证
# 在正式初始化前,预先落盘离线凭证包
import os
from license_manager.storage import FileStore
import json
# 为了演示,通过简单 decode 处理获取裸的数据落盘
# (这里客户可在初始化前直接覆写 config 中的 target 文件)
with open(cfg.license_file_path, "wb") as f:
f.write(base64.b64decode(offline_envelope_payload))
# 第三步:实例化客户端(硬件提取、本地验签恢复、以及心跳线程启动 自动完成)
client_facade = Client(cfg, opts=opts)
# 获取详细授权,展示UI
current_lic = client_facade.current_license()
print(f"初次打开授权检测:OK - 可以进入机器视觉主界面开展检测任务 (最大同时终端数: {current_lic.extras.get('max_activations', 1)})")
except Exception as e:
print(f"初次打开授权检测:NG - 未检测到合法授权或脱机鉴权失败: {e}")
```
## 客户反馈
客户研发负责人在发版后给出了极高的评价:
> “引入 SDK 和 SaaS 管理端之后,我们实现了授权码的一码多端支持以及极为巧妙的用户配额转让与自助解绑制度。**这一套 3 个月的自助风控简直极大减轻了我们客服的压力,而用户对自由转让闲余设备额度也表现出了浓厚的兴趣。**最棒的一点是,针对纯离线的厂区机器,那段拼接了 `&` 和签名 JSON 的魔术字符串直接绕开了传统必须让用户提报硬件机器码、等我们生成许可并人工发邮件回去对拷证书的繁琐。直接复制粘贴,秒下场干活脱机鉴定——一旦设备联网违规,客户端拦截也能立即锁喉,这真正做到了防白嫖和获客的平衡。”
## 设计哲学与防破解的现实主义思考 (深度剖析)
面对这样一个允许“脱机鉴权”又允许“白嫖试用”、定价仅为 300 元左右的混合验证方案,很多开发者第一直觉是:**“如果有人永远彻底断网运行这个软件,然后伪造机器特征去刷带 `&` 的激活码包,它防得住百分百的破解和白嫖吗?”**
答案是防不住绝对的黑客,但**在此业务场景下,这正是客户极其高明且克制的架构考量,它完全契合实际的工业化商业运作逻辑:**
1. **绝对离线环境在当代工业界的假象化**:
- 绝大多数工厂的机台,尤其是应用机器视觉这种需要上传质检报表并与 MES (制造执行系统) / ERP 打通的机器,**必然要连接厂内局域网或外部专网**。
- 这意味着设备长期处于“绝对物理隔离且无网”的概率越来越低。一旦联网,心跳钩子会在几分钟内抓取异常许可并锁定。
2. **“防君子不防小人”的用户心理博弈**:
- 企业级用户(特别是工厂买办和系统集成商)的心理是:只要官方给出一个清晰、名正言顺的购买和核查渠道,且价格(300元)远低于找破解版所承担的法律风险、版本维护成本和潜在勒索病毒风险,绝大多数企业极度乐于花这笔“小财”买个官方的正品安心。
- 商业软件保护的终极目的**从来不是杜绝所有逆向工程,而是极大地提高白嫖的门槛和心理成本**,促使 95% 以上具备支付能力的人觉得“花 300 块钱买个合法授权和转让自由,比到处找断网机刷盗版划算太多了”。
3. **“漏洞”本身就是极佳的病毒式营销**:
- 客户这种带有转让属性、时效性的策略,在某种程度上甚至是“刻意留有余地”。小规模的破解者或是教育机即便破解了不联网使用,反而极大增加了该软件在算法工程师和底层工区内的市占率和习惯依赖。
- 一旦这些人走上带有大规模生产网络的成熟产线接下商业订单,他们最先采购的,必然是此时用得最顺手的正版授权。
## 总结
此机器视觉案例充分证实了采用 SDK 与 License Manger 平台组合带来的威力:它不仅实现了精细至季度的设备生命周期管控,更是实现了对线下物理隔离场景的前卫处理。通过巧妙拼接带签名的包围体,充分运用 SDK 本地的 RSA Validator 来达到无需任何多重校验的离线“秒放行”,为工业场景的软件授权探索出了低侵入但高防范的实施样板。真正做到了以商业效率为先导,而非陷入无效“自嗨式反破解”的泥潭。