安全(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(市场 / 开发者中心 / 审核 / 分润)
275 lines
10 KiB
PHP
275 lines
10 KiB
PHP
<?php
|
||
/**
|
||
* 一次性脚本:把 backend_power 的 id 重排成「1..N 连续、父节点编号小于子节点」。
|
||
*
|
||
* 背景:account 权限表的 id 是历史累积出来的 —— 内置商城下线挖掉了 #35、系统信息页下线挖掉了 #84,
|
||
* 后补的节点又只能往后加到 100+,于是有了 100/101/104/105 这种看起来像乱码的编号,
|
||
* 且 100+ 的节点在文件里排在中间位置,读 install.sql 时没法一眼看出父子结构。
|
||
* 现在按「深度优先遍历」重编号:顶层目录按 sort 排,每个父节点的 children 紧跟其后,
|
||
* 编号天然满足 id(parent) < id(child),对着行号就能看出菜单树。
|
||
*
|
||
* 两条必须同步的事:
|
||
* 1. backend_role_power.power_id —— 不同步就会变成指向空 id 的孤儿行,角色权限直接失效
|
||
* 2. 表的 AUTO_INCREMENT —— 不跟上会导致下一个新增节点 id 撞车
|
||
*
|
||
* 安全机制:
|
||
* - 默认 dry-run,只打印映射表,加 --force 才动库
|
||
* - 动库前整表备份到 {prefix}backend_power_reindex_bak(已存在则不覆盖,直接中止并提示)
|
||
* - 全部 UPDATE 包在一个事务里,失败整体回滚
|
||
* - 主键重编号走「先整体搬到高位偏移区间 → 再落回 1..N」两阶段,避开唯一键冲突。
|
||
* 不能用负数做中间态:power.id / pid 都是 int unsigned,负值直接 1264 越界。
|
||
*
|
||
* 幂等:重排后再跑,newId 与 oldId 全等,提示无需处理。
|
||
*
|
||
* 用法:
|
||
* php scripts/reindex_backend_power.php # 预演
|
||
* php scripts/reindex_backend_power.php --force # 执行
|
||
* php scripts/reindex_backend_power.php --force --drop-bak # 备份表已存在且确认无用,删掉它再执行
|
||
*/
|
||
declare(strict_types=1);
|
||
|
||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
|
||
$force = in_array('--force', $argv ?? [], true);
|
||
$dropBak = in_array('--drop-bak', $argv ?? [], true);
|
||
|
||
echo $force
|
||
? "[模式] 执行:会改 backend_power 主键,请先确认备份可用\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';
|
||
$bakTable = $prefix . 'backend_power_reindex_bak';
|
||
|
||
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. 读全表,按 DFS 算新编号 ------------------------- */
|
||
|
||
$rows = $pdo->query(
|
||
"SELECT `id`, `pid`, `sort`, `name`, `title` FROM `{$powerTable}` ORDER BY `pid`, `sort`, `id`"
|
||
)->fetchAll();
|
||
|
||
if (! $rows) {
|
||
echo "[中止] {$powerTable} 是空表\n";
|
||
exit(0);
|
||
}
|
||
|
||
$children = [];
|
||
$exists = [];
|
||
foreach ($rows as $r) {
|
||
$exists[(int) $r['id']] = true;
|
||
}
|
||
|
||
$orphans = [];
|
||
foreach ($rows as $r) {
|
||
$pid = (int) $r['pid'];
|
||
// pid 指向不存在的行(历史脏数据)当根节点处理,避免整棵树漏掉这一支
|
||
if ($pid !== 0 && ! isset($exists[$pid])) {
|
||
$orphans[] = (int) $r['id'] . '(' . $r['name'] . ',原 pid=' . $pid . ')';
|
||
$pid = 0;
|
||
}
|
||
$children[$pid][] = $r;
|
||
}
|
||
foreach ($children as &$list) {
|
||
usort($list, static function (array $a, array $b): int {
|
||
return ((int) $a['sort'] <=> (int) $b['sort']) ?: ((int) $a['id'] <=> (int) $b['id']);
|
||
});
|
||
}
|
||
unset($list);
|
||
|
||
/**
|
||
* 深度优先:父先分配编号,紧接着分配它的孩子——保证 id(parent) < id(child)
|
||
*
|
||
* @param int $pid
|
||
* @param array $children
|
||
* @param array $map oldId => newId
|
||
* @param int $next
|
||
* @return void
|
||
*/
|
||
$walk = function (int $pid, array &$children, array &$map, int &$next) use (&$walk): void {
|
||
foreach ($children[$pid] ?? [] as $row) {
|
||
$old = (int) $row['id'];
|
||
$map[$old] = $next++;
|
||
$walk($old, $children, $map, $next);
|
||
}
|
||
};
|
||
|
||
$map = [];
|
||
$next = 1;
|
||
$walk(0, $children, $map, $next);
|
||
|
||
if ($orphans) {
|
||
echo '[提示] 发现 pid 悬空的行,已当根节点处理:' . implode('、', $orphans) . "\n";
|
||
}
|
||
|
||
$total = count($rows);
|
||
$changed = 0;
|
||
$planLine = [];
|
||
foreach ($rows as $r) {
|
||
$old = (int) $r['id'];
|
||
$new = $map[$old];
|
||
if ($old !== $new) {
|
||
$changed++;
|
||
}
|
||
$planLine[] = sprintf(' %3d → %3d %-24s %s', $old, $new, $r['name'], $r['title']);
|
||
}
|
||
|
||
echo "[计划] 共 {$total} 个节点,编号变动 {$changed} 个\n";
|
||
echo implode("\n", $planLine) . "\n";
|
||
|
||
if ($changed === 0) {
|
||
echo "[无需处理] 编号已经是连续的 DFS 顺序\n";
|
||
exit(0);
|
||
}
|
||
|
||
$hasRolePower = $tableExists($rolePowerTable);
|
||
$grantRows = $hasRolePower
|
||
? (int) $pdo->query("SELECT COUNT(*) FROM `{$rolePowerTable}`")->fetchColumn()
|
||
: 0;
|
||
echo "[计划] 同步 {$rolePowerTable} 的 role_id/power_id 映射(共 {$grantRows} 条授权)\n";
|
||
echo '[计划] AUTO_INCREMENT 置为 ' . ($total + 1) . "\n";
|
||
|
||
if (! $force) {
|
||
echo "[提示] 以上是预演结果,未做任何改动。确认后执行: php scripts/reindex_backend_power.php --force\n";
|
||
exit(0);
|
||
}
|
||
|
||
/* ------------------------- 2. 备份 + 事务内重编号 ------------------------- */
|
||
|
||
if ($tableExists($bakTable)) {
|
||
// 上一轮跑挂了(回滚后备份还在)或跑完忘了收尾,都可以靠 --drop-bak 直接重来
|
||
if (! $dropBak) {
|
||
fwrite(STDERR, "[中止] 备份表 {$bakTable} 已存在,先确认它是否可以删除后再跑,避免覆盖历史备份\n");
|
||
fwrite(STDERR, "[提示] 确认无用后重跑: php scripts/reindex_backend_power.php --force --drop-bak\n");
|
||
exit(1);
|
||
}
|
||
$pdo->exec("DROP TABLE IF EXISTS `{$bakTable}`");
|
||
$pdo->exec("DROP TABLE IF EXISTS `{$bakTable}_role`");
|
||
echo "[备份] 已按 --drop-bak 删除旧备份\n";
|
||
}
|
||
|
||
$pdo->exec("CREATE TABLE `{$bakTable}` AS SELECT * FROM `{$powerTable}`");
|
||
$pdo->exec("CREATE TABLE `{$bakTable}_role` AS SELECT * FROM `{$rolePowerTable}`");
|
||
echo "[备份] {$bakTable} 与 {$bakTable}_role 已建立\n";
|
||
|
||
// 中间态偏移量:id/pid 是 int unsigned,最大 4294967295,这里加到 100 万量级足够安全
|
||
$offset = 1000000;
|
||
|
||
$pdo->beginTransaction();
|
||
try {
|
||
// 阶段一:整表搬到 OFFSET 高位区间。id/pid 都是 unsigned,负数中间态不行,
|
||
// 只能先整体抬到一个没人用的区间;自重编号可能互换(85→2、2→3),逐行改必然撞唯一键。
|
||
$updPower = $pdo->prepare("UPDATE `{$powerTable}` SET `id` = ?, `pid` = ? WHERE `id` = ?");
|
||
foreach ($map as $old => $new) {
|
||
$row = null;
|
||
foreach ($rows as $r) {
|
||
if ((int) $r['id'] === $old) {
|
||
$row = $r;
|
||
break;
|
||
}
|
||
}
|
||
$oldPid = (int) $row['pid'];
|
||
$newPid = $map[$oldPid] ?? 0; // 父不在本表(悬空)则归零
|
||
$updPower->execute([$new + $offset, $newPid === 0 ? 0 : $newPid + $offset, $old]);
|
||
}
|
||
|
||
if ($hasRolePower) {
|
||
$updGrant = $pdo->prepare("UPDATE `{$rolePowerTable}` SET `power_id` = ? WHERE `power_id` = ?");
|
||
foreach ($map as $old => $new) {
|
||
$updGrant->execute([$new + $offset, $old]);
|
||
}
|
||
}
|
||
|
||
// 阶段二:把偏移量减掉,落到最终的 1..N。
|
||
// 必须带 WHERE 限定「只动搬上去的那些」:role_power 里可能早就有指向不存在节点的孤儿行
|
||
// (历史遗留,不在 $map 里),无差别减会让它们变成负数。
|
||
$pdo->exec("UPDATE `{$powerTable}` SET `id` = `id` - {$offset}"
|
||
. ", `pid` = IF(`pid` = 0, 0, `pid` - {$offset}) WHERE `id` > {$offset}");
|
||
if ($hasRolePower) {
|
||
$pdo->exec("UPDATE `{$rolePowerTable}` SET `power_id` = `power_id` - {$offset}"
|
||
. " WHERE `power_id` > {$offset}");
|
||
}
|
||
|
||
$pdo->commit();
|
||
echo "[执行] 主键重编号完成\n";
|
||
} catch (Throwable $e) {
|
||
$pdo->rollBack();
|
||
fwrite(STDERR, '[失败] 已回滚:' . $e->getMessage() . "\n");
|
||
fwrite(STDERR, "[提示] 如需还原: DROP TABLE `{$powerTable}`; RENAME TABLE `{$bakTable}` TO `{$powerTable}`;\n");
|
||
exit(1);
|
||
}
|
||
|
||
$pdo->exec("ALTER TABLE `{$powerTable}` AUTO_INCREMENT = " . ($total + 1));
|
||
echo '[执行] AUTO_INCREMENT 已置为 ' . ($total + 1) . "\n";
|
||
|
||
// 复核
|
||
$bad = $pdo->query("SELECT COUNT(*) FROM `{$powerTable}` p LEFT JOIN `{$powerTable}` f ON p.`pid` = f.`id` WHERE p.`pid` <> 0 AND f.`id` IS NULL")->fetchColumn();
|
||
echo "[复核] 悬空 pid 行数:{$bad}\n";
|
||
echo "[完成] 建议顺手把 install.sql 的种子同步成同一套编号(本次种子改的就是这个)\n";
|