Files
lgp-admin-plus-api/app/Console/Commands/RbacMigrateCommand.php
LQ bcf54c2727 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:21 +08:00

416 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 : [];
}
}