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