sdpy-kit 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/PKG-INFO +2 -1
  2. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/__init__.py +1 -1
  3. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/config/log_config.py +42 -5
  4. sdpy_kit-0.4.2/kit/license_kit.py +524 -0
  5. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/pyproject.toml +2 -0
  6. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/.gitignore +0 -0
  7. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/README.md +0 -0
  8. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/__init__.py +0 -0
  9. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/base_service.py +0 -0
  10. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/chat_service.py +0 -0
  11. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/embedding_service.py +0 -0
  12. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/exceptions.py +0 -0
  13. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/failover.py +0 -0
  14. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/image_service.py +0 -0
  15. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/model_builder.py +0 -0
  16. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/model_client.py +0 -0
  17. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/pool.py +0 -0
  18. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/profiles.py +0 -0
  19. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/rerank_service.py +0 -0
  20. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/runner.py +0 -0
  21. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/ai/types.py +0 -0
  22. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/anydoc_kit.py +0 -0
  23. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/common_kit.py +0 -0
  24. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/config/__init__.py +0 -0
  25. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/config/kit_config.py +0 -0
  26. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/crawl/__init__.py +0 -0
  27. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/crawl/article_models.py +0 -0
  28. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/crawl/web_article_kit.py +0 -0
  29. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/crawl/wx_article_kit.py +0 -0
  30. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/__init__.py +0 -0
  31. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/base_db_kit.py +0 -0
  32. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/crud.py +0 -0
  33. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/engine.py +0 -0
  34. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/exceptions.py +0 -0
  35. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/models/__init__.py +0 -0
  36. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/models/base.py +0 -0
  37. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/mysql_kit.py +0 -0
  38. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/pg_kit.py +0 -0
  39. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/query.py +0 -0
  40. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/session.py +0 -0
  41. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/sqlite_kit.py +0 -0
  42. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/db/transaction.py +0 -0
  43. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/docx_kit.py +0 -0
  44. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/file_kit.py +0 -0
  45. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/laya_kit.py +0 -0
  46. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/marker_kit.py +0 -0
  47. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/md2doc_kit.py +0 -0
  48. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/mineru_kit.py +0 -0
  49. {sdpy_kit-0.4.0 → sdpy_kit-0.4.2}/kit/redis_kit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -17,6 +17,7 @@ Classifier: Topic :: Database
17
17
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.13
20
+ Requires-Dist: cryptography>=46.0.7
20
21
  Requires-Dist: pydantic-settings>=2.15.0
21
22
  Requires-Dist: pydantic>=2.14.1
22
23
  Requires-Dist: sqlalchemy~=2.0.54
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.4.0"
26
+ __version__ = "0.4.2"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -24,6 +24,7 @@ Author: Clark Chang
24
24
  import logging
25
25
  import os
26
26
  import sys
27
+ import threading
27
28
  from logging.handlers import RotatingFileHandler
28
29
 
29
30
  from kit.config.kit_config import get_settings
@@ -49,6 +50,43 @@ def ensure_log_dir(log_dir: str) -> None:
49
50
  os.makedirs(log_dir, exist_ok=True)
50
51
 
51
52
 
