feat(database): 添加用户角色权限系统及相关监控功能

- 创建用户表(users)包含基本信息和认证字段
- 创建角色表(roles)用于权限控制
- 创建权限表(permissions)定义系统权限
- 创建用户角色关联表(user_roles)建立用户与角色关系
- 创建角色权限关联表(role_permissions)建立角色与权限关系
- 创建迁移记录表(migrations)追踪数据库变更
- 添加AdminController提供管理员面板功能
- 实现系统监控、配置管理、缓存清理等功能
- 添加AOP切面编程支持的各种通知类型
- 实现告警管理AlertManager支持多渠道告警
- 添加文档注解接口规范
This commit is contained in:
Lawson
2026-04-08 17:00:28 +08:00
commit 2782d765fb
270 changed files with 107192 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
{
"name": "fendx/web",
"description": "FendxPHP Web Module - 路由、请求、响应、拦截器",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Lawson",
"email": "lawson@fendx.cn"
}
],
"require": {
"php": ">=8.1",
"fendx/common": "^1.0",
"fendx/core": "^1.0"
},
"autoload": {
"psr-4": {
"Fendx\\Web\\": "src/"
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Annotation;
#[\Attribute(\Attribute::TARGET_METHOD)]
final class GetRoute
{
public string $path;
public function __construct(string $path)
{
$this->path = $path;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Annotation;
#[\Attribute(\Attribute::TARGET_METHOD)]
final class PostRoute
{
public string $path;
public function __construct(string $path)
{
$this->path = $path;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Interceptor;
use Fendx\Web\Request\Request;
use Fendx\Security\Auth\Auth;
final class AuthInterceptor implements Interceptor
{
private array $excludeRoutes = [
'/login',
'/register',
'/health',
'/',
];
public function before(Request $request): bool
{
$path = $request->path();
// 跳过不需要认证的路由
if (in_array($path, $this->excludeRoutes)) {
return true;
}
// 检查Authorization头
$token = $request->header('Authorization');
if (!$token) {
return false;
}
// 移除Bearer前缀
if (str_starts_with($token, 'Bearer ')) {
$token = substr($token, 7);
}
// 验证token
$user = Auth::authenticate($token);
if (!$user) {
return false;
}
return true;
}
public function after(Request $request, mixed $result): mixed
{
// 可以在这里添加响应头信息
return $result;
}
public function afterCompletion(Request $request, ?\Throwable $exception): void
{
// 记录请求日志
if ($exception) {
error_log("Request failed: " . $request->path() . " - " . $exception->getMessage());
}
}
public function addExcludeRoute(string $route): void
{
if (!in_array($route, $this->excludeRoutes)) {
$this->excludeRoutes[] = $route;
}
}
public function removeExcludeRoute(string $route): void
{
$key = array_search($route, $this->excludeRoutes);
if ($key !== false) {
unset($this->excludeRoutes[$key]);
$this->excludeRoutes = array_values($this->excludeRoutes);
}
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Interceptor;
use Fendx\Web\Request\Request;
interface Interceptor
{
public function before(Request $request): bool;
public function after(Request $request, mixed $result): mixed;
public function afterCompletion(Request $request, ?\Throwable $exception): void;
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Interceptor;
use Fendx\Web\Request\Request;
use Fendx\Common\Exception\BusinessException;
final class InterceptorManager
{
private array $globalInterceptors = [];
private array $routeInterceptors = [];
private array $groupInterceptors = [];
public function addGlobalInterceptor(Interceptor $interceptor, int $priority = 0): void
{
$this->globalInterceptors[] = [
'interceptor' => $interceptor,
'priority' => $priority
];
// 按优先级排序
usort($this->globalInterceptors, fn($a, $b) => $b['priority'] - $a['priority']);
}
public function addRouteInterceptor(string $route, Interceptor $interceptor, int $priority = 0): void
{
if (!isset($this->routeInterceptors[$route])) {
$this->routeInterceptors[$route] = [];
}
$this->routeInterceptors[$route][] = [
'interceptor' => $interceptor,
'priority' => $priority
];
usort($this->routeInterceptors[$route], fn($a, $b) => $b['priority'] - $a['priority']);
}
public function addGroupInterceptor(string $group, Interceptor $interceptor, int $priority = 0): void
{
if (!isset($this->groupInterceptors[$group])) {
$this->groupInterceptors[$group] = [];
}
$this->groupInterceptors[$group][] = [
'interceptor' => $interceptor,
'priority' => $priority
];
usort($this->groupInterceptors[$group], fn($a, $b) => $b['priority'] - $a['priority']);
}
public function executeBefore(Request $request, string $route, array $groups = []): void
{
$interceptors = $this->getApplicableInterceptors($route, $groups);
foreach ($interceptors as $interceptorData) {
$interceptor = $interceptorData['interceptor'];
if (!$interceptor->before($request)) {
throw new BusinessException(403, 'Request intercepted by ' . get_class($interceptor));
}
}
}
public function executeAfter(Request $request, mixed $result, string $route, array $groups = []): mixed
{
$interceptors = $this->getApplicableInterceptors($route, $groups);
foreach ($interceptors as $interceptorData) {
$interceptor = $interceptorData['interceptor'];
$result = $interceptor->after($request, $result);
}
return $result;
}
public function executeAfterCompletion(Request $request, ?\Throwable $exception, string $route, array $groups = []): void
{
$interceptors = $this->getApplicableInterceptors($route, $groups);
foreach ($interceptors as $interceptorData) {
$interceptor = $interceptorData['interceptor'];
$interceptor->afterCompletion($request, $exception);
}
}
private function getApplicableInterceptors(string $route, array $groups): array
{
$interceptors = [];
// 添加全局拦截器
$interceptors = array_merge($interceptors, $this->globalInterceptors);
// 添加分组拦截器
foreach ($groups as $group) {
if (isset($this->groupInterceptors[$group])) {
$interceptors = array_merge($interceptors, $this->groupInterceptors[$group]);
}
}
// 添加路由拦截器
if (isset($this->routeInterceptors[$route])) {
$interceptors = array_merge($interceptors, $this->routeInterceptors[$route]);
}
// 按优先级排序
usort($interceptors, fn($a, $b) => $b['priority'] - $a['priority']);
return $interceptors;
}
public function removeGlobalInterceptor(string $interceptorClass): void
{
$this->globalInterceptors = array_filter(
$this->globalInterceptors,
fn($item) => get_class($item['interceptor']) !== $interceptorClass
);
}
public function removeRouteInterceptor(string $route, string $interceptorClass): void
{
if (isset($this->routeInterceptors[$route])) {
$this->routeInterceptors[$route] = array_filter(
$this->routeInterceptors[$route],
fn($item) => get_class($item['interceptor']) !== $interceptorClass
);
}
}
public function removeGroupInterceptor(string $group, string $interceptorClass): void
{
if (isset($this->groupInterceptors[$group])) {
$this->groupInterceptors[$group] = array_filter(
$this->groupInterceptors[$group],
fn($item) => get_class($item['interceptor']) !== $interceptorClass
);
}
}
public function clear(): void
{
$this->globalInterceptors = [];
$this->routeInterceptors = [];
$this->groupInterceptors = [];
}
public function getInterceptorsCount(): int
{
$count = count($this->globalInterceptors);
foreach ($this->routeInterceptors as $routeInterceptors) {
$count += count($routeInterceptors);
}
foreach ($this->groupInterceptors as $groupInterceptors) {
$count += count($groupInterceptors);
}
return $count;
}
}

View File

@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Request;
final class Request
{
private array $get;
private array $post;
private array $files;
private array $server;
private array $headers;
private array $cookies;
private ?string $rawBody;
private array $attributes = [];
private ?object $parsedBody = null;
public function __construct(
array $get = [],
array $post = [],
array $files = [],
array $server = [],
array $cookies = [],
array $headers = []
) {
$this->get = $get ?: $_GET;
$this->post = $post ?: $_POST;
$this->files = $files ?: $_FILES;
$this->server = $server ?: $_SERVER;
$this->cookies = $cookies ?: $_COOKIE;
$this->headers = $headers ?: $this->getAllHeaders();
$this->rawBody = file_get_contents('php://input');
}
public static function createFromGlobals(): self
{
return new self();
}
public function get(string $key, mixed $default = null): mixed
{
return $this->get[$key] ?? $default;
}
public function post(string $key, mixed $default = null): mixed
{
return $this->post[$key] ?? $default;
}
public function input(string $key, mixed $default = null): mixed
{
return $this->post($key) ?? $this->get($key) ?? $default;
}
public function all(): array
{
return array_merge($this->get, $this->post);
}
public function header(string $key, mixed $default = null): mixed
{
return $this->headers[strtolower($key)] ?? $default;
}
public function cookie(string $key, mixed $default = null): mixed
{
return $this->cookies[$key] ?? $default;
}
public function file(string $key): mixed
{
return $this->files[$key] ?? null;
}
public function method(): string
{
return $this->server['REQUEST_METHOD'] ?? 'GET';
}
public function uri(): string
{
return $this->server['REQUEST_URI'] ?? '/';
}
public function path(): string
{
return parse_url($this->uri(), PHP_URL_PATH) ?: '/';
}
public function isGet(): bool
{
return $this->method() === 'GET';
}
public function isPost(): bool
{
return $this->method() === 'POST';
}
public function isPut(): bool
{
return $this->method() === 'PUT';
}
public function isDelete(): bool
{
return $this->method() === 'DELETE';
}
public function isAjax(): bool
{
return strtolower($this->header('X-Requested-With') ?? '') === 'xmlhttprequest';
}
public function wantsJson(): bool
{
return str_contains($this->header('Accept', ''), 'application/json');
}
public function json(): mixed
{
if ($this->rawBody === null) {
return null;
}
$decoded = json_decode($this->rawBody, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}
public function ip(): string
{
$ipKeys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'];
foreach ($ipKeys as $key) {
$ip = $this->server[$key] ?? '';
if ($ip && $ip !== 'unknown') {
$ips = explode(',', $ip);
return trim($ips[0]);
}
}
return '127.0.0.1';
}
public function userAgent(): string
{
return $this->server['HTTP_USER_AGENT'] ?? '';
}
/**
* 设置属性
*/
public function setAttribute(string $key, mixed $value): void
{
$this->attributes[$key] = $value;
}
/**
* 获取属性
*/
public function getAttribute(string $key, mixed $default = null): mixed
{
return $this->attributes[$key] ?? $default;
}
/**
* 获取所有属性
*/
public function getAttributes(): array
{
return $this->attributes;
}
/**
* 检查是否有输入参数
*/
public function has(string $key): bool
{
return isset($this->get[$key]) || isset($this->post[$key]);
}
/**
* 获取所有GET参数
*/
public function query(): array
{
return $this->get;
}
/**
* 获取所有POST参数
*/
public function body(): array
{
return $this->post;
}
/**
* 获取原始请求体
*/
public function getRawBody(): string
{
return $this->rawBody ?: '';
}
/**
* 获取解析后的请求体
*/
public function getParsedBody(): ?object
{
if ($this->parsedBody === null) {
$rawBody = $this->getRawBody();
$contentType = $this->header('Content-Type', '');
if (str_contains($contentType, 'application/json')) {
$this->parsedBody = json_decode($rawBody);
} elseif (str_contains($contentType, 'application/xml')) {
$this->parsedBody = simplexml_load_string($rawBody);
} else {
$this->parsedBody = (object) [];
}
}
return $this->parsedBody;
}
/**
* 获取内容类型
*/
public function getContentType(): string
{
return $this->header('Content-Type', '');
}
/**
* 是否为HTTPS请求
*/
public function isSecure(): bool
{
return ($this->server['HTTPS'] ?? '') !== '' &&
($this->server['HTTPS'] ?? '') !== 'off' ||
($this->server['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https' ||
($this->server['HTTP_X_FORWARDED_SSL'] ?? '') === 'on';
}
/**
* 是否为PATCH请求
*/
public function isPatch(): bool
{
return $this->method() === 'PATCH';
}
/**
* 是否为OPTIONS请求
*/
public function isOptions(): bool
{
return $this->method() === 'OPTIONS';
}
/**
* 获取基础URL
*/
public function getBaseUrl(): string
{
$scheme = $this->isSecure() ? 'https' : 'http';
$host = $this->server['HTTP_HOST'] ?? 'localhost';
$port = $this->server['SERVER_PORT'] ?? '';
$url = $scheme . '://' . $host;
if (($scheme === 'http' && $port !== '80') || ($scheme === 'https' && $port !== '443')) {
$url .= ':' . $port;
}
return $url;
}
/**
* 获取完整URL
*/
public function getFullUrl(): string
{
return $this->getBaseUrl() . $this->uri();
}
/**
* 获取查询字符串
*/
public function queryString(): string
{
return parse_url($this->uri(), PHP_URL_QUERY) ?: '';
}
/**
* 获取服务器变量
*/
public function server(string $key, mixed $default = null): mixed
{
return $this->server[$key] ?? $default;
}
/**
* 获取所有请求头
*/
public function headers(): array
{
return $this->headers;
}
/**
* 获取所有Cookie
*/
public function cookies(): array
{
return $this->cookies;
}
/**
* 获取所有上传文件
*/
public function files(): array
{
return $this->files;
}
/**
* 验证请求方法
*/
public function isMethod(string $method): bool
{
return strtoupper($this->method()) === strtoupper($method);
}
/**
* 获取客户端IP更完善的版本
*/
public function getClientIp(): string
{
$ipKeys = [
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR'
];
foreach ($ipKeys as $key) {
if (array_key_exists($key, $this->server) === true) {
foreach (explode(',', $this->server[$key]) as $ip) {
$ip = trim($ip);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) {
return $ip;
}
}
}
}
return $this->server['REMOTE_ADDR'] ?? '127.0.0.1';
}
/**
* 转换为数组
*/
public function toArray(): array
{
return [
'method' => $this->method(),
'uri' => $this->uri(),
'path' => $this->path(),
'query_string' => $this->queryString(),
'get' => $this->get,
'post' => $this->post,
'files' => $this->files,
'cookies' => $this->cookies,
'headers' => $this->headers,
'attributes' => $this->attributes,
'ip' => $this->ip(),
'client_ip' => $this->getClientIp(),
'user_agent' => $this->userAgent(),
'is_ajax' => $this->isAjax(),
'wants_json' => $this->wantsJson(),
'is_secure' => $this->isSecure(),
];
}
private function getAllHeaders(): array
{
$headers = [];
foreach ($this->server as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$header = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5)))));
$headers[strtolower($header)] = $value;
}
}
return $headers;
}
}

View File

@@ -0,0 +1,398 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Response;
/**
* HTTP响应类
* 封装HTTP响应的相关操作
*/
class HttpResponse
{
private string $content = '';
private int $statusCode = 200;
private array $headers = [];
private string $version = '1.1';
private ?string $charset = null;
/**
* 标准HTTP状态码
*/
const STATUS_CODES = [
100 => 'Continue',
101 => 'Switching Protocols',
200 => 'OK',
201 => 'Created',
202 => 'Accepted',
204 => 'No Content',
301 => 'Moved Permanently',
302 => 'Found',
304 => 'Not Modified',
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
409 => 'Conflict',
422 => 'Unprocessable Entity',
429 => 'Too Many Requests',
500 => 'Internal Server Error',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout',
];
public function __construct(string $content = '', int $statusCode = 200, array $headers = [])
{
$this->content = $content;
$this->statusCode = $statusCode;
$this->headers = $headers;
}
/**
* 创建响应
*/
public static function create(string $content = '', int $statusCode = 200, array $headers = []): self
{
return new self($content, $statusCode, $headers);
}
/**
* 设置内容
*/
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
/**
* 获取内容
*/
public function getContent(): string
{
return $this->content;
}
/**
* 设置状态码
*/
public function setStatusCode(int $statusCode): self
{
$this->statusCode = $statusCode;
return $this;
}
/**
* 获取状态码
*/
public function getStatusCode(): int
{
return $this->statusCode;
}
/**
* 获取状态文本
*/
public function getStatusText(): string
{
return self::STATUS_CODES[$this->statusCode] ?? 'Unknown';
}
/**
* 设置请求头
*/
public function setHeader(string $name, string $value): self
{
$this->headers[$name] = $value;
return $this;
}
/**
* 批量设置请求头
*/
public function setHeaders(array $headers): self
{
$this->headers = array_merge($this->headers, $headers);
return $this;
}
/**
* 获取请求头
*/
public function getHeaders(): array
{
return $this->headers;
}
/**
* 获取指定请求头
*/
public function getHeader(string $name, string $default = ''): string
{
return $this->headers[$name] ?? $default;
}
/**
* 设置内容类型
*/
public function setContentType(string $contentType, ?string $charset = null): self
{
$header = $contentType;
if ($charset) {
$header .= '; charset=' . $charset;
$this->charset = $charset;
}
return $this->setHeader('Content-Type', $header);
}
/**
* 获取内容类型
*/
public function getContentType(): string
{
return $this->getHeader('Content-Type');
}
/**
* 设置字符集
*/
public function setCharset(string $charset): self
{
$this->charset = $charset;
$contentType = $this->getContentType();
if ($contentType && strpos($contentType, 'charset') === false) {
$this->setContentType($contentType, $charset);
}
return $this;
}
/**
* 获取字符集
*/
public function getCharset(): ?string
{
return $this->charset;
}
/**
* 设置HTTP版本
*/
public function setVersion(string $version): self
{
$this->version = $version;
return $this;
}
/**
* 获取HTTP版本
*/
public function getVersion(): string
{
return $this->version;
}
/**
* JSON响应
*/
public function json($data, int $statusCode = 200, array $headers = []): self
{
$headers['Content-Type'] = 'application/json';
if (is_array($data) || is_object($data)) {
$content = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
$content = json_encode(['data' => $data]);
}
return $this->setContent($content)
->setStatusCode($statusCode)
->setHeaders($headers);
}
/**
* 统一格式响应
*/
public function api($data = null, string $message = 'success', int $code = 0, int $statusCode = 200): self
{
$response = [
'code' => $code,
'message' => $message,
'data' => $data,
'timestamp' => time(),
'traceId' => \Fendx\Core\Context\Context::getTraceId(),
];
return $this->json($response, $statusCode);
}
/**
* 成功响应
*/
public function success($data = null, string $message = 'success'): self
{
return $this->api($data, $message, 0, 200);
}
/**
* 错误响应
*/
public function error(string $message = 'error', int $code = 400, $data = null): self
{
return $this->api($data, $message, $code, 400);
}
/**
* 分页响应
*/
public function paginate(array $items, int $total, int $page, int $pageSize, string $message = 'success'): self
{
$data = [
'items' => $items,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'total_pages' => ceil($total / $pageSize),
'has_more' => $page * $pageSize < $total
];
return $this->api($data, $message, 0, 200);
}
/**
* 重定向响应
*/
public function redirect(string $url, int $statusCode = 302): self
{
return $this->setHeader('Location', $url)
->setStatusCode($statusCode);
}
/**
* 文件下载响应
*/
public function download(string $filePath, string $fileName = null): self
{
if (!file_exists($filePath)) {
return $this->error('File not found', 404);
}
$fileName = $fileName ?: basename($filePath);
$fileSize = filesize($filePath);
$this->setHeader('Content-Type', 'application/octet-stream')
->setHeader('Content-Disposition', 'attachment; filename="' . $fileName . '"')
->setHeader('Content-Length', (string) $fileSize)
->setHeader('Cache-Control', 'no-cache, must-revalidate')
->setHeader('Pragma', 'no-cache');
$this->setContent(file_get_contents($filePath));
return $this;
}
/**
* 文件响应
*/
public function file(string $filePath, string $contentType = null): self
{
if (!file_exists($filePath)) {
return $this->error('File not found', 404);
}
$fileName = basename($filePath);
$fileSize = filesize($filePath);
if (!$contentType) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$contentType = finfo_file($finfo, $filePath);
finfo_close($finfo);
}
$this->setHeader('Content-Type', $contentType)
->setHeader('Content-Length', (string) $fileSize)
->setContent(file_get_contents($filePath));
return $this;
}
/**
* 设置缓存控制
*/
public function setCache(int $maxAge = 3600, bool $public = true): self
{
$control = $public ? 'public' : 'private';
$control .= ', max-age=' . $maxAge;
return $this->setHeader('Cache-Control', $control)
->setHeader('Expires', gmdate('D, d M Y H:i:s', time() + $maxAge) . ' GMT');
}
/**
* 禁用缓存
*/
public function setNoCache(): self
{
return $this->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0')
->setHeader('Pragma', 'no-cache')
->setHeader('Expires', 'Thu, 19 Nov 1981 08:52:00 GMT');
}
/**
* 设置CORS
*/
public function setCors(string $origin = '*', array $methods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], array $headers = []): self
{
return $this->setHeader('Access-Control-Allow-Origin', $origin)
->setHeader('Access-Control-Allow-Methods', implode(', ', $methods))
->setHeader('Access-Control-Allow-Headers', empty($headers) ? 'Content-Type, Authorization' : implode(', ', $headers));
}
/**
* 发送响应
*/
public function send(): void
{
// 发送状态行
header(sprintf('HTTP/%s %d %s', $this->version, $this->statusCode, $this->getStatusText()));
// 发送请求头
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
// 发送内容
echo $this->content;
// 如果需要,可以在这里添加终止逻辑
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
}
/**
* 转换为数组
*/
public function toArray(): array
{
return [
'content' => $this->content,
'status_code' => $this->statusCode,
'status_text' => $this->getStatusText(),
'headers' => $this->headers,
'version' => $this->version,
'charset' => $this->charset,
];
}
/**
* 转换为字符串
*/
public function __toString(): string
{
return $this->content;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Response;
final class Response
{
public static function success(mixed $data = null, string $message = 'Success'): array
{
return [
'code' => 200,
'message' => $message,
'data' => $data,
'traceId' => \Fendx\Core\Context\Context::getTraceId(),
];
}
public static function error(int $code, string $message, mixed $data = null): array
{
return [
'code' => $code,
'message' => $message,
'data' => $data,
'traceId' => \Fendx\Core\Context\Context::getTraceId(),
];
}
public static function paginated(array $items, int $total, int $page, int $pageSize, string $message = 'Success'): array
{
return [
'code' => 200,
'message' => $message,
'data' => [
'items' => $items,
'pagination' => [
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'totalPages' => ceil($total / $pageSize),
]
],
'traceId' => \Fendx\Core\Context\Context::getTraceId(),
];
}
public static function notFound(string $message = 'Resource not found'): array
{
return self::error(404, $message);
}
public static function unauthorized(string $message = 'Unauthorized'): array
{
return self::error(401, $message);
}
public static function forbidden(string $message = 'Forbidden'): array
{
return self::error(403, $message);
}
public static function validationError(string $message = 'Validation failed', array $errors = []): array
{
return self::error(422, $message, $errors);
}
public static function serverError(string $message = 'Internal server error'): array
{
return self::error(500, $message);
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Route;
use Fendx\Common\Exception\BusinessException;
use Fendx\Web\Request\Request;
use Fendx\Web\Response\Response;
use Fendx\Web\Interceptor\InterceptorManager;
final class Router
{
private array $routes = [];
private array $methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
private ?InterceptorManager $interceptorManager = null;
public function get(string $path, mixed $handler): void
{
$this->addRoute('GET', $path, $handler);
}
public function post(string $path, mixed $handler): void
{
$this->addRoute('POST', $path, $handler);
}
public function put(string $path, mixed $handler): void
{
$this->addRoute('PUT', $path, $handler);
}
public function delete(string $path, mixed $handler): void
{
$this->addRoute('DELETE', $path, $handler);
}
public function patch(string $path, mixed $handler): void
{
$this->addRoute('PATCH', $path, $handler);
}
public function options(string $path, mixed $handler): void
{
$this->addRoute('OPTIONS', $path, $handler);
}
private function addRoute(string $method, string $path, mixed $handler): void
{
if (!in_array($method, $this->methods)) {
throw new BusinessException(500, 'INVALID_HTTP_METHOD', ['method' => $method]);
}
$this->routes[$method][$path] = $handler;
}
public function dispatch(Request $request): Response
{
$method = $request->method();
$path = $request->path();
if (!isset($this->routes[$method])) {
return Response::error(405, 'Method not allowed');
}
$handler = $this->findHandler($method, $path);
if (!$handler) {
return Response::error(404, 'Route not found');
}
try {
// 执行前置拦截器
if ($this->interceptorManager) {
$this->interceptorManager->executeBefore($request, $path);
}
// 执行控制器方法
$result = $this->executeHandler($handler, $request);
// 执行后置拦截器
if ($this->interceptorManager) {
$result = $this->interceptorManager->executeAfter($request, $result, $path);
}
return $this->createResponse($result);
} catch (\Throwable $e) {
// 执行完成拦截器
if ($this->interceptorManager) {
$this->interceptorManager->executeAfterCompletion($request, $e, $path);
}
throw $e;
}
}
private function findHandler(string $method, string $path): mixed
{
// 精确匹配
if (isset($this->routes[$method][$path])) {
return $this->routes[$method][$path];
}
// 路径参数匹配
foreach ($this->routes[$method] as $route => $handler) {
if ($this->matchRoute($route, $path)) {
return $handler;
}
}
return null;
}
private function matchRoute(string $route, string $path): bool
{
// 简单的路径参数匹配,如 /user/{id}
$routeParts = explode('/', trim($route, '/'));
$pathParts = explode('/', trim($path, '/'));
if (count($routeParts) !== count($pathParts)) {
return false;
}
foreach ($routeParts as $i => $routePart) {
if (str_starts_with($routePart, '{') && str_ends_with($routePart, '}')) {
continue; // 路径参数
}
if ($routePart !== $pathParts[$i]) {
return false;
}
}
return true;
}
private function executeHandler(mixed $handler, Request $request): mixed
{
if (is_callable($handler)) {
return $handler($request);
}
if (is_array($handler) && count($handler) === 2) {
[$controller, $method] = $handler;
$controllerInstance = new $controller();
return $controllerInstance->$method($request);
}
throw new BusinessException(500, 'Invalid route handler');
}
private function createResponse(mixed $result): Response
{
if ($result instanceof Response) {
return $result;
}
if (is_array($result)) {
return Response::success($result);
}
return Response::success($result);
}
public function setInterceptorManager(InterceptorManager $interceptorManager): void
{
$this->interceptorManager = $interceptorManager;
}
public function getInterceptorManager(): ?InterceptorManager
{
return $this->interceptorManager;
}
public function loadRoutes(string $routesFile): void
{
if (file_exists($routesFile)) {
$routes = require $routesFile;
if (is_array($routes)) {
foreach ($routes as $method => $methodRoutes) {
foreach ($methodRoutes as $path => $handler) {
$this->addRoute(strtoupper($method), $path, $handler);
}
}
}
}
}
public function getRoutes(): array
{
return $this->routes;
}
public function clear(): void
{
$this->routes = [];
$this->interceptorManager = null;
}
}

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Router;
/**
* 路由类
* 定义单个路由的信息
*/
class Route
{
private string $method;
private string $path;
private $handler;
private array $middlewares = [];
private string $name;
private array $parameters = [];
private array $where = [];
public function __construct(string $method, string $path, $handler, string $name = '')
{
$this->method = strtoupper($method);
$this->path = $path;
$this->handler = $handler;
$this->name = $name ?: $this->generateDefaultName();
}
/**
* 获取HTTP方法
*/
public function getMethod(): string
{
return $this->method;
}
/**
* 获取路径
*/
public function getPath(): string
{
return $this->path;
}
/**
* 获取处理器
*/
public function getHandler()
{
return $this->handler;
}
/**
* 获取中间件
*/
public function getMiddlewares(): array
{
return $this->middlewares;
}
/**
* 添加中间件
*/
public function middleware($middleware): self
{
$this->middlewares[] = $middleware;
return $this;
}
/**
* 批量添加中间件
*/
public function middlewares(array $middlewares): self
{
$this->middlewares = array_merge($this->middlewares, $middlewares);
return $this;
}
/**
* 获取路由名称
*/
public function getName(): string
{
return $this->name;
}
/**
* 设置路由名称
*/
public function name(string $name): self
{
$this->name = $name;
return $this;
}
/**
* 获取参数
*/
public function getParameters(): array
{
return $this->parameters;
}
/**
* 设置参数
*/
public function setParameters(array $parameters): void
{
$this->parameters = $parameters;
}
/**
* 设置参数约束
*/
public function where(string $param, string $pattern): self
{
$this->where[$param] = $pattern;
return $this;
}
/**
* 批量设置参数约束
*/
public function wheres(array $wheres): self
{
$this->where = array_merge($this->where, $wheres);
return $this;
}
/**
* 获取参数约束
*/
public function getWhere(): array
{
return $this->where;
}
/**
* 编译路由为正则表达式
*/
public function compile(): string
{
$pattern = $this->path;
// 转换路径参数为正则表达式
$pattern = preg_replace_callback('/\{(\w+)(?:\?|:([^}]+))?\}/', function ($matches) {
$param = $matches[1];
$constraint = $matches[2] ?? '[^/]+';
// 如果有问号,表示可选参数
$optional = strpos($matches[0], '?') !== false;
if ($optional) {
return '(?:/([^/]+))?';
} else {
return '/([^/]+)';
}
}, $pattern);
// 处理通配符
$pattern = str_replace('*', '.*', $pattern);
return '#^' . $pattern . '$#';
}
/**
* 匹配请求路径
*/
public function match(string $method, string $path): bool
{
if ($this->method !== '*' && $this->method !== strtoupper($method)) {
return false;
}
$pattern = $this->compile();
if (!preg_match($pattern, $path, $matches)) {
return false;
}
// 提取参数
$this->extractParameters($path, $matches);
return true;
}
/**
* 提取路径参数
*/
private function extractParameters(string $path, array $matches): void
{
$this->parameters = [];
// 解析路径中的参数名
preg_match_all('/\{(\w+)(?:\?|:[^}]+)?\}/', $this->path, $paramMatches);
$paramNames = $paramMatches[1];
// 提取参数值
for ($i = 1; $i < count($matches); $i++) {
if (isset($paramNames[$i - 1]) && $matches[$i] !== null) {
$paramName = $paramNames[$i - 1];
$paramValue = $matches[$i];
// 验证参数约束
if (isset($this->where[$paramName])) {
$pattern = '/^' . $this->where[$paramName] . '$/';
if (!preg_match($pattern, $paramValue)) {
return; // 参数不匹配
}
}
$this->parameters[$paramName] = $paramValue;
}
}
}
/**
* 生成默认路由名称
*/
private function generateDefaultName(): string
{
$handler = $this->handler;
if (is_string($handler)) {
return $handler;
}
if (is_array($handler)) {
$class = is_object($handler[0]) ? get_class($handler[0]) : $handler[0];
$method = $handler[1] ?? '__invoke';
return $class . '@' . $method;
}
if (is_object($handler)) {
return get_class($handler) . '@__invoke';
}
return 'route_' . uniqid();
}
/**
* 生成URL
*/
public function url(array $parameters = []): string
{
$url = $this->path;
// 替换路径参数
foreach ($parameters as $key => $value) {
$url = str_replace('{' . $key . '}', (string) $value, $url);
$url = str_replace('{' . $key . '?}', (string) $value, $url);
}
// 移除未替换的可选参数
$url = preg_replace('/\{\w+\?\}/', '', $url);
// 移除未替换的必需参数(应该抛出异常)
if (preg_match('/\{\w+\}/', $url)) {
throw new \InvalidArgumentException('Missing required parameters for route: ' . $this->name);
}
return $url;
}
/**
* 转换为数组
*/
public function toArray(): array
{
return [
'method' => $this->method,
'path' => $this->path,
'handler' => $this->handler,
'name' => $this->name,
'middlewares' => $this->middlewares,
'where' => $this->where,
];
}
/**
* 路由信息调试输出
*/
public function __toString(): string
{
return sprintf(
'%s %s -> %s [%s]',
$this->method,
$this->path,
$this->name,
implode(', ', $this->middlewares)
);
}
}

View File

@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Router;
/**
* 路由集合类
* 管理所有路由
*/
class RouteCollection
{
private array $routes = [];
private array $namedRoutes = [];
private array $groupStack = [];
/**
* 添加路由
*/
public function add(Route $route): Route
{
$this->routes[] = $route;
// 添加到命名路由索引
if ($route->getName()) {
$this->namedRoutes[$route->getName()] = $route;
}
return $route;
}
/**
* 添加GET路由
*/
public function get(string $path, $handler, string $name = ''): Route
{
$route = new Route('GET', $path, $handler, $name);
return $this->add($this->applyGroupAttributes($route));
}
/**
* 添加POST路由
*/
public function post(string $path, $handler, string $name = ''): Route
{
$route = new Route('POST', $path, $handler, $name);
return $this->add($this->applyGroupAttributes($route));
}
/**
* 添加PUT路由
*/
public function put(string $path, $handler, string $name = ''): Route
{
$route = new Route('PUT', $path, $handler, $name);
return $this->add($this->applyGroupAttributes($route));
}
/**
* 添加DELETE路由
*/
public function delete(string $path, $handler, string $name = ''): Route
{
$route = new Route('DELETE', $path, $handler, $name);
return $this->add($this->applyGroupAttributes($route));
}
/**
* 添加PATCH路由
*/
public function patch(string $path, $handler, string $name = ''): Route
{
$route = new Route('PATCH', $path, $handler, $name);
return $this->add($this->applyGroupAttributes($route));
}
/**
* 添加OPTIONS路由
*/
public function options(string $path, $handler, string $name = ''): Route
{
$route = new Route('OPTIONS', $path, $handler, $name);
return $this->add($this->applyGroupAttributes($route));
}
/**
* 添加任意方法路由
*/
public function any(string $path, $handler, string $name = ''): Route
{
$route = new Route('*', $path, $handler, $name);
return $this->add($this->applyGroupAttributes($route));
}
/**
* 添加匹配多种方法的路由
*/
public function match(array $methods, string $path, $handler, string $name = ''): Route
{
$methods = array_map('strtoupper', $methods);
$method = implode('|', $methods);
$route = new Route($method, $path, $handler, $name);
return $this->add($this->applyGroupAttributes($route));
}
/**
* 路由分组
*/
public function group(array $attributes, callable $callback): void
{
$this->updateGroupStack($attributes);
$callback($this);
array_pop($this->groupStack);
}
/**
* 更新分组栈
*/
protected function updateGroupStack(array $attributes): void
{
if (!empty($this->groupStack)) {
$attributes = $this->mergeGroupAttributes($attributes, end($this->groupStack));
}
$this->groupStack[] = $attributes;
}
/**
* 合并分组属性
*/
protected function mergeGroupAttributes(array $new, array $old): array
{
$attributes = [
'namespace' => $new['namespace'] ?? $old['namespace'] ?? null,
'prefix' => $new['prefix'] ?? $old['prefix'] ?? null,
'middleware' => $new['middleware'] ?? $old['middleware'] ?? [],
];
// 合并中间件
if (isset($old['middleware']) && isset($new['middleware'])) {
$attributes['middleware'] = array_merge(
(array) $old['middleware'],
(array) $new['middleware']
);
}
return array_filter($attributes);
}
/**
* 应用分组属性到路由
*/
protected function applyGroupAttributes(Route $route): Route
{
if (empty($this->groupStack)) {
return $route;
}
$attributes = end($this->groupStack);
// 应用前缀
if (isset($attributes['prefix'])) {
$path = rtrim($attributes['prefix'], '/') . '/' . ltrim($route->getPath(), '/');
$route = new Route(
$route->getMethod(),
$path,
$route->getHandler(),
$route->getName()
);
}
// 应用中间件
if (isset($attributes['middleware'])) {
$route->middlewares((array) $attributes['middleware']);
}
// 应用命名空间
if (isset($attributes['namespace'])) {
$handler = $route->getHandler();
if (is_string($handler) && strpos($handler, '\\') === false) {
$handler = $attributes['namespace'] . '\\' . $handler;
$route = new Route(
$route->getMethod(),
$route->getPath(),
$handler,
$route->getName()
);
}
}
return $route;
}
/**
* 根据名称获取路由
*/
public function getByName(string $name): ?Route
{
return $this->namedRoutes[$name] ?? null;
}
/**
* 匹配路由
*/
public function match(string $method, string $path): ?Route
{
foreach ($this->routes as $route) {
if ($route->match($method, $path)) {
return $route;
}
}
return null;
}
/**
* 获取所有路由
*/
public function getRoutes(): array
{
return $this->routes;
}
/**
* 获取命名路由
*/
public function getNamedRoutes(): array
{
return $this->namedRoutes;
}
/**
* 获取指定方法的路由
*/
public function getRoutesByMethod(string $method): array
{
return array_filter($this->routes, function ($route) use ($method) {
return $route->getMethod() === '*' ||
$route->getMethod() === strtoupper($method) ||
strpos($route->getMethod(), strtoupper($method)) !== false;
});
}
/**
* 检查路由是否存在
*/
public function has(string $name): bool
{
return isset($this->namedRoutes[$name]);
}
/**
* 生成URL
*/
public function url(string $name, array $parameters = []): string
{
$route = $this->getByName($name);
if (!$route) {
throw new \InvalidArgumentException("Route '{$name}' not found.");
}
return $route->url($parameters);
}
/**
* 清空所有路由
*/
public function clear(): void
{
$this->routes = [];
$this->namedRoutes = [];
$this->groupStack = [];
}
/**
* 获取路由数量
*/
public function count(): int
{
return count($this->routes);
}
/**
* 转换为数组
*/
public function toArray(): array
{
return array_map(function ($route) {
return $route->toArray();
}, $this->routes);
}
/**
* 获取路由统计信息
*/
public function getStatistics(): array
{
$stats = [
'total' => count($this->routes),
'named' => count($this->namedRoutes),
'by_method' => [],
'with_middleware' => 0,
];
foreach ($this->routes as $route) {
$method = $route->getMethod();
if (strpos($method, '|') !== false) {
$methods = explode('|', $method);
foreach ($methods as $m) {
$stats['by_method'][$m] = ($stats['by_method'][$m] ?? 0) + 1;
}
} else {
$stats['by_method'][$method] = ($stats['by_method'][$method] ?? 0) + 1;
}
if (!empty($route->getMiddlewares())) {
$stats['with_middleware']++;
}
}
ksort($stats['by_method']);
return $stats;
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Scanner;
use ReflectionClass;
use ReflectionMethod;
use ReflectionException;
use Fendx\Core\Annotation\Controller;
use Fendx\Web\Annotation\GetRoute;
use Fendx\Web\Annotation\PostRoute;
use Fendx\Web\Route\Router;
final class RouteScanner
{
private Router $router;
private array $controllers = [];
public function __construct(Router $router)
{
$this->router = $router;
}
public function scan(string $scanPath): void
{
$this->scanControllers($scanPath);
$this->registerRoutes();
}
private function scanControllers(string $path): void
{
if (!is_dir($path)) {
return;
}
$files = glob($path . '/**/*.php');
foreach ($files as $file) {
$this->scanControllerFile($file);
}
}
private function scanControllerFile(string $file): void
{
$className = $this->getClassNameFromFile($file);
if ($className === null || class_exists($className) === false) {
return;
}
try {
$reflection = new ReflectionClass($className);
// 检查是否有Controller注解
$controllerAttributes = $reflection->getAttributes(Controller::class);
if (empty($controllerAttributes)) {
return;
}
$controllerAttribute = $controllerAttributes[0]->newInstance();
$prefix = $controllerAttribute->prefix;
// 扫描方法上的路由注解
$this->scanControllerMethods($className, $reflection, $prefix);
} catch (ReflectionException $e) {
error_log("Failed to scan controller $className: " . $e->getMessage());
}
}
private function scanControllerMethods(string $className, ReflectionClass $reflection, string $prefix): void
{
$methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
if ($method->getName() === '__construct') {
continue;
}
$this->scanMethodRoutes($className, $method, $prefix);
}
}
private function scanMethodRoutes(string $className, ReflectionMethod $method, string $prefix): void
{
// 扫描GetRoute注解
$getRouteAttributes = $method->getAttributes(GetRoute::class);
foreach ($getRouteAttributes as $attribute) {
$routeAttribute = $attribute->newInstance();
$path = $prefix . $routeAttribute->path;
$this->controllers[] = [
'method' => 'GET',
'path' => $path,
'controller' => $className,
'action' => $method->getName()
];
}
// 扫描PostRoute注解
$postRouteAttributes = $method->getAttributes(PostRoute::class);
foreach ($postRouteAttributes as $attribute) {
$routeAttribute = $attribute->newInstance();
$path = $prefix . $routeAttribute->path;
$this->controllers[] = [
'method' => 'POST',
'path' => $path,
'controller' => $className,
'action' => $method->getName()
];
}
}
private function registerRoutes(): void
{
foreach ($this->controllers as $route) {
$handler = [$route['controller'], $route['action']];
switch ($route['method']) {
case 'GET':
$this->router->get($route['path'], $handler);
break;
case 'POST':
$this->router->post($route['path'], $handler);
break;
case 'PUT':
$this->router->put($route['path'], $handler);
break;
case 'DELETE':
$this->router->delete($route['path'], $handler);
break;
}
}
}
private function getClassNameFromFile(string $file): ?string
{
$content = file_get_contents($file);
if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) {
$namespace = trim($matches[1]);
$className = basename($file, '.php');
return $namespace . '\\' . $className;
}
return null;
}
public function getRoutes(): array
{
return $this->controllers;
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Fendx\Web\Validator;
use Fendx\Common\Exception\BusinessException;
final class Validator
{
private array $rules = [];
private array $messages = [];
private array $data = [];
private array $errors = [];
public static function make(array $data, array $rules, array $messages = []): self
{
$validator = new self();
$validator->data = $data;
$validator->rules = $rules;
$validator->messages = $messages;
return $validator;
}
public function validate(): bool
{
$this->errors = [];
foreach ($this->rules as $field => $fieldRules) {
$value = $this->data[$field] ?? null;
$rules = is_string($fieldRules) ? explode('|', $fieldRules) : $fieldRules;
foreach ($rules as $rule) {
if (!$this->validateRule($field, $value, $rule)) {
break; // 如果某个规则失败,停止验证该字段的后续规则
}
}
}
return empty($this->errors);
}
private function validateRule(string $field, mixed $value, string $rule): bool
{
// 解析规则参数,如 "max:255"
if (str_contains($rule, ':')) {
[$ruleName, $parameter] = explode(':', $rule, 2);
} else {
$ruleName = $rule;
$parameter = null;
}
return match ($ruleName) {
'required' => $this->validateRequired($field, $value),
'email' => $this->validateEmail($field, $value),
'mobile' => $this->validateMobile($field, $value),
'id_card' => $this->validateIdCard($field, $value),
'min' => $this->validateMin($field, $value, (int)$parameter),
'max' => $this->validateMax($field, $value, (int)$parameter),
'length' => $this->validateLength($field, $value, (int)$parameter),
'in' => $this->validateIn($field, $value, explode(',', $parameter)),
'regex' => $this->validateRegex($field, $value, $parameter),
default => true,
};
}
private function validateRequired(string $field, mixed $value): bool
{
if ($value === null || $value === '' || $value === []) {
$this->addError($field, 'required', 'The :field field is required.');
return false;
}
return true;
}
private function validateEmail(string $field, mixed $value): bool
{
if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->addError($field, 'email', 'The :field must be a valid email address.');
return false;
}
return true;
}
private function validateMobile(string $field, mixed $value): bool
{
if ($value !== null && $value !== '' && !preg_match('/^1[3-9]\d{9}$/', (string)$value)) {
$this->addError($field, 'mobile', 'The :field must be a valid mobile number.');
return false;
}
return true;
}
private function validateIdCard(string $field, mixed $value): bool
{
if ($value !== null && $value !== '') {
$pattern = '/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/';
if (!preg_match($pattern, (string)$value)) {
$this->addError($field, 'id_card', 'The :field must be a valid ID card number.');
return false;
}
}
return true;
}
private function validateMin(string $field, mixed $value, int $min): bool
{
if (is_string($value) && strlen($value) < $min) {
$this->addError($field, 'min', "The :field must be at least :min characters.", ['min' => $min]);
return false;
}
if (is_numeric($value) && $value < $min) {
$this->addError($field, 'min', "The :field must be at least :min.", ['min' => $min]);
return false;
}
return true;
}
private function validateMax(string $field, mixed $value, int $max): bool
{
if (is_string($value) && strlen($value) > $max) {
$this->addError($field, 'max', "The :field may not be greater than :max characters.", ['max' => $max]);
return false;
}
if (is_numeric($value) && $value > $max) {
$this->addError($field, 'max', "The :field may not be greater than :max.", ['max' => $max]);
return false;
}
return true;
}
private function validateLength(string $field, mixed $value, int $length): bool
{
if (is_string($value) && strlen($value) !== $length) {
$this->addError($field, 'length', "The :field must be :length characters.", ['length' => $length]);
return false;
}
return true;
}
private function validateIn(string $field, mixed $value, array $values): bool
{
if ($value !== null && !in_array($value, $values)) {
$this->addError($field, 'in', "The selected :field is invalid.");
return false;
}
return true;
}
private function validateRegex(string $field, mixed $value, string $pattern): bool
{
if ($value !== null && $value !== '' && !preg_match($pattern, (string)$value)) {
$this->addError($field, 'regex', "The :field format is invalid.");
return false;
}
return true;
}
private function addError(string $field, string $rule, string $message, array $parameters = []): void
{
$key = "{$field}.{$rule}";
if (isset($this->messages[$key])) {
$message = $this->messages[$key];
}
// 替换消息中的占位符
$message = str_replace(':field', $field, $message);
foreach ($parameters as $key => $value) {
$message = str_replace(":{$key}", (string)$value, $message);
}
$this->errors[$field][] = $message;
}
public function errors(): array
{
return $this->errors;
}
public function firstError(string $field): ?string
{
return $this->errors[$field][0] ?? null;
}
public function fails(): bool
{
return !empty($this->errors);
}
public function validateOrThrow(): void
{
if (!$this->validate()) {
throw new BusinessException(422, 'Validation failed', $this->errors());
}
}
}