feat: 增加 Client Windows 打包基础 (#92)
This commit is contained in:
@@ -8,6 +8,8 @@ data/
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
client/.build-venv/
|
||||
client/release/
|
||||
admin/admin.exe
|
||||
admin/admin
|
||||
admin/admin-dev.exe
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Client Windows 打包辅助代码。"""
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# 只在独立打包环境中安装,不属于 Client 运行时依赖。
|
||||
# PyInstaller 6.21.0 支持 Python 3.8-3.15;本项目固定使用 Python 3.10。
|
||||
PyInstaller==6.21.0
|
||||
+9
-1
@@ -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)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Client 的软件版本。
|
||||
|
||||
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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. 接下来读什么
|
||||
|
||||
不用一次读完全部文档。按你要做的事挑:
|
||||
|
||||
|
||||
@@ -183,8 +183,11 @@ one-file 每次启动都要解压到临时目录,启动慢,出错几乎没
|
||||
|
||||
```text
|
||||
CMAutoBuy/ 整个文件夹拷到任何机器都能用
|
||||
├── launcher.exe 双击这个启动
|
||||
├── app/ 运行时依赖,用户不要动
|
||||
├── Launcher.exe 双击这个启动;它只负责启动主程序
|
||||
├── app/ 主程序和运行时依赖,升级时整体替换
|
||||
│ ├── CMAutoBuy.exe
|
||||
│ ├── version.txt
|
||||
│ └── dependencies/
|
||||
│ ├── python310.dll
|
||||
│ ├── PyQt5/Qt5/
|
||||
│ │ ├── bin/ Qt5Core.dll / Qt5Gui.dll / Qt5Widgets.dll …
|
||||
@@ -198,10 +201,17 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
|
||||
└── 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
|
||||
用于发现下载损坏,不等同于发布者身份认证。联网更新和清单签名必须另建工单。
|
||||
|
||||
**已知风险点**(打包工单必须逐项验证):
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 项 | **项目负责人 + 操作人员共同确认** |
|
||||
|
||||
Reference in New Issue
Block a user