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

239 lines
7.9 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 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;
}
}