Files
YwxAppThink/scripts/migrate_console_clean.php
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

243 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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";