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

+204
View File
@@ -0,0 +1,204 @@
<?php
/**
* 批量打包所有本地插件为市场 zip(结构对齐 ywxapp\service\AddonService::package())。
*
* - addon/<name>/ 内容 -> zip 根
* - public/static/<name>/ 静态资源 -> zip 的 static/ 前缀
*
* 每个插件自动从 addon/<name>/info.php 读取 title/author/intro/version,
* 输出到 build/addons/,并生成 wxapp_appmall_addon_list 测试记录 SQL(build/sql/<name>-<ver>-appmall_addon_list.sql)。
*
* 用法:
* php scripts/package_all_addons.php [bump] [skip:name1,name2,...]
*
* 例:
* php scripts/package_all_addons.php # 按 info.php 当前版本打包(不改版本号)
* php scripts/package_all_addons.php bump # 每个插件版本按 patch +1 并写回 info.php
* php scripts/package_all_addons.php bump skip:market # 同上但跳过 market
* php scripts/package_all_addons.php skip:market # 跳过 market,版本号不变
*/
declare(strict_types=1);
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR; // d:/wwwroot/ywxapp_dev/
// 参数:[bump] [skip:name1,name2,...]
// bump 可选:每个插件版本按 patch +1 并写回 addons/<name>/info.php
// skip: 可选:跳过指定插件(逗号分隔)
$bumpArg = $argv[1] ?? '';
$bump = false;
if ($bumpArg === 'bump') {
$bump = true;
$skipArg = $argv[2] ?? '';
} else {
$skipArg = $bumpArg;
}
$skip = [];
if (str_starts_with($skipArg, 'skip:')) {
$skip = array_filter(explode(',', substr($skipArg, 5)), static fn($s) => $s !== '');
}
$addonsDir = $root . 'addon' . DIRECTORY_SEPARATOR;
if (!is_dir($addonsDir)) {
fwrite(STDERR, "addons 目录不存在: {$addonsDir}\n");
exit(1);
}
if (!class_exists('ZipArchive')) {
fwrite(STDERR, "ZipArchive 扩展不可用\n");
exit(1);
}
$marketDir = $root . 'build' . DIRECTORY_SEPARATOR . 'addons' . DIRECTORY_SEPARATOR;
if (!is_dir($marketDir)) {
mkdir($marketDir, 0755, true);
}
$rows = [];
$ok = [];
$fail = [];
foreach (array_diff(scandir($addonsDir), ['.', '..']) as $name) {
$addonDir = $addonsDir . $name . DIRECTORY_SEPARATOR;
if (!is_dir($addonDir)) {
continue;
}
$infoFile = $addonDir . 'info.php';
if (!is_file($infoFile)) {
$fail[] = $name . ' (缺 info.php)';
continue;
}
if (in_array($name, $skip, true)) {
echo "[skip] {$name}\n";
continue;
}
$info = include $infoFile;
$title = (string) ($info['title'] ?? $name);
$author = (string) ($info['author'] ?? '');
$intro = (string) ($info['intro'] ?? '');
$version = (string) ($info['version'] ?? '');
if (!$version) {
$fail[] = $name . ' (info.php 缺 version)';
continue;
}
// 自动递增版本号(bump 模式):先记录旧值,+1 后写回 info.php
$oldVersion = $version;
if ($bump) {
$version = bumpVersion($oldVersion, 'patch');
writeAddonVersion($infoFile, $version);
}
$zipFile = $marketDir . $name . '-' . $version . '.zip';
$staticDir = $root . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR
. $name . DIRECTORY_SEPARATOR;
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
$fail[] = $name . ' (无法创建 zip)';
continue;
}
addDirToZip($zip, $addonDir, '');
if (is_dir($staticDir)) {
addDirToZip($zip, $staticDir, 'static/');
}
$zip->close();
$hash = hash_file('sha256', $zipFile);
$size = filesize($zipFile);
$absPath = realpath($zipFile);
$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($name) . "',"
. "'" . $esc($title) . "',"
. "'" . $esc($version) . "',"
. "0.00,"
. "'" . $esc($author) . "',"
. "'" . $esc($intro) . "',"
. "'',"
. "'" . $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 . $name . '-' . $version . '-appmall_addon_list.sql';
file_put_contents($sqlFile, "-- 插件市场测试记录(由 scripts/package_all_addons.php 生成)\n-- zip: {$absPath}\n-- sha256: {$hash}\n" . $sql . "\n");
$verLabel = $bump ? ($oldVersion . ' -> ' . $version) : $version;
$rows[] = [$name, $version, $size, $absPath];
$ok[] = $name;
printf(" [ok] %-12s v%s %8d bytes\n", $name, $verLabel, $size);
}
echo "\n=== 打包汇总 ===\n";
echo "成功: " . count($ok) . " 失败: " . count($fail) . "\n";
if ($fail) {
echo "失败列表:\n - " . implode("\n - ", $fail) . "\n";
}
echo "输出目录: {$marketDir}\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);
}
}
}