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

+242
View File
@@ -0,0 +1,242 @@
<?php
/**
* 一次性脚本:把「系统信息」页的权限节点改成控制台的清理权限。
*
* 背景:内置「系统信息」页(System 控制器 / view/system / modules/system.js / install.sql 的 system:index)
* 已整体下线,清理能力迁到 Console 控制器:
* - 页面节点 system:index ——> 删除(连同挂它下面的子权限与角色授权)
* - 按钮节点 system:clean ——> 就地把 name/route 改成 console:clean,并挂到「控制台」菜单下
*
* 为什么要就地改名而不是新建:新节点的 id 是新的,角色授权表(backend_role_power)里没有它,
* 非超管账号会因为拿不到这个按钮权限而被 auth.filterDom() 摘掉按钮。
* 保留原 id 就等于保留全部既有授权,老库零感知。
*
* 用法:
* php scripts/migrate_console_clean.php # 预演:只统计,不动库(默认)
* php scripts/migrate_console_clean.php --force # 真正执行
*
* 幂等:跑第二次时 system:* 已不存在,只要 console:clean 还在「控制台」下就什么都不做。
*/
declare(strict_types=1);
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
$force = in_array('--force', $argv ?? [], true);
echo $force
? "[模式] 执行:以下动作会真实落库\n"
: "[模式] 预演(dry-run):只统计不改动,加 --force 才执行\n";
/* ------------------------- 读配置:极简 env() + include database.php ------------------------- */
$GLOBALS['__env'] = [];
foreach ([$root . '.env', $root . '.env.local'] as $envFile) {
if (! is_file($envFile)) {
continue;
}
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$pos = strpos($line, '=');
if ($pos === false) {
continue;
}
$GLOBALS['__env'][trim(substr($line, 0, $pos))] = trim(trim(substr($line, $pos + 1)), "\"'");
}
}
if (! function_exists('env')) {
function env(string $key = '', $default = null)
{
return $GLOBALS['__env'][$key] ?? $default;
}
}
$dbConfFile = $root . 'config' . DIRECTORY_SEPARATOR . 'database.php';
if (! is_file($dbConfFile)) {
fwrite(STDERR, "找不到配置文件: {$dbConfFile}\n");
exit(1);
}
$dbConf = include $dbConfFile;
$conn = $dbConf['connections'][$dbConf['default'] ?? 'mysql'] ?? [];
/* ------------------------- 连库 ------------------------- */
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$conn['hostname'] ?? '127.0.0.1',
(int) ($conn['hostport'] ?? 3306),
$conn['database'] ?? '',
$conn['charset'] ?: 'utf8mb4'
);
try {
$pdo = new PDO($dsn, $conn['username'] ?? 'root', $conn['password'] ?? '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (Throwable $e) {
fwrite(STDERR, '数据库连接失败: ' . $e->getMessage() . "\n");
exit(1);
}
$prefix = (string) ($conn['prefix'] ?? '');
$powerTable = $prefix . 'backend_power';
$rolePowerTable = $prefix . 'backend_role_power';
$roleTable = $prefix . 'backend_role';
echo '[连接] ' . ($conn['hostname'] ?? '') . ':' . ($conn['hostport'] ?? 3306)
. ' / ' . ($conn['database'] ?? '') . " (表前缀 {$prefix})\n";
$tableExists = static function (string $table) use ($pdo): bool {
return (bool) $pdo->query("SHOW TABLES LIKE '{$table}'")->fetchColumn();
};
if (! $tableExists($powerTable)) {
echo "[权限] {$powerTable} 不存在,没什么可迁移的\n";
exit(0);
}
/* ------------------------- 1. 「控制台」节点:新权限的挂载点 ------------------------- */
$consoleRow = $pdo->query(
"SELECT `id`, `title` FROM `{$powerTable}` WHERE `name` = 'console:index' ORDER BY `id` LIMIT 1"
)->fetch();
$consoleId = $consoleRow ? (int) $consoleRow['id'] : 0;
echo $consoleId
? "[挂载点] 控制台 #{$consoleId}({$consoleRow['title']})\n"
: "[挂载点] 找不到 console:index 节点,新节点将挂到根节点(pid=0)\n";
/* ------------------------- 2. 系统信息页节点:删除 ------------------------- */
$menuRow = $pdo->query(
"SELECT `id`, `name`, `title` FROM `{$powerTable}` WHERE `name` = 'system:index' ORDER BY `id` LIMIT 1"
)->fetch();
if ($menuRow) {
$menuId = (int) $menuRow['id'];
echo "[下线] 找到「{$menuRow['title']}」#{$menuId}({$menuRow['name']})\n";
$children = $pdo->query(
"SELECT `id`, `name`, `title` FROM `{$powerTable}` WHERE `pid` = {$menuId} ORDER BY `id`"
)->fetchAll();
foreach ($children as $row) {
echo sprintf(" 子节点 #%d %s (%s)\n", (int) $row['id'], $row['name'], $row['title']);
}
$ids = array_merge([$menuId], array_map('intval', array_column($children, 'id')));
$in = implode(',', $ids);
$hasRolePower = $tableExists($rolePowerTable);
$grantRows = $hasRolePower
? (int) $pdo->query("SELECT COUNT(*) FROM `{$rolePowerTable}` WHERE `power_id` IN ({$in})")->fetchColumn()
: 0;
echo "[下线] 待删权限节点 " . count($ids) . " 个、角色授权 {$grantRows} 条\n";
if ($force) {
$pdo->beginTransaction();
try {
if ($hasRolePower) {
$pdo->exec("DELETE FROM `{$rolePowerTable}` WHERE `power_id` IN ({$in})");
}
// system:clean 要在下一步改造成 console:clean,别在这里跟着父节点一起删了
$pdo->exec(
"DELETE FROM `{$powerTable}` WHERE `id` IN ({$in}) AND `name` NOT IN ('system:clean', 'console:clean')"
);
$pdo->commit();
echo "[执行] 页面节点与其下无用子节点已删除(system:clean 保留待改造)\n";
} catch (Throwable $e) {
$pdo->rollBack();
fwrite(STDERR, '[失败] 删除回滚: ' . $e->getMessage() . "\n");
exit(1);
}
}
} else {
echo "[下线] 没有 system:index 节点,跳过\n";
}
/* ------------------------- 3. 清理按钮权限:system:clean → console:clean ------------------------- */
$oldClean = $pdo->query(
"SELECT `id`, `name`, `title` FROM `{$powerTable}` WHERE `name` = 'system:clean' ORDER BY `id` LIMIT 1"
)->fetch();
$newClean = $pdo->query(
"SELECT `id`, `name`, `title` FROM `{$powerTable}` WHERE `name` = 'console:clean' ORDER BY `id` LIMIT 1"
)->fetch();
if ($oldClean && $newClean) {
// 两边都有:把授权合并到 console:clean,删掉旧的那个
$oldId = (int) $oldClean['id'];
$newId = (int) $newClean['id'];
echo "[合并] system:clean #{$oldId} 与 console:clean #{$newId} 并存,授权合并到后者并删除旧节点\n";
if ($force && $tableExists($rolePowerTable)) {
$pdo->beginTransaction();
try {
// 目标节点已有授权的行会撞唯一键,先去重再迁移
$owned = $pdo->query(
"SELECT `role_id` FROM `{$rolePowerTable}` WHERE `power_id` = {$newId}"
)->fetchAll(PDO::FETCH_COLUMN);
$ownedIds = $owned ? implode(',', array_map('intval', $owned)) : '0';
$pdo->exec(
"DELETE FROM `{$rolePowerTable}` WHERE `power_id` = {$oldId} AND `role_id` IN ({$ownedIds})"
);
$pdo->exec("UPDATE `{$rolePowerTable}` SET `power_id` = {$newId} WHERE `power_id` = {$oldId}");
$pdo->exec("DELETE FROM `{$powerTable}` WHERE `id` = {$oldId}");
$pdo->commit();
echo "[执行] 授权迁移完成,旧节点已删除\n";
} catch (Throwable $e) {
$pdo->rollBack();
fwrite(STDERR, '[失败] 合并回滚: ' . $e->getMessage() . "\n");
exit(1);
}
}
} elseif ($oldClean) {
$oldId = (int) $oldClean['id'];
echo "[改名] system:clean #{$oldId} → console:clean,路由 system/clean → console/clean,挂到 #{$consoleId}\n";
if ($force) {
$pdo->prepare(
"UPDATE `{$powerTable}`
SET `name` = 'console:clean', `route` = 'console/clean', `pid` = ?, `update_at` = ?
WHERE `id` = ?"
)->execute([$consoleId, time(), $oldId]);
echo "[执行] 已改名(id 不变 ⇒ 角色授权自动保留)\n";
}
} elseif ($newClean) {
echo "[跳过] console:clean #" . (int) $newClean['id'] . " 已就位\n";
} else {
echo "[补建] 没有 system:clean,也没有 console:clean —— 补建一个并授权给所有角色\n";
if ($force) {
$now = time();
$newId = (int) $pdo->query("SELECT IFNULL(MAX(`id`), 0) + 1 FROM `{$powerTable}`")->fetchColumn();
$stmt = $pdo->prepare(
"INSERT INTO `{$powerTable}`
(`id`, `pid`, `title`, `name`, `type`, `route`, `icon`, `sort`, `addon`, `status`, `create_at`, `update_at`, `delete_at`)
VALUES (?, ?, '清理缓存', 'console:clean', 3, 'console/clean', '', 1, NULL, 1, ?, ?, 0)"
);
$stmt->execute([$newId, $consoleId, $now, $now]);
echo "[执行] 已补建 console:clean #{$newId}\n";
// 新节点不在任何角色的授权列表里 ⇒ 非超管看不见按钮,必须全角色补授权
if ($tableExists($roleTable) && $tableExists($rolePowerTable)) {
$roles = $pdo->query("SELECT `id` FROM `{$roleTable}`")->fetchAll(PDO::FETCH_COLUMN);
$ins = $pdo->prepare(
"INSERT INTO `{$rolePowerTable}` (`role_id`, `power_id`, `create_at`) VALUES (?, ?, ?)"
);
$n = 0;
foreach ($roles as $roleId) {
$ins->execute([(int) $roleId, $newId, $now]);
$n++;
}
echo "[执行] 已授权给 {$n} 个角色\n";
}
}
}
echo $force
? "[完成] 权限迁移结束(控制台菜单与按钮权限每次请求读库,无需清缓存)\n"
: "[提示] 以上为预演结果,未做任何改动。确认后执行: php scripts/migrate_console_clean.php --force\n";