Files
chongming-license-gui/src/chongming_license_gui/issue_tab.py
T
2026-09-22 16:08:12 +08:00

478 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""签发页:把"申请码 → license 文件"的过程可视化。
载荷与签名一律调用 ``chongming_license.issue``(与 CLI 共用),本页只负责
收集输入、展示解析结果、给出签发前预览 —— 让操作者**在按下签发之前**就能看清
"给谁、哪台机器、哪些插件、多久",而不是签完才发现勾错了插件。
"""
from __future__ import annotations
import json
from pathlib import Path
from PySide6.QtCore import QDate, QSettings, Qt
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDateEdit,
QFileDialog,
QLabel,
QLineEdit,
QPlainTextEdit,
QRadioButton,
QSpinBox,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from chongming_license import (
RequestCodeError,
build_license,
build_payload,
decode_request_code,
machine_code,
write_license,
)
from .common import (
ask_overwrite,
btn,
copy_to_clipboard,
error,
hint,
hbox,
info,
make_mono_label,
mono_font,
section,
set_status,
warn,
)
from .keystore import SigningKey
from .plugin_picker import PluginPicker
# 签发依据的三种方式(与 CLI 的 --request-code / --sources-json / --machine-code 对位)
MODE_REQUEST_CODE = 0
MODE_SOURCES_JSON = 1
MODE_MACHINE_CODE = 2
MODE_LABELS = ("申请码(推荐,支持换硬件容错)", "来源 JSON 文件", "仅机器码(精确匹配,无容错)")
class IssueTab(QWidget):
"""签发页。"""
def __init__(self, signing_key: SigningKey, registry, settings: QSettings,
parent=None) -> None:
super().__init__(parent)
self.key = signing_key
self.registry = registry
self.settings = settings
# 当前解析出的签发对象
self._sources: dict[str, str] = {}
self._machine_code: str = ""
self._source_note: str = ""
root = QVBoxLayout(self)
root.setContentsMargins(14, 14, 14, 14)
root.setSpacing(12)
# ---------- 签名私钥 ----------
key_box, key_form = section("签名私钥")
self.key_status = QLabel("—")
self.key_status.setWordWrap(True)
# 指纹要逐字符核对,必须等宽
self.key_fp = QLabel("—")
self.key_fp.setFont(mono_font(11))
self.key_fp.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
key_form.addRow("当前", self.key_status)
key_form.addRow("公钥指纹", self.key_fp)
key_form.addRow("", hint("在「密钥」页生成或加载私钥。签发前请核对公钥指纹与设备端一致。"))
root.addWidget(key_box)
# ---------- 签发依据 ----------
target_box, target_form = section("签发依据")
self.mode_combo = QComboBox()
self.mode_combo.addItems(MODE_LABELS)
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
target_form.addRow("方式", self.mode_combo)
self.stack = QStackedWidget()
# 页 0:申请码
page_rc = QWidget()
rc_layout = QVBoxLayout(page_rc)
rc_layout.setContentsMargins(0, 0, 0, 0)
self.rc_edit = QPlainTextEdit()
self.rc_edit.setPlaceholderText(
"粘贴客户发来的申请码(CM1-... ,可带换行/空格,会自动清理)"
)
self.rc_edit.setMaximumHeight(76)
rc_layout.addWidget(self.rc_edit)
rc_button = btn("解析申请码", icon_name="search")
rc_button.clicked.connect(self._parse_request_code)
rc_layout.addWidget(rc_button)
self.stack.addWidget(page_rc)
# 页 1:来源 JSON 文件
page_json = QWidget()
json_layout = QVBoxLayout(page_json)
json_layout.setContentsMargins(0, 0, 0, 0)
self.json_edit = QLineEdit()
self.json_edit.setPlaceholderText("show --json 的导出文件(含 sources 字段,或就是来源字典)")
json_browse = btn("浏览…", icon_name="folder")
json_browse.clicked.connect(self._pick_json)
json_load = btn("解析", icon_name="import_file")
json_load.clicked.connect(self._parse_json_file)
json_layout.addWidget(hbox(self.json_edit, json_browse, json_load))
self.stack.addWidget(page_json)
# 页 2:仅机器码
page_mc = QWidget()
mc_layout = QVBoxLayout(page_mc)
mc_layout.setContentsMargins(0, 0, 0, 0)
self.mc_edit = QLineEdit()
self.mc_edit.setPlaceholderText("如 BC60-D20E-F863-7236-68E2-6A24")
self.mc_edit.setFont(make_mono_label().font())
mc_button = btn("使用该机器码")
mc_button.clicked.connect(self._use_machine_code)
mc_layout.addWidget(hbox(self.mc_edit, mc_button))
mc_layout.addWidget(hint(
"⚠ 仅按机器码精确匹配:换一块网卡就会失授权。建议改用申请码。", role="warn"
))
self.stack.addWidget(page_mc)
target_form.addRow("", self.stack)
self.target_result = QLabel("尚未解析签发对象")
self.target_result.setWordWrap(True)
self.target_result.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
target_form.addRow("解析结果", self.target_result)
self.sources_view = QPlainTextEdit()
self.sources_view.setReadOnly(True)
self.sources_view.setFont(make_mono_label().font())
self.sources_view.setMaximumHeight(96)
self.sources_view.setPlaceholderText("(各硬件来源指纹,解析成功后显示)")
target_form.addRow("来源指纹", self.sources_view)
root.addWidget(target_box)
# ---------- 授权插件 ----------
# 勾选组件与台账「改插件」共用(plugin_picker):行直接加进本卡片,排版与
# 抽出之前一致;下面几个属性名沿用旧名,既有代码与回归测试都在用
plugin_box, plugin_form = section("授权插件")
self.plugin_picker = PluginPicker(
plugin_form, settings, notify=lambda title, msg: warn(self, title, msg)
)
self.plugin_list = self.plugin_picker.list
self.plugin_input = self.plugin_picker.input
self.plugin_note = self.plugin_picker.note
self.root_edit = self.plugin_picker.root_edit
root.addWidget(plugin_box)
# ---------- 授权信息 ----------
info_box, info_form = section("授权信息")
self.customer_edit = QLineEdit(str(settings.value("issue/customer", "")))
info_form.addRow("客户名称", self.customer_edit)
self.lic_id_edit = QLineEdit("")
self.lic_id_edit.setPlaceholderText("留空则自动生成(L<日期>-<随机>)")
info_form.addRow("授权编号", self.lic_id_edit)
self.note_edit = QLineEdit(str(settings.value("issue/note", "")))
self.note_edit.setPlaceholderText("合同号等(仅展示/审计)")
info_form.addRow("备注", self.note_edit)
self.kid_edit = QLineEdit(str(settings.value("issue/kid", "")))
self.kid_edit.setPlaceholderText("可选,写入 license 的 kid 字段")
info_form.addRow("密钥标识", self.kid_edit)
# 有效期三选一
self.expiry_group = QButtonGroup(self)
self.rb_days = QRadioButton("N 天")
self.rb_days.setChecked(True)
self.days_spin = QSpinBox()
self.days_spin.setRange(1, 36500)
self.days_spin.setValue(int(settings.value("issue/days", 365)))
self.days_spin.setSuffix(" 天")
self.rb_date = QRadioButton("指定到期日")
self.date_edit = QDateEdit()
self.date_edit.setCalendarPopup(True)
self.date_edit.setDisplayFormat("yyyy-MM-dd")
self.date_edit.setDate(QDate.currentDate().addYears(1))
self.rb_forever = QRadioButton("永久有效")
for rb in (self.rb_days, self.rb_date, self.rb_forever):
self.expiry_group.addButton(rb)
# 启停联动的输入控件,避免"选了永久却还能改天数"的歧义
self.rb_days.toggled.connect(lambda on: self.days_spin.setEnabled(on))
self.rb_date.toggled.connect(lambda on: self.date_edit.setEnabled(on))
self.days_spin.setEnabled(True)
self.date_edit.setEnabled(False)
info_form.addRow("有效期", hbox(self.rb_days, self.days_spin, self.rb_date, self.date_edit))
info_form.addRow("", self.rb_forever)
root.addWidget(info_box)
# ---------- 预览与签发 ----------
action_box, action_form = section("预览与签发")
self.preview = QPlainTextEdit()
self.preview.setReadOnly(True)
self.preview.setFont(make_mono_label().font())
self.preview.setMinimumHeight(150)
self.preview.setPlaceholderText("载荷预览:点「刷新预览」")
action_form.addRow("", self.preview)
refresh_btn = btn("刷新预览", icon_name="refresh")
refresh_btn.clicked.connect(self._refresh_preview)
copy_btn = btn("复制预览", icon_name="copy")
copy_btn.clicked.connect(lambda: copy_to_clipboard(self.preview.toPlainText(), self))
self.record_check = QCheckBox("签发后记入台账(便于日后续期与检索)")
self.record_check.setChecked(True)
self.record_check.setToolTip(
"台账会连机器指纹一起存下来,续期时可直接复用,无需再向客户索取申请码"
)
self.issue_btn = btn("签发并保存…", role="primary", icon_name="save")
self.issue_btn.clicked.connect(self._issue)
action_form.addRow("", hbox(refresh_btn, copy_btn, self.issue_btn))
action_form.addRow("", self.record_check)
root.addWidget(action_box)
root.addStretch(1)
self.key.changed.connect(self._sync_key)
self._sync_key()
self._reload_plugins()
# ------------------------------------------------------------------
# 私钥状态
# ------------------------------------------------------------------
def _sync_key(self) -> None:
if self.key.loaded:
self.key_status.setText(self.key.describe())
self.key_fp.setText(self.key.fingerprint)
else:
self.key_status.setText("未加载私钥 —— 请到「密钥」页生成或加载")
self.key_fp.setText("—")
# ------------------------------------------------------------------
# 插件列表(实现在 plugin_picker.PluginPicker,这里只为既有调用点留入口)
# ------------------------------------------------------------------
def _reload_plugins(self) -> None:
self.plugin_picker.reload()
def _checked_plugins(self) -> list[str]:
return self.plugin_picker.checked()
def _set_all_checked(self, checked: bool) -> None:
self.plugin_picker.set_all(checked)
# ------------------------------------------------------------------
# 签发依据解析
# ------------------------------------------------------------------
def _on_mode_changed(self, index: int) -> None:
self.stack.setCurrentIndex(index)
self._clear_target()
def _clear_target(self) -> None:
self._sources = {}
self._machine_code = ""
self._source_note = ""
set_status(self.target_result, "尚未解析签发对象", "hint")
self.sources_view.clear()
self.preview.clear()
def _pick_json(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "选择来源 JSON 文件", self.json_edit.text() or "", "JSON 文件 (*.json);;所有文件 (*)"
)
if path:
self.json_edit.setText(path)
self._parse_json_file()
def _parse_request_code(self) -> None:
raw = self.rc_edit.toPlainText()
if not raw.strip():
warn(self, "未填写", "请粘贴客户发来的申请码。")
return
try:
data = decode_request_code(raw)
except RequestCodeError as exc:
error(self, "申请码无法解析", f"{exc}")
return
self._set_target(
data["sources"], data["machine_code"],
note=f"来自申请码({len(data['sources'])} 个来源)",
)
def _parse_json_file(self) -> None:
path = self.json_edit.text().strip()
if not path:
warn(self, "未选择文件", "请选择来源 JSON 文件。")
return
try:
raw = json.loads(Path(path).read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 - 读盘/解析失败统一提示
error(self, "无法读取", f"{exc}")
return
sources = raw.get("sources", raw) if isinstance(raw, dict) else {}
if not isinstance(sources, dict) or not sources:
error(self, "内容不含来源", f"{path} 中未找到 sources 字段,且本身不是来源字典。")
return
sources = {str(k): str(v) for k, v in sources.items()}
try:
code = raw.get("machine_code") or machine_code(sources)
except Exception as exc: # noqa: BLE001
error(self, "无法推导机器码", f"{exc}")
return
self._set_target(sources, str(code), note=f"来自文件 {Path(path).name}")
def _use_machine_code(self) -> None:
code = self.mc_edit.text().strip().upper()
if not code:
warn(self, "未填写", "请输入机器码。")
return
self._set_target({}, code, note="仅机器码(精确匹配,无换硬件容错)")
def _set_target(self, sources: dict[str, str], code: str, note: str) -> None:
self._sources = dict(sources)
self._machine_code = str(code).strip().upper()
self._source_note = note
detail = [f"机器码:{self._machine_code}", note]
if sources:
# weak 来源不参与判定阈值,这里如实标注,避免操作者误以为来源越多越保险
from chongming_license import SOURCES as _SOURCES
weak = {s.name for s in _SOURCES if s.weak}
strong = sorted(k for k in sources if k not in weak)
weak_found = sorted(k for k in sources if k in weak)
detail.append(f"可信来源 {len(strong)} 个:{', '.join(strong) or '无'}")
if weak_found:
detail.append(f"不参与判定(weak):{', '.join(weak_found)}")
if not strong:
detail.append("⚠ 没有任何可信来源,判定将只依赖易重复的来源,安全性不足")
else:
detail.append("⚠ 无来源指纹:仅按机器码精确匹配,换硬件即失授权")
# 命中"⚠"时整块转警示色 —— 弱来源/无来源是签发前最该被看见的信息
set_status(self.target_result, "\n".join(detail),
"warn" if any("⚠" in line for line in detail) else "hint")
self.sources_view.setPlainText(
"\n".join(f"{k} = {v}" for k, v in sorted(sources.items())) if sources else "(无)"
)
self._refresh_preview()
# ------------------------------------------------------------------
# 预览与签发
# ------------------------------------------------------------------
def _expiry_value(self) -> tuple[str | None, int | None]:
"""返回 (expires_at, days) —— 二者最多一个非空,供 build_payload/resolve_expiry 使用。"""
if self.rb_forever.isChecked():
return None, None
if self.rb_days.isChecked():
return None, self.days_spin.value()
return self.date_edit.date().toString("yyyy-MM-dd"), None
def _build_payload(self) -> dict:
"""收集界面输入并构造载荷(未签名)。"""
if not self._machine_code:
raise ValueError("请先解析签发对象(申请码 / 来源文件 / 机器码)")
plugins = self._checked_plugins()
if not plugins:
raise ValueError("请至少勾选一个授权插件")
expires_at, days = self._expiry_value()
from chongming_license import resolve_expiry
return build_payload(
machine_code=self._machine_code,
plugins=plugins,
sources=self._sources,
customer=self.customer_edit.text().strip(),
lic_id=self.lic_id_edit.text().strip(),
expires_at=resolve_expiry(days=days, expires=expires_at),
note=self.note_edit.text().strip(),
)
def _refresh_preview(self) -> None:
try:
payload = self._build_payload()
except ValueError as exc:
self.preview.setPlainText(f"(无法生成预览:{exc})")
return
# 预览用与签名完全相同的载荷对象,所见即所签
self.preview.setPlainText(json.dumps(payload, ensure_ascii=False, indent=2))
def _issue(self) -> None:
if not self.key.loaded:
error(self, "未加载私钥", "请先到「密钥」页生成或加载签名私钥。")
return
try:
payload = self._build_payload()
except ValueError as exc:
error(self, "无法签发", str(exc))
return
default_dir = self.settings.value("issue/outdir", str(Path.cwd()))
default_name = f"license_{payload['lic_id']}.lic"
path, _ = QFileDialog.getSaveFileName(
self, "保存 license 文件", str(Path(str(default_dir)) / default_name),
"License 文件 (*.lic);;所有文件 (*)",
)
if not path:
return
target = Path(path)
if target.exists() and not ask_overwrite(self, target):
return
try:
doc = build_license(payload, self.key.key, kid=self.kid_edit.text().strip())
write_license(doc, target)
except Exception as exc: # noqa: BLE001 - 签名/写盘失败统一提示
error(self, "签发失败", f"{exc}")
return
# 记住这次的选择,便于连续签发给多个客户
self.settings.setValue("issue/outdir", str(target.parent))
self.settings.setValue("issue/customer", self.customer_edit.text().strip())
self.settings.setValue("issue/note", self.note_edit.text().strip())
self.settings.setValue("issue/kid", self.kid_edit.text().strip())
self.settings.setValue("issue/days", self.days_spin.value())
# 授权编号是自动生成的,签完清空以便下一单重新生成
self.lic_id_edit.clear()
# 签发即入库:避免授权文件散落在邮件/下载目录里,日后既不知道给哪台机器
# 签过,也拿不到那台机器的指纹(续期只能再找客户要申请码)
recorded = ""
if self.record_check.isChecked():
try:
self.registry.save(
doc, public_key_fp=self.key.fingerprint, source="gui"
)
recorded = str(self.registry.path)
except Exception as exc: # noqa: BLE001 - 入库失败不应让"已签发"变成失败
warn(self, "已签发但记入台账失败",
f"{exc}\n\n授权文件已正常生成,只是台账未记录。"
"请到「台账」页检查台账库路径是否可写。")
info(
self, "已签发",
f"{target}\n\n"
f"授权编号:{payload['lic_id']}\n"
f"客户:{payload['customer'] or '(未填)'}\n"
f"机器码:{payload['machine_code']}\n"
f"授权插件:{', '.join(payload['plugins'])}\n"
f"有效期:{payload['not_before'] or '立即生效'} → {payload['expires_at'] or '永久'}\n"
f"公钥指纹:{self.key.fingerprint}\n\n"
+ (f"已记入台账:{recorded}\n"
f"续期可用「台账」页,无需客户再提供申请码。\n\n" if recorded else "")
+ "把该文件交给客户,在设备端「授权」页导入后重启网关生效。",
)