初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\adminapi\middleware;
use app\adminapi\middleware\helper\SseHelper;
use core\exception\handler\UnauthorizedHttpException;
use core\jwt\JwtToken;
use core\tool\Json;
use madong\swagger\attribute\AllowAnonymous;
use madong\swagger\helper\AnnotationHelper;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
/**
* AccessToken 中间件(JWT Token 验证)
* 功能:
* 1. 检查请求是否需要跳过 Token 验证(通过 AllowAnonymous 注解)
* 2. 验证 JWT Token 有效性,无效则返回 401
*/
#\[\Attribute\]
final class AccessTokenMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$route = $request->route;
if (!$route || !isset($request->action)) {
return $handler($request);
}
$controllerClass = $request->controller;
$action = $request->action;
$skipAuth = AnnotationHelper::getMethodAnnotation($controllerClass, $action, AllowAnonymous::class);
if ($skipAuth && !$skipAuth->requireToken) {
return $handler($request);
}
try {
$jwt = new JwtToken();
$payload = $jwt->verify();
$userId = $payload['id'] ?? null;
if (empty($userId)) {
throw new UnauthorizedHttpException();
}
$jwt->touchCurrentToken();
} catch (\Exception $e) {
// 检查是否为SSE请求
if (SseHelper::isSseRequest($request)) {
return SseHelper::sendSseErrorViaConnection($request, $e->getMessage());
}
return Json::fail($e->getMessage(), [], 401);
}
return $handler($request);
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\adminapi\middleware;
use app\adminapi\CurrentUser;
use app\adminapi\event\OperationLogEvent;
use madong\helper\Arr;
use madong\swagger\attribute\AllowAnonymous;
use madong\swagger\helper\AnnotationHelper;
use support\Container;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
#[\Attribute]
final class OperationMiddleware implements MiddlewareInterface
{
private CurrentUser $currentUser;
public function __construct()
{
$this->currentUser = Container::make(CurrentUser::class);
}
public function process(Request $request, callable $handler): Response
{
$controllerClass = $request->controller;
$action = $request->action;
$oaAnnotations = AnnotationHelper::getMethodOaAnnotations($controllerClass, $action);
$oaData = $this->extractOaData($oaAnnotations);
$firstOa = Arr::first($oaData);
$response = $handler($request);
// 检查是否需要记录操作日志(跳过匿名访问)
$allowAnonymous = AnnotationHelper::getMethodAnnotation($controllerClass, $action, AllowAnonymous::class);
if (!$allowAnonymous || $allowAnonymous->requirePermission) {
$logData = $this->buildLogData($request, $response, $firstOa);
$event = new OperationLogEvent(
$logData['name'],
$logData['oa_tags'],
$logData['oa_description'],
$logData['app'],
$logData['ip'],
$logData['ip_location'],
$logData['browser'],
$logData['os'],
$logData['url'],
$logData['class_name'],
$logData['action'],
$logData['method'],
$logData['param'],
$logData['result'],
$logData['user_name'],
$logData['user_id']
);
$event->dispatch();
}
return $response;
}
private function extractOaData(array $oaAnnotations): array
{
return array_map(function ($annotation) {
return [
'type' => $annotation::class, // 注解类型(如 OpenApi\Attributes\Get)
'summary' => $annotation->summary, // 操作摘要
'tags' => $annotation->tags, // 标签
'description' => $annotation->description ?? '', // 描述
];
}, $oaAnnotations);
}
private function buildLogData(Request $request, Response $response, ?array $firstOa): array
{
$adminInfo = $this->currentUser->admin();
return [
'name' => $firstOa['summary'] ?? '未知操作', // 用 OA summary 作为操作名称
'oa_tags' => $firstOa['tags'] ?? [], // OA 标签
'oa_description' => $firstOa['description'] ?? '', // OA 描述
'app' => $request->app,
'ip' => $request->getRealIp(),
'ip_location' => $this->getIpLocation($request->getRealIp()), // 可选:IP 归属地
'browser' => $this->getBrowser($request->header('user-agent')),
'os' => $this->getOs($request->header('user-agent')),
'url' => trim($request->path()),
'class_name' => $request->controller,
'action' => $request->action,
'method' => $request->method(),
'param' => $this->filterParams($request->all()), // 过滤后的参数(数组)
'result' => $this->formatResponse($response), // 格式化响应(数组/字符串)
'user_name' => $adminInfo['user_name'] ?? '匿名用户',
'user_id' => $adminInfo['user_id'] ?? 0,
];
}
private function getIpLocation(string $ip): string
{
return '未知';
}
private function getBrowser($user_agent): string
{
$br = 'Unknown';
if (preg_match('/MSIE/i', $user_agent)) {
$br = 'MSIE';
} elseif (preg_match('/Firefox/i', $user_agent)) {
$br = 'Firefox';
} elseif (preg_match('/Chrome/i', $user_agent)) {
$br = 'Chrome';
} elseif (preg_match('/Safari/i', $user_agent)) {
$br = 'Safari';
} elseif (preg_match('/Opera/i', $user_agent)) {
$br = 'Opera';
} else {
$br = 'Other';
}
return $br;
}
private function getOs($user_agent): string
{
$os = 'Unknown';
if (preg_match('/win/i', $user_agent)) {
$os = 'Windows';
} elseif (preg_match('/mac/i', $user_agent)) {
$os = 'Mac';
} elseif (preg_match('/linux/i', $user_agent)) {
$os = 'Linux';
} else {
$os = 'Other';
}
return $os;
}
private function filterParams($params): string
{
$blackList = ['password', 'old_password', 'new_password', 'content'];
foreach ($params as $key => $value) {
if (in_array($key, $blackList)) {
$params[$key] = '******';
}
}
return json_encode($params, JSON_UNESCAPED_UNICODE);
}
private function formatResponse(Response $response): array|string
{
$rawBody = $response->rawBody();
if (str_contains($rawBody, 'json')) {
return json_decode($rawBody, true) ?? $rawBody;
}
return $rawBody;
}
}
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\adminapi\middleware;
use app\adminapi\middleware\helper\SseHelper;
use app\adminapi\CurrentUser;
use madong\swagger\attribute\Permission;
use core\exception\handler\UnauthorizedHttpException;
use core\jwt\JwtToken;
use core\tool\Json;
use madong\swagger\attribute\AllowAnonymous;
use madong\swagger\helper\AnnotationHelper;
use support\Container;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
/**
* 方法级权限校验中间件
* 功能:
* 1. 支持 SkipAuth 注解跳过权限校验
* 2. 校验类/方法级的 Permission 注解
* 3. 处理 AND/OR 操作类型的权限逻辑
*/
#\[\Attribute\]
final class PermissionMiddleware implements MiddlewareInterface
{
/**
* Webman 中间件入口
*
* @throws \core\exception\handler\ForbiddenHttpException|\core\exception\handler\UnauthorizedHttpException
*/
public function process(Request $request, callable $handler): Response
{
$controllerClass = $request->controller;
$action = $request->action;
$allowAnonymous = AnnotationHelper::getMethodAnnotation($controllerClass, $action, AllowAnonymous::class);
if ($allowAnonymous && !$allowAnonymous->requirePermission) {
return $handler($request);
}
try {
$userId = (new JwtToken())->id();
if (empty($userId)) {
throw new UnauthorizedHttpException();
}
} catch (\Exception $e) {
// 检查是否为SSE请求
if (SseHelper::isSseRequest($request)) {
return SseHelper::sendSseErrorViaConnection($request, $e->getMessage());
}
return Json::fail($e->getMessage(), [], 401);
}
// 使用CurrentUser进行权限验证
$currentUser = Container::make(CurrentUser::class);
// 顶级管理员直接跳过权限验证
if ($currentUser->isSuperAdmin()) {
return $handler($request);
}
try {
//权限验证
$permissions = $this->resolvePermissions($controllerClass, $action);
$this->validatePermissions($currentUser, $permissions);
} catch (\Exception $e) {
// 检查是否为SSE请求
if (SseHelper::isSseRequest($request)) {
return SseHelper::sendSseErrorViaConnection($request, $e->getMessage());
}
throw $e;
}
return $handler($request);
}
/**
* 解析类/方法级的 Permission 注解
*
* @return array<Permission> 合并后的权限注解列表
*/
private function resolvePermissions(string $controllerClass, string $action): array
{
$permissions = [];
// a. 类级别注解(作用于整个控制器)
$classAnnotation = AnnotationHelper::getClassAnnotation($controllerClass, Permission::class);
if ($classAnnotation) {
$permissions[] = $classAnnotation;
}
// b. 方法级别注解(覆盖/补充类级别规则)
$methodAnnotation = AnnotationHelper::getMethodAnnotation($controllerClass, $action, Permission::class);
if ($methodAnnotation) {
$permissions[] = $methodAnnotation;
}
return $permissions;
}
/**
* 校验用户权限是否符合注解规则
*
* @throws \core\exception\handler\ForbiddenHttpException
*/
private function validatePermissions(CurrentUser $currentUser, array $permissions): void
{
foreach ($permissions as $permission) {
$this->handleSinglePermission($currentUser, $permission);
}
}
/**
* 处理 Permission 注解的权限校验逻辑
*
* @param CurrentUser $currentUser 当前用户
* @param Permission $permission 权限注解
*
* @throws \core\exception\handler\ForbiddenHttpException
*/
private function handleSinglePermission(
CurrentUser $currentUser,
Permission $permission
): void
{
// 权限码数组
$codes = $permission->getCodes();
// 操作类型
$operation = $permission->getOperation();
// 使用CurrentUser的checkPermission方法进行权限验证
$currentUser->checkPermission($codes, $operation);
}
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\adminapi\middleware\helper;
use core\tool\Sse;
use Webman\Http\Request;
use Webman\Http\Response;
/**
* SSE 处理辅助类
* 提供 SSE 请求检测和错误处理功能
*/
class SseHelper
{
/**
* 检查是否为SSE请求
*
* @param Request $request
*
* @return bool
*/
public static function isSseRequest(Request $request): bool
{
// 方法1:检查请求头
$acceptHeader = $request->header('Accept', '');
if (str_contains($acceptHeader, 'text/event-stream')) {
return true;
}
// 方法2:检查路径(SSE接口通常有特定路径模式)
$path = $request->path();
$ssePaths = [
'/terminal',
'/terminal/exec',
];
foreach ($ssePaths as $ssePath) {
if (str_starts_with($path, $ssePath)) {
return true;
}
}
// 方法3:检查查询参数
$isSse = $request->input('sse', false) || $request->input('stream', false);
if ($isSse) {
return true;
}
return false;
}
/**
* 通过连接发送SSE错误消息
*
* @param Request $request
* @param string $message
*
* @return Response
* @throws \Exception
*/
public static function sendSseErrorViaConnection(Request $request, string $message): Response
{
// 检查是否有连接对象
if (!property_exists($request, 'connection') || !$request->connection) {
// 回退到普通的SSE响应
return self::createSseErrorResponse($message, $request->input('uuid'));
}
$connection = $request->connection;
$uuid = $request->input('uuid');
try {
// 发送SSE响应头
$connection->send(new Response(200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Expose-Headers' => 'Content-Type',
], "\r\n"));
// 发送SSE错误消息,传递uuid
$connection->send(Sse::error($message, null, $uuid));
// 发送空数据表示结束,防止前端等待更多数据
$connection->send(new Response(200, [], ""));
} catch (\Exception $e) {
// 忽略连接异常,返回普通响应
return self::createSseErrorResponse($message, $uuid);
}
// 返回空响应结束请求
return new Response(200, [], '');
}
/**
* 创建SSE格式的错误响应
*
* @param string $message
* @param string|null $uuid
*
* @return Response
* @throws \Exception
*/
public static function createSseErrorResponse(string $message, ?string $uuid = null): Response
{
$errorMessage = Sse::error($message, [], $uuid);
// 创建完整的SSE响应
$responseBody = $errorMessage;
return new Response(200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no', // 禁用Nginx缓冲
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Expose-Headers' => 'Content-Type',
], $responseBody);
}
}