Files
YwxAppThink/scripts/reindex_backend_power.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

275 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
/**
* 一次性脚本:把 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";