2026-03-17 11:40:07 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Middleware;
|
|
|
|
|
|
|
|
|
|
|
|
use App\Model\ApiRequestLog;
|
|
|
|
|
|
use App\Utils\Log;
|
2026-03-18 08:49:10 +08:00
|
|
|
|
use App\Utils\RequestHelper;
|
2026-03-17 11:40:07 +08:00
|
|
|
|
use Psr\Http\Message\ResponseInterface;
|
|
|
|
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
|
|
use Psr\Http\Server\MiddlewareInterface;
|
|
|
|
|
|
use Psr\Http\Server\RequestHandlerInterface;
|
|
|
|
|
|
use Qbhy\HyperfAuth\AuthManager;
|
|
|
|
|
|
use Swoole\Coroutine;
|
|
|
|
|
|
|
|
|
|
|
|
class RequestLogMiddleware implements MiddlewareInterface
|
|
|
|
|
|
{
|
|
|
|
|
|
public function __construct(
|
|
|
|
|
|
protected AuthManager $auth
|
|
|
|
|
|
) {
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
|
|
|
|
|
{
|
|
|
|
|
|
$start = microtime(true);
|
|
|
|
|
|
|
|
|
|
|
|
$response = $handler->handle($request);
|
|
|
|
|
|
|
|
|
|
|
|
$duration_ms = (int) ((microtime(true) - $start) * 1000);
|
|
|
|
|
|
|
|
|
|
|
|
// 在父协程中提取 user_id(子协程不继承 Swoole Context)
|
|
|
|
|
|
$user_id = null;
|
|
|
|
|
|
try {
|
2026-04-16 13:16:43 +08:00
|
|
|
|
$ctx_request = \Hyperf\Context\Context::get(\Psr\Http\Message\ServerRequestInterface::class);
|
|
|
|
|
|
$auth_user = $ctx_request?->getAttribute('auth_user');
|
|
|
|
|
|
$user_id = $auth_user?->getId() ?? $this->auth->guard('jwt')->user()?->getId();
|
2026-03-17 11:40:07 +08:00
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
|
// 未认证请求,user_id 保持 null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$method = $request->getMethod();
|
|
|
|
|
|
$path = $request->getUri()->getPath();
|
|
|
|
|
|
$status_code = $response->getStatusCode();
|
2026-03-18 08:49:10 +08:00
|
|
|
|
$ip = RequestHelper::getClientIp($request);
|
2026-03-17 11:40:07 +08:00
|
|
|
|
$user_agent = $request->getHeaderLine('User-Agent') ?: null;
|
|
|
|
|
|
|
|
|
|
|
|
// GET/DELETE 请求不记录请求体
|
|
|
|
|
|
$request_body = null;
|
|
|
|
|
|
if (!in_array($method, ['GET', 'DELETE', 'HEAD', 'OPTIONS'], true)) {
|
|
|
|
|
|
$parsed_body = $request->getParsedBody();
|
|
|
|
|
|
if (is_array($parsed_body) && !empty($parsed_body)) {
|
|
|
|
|
|
$request_body = self::sanitizeBody($parsed_body);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$response_code = self::extractResponseCode($response);
|
|
|
|
|
|
|
2026-03-17 12:45:22 +08:00
|
|
|
|
// defer 写库:在当前协程退出时执行,响应已返回客户端,同时保证协程内 DB 连接可用
|
|
|
|
|
|
Coroutine::defer(static function () use (
|
2026-03-17 11:40:07 +08:00
|
|
|
|
$user_id, $method, $path, $status_code, $ip,
|
|
|
|
|
|
$user_agent, $request_body, $response_code, $duration_ms
|
|
|
|
|
|
): void {
|
|
|
|
|
|
try {
|
|
|
|
|
|
ApiRequestLog::query()->create([
|
|
|
|
|
|
'user_id' => $user_id,
|
|
|
|
|
|
'method' => $method,
|
|
|
|
|
|
'path' => $path,
|
|
|
|
|
|
'status_code' => $status_code,
|
|
|
|
|
|
'ip' => $ip,
|
|
|
|
|
|
'user_agent' => $user_agent,
|
|
|
|
|
|
'request_body' => $request_body,
|
|
|
|
|
|
'response_code' => $response_code,
|
|
|
|
|
|
'duration_ms' => $duration_ms,
|
|
|
|
|
|
]);
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
Log::get()->error('RequestLogMiddleware: 写入请求日志失败', [
|
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
|
'path' => $path,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return $response;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 递归替换敏感字段为 ***
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function sanitizeBody(array $body): array
|
|
|
|
|
|
{
|
2026-03-17 14:17:06 +08:00
|
|
|
|
$sensitive_keys = [
|
|
|
|
|
|
'password', 'old_password', 'new_password', 'password_confirmation',
|
|
|
|
|
|
'token', 'secret', 'api_key', 'access_token', 'refresh_token',
|
|
|
|
|
|
];
|
2026-03-17 11:40:07 +08:00
|
|
|
|
|
|
|
|
|
|
foreach ($body as $key => $value) {
|
|
|
|
|
|
if (in_array($key, $sensitive_keys, true)) {
|
|
|
|
|
|
$body[$key] = '***';
|
|
|
|
|
|
} elseif (is_array($value)) {
|
|
|
|
|
|
$body[$key] = self::sanitizeBody($value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $body;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从 JSON 响应体中提取 code 字段
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function extractResponseCode(ResponseInterface $response): ?int
|
|
|
|
|
|
{
|
|
|
|
|
|
$content_type = $response->getHeaderLine('Content-Type');
|
|
|
|
|
|
if (stripos($content_type, 'application/json') === false) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-17 12:45:22 +08:00
|
|
|
|
$stream = $response->getBody();
|
|
|
|
|
|
$body = (string) $stream;
|
|
|
|
|
|
// 回退流指针,避免后续读取得到空内容
|
|
|
|
|
|
if ($stream->isSeekable()) {
|
|
|
|
|
|
$stream->rewind();
|
|
|
|
|
|
}
|
2026-03-17 11:40:07 +08:00
|
|
|
|
$data = json_decode($body, true);
|
|
|
|
|
|
if (is_array($data) && isset($data['code'])) {
|
|
|
|
|
|
return (int) $data['code'];
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
|
// 解析失败返回 null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|