jdeploy 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: jdeploy
3
+ Version: 0.1.0
4
+ Summary: 一键本地运行与远程部署工具 - 让Python项目部署更简单
5
+ Author-email: bruce zhang <1195747247@qq.com>
6
+ License: MIT
7
+ Project-URL: Homepage, http://www.lingma.site/deploy/
8
+ Project-URL: Repository, https://github.com/brucezhang/jdeploy
9
+ Keywords: deploy,python,deployment,ssh,automation
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: click>=8.0.0
22
+ Requires-Dist: cryptography>=3.3
23
+ Requires-Dist: paramiko>=3.0.0
24
+ Requires-Dist: requests>=2.25.0
25
+
26
+ # JDeploy (简部署)
27
+
28
+ 一键本地运行与远程部署工具 - 让Python项目部署更简单
29
+
30
+ ## 功能特点
31
+
32
+ - 🚀 **一键本地运行**:自动创建虚拟环境、安装依赖、启动项目
33
+ - 🌐 **远程部署**:通过SSH自动部署到服务器
34
+ - 🔧 **简单易用**:无需复杂的配置,开箱即用
35
+ - 📦 **依赖管理**:智能检测并安装项目依赖
36
+
37
+ ## 安装
38
+
39
+ ```bash
40
+ pip install jdeploy
41
+ ```
42
+
43
+ ## 使用方法
44
+
45
+ ### 激活产品
46
+
47
+ ```bash
48
+ jdeploy activate <激活码>
49
+ ```
50
+
51
+ ### 查看激活状态
52
+
53
+ ```bash
54
+ jdeploy status
55
+ ```
56
+
57
+ ### 演示模式(免费,无需激活)
58
+
59
+ ```bash
60
+ jdeploy demo run
61
+ ```
62
+
63
+ ### 本地运行项目
64
+
65
+ ```bash
66
+ jdeploy run .
67
+ ```
68
+
69
+ ### 远程部署
70
+
71
+ ```bash
72
+ jdeploy deploy --host 服务器IP --user 用户名 [--password 密码]
73
+ ```
74
+
75
+ ## 要求
76
+
77
+ - Python 3.8+
78
+ - 服务器需支持SSH连接
79
+
80
+ ## 许可证
81
+
82
+ MIT License
83
+
84
+ ## 作者
85
+
86
+ bruce zhang (1195747247@qq.com)
87
+
88
+ ## 主页
89
+
90
+ http://www.lingma.site/deploy/
@@ -0,0 +1,14 @@
1
+ pydeploy_cli/__init__.py,sha256=RfCZaTYnaZWMOB6zUEL1ObAv6oPfJhBLGZG-FMWmnVA,97
2
+ pydeploy_cli/__main__.py,sha256=BcMl5HQA1PGwwN0oK9k2iKXCDVqcIDhbGo1FK7uZZbw,130
3
+ pydeploy_cli/cli.py,sha256=UmBa0ZNYeN5y4USPrILsU60fJtYVnrEDGi7wXobfD1I,4033
4
+ pydeploy_cli/demo.py,sha256=rK4_yBfMtrrQuYq4QSx6DuttCe9a9XZu1IaQWpYS8Ys,9675
5
+ pydeploy_cli/license.py,sha256=RzvjjCX5GQI-V3MQdlXGiKIuZS_dWoOiK1x-6Pelcao,4183
6
+ pydeploy_cli/local.py,sha256=BYuMNsprqJpXFl7YVL5w73LTiDDZRQ2_3LfrzcqsvSc,6876
7
+ pydeploy_cli/remote.py,sha256=AT8iUctpSFXlBq1TSrj_YVO5cY-6ZxvjNIPOT71t92Y,7381
8
+ pydeploy_cli/utils.py,sha256=pc2gXoR9jnvCpysYQDXiUxv-fbvzO1MF0ijh5Gqaf3k,14114
9
+ pydeploy_cli/templates/start.sh,sha256=Bq1lxprRuDHVkAAooIBxVTjEiMzcP9xYeytn7zcyplY,460
10
+ jdeploy-0.1.0.dist-info/METADATA,sha256=e0v3v3K4AZO0oRdz8lNG6OAn414PzlBEWAS109WSNEE,2031
11
+ jdeploy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ jdeploy-0.1.0.dist-info/entry_points.txt,sha256=yk2UOvl76Xf2K93AikDxBbGWdVopQPfRZJq4XzsFCJI,50
13
+ jdeploy-0.1.0.dist-info/top_level.txt,sha256=aqweg8vUhsDv0NlVPIxWufuqWgIUFNxTDY3IrqWkcco,13
14
+ jdeploy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jdeploy = pydeploy_cli.cli:main
@@ -0,0 +1 @@
1
+ pydeploy_cli
@@ -0,0 +1,3 @@
1
+ """JDeploy (简部署):一键本地运行与远程部署工具。"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """支持 ``python -m pydeploy_cli`` 调用。"""
2
+
3
+ from pydeploy_cli.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
pydeploy_cli/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ """JDeploy (简部署) 命令行入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Optional
7
+
8
+ import click
9
+
10
+ from pydeploy_cli import __version__
11
+ from pydeploy_cli import license as license_mod
12
+
13
+
14
+ def _ensure_utf8_stdio() -> None:
15
+ """
16
+ 在 Windows 等环境下尽量使用 UTF-8 标准输出,减少中文乱码。
17
+ """
18
+ if sys.platform != "win32":
19
+ return
20
+ for stream in (sys.stdout, sys.stderr):
21
+ try:
22
+ stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
23
+ except (AttributeError, OSError, ValueError):
24
+ pass
25
+
26
+
27
+ @click.group()
28
+ @click.version_option(version=__version__, prog_name="jdeploy")
29
+ def cli() -> None:
30
+ """
31
+ 简部署 - 一键本地运行与远程部署工具。
32
+ """
33
+ _ensure_utf8_stdio()
34
+
35
+
36
+ @cli.command("activate")
37
+ @click.argument("code", type=str)
38
+ @click.option("--email", default=None, help="可选邮箱")
39
+ def activate_cmd(code: str, email: Optional[str]) -> None:
40
+ """
41
+ 使用激活码在本机激活(绑定设备标识)。
42
+ """
43
+ from pydeploy_cli import utils
44
+
45
+ try:
46
+ machine_id = utils.get_machine_id()
47
+ result = license_mod.activate_license(code.strip(), machine_id, email=email)
48
+ except OSError as exc:
49
+ click.echo(f"错误:激活过程出错: {exc}")
50
+ raise SystemExit(1) from exc
51
+
52
+ if not result.get("success"):
53
+ msg = result.get("message", "激活失败")
54
+ click.echo(f"错误:{msg}")
55
+ raise SystemExit(1)
56
+
57
+ perm = result.get("type", "unknown")
58
+ try:
59
+ license_mod.save_license(code.strip(), str(perm))
60
+ except OSError as exc:
61
+ click.echo(f"错误:保存许可证失败: {exc}")
62
+ raise SystemExit(1) from exc
63
+
64
+ click.echo(f"激活成功。权限类型: {perm}")
65
+
66
+
67
+ @cli.command("status")
68
+ def status_cmd() -> None:
69
+ """
70
+ 查看当前激活状态与权限类型。
71
+ """
72
+ data = license_mod.load_license()
73
+ if not data or not data.get("code"):
74
+ click.echo("未激活")
75
+ return
76
+
77
+ suffix = license_mod.get_license_code_suffix(data)
78
+ perm = data.get("type", "unknown")
79
+ click.echo(f"已激活,权限类型:{perm},激活码后四位:{suffix}")
80
+
81
+
82
+ @cli.group()
83
+ def demo() -> None:
84
+ """示例程序(免费,无需激活)。"""
85
+ pass
86
+
87
+
88
+ @demo.command("run")
89
+ def demo_run_cmd() -> None:
90
+ """创建示例项目并走完整流程:venv、pip(轻量 bottle)、启动服务。"""
91
+ from pydeploy_cli.demo import run_demo
92
+
93
+ run_demo()
94
+
95
+
96
+ cli.add_command(demo)
97
+
98
+
99
+ @cli.command("run")
100
+ @click.argument(
101
+ "project_dir",
102
+ default=".",
103
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
104
+ )
105
+ def run_cmd(project_dir: str) -> None:
106
+ """
107
+ 在虚拟环境中运行自己的项目(需要激活码)。
108
+ """
109
+ from pydeploy_cli.local import run_user_project
110
+
111
+ run_user_project(project_dir)
112
+
113
+
114
+ @cli.command("deploy")
115
+ @click.option("--host", required=True, help="服务器 IP 或域名")
116
+ @click.option("--user", required=True, help="SSH 用户名")
117
+ @click.option("--port", default=22, show_default=True, type=int, help="SSH 端口")
118
+ @click.option("--key", "key_path", default=None, type=click.Path(exists=True), help="私钥路径")
119
+ @click.option("--password", default=None, help="SSH 密码")
120
+ def deploy_cmd(
121
+ host: str,
122
+ user: str,
123
+ port: int,
124
+ key_path: Optional[str],
125
+ password: Optional[str],
126
+ ) -> None:
127
+ """
128
+ 将当前目录上传到服务器 /opt/<项目名>(需要 full 激活码)。
129
+ """
130
+ from pydeploy_cli.remote import deploy_to_server
131
+
132
+ deploy_to_server(
133
+ host=host,
134
+ user=user,
135
+ port=port,
136
+ key_path=key_path,
137
+ password=password,
138
+ )
139
+
140
+
141
+ def main() -> None:
142
+ """
143
+ setuptools 控制台脚本 ``jdeploy`` 的入口函数。
144
+ """
145
+ cli()
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
pydeploy_cli/demo.py ADDED
@@ -0,0 +1,246 @@
1
+ """示例程序:流程与 ``jdeploy run`` 一致(venv + pip + 启动),依赖使用轻量 Bottle。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import webbrowser
11
+ from datetime import datetime
12
+
13
+ import click
14
+
15
+ from pydeploy_cli import license as license_mod
16
+ from pydeploy_cli import utils
17
+
18
+ DEMO_DIR = ".jdeploy_demo"
19
+
20
+ # 端口冲突检测正则(Windows)
21
+ _PORT_CONFLICT_PATTERN = re.compile(
22
+ r"(?:(?:Errno\s*10048)|(?:WinError\s*10048)).*?\(\s*'0\.0\.0\.0'\s*,\s*(\d+)\s*\)",
23
+ re.IGNORECASE,
24
+ )
25
+ DEMO_URL = "http://127.0.0.1:5000/"
26
+
27
+ # Bottle 体积小、wheel 安装快,适合演示「真实跑项目」流程而不拉 Flask 全家桶
28
+ REQUIREMENTS = "bottle>=0.12.0\n"
29
+
30
+ # 使用 wsgiref 托管 Bottle,绑定端口后再打印 READY,便于父进程判断可访问
31
+ # 写入文件时将 {{OFFICIAL_SITE}} 替换为与工具一致的官网地址
32
+ DEMO_APP = '''"""JDeploy 演示应用(Bottle,轻量依赖)。"""
33
+ from bottle import Bottle, response
34
+ from wsgiref.simple_server import WSGIRequestHandler, make_server
35
+
36
+ app = Bottle()
37
+
38
+ _HTML = """<!DOCTYPE html>
39
+ <html lang="zh-CN">
40
+ <head>
41
+ <meta charset="utf-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1">
43
+ <title>JDeploy 演示</title>
44
+ <style>
45
+ body{font-family:Segoe UI,Microsoft YaHei,sans-serif;max-width:640px;margin:48px auto;padding:0 20px;line-height:1.6;color:#222;}
46
+ h1{font-size:1.5rem;margin-bottom:0.5rem;color:#0d47a1;}
47
+ .lead{color:#444;margin-bottom:1.25rem;}
48
+ ul{padding-left:1.2rem;}
49
+ li{margin:0.35rem 0;}
50
+ .cta{margin-top:1.5rem;padding:1rem;background:#e3f2fd;border-radius:8px;}
51
+ a{color:#1565c0;}
52
+ .footer{margin-top:2rem;font-size:0.9rem;color:#666;}
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <h1>您正在使用 JDeploy (简部署) 自动生成的演示应用</h1>
57
+ <p class="lead">当前页面由 <strong>jdeploy demo run</strong> 一键创建并启动。
58
+ 它与正式运行您自己项目时的流程一致:检测 Python、创建虚拟环境、按 requirements.txt 安装依赖、再启动服务。</p>
59
+ <p>这说明 JDeploy 在本地开发场景下<strong>可靠、可复现</strong>,适合希望少踩环境坑、快速验证效果的团队与个人。</p>
60
+ <ul>
61
+ <li><strong>本地运行</strong>:激活后可对任意项目目录执行相同流程,专注写代码而不是配环境。</li>
62
+ <li><strong>远程部署</strong>:专业版支持将当前项目上传至服务器并生成启动脚本,部署路径清晰、可运维。</li>
63
+ <li><strong>持续使用</strong>:从演示到真实项目,同一套命令习惯,降低从「能跑」到「能上线」的心智成本。</li>
64
+ </ul>
65
+ <div class="cta">
66
+ <strong>下一步建议</strong>:若体验符合预期,可前往官网了解激活与版本权益,把 JDeploy 用进日常开发与发布流程。
67
+ <p style="margin:0.75rem 0 0 0;"><a href="{{OFFICIAL_SITE}}">访问 JDeploy 官网</a></p>
68
+ </div>
69
+ <p class="footer">感谢试用。关闭本页后,在运行 demo 的命令行窗口按 Ctrl+C 即可停止服务。</p>
70
+ </body>
71
+ </html>"""
72
+
73
+
74
+ @app.route("/")
75
+ def hello():
76
+ response.content_type = "text/html; charset=utf-8"
77
+ return _HTML
78
+
79
+
80
+ class _QuietHandler(WSGIRequestHandler):
81
+ """关闭访问日志,避免终端刷屏。"""
82
+
83
+ def log_message(self, format, *args):
84
+ return
85
+
86
+
87
+ if __name__ == "__main__":
88
+ httpd = make_server("0.0.0.0", 5000, app, handler_class=_QuietHandler)
89
+ print("JDEPLOY_DEMO_READY http://127.0.0.1:5000/", flush=True)
90
+ httpd.serve_forever()
91
+ '''
92
+
93
+
94
+ def run_demo() -> None:
95
+ """
96
+ 在当前目录下创建 ``.jdeploy_demo``,按与 ``run_user_project`` 相同顺序执行:
97
+
98
+ 检测 Python、创建虚拟环境、存在 ``requirements.txt`` 则 ``pip install``、
99
+ 启动固定入口 ``demo_app.py``;就绪后展示地址并尝试打开浏览器。
100
+ """
101
+ cwd = os.getcwd()
102
+ demo_path = os.path.join(cwd, DEMO_DIR)
103
+ cleanup_done = False
104
+
105
+ def do_cleanup() -> None:
106
+ nonlocal cleanup_done
107
+ if cleanup_done:
108
+ return
109
+ cleanup_done = True
110
+ click.echo("\n正在清理临时目录...")
111
+ utils.safe_rmtree(demo_path)
112
+ click.echo("已清理 .jdeploy_demo")
113
+
114
+ # 如果目录已存在,直接删除重建(参考桌面版实现)
115
+ if os.path.exists(demo_path):
116
+ click.echo("检测到 Demo 目录残留,正在清理...")
117
+ try:
118
+ shutil.rmtree(demo_path)
119
+ except OSError:
120
+ # 清理失败(通常是被占用)则换一个目录名
121
+ suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
122
+ demo_path = os.path.join(cwd, f"{DEMO_DIR}_{suffix}")
123
+ click.echo(f"提示:目录被占用(可能有进程在运行),将使用新目录: {os.path.basename(demo_path)}")
124
+
125
+ try:
126
+ os.makedirs(demo_path, exist_ok=True)
127
+ app_path = os.path.join(demo_path, "demo_app.py")
128
+ req_path = os.path.join(demo_path, "requirements.txt")
129
+ with open(app_path, "w", encoding="utf-8") as f:
130
+ f.write(DEMO_APP.replace("{{OFFICIAL_SITE}}", license_mod.OFFICIAL_SITE))
131
+ with open(req_path, "w", encoding="utf-8") as f:
132
+ f.write(REQUIREMENTS)
133
+
134
+ click.echo("正在检测 Python...", nl=False)
135
+ ok, msg = utils.check_python_installed()
136
+ if not ok:
137
+ click.echo(" 失败")
138
+ click.echo(msg)
139
+ click.echo("请前往 https://www.python.org 下载并安装 Python。")
140
+ raise SystemExit(1)
141
+ click.echo(f" 完成 ({msg})")
142
+
143
+ py_cmd = utils.get_python_executable_for_venv()
144
+ click.echo("正在创建虚拟环境...", nl=False)
145
+ try:
146
+ subprocess.run(
147
+ [py_cmd, "-m", "venv", "venv"],
148
+ cwd=demo_path,
149
+ check=True,
150
+ timeout=120,
151
+ )
152
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
153
+ click.echo(" 失败")
154
+ click.echo(f"创建虚拟环境失败: {exc}")
155
+ raise SystemExit(1) from exc
156
+ click.echo(" 完成")
157
+
158
+ venv_python = utils.get_venv_python(demo_path)
159
+ click.echo("正在安装依赖...")
160
+ try:
161
+ # 显示包名,省略下载详情
162
+ def _pip_line(s: str) -> None:
163
+ if s.startswith("Collecting ") or s.startswith("Installing") or s.startswith("Successfully installed"):
164
+ click.echo(s)
165
+ elif "error" in s.lower() or "failed" in s.lower():
166
+ click.echo(s)
167
+
168
+ utils.pip_install_with_mirror_first(
169
+ venv_python=venv_python,
170
+ work_dir=demo_path,
171
+ requirements_file="requirements.txt",
172
+ handle_line=_pip_line,
173
+ stall_seconds=10.0,
174
+ timeout_seconds=600.0,
175
+ )
176
+ except SystemExit:
177
+ raise
178
+ click.echo("安装依赖: 完成")
179
+
180
+ venv_python = utils.get_venv_python(demo_path)
181
+ click.echo("正在启动服务(本窗口 Ctrl+C 停止)...")
182
+
183
+ proc = subprocess.Popen(
184
+ [venv_python, "demo_app.py"],
185
+ cwd=demo_path,
186
+ stdin=subprocess.DEVNULL,
187
+ stdout=subprocess.PIPE,
188
+ stderr=subprocess.STDOUT,
189
+ text=True,
190
+ bufsize=1,
191
+ )
192
+
193
+ assert proc.stdout is not None
194
+ try:
195
+ first_line = proc.stdout.readline()
196
+ if not first_line.strip() and proc.poll() is not None:
197
+ click.echo("错误:服务未能启动(子进程已退出)。请检查 5000 端口是否被占用。")
198
+ raise SystemExit(1)
199
+
200
+ click.echo(f"访问: {DEMO_URL}")
201
+
202
+ opened = False
203
+ try:
204
+ opened = bool(webbrowser.open(DEMO_URL))
205
+ except OSError:
206
+ opened = False
207
+ if opened:
208
+ click.echo(
209
+ "已尝试自动打开浏览器。若未弹出窗口,请手动复制上面地址到浏览器打开。"
210
+ )
211
+ else:
212
+ click.echo(
213
+ "未能自动打开浏览器,请手动复制上面地址到浏览器地址栏访问。"
214
+ )
215
+
216
+ # 后台读管道 + 主线程短超时等待,避免 Windows 上阻塞读导致 Ctrl+C 无效
217
+ def handle_demo_line(s: str) -> None:
218
+ click.echo(s)
219
+ # 检测端口冲突
220
+ m = _PORT_CONFLICT_PATTERN.search(s)
221
+ if m:
222
+ port = int(m.group(1))
223
+ click.echo(f"错误:端口 {port} 已被占用,请关闭占用该端口的程序后重试。")
224
+
225
+ utils.pump_subprocess_stdout(proc, handle_demo_line)
226
+ except KeyboardInterrupt:
227
+ click.echo("\n收到中断信号,正在停止...")
228
+ proc.terminate()
229
+ try:
230
+ proc.wait(timeout=10)
231
+ except subprocess.TimeoutExpired:
232
+ proc.kill()
233
+ do_cleanup()
234
+ sys.exit(0)
235
+
236
+ proc.wait()
237
+ except KeyboardInterrupt:
238
+ do_cleanup()
239
+ sys.exit(0)
240
+ except SystemExit:
241
+ utils.safe_rmtree(demo_path)
242
+ raise
243
+ except Exception as exc:
244
+ click.echo(f"错误:运行异常: {exc}")
245
+ utils.safe_rmtree(demo_path)
246
+ raise SystemExit(1) from exc
@@ -0,0 +1,131 @@
1
+ """激活码管理:本地配置读写与验证。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime
8
+ from typing import Any, Dict, Optional
9
+
10
+ import requests
11
+
12
+ # 后端API地址
13
+ API_BASE_URL = "https://api.lingma.site/jiandeploy"
14
+ VERIFY_URL = f"{API_BASE_URL}/api/verifyLicense"
15
+ ACTIVATE_URL = f"{API_BASE_URL}/api/activateLicense"
16
+ OFFICIAL_SITE = "http://www.lingma.site/deploy/"
17
+
18
+
19
+ def get_license_config_path() -> str:
20
+ """
21
+ 返回本机激活配置文件路径 ``~/.jdeploy/license.json``。
22
+ """
23
+ home = os.path.expanduser("~")
24
+ return os.path.join(home, ".jdeploy", "license.json")
25
+
26
+
27
+ def ensure_config_dir() -> str:
28
+ """
29
+ 确保 ``~/.jdeploy`` 目录存在,并返回该目录路径。
30
+ """
31
+ d = os.path.join(os.path.expanduser("~"), ".jdeploy")
32
+ os.makedirs(d, exist_ok=True)
33
+ return d
34
+
35
+
36
+ def load_license() -> Optional[Dict[str, Any]]:
37
+ """
38
+ 读取本地 ``license.json``;文件不存在或解析失败时返回 None。
39
+ """
40
+ path = get_license_config_path()
41
+ if not os.path.isfile(path):
42
+ return None
43
+ try:
44
+ with open(path, "r", encoding="utf-8") as f:
45
+ data = json.load(f)
46
+ if isinstance(data, dict) and data.get("code"):
47
+ return data
48
+ except (OSError, json.JSONDecodeError):
49
+ return None
50
+ return None
51
+
52
+
53
+ def save_license(code: str, perm_type: str) -> None:
54
+ """
55
+ 将激活码与权限类型写入 ``license.json``。
56
+ """
57
+ ensure_config_dir()
58
+ path = get_license_config_path()
59
+ payload = {
60
+ "code": code,
61
+ "type": perm_type,
62
+ "activated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
63
+ }
64
+ with open(path, "w", encoding="utf-8") as f:
65
+ json.dump(payload, f, ensure_ascii=False, indent=4)
66
+
67
+
68
+ def verify_license(code: str, machine_id: str) -> Dict[str, Any]:
69
+ """
70
+ 向服务端校验激活码是否有效。
71
+
72
+ Args:
73
+ code: 激活码字符串
74
+ machine_id: 设备唯一标识
75
+
76
+ Returns:
77
+ 形如 ``{"valid": bool, "type": "local"|"full", "message": "..."}`` 的字典
78
+ """
79
+ try:
80
+ resp = requests.post(
81
+ VERIFY_URL,
82
+ json={"code": code, "machine_id": machine_id},
83
+ timeout=30,
84
+ )
85
+ data = resp.json()
86
+ if data.get("code") == 0 and data.get("data", {}).get("valid"):
87
+ return {"valid": True, "type": data["data"].get("type", "local")}
88
+ return {"valid": False, "message": data.get("message", "验证失败")}
89
+ except requests.RequestException as exc:
90
+ return {"valid": False, "message": f"网络错误: {exc}"}
91
+ except (KeyError, ValueError) as exc:
92
+ return {"valid": False, "message": f"响应解析错误: {exc}"}
93
+
94
+
95
+ def activate_license(
96
+ code: str, machine_id: str, email: Optional[str] = None
97
+ ) -> Dict[str, Any]:
98
+ """
99
+ 激活设备。
100
+
101
+ Args:
102
+ code: 激活码
103
+ machine_id: 设备标识
104
+ email: 可选邮箱
105
+
106
+ Returns:
107
+ 形如 ``{"success": bool, "type": "local"|"full", "message": "..."}``
108
+ """
109
+ payload = {"code": code, "machine_id": machine_id}
110
+ if email:
111
+ payload["email"] = email
112
+ try:
113
+ resp = requests.post(ACTIVATE_URL, json=payload, timeout=30)
114
+ data = resp.json()
115
+ if data.get("code") == 0:
116
+ return {"success": True, "type": data.get("data", {}).get("type", "local")}
117
+ return {"success": False, "message": data.get("message", "激活失败")}
118
+ except requests.RequestException as exc:
119
+ return {"success": False, "message": f"网络错误: {exc}"}
120
+ except (KeyError, ValueError) as exc:
121
+ return {"success": False, "message": f"响应解析错误: {exc}"}
122
+
123
+
124
+ def get_license_code_suffix(data: Optional[Dict[str, Any]], n: int = 4) -> str:
125
+ """
126
+ 返回激活码后 n 位,用于状态展示;无激活码时返回空字符串。
127
+ """
128
+ if not data or not data.get("code"):
129
+ return ""
130
+ code = str(data["code"])
131
+ return code[-n:] if len(code) >= n else code