汇总结果,供 --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); } }