397 lines
17 KiB
PHP
397 lines
17 KiB
PHP
<?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('请先执行 SQL:database/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,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 密码格式:老库是无盐 sha1(40 位 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>(惰性升级依赖无盐 sha1,40 位 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);
|
||
}
|
||
}
|