233 lines
6.8 KiB
PHP
233 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\middleware;
|
|
|
|
use support\Log;
|
|
use Throwable;
|
|
use Webman\Http\Request;
|
|
use Webman\Http\Response;
|
|
use Webman\MiddlewareInterface;
|
|
|
|
/**
|
|
* 接口调用日志中间件
|
|
*
|
|
* 目标:
|
|
* - 统一记录接口调用(请求 + 响应)
|
|
* - 成功记录返回参数
|
|
* - 失败记录失败原因
|
|
*/
|
|
class InterfaceRequestLog implements MiddlewareInterface
|
|
{
|
|
/**
|
|
* 仅记录接口路由(避免静态资源噪音)
|
|
*/
|
|
private const API_PATH_PREFIXES = ['api/', 'admin/'];
|
|
|
|
/**
|
|
* 需要脱敏的字段名(不区分大小写)
|
|
*/
|
|
private const SENSITIVE_KEYS = [
|
|
'password',
|
|
'oldpassword',
|
|
'newpassword',
|
|
'token',
|
|
'user-token',
|
|
'user_token',
|
|
'auth-token',
|
|
'auth_token',
|
|
'refresh_token',
|
|
'secret',
|
|
'signature',
|
|
'sign',
|
|
];
|
|
|
|
public function process(Request $request, callable $handler): Response
|
|
{
|
|
$path = trim($request->path(), '/');
|
|
if (!$this->shouldLogPath($path)) {
|
|
return $handler($request);
|
|
}
|
|
|
|
$start = microtime(true);
|
|
$requestPayload = $this->buildRequestPayload($request);
|
|
|
|
try {
|
|
$response = $handler($request);
|
|
} catch (Throwable $e) {
|
|
$costMs = $this->costMs($start);
|
|
Log::error('[InterfaceRequestLog] ' . $this->encodeJson([
|
|
'path' => '/' . $path,
|
|
'method' => strtoupper($request->method()),
|
|
'ip' => $request->getRealIp(),
|
|
'cost_ms' => $costMs,
|
|
'request' => $requestPayload,
|
|
'success' => false,
|
|
'fail_reason' => $e->getMessage(),
|
|
'exception_class' => get_class($e),
|
|
'exception_trace' => $this->truncateText($e->getTraceAsString(), 3000),
|
|
]));
|
|
throw $e;
|
|
}
|
|
|
|
$costMs = $this->costMs($start);
|
|
$statusCode = method_exists($response, 'getStatusCode') ? intval($response->getStatusCode()) : 200;
|
|
$responseBodyRaw = $this->extractResponseBody($response);
|
|
$responseDecoded = $this->decodeJsonArray($responseBodyRaw);
|
|
|
|
$success = false;
|
|
$failReason = '';
|
|
$responseData = null;
|
|
|
|
if (is_array($responseDecoded)) {
|
|
if (array_key_exists('code', $responseDecoded)) {
|
|
$success = intval($responseDecoded['code']) === 1;
|
|
} else {
|
|
$success = $statusCode >= 200 && $statusCode < 400;
|
|
}
|
|
$responseData = $responseDecoded['data'] ?? $responseDecoded;
|
|
if (!$success) {
|
|
$failReasonRaw = $responseDecoded['message'] ?? ($responseDecoded['msg'] ?? '');
|
|
$failReason = is_string($failReasonRaw) ? $failReasonRaw : strval($failReasonRaw);
|
|
}
|
|
} else {
|
|
$success = $statusCode >= 200 && $statusCode < 400;
|
|
if ($success) {
|
|
$responseData = $this->truncateText($responseBodyRaw, 2000);
|
|
} else {
|
|
$failReason = $this->truncateText($responseBodyRaw, 2000);
|
|
}
|
|
}
|
|
|
|
$logPayload = [
|
|
'path' => '/' . $path,
|
|
'method' => strtoupper($request->method()),
|
|
'ip' => $request->getRealIp(),
|
|
'status_code' => $statusCode,
|
|
'cost_ms' => $costMs,
|
|
'request' => $requestPayload,
|
|
'success' => $success,
|
|
];
|
|
|
|
if ($success) {
|
|
$logPayload['response_data'] = $this->maskMixed($responseData);
|
|
Log::info('[InterfaceRequestLog] ' . $this->encodeJson($logPayload));
|
|
} else {
|
|
$logPayload['fail_reason'] = $failReason !== '' ? $failReason : 'Unknown error';
|
|
if (is_array($responseDecoded)) {
|
|
$logPayload['api_response'] = $this->maskMixed($responseDecoded);
|
|
}
|
|
Log::error('[InterfaceRequestLog] ' . $this->encodeJson($logPayload));
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
private function shouldLogPath(string $path): bool
|
|
{
|
|
foreach (self::API_PATH_PREFIXES as $prefix) {
|
|
if (str_starts_with($path, $prefix)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function buildRequestPayload(Request $request): array
|
|
{
|
|
$query = $request->get();
|
|
$post = $request->post();
|
|
if (!is_array($query)) {
|
|
$query = [];
|
|
}
|
|
if (!is_array($post)) {
|
|
$post = [];
|
|
}
|
|
|
|
$rawBody = method_exists($request, 'rawBody') ? strval($request->rawBody()) : '';
|
|
$jsonBody = [];
|
|
if ($rawBody !== '') {
|
|
$jsonDecoded = json_decode($rawBody, true);
|
|
if (is_array($jsonDecoded)) {
|
|
$jsonBody = $jsonDecoded;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'query' => $this->maskMixed($query),
|
|
'post' => $this->maskMixed($post),
|
|
'json' => $this->maskMixed($jsonBody),
|
|
];
|
|
}
|
|
|
|
private function extractResponseBody(Response $response): string
|
|
{
|
|
if (method_exists($response, 'rawBody')) {
|
|
return strval($response->rawBody());
|
|
}
|
|
return '';
|
|
}
|
|
|
|
private function decodeJsonArray(string $content): ?array
|
|
{
|
|
if ($content === '') {
|
|
return null;
|
|
}
|
|
$decoded = json_decode($content, true);
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
private function maskMixed($value, ?string $parentKey = null)
|
|
{
|
|
if (is_array($value)) {
|
|
$result = [];
|
|
foreach ($value as $k => $v) {
|
|
$keyName = is_string($k) ? $k : $parentKey;
|
|
$result[$k] = $this->maskMixed($v, $keyName);
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
if ($parentKey !== null && $this->isSensitiveKey($parentKey)) {
|
|
return '***';
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
private function isSensitiveKey(string $key): bool
|
|
{
|
|
$normalized = strtolower(trim($key));
|
|
foreach (self::SENSITIVE_KEYS as $sensitive) {
|
|
if ($normalized === $sensitive) {
|
|
return true;
|
|
}
|
|
if (str_contains($normalized, $sensitive)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function truncateText(string $text, int $maxLen): string
|
|
{
|
|
if ($maxLen <= 0) {
|
|
return '';
|
|
}
|
|
if (mb_strlen($text) <= $maxLen) {
|
|
return $text;
|
|
}
|
|
return mb_substr($text, 0, $maxLen) . '...';
|
|
}
|
|
|
|
private function encodeJson(array $payload): string
|
|
{
|
|
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
private function costMs(float $start): int
|
|
{
|
|
return intval((microtime(true) - $start) * 1000);
|
|
}
|
|
}
|