Files

397 lines
17 KiB
PHP
Raw Permalink Normal View History

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 账号与角色迁移前的数据体检
*
* 老库 cc_user / cc_role / cc_user_role_relation 的结构与新库 nl_admin / nl_role 有几处不兼容,
* 直接搬运会静默出错(登录到错误的人、普通角色变超管、账号迁过去全是禁用)。
* 本命令只读不写,把这些隐患一次列清,供 lgp:rbac-migrate 之前决策。
*/
class RbacAuditCommand extends Command
{
protected $signature = 'lgp:rbac-audit
{--json= : 额外把结果写成 JSON 文件,便于留档比对}';
protected $description = '账号与角色迁移前数据体检(只读)';
/** 老库连接:业务表走 business 连接(前缀 cc_ */
private const OLD = 'business';
/** 新库连接:系统表走默认 mysql 连接(前缀 nl_ */
private const NEW = 'mysql';
/** 阻断级问题数量,决定退出码 */
private int $blockerCount = 0;
/** @var array<string, mixed> 汇总结果,供 --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('请先执行 SQLdatabase/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('<comment>[1] 迁移规模</comment>');
$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('<comment>[2] 绑定了多个角色的用户</comment>(新库一个账号只有一个 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('<comment>[3] 重复手机号</comment>(新库登录按 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('<comment>[4] 手机号长度异常</comment>(新库是 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('<comment>[5] 老角色清单</comment>(新库 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('<comment>[6] 角色 ID 撞车检查</comment>');
$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,
];
}
/**
* 密码格式:老库是无盐 sha140 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('<comment>[7] 密码格式</comment>(惰性升级依赖无盐 sha140 位 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('<comment>[8] 部门悬空引用</comment>');
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('<comment>[9] account 与 phone 不一致的账号</comment>');
$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);
}
}