老角色 id => 新角色 id */ private array $roleMap = []; public function handle(): int { $this->dryRun = (bool) $this->option('dry-run'); $this->offset = max(1, (int) $this->option('offset')); if ($this->dryRun) { $this->warn('预演模式:不会写入任何数据'); } $this->ensureColumns(); if (!$this->dryRun && !$this->option('force')) { $this->warn('即将向 nl_role / nl_admin 写入数据。建议先跑 lgp:rbac-audit 与 --dry-run。'); if (!$this->confirm('继续?', false)) { $this->line('已取消'); return self::SUCCESS; } } try { if (!$this->dryRun) { DB::connection(self::NEW)->beginTransaction(); } $this->migrateRoles(); $this->migrateAdmins(); if (!$this->dryRun) { DB::connection(self::NEW)->commit(); } } catch (Throwable $e) { if (!$this->dryRun && DB::connection(self::NEW)->transactionLevel() > 0) { DB::connection(self::NEW)->rollBack(); } $this->error('迁移失败已回滚:' . $e->getMessage()); return self::FAILURE; } $this->reportRoleMenu(); $this->newLine(); $this->info($this->dryRun ? '预演结束' : '迁移完成'); $this->line('后续手动步骤:'); $this->line(' 1. 按第十一节重建 nl_menu,再按功能重绑角色菜单(老 menu_id 不能直搬)'); $this->line(' 2. 确认手机号无重复后执行 database/sql/rbac_migrate_ddl.sql 里的 uk_phone 唯一索引'); return self::SUCCESS; } /** * 幂等补列:MySQL 不支持 ADD COLUMN IF NOT EXISTS,这里查 information_schema 再决定 */ private function ensureColumns(): void { $adds = [ 'admin' => [ 'department_id' => "ADD COLUMN `department_id` int NOT NULL DEFAULT 0 COMMENT '所属部门ID,对应 cc_department.id' AFTER `role_id`", 'legacy_password' => "ADD COLUMN `legacy_password` varchar(64) NOT NULL DEFAULT '' COMMENT '老系统无盐sha1密码,登录成功后清空' AFTER `password`", 'legacy_password_expire_at' => "ADD COLUMN `legacy_password_expire_at` int NOT NULL DEFAULT 0 COMMENT '遗留密码失效时间' AFTER `legacy_password`", ], 'role' => [ 'status' => "ADD COLUMN `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态 0正常 1禁用' AFTER `desc`", 'color' => "ADD COLUMN `color` varchar(32) NOT NULL DEFAULT '' COMMENT '角色标签颜色' AFTER `status`", ], ]; foreach ($adds as $table => $columns) { $missing = []; foreach ($columns as $column => $clause) { if (!$this->hasColumn($table, $column)) { $missing[] = $clause; } } if (empty($missing)) { continue; } $physical = DB::connection(self::NEW)->getTablePrefix() . $table; $sql = "ALTER TABLE `{$physical}` " . implode(', ', $missing); $this->line('补列:' . $physical . ' → ' . count($missing) . ' 列'); if (!$this->dryRun) { DB::connection(self::NEW)->statement($sql); } } // 安装包里超管 status=1,而列注释是「0正常 1禁用」,补上状态校验后会被锁死,先纠正 if (!$this->dryRun) { DB::connection(self::NEW)->table('admin')->where('id', 1)->where('status', 1)->update(['status' => 0]); } } private function hasColumn(string $table, string $column): bool { return DB::connection(self::NEW)->getSchemaBuilder()->hasColumn($table, $column); } /** * 角色迁移:新 id = 老 id + offset,value 缺省生成 role_{老id} */ private function migrateRoles(): void { $this->newLine(); $this->line('迁移角色'); $valueMap = $this->loadValueMap(); $roles = DB::connection(self::OLD)->table('role')->where('deleted_at', 0)->orderBy('id')->get(); $rows = []; $skipped = 0; foreach ($roles as $role) { $newId = (int) $role->id + $this->offset; $this->roleMap[(int) $role->id] = $newId; if (DB::connection(self::NEW)->table('role')->where('id', $newId)->exists()) { $skipped++; continue; } $value = $valueMap[$role->name] ?? ('role_' . $role->id); $data = [ 'id' => $newId, 'name' => mb_substr((string) $role->name, 0, 32), 'value' => mb_substr($value, 0, 32), 'pid' => 0, 'desc' => $this->tagged(self::ROLE_TAG, (int) $role->id, (string) $role->desc), 'status' => (int) $role->status, 'color' => (string) $role->color, 'created_at' => (int) $role->created_at, 'updated_at' => (int) $role->updated_at, 'deleted_at' => 0, ]; $rows[] = $data; } $this->table( ['老 id', '新 id', 'name', 'value', 'status'], array_map( fn ($r) => [$r['id'] - $this->offset, $r['id'], $r['name'], $r['value'], $r['status']], $rows ) ); if ($skipped > 0) { $this->line('已存在跳过:' . $skipped . ' 个'); } if (!$this->dryRun && !empty($rows)) { DB::connection(self::NEW)->table('role')->insert($rows); } $this->info('角色新增 ' . count($rows) . ' 个'); } /** * 账号迁移:新 id = 老 id + offset,密码进 legacy_password 走惰性升级 */ private function migrateAdmins(): void { $this->newLine(); $this->line('迁移账号'); // 多角色降级:取 role_id 最小者,与体检报告口径一致 $userRole = DB::connection(self::OLD)->table('user_role_relation') ->groupBy('user_id') ->selectRaw('user_id, MIN(role_id) AS role_id') ->pluck('role_id', 'user_id'); $validDept = DB::connection(self::OLD)->table('department') ->where('deleted_at', 0)->pluck('id')->all(); $validDept = array_flip(array_map('intval', $validDept)); $legacyExpire = time() + max(1, (int) $this->option('legacy-days')) * 86400; $users = DB::connection(self::OLD)->table('user')->where('deleted_at', 0)->orderBy('id')->get(); $rows = []; $skipped = 0; $problems = []; foreach ($users as $user) { $newId = (int) $user->id + $this->offset; if (DB::connection(self::NEW)->table('admin')->where('id', $newId)->exists()) { $skipped++; continue; } $phone = trim((string) $user->phone); if ($phone === '' || mb_strlen($phone) !== 11) { $problems[] = [$user->id, $user->account, $phone === '' ? '(空)' : $phone, '手机号非 11 位,已跳过']; continue; } $legacy = $this->normalizeLegacyPassword((string) $user->password); if ($legacy === '') { $problems[] = [$user->id, $user->account, $phone, '密码非标准 sha1,迁移后需走重置']; } $oldRoleId = (int) ($userRole[$user->id] ?? 0); $newRoleId = $oldRoleId > 0 ? ($this->roleMap[$oldRoleId] ?? 0) : 0; if ($newRoleId === 0) { $problems[] = [$user->id, $user->account, $phone, '无角色,迁移后看不到菜单']; } $dept = (int) $user->department; if ($dept > 0 && !isset($validDept[$dept])) { $problems[] = [$user->id, $user->account, $phone, "部门 {$dept} 不存在,已置 0"]; $dept = 0; } $nickName = trim((string) $user->nick_name); if ($nickName === '') { $nickName = (string) $user->account; } $rows[] = [ 'id' => $newId, // NOT NULL 无默认值,格式对齐安装包里超管那行 'open_id' => 'nl_' . bin2hex(random_bytes(15)), 'avatar' => (string) $user->avatar, 'nick_name' => mb_substr($nickName, 0, 32), // 新库是 bcrypt,老 sha1 没有明文无法转换,先留空由 legacy_password 兜底 'password' => '', 'legacy_password' => $legacy, 'legacy_password_expire_at' => $legacy === '' ? 0 : $legacyExpire, 'phone' => $phone, 'email' => substr((string) $user->email, 0, 32), 'code' => '', 'role_id' => $newRoleId, 'department_id' => $dept, 'province_id' => 0, 'city_id' => 0, 'reg_ip' => 0, 'last_login_time' => $this->parseLastLogin((string) $user->last_login_at), 'ip' => (string) ($user->last_login_ip ?: '0'), // NOT NULL 的 json 列,不给值会直接插入失败 'ip_table' => '[]', 'operation_password' => '0', 'desc' => $this->buildAdminDesc($user), // 老库同样是 0正常 1禁用,可以直搬;但新表默认值是 1,必须显式写 'status' => (int) $user->status, 'created_at' => (int) $user->created_at, 'updated_at' => (int) $user->updated_at, 'deleted_at' => 0, ]; } if (!empty($problems)) { $this->newLine(); $this->warn('需要关注的账号:'); $this->table(['老 id', 'account', 'phone', '说明'], $problems); } if (!$this->dryRun && !empty($rows)) { foreach (array_chunk($rows, 200) as $chunk) { DB::connection(self::NEW)->table('admin')->insert($chunk); } // 后续新建账号从迁移区间之后继续,避免自增撞上已占用的 id $maxId = (int) DB::connection(self::NEW)->table('admin')->max('id'); $physical = DB::connection(self::NEW)->getTablePrefix() . 'admin'; DB::connection(self::NEW)->statement("ALTER TABLE `{$physical}` AUTO_INCREMENT = " . ($maxId + 1)); } if ($skipped > 0) { $this->line('已存在跳过:' . $skipped . ' 个'); } $this->info('账号新增 ' . count($rows) . ' 个'); } /** * 老 sha1 必须是 40 位纯 hex 才能参与惰性升级,其它格式一律留空走重置 */ private function normalizeLegacyPassword(string $password): string { $password = strtolower(trim($password)); return preg_match('/^[0-9a-f]{40}$/', $password) === 1 ? $password : ''; } /** * 老 last_login_at 是 varchar,可能是时间戳字符串也可能是日期文本 */ private function parseLastLogin(string $value): int { $value = trim($value); if ($value === '' || $value === '0') { return 0; } if (ctype_digit($value)) { return (int) $value; } $ts = strtotime($value); return $ts === false ? 0 : $ts; } /** * 备注里留迁移痕迹:account 不参与登录(老 login 查的是 phone),但对不上账时要能追溯 */ private function buildAdminDesc(object $user): string { $parts = []; $intro = trim((string) ($user->introduction ?? '')); $address = trim((string) ($user->address ?? '')); if ($intro !== '') { $parts[] = $intro; } if ($address !== '') { $parts[] = '所在地:' . $address; } $desc = implode(';', $parts); return $this->tagged(self::TAG, (int) $user->id, $desc, (string) $user->account); } /** * 拼迁移留痕,同时作为幂等判断依据。超长时优先保留留痕 */ private function tagged(string $tag, int $oldId, string $desc, string $account = ''): string { $mark = $tag . $oldId . ($account !== '' ? ' account=' . $account : '') . ']'; $full = $desc === '' ? $mark : $mark . ' ' . $desc; return mb_substr($full, 0, 255); } /** * 角色菜单关系不能按 menu_id 直搬:业务页面在 vben5 里路径全变了,老 menu_id 指向的菜单不复存在。 * 这里只导出「哪个角色能看哪些功能」的语义清单,供重建菜单后按功能重绑。 */ private function reportRoleMenu(): void { $this->newLine(); $this->line('角色菜单语义参照(需人工重绑,不自动迁移)'); $rows = DB::connection(self::OLD)->table('role_menu_relation as rm') ->join('role as r', 'r.id', '=', 'rm.role_id') ->join('menu as m', 'm.id', '=', 'rm.menu_id') ->where('r.deleted_at', 0) ->where('m.deleted_at', 0) ->orderBy('r.id') ->orderBy('m.order_no') ->get(['r.id as role_id', 'r.name as role_name', 'm.title', 'm.router']); if ($rows->isEmpty()) { $this->line(' 老库无角色菜单关系'); return; } $grouped = []; foreach ($rows as $row) { $key = ($this->roleMap[(int) $row->role_id] ?? 0) . '|' . $row->role_name; $grouped[$key][] = $row->title; } $table = []; foreach ($grouped as $key => $titles) { [$newRoleId, $roleName] = explode('|', $key, 2); $table[] = [$newRoleId, $roleName, count($titles), implode('、', array_slice($titles, 0, 8)) . (count($titles) > 8 ? ' …' : '')]; } $this->table(['新角色 id', '角色名', '菜单数', '可见功能(截断)'], $table); } /** * 角色 value 映射:老 cc_role 没有 value 字段,缺省用 role_{老id}, * 想要可读的英文标识就传 --value-map */ private function loadValueMap(): array { $path = (string) $this->option('value-map'); if ($path === '') { return []; } $full = str_starts_with($path, '/') ? $path : base_path($path); if (!file_exists($full)) { $this->warn('value-map 文件不存在,改用默认 role_{老id}:' . $full); return []; } $map = json_decode((string) file_get_contents($full), true); return is_array($map) ? $map : []; } }