汇总结果,供 --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('[1] 迁移规模');
$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('[2] 绑定了多个角色的用户(新库一个账号只有一个 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('[3] 重复手机号(新库登录按 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('[4] 手机号长度异常(新库是 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('[5] 老角色清单(新库 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('[6] 角色 ID 撞车检查');
$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('[7] 密码格式(惰性升级依赖无盐 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('[8] 部门悬空引用');
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('[9] account 与 phone 不一致的账号');
$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);
}
}