初始化
缺陷:主题配色需要优化整体的同风格
This commit is contained in:
238
app/Console/Commands/EndpointSyncCommand.php
Normal file
238
app/Console/Commands/EndpointSyncCommand.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ApiEndpointModel;
|
||||
use App\Service\PermissionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Routing\Route as RoutingRoute;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use ReflectionMethod;
|
||||
|
||||
/**
|
||||
* 把已注册的路由同步进 nl_api_endpoint
|
||||
*
|
||||
* 这张表同时是操作日志的匹配依据和接口级权限的授权清单,手工维护必然漏。
|
||||
* 路由本身由 autoRouteRegister 反射生成,这里再反射一次路由表,
|
||||
* 顺便把控制器方法的 PHPDoc 首行当作操作名,新增模块不用再补 SQL。
|
||||
*/
|
||||
class EndpointSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'lgp:endpoint-sync
|
||||
{--dry-run : 只预演不写库}
|
||||
{--prune : 把路由表里已不存在的接口标记为停用}';
|
||||
|
||||
protected $description = '同步路由到接口注册表 nl_api_endpoint(幂等)';
|
||||
|
||||
private bool $dryRun = false;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->dryRun = (bool) $this->option('dry-run');
|
||||
if ($this->dryRun) {
|
||||
$this->warn('预演模式:不会写入任何数据');
|
||||
}
|
||||
|
||||
$noLog = (array) config('nl.log.no_insert', []);
|
||||
$seen = [];
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach (Route::getRoutes() as $route) {
|
||||
$url = $this->normalizeUri($route->uri());
|
||||
if ($url === null) {
|
||||
continue;
|
||||
}
|
||||
$method = $this->mapMethod($route);
|
||||
$key = $url . '#' . $method;
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
$attributes = [
|
||||
'name' => $this->resolveName($route, $url),
|
||||
'description' => $this->resolveDescription($route),
|
||||
'controller' => $this->resolveController($route),
|
||||
'is_log' => in_array($url, $noLog, true) ? 0 : 1,
|
||||
'status' => 1,
|
||||
];
|
||||
|
||||
$exists = ApiEndpointModel::where('url', $url)->where('method', $method)->first();
|
||||
if ($exists) {
|
||||
// 名称与说明可能被人在后台改过,不覆盖;只补空值与纠正控制器归属
|
||||
$diff = [];
|
||||
foreach (['controller', 'status'] as $field) {
|
||||
if ((string) $exists->{$field} !== (string) $attributes[$field]) {
|
||||
$diff[$field] = $attributes[$field];
|
||||
}
|
||||
}
|
||||
foreach (['name', 'description'] as $field) {
|
||||
if ((string) $exists->{$field} === '' && $attributes[$field] !== '') {
|
||||
$diff[$field] = $attributes[$field];
|
||||
}
|
||||
}
|
||||
if ((int) $exists->deleted_at !== 0) {
|
||||
$diff['deleted_at'] = 0;
|
||||
}
|
||||
if (!empty($diff)) {
|
||||
$updated++;
|
||||
$this->line(" ~ {$url} [{$method}] " . implode(', ', array_keys($diff)));
|
||||
if (!$this->dryRun) {
|
||||
$diff['updated_at'] = time();
|
||||
ApiEndpointModel::where('id', $exists->id)->update($diff);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$created++;
|
||||
$this->line(" + {$url} [{$method}] {$attributes['name']}");
|
||||
if (!$this->dryRun) {
|
||||
ApiEndpointModel::insert($attributes + [
|
||||
'url' => $url,
|
||||
'method' => $method,
|
||||
'created_at' => time(),
|
||||
'deleted_at' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$stale = $this->findStale(array_keys($seen));
|
||||
$this->newLine();
|
||||
$this->info("新增 {$created} 条,更新 {$updated} 条,路由已不存在 " . count($stale) . ' 条');
|
||||
|
||||
if (!empty($stale)) {
|
||||
foreach ($stale as $row) {
|
||||
$this->line(" ? {$row->url} [{$row->method}] {$row->name}");
|
||||
}
|
||||
if ($this->option('prune') && !$this->dryRun) {
|
||||
ApiEndpointModel::whereIn('id', array_column($stale, 'id'))->update([
|
||||
'status' => 0,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
$this->warn('以上接口已停用(未删除,避免历史操作日志失去关联)');
|
||||
} else {
|
||||
$this->warn('加 --prune 可把它们停用');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->dryRun) {
|
||||
PermissionService::getInstance()->clear();
|
||||
$this->info('已清空角色权限缓存');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只收 /api/ 下的业务接口;带路径参数的路由不进注册表(授权与日志都按固定路径匹配)
|
||||
*/
|
||||
private function normalizeUri(string $uri): ?string
|
||||
{
|
||||
$uri = trim($uri, '/');
|
||||
if (!str_starts_with($uri, 'api/')) {
|
||||
return null;
|
||||
}
|
||||
$uri = trim(substr($uri, 4), '/');
|
||||
if ($uri === '' || str_contains($uri, '{')) {
|
||||
return null;
|
||||
}
|
||||
return $uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET=1 POST=2 其他=0,与 ApiOpLogMiddleware 的映射保持一致
|
||||
*/
|
||||
private function mapMethod(RoutingRoute $route): int
|
||||
{
|
||||
$methods = array_diff($route->methods(), ['HEAD']);
|
||||
if ($methods === ['GET']) {
|
||||
return 1;
|
||||
}
|
||||
if ($methods === ['POST']) {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function resolveController(RoutingRoute $route): string
|
||||
{
|
||||
$action = $route->getAction('controller');
|
||||
if (!is_string($action) || !str_contains($action, '@')) {
|
||||
return '';
|
||||
}
|
||||
return class_basename(explode('@', $action)[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取控制器方法 PHPDoc 的首行作为操作名,没有注释时回落成路径
|
||||
*/
|
||||
private function resolveName(RoutingRoute $route, string $url): string
|
||||
{
|
||||
$summary = $this->docSummary($route);
|
||||
return $summary !== '' ? mb_substr($summary, 0, 64) : $url;
|
||||
}
|
||||
|
||||
private function resolveDescription(RoutingRoute $route): string
|
||||
{
|
||||
$lines = $this->docLines($route);
|
||||
array_shift($lines);
|
||||
return mb_substr(trim(implode(' ', $lines)), 0, 255);
|
||||
}
|
||||
|
||||
private function docSummary(RoutingRoute $route): string
|
||||
{
|
||||
return $this->docLines($route)[0] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> 去掉 @tag 行后的注释正文
|
||||
*/
|
||||
private function docLines(RoutingRoute $route): array
|
||||
{
|
||||
$action = $route->getAction('controller');
|
||||
if (!is_string($action) || !str_contains($action, '@')) {
|
||||
return [];
|
||||
}
|
||||
[$class, $method] = explode('@', $action);
|
||||
try {
|
||||
$doc = (new ReflectionMethod($class, $method))->getDocComment();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
if (!is_string($doc)) {
|
||||
return [];
|
||||
}
|
||||
$lines = [];
|
||||
foreach (explode("\n", $doc) as $line) {
|
||||
$line = trim(ltrim(trim($line), '/*'));
|
||||
if ($line === '' || str_starts_with($line, '@')) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = $line;
|
||||
}
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $seenKeys url#method 组合
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function findStale(array $seenKeys): array
|
||||
{
|
||||
$stale = [];
|
||||
$rows = ApiEndpointModel::where('deleted_at', 0)->where('status', 1)->get(['id', 'url', 'method', 'name']);
|
||||
foreach ($rows as $row) {
|
||||
if (!in_array($row->url . '#' . (int) $row->method, $seenKeys, true)) {
|
||||
$stale[] = (object) [
|
||||
'id' => (int) $row->id,
|
||||
'url' => (string) $row->url,
|
||||
'method' => (int) $row->method,
|
||||
'name' => (string) $row->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
return $stale;
|
||||
}
|
||||
}
|
||||
157
app/Console/Commands/MenuSyncCommand.php
Normal file
157
app/Console/Commands/MenuSyncCommand.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MenuModel;
|
||||
use App\Models\RoleMenuRelationModel;
|
||||
use App\Service\common\RedisService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 按 config/lgp_menu.php 幂等同步 nl_menu
|
||||
*
|
||||
* name 是全局唯一键,同步以它为准:存在则按声明纠正字段,不存在则插入。
|
||||
* pid 用父节点的 name 反查,所以配置里不出现任何自增 id,反复执行结果一致。
|
||||
* 同步完必须清菜单缓存,否则前端还会拿到 Redis 里的旧树。
|
||||
*/
|
||||
class MenuSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'lgp:menu-sync
|
||||
{--dry-run : 只预演不写库}
|
||||
{--grant-super : 同步后把新菜单补进超级管理员之外的角色(默认不动角色关系)}';
|
||||
|
||||
protected $description = '同步 LGP 业务菜单到 nl_menu(幂等)';
|
||||
|
||||
private bool $dryRun = false;
|
||||
|
||||
private int $created = 0;
|
||||
|
||||
private int $updated = 0;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->dryRun = (bool) $this->option('dry-run');
|
||||
if ($this->dryRun) {
|
||||
$this->warn('预演模式:不会写入任何数据');
|
||||
}
|
||||
|
||||
$tree = config('lgp_menu', []);
|
||||
if (empty($tree)) {
|
||||
$this->error('config/lgp_menu.php 为空');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->walk($tree, 0);
|
||||
|
||||
$this->newLine();
|
||||
$this->info("新增 {$this->created} 条,更新 {$this->updated} 条");
|
||||
|
||||
if (!$this->dryRun) {
|
||||
// 菜单树按角色缓存在 Redis,不清的话前端看不到新菜单
|
||||
RedisService::getInstance()->init(config('nl.redis.menu_key'))->delAll();
|
||||
$this->info('已清空菜单缓存');
|
||||
$this->reportRoleBinding();
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $nodes
|
||||
*/
|
||||
private function walk(array $nodes, int $pid): void
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
// parent 显式声明时优先(用于挂到脚手架已有菜单下),否则跟随递归层级
|
||||
$parentId = isset($node['parent'])
|
||||
? $this->resolveParentId((string) $node['parent'])
|
||||
: $pid;
|
||||
$id = $this->upsert($node, $parentId);
|
||||
if (!empty($node['children'])) {
|
||||
$this->walk($node['children'], $id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveParentId(string $name): int
|
||||
{
|
||||
$id = (int) MenuModel::where('name', $name)->where('deleted_at', 0)->value('id');
|
||||
if ($id === 0) {
|
||||
$this->warn("父级菜单 {$name} 不存在,该节点挂到根目录");
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return int 菜单 id(预演模式下返回 0,子节点会挂到根,仅影响预演输出)
|
||||
*/
|
||||
private function upsert(array $node, int $pid): int
|
||||
{
|
||||
$isDirectory = ($node['component'] ?? '') === 'BasicLayout';
|
||||
$attributes = [
|
||||
'title' => (string) $node['title'],
|
||||
'icon' => (string) ($node['icon'] ?? ''),
|
||||
'path' => (string) $node['path'],
|
||||
'component' => (string) $node['component'],
|
||||
'redirect' => (string) ($node['redirect'] ?? ''),
|
||||
// 取反字段:0 开启缓存 / 0 固定标签
|
||||
'keep_alive' => (int) ($node['keep_alive'] ?? ($isDirectory ? 0 : 1)),
|
||||
'hide_in_menu' => (int) ($node['hide_in_menu'] ?? 0),
|
||||
'affix_tab' => (int) ($node['affix_tab'] ?? 1),
|
||||
'badge' => (string) ($node['badge'] ?? ''),
|
||||
'badge_type' => (int) ($node['badge_type'] ?? 0),
|
||||
'badge_variants' => (int) ($node['badge_variants'] ?? 0),
|
||||
'iframe_src' => (string) ($node['iframe_src'] ?? ''),
|
||||
'query' => (string) ($node['query'] ?? ''),
|
||||
'pid' => $pid,
|
||||
'sort' => (int) ($node['sort'] ?? 0),
|
||||
];
|
||||
|
||||
$name = (string) $node['name'];
|
||||
$exists = MenuModel::where('name', $name)->first();
|
||||
|
||||
if ($exists) {
|
||||
$diff = array_filter(
|
||||
$attributes,
|
||||
fn ($value, $key) => (string) $exists->{$key} !== (string) $value,
|
||||
ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
// 已软删的菜单同步时一并复活,否则改配置也救不回来
|
||||
if ((int) $exists->deleted_at !== 0) {
|
||||
$diff['deleted_at'] = 0;
|
||||
}
|
||||
if (empty($diff)) {
|
||||
$this->line(" = {$attributes['title']} ({$name})");
|
||||
return (int) $exists->id;
|
||||
}
|
||||
$this->line(" ~ {$attributes['title']} ({$name}) " . implode(', ', array_keys($diff)));
|
||||
$this->updated++;
|
||||
if (!$this->dryRun) {
|
||||
$diff['updated_at'] = time();
|
||||
MenuModel::where('id', $exists->id)->update($diff);
|
||||
}
|
||||
return (int) $exists->id;
|
||||
}
|
||||
|
||||
$this->line(" + {$attributes['title']} ({$name})");
|
||||
$this->created++;
|
||||
if ($this->dryRun) {
|
||||
return 0;
|
||||
}
|
||||
$attributes['created_at'] = time();
|
||||
return (int) MenuModel::insertGetId($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只提示不自动授权:给谁看哪些菜单是业务决策,命令替用户决定容易造成越权
|
||||
*/
|
||||
private function reportRoleBinding(): void
|
||||
{
|
||||
if (!$this->option('grant-super')) {
|
||||
$bound = RoleMenuRelationModel::distinct()->count('role_id');
|
||||
$this->newLine();
|
||||
$this->warn("超级管理员(role_id=1)自动可见全部菜单;其余 {$bound} 个已授权角色需到「角色管理」重新勾选新菜单。");
|
||||
}
|
||||
}
|
||||
}
|
||||
396
app/Console/Commands/RbacAuditCommand.php
Normal file
396
app/Console/Commands/RbacAuditCommand.php
Normal file
@@ -0,0 +1,396 @@
|
||||
<?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('请先执行 SQL:database/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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码格式:老库是无盐 sha1(40 位 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>(惰性升级依赖无盐 sha1,40 位 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);
|
||||
}
|
||||
}
|
||||
415
app/Console/Commands/RbacMigrateCommand.php
Normal file
415
app/Console/Commands/RbacMigrateCommand.php
Normal file
@@ -0,0 +1,415 @@
|
||||
<?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 : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user