"""签发页:把"申请码 → 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 "") + "把该文件交给客户,在设备端「授权」页导入后重启网关生效。", )