From 240626f9450bc9368cff330704fad44591b10a52 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 14:55:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=20Client=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E4=BA=A7=E7=89=A9=E5=91=BD=E5=90=8D=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/build_tools/release_manifest.py | 15 ++++++- client/packaging/build.ps1 | 17 ++++++-- client/src/settings_ui.py | 2 +- client/src/update_service.py | 3 +- client/src/update_ui_event.py | 4 ++ client/src/version.py | 2 +- client/test/test_packaging.py | 20 ++++++++-- client/test/test_update_service.py | 7 ++-- client/test/test_update_ui_event.py | 54 ++++++++++++++++++++++++-- docs/client/00-getting-started.md | 5 ++- docs/client/01-requirements.md | 13 +++++-- docs/client/06-quality-security.md | 2 +- 12 files changed, 119 insertions(+), 25 deletions(-) diff --git a/client/build_tools/release_manifest.py b/client/build_tools/release_manifest.py index 3ddbb43..9acf557 100644 --- a/client/build_tools/release_manifest.py +++ b/client/build_tools/release_manifest.py @@ -5,11 +5,24 @@ from __future__ import annotations import argparse import hashlib import json +import re from pathlib import Path from typing import Any -MANIFEST_FILE_NAME = "autobuy——manifest.json" +MANIFEST_FILE_NAME = "autobuy_manifest.json" +_VERSION_PATTERN = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$" +) + + +def versioned_manifest_file_name(version: str) -> str: + """返回版本化清单文件名,例如 ``autobuy_manifest_0.2.2.json``。""" + + normalized = version.strip() + if _VERSION_PATTERN.fullmatch(normalized) is None: + raise ValueError("版本号必须是三段或四段非负整数") + return f"autobuy_manifest_{normalized}.json" def file_description(path: Path) -> dict[str, Any]: diff --git a/client/packaging/build.ps1 b/client/packaging/build.ps1 index 94d58f2..d0c6cfc 100644 --- a/client/packaging/build.ps1 +++ b/client/packaging/build.ps1 @@ -160,23 +160,32 @@ VSVersionInfo( [Text.UTF8Encoding]::new($false) ) - $portableZip = Join-Path $releaseRoot "$packageName-portable.zip" + # Windows PowerShell 5 may read a UTF-8 script without BOM as the system code page. + # Build the Chinese product name from Unicode code points so the release name is stable. + $portableProductName = -join @( + [char]0x81EA, [char]0x52A8, [char]0x91C7, [char]0x96C6, + [char]0x91C7, [char]0x8D2D, [char]0x5DE5, [char]0x5177 + ) + $portableZip = Join-Path $releaseRoot "$($portableProductName)_$version.zip" Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $portableZip -CompressionLevel Optimal + $portableLatestZip = Join-Path $releaseRoot "$portableProductName.zip" + Copy-Item -LiteralPath $portableZip -Destination $portableLatestZip $updateStage = Join-Path $buildRoot "update-stage" New-Item -ItemType Directory -Force -Path $updateStage | Out-Null Copy-Item -LiteralPath $appRoot -Destination (Join-Path $updateStage "app") -Recurse - $updateZip = Join-Path $releaseRoot "$packageName-update.zip" + $updateZip = Join-Path $releaseRoot "CMAutoBuy_$version.zip" Compress-Archive -Path (Join-Path $updateStage "*") -DestinationPath $updateZip -CompressionLevel Optimal Write-Host "[7/7] Generating the release manifest..." - $emDash = [char]0x2014 - $manifestPath = Join-Path $releaseRoot ("autobuy{0}{0}manifest.json" -f $emDash) + $manifestPath = Join-Path $releaseRoot "autobuy_manifest.json" Invoke-Checked -FailureMessage "Failed to generate the release manifest" -Command { & $buildPython (Join-Path $clientRoot "build_tools\release_manifest.py") ` --version $version --update $updateZip --portable $portableZip ` --output $manifestPath } + $versionedManifestPath = Join-Path $releaseRoot "autobuy_manifest_$version.json" + Copy-Item -LiteralPath $manifestPath -Destination $versionedManifestPath Write-Host "Build complete: $releaseRoot" Write-Host "Release manifest: $manifestPath" diff --git a/client/src/settings_ui.py b/client/src/settings_ui.py index 054cebe..747fe2d 100644 --- a/client/src/settings_ui.py +++ b/client/src/settings_ui.py @@ -300,7 +300,7 @@ class SettingsPage(QWidget): self.currentVersionLabel.setAccessibleName("当前软件版本") self.updateManifestUrlInput = LineEdit(self) self.updateManifestUrlInput.setPlaceholderText( - "https://updates.example.com/autobuy——manifest.json" + "https://updates.example.com/autobuy_manifest.json" ) self.updateManifestUrlInput.setClearButtonEnabled(True) self.updateManifestUrlInput.setAccessibleName("在线更新清单地址") diff --git a/client/src/update_service.py b/client/src/update_service.py index a4fc695..01bc23a 100644 --- a/client/src/update_service.py +++ b/client/src/update_service.py @@ -30,7 +30,8 @@ MAX_UPDATE_BYTES = 500 * 1024 * 1024 MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024 UPDATE_MANIFEST_SETTING = "update.manifest_url" UPDATE_USERNAME_SETTING = "update.username" -DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy——manifest.json" +DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy_manifest.json" +LEGACY_DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy——manifest.json" DEFAULT_UPDATE_USERNAME = "admin" _ALLOWED_HTTP_UPDATE_HOST = "cm.xiapi.com" _VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$") diff --git a/client/src/update_ui_event.py b/client/src/update_ui_event.py index 69ab865..f8d9d08 100644 --- a/client/src/update_ui_event.py +++ b/client/src/update_ui_event.py @@ -12,6 +12,7 @@ from .settings_repository import SettingsRepository from .update_service import ( DEFAULT_UPDATE_MANIFEST_URL, DEFAULT_UPDATE_USERNAME, + LEGACY_DEFAULT_UPDATE_MANIFEST_URL, UPDATE_MANIFEST_SETTING, UPDATE_USERNAME_SETTING, UpdateCancelled, @@ -142,6 +143,9 @@ class UpdateUiEventBinder(QObject): if saved_url_record is not None and isinstance(saved_url_record.value, str) else DEFAULT_UPDATE_MANIFEST_URL ) + if saved_url == LEGACY_DEFAULT_UPDATE_MANIFEST_URL: + saved_url = DEFAULT_UPDATE_MANIFEST_URL + repository.set(UPDATE_MANIFEST_SETTING, saved_url) saved_username = ( saved_username_record.value if saved_username_record is not None diff --git a/client/src/version.py b/client/src/version.py index 6d6f646..73455c3 100644 --- a/client/src/version.py +++ b/client/src/version.py @@ -3,4 +3,4 @@ 发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。 """ -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/client/test/test_packaging.py b/client/test/test_packaging.py index 24d935a..062125b 100644 --- a/client/test/test_packaging.py +++ b/client/test/test_packaging.py @@ -16,7 +16,11 @@ from build_tools.launcher import ( install_root, is_main_program_running, ) -from build_tools.release_manifest import MANIFEST_FILE_NAME, write_manifest +from build_tools.release_manifest import ( + MANIFEST_FILE_NAME, + versioned_manifest_file_name, + write_manifest, +) from src.db import data_dir @@ -140,15 +144,15 @@ class ReleaseManifestTest(unittest.TestCase): def test_manifest_name_and_hash_match_release_files(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - update_zip = root / "CMAutoBuy-0.1.0-update.zip" - portable_zip = root / "CMAutoBuy-0.1.0-portable.zip" + update_zip = root / "CMAutoBuy_0.1.0.zip" + portable_zip = root / "自动采集采购工具_0.1.0.zip" update_zip.write_bytes(b"update") portable_zip.write_bytes(b"portable") output = root / MANIFEST_FILE_NAME write_manifest("0.1.0", update_zip, portable_zip, output) - self.assertEqual(output.name, "autobuy——manifest.json") + self.assertEqual(output.name, "autobuy_manifest.json") manifest = json.loads(output.read_text(encoding="utf-8")) self.assertEqual(manifest["version"], "0.1.0") self.assertEqual(manifest["update"]["size"], len(b"update")) @@ -158,6 +162,14 @@ class ReleaseManifestTest(unittest.TestCase): ) self.assertEqual(manifest["portable"]["file"], portable_zip.name) + def test_versioned_manifest_name_is_stable_and_validated(self): + self.assertEqual( + versioned_manifest_file_name("0.1.0"), + "autobuy_manifest_0.1.0.json", + ) + with self.assertRaises(ValueError): + versioned_manifest_file_name("../latest") + if __name__ == "__main__": unittest.main() diff --git a/client/test/test_update_service.py b/client/test/test_update_service.py index 4049556..9d4cf18 100644 --- a/client/test/test_update_service.py +++ b/client/test/test_update_service.py @@ -12,6 +12,7 @@ from pathlib import Path from src.update_service import ( DEFAULT_UPDATE_MANIFEST_URL, + LEGACY_DEFAULT_UPDATE_MANIFEST_URL, UnsafeUpdateArchiveError, UpdateConfigurationError, UpdateIntegrityError, @@ -153,9 +154,9 @@ class UpdateServiceTest(unittest.TestCase): ) self.assertNotIn("unit-test-password", repr(credentials)) - def test_default_unicode_manifest_path_is_encoded_for_http_request(self): + def test_legacy_unicode_manifest_path_is_encoded_for_http_request(self): request = UpdateService._make_request( - DEFAULT_UPDATE_MANIFEST_URL, + LEGACY_DEFAULT_UPDATE_MANIFEST_URL, UpdateCredentials("release-reader", "unit-test-password"), ) @@ -257,7 +258,7 @@ class UpdateServiceTest(unittest.TestCase): health = json.loads( (self.update_directory / "healthy.json").read_text(encoding="utf-8") ) - self.assertEqual(health["version"], "0.2.1") + self.assertEqual(health["version"], "0.2.2") if __name__ == "__main__": diff --git a/client/test/test_update_ui_event.py b/client/test/test_update_ui_event.py index 6b2a2b2..658c7f2 100644 --- a/client/test/test_update_ui_event.py +++ b/client/test/test_update_ui_event.py @@ -19,6 +19,7 @@ from src.settings_ui import SettingsPage from src.update_service import ( DEFAULT_UPDATE_MANIFEST_URL, DEFAULT_UPDATE_USERNAME, + LEGACY_DEFAULT_UPDATE_MANIFEST_URL, UPDATE_MANIFEST_SETTING, UPDATE_USERNAME_SETTING, UpdateCheckResult, @@ -46,7 +47,7 @@ class FakeCredentialStore: class FakeUpdateService: def __init__(self, result=None, delay=0.0, download_error=None): - self.result = result or UpdateCheckResult("0.2.1", "0.2.1", False) + self.result = result or UpdateCheckResult("0.2.2", "0.2.2", False) self.delay = delay self.download_error = download_error self.check_count = 0 @@ -57,7 +58,7 @@ class FakeUpdateService: def check( self, manifest_url, - current_version="0.2.1", + current_version="0.2.2", is_cancelled=None, credentials=None, ): @@ -141,7 +142,7 @@ class UpdateUiEventTest(unittest.TestCase): def test_update_card_uses_safe_defaults_and_password_input(self): page = self._page(FakeUpdateService()) - self.assertEqual(page.currentVersionLabel.text(), "0.2.1") + self.assertEqual(page.currentVersionLabel.text(), "0.2.2") self.assertEqual( page.updateManifestUrlInput.text(), DEFAULT_UPDATE_MANIFEST_URL ) @@ -257,6 +258,51 @@ class UpdateUiEventTest(unittest.TestCase): page.eventBinder.shutdown() page.deleteLater() + def test_legacy_default_manifest_url_is_migrated_before_auto_check(self): + self.repository.set_many( + { + UPDATE_MANIFEST_SETTING: LEGACY_DEFAULT_UPDATE_MANIFEST_URL, + UPDATE_USERNAME_SETTING: TEST_USERNAME, + } + ) + store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD)) + service = FakeUpdateService() + + page = self._page(service, store) + self._wait_until(lambda: service.check_count == 1) + self._wait_until( + lambda: page.eventBinder.updateEventBinder._check_thread is None + ) + + self.assertEqual( + self.repository.get(UPDATE_MANIFEST_SETTING), + DEFAULT_UPDATE_MANIFEST_URL, + ) + self.assertEqual(service.manifest_url, DEFAULT_UPDATE_MANIFEST_URL) + page.eventBinder.shutdown() + page.deleteLater() + + def test_custom_manifest_url_is_not_migrated(self): + self.repository.set_many( + { + UPDATE_MANIFEST_SETTING: MANIFEST_URL, + UPDATE_USERNAME_SETTING: TEST_USERNAME, + } + ) + store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD)) + service = FakeUpdateService() + + page = self._page(service, store) + self._wait_until(lambda: service.check_count == 1) + self._wait_until( + lambda: page.eventBinder.updateEventBinder._check_thread is None + ) + + self.assertEqual(self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL) + self.assertEqual(service.manifest_url, MANIFEST_URL) + page.eventBinder.shutdown() + page.deleteLater() + def test_confirmed_new_version_downloads_with_saved_credentials(self): update = UpdateInfo( version="0.3.0", @@ -267,7 +313,7 @@ class UpdateUiEventTest(unittest.TestCase): sha256="0" * 64, ) service = FakeUpdateService( - UpdateCheckResult("0.2.1", "0.3.0", True, update) + UpdateCheckResult("0.2.2", "0.3.0", True, update) ) page = self._page(service) self._save_configuration(page) diff --git a/docs/client/00-getting-started.md b/docs/client/00-getting-started.md index e710ca7..ff178cc 100644 --- a/docs/client/00-getting-started.md +++ b/docs/client/00-getting-started.md @@ -158,8 +158,9 @@ cd D:\chengma\cmautobuy\client ``` 第一次会在 `.build-venv/` 安装固定版本依赖,完成后产物位于 `release/`。发布时 -使用完整便携压缩包;`autobuy——manifest.json` 和更新压缩包留给版本发布与后续 -更新功能使用。不要把本机已有的 `data/` 塞进发布包。 +使用 `自动采集采购工具_<版本号>.zip` 完整便携压缩包;`autobuy_manifest.json` +是在线更新固定入口,`autobuy_manifest_<版本号>.json` 用于归档, +`CMAutoBuy_<版本号>.zip` 是更新包。不要把本机已有的 `data/` 塞进发布包。 ## 11. 接下来读什么 diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 642a70e..4dbdbdd 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -210,9 +210,16 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用 数据库靠 `PRAGMA user_version` 自动迁移,见 [03](03-data-model.md) §2.3。完整便携包 用于首次安装;更新包只包含 `app/`,不得包含 `data/`。 -每次构建在 `client/release/` 生成更新包、完整便携包和 -`autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256 -用于发现下载损坏,不等同于发布者身份认证。 +每次构建在 `client/release/` 生成以下发布文件: + +- `CMAutoBuy_<版本号>.zip`:在线更新包; +- `自动采集采购工具_<版本号>.zip`:带版本号的完整便携包; +- `自动采集采购工具.zip`:与带版本号便携包内容完全相同的下载别名; +- `autobuy_manifest.json`:Client 固定读取的在线更新清单; +- `autobuy_manifest_<版本号>.json`:与固定清单内容完全相同的归档副本。 + +清单中的更新包和便携包都引用带版本号的文件名,并记录版本、字节大小和 SHA256; +SHA256 用于发现下载损坏,不等同于发布者身份认证。 **在线更新:**设置页默认显示已确认的发布清单地址和账号,密码由操作人员首次 填写并保存到 Windows 凭据管理器;URL 和账号作为非敏感设置保存在 SQLite,密码 diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 7c43a9d..019be64 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -226,7 +226,7 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置 | 5 | Mock Admin 契约测试通过 | Mock 和 HTTP 两个实现跑同一套测试 | 开发者 | | 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `Launcher.exe` | 开发者 | | 6b | 升级不丢数据(仅打包版本) | 关闭程序并只换掉 `app/`,保留 `Launcher.exe` 和 `data/`;启动后任务、日志、设置都还在 | 开发者 | -| 6c | 发布清单与产物一致 | `autobuy——manifest.json` 中的版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 | +| 6c | 发布清单与产物一致 | `autobuy_manifest.json` 与对应版本化清单内容相同;其中版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 | | 6d | 在线更新和回退通过 | 使用上一版本目录检查新版本、下载、下次启动替换、健康标记、文件占用失败和自动回退;确认数据库未被覆盖 | 开发者 | | 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 | | 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) |