Files
lgp-admin-plus-api/app/Console/Commands/RbacAuditCommand.php
LQ bcf54c2727 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:21 +08:00

397 lines
17 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
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 账号与角色迁移前的数据体检
*
* 老库 cc_user / cc_role / cc_user_role_relation 的结构与新库 nl_admin / nl_role 有几处不兼容,
* 直接搬运会静默出错(登录到错误的人、普通角色变超管、账号迁过去全是禁用)。
* 本命令只读不写,把这些隐患一次列清,供 lgp:rbac-migrate 之前决策。
*/
class RbacAuditCommand extends Command
{
protected $signature = 'lgp:rbac-audit
{--json= : 额外把结果写成 JSON 文件,便于留档比对}';
protected $description = '账号与角色迁移前数据体检(只读)';
/** 老库连接:业务表走 business 连接(前缀 cc_ */
private const OLD = 'business';
/** 新库连接:系统表走默认 mysql 连接(前缀 nl_ */
private const NEW = 'mysql';
/** 阻断级问题数量,决定退出码 */
private int $blockerCount = 0;
/** @var array<string, mixed> 汇总结果,供 --json 落盘 */
private array $report = [];
public function handle(): int
{
$this->info('账号与角色迁移前数据体检');
$this->line('老库连接:' . self::OLD . '(前缀 ' . DB::connection(self::OLD)->getTablePrefix() . '');
$this->line('新库连接:' . self::NEW . '(前缀 ' . DB::connection(self::NEW)->getTablePrefix() . '');
$this->newLine();
if (!$this->checkTables()) {
return self::FAILURE;
}
$this->sectionScale();
$this->sectionMultiRole();
$this->sectionDuplicatePhone();
$this->sectionInvalidPhone();
$this->sectionRoles();
$this->sectionRoleIdCollision();
$this->sectionPasswordFormat();
$this->sectionDepartment();
$this->sectionAccountVsPhone();
$this->newLine();
if ($this->blockerCount > 0) {
$this->error("发现 {$this->blockerCount} 项阻断级问题,请先处理再执行 lgp:rbac-migrate");
} else {
$this->info('未发现阻断级问题,可以执行 lgp:rbac-migrate --dry-run 预演');
}
if ($path = $this->option('json')) {
$this->writeJson($path);
}
return $this->blockerCount > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* 前置检查:两侧表都得在,否则后面每条查询都会抛异常;
* 同时检查 nl_admin / nl_role 是否已补齐 RBAC 迁移所需列(缺列时 my-info 等接口会直接 500
*/
private function checkTables(): bool
{
$missing = [];
foreach (['user', 'role', 'user_role_relation', 'role_menu_relation', 'department'] as $table) {
if (!DB::connection(self::OLD)->getSchemaBuilder()->hasTable($table)) {
$missing[] = DB::connection(self::OLD)->getTablePrefix() . $table;
}
}
foreach (['admin', 'role'] as $table) {
if (!DB::connection(self::NEW)->getSchemaBuilder()->hasTable($table)) {
$missing[] = DB::connection(self::NEW)->getTablePrefix() . $table;
}
}
if (!empty($missing)) {
$this->error('缺少表:' . implode('、', $missing));
$this->line('新库系统表请先执行 php artisan sql:run-nl-admin 安装');
return false;
}
// 代码已依赖这些列AdminService::selectField / LoginService 惰性升级),迁移前就必须存在
$missingColumns = [];
foreach ([
'admin' => ['department_id', 'legacy_password', 'legacy_password_expire_at'],
'role' => ['status', 'color'],
] as $table => $columns) {
foreach ($columns as $column) {
if (!DB::connection(self::NEW)->getSchemaBuilder()->hasColumn($table, $column)) {
$missingColumns[] = DB::connection(self::NEW)->getTablePrefix() . $table . '.' . $column;
}
}
}
if (!empty($missingColumns)) {
$this->error('新库缺少列:' . implode('、', $missingColumns));
$this->line('请先执行 SQLdatabase/sql/rbac_migrate_ddl.sql幂等可重复跑');
$this->line('或直接跑php artisan lgp:rbac-migrate --force (会先幂等补列再迁数据)');
return false;
}
return true;
}
/**
* 迁移规模:先知道要搬多少行
*/
private function sectionScale(): void
{
$rows = [
['cc_user 待迁账号', $this->oldCount('user')],
['cc_role 待迁角色', $this->oldCount('role')],
['cc_department 部门(复用不迁)', $this->oldCount('department')],
['cc_user_role_relation 用户角色关系', DB::connection(self::OLD)->table('user_role_relation')->count()],
['cc_role_menu_relation 角色菜单关系', DB::connection(self::OLD)->table('role_menu_relation')->count()],
['nl_admin 现有账号', $this->newCount('admin')],
['nl_role 现有角色', $this->newCount('role')],
];
$this->line('<comment>[1] 迁移规模</comment>');
$this->table(['项目', '数量'], $rows);
$this->report['scale'] = collect($rows)->mapWithKeys(fn ($r) => [$r[0] => $r[1]])->all();
}
/**
* 多角色用户:老库是中间表多对多,新库是 nl_admin.role_id 单字段,多绑的必须人工决定取哪个
*
* 注意business 连接有前缀 cc_Laravel 会把别名 `ur` 编译成 `cc_ur`
* 但 selectRaw 不会自动改写,必须手写带前缀的别名,否则报 Unknown column 'ur.user_id'
*/
private function sectionMultiRole(): void
{
$p = $this->oldPrefix();
$rows = DB::connection(self::OLD)->table('user_role_relation as ur')
->join('user as u', 'u.id', '=', 'ur.user_id')
->where('u.deleted_at', 0)
->groupBy('ur.user_id', 'u.account', 'u.nick_name')
->havingRaw('COUNT(*) > 1')
->selectRaw("{$p}ur.user_id, {$p}u.account, {$p}u.nick_name, COUNT(*) AS role_count, GROUP_CONCAT({$p}ur.role_id ORDER BY {$p}ur.role_id) AS role_ids")
->get();
$this->line('<comment>[2] 绑定了多个角色的用户</comment>(新库一个账号只有一个 role_id');
if ($rows->isEmpty()) {
$this->info(' 无,可安全一对一迁移');
$this->report['multi_role'] = [];
return;
}
$this->blockerCount++;
$this->table(
['user_id', 'account', 'nick_name', '角色数', 'role_ids'],
$rows->map(fn ($r) => [$r->user_id, $r->account, $r->nick_name, $r->role_count, $r->role_ids])->all()
);
$this->warn(' 需决策:取 role_id 最小者、还是给新系统补多角色支持');
$this->report['multi_role'] = $rows->toArray();
// 没有任何角色关系的账号同样要处理,否则迁过去 role_id=0 什么菜单都看不到
$noRole = DB::connection(self::OLD)->table('user as u')
->where('u.deleted_at', 0)
->whereNotExists(function ($q) {
$q->select(DB::raw(1))->from('user_role_relation as ur')->whereColumn('ur.user_id', 'u.id');
})
->get(['id', 'account', 'nick_name']);
if ($noRole->isNotEmpty()) {
$this->warn(' 另有 ' . $noRole->count() . ' 个账号没有任何角色关系,迁移后 role_id=0 将看不到任何菜单');
$this->report['no_role'] = $noRole->toArray();
}
}
/**
* 重复手机号:登录是 where('phone', ...)->first(),重复会登录到错误的人
*/
private function sectionDuplicatePhone(): void
{
$rows = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)
->groupBy('phone')
->havingRaw('COUNT(*) > 1')
->selectRaw('phone, COUNT(*) AS c, GROUP_CONCAT(id ORDER BY id) AS ids, GROUP_CONCAT(account ORDER BY id) AS accounts')
->get();
$this->line('<comment>[3] 重复手机号</comment>(新库登录按 phone 查询,重复会登错人)');
if ($rows->isEmpty()) {
$this->info(' 无重复');
$this->report['duplicate_phone'] = [];
return;
}
$this->blockerCount++;
$this->table(
['phone', '重复数', 'user_ids', 'accounts'],
$rows->map(fn ($r) => [$r->phone, $r->c, $r->ids, $r->accounts])->all()
);
$this->warn(' 必须先在老库消重,迁移后还要给 nl_admin.phone 补唯一索引');
$this->report['duplicate_phone'] = $rows->toArray();
}
/**
* 手机号格式:新库 nl_admin.phone 是 char(11),超长会被截断,空值无法登录
*/
private function sectionInvalidPhone(): void
{
$rows = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)
->whereRaw("(phone = '' OR CHAR_LENGTH(phone) <> 11)")
->selectRaw('id, account, nick_name, phone, CHAR_LENGTH(phone) AS len')
->get();
$this->line('<comment>[4] 手机号长度异常</comment>(新库是 char(11),超长会截断)');
if ($rows->isEmpty()) {
$this->info(' 全部为 11 位');
$this->report['invalid_phone'] = [];
return;
}
$this->blockerCount++;
$this->table(
['id', 'account', 'nick_name', 'phone', '长度'],
$rows->map(fn ($r) => [$r->id, $r->account, $r->nick_name, $r->phone, $r->len])->all()
);
$this->warn(' 空手机号将完全无法登录,带区号/空格的要先清洗');
$this->report['invalid_phone'] = $rows->toArray();
}
/**
* 老角色清单:新库 nl_role 需要 value英文标识老库没这个字段逐个要指定
*/
private function sectionRoles(): void
{
$rows = DB::connection(self::OLD)->table('role')
->where('deleted_at', 0)
->orderBy('id')
->get(['id', 'name', 'desc', 'status', 'color']);
$counts = DB::connection(self::OLD)->table('user_role_relation as ur')
->join('user as u', 'u.id', '=', 'ur.user_id')
->where('u.deleted_at', 0)
->groupBy('ur.role_id')
->selectRaw("{$this->oldPrefix()}ur.role_id, COUNT(*) AS c")
->pluck('c', 'role_id');
$menuCounts = DB::connection(self::OLD)->table('role_menu_relation')
->groupBy('role_id')
->selectRaw('role_id, COUNT(*) AS c')
->pluck('c', 'role_id');
$this->line('<comment>[5] 老角色清单</comment>(新库 nl_role.value 必填,需为每个角色指定英文标识)');
$this->table(
['老 id', 'name', 'desc', 'status(0正常1禁用)', 'color', '账号数', '菜单数'],
$rows->map(fn ($r) => [
$r->id, $r->name, $r->desc, $r->status, $r->color,
$counts[$r->id] ?? 0, $menuCounts[$r->id] ?? 0,
])->all()
);
$this->report['old_roles'] = $rows->toArray();
}
/**
* 角色 ID 撞车nl_role.id=1 是超级管理员且代码里硬判断 role_id===1 全量放行,
* 老库 id=1 未必是超管,按原 ID 迁会把普通角色提权成超管
*/
private function sectionRoleIdCollision(): void
{
$this->line('<comment>[6] 角色 ID 撞车检查</comment>');
$oldOne = DB::connection(self::OLD)->table('role')->where('id', 1)->first();
$newOne = DB::connection(self::NEW)->table('role')->where('id', 1)->first();
if ($oldOne && $newOne) {
$this->table(
['库', 'id', 'name'],
[['老 cc_role', 1, $oldOne->name], ['新 nl_role', 1, $newOne->name]]
);
$this->warn(' 老 id=1 是「' . $oldOne->name . '」,新 id=1 是「' . $newOne->name . '」(硬编码全量放行)');
$this->warn(' 迁移必须做 ID 偏移,不能按原 ID 直搬');
$this->blockerCount++;
} else {
$this->info(' 老库无 id=1 角色,仍建议偏移以留出系统角色区间');
}
$maxOld = (int) DB::connection(self::OLD)->table('role')->max('id');
$this->line(' 老角色最大 id = ' . $maxOld . ',建议偏移量 100迁后占用 101..' . (100 + $maxOld) . '');
$this->report['role_id_collision'] = [
'old_role_1' => $oldOne->name ?? null,
'new_role_1' => $newOne->name ?? null,
'old_max_id' => $maxOld,
];
}
/**
* 密码格式:老库是无盐 sha140 位 hex非此格式的迁过去无法惰性升级只能走重置
*/
private function sectionPasswordFormat(): void
{
$total = $this->oldCount('user');
$sha1 = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)
->whereRaw("CHAR_LENGTH(password) = 40 AND password REGEXP '^[0-9a-f]{40}$'")
->count();
$empty = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)->where('password', '')->count();
$other = $total - $sha1 - $empty;
$this->line('<comment>[7] 密码格式</comment>(惰性升级依赖无盐 sha140 位 hex');
$this->table(['类型', '数量'], [
['标准 sha1可惰性升级', $sha1],
['空密码(必须重置)', $empty],
['其它格式(必须重置)', max($other, 0)],
]);
if ($empty > 0 || $other > 0) {
$this->warn(' 非 sha1 的账号迁移后需强制走密码重置流程');
}
$this->report['password_format'] = ['sha1' => $sha1, 'empty' => $empty, 'other' => max($other, 0)];
}
/**
* 部门引用cc_user.department 指向 cc_department.id悬空引用迁过去会显示空部门
*/
private function sectionDepartment(): void
{
$rows = DB::connection(self::OLD)->table('user as u')
->where('u.deleted_at', 0)
->where('u.department', '>', 0)
->whereNotExists(function ($q) {
$q->select(DB::raw(1))->from('department as d')
->whereColumn('d.id', 'u.department')->where('d.deleted_at', 0);
})
->get(['u.id', 'u.account', 'u.department']);
$this->line('<comment>[8] 部门悬空引用</comment>');
if ($rows->isEmpty()) {
$this->info(' 无悬空引用');
$this->report['orphan_department'] = [];
return;
}
$this->table(
['user_id', 'account', 'department已不存在'],
$rows->map(fn ($r) => [$r->id, $r->account, $r->department])->all()
);
$this->warn(' 这些账号迁移后 department_id 建议置 0');
$this->report['orphan_department'] = $rows->toArray();
}
/**
* account 与 phone 的关系:老前端表单字段叫 account但后端一直是 where('phone', ...)
* 也就是用户实际输的是手机号。account 不参与登录,迁移时只留痕
*/
private function sectionAccountVsPhone(): void
{
$diff = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)
->whereColumn('account', '<>', 'phone')
->count();
$this->line('<comment>[9] account 与 phone 不一致的账号</comment>');
$this->line(' 共 ' . $diff . ' 个。account 从不参与登录(老 UserService::login 查的是 phone');
$this->line(' 迁移时把原 account 写进 nl_admin.desc 留痕即可,不新增列');
$this->report['account_phone_diff'] = $diff;
}
private function oldCount(string $table): int
{
return DB::connection(self::OLD)->table($table)->where('deleted_at', 0)->count();
}
private function newCount(string $table): int
{
return DB::connection(self::NEW)->table($table)->where('deleted_at', 0)->count();
}
/**
* 老库business表前缀selectRaw 里引用别名时必须手动拼上
* Laravel 会把 `as ur` 编译成 `as cc_ur`,但 selectRaw 字符串不会自动改写
*/
private function oldPrefix(): string
{
return DB::connection(self::OLD)->getTablePrefix();
}
private function writeJson(string $path): void
{
$full = str_starts_with($path, '/') ? $path : base_path($path);
@mkdir(dirname($full), 0755, true);
file_put_contents(
$full,
json_encode([
'generated_at' => date('Y-m-d H:i:s'),
'blocker_count' => $this->blockerCount,
'report' => $this->report,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$this->info('体检结果已写入:' . $full);
}
}