Files
lgp-admin-plus-api/app/Console/Commands/RbacMigrateCommand.php

416 lines
16 KiB
PHP
Raw Normal View History

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 账号与角色迁移cc_user / cc_role / cc_user_role_relation nl_admin / nl_role
*
* 几处必须偏移或转换的地方(照原样搬会静默出错):
* - 角色 ID 偏移nl_role.id=1 是超管且代码里硬判断 role_id===1 全量放行,老库 id=1 未必是超管
* - 账号 ID 同样偏移:老 cc_user.id 1 起,会撞上安装包里的超管
* - 用户角色从多对多降级成 nl_admin.role_id 单字段,多绑的取 role_id 最小者
* - 密码是无盐 sha1 legacy_password 走惰性升级password 留空
* - nl_admin.open_id / ip_table NOT NULL 且无默认值,必须显式赋值
*
* 幂等:按 nl_admin.desc / nl_role.desc 里的迁移留痕判断是否已迁,重复执行只补未迁的行。
*/
class RbacMigrateCommand extends Command
{
protected $signature = 'lgp:rbac-migrate
{--dry-run : 只预演不写库}
{--offset=100 : 角色与账号的 ID 偏移量,避开系统预留区间}
{--legacy-days=90 : 遗留 sha1 密码的有效天数,过期强制重置}
{--value-map= : 角色 value 映射 JSON 文件,形如 {"管理员":"manager"}}
{--force : 跳过确认}';
protected $description = '账号与角色迁移cc_user/cc_role → nl_admin/nl_role';
private const OLD = 'business';
private const NEW = 'mysql';
/** 迁移留痕前缀,用于幂等判断 */
private const TAG = '[migrated:cc_user#';
private const ROLE_TAG = '[migrated:cc_role#';
private bool $dryRun = false;
private int $offset = 100;
/** @var array<int, int> 老角色 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 + offsetvalue 缺省生成 role_{老id}
*/
private function migrateRoles(): void
{
$this->newLine();
$this->line('<comment>迁移角色</comment>');
$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('<comment>迁移账号</comment>');
// 多角色降级:取 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('<comment>角色菜单语义参照(需人工重绑,不自动迁移)</comment>');
$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 : [];
}
}