From 99252aecce6511a147aec624f391e2ce9ee9b063 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 11:59:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Client=20Windows?= =?UTF-8?q?=20=E6=89=93=E5=8C=85=E5=9F=BA=E7=A1=80=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + client/build_client.bat | 20 +++ client/build_tools/__init__.py | 1 + client/build_tools/launcher.py | 53 +++++++ client/build_tools/release_manifest.py | 79 ++++++++++ client/packaging/build.ps1 | 185 ++++++++++++++++++++++++ client/packaging/requirements-build.txt | 3 + client/src/db.py | 10 +- client/src/version.py | 6 + client/test/test_packaging.py | 65 +++++++++ docs/client/00-getting-started.md | 17 ++- docs/client/01-requirements.md | 34 +++-- docs/client/03-data-model.md | 12 +- docs/client/06-quality-security.md | 5 +- 14 files changed, 470 insertions(+), 22 deletions(-) create mode 100644 client/build_client.bat create mode 100644 client/build_tools/__init__.py create mode 100644 client/build_tools/launcher.py create mode 100644 client/build_tools/release_manifest.py create mode 100644 client/packaging/build.ps1 create mode 100644 client/packaging/requirements-build.txt create mode 100644 client/src/version.py create mode 100644 client/test/test_packaging.py diff --git a/.gitignore b/.gitignore index 0586815..abddef7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ data/ build/ dist/ *.spec +client/.build-venv/ +client/release/ admin/admin.exe admin/admin admin/admin-dev.exe diff --git a/client/build_client.bat b/client/build_client.bat new file mode 100644 index 0000000..0d4ffe9 --- /dev/null +++ b/client/build_client.bat @@ -0,0 +1,20 @@ +@echo off +setlocal +cd /d "%~dp0" + +where powershell.exe >nul 2>nul +if errorlevel 1 ( + echo [ERROR] powershell.exe not found. + exit /b 1 +) + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0packaging\build.ps1" +if errorlevel 1 ( + echo. + echo [ERROR] Client build failed. See the error above. + exit /b 1 +) + +echo. +echo [OK] Client release files are in client\release. +exit /b 0 diff --git a/client/build_tools/__init__.py b/client/build_tools/__init__.py new file mode 100644 index 0000000..8928cf2 --- /dev/null +++ b/client/build_tools/__init__.py @@ -0,0 +1 @@ +"""Client Windows 打包辅助代码。""" diff --git a/client/build_tools/launcher.py b/client/build_tools/launcher.py new file mode 100644 index 0000000..4b58040 --- /dev/null +++ b/client/build_tools/launcher.py @@ -0,0 +1,53 @@ +"""发布包的轻量启动器。 + +启动器不联网、不更新文件,只负责从发布根目录启动 ``app/CMAutoBuy.exe``。 +""" + +from __future__ import annotations + +import ctypes +import subprocess +import sys +from pathlib import Path + + +def install_root(executable: str | None = None) -> Path: + """返回 Launcher.exe 所在的发布根目录。""" + + return Path(executable or sys.executable).resolve().parent + + +def app_executable(root: Path) -> Path: + """返回主程序路径。""" + + return root / "app" / "CMAutoBuy.exe" + + +def show_error(message: str) -> None: + """使用 Windows 原生对话框显示启动错误。""" + + ctypes.windll.user32.MessageBoxW(0, message, "商品采集采购工具", 0x10) + + +def main() -> int: + """启动主程序;找不到程序时返回非零退出码。""" + + root = install_root() + target = app_executable(root) + if not target.is_file(): + show_error( + "主程序不存在,请确认 app 文件夹完整。\n\n" + f"缺少文件:{target}" + ) + return 1 + + try: + subprocess.Popen([str(target)], cwd=str(target.parent)) + except OSError as exc: + show_error(f"主程序启动失败。\n\n{exc}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client/build_tools/release_manifest.py b/client/build_tools/release_manifest.py new file mode 100644 index 0000000..3ddbb43 --- /dev/null +++ b/client/build_tools/release_manifest.py @@ -0,0 +1,79 @@ +"""生成可校验的 Client 发布清单。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + + +MANIFEST_FILE_NAME = "autobuy——manifest.json" + + +def file_description(path: Path) -> dict[str, Any]: + """返回发布文件的名称、字节大小和 SHA256。""" + + digest = hashlib.sha256() + with path.open("rb") as file: + for block in iter(lambda: file.read(1024 * 1024), b""): + digest.update(block) + return { + "file": path.name, + "size": path.stat().st_size, + "sha256": digest.hexdigest(), + } + + +def build_manifest(version: str, update_zip: Path, portable_zip: Path) -> dict[str, Any]: + """组装发布清单数据。""" + + if not version.strip(): + raise ValueError("版本号不能为空") + for path in (update_zip, portable_zip): + if not path.is_file(): + raise FileNotFoundError(path) + + return { + "schema_version": 1, + "product": "CMAutoBuy", + "version": version, + "update": file_description(update_zip), + "portable": file_description(portable_zip), + } + + +def write_manifest( + version: str, + update_zip: Path, + portable_zip: Path, + output: Path, +) -> None: + """把发布清单写成不带 BOM 的 UTF-8 JSON。""" + + manifest = build_manifest(version, update_zip, portable_zip) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="生成 Client 发布清单") + parser.add_argument("--version", required=True) + parser.add_argument("--update", required=True, type=Path) + parser.add_argument("--portable", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + write_manifest(args.version, args.update, args.portable, args.output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client/packaging/build.ps1 b/client/packaging/build.ps1 new file mode 100644 index 0000000..94d58f2 --- /dev/null +++ b/client/packaging/build.ps1 @@ -0,0 +1,185 @@ +param( + [string]$PythonExe = "C:\Python310\python.exe", + [switch]$SkipInstall +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +$env:PYTHONUTF8 = "1" + +$clientRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$venvRoot = Join-Path $clientRoot ".build-venv" +$buildRoot = Join-Path $clientRoot "build\client-package" +$releaseRoot = Join-Path $clientRoot "release" +$buildPython = Join-Path $venvRoot "Scripts\python.exe" + +function Assert-ChildPath { + param([string]$Path, [string]$Parent) + + $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $fullParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\' + if (-not $fullPath.StartsWith($fullParent, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to operate outside the Client directory: $fullPath" + } +} + +function Remove-BuildDirectory { + param([string]$Path) + + Assert-ChildPath -Path $Path -Parent $clientRoot + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Recurse -Force + } +} + +function Invoke-Checked { + param([scriptblock]$Command, [string]$FailureMessage) + + & $Command + if ($LASTEXITCODE -ne 0) { + throw "$FailureMessage (exit code: $LASTEXITCODE)" + } +} + +if (-not (Test-Path -LiteralPath $PythonExe -PathType Leaf)) { + throw "Python 3.10 was not found: $PythonExe" +} + +if (-not (Test-Path -LiteralPath $buildPython -PathType Leaf)) { + Write-Host "[1/7] Creating the isolated build environment..." + Invoke-Checked -FailureMessage "Failed to create the build environment" -Command { + & $PythonExe -m venv $venvRoot + } +} + +if (-not $SkipInstall) { + Write-Host "[2/7] Installing pinned runtime and build dependencies..." + Invoke-Checked -FailureMessage "Failed to install build dependencies" -Command { + & $buildPython -m pip install --disable-pip-version-check ` + -r (Join-Path $clientRoot "requirements.txt") ` + -r (Join-Path $PSScriptRoot "requirements-build.txt") + } +} else { + Write-Host "[2/7] Skipping dependency installation." +} + +Push-Location $clientRoot +try { + $version = (& $buildPython -c "from src.version import __version__; print(__version__)").Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($version)) { + throw "Failed to read the software version" + } + + Write-Host "[3/7] Cleaning old build output..." + Remove-BuildDirectory -Path $buildRoot + Remove-BuildDirectory -Path $releaseRoot + New-Item -ItemType Directory -Force -Path $buildRoot, $releaseRoot | Out-Null + + $versionParts = $version.Split('.') + if ($versionParts.Count -lt 3 -or $versionParts.Count -gt 4) { + throw "The version must contain three or four numeric parts: $version" + } + foreach ($part in $versionParts) { + $number = 0 + if (-not [int]::TryParse($part, [ref]$number) -or $number -lt 0) { + throw "The version must contain three or four numeric parts: $version" + } + } + while ($versionParts.Count -lt 4) { $versionParts += "0" } + $fileVersion = $versionParts -join ',' + $versionFile = Join-Path $buildRoot "windows-version.txt" + $versionInfo = @" +VSVersionInfo( + ffi=FixedFileInfo(filevers=($fileVersion), prodvers=($fileVersion), + mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, date=(0, 0)), + kids=[StringFileInfo([StringTable('080404b0', [ + StringStruct('CompanyName', 'CMAutoBuy'), + StringStruct('FileDescription', 'CMAutoBuy Client'), + StringStruct('FileVersion', '$version'), + StringStruct('InternalName', 'CMAutoBuy'), + StringStruct('OriginalFilename', 'CMAutoBuy.exe'), + StringStruct('ProductName', 'CMAutoBuy Client'), + StringStruct('ProductVersion', '$version') + ])]), VarFileInfo([VarStruct('Translation', [2052, 1200])])] +) +"@ + [IO.File]::WriteAllText($versionFile, $versionInfo, [Text.UTF8Encoding]::new($false)) + + $distPath = Join-Path $buildRoot "dist" + $workPath = Join-Path $buildRoot "work" + $specPath = Join-Path $buildRoot "spec" + + Write-Host "[4/7] Building the main application..." + Invoke-Checked -FailureMessage "Failed to build the main application" -Command { + & $buildPython -m PyInstaller --noconfirm --clean --onedir --windowed ` + --name CMAutoBuy --contents-directory dependencies ` + --distpath $distPath --workpath $workPath --specpath $specPath ` + --version-file $versionFile ` + --collect-all qfluentwidgets ` + --collect-all uiautomator2 ` + --collect-all adbutils ` + (Join-Path $clientRoot "buyer_main.py") + } + + Write-Host "[5/7] Building the lightweight launcher..." + Invoke-Checked -FailureMessage "Failed to build the launcher" -Command { + & $buildPython -m PyInstaller --noconfirm --clean --onefile --windowed ` + --name Launcher --distpath $distPath --workpath $workPath ` + --specpath $specPath --version-file $versionFile ` + (Join-Path $clientRoot "build_tools\launcher.py") + } + + $mainDist = Join-Path $distPath "CMAutoBuy" + $launcherExe = Join-Path $distPath "Launcher.exe" + $adbExe = Join-Path $mainDist "dependencies\adbutils\binaries\adb.exe" + foreach ($requiredPath in @( + (Join-Path $mainDist "CMAutoBuy.exe"), + (Join-Path $mainDist "dependencies\PyQt5\Qt5\plugins\platforms\qwindows.dll"), + $adbExe, + $launcherExe + )) { + if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { + throw "A required packaged file is missing: $requiredPath" + } + } + + Write-Host "[6/7] Assembling portable and update archives..." + $packageName = "CMAutoBuy-$version" + $packageRoot = Join-Path $releaseRoot $packageName + $appRoot = Join-Path $packageRoot "app" + New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null + Copy-Item -LiteralPath $mainDist -Destination $appRoot -Recurse + Copy-Item -LiteralPath $launcherExe -Destination (Join-Path $packageRoot "Launcher.exe") + New-Item -ItemType Directory -Force -Path ` + (Join-Path $packageRoot "data"), ` + (Join-Path $packageRoot "data\logs"), ` + (Join-Path $packageRoot "data\artifacts") | Out-Null + [IO.File]::WriteAllText( + (Join-Path $appRoot "version.txt"), + "$version`n", + [Text.UTF8Encoding]::new($false) + ) + + $portableZip = Join-Path $releaseRoot "$packageName-portable.zip" + Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $portableZip -CompressionLevel Optimal + + $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" + 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) + 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 + } + + Write-Host "Build complete: $releaseRoot" + Write-Host "Release manifest: $manifestPath" +} finally { + Pop-Location +} diff --git a/client/packaging/requirements-build.txt b/client/packaging/requirements-build.txt new file mode 100644 index 0000000..9d33458 --- /dev/null +++ b/client/packaging/requirements-build.txt @@ -0,0 +1,3 @@ +# 只在独立打包环境中安装,不属于 Client 运行时依赖。 +# PyInstaller 6.21.0 支持 Python 3.8-3.15;本项目固定使用 Python 3.10。 +PyInstaller==6.21.0 diff --git a/client/src/db.py b/client/src/db.py index a20fe5d..2a88330 100644 --- a/client/src/db.py +++ b/client/src/db.py @@ -19,7 +19,15 @@ def data_dir() -> Path: """返回可写数据目录;目录不存在时自动创建。""" if getattr(sys, "frozen", False): - directory = Path(sys.executable).resolve().parent / "data" + executable_directory = Path(sys.executable).resolve().parent + # 发布包结构是 Launcher.exe + app/CMAutoBuy.exe。主程序位于 app + # 目录时,data 必须放在上一层,升级替换 app 才不会覆盖本地数据库。 + install_root = ( + executable_directory.parent + if executable_directory.name.casefold() == "app" + else executable_directory + ) + directory = install_root / "data" else: directory = Path(__file__).resolve().parents[1] / "data" directory.mkdir(parents=True, exist_ok=True) diff --git a/client/src/version.py b/client/src/version.py new file mode 100644 index 0000000..01d6abb --- /dev/null +++ b/client/src/version.py @@ -0,0 +1,6 @@ +"""Client 的软件版本。 + +发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。 +""" + +__version__ = "0.1.0" diff --git a/client/test/test_packaging.py b/client/test/test_packaging.py new file mode 100644 index 0000000..4492485 --- /dev/null +++ b/client/test/test_packaging.py @@ -0,0 +1,65 @@ +"""Client 打包目录和发布清单测试。""" + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from build_tools.launcher import app_executable, install_root +from build_tools.release_manifest import MANIFEST_FILE_NAME, write_manifest +from src.db import data_dir + + +class LauncherPathTest(unittest.TestCase): + def test_main_program_is_inside_app_directory(self): + root = Path("D:/CMAutoBuy") + self.assertEqual( + app_executable(root), + root / "app" / "CMAutoBuy.exe", + ) + + def test_install_root_is_launcher_parent(self): + self.assertEqual( + install_root("D:/CMAutoBuy/Launcher.exe"), + Path("D:/CMAutoBuy"), + ) + + +class PackagedDataPathTest(unittest.TestCase): + def test_packaged_main_program_uses_root_data_directory(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + executable = root / "app" / "CMAutoBuy.exe" + with patch("src.db.sys.frozen", True, create=True), patch( + "src.db.sys.executable", str(executable) + ): + self.assertEqual(data_dir(), root / "data") + + +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.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") + manifest = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(manifest["version"], "0.1.0") + self.assertEqual(manifest["update"]["size"], len(b"update")) + self.assertEqual( + manifest["update"]["sha256"], + hashlib.sha256(b"update").hexdigest(), + ) + self.assertEqual(manifest["portable"]["file"], portable_zip.name) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/client/00-getting-started.md b/docs/client/00-getting-started.md index 01a2ee4..e710ca7 100644 --- a/docs/client/00-getting-started.md +++ b/docs/client/00-getting-started.md @@ -111,7 +111,7 @@ client/data/ └── artifacts/ 失败截图和控件树 XML ``` -跑源码时它就在 `client\data\`;将来打包成 exe 后,它在 `launcher.exe` 旁边。 +跑源码时它就在 `client\data\`;打包成 exe 后,它在根目录 `Launcher.exe` 旁边。 想看里面的数据,装一个免费工具 **DB Browser for SQLite**,用它打开 `client.db` 即可。表结构和每个字段什么意思,见 [03 数据模型](03-data-model.md)。 @@ -148,7 +148,20 @@ Remove-Item Env:QT_QPA_PLATFORM 第 2 条打印出 `OK` 就算通过。完整的验证要求见 [client/AGENTS.md](../../client/AGENTS.md) §验证。 -## 10. 接下来读什么 +## 10. 打包 Windows 便携版 + +在 `client` 目录双击 `build_client.bat`,或者从 PowerShell 执行: + +```powershell +cd D:\chengma\cmautobuy\client +.\build_client.bat +``` + +第一次会在 `.build-venv/` 安装固定版本依赖,完成后产物位于 `release/`。发布时 +使用完整便携压缩包;`autobuy——manifest.json` 和更新压缩包留给版本发布与后续 +更新功能使用。不要把本机已有的 `data/` 塞进发布包。 + +## 11. 接下来读什么 不用一次读完全部文档。按你要做的事挑: diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 46a8186..32763d5 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -183,25 +183,35 @@ one-file 每次启动都要解压到临时目录,启动慢,出错几乎没 ```text CMAutoBuy/ 整个文件夹拷到任何机器都能用 -├── launcher.exe 双击这个启动 -├── app/ 运行时依赖,用户不要动 -│ ├── python310.dll -│ ├── PyQt5/Qt5/ -│ │ ├── bin/ Qt5Core.dll / Qt5Gui.dll / Qt5Widgets.dll … -│ │ └── plugins/platforms/qwindows.dll -│ ├── qfluentwidgets/ qss 样式、字体、图标资源 -│ ├── adbutils/binaries/ adb.exe -│ └── … +├── Launcher.exe 双击这个启动;它只负责启动主程序 +├── app/ 主程序和运行时依赖,升级时整体替换 +│ ├── CMAutoBuy.exe +│ ├── version.txt +│ └── dependencies/ +│ ├── python310.dll +│ ├── PyQt5/Qt5/ +│ │ ├── bin/ Qt5Core.dll / Qt5Gui.dll / Qt5Widgets.dll … +│ │ └── plugins/platforms/qwindows.dll +│ ├── qfluentwidgets/ qss 样式、字体、图标资源 +│ ├── adbutils/binaries/adb.exe +│ └── … └── data/ 本地数据,升级时保留(见 [03 数据模型](03-data-model.md) §2.1) ├── client.db ├── logs/ └── artifacts/ ``` -`app/` 这个名字来自 PyInstaller 的 `--contents-directory app` 参数(默认叫 `_internal`)。 +主程序使用 PyInstaller one-dir,内部依赖目录通过 `--contents-directory dependencies` +固定命名。根目录的 `Launcher.exe` 是独立轻量启动器,为后续“关闭主程序后再替换 +`app/`”保留边界;当前版本不联网、不下载,也不自动更新。 -**升级方式:** 删掉 `launcher.exe` 和 `app/`,换成新版本,**`data/` 原样不动**。 -数据库靠 `PRAGMA user_version` 自动迁移,见 [03](03-data-model.md) §2.3。 +**当前升级方式:** 关闭程序后只替换 `app/`,**`Launcher.exe` 和 `data/` 原样不动**。 +数据库靠 `PRAGMA user_version` 自动迁移,见 [03](03-data-model.md) §2.3。完整便携包 +用于首次安装;更新包只包含 `app/`,不得包含 `data/`。 + +每次构建在 `client/release/` 生成更新包、完整便携包和 +`autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256 +用于发现下载损坏,不等同于发布者身份认证。联网更新和清单签名必须另建工单。 **已知风险点**(打包工单必须逐项验证): diff --git a/docs/client/03-data-model.md b/docs/client/03-data-model.md index d78c809..cd3b6fc 100644 --- a/docs/client/03-data-model.md +++ b/docs/client/03-data-model.md @@ -32,7 +32,7 @@ data/ | 什么情况 | `data/` 在哪 | |---|---| -| 打包成 exe 后 | `launcher.exe` 旁边(见 [01 需求](01-requirements.md) §8.1 的目录结构) | +| 打包成 exe 后 | 根目录 `Launcher.exe` 旁边(见 [01 需求](01-requirements.md) §8.1 的目录结构) | | 直接跑源码 | `client/data/`(已在 `.gitignore` 里,不会被提交) | 这叫**便携模式**:整个程序文件夹拷到哪都能用,出问题把文件夹打包发出来就能复现。 @@ -57,11 +57,13 @@ from pathlib import Path def data_dir() -> Path: """可写数据目录:数据库、日志、截图都放这儿。 - 打包后 = launcher.exe 旁边的 data/ + 打包后 = 根目录 Launcher.exe 旁边的 data/ 跑源码 = client/data/ """ if getattr(sys, "frozen", False): # frozen=True 说明是打包后的 exe - base = Path(sys.executable).parent / "data" + executable_dir = Path(sys.executable).resolve().parent + install_root = executable_dir.parent if executable_dir.name.casefold() == "app" else executable_dir + base = install_root / "data" else: base = Path(__file__).resolve().parents[1] / "data" base.mkdir(parents=True, exist_ok=True) @@ -71,7 +73,7 @@ def data_dir() -> Path: def app_dir() -> Path: """只读资源目录:图标、内置 qss 之类,**不要往这里写东西**。 - 打包后 = app/ 目录(PyInstaller 解包位置) + 打包后 = app/dependencies/(PyInstaller 的只读依赖和收集资源目录) 跑源码 = client/ """ if getattr(sys, "frozen", False): @@ -106,7 +108,7 @@ PRAGMA busy_timeout = 5000; 使用 `PRAGMA user_version` 管理顺序迁移。数据库升级必须支持从所有已发布版本迁移,不得在启动时直接删除旧库重建。 -这条在打包后尤其重要:升级 = 换掉 `launcher.exe` 和 `app/`,`data/` 原样保留, +这条在打包后尤其重要:升级 = 换掉 `app/`,`Launcher.exe` 和 `data/` 原样保留, 所以新版本必须能读旧数据库。 ## 3. `pdd_tasks` diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 2b37fe6..00a2e22 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -204,8 +204,9 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置 | 3 | UI 离屏冒烟测试通过 | 见 [client/AGENTS.md](../../client/AGENTS.md) §验证 第 2 条 | 开发者 | | 4 | 数据库从上一发布版本迁移成功 | 拿上一版本的 `client.db` 副本启动新版本,数据不丢 | 开发者 | | 5 | Mock Admin 契约测试通过 | Mock 和 HTTP 两个实现跑同一套测试 | 开发者 | -| 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `launcher.exe` | 开发者 | -| 6b | 升级不丢数据(仅打包版本) | 换掉 `launcher.exe` 和 `app/`,`data/` 保留,启动后任务、日志、设置都还在 | 开发者 | +| 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `Launcher.exe` | 开发者 | +| 6b | 升级不丢数据(仅打包版本) | 关闭程序并只换掉 `app/`,保留 `Launcher.exe` 和 `data/`;启动后任务、日志、设置都还在 | 开发者 | +| 6c | 发布清单与产物一致 | `autobuy——manifest.json` 中的版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 | | 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 | | 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) | | 9 | 真实下单版本额外满足采购安全门禁 | 逐条核对 §3 的 8 项 | **项目负责人 + 操作人员共同确认** |