安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
519 lines
23 KiB
PHP
519 lines
23 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 一键打包「主框架整站升级包」(整包 + 增量补丁包)以及「完整项目安装包」。
|
||
*
|
||
* 与 ywxapp\service\FrameworkService::apply() 的约定一致:
|
||
* - 整包(full):zip 内含整站代码目录(app/config/extend/ywxapp/public/route 等),
|
||
* 解压根即项目根目录,apply 会按包内顶层目录识别为「整站包」并整体覆盖。
|
||
* - 补丁(patch):zip 内仅含相对基础版本的变更文件(路径相对项目根),
|
||
* 并附 delete.list(路径相对项目根,列出待删除的下线文件)。
|
||
*
|
||
* 安全:打包时排除运行/数据/客户端配置目录——runtime/vendor/data/.git、
|
||
* public/uploads(用户上传)、config/database.php、config/ywxapp.php(库与升级服务器配置)、
|
||
* .env、install.lock,避免升级覆盖客户机本地配置与数据。
|
||
* 另:本脚本只打包「框架/整站骨架」,**不包含插件、模板皮肤与中心站专用模块**:
|
||
* - 插件代码位于 addon/(不在打包目录内);
|
||
* - app/appmall/ 是「中心站专用」的应用市场服务端,只在总站部署;站长站(客户机)仅需
|
||
* ywxapp/ 内的客户端服务 + config('appmall.*') 调中心站远程 API 购买/安装插件,
|
||
* 故整包/补丁/安装包一律排除(见下方 $excludeAppRel)。
|
||
* public/static/ 采用白名单制——只打包 backend/frontend/member/common 四个核心
|
||
* 静态目录,其余(插件静态 public/static/<插件名>/ 如 haonav/download、模板皮肤
|
||
* public/static/templates 等)一律从整包/补丁/安装包中自动剔除。
|
||
* 插件请改用 scripts/package_addon.php、模板请改用 scripts/package_template.php
|
||
* 等独立打包流程分发。
|
||
*
|
||
* 产物输出到 build/(非 Web 可访问目录):
|
||
* - build/framework/ywxapp-<version>.zip 整包(整站,用于升级)
|
||
* - build/framework/ywxapp-<version>-patch.zip 补丁(from_version = 基础版本,用于升级)
|
||
* - build/framework/ywxapp-<version>-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);
|
||
}
|
||
}
|
||
}
|