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

+405 -67
View File
@@ -25,6 +25,7 @@ use think\facade\Request;
use ywxapp\exception\AddonException;
use ywxapp\model\BackendPower as PowerModel;
use ywxapp\model\MemberRule as RuleModel;
use ywxapp\utils\Version;
/**
* 插件服务(优化版)
@@ -93,6 +94,14 @@ class AddonService
/** install.sql 是否已导入(防止重复导入) */
private bool $sqlImported = false;
/**
* 市场下载时服务端经 X-Addon-Sha256 下发的包指纹
*
* 同一实例内 download() → install()/local()/onlineUpgrade() 共享:
* download() 记下来,后续 postExtractChecks()/verifyPackageIntegrity() 用它比对落盘文件。
*/
private string $downloadHash = '';
/** HTTP客户端实例 */
private static ?Client $httpClient = null;
@@ -154,6 +163,8 @@ class AddonService
$info = $this->parseAddonInfo($tmpFile);
// 4. 验证插件信息
$this->validateAddonInfo($info);
// 4.5 包完整性校验(解压之前):损坏/被篡改的包到此为止,不落地到插件目录
$this->verifyPackageIntegrity($tmpFile, $info);
// 5. 准备安装参数
$params = $this->prepareInstallParams($info, $extend, $tmpFile);
// 6. 创建插件目录
@@ -163,6 +174,8 @@ class AddonService
$this->valid($params);
// 8. 解压文件
$this->extractAddonFiles($tmpFile);
// 8.5 解压后校验:依赖/扩展/框架版本 → 资源冲突(包完整性已在 4.5 校验过)
$this->postExtractChecks($info);
// 9. 执行安装逻辑
$result = $this->executeInstall($params);
// 10. 导入SQL
@@ -207,6 +220,8 @@ class AddonService
$this->info = $this->parseAddonInfo($tmpFile);
// 4. 验证插件信息
$this->validateAddonInfo($this->info);
// 4.5 包完整性校验(解压之前)
$this->verifyPackageIntegrity($tmpFile, $this->info);
// 5. 准备安装参数
$params = $this->prepareInstallParams($this->info, $extend, $tmpFile);
// 6. 创建插件目录
@@ -222,6 +237,8 @@ class AddonService
}
// 8. 解压文件
$this->extractAddonFiles($tmpFile);
// 8.5 解压后校验:依赖/扩展/框架版本 → 资源冲突(包完整性已在 4.5 校验过)
$this->postExtractChecks($this->info);
// 9. 执行安装逻辑
$result = $this->executeInstall($params);
// 10. 导入SQL
@@ -645,11 +662,13 @@ class AddonService
$this->addon = $name;
$this->initPaths($name);
// 校验依赖关系
$this->validateDependencies($info);
// 安装前环境体检:PHP 版本 / 目录可写 / 磁盘空间(与包内容无关,可前置)
$this->checkEnvironment($info);
// 检测插件冲突
$this->checkConflicts($info);
// 注意:依赖校验(依赖插件 / PHP 扩展 / 框架版本)与资源冲突检测(路由 / 菜单 / 数据表)
// 都需要读到解压后的 Addon 主类、route/app.php、install.sql,因此统一延后到
// extractAddonFiles() 之后由 postExtractChecks() 执行。此前放在这里会因插件类尚未
// 加载而直接 return,整套检测形同虚设(历史 bug)。
}
/**
@@ -660,59 +679,279 @@ class AddonService
*/
private function validateDependencies(array $info): void
{
// 尝试获取插件实例来检查依赖
$addonClass = '\\addon\\' . $info['name'] . '\\Addon';
$addonName = $info['name'] ?? $this->addon;
$addonClass = '\\addon\\' . $addonName . '\\Addon';
// 如果插件类不存在,先解压后再检查(此处暂时跳过,等待解压完成后再检查)
// 本方法仅在解压之后调用:插件类应可被 PSR-4 自动加载,若仍未加载则显式引入一次
if (!class_exists($addonClass)) {
return; // 稍后在安装过程中再检查
$addonFile = ADDON_PATH . $addonName . DIRECTORY_SEPARATOR . 'Addon.php';
if (is_file($addonFile)) {
@include_once $addonFile;
}
}
try {
$instance = app()->make($addonClass);
// 检查插件依赖
$dependencies = method_exists($instance, 'getDependencies') ? $instance->getDependencies() : [];
if (!empty($dependencies)) {
foreach ($dependencies as $depAddon) {
if (!$this->isAddonInstalled($depAddon)) {
throw new Exception("依赖插件 {$depAddon} 未安装,请先安装该插件");
}
if (!$this->isAddonEnabled($depAddon)) {
throw new Exception("依赖插件 {$depAddon} 未启用,请先启用该插件");
}
}
$instance = null;
if (class_exists($addonClass)) {
try {
$instance = app()->make($addonClass);
} catch (\Throwable $e) {
Log::warning("无法实例化插件类 {$addonClass} 进行依赖检查:" . $e->getMessage());
}
// 检查 PHP 扩展依赖
$extensions = method_exists($instance, 'getExtensions') ? $instance->getExtensions() : [];
if (!empty($extensions)) {
foreach ($extensions as $ext) {
if (!extension_loaded($ext)) {
throw new Exception("缺少必需的 PHP 扩展:{$ext}");
}
}
}
// 检查 ThinkPHP 版本依赖
if (method_exists($instance, 'getThinkVersion')) {
$requiredVersion = $instance->getThinkVersion();
$currentVersion = \think\App::VERSION;
// 解析版本比较
if (preg_match('/^([><=!]+)?\s*(\d+\.\d+\.?\d*)/', $requiredVersion, $matches)) {
$operator = $matches[1] ?? '>=';
$requiredVer = $matches[2];
if (!version_compare($currentVersion, $requiredVer, $operator)) {
throw new Exception("需要 ThinkPHP {$requiredVersion} 或更高版本,当前版本:{$currentVersion}");
}
}
}
} catch (\ReflectionException $e) {
// 无法实例化插件类,跳过依赖检查
Log::warning("无法实例化插件类 {$addonClass} 进行依赖检查:" . $e->getMessage());
}
// 依赖声明:插件主类 getDependencies() 优先,回退 info.php['dependencies']
$dependencies = [];
if ($instance && method_exists($instance, 'getDependencies')) {
$dependencies = (array) ($instance->getDependencies() ?: []);
} elseif (!empty($info['dependencies'])) {
$dependencies = (array) $info['dependencies'];
}
// 收集式校验:一次把「缺哪些依赖、该装还是该升」全部算出来,
// 而不是遇到第一个问题就抛。运维视角下「一起告诉我缺什么」远比逐个试错有用。
$problems = [];
foreach ($dependencies as $dep) {
[$depName, $constraint] = $this->normalizeDependency($dep);
if ($depName === '') {
continue;
}
$installed = $this->getAddonVersion($depName);
if (!$this->isAddonInstalled($depName)) {
$problems[] = [
'type' => 'addon',
'name' => $depName,
'reason' => 'missing',
'installed' => '',
'constraint' => $constraint,
'text' => '缺少依赖插件 ' . $depName . ($constraint !== '' ? "(需 {$constraint})" : ''),
];
continue;
}
if (!$this->isAddonEnabled($depName)) {
$problems[] = [
'type' => 'addon',
'name' => $depName,
'reason' => 'disabled',
'installed' => $installed,
'constraint' => $constraint,
'text' => "依赖插件 {$depName} 未启用,请先启用后再安装本插件",
];
continue;
}
// 版本区间:'haonav:>=1.2.0' / ['name'=>'haonav','version'=>'^1.2']
if ($constraint !== '' && !$this->versionSatisfies($installed, $constraint)) {
$problems[] = [
'type' => 'addon',
'name' => $depName,
'reason' => 'version',
'installed' => $installed,
'constraint' => $constraint,
'text' => "依赖插件 {$depName} 需 {$constraint},当前 {$installed}(需先升级该插件)",
];
}
}
// 检查 PHP 扩展依赖
$extensions = [];
if ($instance && method_exists($instance, 'getExtensions')) {
$extensions = (array) ($instance->getExtensions() ?: []);
} elseif (!empty($info['extensions'])) {
$extensions = (array) $info['extensions'];
}
foreach ($extensions as $ext) {
if ($ext !== '' && !extension_loaded($ext)) {
$problems[] = [
'type' => 'extension',
'name' => (string) $ext,
'reason' => 'missing',
'text' => "缺少必需的 PHP 扩展 {$ext},请在 php.ini 启用后重试",
];
}
}
// 检查 ThinkPHP 版本依赖(支持 >=8.0 / ^8.1 等写法)
$requiredVersion = '';
if ($instance && method_exists($instance, 'getThinkVersion')) {
$requiredVersion = (string) $instance->getThinkVersion();
} elseif (!empty($info['think'])) {
$requiredVersion = (string) $info['think'];
}
if ($requiredVersion !== '') {
$currentVersion = \think\App::VERSION;
if (!$this->versionSatisfies($currentVersion, $requiredVersion)) {
$problems[] = [
'type' => 'framework',
'name' => 'ThinkPHP',
'reason' => 'version',
'installed' => $currentVersion,
'constraint' => $requiredVersion,
'text' => "需要 ThinkPHP {$requiredVersion},当前 {$currentVersion}",
];
}
}
if (!empty($problems)) {
$lines = array_column($problems, 'text');
$missing = [];
foreach ($problems as $p) {
if ($p['type'] === 'addon' && $p['reason'] === 'missing') {
$missing[] = (string) $p['name'];
}
}
// data 供后台 UI 逐条展示与「去市场一键安装」使用
throw new AddonException(
'依赖检查未通过(' . count($problems) . ' 项):' . implode(';', $lines),
0,
[
'addon' => $addonName,
'problems' => $problems,
'missing_addons' => $missing,
]
);
}
}
/**
* 解压后统一校验:包完整性 → 依赖(插件/扩展/框架版本)→ 资源冲突(路由/菜单/数据表)
*
* 依赖与冲突检测必须发生在解压之后,否则读不到 Addon 主类、route/app.php、install.sql。
*/
private function postExtractChecks(array $info, string $tmpFile = ''): void
{
if ($tmpFile !== '' && is_file($tmpFile)) {
$this->verifyPackageIntegrity($tmpFile, $info);
}
$this->validateDependencies($info);
$this->checkConflicts($info);
}
/**
* 包完整性校验:与市场下发或打包时写入的 SHA256 比对
*
* 哈希来源优先级:
* 1. 调用方传入的 `$info['file_hash']`(市场列表项 / 临时参数)
* 2. $this->downloadHash(服务端本次下载经 X-Addon-Sha256 响应头下发)
* 3. zip 注释里的 YWXAPP-SHA256(AddonService::package() 与打包脚本写入)
* 三者都缺失(自制包)时只留痕日志,不阻断安装。
*/
private function verifyPackageIntegrity(string $tmpFile, array $info = []): void
{
$expected = (string) ($info['file_hash'] ?? ($info['sha256'] ?? ''));
if ($expected === '') {
$expected = $this->downloadHash;
}
// 回退:读取打包时写入 zip 注释的哈希(离线包 / 自制包)
if ($expected === '' && class_exists('ZipArchive')) {
$zip = new \ZipArchive();
if ($zip->open($tmpFile) === true) {
$comment = (string) $zip->getArchiveComment();
$zip->close();
if (preg_match('/YWXAPP-SHA256:([a-f0-9]{64})/i', $comment, $m)) {
$expected = $m[1];
}
}
}
// 兼容历史记录:32 位十六进制按 md5 比对(早期中心站 file_hash 由 md5_file 写入),
// 其余按 sha256(现行口径,与 appmall_install.sql 的 varchar(64) COMMENT 'SHA256' 对齐)
$algo = (strlen($expected) === 32 && ctype_xdigit($expected)) ? 'md5' : 'sha256';
$actual = hash_file($algo, $tmpFile);
if ($expected === '') {
Log::info("插件包未提供哈希,跳过完整性校验:{$this->addon} sha256={$actual}");
return;
}
if (!hash_equals(strtolower($expected), strtolower($actual))) {
throw new Exception('插件包完整性校验失败,文件可能已损坏或被篡改'
. '(期望 ' . substr($expected, 0, 16) . '…,实际 ' . substr($actual, 0, 16) . '…)');
}
Log::info("插件包完整性校验通过:{$this->addon} sha256={$actual}");
}
/**
* 安装前环境体检:PHP 版本 / 目录可写 / 磁盘空间
*
* 与包内容无关,可在解压前执行,失败即中止,避免装到一半才发现环境不满足。
*/
private function checkEnvironment(array $info = []): void
{
// 1. PHP 版本约束:info.php['php'](如 '>=8.1')
$requiredPhp = (string) ($info['php'] ?? '');
if ($requiredPhp !== '' && !$this->versionSatisfies(PHP_VERSION, $requiredPhp)) {
throw new Exception("需要 PHP {$requiredPhp},当前版本:" . PHP_VERSION);
}
// 2. 目录可写:插件目录与静态资源根目录
foreach ([ADDON_PATH, public_path() . 'static'] as $dir) {
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
throw new Exception("目录不存在且无法创建:{$dir}");
}
if (!is_writable($dir)) {
throw new Exception("目录不可写,无法安装插件:{$dir}");
}
}
// 3. 磁盘空间(至少 32MB)
$free = @disk_free_space(ADDON_PATH);
if ($free !== false && $free < 32 * 1024 * 1024) {
throw new Exception('磁盘剩余空间不足(<32MB),无法安装插件');
}
}
/**
* 归一化依赖声明
*
* 支持:'foo'、'foo:>=1.2.0'、['name'=>'foo','version'=>'>=1.2.0']、['foo','>=1.2.0']
*
* @param mixed $dep
* @return array [插件名, 版本约束]
*/
private function normalizeDependency($dep): array
{
if (is_string($dep)) {
if (strpos($dep, ':') !== false) {
[$name, $constraint] = explode(':', $dep, 2);
return [trim($name), trim($constraint)];
}
return [trim($dep), ''];
}
if (is_array($dep)) {
$name = (string) ($dep['name'] ?? ($dep[0] ?? ''));
$constraint = (string) ($dep['version'] ?? ($dep[1] ?? ''));
return [trim($name), trim($constraint)];
}
return ['', ''];
}
/**
* 版本约束判定
*
* 完整语义见 ywxapp\utils\Version:支持 >= / > / <= / < / == / !=、^m.n.p、~m.n.p、
* 通配符 1.2.*,以及「空格 AND、| OR」的组合写法。无法解析的约束一律放行,不阻断安装。
*/
private function versionSatisfies(string $version, string $constraint): bool
{
if ($version === '' || $constraint === '') {
return true;
}
return Version::satisfies($version, $constraint);
}
/**
* 读取已安装插件的版本号
*/
private function getAddonVersion(string $addon): string
{
$infoFile = ADDON_PATH . $addon . DIRECTORY_SEPARATOR . 'info.php';
if (!is_file($infoFile)) {
return '';
}
$info = include $infoFile;
return (string) ($info['version'] ?? '');
}
/**
@@ -753,11 +992,8 @@ class AddonService
$conflicts = [];
$addonName = $info['name'];
// 检查命名空间冲突
$namespace = 'addon\\' . $addonName;
if (class_exists($namespace . '\\Addon') && $this->isAddonInstalled($addonName)) {
$conflicts[] = "命名空间冲突:{$namespace} 已被占用";
}
// 说明:命名空间占用已由 validateAddonInfo() 的「Addon already exists」检查覆盖。
// 本方法在解压之后执行,此时插件目录与类必然已存在,若在此判断会永远误报。
// 检查路由冲突
$existingRoutes = $this->getExistingRoutes();
@@ -980,13 +1216,16 @@ class AddonService
}
/**
* 验证压缩包、依赖验证
*
* 验证压缩包、依赖验证(远程授权校验)
*
* @param array $params 验证参数
* @param bool $softOnNetworkError 网络/连接类异常是否放行(false=拒绝,安装场景的安全默认值)
* 离线升级场景传 true:已经装着的插件不能因为授权服务器
* 偶发不可达就升不了,但服务端「明确拒绝」仍然拦截。
* @return bool 验证结果
* @throws Exception
*/
private function valid(array $params = []): bool
private function valid(array $params = [], bool $softOnNetworkError = false): bool
{
// 开发环境 + 允许未知来源 才跳过远程校验(用于本地开发调试)
if (Env::get('app_debug') && config('ywxapp.unknownsources')) {
@@ -1006,6 +1245,11 @@ class AddonService
} catch (TransferException $e) {
// 网络/证书失败:默认「拒绝安装」而非放行,防止通过阻断授权服务器绕过校验
Log::error('Addon validation network error: ' . $e->getMessage());
if ($softOnNetworkError) {
// 离线升级例外:此刻插件已经装着,放行并记录留痕,交由服务端日志审计
Log::warning('Addon validation skipped: 授权服务器不可达,离线升级放行(插件:' . $this->addon . ')');
return true;
}
throw new Exception('插件授权校验服务器暂不可达,请稍后重试或联系管理员');
}
$json = (array) json_decode($content, true);
@@ -1053,10 +1297,18 @@ class AddonService
// 处理静态资源
$staticPath = $this->addonDir . DIRECTORY_SEPARATOR . 'static';
if (is_dir($staticPath)) {
$newStaticPath = public_path() . 'static' . DIRECTORY_SEPARATOR . $this->addon;
// 统一下游路径:public/static/addon/<name>/(与 initPaths / package / uninstall 一致)。
// 历史版本解压到 public/static/<name>/,这里把旧目录内容迁移过来,避免两套路径并存。
$newStaticPath = $this->staticDir;
if (!is_dir($newStaticPath)) {
@mkdir($newStaticPath, 0755, true);
}
$legacyPath = public_path() . 'static' . DIRECTORY_SEPARATOR . $this->addon;
if (is_dir($legacyPath) && realpath($legacyPath) !== realpath($newStaticPath)) {
$this->copyAddonDir($legacyPath, $newStaticPath);
$this->deleteDirectory($legacyPath);
Log::info("已迁移历史静态资源目录:{$legacyPath} → {$newStaticPath}");
}
// 将已解压到插件目录的 static/ 递归复制到公共静态目录。
// 注意:ZipArchive::extractTo($dst, 'static') 不会递归抽取子条目(仅匹配同名条目本身),
// 实测抽出 0 文件,导致 public/static/<name>/ 为空。故改用目录复制。
@@ -1265,13 +1517,23 @@ class AddonService
$zip->open($addonFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
try {
$this->addDirectoryToZip($zip, $this->addonDir, '');
if (is_dir($this->staticDir)) {
$this->addDirectoryToZip($zip, $this->staticDir, 'static/');
// 静态资源:优先 public/static/addon/<name>/,回退历史路径 public/static/<name>/
$staticSrc = $this->resolveStaticSourceDir();
if ($staticSrc !== '') {
$this->addDirectoryToZip($zip, $staticSrc, 'static/');
}
$zip->close();
// 写入包指纹:SHA256 存入 zip 注释与同名 .sha256 文件,
// 供安装端 verifyPackageIntegrity() 校验完整性与防篡改。
$hash = hash_file('sha256', $addonFile);
$zip->open($addonFile);
$zip->setArchiveComment('YWXAPP-SHA256:' . $hash);
$zip->close();
@file_put_contents($addonFile . '.sha256', $hash . ' ' . basename($addonFile) . PHP_EOL);
$this->logOperation('package', [
'file' => $addonFile,
'size' => filesize($addonFile)
'file' => $addonFile,
'size' => filesize($addonFile),
'sha256' => $hash,
]);
return $addonFile;
} catch (\Exception $e) {
@@ -1288,6 +1550,20 @@ class AddonService
* @param string $dir 目录路径
* @param string $prefix ZIP内路径前缀
*/
/**
* 解析插件静态资源源目录:优先 public/static/addon/<name>/,回退历史 public/static/<name>/
*
* @return string 目录(末尾带分隔符),不存在时返回空串
*/
private function resolveStaticSourceDir(): string
{
if (is_dir($this->staticDir)) {
return $this->staticDir;
}
$legacy = public_path() . 'static' . DIRECTORY_SEPARATOR . $this->addon . DIRECTORY_SEPARATOR;
return is_dir($legacy) ? $legacy : '';
}
private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $prefix): void
{
$files = new RecursiveIteratorIterator(
@@ -1542,6 +1818,10 @@ class AddonService
}
$tmpFile = $this->download($extend);
// 1.5 包完整性校验(解压/覆盖之前):比对市场下发的 SHA256(downloadHash)或 zip 注释指纹。
// 不符即中止,避免把损坏/被篡改的包解压到运行中的插件目录里。
$this->verifyPackageIntegrity($tmpFile);
// 2. 解析新包信息
$info = $this->parseAddonInfo($tmpFile);
$newVersion = $info['version'] ?? '';
@@ -1600,10 +1880,19 @@ class AddonService
// 1. 上传(移动)本地 zip 到临时目录
$tmpFile = $this->uploadFile($file);
// 1.5 包完整性校验:本地升级包按 zip 注释里的 YWXAPP-SHA256 自校验
$this->verifyPackageIntegrity($tmpFile);
// 2. 解析新包信息
$info = $this->parseAddonInfo($tmpFile);
$newVersion = $info['version'] ?? '';
// 2.5 远程授权校验(解压覆盖之前):过去离线升级完全不校验,付费插件拿一个自制 zip
// 就能绕过购买直接覆盖升级。现在一律过 valid():
// - 服务端明确拒绝(未购买 / 授权过期 / 版本不在授权范围)→ 中止升级;
// - 授权服务器不可达 → 放行并留痕,避免内网离线部署升不了自己的插件。
$this->valid($this->prepareInstallParams($info, [], $tmpFile), true);
// 3. 备份现有插件目录
$backupDir = $this->backupDir . $this->addon . '-' . ($currentVersion ?: 'unknown')
. '-' . date('YmdHis') . DIRECTORY_SEPARATOR;
@@ -1877,10 +2166,15 @@ class AddonService
throw new AddonException('下载插件包不完整(期望 ' . $contentLength . ' 字节,实际 ' . $size . ' 字节)');
}
// 记录服务端下发的包指纹(响应头 X-Addon-Sha256),供安装/升级前做完整性比对。
// 取「最后一次响应」:服务端若先返回带 url 的 JSON 再重定向,指纹在第二次响应头上。
$this->downloadHash = strtolower(trim((string) $response->getHeaderLine('X-Addon-Sha256')));
// 记录操作日志
$this->logOperation('download', [
'file' => $tmpFile,
'size' => $size
'file' => $tmpFile,
'size' => $size,
'sha256' => $this->downloadHash !== '' ? $this->downloadHash : hash_file('sha256', $tmpFile),
]);
return $tmpFile;
@@ -2452,11 +2746,16 @@ class AddonService
$this->deleteDirectory($this->addonDir);
Log::info("已删除插件目录: {$this->addonDir}");
}
// 5.2 删除静态资源目录
// 5.2 删除静态资源目录(含历史路径 public/static/<name>/,避免残留)
if (is_dir($this->staticDir)) {
$this->deleteDirectory($this->staticDir);
Log::info("已删除静态资源目录: {$this->staticDir}");
}
$legacyStatic = public_path() . 'static' . DIRECTORY_SEPARATOR . $this->addon . DIRECTORY_SEPARATOR;
if (is_dir($legacyStatic)) {
$this->deleteDirectory($legacyStatic);
Log::info("已删除历史静态资源目录: {$legacyStatic}");
}
} catch (\Exception $e) {
throw new Exception("删除插件目录时出错: " . $e->getMessage());
}
@@ -2533,7 +2832,26 @@ class AddonService
$prefix = Config::get("database.connections.{$defaultDb}.prefix");
$powerTable = $prefix . 'backend_power';
$ruleTable = $prefix . 'member_rule';
// 防护:插件名为空时拒绝执行。否则 name LIKE ':%' 会命中所有 addon 为空的
// 【核心后台菜单】,造成后台菜单与权限节点被清空。
if (empty($this->addon)) {
Log::error('[AddonService] 拒绝清理菜单:插件名为空(会误删核心后台菜单)');
return;
}
try {
// 删除前落快照,误删后可按日志审计/恢复
$snapshot = Db::name('backend_power')
->where('addon', $this->addon)
->whereOr('name', 'like', $this->addon . ':%')
->select()
->toArray();
if ($snapshot) {
Log::warning('[AddonService] 清理插件菜单(卸载/停用前)', [
'addon' => $this->addon,
'count' => count($snapshot),
'snapshot' => $snapshot,
]);
}
// 按 addon 列删(新版写入了 addon)+ 按 name 前缀删(兼容历史数据 addon 列为空的情况)
Db::execute("DELETE FROM `{$powerTable}` WHERE addon=? OR name LIKE ?", [$this->addon, $this->addon . ':%']);
} catch (\Exception $e) {
@@ -2787,6 +3105,26 @@ class AddonService
public function deleteMenu(string $addon = '')
{
$addon = $addon ?: $this->addon;
// 防护:插件名为空时拒绝执行。否则 name LIKE ':%' 会命中所有 addon 为空的
// 【核心后台菜单】,造成后台菜单与权限节点被清空。
if ($addon === '') {
Log::error('[AddonService] 拒绝删除菜单:插件名为空(会误删核心后台菜单)');
return;
}
// 删除前落快照,误删后可按日志审计/恢复
$snapshot = \ywxapp\model\BackendPower::withTrashed()
->where(function ($q) use ($addon) {
$q->where('addon', $addon)->whereOr('name', 'like', $addon . ':%');
})
->select()
->toArray();
if ($snapshot) {
Log::warning('[AddonService] 物理删除插件菜单', [
'addon' => $addon,
'count' => count($snapshot),
'snapshot' => $snapshot,
]);
}
// 必须物理删除:BackendPower/MemberRule 启用 SoftDelete,普通 delete() 仅软删(置 delete_at),
// 记录残留导致卸载后菜单仍显示,且不触发 onAfterDelete 残留角色关联。
// destroy($ids, true) 强制物理删除并触发 onAfterDelete;withTrashed 连历史软删残留一并清。