416 lines
16 KiB
PHP
416 lines
16 KiB
PHP
<?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 + offset,value 缺省生成 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 : [];
|
||
}
|
||
}
|