feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用

安全(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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
<?php
/**
* 一次性脚本:把本地插件打包成「非 Web 目录」下的市场 zip,并生成 wxapp_appmall_addon_list 测试记录 SQL。
* 产物(zip + sql)统一输出到 build/addons/ 与 build/sql/(非 Web 可访问目录)。
*
* 与 ywxapp\service\AddonService::package() 的打包结构保持一致:
* - addon/<name>/ 目录内容 -> zip 根
* - public/static/addon/<name>/ 静态资源 -> zip 的 static/ 前缀(缺失时回退历史 public/static/<name>/)
* 客户端 download()/local() 解压时按此结构还原。
*
* 用法:
* php scripts/package_addon.php <addon> [version | major|minor|patch] [title] [author] [description] [price]
* - version 缺省时按 patch 自动递增 addons/<addon>/info.php 的版本号并写回;可显式传 x.y.z 或 major/minor/patch
*
* 例:
* php scripts/package_addon.php appmall # 自动 1.0.0 -> 1.0.1
* php scripts/package_addon.php appmall minor # 1.0.0 -> 1.1.0
* php scripts/package_addon.php mqttbroker "MQTT代理" "" "基于 Workerman 自研 MQTT Broker" 0
*/
declare(strict_types=1);
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR; // d:/wwwroot/ywxapp_dev/
$addon = $argv[1] ?? 'mqttbroker';
// 解析可选版本参数(位于 $argv[2]);若是版本格式则视为版本参数,其余左移一位
$verArg = $argv[2] ?? '';
$isVerArg = ($verArg !== '' && (preg_match('/^\d+\.\d+\.\d+$/', $verArg) || in_array($verArg, ['major', 'minor', 'patch'], true)));
if ($isVerArg) {
$argv[2] = $argv[3] ?? '';
$argv[3] = $argv[4] ?? '';
$argv[4] = $argv[5] ?? '';
$argv[5] = $argv[6] ?? '';
}
$price = $argv[5] ?? '0.00';
$addonDir = $root . 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR;
if (!is_dir($addonDir)) {
fwrite(STDERR, "插件目录不存在: {$addonDir}\n");
exit(1);
}
$info = include $addonDir . 'info.php';
$curVersion = $info['version'] ?? '';
if (!$curVersion) {
fwrite(STDERR, "info.php 缺少 version\n");
exit(1);
}
// ===================== 确定新版本号(支持自动递增) =====================
if ($verArg === '') {
$newVersion = bumpVersion($curVersion, 'patch');
echo "[版本] 未指定版本号,按 patch 自动递增: {$curVersion} -> {$newVersion}\n";
} elseif (preg_match('/^\d+\.\d+\.\d+$/', $verArg)) {
$newVersion = $verArg;
} else {
$newVersion = bumpVersion($curVersion, $verArg);
echo "[版本] 按 {$verArg} 递增: {$curVersion} -> {$newVersion}\n";
}
// 将新版本写回 info.php(保持插件元数据与产出一致,下次打包在此基础上再 +1)
writeAddonVersion($addonDir . 'info.php', $newVersion);
echo "[版本] 已写回 addon/{$addon}/info.php: {$newVersion}\n";
$version = $newVersion;
// 标题/作者/简介:命令行参数优先,缺省则从 info.php 读取,保持与插件元数据一致
$title = $argv[2] ?? ($info['title'] ?? '');
$author = $argv[3] ?? ($info['author'] ?? '');
$description = $argv[4] ?? ($info['intro'] ?? '');
// 输出到「非 Web 可访问」目录(build/ 不在站点根 public/ 下)
$marketDir = $root . 'build' . DIRECTORY_SEPARATOR . 'addons' . DIRECTORY_SEPARATOR;
if (!is_dir($marketDir)) {
mkdir($marketDir, 0755, true);
}
$zipFile = $marketDir . $addon . '-' . $version . '.zip';
// 静态资源目录(可选):统一 public/static/addon/<name>/,回退历史 public/static/<name>/
$staticDir = $root . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR
. 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR;
$legacyStaticDir = $root . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR;
if (!class_exists('ZipArchive')) {
fwrite(STDERR, "ZipArchive 扩展不可用\n");
exit(1);
}
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "无法创建 zip: {$zipFile}\n");
exit(1);
}
addDirToZip($zip, $addonDir, '');
if (is_dir($staticDir)) {
addDirToZip($zip, $staticDir, 'static/');
} elseif (is_dir($legacyStaticDir)) {
fwrite(STDOUT, "提示:静态资源仍在历史路径 public/static/{$addon}/,已按新口径打包;"
. "建议迁移到 public/static/addon/{$addon}/\n");
addDirToZip($zip, $legacyStaticDir, 'static/');
}
$zip->close();
// 写入包指纹(与 AddonService::package() 一致):zip 注释 + 同名 .sha256 文件,
// 供安装端 AddonService::verifyPackageIntegrity() 校验完整性与防篡改。
$zip->open($zipFile);
$zip->setArchiveComment('YWXAPP-SHA256:' . hash_file('sha256', $zipFile));
$zip->close();
@file_put_contents($zipFile . '.sha256', hash_file('sha256', $zipFile) . ' ' . basename($zipFile) . PHP_EOL);
$hash = hash_file('sha256', $zipFile);
$size = filesize($zipFile);
$absPath = realpath($zipFile);
// SQL 转义(生成供人工执行的 INSERT)
$esc = static function (string $v): string {
return str_replace(["\\", "'"], ["\\\\", "\\'"], $v);
};
$sql = "INSERT INTO `wxapp_appmall_addon_list` "
. "(`name`,`title`,`version`,`price`,`author`,`description`,`logo`,`file_path`,`file_hash`,`status`,`create_at`,`update_at`) VALUES ("
. "'" . $esc($addon) . "',"
. "'" . $esc($title) . "',"
. "'" . $esc($version) . "',"
. floatval($price) . ","
. "'" . $esc($author) . "',"
. "'" . $esc($description) . "',"
. "'',"
. "'" . $esc($absPath) . "',"
. "'" . $hash . "',"
. "1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP());";
$sqlDir = $root . 'build' . DIRECTORY_SEPARATOR . 'sql' . DIRECTORY_SEPARATOR;
if (!is_dir($sqlDir)) {
mkdir($sqlDir, 0755, true);
}
$sqlFile = $sqlDir . $addon . '-' . $version . '-appmall_addon_list.sql';
file_put_contents($sqlFile, "-- 插件市场测试记录(由 scripts/package_addon.php 生成)\n-- zip: {$absPath}\n-- sha256: {$hash}\n" . $sql . "\n");
echo "=== 打包完成 ===\n";
echo "zip : {$absPath}\n";
echo "size : {$size} bytes\n";
echo "sha256: {$hash}\n\n";
echo "=== 测试记录 SQL(已写入 {$sqlFile})===\n";
echo $sql . "\n";
/**
* 在 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;
}
/**
* 将新版本号写回插件 info.php
*/
function writeAddonVersion(string $infoFile, string $ver): void
{
if (!is_file($infoFile)) {
return;
}
$c = file_get_contents($infoFile);
$c = preg_replace(
"/(['\"])version['\"]\s*=>\s*['\"][^'\"]*['\"]/",
"'version' => '" . $ver . "'",
$c
);
file_put_contents($infoFile, $c);
}
/**
* 将目录递归加入 zip(结构对齐 AddonService::addDirectoryToZip)
*/
function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
{
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($files 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);
}
}
}