53
+ # 同一日志文件路径共享单例文件 handler:
54
+ # 进程内该文件仅一个句柄,避免 Windows 下多句柄占用导致轮转必失败(WinError 32)。
55
+ # 参数策略 first-wins:maxBytes / backupCount / formatter 以首个创建者为准。
56
+ _file_handlers: dict[str, RotatingFileHandler] = {}
57
+ _file_handlers_lock = threading.Lock()
58
+
59
+
60
+ def _file_handler_cache_key(log_file_path: str) -> str:
61
+ """计算共享 handler 缓存 key(归一 Windows 大小写、分隔符与相对路径)"""
62
+ return os.path.normcase(os.path.abspath(log_file_path))
63
+
64
+
65
+ def _get_shared_file_handler(
66
+ log_file_path: str, max_bytes: int, backup_count: int, formatter: logging.Formatter
67
+ ) -> RotatingFileHandler:
68
+ """获取指定日志文件路径的共享文件 handler(不存在则创建)"""
69
+ key = _file_handler_cache_key(log_file_path)
70
+ with _file_handlers_lock:
71
+ handler = _file_handlers.get(key)
72
+ if handler is None:
73
+ handler = RotatingFileHandler(
74
+ log_file_path,
75
+ maxBytes=max_bytes,
76
+ backupCount=backup_count,
77
+ encoding="utf-8",
78
+ )
79
+ handler.setFormatter(formatter)
80
+ _file_handlers[key] = handler
81
+ return handler
82
+
83
+
84
+ def _reset_file_handlers() -> None:
85
+ """复位共享 handler 缓存(仅供测试隔离使用,不进 __all__)"""
86
+ with _file_handlers_lock:
87
+ _file_handlers.clear()
88
+
89
+
52
90
  def setup_logger(
53
91
  name: str | None = None,
54
92
  log_level: str | None = None,
@@ -77,13 +115,12 @@ def setup_logger(
77
115
  if settings.LOG_TO_FILE:
78
116
  ensure_log_dir(settings.LOG_DIR)
79
117
  log_file_path = log_file or os.path.join(settings.LOG_DIR, settings.LOG_FILE)
80
- file_handler = RotatingFileHandler(
118
+ file_handler = _get_shared_file_handler(
81
119
  log_file_path,
82
- maxBytes=max_bytes,
83
- backupCount=backup_count,
84
- encoding="utf-8",
120
+ max_bytes=max_bytes,
121
+ backup_count=backup_count,
122
+ formatter=formatter,
85
123
  )
86
- file_handler.setFormatter(formatter)
87
124
  logger.addHandler(file_handler)
88
125
 
89
126
  return logger
@@ -0,0 +1,524 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ # date: 2026-10-16
21
+ """license 授权工具类:机器指纹 + 离线签名授权文件(Ed25519)
22
+
23
+ 面向单机分发场景的轻量授权体系(防君子定位):应用以打包 exe 形式离线分发,
24
+ 无远端授权服务。工作流:
25
+
26
+ 1. 客户机 `export_fingerprint_file()` 导出 `.fp` 指纹文件;
27
+ 2. 签发方 `parse_fingerprint_file()` 解析,填入 `LicensePayload` 后
28
+ `sign()` 生成 `.lic` 授权文件回签;
29
+ 3. 客户机 `LicenseVerifier.verify()` 验签 + 绑定校验(product / 机器码 / 有效期)。
30
+
31
+ 安全边界(务必知悉):
32
+
33
+ - 非对称体系安全性**仅依赖私钥保密**;本模块代码(含 `sign`)公开可读无碍;
34
+ - 离线体系无法吊销永久授权;订阅场景靠 `expires_at` 到期不续签实现"软吊销";
35
+ - `expires_at` 依赖系统时钟,回拨可延长——防君子定位下接受,不做时钟防护;
36
+ - 指纹 = MachineGuid + 系统盘卷序列号组合短码:VM 克隆镜像会复制指纹,
37
+ 重装系统需重新授权;
38
+ - 私钥丢失 = 全部已发授权作废,务必备份(换钥需重发全部授权)。
39
+
40
+ 时间约定:payload 的时间字段一律 **aware UTC**(naive 直接拒绝),
41
+ 规范化 JSON 序列化后签名,杜绝字段顺序 / 空白差异导致验签漂移。
42
+
43
+ 用法:
44
+ from kit.license_kit import (
45
+ LicensePayload, LicenseVerifier, export_fingerprint_file,
46
+ generate_keypair, parse_fingerprint_file, sign,
47
+ )
48
+
49
+ private_pem, public_pem = generate_keypair()
50
+ payload = LicensePayload(product="myapp", machine_code="0123456789abcdef",
51
+ issued_at=datetime.now(timezone.utc),
52
+ expires_at=None)
53
+ lic_text = sign(payload, private_pem)
54
+
55
+ verifier = LicenseVerifier(public_pem, Path("license.lic"))
56
+ info = verifier.verify(product="myapp", machine_code="0123456789abcdef")
57
+ """
58
+
59
+ import base64
60
+ import hashlib
61
+ import json
62
+ import re
63
+ import sys
64
+ from datetime import datetime, timezone
65
+ from pathlib import Path
66
+
67
+ from cryptography.exceptions import InvalidSignature
68
+ from cryptography.hazmat.primitives import serialization
69
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
70
+ Ed25519PrivateKey,
71
+ Ed25519PublicKey,
72
+ )
73
+ from pydantic import BaseModel, field_validator
74
+
75
+ from kit.config.log_config import get_default_logger
76
+
77
+ logger = get_default_logger(__name__)
78
+
79
+ # 指纹文件格式版本(结构变更时递增;parse 只接受 <= 当前版本)
80
+ FINGERPRINT_FORMAT_VERSION = 1
81
+ # license 载荷格式版本(载荷结构变更时递增;verify 只接受当前版本)
82
+ LICENSE_PAYLOAD_VERSION = 1
83
+ # 机器码短码长度(SHA256 截断,16 hex = 64bit,单机场景碰撞可忽略)
84
+ MACHINE_CODE_LENGTH = 16
85
+ # 指纹文件标识(parse 时强校验,防止拿错文件)
86
+ _FINGERPRINT_KIND = "sdpy-fingerprint"
87
+
88
+ _MACHINE_GUID_KEY_PATH = r"SOFTWARE\Microsoft\Cryptography"
89
+ _MACHINE_GUID_VALUE_NAME = "MachineGuid"
90
+
91
+ _HEX16_RE = re.compile(r"^[0-9a-f]{16}$")
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # 异常体系(独立基线,不挂 DBError——license 非 db 域)
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ class LicenseError(Exception):
100
+ """license 域异常基类"""
101
+
102
+
103
+ class LicenseUnsupportedPlatformError(LicenseError):
104
+ """当前平台不支持指纹采集(指纹实现仅覆盖 Windows)"""
105
+
106
+
107
+ class LicenseFormatError(LicenseError):
108
+ """文件损坏 / JSON 结构不合法 / 格式版本不识别 / 密钥类型不符"""
109
+
110
+
111
+ class LicenseSignatureError(LicenseError):
112
+ """Ed25519 签名验证失败(文件被篡改或公钥不匹配)"""
113
+
114
+
115
+ class LicenseProductMismatchError(LicenseError):
116
+ """product 与验签方期望值不符(防跨项目重放)"""
117
+
118
+
119
+ class LicenseMachineMismatchError(LicenseError):
120
+ """machine_code 与本机指纹不符(授权被拷贝到其他机器)"""
121
+
122
+
123
+ class LicenseExpiredError(LicenseError):
124
+ """授权已过期(expires_at 早于当前时间)"""
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # 机器指纹(Windows 专属;winreg/ctypes 延迟导入保证模块可在任意平台 import)
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def _read_machine_guid() -> str:
133
+ """读注册表 MachineGuid(重装系统才变化的主机标识)"""
134
+ import winreg
135
+
136
+ with winreg.OpenKey(
137
+ winreg.HKEY_LOCAL_MACHINE, _MACHINE_GUID_KEY_PATH
138
+ ) as key:
139
+ value, _ = winreg.QueryValueEx(key, _MACHINE_GUID_VALUE_NAME)
140
+ guid = str(value).strip()
141
+ if not guid:
142
+ raise LicenseFormatError("注册表 MachineGuid 为空")
143
+ return guid
144
+
145
+
146
+ def _read_volume_serial() -> str:
147
+ """读系统盘卷序列号(GetVolumeInformationW,十进制字符串)"""
148
+ import ctypes.wintypes
149
+ import os
150
+
151
+ drive = os.environ.get("SystemDrive", "C:") + "\\"
152
+ serial = ctypes.wintypes.DWORD(0)
153
+ # GetVolumeInformationW 只需要根路径与序列号出参,其余参数可传占位
154
+ ok = ctypes.windll.kernel32.GetVolumeInformationW(
155
+ ctypes.c_wchar_p(drive),
156
+ None,
157
+ 0,
158
+ ctypes.byref(serial),
159
+ None,
160
+ None,
161
+ None,
162
+ 0,
163
+ )
164
+ if not ok or serial.value == 0:
165
+ raise LicenseFormatError(f"读取系统盘卷序列号失败:{drive}")
166
+ return str(serial.value)
167
+
168
+
169
+ def _collect_components() -> tuple[str, str]:
170
+ """采集原始指纹组件,返回 (machine_guid, volume_serial);非 Windows 抛错"""
171
+ if sys.platform != "win32":
172
+ raise LicenseUnsupportedPlatformError(
173
+ f"机器指纹采集仅支持 Windows,当前平台:{sys.platform}"
174
+ )
175
+ return _read_machine_guid(), _read_volume_serial()
176
+
177
+
178
+ def get_machine_code() -> str:
179
+ """计算本机机器码:组合指纹 SHA256 短码(16 hex 小写)
180
+
181
+ Returns:
182
+ str: 16 位十六进制机器码
183
+
184
+ Raises:
185
+ LicenseUnsupportedPlatformError: 非 Windows 平台
186
+ LicenseFormatError: 指纹组件读取失败
187
+ """
188
+ machine_guid, volume_serial = _collect_components()
189
+ digest = hashlib.sha256(f"{machine_guid}|{volume_serial}".encode("utf-8"))
190
+ return digest.hexdigest()[:MACHINE_CODE_LENGTH]
191
+
192
+
193
+ def export_fingerprint_file(path: Path) -> Path:
194
+ """导出指纹文件(.fp):机器码 + 原始组件 + 导出时间
195
+
196
+ 原始组件仅用于报障诊断(作者侧可看出哪个组件不匹配),验签只比短码。
197
+
198
+ Args:
199
+ path: 指纹文件输出路径
200
+
201
+ Returns:
202
+ Path: 实际写入的文件路径
203
+
204
+ Raises:
205
+ LicenseUnsupportedPlatformError: 非 Windows 平台
206
+ LicenseFormatError: 指纹组件读取失败
207
+ """
208
+ machine_guid, volume_serial = _collect_components()
209
+ machine_code = hashlib.sha256(
210
+ f"{machine_guid}|{volume_serial}".encode("utf-8")
211
+ ).hexdigest()[:MACHINE_CODE_LENGTH]
212
+ document = {
213
+ "kind": _FINGERPRINT_KIND,
214
+ "format_version": FINGERPRINT_FORMAT_VERSION,
215
+ "machine_code": machine_code,
216
+ "components": {
217
+ "machine_guid": machine_guid,
218
+ "volume_serial": volume_serial,
219
+ },
220
+ "exported_at": datetime.now(timezone.utc).isoformat(),
221
+ }
222
+ target = Path(path)
223
+ target.parent.mkdir(parents=True, exist_ok=True)
224
+ target.write_text(
225
+ json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8"
226
+ )
227
+ logger.info("指纹文件已导出:%s(机器码 %s)", target, machine_code)
228
+ return target
229
+
230
+
231
+ def parse_fingerprint_file(path: Path) -> str:
232
+ """解析指纹文件,返回机器码(签发侧使用;跨机器无法重算原文,只校验结构)
233
+
234
+ Args:
235
+ path: 指纹文件路径
236
+
237
+ Returns:
238
+ str: 16 位十六进制机器码
239
+
240
+ Raises:
241
+ LicenseFormatError: 文件缺失 / JSON 损坏 / 标识或版本不符 / 机器码格式非法
242
+ """
243
+ source = Path(path)
244
+ if not source.is_file():
245
+ raise LicenseFormatError(f"指纹文件不存在:{source}")
246
+ try:
247
+ document = json.loads(source.read_text(encoding="utf-8"))
248
+ except (OSError, json.JSONDecodeError) as exc:
249
+ raise LicenseFormatError(f"指纹文件读取或解析失败:{exc}") from exc
250
+ if not isinstance(document, dict):
251
+ raise LicenseFormatError("指纹文件结构不合法(顶层应为 JSON 对象)")
252
+ if document.get("kind") != _FINGERPRINT_KIND:
253
+ raise LicenseFormatError("指纹文件标识不符(kind)")
254
+ version = document.get("format_version")
255
+ if not isinstance(version, int) or version > FINGERPRINT_FORMAT_VERSION:
256
+ raise LicenseFormatError(f"指纹文件格式版本不识别:{version!r}")
257
+ machine_code = document.get("machine_code")
258
+ if not isinstance(machine_code, str) or not _HEX16_RE.fullmatch(machine_code):
259
+ raise LicenseFormatError("指纹文件缺少合法机器码(16 hex)")
260
+ return machine_code
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # 密钥
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ def generate_keypair() -> tuple[str, str]:
269
+ """生成 Ed25519 密钥对(一次性初始化用)
270
+
271
+ Returns:
272
+ tuple[str, str]: (private_pem, public_pem),均为 PEM 文本;
273
+ 私钥务必本机妥善保管(丢失 = 已发授权全部作废)
274
+ """
275
+ private_key = Ed25519PrivateKey.generate()
276
+ private_pem = private_key.private_bytes(
277
+ encoding=serialization.Encoding.PEM,
278
+ format=serialization.PrivateFormat.PKCS8,
279
+ encryption_algorithm=serialization.NoEncryption(),
280
+ ).decode("utf-8")
281
+ public_pem = private_key.public_key().public_bytes(
282
+ encoding=serialization.Encoding.PEM,
283
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
284
+ ).decode("utf-8")
285
+ return private_pem, public_pem
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # 载荷与签名(格式唯一事实源——签发与验签共用本模块实现)
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ class LicensePayload(BaseModel):
294
+ """授权载荷(license 文件中承载的业务信息)"""
295
+
296
+ version: int = LICENSE_PAYLOAD_VERSION
297
+ product: str # 应用标识,防跨项目重放
298
+ machine_code: str # 授权绑定的机器码(16 hex)
299
+ tier: str = "full" # 档位,语义由应用侧定义
300
+ customer_id: str | None = None # 可选,便于售后检索
301
+ issued_at: datetime # 签发时间(aware UTC)
302
+ expires_at: datetime | None = None # 过期时间;None = 买断永久
303
+
304
+ @field_validator("version")
305
+ @classmethod
306
+ def _check_version(cls, value: int) -> int:
307
+ if value != LICENSE_PAYLOAD_VERSION:
308
+ raise ValueError(f"不支持的载荷版本:{value}")
309
+ return value
310
+
311
+ @field_validator("product")
312
+ @classmethod
313
+ def _check_product(cls, value: str) -> str:
314
+ if not value.strip():
315
+ raise ValueError("product 不能为空")
316
+ return value
317
+
318
+ @field_validator("machine_code")
319
+ @classmethod
320
+ def _check_machine_code(cls, value: str) -> str:
321
+ normalized = value.strip().lower()
322
+ if not _HEX16_RE.fullmatch(normalized):
323
+ raise ValueError("machine_code 必须为 16 位十六进制短码")
324
+ return normalized
325
+
326
+ @field_validator("issued_at", "expires_at")
327
+ @classmethod
328
+ def _require_aware_utc(cls, value: datetime | None) -> datetime | None:
329
+ if value is None:
330
+ return None
331
+ if value.tzinfo is None:
332
+ raise ValueError("时间字段必须为 aware datetime(拒绝 naive)")
333
+ return value.astimezone(timezone.utc)
334
+
335
+
336
+ def _canonical_bytes(payload: dict) -> bytes:
337
+ """规范化 JSON 字节:键排序 + 紧凑分隔符 + UTF-8(签发/验签共用)"""
338
+ return json.dumps(
339
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
340
+ ).encode("utf-8")
341
+
342
+
343
+ def sign(payload: LicensePayload, private_key_pem: str) -> str:
344
+ """对载荷签名,返回 license 文件内容(JSON 文本,即 .lic)
345
+
346
+ Args:
347
+ payload: 授权载荷
348
+ private_key_pem: Ed25519 私钥 PEM(PKCS8)
349
+
350
+ Returns:
351
+ str: license 文件内容 `{"payload": {...}, "signature": "<b64>"}`
352
+
353
+ Raises:
354
+ LicenseFormatError: 私钥解析失败或类型不是 Ed25519
355
+ """
356
+ try:
357
+ private_key = serialization.load_pem_private_key(
358
+ private_key_pem.encode("utf-8"), password=None
359
+ )
360
+ except (ValueError, TypeError) as exc:
361
+ raise LicenseFormatError(f"私钥 PEM 解析失败:{exc}") from exc
362
+ if not isinstance(private_key, Ed25519PrivateKey):
363
+ raise LicenseFormatError("私钥类型不是 Ed25519")
364
+ payload_dict = payload.model_dump(mode="json")
365
+ signature = private_key.sign(_canonical_bytes(payload_dict))
366
+ document = {
367
+ "payload": payload_dict,
368
+ "signature": base64.b64encode(signature).decode("ascii"),
369
+ }
370
+ logger.info(
371
+ "license 已签发:product=%s machine_code=%s",
372
+ payload.product,
373
+ payload.machine_code,
374
+ )
375
+ return json.dumps(document, ensure_ascii=False, indent=2)
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # 验签
380
+ # ---------------------------------------------------------------------------
381
+
382
+
383
+ class LicenseVerifier:
384
+ """license 文件验签器(应用启动/请求时校验授权)
385
+
386
+ 校验链:签名 → product 匹配 → machine_code 匹配 → 未过期;
387
+ 各环节失败抛对应 LicenseError 子类,应用侧可区分提示语。
388
+ """
389
+
390
+ def __init__(self, public_key_pem: str, license_path: Path) -> None:
391
+ """保存公钥与 license 路径(文件读取延迟到 verify 时)
392
+
393
+ Args:
394
+ public_key_pem: Ed25519 公钥 PEM(应用配置注入)
395
+ license_path: license 文件路径(应用侧显式给出,不做路径猜测)
396
+ """
397
+ self._public_key_pem = public_key_pem
398
+ self._license_path = Path(license_path)
399
+ self._public_key: Ed25519PublicKey | None = None
400
+ # 最近一次 verify 成功的上下文,供 reverify() 使用
401
+ self._verified_context: tuple[str, str] | None = None
402
+
403
+ def _load_public_key(self) -> Ed25519PublicKey:
404
+ """解析并缓存公钥;类型不符抛 LicenseFormatError"""
405
+ if self._public_key is None:
406
+ try:
407
+ key = serialization.load_pem_public_key(
408
+ self._public_key_pem.encode("utf-8")
409
+ )
410
+ except (ValueError, TypeError) as exc:
411
+ raise LicenseFormatError(f"公钥 PEM 解析失败:{exc}") from exc
412
+ if not isinstance(key, Ed25519PublicKey):
413
+ raise LicenseFormatError("公钥类型不是 Ed25519")
414
+ self._public_key = key
415
+ return self._public_key
416
+
417
+ def _load_document(self) -> tuple[dict, bytes]:
418
+ """读 license 文件并拆出 (payload dict, 签名字节)"""
419
+ if not self._license_path.is_file():
420
+ raise LicenseFormatError(f"license 文件不存在:{self._license_path}")
421
+ try:
422
+ document = json.loads(self._license_path.read_text(encoding="utf-8"))
423
+ except (OSError, json.JSONDecodeError) as exc:
424
+ raise LicenseFormatError(f"license 文件读取或解析失败:{exc}") from exc
425
+ if not isinstance(document, dict):
426
+ raise LicenseFormatError("license 文件结构不合法(顶层应为 JSON 对象)")
427
+ payload_dict = document.get("payload")
428
+ signature_text = document.get("signature")
429
+ if not isinstance(payload_dict, dict) or not isinstance(signature_text, str):
430
+ raise LicenseFormatError("license 文件缺少 payload 或 signature 字段")
431
+ try:
432
+ signature = base64.b64decode(signature_text, validate=True)
433
+ except (ValueError, TypeError) as exc:
434
+ raise LicenseFormatError(f"signature 不是合法 base64:{exc}") from exc
435
+ return payload_dict, signature
436
+
437
+ def verify(
438
+ self,
439
+ *,
440
+ product: str,
441
+ machine_code: str,
442
+ now: datetime | None = None,
443
+ ) -> LicensePayload:
444
+ """全量校验 license,返回载荷
445
+
446
+ Args:
447
+ product: 期望的应用标识
448
+ machine_code: 期望的机器码(本机 `get_machine_code()` 结果)
449
+ now: 判定"当前时间";None 取 aware UTC 当前时刻;naive 按 UTC 处理
450
+
451
+ Returns:
452
+ LicensePayload: 校验通过的授权载荷
453
+
454
+ Raises:
455
+ LicenseFormatError: 文件缺失/损坏/版本不识别/字段非法
456
+ LicenseSignatureError: 签名验证失败
457
+ LicenseProductMismatchError: product 不匹配
458
+ LicenseMachineMismatchError: machine_code 不匹配
459
+ LicenseExpiredError: 已过期
460
+ """
461
+ check_time = now or datetime.now(timezone.utc)
462
+ if check_time.tzinfo is None:
463
+ check_time = check_time.replace(tzinfo=timezone.utc)
464
+ payload_dict, signature = self._load_document()
465
+ try:
466
+ self._load_public_key().verify(signature, _canonical_bytes(payload_dict))
467
+ except InvalidSignature as exc:
468
+ raise LicenseSignatureError("license 签名验证失败") from exc
469
+ payload = self._validated_payload(payload_dict)
470
+ if payload.product != product:
471
+ raise LicenseProductMismatchError(
472
+ f"product 不匹配:期望 {product!r},实际 {payload.product!r}"
473
+ )
474
+ expected_code = machine_code.strip().lower()
475
+ if payload.machine_code != expected_code:
476
+ raise LicenseMachineMismatchError(
477
+ f"机器码不匹配:期望 {expected_code!r},实际 {payload.machine_code!r}"
478
+ )
479
+ if payload.expires_at is not None and check_time > payload.expires_at:
480
+ raise LicenseExpiredError(
481
+ f"授权已于 {payload.expires_at.isoformat()} 过期"
482
+ )
483
+ self._verified_context = (product, expected_code)
484
+ logger.info("license 校验通过:product=%s tier=%s", payload.product, payload.tier)
485
+ return payload
486
+
487
+ def _validated_payload(self, payload_dict: dict) -> LicensePayload:
488
+ """载荷 dict → 模型校验(版本/字段/时间格式),失败归一为 LicenseFormatError"""
489
+ try:
490
+ return LicensePayload.model_validate(payload_dict)
491
+ except Exception as exc: # noqa: BLE001 - pydantic 校验错误统一归一
492
+ raise LicenseFormatError(f"license 载荷校验失败:{exc}") from exc
493
+
494
+ def file_signature(self) -> str:
495
+ """license 文件内容指纹:SHA256(文件字节)
496
+
497
+ 供应用侧运行中检测文件被替换(每请求比对,变化即调 `reverify()`)。
498
+ 只哈希内容、不含 mtime——mtime 在解压/拷贝场景随机变化,参与比对只会误触发。
499
+
500
+ Returns:
501
+ str: 十六进制 SHA256
502
+
503
+ Raises:
504
+ LicenseFormatError: 文件缺失或不可读
505
+ """
506
+ try:
507
+ content = self._license_path.read_bytes()
508
+ except OSError as exc:
509
+ raise LicenseFormatError(f"license 文件不可读:{exc}") from exc
510
+ return hashlib.sha256(content).hexdigest()
511
+
512
+ def reverify(self) -> LicensePayload:
513
+ """用最近一次成功 verify 的上下文重新全量校验(文件被换时相应失败)
514
+
515
+ Returns:
516
+ LicensePayload: 重新校验通过的授权载荷
517
+
518
+ Raises:
519
+ LicenseError: 尚未成功执行过 verify()
520
+ """
521
+ if self._verified_context is None:
522
+ raise LicenseError("尚未成功执行 verify(),无法 reverify")
523
+ product, machine_code = self._verified_context
524
+ return self.verify(product=product, machine_code=machine_code)
@@ -33,6 +33,8 @@ classifiers = [
33
33
  dependencies = [
34
34
  "pydantic>=2.14.1",
35
35
  "pydantic-settings>=2.15.0",
36
+ # Ed25519 签名/验签(kit/license_kit.py):零传递依赖 + abi3 wheel,评审定案放 base
37
+ "cryptography>=46.0.7",
36
38
  "sqlalchemy~=2.0.54",
37
39
  "sqlmodel>=0.0.48",
38
40
  ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes