From 95b180e9cf95f99392d649405cdc2c4fa1df5923 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 7 Jul 2026 17:58:28 +0800 Subject: [PATCH] feat: add startup forced update check --- app/gui/__init__.py | 79 +++++++++++++++- app/update_check.py | 189 +++++++++++++++++++++++++++++++++++++ app/version.py | 1 + docs/06-tasks.md | 2 +- docs/current-state.md | 14 +-- docs/packaging.md | 22 ++++- progress.md | 17 ++++ tests/test_gui.py | 93 +++++++++++++++++- tests/test_update_check.py | 122 ++++++++++++++++++++++++ 9 files changed, 527 insertions(+), 12 deletions(-) create mode 100644 app/update_check.py create mode 100644 tests/test_update_check.py diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 600560c..52cb725 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -4,8 +4,9 @@ from __future__ import annotations import os import sys +import webbrowser -from .. import appconfig +from .. import appconfig, diagnostics, update_check from ..version import APP_NAME, display_name from . import widgets as _widgets from .widgets import * @@ -42,6 +43,80 @@ def _ensure_offscreen_for_headless_tests(): os.environ["QT_QPA_PLATFORM"] = "offscreen" +def _write_update_check_diagnostic(message, *, result=None, exc=None): + payload = None + if result is not None: + payload = { + "checked": result.checked, + "forced": result.forced, + "current_version": result.current_version, + "latest_version": result.latest_version, + "min_supported_version": result.min_supported_version, + "download_url": result.download_url, + "error": result.error, + } + try: + diagnostics.write_diagnostic_log( + message, + level="WARNING", + step="startup_update_check", + payload=payload, + exc=exc, + ) + except Exception: + # 版本检查第一版必须失败放行,诊断日志不可写也不能阻断启动。 + pass + + +def _forced_update_details(result): + online_version = result.latest_version or result.min_supported_version or "未知" + lines = [ + f"当前版本:{result.current_version}", + f"线上版本:{online_version}", + ] + if result.min_supported_version: + lines.append(f"最低支持版本:{result.min_supported_version}") + if result.message: + lines.append(f"升级说明:{result.message}") + if result.download_url: + lines.append("请下载新版,关闭程序后覆盖程序文件和 _internal/,保留 data/ 目录。") + else: + lines.append("版本接口未提供下载地址,请联系管理员获取新版后再使用。") + return "\n".join(lines) + + +def _show_forced_update_dialog(result, *, parent=None, opener=None) -> bool: + opener = opener or webbrowser.open + box = QMessageBox(parent) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle("必须升级") + box.setText("当前版本已不能继续使用,请先升级到新版。") + box.setInformativeText(_forced_update_details(result)) + download_button = box.addButton("下载新版", QMessageBox.AcceptRole) + exit_button = box.addButton("退出程序", QMessageBox.RejectRole) + if not result.download_url and hasattr(download_button, "setEnabled"): + download_button.setEnabled(False) + box.setDefaultButton(download_button if result.download_url else exit_button) + box.exec() + if box.clickedButton() is download_button and result.download_url: + opener(result.download_url) + return False + + +def _run_startup_update_gate(*, checker=None, opener=None) -> bool: + try: + result = (checker or update_check.check_for_update)() + except Exception as exc: + _write_update_check_diagnostic("启动版本检查异常,已允许继续使用", exc=exc) + return True + + if result.error: + _write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result) + if result.forced: + return _show_forced_update_dialog(result, opener=opener) + return True + + def main() -> int: if QT_IMPORT_ERROR is not None: print(f"{APP_NAME} GUI 无法启动:当前 Python 环境未安装 PySide6。") @@ -59,6 +134,8 @@ def main() -> int: except appconfig.ConfigError as exc: QMessageBox.critical(None, "启动配置错误", str(exc)) return 1 + if not _run_startup_update_gate(): + return 1 window = MainWindow() window.show() return app.exec() diff --git a/app/update_check.py b/app/update_check.py new file mode 100644 index 0000000..a48cd58 --- /dev/null +++ b/app/update_check.py @@ -0,0 +1,189 @@ +"""Startup update-check helpers. + +The first release only decides whether the app may enter the main window. +It never downloads, overwrites, deletes, or migrates local program/data files. +""" + +from __future__ import annotations + +import json +import re +import urllib.request +from dataclasses import dataclass + +from .version import APP_CODE_NAME, APP_UPDATE_CHECK_URL, APP_VERSION + +DEFAULT_TIMEOUT_SECONDS = 3.0 + + +class UpdateCheckError(RuntimeError): + """Raised for invalid update-check inputs or server responses.""" + + +@dataclass(frozen=True) +class UpdateInfo: + latest_version: str = "" + min_supported_version: str = "" + force_update: bool = False + download_url: str = "" + sha256: str = "" + message: str = "" + + +@dataclass(frozen=True) +class UpdateCheckResult: + current_version: str + checked: bool = False + forced: bool = False + latest_version: str = "" + min_supported_version: str = "" + download_url: str = "" + sha256: str = "" + message: str = "" + error: str = "" + + @property + def can_enter(self) -> bool: + return not self.forced + + +def parse_version(version) -> tuple[int, ...]: + """Parse semantic numeric version segments for comparison.""" + + text = str(version or "").strip() + if text.lower().startswith("v"): + text = text[1:].strip() + if not text: + raise UpdateCheckError("版本号不能为空") + + segments = [] + for part in text.split("."): + match = re.match(r"^(\d+)", part.strip()) + if match is None: + raise UpdateCheckError(f"版本号格式不正确:{version}") + segments.append(int(match.group(1))) + return tuple(segments) + + +def compare_versions(left, right) -> int: + """Return -1/0/1 for numeric semantic version comparison.""" + + left_segments = parse_version(left) + right_segments = parse_version(right) + length = max(len(left_segments), len(right_segments)) + left_padded = left_segments + (0,) * (length - len(left_segments)) + right_padded = right_segments + (0,) * (length - len(right_segments)) + if left_padded < right_padded: + return -1 + if left_padded > right_padded: + return 1 + return 0 + + +def _as_bool(value) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + return False + + +def parse_update_info(payload) -> UpdateInfo: + if not isinstance(payload, dict): + raise UpdateCheckError("版本接口返回内容不是 JSON 对象") + + release = payload.get("release") + if not isinstance(release, dict): + release = {} + + latest_version = str(payload.get("latest_version") or release.get("version") or "").strip() + min_supported_version = str( + payload.get("min_supported_version") or release.get("min_supported_version") or "" + ).strip() + if not latest_version and not min_supported_version: + raise UpdateCheckError("版本接口缺少 latest_version 或 min_supported_version") + + return UpdateInfo( + latest_version=latest_version, + min_supported_version=min_supported_version, + force_update=_as_bool(payload.get("force_update", release.get("force_update"))), + download_url=str(payload.get("download_url") or release.get("download_url") or "").strip(), + sha256=str(payload.get("sha256") or release.get("sha256") or "").strip(), + message=str( + payload.get("message") + or payload.get("release_notes") + or release.get("message") + or release.get("release_notes") + or "" + ).strip(), + ) + + +def is_forced_update(info: UpdateInfo, current_version: str) -> bool: + if info.min_supported_version and compare_versions(current_version, info.min_supported_version) < 0: + return True + if ( + info.force_update + and info.latest_version + and compare_versions(current_version, info.latest_version) < 0 + ): + return True + return False + + +def _decode_payload(raw_payload): + if isinstance(raw_payload, (bytes, bytearray)): + raw_payload = raw_payload.decode("utf-8") + if isinstance(raw_payload, str): + try: + return json.loads(raw_payload) + except ValueError as exc: + raise UpdateCheckError("版本接口返回内容不是合法 JSON") from exc + return raw_payload + + +def fetch_update_payload(url: str, timeout: float = DEFAULT_TIMEOUT_SECONDS): + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": f"{APP_CODE_NAME}/{APP_VERSION}", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return _decode_payload(response.read()) + + +def check_for_update( + *, + current_version: str = APP_VERSION, + url: str = APP_UPDATE_CHECK_URL, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + fetcher=None, +) -> UpdateCheckResult: + if not str(url or "").strip(): + return UpdateCheckResult(current_version=current_version, checked=False) + + try: + payload = (fetcher or fetch_update_payload)(url, timeout) + info = parse_update_info(_decode_payload(payload)) + forced = is_forced_update(info, current_version) + return UpdateCheckResult( + current_version=current_version, + checked=True, + forced=forced, + latest_version=info.latest_version, + min_supported_version=info.min_supported_version, + download_url=info.download_url, + sha256=info.sha256, + message=info.message, + ) + except Exception as exc: + return UpdateCheckResult( + current_version=current_version, + checked=True, + forced=False, + error=f"启动版本检查失败,已允许继续使用:{exc}", + ) diff --git a/app/version.py b/app/version.py index e6ce626..6c958cc 100644 --- a/app/version.py +++ b/app/version.py @@ -3,6 +3,7 @@ APP_NAME = "蝦皮圈優化助手" APP_CODE_NAME = "cmshopee" APP_VERSION = "0.1.0" +APP_UPDATE_CHECK_URL = "https://cm.833729.com/api/v1/client/releases/latest?platform=windows" def display_name() -> str: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 208fee2..ba99099 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -149,7 +149,7 @@ | T-539 | ⑤ 隐藏数据路径设置(设置简化,非防逆向) | T-538, T-517 | 背景:T-538 后用户数据固定收进 exe 同级 `data/`,⑤「路径与端口」里的 DB 路径(`dbPathEdit`)、图片目录(`imageDirEdit`)、账号数据根目录(`userDataRootEdit`)已不该由用户改动——改了会指向 `data/` 之外导致数据分裂/丢失。**定性说明**:本任务是**设置简化**,与 T-517/T-529 隐藏 `test_item_id`/`dry_run` 同一思路;**不是防逆向手段**——`cmshopee.db` 在磁盘上任何 SQLite 浏览器可直接打开,隐藏 UI 字段对逆向无意义,防逆向由另行任务(发布包加固/价值上移 cmhub)承担。方案:① ⑤普通设置页**隐藏** `db_path`/`image_dir`/账号数据根三个输入框(不再占表单位置),`config.json` 对应字段保留为内部兼容——手工编辑配置仍生效(内部回滚路径),加载/保存不丢字段;② **保留可见**:Chrome 路径(每台机器安装位置不同,真实配置需求)与默认端口/端口区间/CDP 就绪超时(端口冲突需可调);③ 首次运行/迁移后各路径默认解析到 `data/` 下(沿用 T-538 的 `data_dir` 规则),无需用户感知。同步 `docs/routes.md` ⑤ 说明与 GUI 设置单测(隐藏字段不再可见、config 字段仍读写、手工配置值仍被尊重)。边界:只改⑤设置页展示层与文档;不改 `config.json` schema、路径解析逻辑(T-538 范围)、DB、业务流程、CDP/Shopee | DONE | | T-540 | 打包版本号与 release 产物命名统一 | T-537, T-538, T-524 | 参考 `D:\chengma\cmbot` 的轻量版本控制方式,但本阶段不引入 Launcher/manifest/自动更新。方案:① 新增 `app/version.py`,统一维护 `APP_NAME="蝦皮圈優化助手"`、`APP_CODE_NAME="cmshopee"`、`APP_VERSION`,作为 GUI 标题、打包脚本、release 目录、zip 文件名和 `version.txt` 的唯一版本源;② 主窗口标题改为 `蝦皮圈優化助手 v`,PySide6 缺失提示继续读中文品牌名;③ `scripts/build_exe.ps1` 从 `app/version.py` 读取版本,并固定通过 Windows Python Launcher 调用 `py -3.10`,返回版本必须是 `3.10.x`;④ PyInstaller 仍输出 `dist\cmshopee\` 作为中间产物,再组装 `release\cmshopee-\` 和 `release\cmshopee--portable.zip`,发布目录内写 `version.txt`(无 BOM)和中文 `README.txt`;⑤ 发布目录/zip 仍不得包含 `data/`、DB、配置、图片、日志、Chrome 登录态或运营 Excel;当前 PyInstaller 6.11.1 onedir 结构必须包含 `cmshopee.exe` + `_internal\`,脚本会校验 `_internal\` 存在;⑥ 测试覆盖版本文件解析、窗口标题版本、构建脚本 release 命名/排除数据/固定 `py -3.10` 打包。边界:只做手动发版版本号一致性和 release 组装;不做在线更新、启动器、manifest、sha256 下载校验、强制更新或自动替换程序目录 | DONE | | T-543 | 状态栏语义色与统一提示入口 | T-511, T-523, T-536 | 需求:当前左下角状态栏提示几乎都通过 `statusBar().showMessage(...)` 直接写入,颜色一致,用户很难快速区分“已保存/已完成”“正在处理”“没有可生成任务”“未登录/数据目录不可写”等不同严重程度。方案:① 在 `MainWindow` 增加统一入口 `show_status(message, level="muted")`,使用 `docs/ui-color-design.md` 已定语义色板映射 `muted/info/success/warning/danger`,内部先设置状态栏文字颜色再 `showMessage()`,普通/就绪提示必须重置为 muted 或默认色,避免上一条红/绿状态残留;② 逐步替换主窗口和 ①②③④⑤ Tab 传入的 `status_callback=self.statusBar().showMessage` 以及直接调用,新增代码必须显式传 level,不长期依赖中文关键词猜测;③ 颜色口径:普通/导航/就绪用 muted 或默认,进行中用 info,保存/完成/回写成功用 success,当前筛选无任务、请先采集/配置等用户可处理问题用 warning,失败、阻断、未登录、数据目录不可写、点数不足等中止当前动作的问题用 danger;④ 状态栏只改文字色,不做大面积背景,不替代弹窗、空状态、按钮禁用、运行日志或任务状态列;⑤ 复用 `app/gui/widgets.py` 语义色常量,不新增第二套颜色;⑥ GUI 单测覆盖 `show_status()` 各等级颜色、普通提示会清掉前一条错误颜色,以及至少一个保存成功/无任务/失败路径调用正确等级。边界:只改 GUI 提示层和测试;不改业务流程、DB schema、AI HTTP、Excel、Shopee/CDP、运行日志持久化 | DONE | -| T-544 | 启动时检查强制升级(第一版:提示下载,不自动覆盖) | T-540, T-538, T-524 | 需求:程序启动后先请求服务器版本接口;如果线上版本高于当前程序版本且服务端要求强制升级,则必须弹窗阻断进入主界面,用户只能下载新版或退出。第一版方案:① 新增 `app/update_check.py`,读取当前版本 `APP_VERSION`,请求固定版本接口(后续可放配置/构建常量),超时短且错误中文化;② 服务端返回建议结构:`latest_version`、`min_supported_version`、`force_update`、`download_url`、`sha256`、`message`。比较规则:当前版本 `< min_supported_version` 必须升级;或 `force_update=true` 且当前版本 `< latest_version` 必须升级;版本比较使用语义化数字段比较,不用字符串字典序;③ 启动流程在 `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前执行版本检查;命中强制升级时弹中文阻断框,显示当前版本/线上版本/升级说明,按钮只保留「下载新版」和「退出程序」,点击下载用系统浏览器打开 `download_url` 后退出或保持阻断,不允许继续进入主界面;④ 网络失败、接口超时、JSON 非法、版本字段缺失时第一版**允许进入软件**,只在本地诊断日志记录/可选状态提示,不因服务器或用户断网导致全员不可用;只有服务端明确返回强制升级才阻断;⑤ 第一版**不自动下载并覆盖** `cmshopee.exe` 或 `_internal/`,不删除/覆盖 `data/`,不做 Launcher、后台替换、增量补丁、签名校验或回滚;用户仍按打包文档手动下载 zip、关闭程序、覆盖程序文件和 `_internal/`、保留 `data/`;⑥ 单测覆盖版本比较、强制升级判定、网络失败允许进入、非法响应允许进入、启动流程强制阻断弹窗和打开下载链接。边界:只做启动检查与强制提示,不改 DB schema、业务流程、AI、Excel、Shopee/CDP,不自动替换任何本地文件 | TODO | +| T-544 | 启动时检查强制升级(第一版:提示下载,不自动覆盖) | T-540, T-538, T-524 | 需求:程序启动后先请求服务器版本接口;如果线上版本高于当前程序版本且服务端要求强制升级,则必须弹窗阻断进入主界面,用户只能下载新版或退出。第一版方案:① 新增 `app/update_check.py`,读取当前版本 `APP_VERSION`,请求固定版本接口(后续可放配置/构建常量),超时短且错误中文化;② 服务端返回建议结构:`latest_version`、`min_supported_version`、`force_update`、`download_url`、`sha256`、`message`。比较规则:当前版本 `< min_supported_version` 必须升级;或 `force_update=true` 且当前版本 `< latest_version` 必须升级;版本比较使用语义化数字段比较,不用字符串字典序;③ 启动流程在 `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前执行版本检查;命中强制升级时弹中文阻断框,显示当前版本/线上版本/升级说明,按钮只保留「下载新版」和「退出程序」,点击下载用系统浏览器打开 `download_url` 后退出或保持阻断,不允许继续进入主界面;④ 网络失败、接口超时、JSON 非法、版本字段缺失时第一版**允许进入软件**,只在本地诊断日志记录/可选状态提示,不因服务器或用户断网导致全员不可用;只有服务端明确返回强制升级才阻断;⑤ 第一版**不自动下载并覆盖** `cmshopee.exe` 或 `_internal/`,不删除/覆盖 `data/`,不做 Launcher、后台替换、增量补丁、签名校验或回滚;用户仍按打包文档手动下载 zip、关闭程序、覆盖程序文件和 `_internal/`、保留 `data/`;⑥ 单测覆盖版本比较、强制升级判定、网络失败允许进入、非法响应允许进入、启动流程强制阻断弹窗和打开下载链接。边界:只做启动检查与强制提示,不改 DB schema、业务流程、AI、Excel、Shopee/CDP,不自动替换任何本地文件 | DONE | ## 里程碑 diff --git a/docs/current-state.md b/docs/current-state.md index dd167ae..8b6d8b6 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,17 +11,17 @@ - T-525 已完成:根目录新增 `pyproject.toml` 与 `requirements-dev.txt`,引入 ruff 0.5.7;当前只开启安全类规则(`E4/E7/E9/F`),CI 已在语法检查和 unittest 前运行 `python -m ruff check app tests main.py`;`ruff format` 配置可用,但本轮不做全仓格式化重排。 - T-539 已完成:⑤设置页普通 UI 不再显示账号数据根目录、图片目录、DB 路径输入框,只保留 Chrome 路径和端口/CDP 设置;`config.json` 里的 `user_data_root`、`image_dir`、`db_path` 继续保留并在保存时原样写回,手工配置值仍生效。 - T-543 已完成:左下角状态栏已统一通过 `MainWindow.show_status(message, level)` 显示,按 muted/info/success/warning/danger 区分普通、进行中、成功、需用户处理和失败/阻断;只改状态栏文字色,不替代弹窗、空状态、运行日志或任务状态列。 -- T-544 已定义为下一步正式任务:启动时请求服务器版本接口;若服务端明确要求强制升级,则阻断进入主界面,只提供「下载新版」和「退出程序」。第一版不自动覆盖 exe 或 `_internal/`,用户仍手动下载 zip、关闭程序、覆盖程序文件并保留 `data/`。 +- T-544 已完成:启动入口在 `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前执行版本检查;服务端明确要求强制升级时弹中文阻断框,只提供「下载新版」和「退出程序」,点击下载会用系统浏览器打开 `download_url` 并退出启动流程。第一版不自动覆盖 exe 或 `_internal/`,不触碰 `data/`;`APP_UPDATE_CHECK_URL` 当前已配置为线上接口 `https://cm.833729.com/api/v1/client/releases/latest?platform=windows`,接口异常仍按失败放行并写诊断日志。 - 测试卫生修正:`WriteBackWorker` 的 GUI 单测不再使用相对路径 `db.sqlite`,改为临时目录内 SQLite,避免跑测试后在项目根目录重新生成空 `db.sqlite`。 - 技术栈:正式打包固定 Python 3.10(脚本调用 `py -3.10`),CI 使用 Windows + Python 3.11 自动跑 ruff lint、语法和单元/GUI 测试,根目录 `requirements.txt` 锁定运行依赖,`requirements-dev.txt` 锁定开发检查依赖(ruff),PyInstaller onedir 打包为 Windows 免安装 exe;自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `data/config.json` + openpyxl + AI(默认 cmhub: `data/config.json` 网关配置 + `data/config/cmhub.json` Key;direct: `data/config/ai_models.json` 通用 HTTP 内部兼容),GUI PySide6 5 Tab(已定)。 - 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 蝦皮 CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、蝦皮更新安全与执行模式默认值,`ai.backend` 默认 `cmhub`,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,`config/cmhub.json` cmhub Key 读写/打码 helper、cmhub Base URL 规整与请求 URL 拼接 helper,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,支持 direct 通用 HTTP 和 cmhub 网关 backend,direct 按默认文本/图片模型调用,cmhub 按 Base URL + 生文/生图别名调用 title/image/models/balance 接口,生图 `image_url` 安全下载后转本地 JPEG,计费 metadata 通过事件回调传出,HTTP 404 映射为 `not_found` 并给出中文排障提示,且保留重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a/T-534 本地生成结果/更新状态重置函数(②生成结果可按标题/封面组件重置并保持 generated)、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新蝦皮、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录幂等复用已打开 Chrome、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/状态栏语义色 helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`(窗口标题显示「蝦皮圈優化助手」)、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、状态栏语义色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新蝦皮 Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/cmhub余额显示/点数不足弹窗/计费日志/重置生成结果与 `GenerateWorker`,且②重置支持多选/当前筛选结果并可只重置标题、只重置封面或重置全部;③ 更新蝦皮批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置页 T-529 后默认展示 cmhub 网关 Base URL、API Key、动态生文/生图别名下拉、Base URL 网关根提示、刷新别名与测试连接/查余额,不再显示「AI 后端」label/dropdown、direct 模型选择、模型详情或标题/图片模型角色下拉;保存设置固定写 `ai.backend=cmhub`,保存/刷新前会把 Base URL 规整为网关根,允许先保存不完整 cmhub 配置,生成时再提示补齐;direct 模型配置、`AIModelTestWorker` 和 `config/ai_models.json` 仅作内部兼容/手工回滚;通用生成参数、Chrome 路径和端口、蝦皮更新安全与多账号并行设置继续持久化 `config.json`,数据路径字段在普通 UI 隐藏但保存时保留配置兼容,cmhub Key 单独写入 `config/cmhub.json`,保存成功后弹轻量提示框;⑤ 设置页已将「蝦皮更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest,T-525 后 CI 先运行 ruff 安全类 lint;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts(含 T-105b 启动登录复用已运行 Chrome)/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新蝦皮批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、蝦皮更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、蝦皮更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验、T-526 cmhub backend mock、T-527 设置页 cmhub backend 切换/别名下拉/worker mock、T-528 ②cmhub余额显示/计费日志/点数不足中止提示、T-529 默认 cmhub/隐藏 AI 后端选择/保存固定 cmhub/direct 兼容、T-530 cmhub Base URL 规整/404 not_found 明确提示、T-531 设置页未保存状态/离开确认/放弃还原、T-532 cmhub连接成功账号名提示、T-533 增量生成组件口径、T-534 ②按标题/封面组件重置与多选/筛选范围重置、T-535 cmhub标题请求读取等待固定600秒、T-536 GUI按钮全局基础样式与语义色按钮叠加样式、T-537 主窗口标题显示中文品牌、T-539 设置页隐藏数据路径字段并保留配置值、T-543 状态栏语义色与颜色重置;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 +- 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest,T-525 后 CI 先运行 ruff 安全类 lint;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts(含 T-105b 启动登录复用已运行 Chrome)/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新蝦皮批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、蝦皮更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、蝦皮更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验、T-526 cmhub backend mock、T-527 设置页 cmhub backend 切换/别名下拉/worker mock、T-528 ②cmhub余额显示/计费日志/点数不足中止提示、T-529 默认 cmhub/隐藏 AI 后端选择/保存固定 cmhub/direct 兼容、T-530 cmhub Base URL 规整/404 not_found 明确提示、T-531 设置页未保存状态/离开确认/放弃还原、T-532 cmhub连接成功账号名提示、T-533 增量生成组件口径、T-534 ②按标题/封面组件重置与多选/筛选范围重置、T-535 cmhub标题请求读取等待固定600秒、T-536 GUI按钮全局基础样式与语义色按钮叠加样式、T-537 主窗口标题显示中文品牌、T-539 设置页隐藏数据路径字段并保留配置值、T-543 状态栏语义色与颜色重置、T-544 启动版本检查/强制升级阻断/失败放行;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 - 数据:T-538 后默认用户数据根为 `data/`(整体 gitignore);旧布局的 `config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`、`build/`、`dist/` 仍由 `.gitignore` 排除以支持迁移前安全;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `data/config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) - 产品显示名:`蝦皮圈優化助手`;`cmshopee` 仅作为仓库、代码、打包产物、数据库、日志和兼容文件名等技术代号保留。 -- GUI:**PySide6 5 Tab 流水线**,目标顺序文案为 ① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置;后台任务用 worker/QThread/signal。 +- GUI:**PySide6 5 Tab 流水线**,目标顺序文案为 ① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置;后台任务用 worker/QThread/signal。启动时会先执行 T-544 版本检查,命中服务端强制升级时阻断进入主窗口。 - 全局用户可见文案规则:弹窗标题/正文/按钮、窗口标题、按钮、菜单、label、placeholder、tooltip、状态栏、空状态、确认框、运行日志、错误提示和成功提示等都必须使用中文。运行程序后 GUI 上的 `shopee/Shopee` 平台名统一显示为「蝦皮」;URL、配置键、API 字段、模型别名、第三方/Shopee 原始错误可保留原文,但必须配中文解释,不得裸露英文技术报错。 - 流水线阶段:imported → collected(采集旧标题/旧封面+下载+回写)→ generated(AI 提示词生成新标题/新封面)→ applied(③ 弹窗批量确认后改 Shopee 并提交)。**无 confirmed、无常驻提交开关。** - 存储:`data/config.json`(应用设置和 AI backend 内部字段,普通默认 cmhub)+ `data/config/ai_models.json`(direct AI 模型清单与本地明文 Key)+ `data/config/cmhub.json`(cmhub 本地明文 Key)+ SQLite `data/cmshopee.db`(账号/任务/各阶段结果,密码本地明文仅参考)+ openpyxl(Excel)+ 本地 `data/images/`(旧/新封面);密码/API Key 保存或变更时提示,展示和日志/导出必须脱敏。 @@ -81,8 +81,8 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 - 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-103b(登录检测补充识别 Shopee accounts 登录页)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-105b(④启动登录复用已打开 Chrome)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ 蝦皮更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新蝦皮生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)、T-523a(②/③ 新一轮运行前清空界面日志显示)、T-404b(商品详情页加载失败 toast 自动捕获)、T-524(PyInstaller 打包为免安装 exe)、T-526(`app/ai.py` + `appconfig` 接入 cmhub backend)、T-527(⑤设置 cmhub 网关面板)、T-528(②计费错误提示 + 余额展示)、T-529(默认 cmhub 网关并隐藏 AI 后端选择)、T-530(cmhub Base URL 规整 + 404 明确提示)、T-531(⑤设置未保存状态追踪 + 离开确认)、T-532(⑤cmhub连接成功提示显示账号名)。 -- 最近完成补充:T-533(②增量生成:按缺失组件补生成)、T-534(②重置增强:多选/筛选范围 + 按组件重置)、T-535(②cmhub生成标题读取等待固定600秒)、T-536(GUI按钮圆角全局统一)、T-537(品牌名显示为蝦皮圈優化助手)、T-538(打包产物用户数据收进 `data/`)、T-539(⑤隐藏数据路径设置)、T-540(打包版本号与 release 产物命名统一)、T-541(打包版主窗口初始位置与小屏适配)、T-542(GUI 用户可见 shopee/Shopee 文案统一改为蝦皮)、T-543(状态栏语义色与统一提示入口)、T-525(引入 ruff lint/format 工程检查)。 -- 下一个可领取任务:T-544(启动时检查强制升级,第一版只提示下载,不自动覆盖)。 +- 最近完成补充:T-533(②增量生成:按缺失组件补生成)、T-534(②重置增强:多选/筛选范围 + 按组件重置)、T-535(②cmhub生成标题读取等待固定600秒)、T-536(GUI按钮圆角全局统一)、T-537(品牌名显示为蝦皮圈優化助手)、T-538(打包产物用户数据收进 `data/`)、T-539(⑤隐藏数据路径设置)、T-540(打包版本号与 release 产物命名统一)、T-541(打包版主窗口初始位置与小屏适配)、T-542(GUI 用户可见 shopee/Shopee 文案统一改为蝦皮)、T-543(状态栏语义色与统一提示入口)、T-544(启动时检查强制升级,第一版只提示下载,不自动覆盖)、T-525(引入 ruff lint/format 工程检查)。 +- 下一个可领取任务:暂无正式 TODO;后续可从 Backlog 选择新任务或重新打包验证生产版启动检查。 ## 当前已知限制 @@ -116,7 +116,7 @@ - T-537 已完成:产品显示名定为「蝦皮圈優化助手」;主窗口标题、PySide6 缺失启动提示、根 README、入口/导航/愿景文档和 UI 线框图显示中文品牌;`cmshopee` 继续作为仓库、包名、exe、数据库、日志、配置和兼容文件名等技术代号保留。 - T-538 已完成:默认用户数据根统一为 `<程序目录>/data`(源码为项目根 `data/`,打包版为 exe 同级 `data/`);`main.py` 不再 `chdir` 到 exe 目录;启动时迁移 T-524 旧顶层布局数据并检测 `data/` 可写;旧布局和 `data/` 同名冲突时弹“数据迁移冲突”,不可写时才弹“数据目录不可写”;`scripts/build_exe.ps1` 发布目录校验新增禁止打包 `data/`;用户更新方式改为覆盖当前发布包里的程序文件和依赖文件并保留 `data/`。T-540 后当前锁定 PyInstaller 6.11.1,`dist/cmshopee` 必须包含 `cmshopee.exe` 和 `_internal/`。 - T-539 已完成:⑤设置页隐藏 `user_data_root`、`image_dir`、`db_path` 三个数据路径输入框,不再让普通用户把数据指到 `data/` 外;保存设置时这些字段继续从当前配置原样保留并写回,Chrome 路径、默认端口、端口范围、CDP 就绪超时仍可见可调。 -- T-540 已完成:新增 `app/version.py` 作为唯一版本源,GUI 标题显示 `蝦皮圈優化助手 v`;`scripts/build_exe.ps1` 读取 `APP_VERSION`,固定使用 `py -3.10` 产出 `dist/cmshopee`,再组装 `release/cmshopee-`、写入 `version.txt`/中文 `README.txt`,并生成 `release/cmshopee--portable.zip`。T-544 已定义启动强制升级检查,但第一版仍不引入 Launcher、后台自动覆盖、增量补丁或失败回滚。 +- T-540 已完成:新增 `app/version.py` 作为唯一版本源,GUI 标题显示 `蝦皮圈優化助手 v`;`scripts/build_exe.ps1` 读取 `APP_VERSION`,固定使用 `py -3.10` 产出 `dist/cmshopee`,再组装 `release/cmshopee-`、写入 `version.txt`/中文 `README.txt`,并生成 `release/cmshopee--portable.zip`。T-544 已接入启动强制升级检查,但第一版仍不引入 Launcher、后台自动覆盖、增量补丁或失败回滚。 - T-541 已完成:`MainWindow` 启动时通过 `_fit_and_center_window()` 读取 `QApplication.primaryScreen().availableGeometry()`,按可用屏幕限制初始尺寸、居中并夹进屏幕;首次 `showEvent` 后再应用一次,保证打包版在 Windows 10 VM/小分辨率环境标题栏完整可见、可拖动,不保存历史坏坐标。 - 主窗口标题当前由 `app.version.display_name()` 统一显示为「蝦皮圈優化助手 v」;T-541 不改品牌/版本来源,只改启动尺寸与位置。 - T-521 已完成:根目录新增 `requirements.txt` 并锁定当前运行依赖版本;`docs/03-tech-stack.md` 和 `docs/README.md` 已改为 `python -m pip install -r requirements.txt` 安装;未新增运行时依赖、未改业务代码。 @@ -125,7 +125,7 @@ - T-523a 已完成:②点击「开始生成」、③点击「检查本轮更新」或「开始更新」时,先清空对应界面的旧日志显示并写入本轮开始摘要;运行中只追加本轮日志。历史 `run_logs/run_log_events` 和本地 `logs/` 不删除、不自动混入当前运行界面。 - T-404b 已完成:`open_product()` 在导航商品详情页前后安装 toast 捕获,等待详情页关键元素超时时会读取最近 `.eds-toasts`/toast/message 节点及页面缓存,把明确商品失效/商品不存在/无权限类 toast 上浮为 `商品失效:<原始toast>`;如果失败发生在 `open_product()` 内部,本轮自动新建的失败 tab 会关闭,复用用户已有 tab 不关闭;①列表“阶段”列只在这类明确失效错误时显示“商品失效”,底层仍保持 `stage=imported/status=failed`,其他打开失败仍显示“失败”。 - T-524 已完成:新增 PyInstaller onedir 打包配置、构建脚本和发布目录校验;T-538 后打包版不再切换全进程工作目录,首次运行在 exe 同级 `data/` 生成/使用本地配置、DB、图片、日志、登录态和提示词;发布包不内置 `data/`,用户后续更新采用关闭程序后覆盖程序文件、保留 `data/` 的方式。T-544 第一版的强制升级弹窗也沿用该手动覆盖方式。 -- Phase 7 cmhub 网关对接已完成 T-526~T-535;②生成阶段已支持按标题/封面缺失组件增量生成,②重置生成结果已支持多选/筛选范围和按标题/封面组件重置,cmhub 标题请求读取等待固定 600 秒。T-539、T-541、T-542、T-543 与 T-525 已完成;正式任务看板当前下一个 TODO 为 T-544。 +- Phase 7 cmhub 网关对接已完成 T-526~T-535;②生成阶段已支持按标题/封面缺失组件增量生成,②重置生成结果已支持多选/筛选范围和按标题/封面组件重置,cmhub 标题请求读取等待固定 600 秒。T-539、T-541、T-542、T-543、T-544 与 T-525 已完成;正式任务看板当前无 TODO。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `data/config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - 本地 `data/config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 diff --git a/docs/packaging.md b/docs/packaging.md index 7dd3f04..bf3c1fb 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -63,11 +63,13 @@ cmshopee\ APP_NAME = "蝦皮圈優化助手" APP_CODE_NAME = "cmshopee" APP_VERSION = "0.1.0" +APP_UPDATE_CHECK_URL = "https://cm.833729.com/api/v1/client/releases/latest?platform=windows" ``` 规则: - 修改版本号只改 `app/version.py` 的 `APP_VERSION`。 +- 线上版本检查接口只改 `app/version.py` 的 `APP_UPDATE_CHECK_URL`;当前正式接口为 `https://cm.833729.com/api/v1/client/releases/latest?platform=windows`。如需本地开发临时跳过启动检查,可把该值置空。 - GUI 窗口标题读取同一版本源,显示为 `蝦皮圈優化助手 v0.1.0`。 - 打包脚本读取同一版本源生成发布目录、压缩包和 `version.txt`。 - 禁止在 GUI、构建脚本或文档示例之外重复硬编码不同版本号。 @@ -113,8 +115,8 @@ T-544 第一版目标是**启动时检查是否必须升级**,但仍不做自 客户端启动流程: -1. `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前,请求服务器版本接口。 -2. 当前版本从 `app/version.py` 的 `APP_VERSION` 读取。 +1. `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前,请求 `app/version.py` 中 `APP_UPDATE_CHECK_URL` 指向的服务器版本接口。 +2. 当前版本从 `app/version.py` 的 `APP_VERSION` 读取;`APP_UPDATE_CHECK_URL` 为空时跳过检查并允许进入软件,用于本地开发或尚未配置线上接口的构建。 3. 如果服务器明确返回“当前版本必须升级”,弹中文阻断框,不进入主界面。 4. 弹窗只提供「下载新版」和「退出程序」。点击「下载新版」用系统浏览器打开下载地址;用户下载后按第六节手动覆盖程序文件。 5. 网络失败、接口超时、JSON 非法或字段缺失时,第一版允许进入软件;只记录本地诊断日志或给低打扰提示,避免服务器故障导致所有用户不可用。 @@ -132,6 +134,22 @@ T-544 第一版目标是**启动时检查是否必须升级**,但仍不做自 } ``` +客户端同时兼容当前线上接口返回结构: + +```json +{ + "platform": "windows", + "release": { + "version": "0.1.0", + "download_url": "https://example.com/cmshopee-0.1.0-portable.zip", + "sha256": "", + "release_notes": "发布说明" + } +} +``` + +映射规则:`release.version` 作为 `latest_version`,`release.download_url` 作为下载地址,`release.sha256` 作为校验值,`release.release_notes` 作为弹窗说明。若该结构未返回 `min_supported_version` 或 `force_update=true`,客户端只记录可用新版本信息,不会阻断启动。 + 客户端判定规则: - `APP_VERSION < min_supported_version`:必须升级。 diff --git a/progress.md b/progress.md index aab0527..1b33215 100644 --- a/progress.md +++ b/progress.md @@ -1413,3 +1413,20 @@ - 第一版方案:只做“启动检查 + 强制弹窗 + 下载新版/退出程序”。命中强制升级时显示当前版本、线上版本、升级说明,点击「下载新版」用系统浏览器打开 `download_url`;不允许继续进入主界面。 - 策略:只有服务端明确返回强制升级才阻断;网络失败、接口超时、JSON 非法或字段缺失时允许进入软件,避免服务器故障导致用户全部不可用。 - 边界:第一版不自动下载并覆盖 `cmshopee.exe` 或 `_internal/`,不删除/覆盖 `data/`,不做 Launcher、后台替换、增量补丁、签名校验或回滚;本轮只更新文档,未改代码。 + +## 【2026-07-07】T-544 完成 · 启动时检查强制升级 + +- 状态:DONE +- 代码:新增 `app/update_check.py`,实现版本号数字段比较、服务端版本响应解析、强制升级判定、短超时 HTTP 请求和失败放行结果;`app/version.py` 新增 `APP_UPDATE_CHECK_URL` 构建常量,默认空值跳过检查,正式发布前填线上版本接口。 +- 启动流程:`app/gui/__init__.py` 在 `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前执行版本检查。服务端明确要求强制升级时弹中文阻断框,显示当前版本、线上版本、最低支持版本和升级说明,按钮只保留「下载新版」和「退出程序」;点击下载用系统浏览器打开 `download_url` 并退出启动流程。 +- 失败策略:网络失败、超时、非法 JSON、缺少版本字段或诊断日志写入失败都不会阻断启动;可记录到本地 `data/logs/cmshopee.log`,避免服务器故障导致所有用户不可用。 +- 文档:`docs/06-tasks.md` 标记 T-544 为 DONE;`docs/packaging.md` 补充 `APP_UPDATE_CHECK_URL` 配置口径;`docs/current-state.md` 更新当前快照、测试覆盖和看板状态。 +- 验证:`python -m unittest discover -s tests -p "test_update_check.py"` 通过(7 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(101 tests);`python -m ruff check app tests main.py` 通过;`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(232 tests)。 +- 边界:未改 DB schema、AI、Excel、Shopee/CDP 或业务更新流程;不自动下载、覆盖或删除 `cmshopee.exe`、`_internal/`、`data/`。 + +## 【2026-07-07】T-544 补充 · 配置线上版本接口并实测 + +- 代码:`APP_UPDATE_CHECK_URL` 已更新为 `https://cm.833729.com/api/v1/client/releases/latest?platform=windows`;`app/update_check.py` 兼容当前线上返回的 `release.version/download_url/sha256/release_notes` 嵌套结构。 +- 实测:接口 HTTP 200,返回 `platform=windows`、`release.version=0.1.0`、`download_url=https://43.128.3.240:33717/down/tip8cOrTbZJT.zip`、发布说明等字段。 +- 结果:当前程序 `APP_VERSION=0.1.0`,线上版本也是 `0.1.0`,且接口未返回 `min_supported_version` 或 `force_update=true`,所以客户端判定 `forced=False`、`can_enter=True`。 +- 验证:`py -3.10 -m unittest discover -s tests -p "test_update_check.py"` 通过(8 tests);`py -3.10 -m compileall app main.py` 通过。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 26181b7..3f2d591 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -12,7 +12,7 @@ sys.path.insert(0, os.path.dirname(__file__)) from _helpers import TempDirMixin from app import gui -from app import accounts, ai, appconfig, db, prompts +from app import accounts, ai, appconfig, db, prompts, update_check if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") @@ -203,6 +203,97 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_startup_update_gate_forced_blocks_and_opens_download(self): + boxes = [] + + class FakeButton: + def __init__(self, label): + self.label = label + self.enabled = True + + def setEnabled(self, enabled): + self.enabled = enabled + + class FakeMessageBox: + Warning = object() + AcceptRole = object() + RejectRole = object() + + def __init__(self, parent=None): + self.parent = parent + self.icon = None + self.title = "" + self.text = "" + self.informative_text = "" + self.buttons = {} + self.default_button = None + boxes.append(self) + + def setIcon(self, icon): + self.icon = icon + + def setWindowTitle(self, title): + self.title = title + + def setText(self, text): + self.text = text + + def setInformativeText(self, text): + self.informative_text = text + + def addButton(self, label, role): + button = FakeButton(label) + self.buttons[label] = button + return button + + def setDefaultButton(self, button): + self.default_button = button + + def exec(self): + return 0 + + def clickedButton(self): + return self.buttons["下载新版"] + + result = update_check.UpdateCheckResult( + current_version="1.0.0", + checked=True, + forced=True, + latest_version="1.2.0", + min_supported_version="1.1.0", + download_url="https://example.test/cmshopee.zip", + message="必须升级", + ) + opened = [] + + with mock.patch("app.gui.QMessageBox", FakeMessageBox): + allowed = gui._run_startup_update_gate( + checker=lambda: result, + opener=opened.append, + ) + + self.assertFalse(allowed) + self.assertEqual(["https://example.test/cmshopee.zip"], opened) + self.assertEqual("必须升级", boxes[0].title) + self.assertIn("当前版本:1.0.0", boxes[0].informative_text) + self.assertIn("线上版本:1.2.0", boxes[0].informative_text) + self.assertIn("保留 data/ 目录", boxes[0].informative_text) + self.assertEqual(boxes[0].buttons["下载新版"], boxes[0].default_button) + + def test_startup_update_gate_check_failure_allows_entry_and_logs(self): + result = update_check.UpdateCheckResult( + current_version="1.0.0", + checked=True, + forced=False, + error="启动版本检查失败,已允许继续使用:网络超时", + ) + + with mock.patch("app.gui.diagnostics.write_diagnostic_log") as write_log: + allowed = gui._run_startup_update_gate(checker=lambda: result) + + self.assertTrue(allowed) + write_log.assert_called_once() + def test_status_callbacks_classify_success_warning_and_failure(self): with self.make_temp_dir() as temp_dir: statuses = [] diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000..5638e44 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,122 @@ +import socket +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from _helpers import REPO_ROOT + +from app import update_check + +assert REPO_ROOT + + +class UpdateCheckTests(unittest.TestCase): + def test_compare_versions_uses_numeric_segments(self): + self.assertGreater(update_check.compare_versions("0.10.0", "0.2.0"), 0) + self.assertEqual(0, update_check.compare_versions("1.2", "1.2.0")) + self.assertLess(update_check.compare_versions("v1.2.3", "1.2.4"), 0) + + def test_forced_update_by_min_supported_version(self): + info = update_check.UpdateInfo( + latest_version="1.2.0", + min_supported_version="1.1.0", + force_update=False, + ) + + self.assertTrue(update_check.is_forced_update(info, "1.0.9")) + self.assertFalse(update_check.is_forced_update(info, "1.1.0")) + + def test_forced_update_by_force_flag_and_latest_version(self): + forced = update_check.UpdateInfo(latest_version="1.2.0", force_update=True) + optional = update_check.UpdateInfo(latest_version="1.2.0", force_update=False) + + self.assertTrue(update_check.is_forced_update(forced, "1.1.9")) + self.assertFalse(update_check.is_forced_update(optional, "1.1.9")) + self.assertFalse(update_check.is_forced_update(forced, "1.2.0")) + + def test_check_for_update_forced_response(self): + def fetcher(_url, _timeout): + return { + "latest_version": "1.2.0", + "min_supported_version": "1.1.0", + "force_update": True, + "download_url": "https://example.test/cmshopee.zip", + "sha256": "abc", + "message": "请升级后继续使用", + } + + result = update_check.check_for_update( + current_version="1.0.0", + url="https://example.test/version.json", + fetcher=fetcher, + ) + + self.assertTrue(result.checked) + self.assertTrue(result.forced) + self.assertFalse(result.can_enter) + self.assertEqual("1.2.0", result.latest_version) + self.assertEqual("https://example.test/cmshopee.zip", result.download_url) + + def test_check_for_update_accepts_release_wrapper_response(self): + def fetcher(_url, _timeout): + return { + "platform": "windows", + "release": { + "version": "0.1.1", + "download_url": "https://example.test/cmshopee-0.1.1.zip", + "sha256": "abc", + "release_notes": "新版说明", + }, + } + + result = update_check.check_for_update( + current_version="0.1.0", + url="https://example.test/releases/latest?platform=windows", + fetcher=fetcher, + ) + + self.assertTrue(result.checked) + self.assertFalse(result.forced) + self.assertEqual("0.1.1", result.latest_version) + self.assertEqual("https://example.test/cmshopee-0.1.1.zip", result.download_url) + self.assertEqual("abc", result.sha256) + self.assertEqual("新版说明", result.message) + + def test_network_failure_allows_entry(self): + def fetcher(_url, _timeout): + raise socket.timeout("timeout") + + result = update_check.check_for_update( + current_version="1.0.0", + url="https://example.test/version.json", + fetcher=fetcher, + ) + + self.assertTrue(result.checked) + self.assertFalse(result.forced) + self.assertTrue(result.can_enter) + self.assertIn("启动版本检查失败", result.error) + + def test_invalid_response_allows_entry(self): + result = update_check.check_for_update( + current_version="1.0.0", + url="https://example.test/version.json", + fetcher=lambda _url, _timeout: {"message": "missing versions"}, + ) + + self.assertTrue(result.checked) + self.assertFalse(result.forced) + self.assertTrue(result.can_enter) + self.assertIn("缺少 latest_version", result.error) + + def test_empty_update_url_skips_check(self): + result = update_check.check_for_update(current_version="1.0.0", url="") + + self.assertFalse(result.checked) + self.assertFalse(result.forced) + self.assertEqual("", result.error) + + +if __name__ == "__main__": + unittest.main()