/ 如 haonav/download、模板皮肤 * public/static/templates 等)一律从整包/补丁/安装包中自动剔除。 * 插件请改用 scripts/package_addon.php、模板请改用 scripts/package_template.php * 等独立打包流程分发。 * * 产物输出到 build/(非 Web 可访问目录): * - build/framework/ywxapp-.zip 整包(整站,用于升级) * - build/framework/ywxapp--patch.zip 补丁(from_version = 基础版本,用于升级) * - build/framework/ywxapp--install.zip 完整项目安装包(白名单:app/config/extend/public/ywxapp/route * + 根文件;不含插件、不含真实 .env/database.php/ywxapp.php,供新站首次部署) * 一次运行同时产出上述三个文件;插件仍走 scripts/package_all_addons.php 独立分发; * appmall(中心站专用模块)由总站随仓库自行部署,不进任何框架产物。 * * 数据库升级(DDL/DML 变更)怎么办: * - 若本次升级涉及数据库结构/数据变更,把 SQL 放到仓库的 ywxapp/upgrade.sql * (整站包含 ywxapp/ 目录,会自动打进包;解压后位于 项目根/ywxapp/upgrade.sql)。 * - FrameworkService::apply() 解压完成后会自动检测并执行该 SQL(按语句拆分, * 忽略 -- 行注释与 /* *\/ 块注释),并用 build/framework/sql_applied.json * 记录已执行版本,幂等(重复升级不重复执行)。执行失败会抛异常由升级流程提示。 * - 仅放一个 ywxapp/upgrade.sql 即可;如需按版本分别演进,可在每次发布前更新其内容为 * 该版本相对上一版的增量 SQL。 * * 用法: * php scripts/package_framework.php [newVersion | major|minor|patch] [baseVersion] * - newVersion 选填: * x.y.z 显式指定新版本(并写回 config/ywxapp.php) * major|minor|patch 在 config/ywxapp.php 当前版本基础上递增对应段 * 留空 默认按 patch 自动递增(并写回 config/ywxapp.php) * - baseVersion 选填,默认读取 config/ywxapp.php 的当前 version(作为升级起点) */ declare(strict_types=1); $root = dirname(__DIR__) . DIRECTORY_SEPARATOR; $cfgFile = $root . 'config' . DIRECTORY_SEPARATOR . 'ywxapp.php'; $curVersion = readFrameworkVersion($cfgFile); $arg1 = $argv[1] ?? ''; // ===================== 确定新版本号(支持自动递增) ===================== if ($arg1 === '') { // 留空:默认按 patch 自动 +1 if ($curVersion === '') { fwrite(STDERR, "未指定版本号,且无法从 config/ywxapp.php 读取当前版本,请显式传入 x.y.z\n"); exit(1); } $newVersion = bumpVersion($curVersion, 'patch'); echo "[版本] 未指定版本号,按 patch 自动递增: {$curVersion} -> {$newVersion}\n"; } elseif (preg_match('/^\d+\.\d+\.\d+$/', $arg1)) { // 显式 x.y.z $newVersion = $arg1; } elseif (in_array($arg1, ['major', 'minor', 'patch'], true)) { // 按段递增 if ($curVersion === '') { fwrite(STDERR, "未指定版本号,且无法从 config/ywxapp.php 读取当前版本,请显式传入 x.y.z\n"); exit(1); } $newVersion = bumpVersion($curVersion, $arg1); echo "[版本] 按 {$arg1} 递增: {$curVersion} -> {$newVersion}\n"; } else { fwrite(STDERR, "用法: php scripts/package_framework.php [newVersion x.y.z | major|minor|patch] [baseVersion x.y.z]\n"); exit(1); } // ===================== 回归校验:版本号写入不能写坏配置 ===================== // 必须在打包前拦截:一旦 FrameworkService::updateConfigVersion 回归成旧的 // '$1' . $version 写法,真实升级会把 'version' => '1.0.0' 写成 .0.1',损坏配置文件。 regressionCheckConfigVersion($root, $newVersion); // baseVersion 缺省取 config 当前版本(升级起点,即递增前的值) $baseVersion = $argv[2] ?? ''; if ($baseVersion === '') { $baseVersion = $curVersion !== '' ? $curVersion : $newVersion; } // 将新版本写回 config/ywxapp.php(保持仓库版本与产出一致,下次打包在此基础上再 +1) writeFrameworkVersion($cfgFile, $newVersion); echo "[版本] 已写回 config/ywxapp.php: {$newVersion}\n"; if (!class_exists('ZipArchive')) { fwrite(STDERR, "ZipArchive 扩展不可用\n"); exit(1); } // 整站包含的目录与根文件(注意:addons/ 不在此列,插件代码本身不打进框架包) $includeDirs = ['app', 'config', 'extend', 'ywxapp', 'public', 'route']; // appmall 是「中心站专用」模块(应用市场服务端):只在总站部署,绝不随框架分发给站长。 // 站长站(客户机)只需 ywxapp/ 内的客户端服务 + config('appmall.*') 调用中心站远程 API // 购买/安装插件,不需要市场服务端代码。故整包、补丁包、安装包一律排除 app/appmall/。 $excludeAppRel = ['appmall']; // public/static/ 白名单:框架包只保留核心静态目录 backend/frontend/member/common, // 其余子目录(插件静态 public/static/<插件名>/ 如 haonav/download、模板皮肤 // public/static/templates 等)一律排除,由 package_addon.php / package_template.php // 独立打包分发。动态扫描 static 目录得到排除列表,比按 addon/ 名扫描更彻底。 $staticAllowedDirs = ['backend', 'frontend', 'member', 'common']; $excludeStaticRel = []; $staticRoot = $root . 'public' . DIRECTORY_SEPARATOR . 'static'; if (is_dir($staticRoot)) { foreach (array_diff(scandir($staticRoot), ['.', '..']) as $entry) { if (is_dir($staticRoot . DIRECTORY_SEPARATOR . $entry) && !in_array($entry, $staticAllowedDirs, true)) { $excludeStaticRel[] = 'public/static/' . $entry; } } } // 各目录打包时的排除项(保护客户机本地配置/数据,以及排除插件/模板静态资源) $dirExclude = [ 'public' => ['uploads', 'addons'], 'config' => ['database.php', 'ywxapp.php'], ]; // 各目录内的相对路径前缀排除(如 app/appmall、public/static/<插件名>、public/static/templates) $dirExcludeRel = [ 'app' => $excludeAppRel, 'public' => array_map(static function (string $r) { return substr($r, strlen('public/')); // 变为 'static/haonav'、'static/templates' 等 }, $excludeStaticRel), ]; $includeFiles = ['think', 'composer.json', 'README.md', 'LICENSE.txt']; // 受保护、绝不允许进包的路径(相对项目根),用于整包/补丁过滤 $protectedRel = static function (string $rel) use ($excludeStaticRel): bool { $rel = ltrim($rel, '/'); $segs = explode('/', $rel); $first = $segs[0] ?? ''; // 插件/模板静态资源按「目录前缀」剔除(public/static/<非白名单目录>/...), // 与整包 addDirToZip 的 $excludeRel 前缀匹配保持一致;不能只用 in_array // 精确匹配,否则补丁里具体的文件路径(public/static/haonav/css/nav.css) // 匹配不上目录条目(public/static/haonav)而漏进包。 $excludedStatic = false; foreach ($excludeStaticRel as $ex) { $ex = trim($ex, '/'); if ($rel === $ex || strpos($rel . '/', $ex . '/') === 0) { $excludedStatic = true; break; } } if ( in_array($first, ['runtime', 'vendor', 'data'], true) || $rel === '.env' || $rel === 'install.lock' || ($first === 'public' && ($segs[1] ?? '') === 'uploads') || ($first === 'config' && in_array($segs[1] ?? '', ['database.php', 'ywxapp.php'], true)) || strpos($rel, 'addons/') === 0 // 插件代码(双保险,addons/ 本就不在 includeDirs) || ($first === 'app' && ($segs[1] ?? '') === 'appmall') // appmall 中心站专用模块(不随框架分发) || $excludedStatic ) { // 插件静态资源 + 模板/皮肤静态 return true; } return false; }; $outDir = $root . 'build' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR; if (!is_dir($outDir)) { mkdir($outDir, 0755, true); } // ===================== 整包 ===================== $fullZipPath = $outDir . 'ywxapp-' . $newVersion . '.zip'; $full = new ZipArchive(); if ($full->open($fullZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { fwrite(STDERR, "无法创建整包: {$fullZipPath}\n"); exit(1); } foreach ($includeDirs as $d) { $p = $root . $d; if (is_dir($p)) { $excl = $dirExclude[$d] ?? []; $exclRel = $dirExcludeRel[$d] ?? []; addDirToZip($full, $p, $d . '/', $excl, $exclRel); } } foreach ($includeFiles as $f) { $p = $root . $f; if (is_file($p)) { $full->addFile($p, $f); } } $full->close(); $fullHash = hash_file('sha256', $fullZipPath); $fullSize = filesize($fullZipPath); // ===================== 补丁包 ===================== // 基于 git 工作区相对 HEAD(=基础版本) 的变更(跨整站代码目录): // - 已跟踪文件的增/改/删 用 git diff --name-status HEAD // - 未跟踪的新文件 用 git ls-files --others $deleteList = []; $addedRel = []; foreach ($includeDirs as $d) { $diff = (string) @shell_exec('git -C ' . escapeshellarg($root) . ' diff --name-status HEAD -- ' . escapeshellarg($d . '/')); foreach (explode("\n", $diff) as $line) { $line = trim($line); if ($line === '') { continue; } $parts = preg_split('/\t/', $line); $status = $parts[0]; $path = $parts[count($parts) - 1]; // 重命名取新路径 $path = str_replace('\\', '/', trim($path, '"')); if ($protectedRel($path)) { continue; } $code = $status[0]; if ($code === 'D') { $deleteList[] = $path; } elseif ($code === 'A' || $code === 'M') { if (is_file($root . $path)) { $addedRel[] = $path; } } } $untracked = (string) @shell_exec('git -C ' . escapeshellarg($root) . ' ls-files --others --exclude-standard -- ' . escapeshellarg($d . '/')); foreach (explode("\n", $untracked) as $line) { $line = trim($line); if ($line === '') { continue; } $path = str_replace('\\', '/', trim($line, '"')); if ($protectedRel($path)) { continue; } if (is_file($root . $path) && !in_array($path, $addedRel, true)) { $addedRel[] = $path; } } } $patchZipPath = $outDir . 'ywxapp-' . $newVersion . '-patch.zip'; $patch = new ZipArchive(); if ($patch->open($patchZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { fwrite(STDERR, "无法创建补丁包: {$patchZipPath}\n"); exit(1); } foreach ($addedRel as $rel) { $patch->addFile($root . $rel, $rel); } if ($deleteList) { $patch->addFromString('delete.list', implode("\n", $deleteList) . "\n"); } // 补丁元信息(apply 仅消费 delete.list / upgrade.json) $patch->addFromString('upgrade.json', json_encode([ 'from_version' => $baseVersion, 'version' => $newVersion, 'delete' => $deleteList, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); $patch->close(); $patchHash = hash_file('sha256', $patchZipPath); $patchSize = filesize($patchZipPath); // ===================== 完整项目安装包(白名单,供新站首次部署) ===================== $installDir = $outDir; // 三个文件统一输出到 build/framework/ if (!is_dir($installDir)) { mkdir($installDir, 0755, true); } $installZipPath = $installDir . 'ywxapp-' . $newVersion . '-install.zip'; $installZip = new ZipArchive(); if ($installZip->open($installZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { fwrite(STDERR, "无法创建安装包: {$installZipPath}\n"); exit(1); } // 白名单:仅下列根级条目进包(不含插件 addons/、不含运行时/依赖/密钥) $installDirs = ['app', 'config', 'extend', 'public', 'ywxapp', 'route']; $installFiles = ['404.html', 'composer.json', 'think', '.env.production.example']; // 各目录额外排除(按 basename):config 排除本地库/升级服务器配置;public 排除用户上传 $installDirExcludes = [ 'app' => ['appmall'], // 中心站专用模块,不进安装包(站长站无需市场服务端) 'config' => ['database.php', 'ywxapp.php'], 'public' => ['uploads', 'addons'], ]; $installGlobalExcludes = ['runtime', 'vendor', '.git', 'node_modules', 'cache', 'install.lock', '.env']; foreach ($installDirs as $d) { $exNames = array_merge($installGlobalExcludes, $installDirExcludes[$d] ?? []); // public 目录额外排除 static 白名单(backend/frontend/member/common)之外的目录, // 保证安装包不含任何插件/模板静态产物。 // 注意 addDirToZip 的 $excludeRel 是「相对被打包目录」前缀(public 下即 static/xxx),需去掉 public/ 段 $exRel = ($d === 'public') ? array_map(fn($p) => substr($p, strlen('public/')), $excludeStaticRel) : []; addDirToZip($installZip, $root . $d, $d . '/', $exNames, $exRel); } foreach ($installFiles as $f) { $abs = $root . $f; if (!file_exists($abs)) { echo " (安装包) 跳过不存在: {$f}\n"; continue; } // .env.production.example 以 .env 之名打进包(占位模板,无真实密钥) if ($f === '.env.production.example') { $installZip->addFile($abs, '.env'); } else { $installZip->addFile($abs, $f); } } $installZip->close(); $installHash = hash_file('sha256', $installZipPath); $installSize = filesize($installZipPath); // ===================== 发布包签名(防篡改 / 防伪造来源) ===================== // apply() 会把包直接解压覆盖整站,因此发布包必须带我方私钥签名; // 客户机用随版本发布的公钥先验签再安装(见 ywxapp\service\PackageVerifier)。 // 私钥缺失时只警告不中断:本机自测允许出未签名包,**对外发布前必须带签名**。 require_once __DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'package_signature.php'; $signKey = pkResolvePrivateKey($root); $packages = [ '整包' => ['path' => $fullZipPath, 'hash' => &$fullHash, 'size' => &$fullSize, 'sig' => null], '补丁' => ['path' => $patchZipPath, 'hash' => &$patchHash, 'size' => &$patchSize, 'sig' => null], '安装包' => ['path' => $installZipPath, 'hash' => &$installHash, 'size' => &$installSize, 'sig' => null], ]; foreach ($packages as $label => &$pkg) { try { $pkg['sig'] = pkSignPackage($pkg['path'], $newVersion, $signKey); } catch (Throwable $e) { fwrite(STDERR, "[签名失败] {$label}: " . $e->getMessage() . "\n"); } // 签名会改写 zip(追加 signature.json),故哈希与体积以签名后为准 $pkg['hash'] = hash_file('sha256', $pkg['path']); $pkg['size'] = filesize($pkg['path']); } unset($pkg); // ===================== 输出 ===================== echo "=== 打包完成 (base={$baseVersion} -> new={$newVersion}, 整站) ===\n\n"; echo "[整包] {$fullZipPath}\n"; echo " size : {$fullSize} bytes\n"; echo " sha256: {$fullHash}\n"; echo " sign : " . pkSignLabel($packages['整包']['sig']) . "\n\n"; echo "[补丁] {$patchZipPath}\n"; echo " size : {$patchSize} bytes\n"; echo " sha256: {$patchHash}\n"; echo " sign : " . pkSignLabel($packages['补丁']['sig']) . "\n"; echo " changed files (" . count($addedRel) . "):\n"; foreach ($addedRel as $r) { echo " + {$r}\n"; } echo " deleted files (" . count($deleteList) . "):\n"; foreach ($deleteList as $r) { echo " - {$r}\n"; } echo "\n[安装包] {$installZipPath}\n"; echo " size : {$installSize} bytes\n"; echo " sha256: {$installHash}\n"; echo " sign : " . pkSignLabel($packages['安装包']['sig']) . "\n"; echo " 说明 : 新站首次部署用,不含插件/addons、不含 appmall(中心站专用模块)、不含真实 .env·database.php·ywxapp.php\n"; /** * 回归校验:updateConfigVersion 必须用 '${1}' 写法写入版本号。 * 否则当版本号含数字(如 1.0.1)时,'$1' 后接数字会被 PHP 当成第 11 个捕获组, * 把 'version' => '1.0.0' 写成 .0.1',损坏配置文件导致应用无法启动。 */ function regressionCheckConfigVersion(string $root, string $newVersion): void { $svcFile = $root . 'ywxapp' . DIRECTORY_SEPARATOR . 'service' . DIRECTORY_SEPARATOR . 'FrameworkService.php'; if (!is_file($svcFile)) { echo "[回归校验] 跳过:未找到 FrameworkService.php\n"; return; } $src = file_get_contents($svcFile); $bad = '\'$1\' . $version'; $good = '\'${1}\''; // 1) 静态检查:禁止遗留 bug 写法('$1' . $version 且未使用 '${1}') if (strpos($src, $bad) !== false && strpos($src, $good) === false) { fwrite( STDERR, "[回归校验] FAIL: FrameworkService::updateConfigVersion 仍使用 bug 写法 '$1' . \$version," . "会把版本号写坏(应改为 '\${1}' . \$version . '\${2}')。\n" ); exit(1); } // 2) 运行时模拟:用修复后的替换串写入,确认版本号正确落盘格式 // 注意:真实配置行 'version' 与 => 之间有多余空格,替换后原样保留, // 故断言用宽松匹配:版本号 token 被正确写入且 'version' 键前缀未丢失。 $sample = " 'version' => '1.0.0',\n"; $fixed = preg_replace( "/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/", '${1}' . $newVersion . '${2}', $sample ); if (strpos($fixed, "'{$newVersion}'") === false || strpos($fixed, "'version'") === false) { fwrite(STDERR, "[回归校验] FAIL: 模拟写版本号结果异常: " . trim($fixed) . "\n"); exit(1); } // 3) 反向确认:bug 写法确实会写坏(仅作证明,不中断流程) $buggy = preg_replace( "/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/", '$1' . $newVersion . '$2', $sample ); if (strpos($buggy, "'{$newVersion}'") === false) { echo "[回归校验] OK: 已确认修复写法正确(若回归旧写法会写成: " . trim($buggy) . ")\n"; } else { echo "[回归校验] OK: 版本号写入校验通过 (new={$newVersion})\n"; } } /** * 读取 config/ywxapp.php 中的框架版本号 */ function readFrameworkVersion(string $cfgFile): string { if (!is_file($cfgFile)) { return ''; } $c = file_get_contents($cfgFile); if (preg_match("/['\"]version['\"]\s*=>\s*['\"]([^'\"]+)['\"]/", $c, $m)) { return $m[1]; } return ''; } /** * 在 x.y.z 基础上递增指定段(major/minor/patch) */ function bumpVersion(string $ver, string $level = 'patch'): string { if (!preg_match('/^(\d+)\.(\d+)\.(\d+)$/', $ver, $m)) { return $ver; } $major = (int) $m[1]; $minor = (int) $m[2]; $patch = (int) $m[3]; switch ($level) { case 'major': $major++; $minor = 0; $patch = 0; break; case 'minor': $minor++; $patch = 0; break; case 'patch': default: $patch++; break; } return $major . '.' . $minor . '.' . $patch; } /** * 将新版本号写回 config/ywxapp.php(必须用 '${1}' 安全写法,避免重写坏配置) */ function writeFrameworkVersion(string $cfgFile, string $ver): void { if (!is_file($cfgFile)) { return; } $c = file_get_contents($cfgFile); $c = preg_replace( "/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/", '${1}' . $ver . '${2}', $c ); file_put_contents($cfgFile, $c); } /** * 将目录递归加入 zip。 * - $excludeNames:按 basename 排除(如 uploads)。 * - $excludeRel:按相对路径前缀排除(相对被打包目录,如 public 传入 'static/haonav'), * 用于排除插件静态资源等子目录。 */ function addDirToZip(ZipArchive $zip, string $dir, string $prefix, array $excludeNames = [], array $excludeRel = []): void { $dir = rtrim($dir, '/\\'); $iter = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), static function ($file) use ($dir, $excludeNames, $excludeRel) { $fp = $file->getRealPath(); $rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($fp, strlen($dir))), '/'); if (in_array($file->getFilename(), $excludeNames, true)) { return false; } foreach ($excludeRel as $ex) { $ex = trim($ex, '/'); if ($rel === $ex || strpos($rel . '/', $ex . '/') === 0) { return false; } } return true; } ), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iter as $file) { $filePath = $file->getRealPath(); $rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen($dir))), '/'); if ($file->isDir()) { $zip->addEmptyDir($prefix . $rel); } else { $zip->addFile($filePath, $prefix . $rel); } } }