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;
|
|||
|
|
}
|
|||
|
|
}
|