- Pint 스타일 이슈 25개 수정 (783 파일 통과) - 마이그레이션 4개 실행 (payrolls, payroll_settings, push 테이블) - routes/api.php import 정렬
205 lines
6.0 KiB
PHP
205 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use App\Models\ApiRequestLog;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* API 요청/응답 로깅 미들웨어
|
|
*
|
|
* DB와 로그 파일에 이중으로 저장
|
|
*/
|
|
class LogApiRequest
|
|
{
|
|
/**
|
|
* 로깅에서 제외할 경로 패턴
|
|
*/
|
|
protected array $exceptPaths = [
|
|
'api/health',
|
|
'api-docs*',
|
|
'docs*',
|
|
];
|
|
|
|
/**
|
|
* 민감한 헤더 (마스킹 처리)
|
|
*/
|
|
protected array $sensitiveHeaders = [
|
|
'authorization',
|
|
'x-api-key',
|
|
'cookie',
|
|
];
|
|
|
|
/**
|
|
* 민감한 필드 (마스킹 처리)
|
|
*/
|
|
protected array $sensitiveFields = [
|
|
'password',
|
|
'password_confirmation',
|
|
'current_password',
|
|
'new_password',
|
|
'token',
|
|
'secret',
|
|
'api_key',
|
|
];
|
|
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
// 제외 경로 체크
|
|
if ($this->shouldSkip($request)) {
|
|
return $next($request);
|
|
}
|
|
|
|
$startTime = microtime(true);
|
|
|
|
$response = $next($request);
|
|
|
|
$this->logRequest($request, $response, $startTime);
|
|
|
|
return $response;
|
|
}
|
|
|
|
protected function shouldSkip(Request $request): bool
|
|
{
|
|
foreach ($this->exceptPaths as $pattern) {
|
|
if ($request->is($pattern)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function logRequest(Request $request, Response $response, float $startTime): void
|
|
{
|
|
try {
|
|
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
|
|
|
// 사용자/테넌트 정보 가져오기 (ApiKeyMiddleware에서 설정된 값 우선)
|
|
// api_user는 이미 user_id 값, tenant_id도 마찬가지
|
|
$userId = app()->bound('api_user') ? app('api_user') : $request->user()?->id;
|
|
$tenantId = app()->bound('tenant_id') ? app('tenant_id') : $request->user()?->current_tenant_id;
|
|
|
|
// 그룹 ID 생성 (동일 tenant+user의 연속 요청 묶기)
|
|
$groupId = $this->getOrCreateGroupId($tenantId, $userId);
|
|
|
|
$logData = [
|
|
'method' => $request->method(),
|
|
'url' => $request->fullUrl(),
|
|
'route_name' => $request->route()?->getName(),
|
|
'request_headers' => $this->maskSensitiveHeaders($request->headers->all()),
|
|
'request_body' => $this->maskSensitiveFields($request->all()),
|
|
'request_query' => $request->query(),
|
|
'response_status' => $response->getStatusCode(),
|
|
'response_body' => $this->truncateResponse($response->getContent()),
|
|
'duration_ms' => $durationMs,
|
|
'ip_address' => $request->ip(),
|
|
'user_agent' => $request->userAgent(),
|
|
'user_id' => $userId,
|
|
'tenant_id' => $tenantId,
|
|
'group_id' => $groupId,
|
|
];
|
|
|
|
// DB 저장
|
|
ApiRequestLog::create($logData);
|
|
|
|
// 로그 파일 저장
|
|
$this->writeToLogFile($logData);
|
|
|
|
} catch (\Throwable $e) {
|
|
// 로깅 실패가 비즈니스 로직에 영향을 주지 않도록 조용히 처리
|
|
Log::error('API 로깅 실패', [
|
|
'error' => $e->getMessage(),
|
|
'url' => $request->fullUrl(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
protected function maskSensitiveHeaders(array $headers): array
|
|
{
|
|
foreach ($this->sensitiveHeaders as $header) {
|
|
if (isset($headers[$header])) {
|
|
$headers[$header] = ['***MASKED***'];
|
|
}
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
|
|
protected function maskSensitiveFields(array $data): array
|
|
{
|
|
foreach ($data as $key => $value) {
|
|
if (in_array(strtolower($key), $this->sensitiveFields)) {
|
|
$data[$key] = '***MASKED***';
|
|
} elseif (is_array($value)) {
|
|
$data[$key] = $this->maskSensitiveFields($value);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
protected function truncateResponse(?string $content): ?string
|
|
{
|
|
if ($content === null) {
|
|
return null;
|
|
}
|
|
|
|
// 10KB 이상이면 잘라내기
|
|
$maxLength = 10 * 1024;
|
|
if (strlen($content) > $maxLength) {
|
|
return substr($content, 0, $maxLength).'...[TRUNCATED]';
|
|
}
|
|
|
|
return $content;
|
|
}
|
|
|
|
protected function writeToLogFile(array $logData): void
|
|
{
|
|
$logMessage = sprintf(
|
|
'[%s] %s %s | Status: %d | Duration: %dms | IP: %s | User: %s',
|
|
now()->format('Y-m-d H:i:s'),
|
|
$logData['method'],
|
|
$logData['url'],
|
|
$logData['response_status'],
|
|
$logData['duration_ms'],
|
|
$logData['ip_address'] ?? 'N/A',
|
|
$logData['user_id'] ?? 'guest'
|
|
);
|
|
|
|
Log::channel('api')->info($logMessage, [
|
|
'request_body' => $logData['request_body'],
|
|
'response_status' => $logData['response_status'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 그룹 ID 생성 또는 기존 그룹 ID 반환
|
|
* 동일 tenant+user의 5초 내 요청은 같은 그룹으로 묶음
|
|
*/
|
|
protected function getOrCreateGroupId(?int $tenantId, ?int $userId): string
|
|
{
|
|
$cacheKey = "api_log_group:{$tenantId}:{$userId}";
|
|
$groupTtl = 5; // 5초 내 요청은 같은 그룹
|
|
|
|
// 캐시에서 기존 그룹 ID 확인
|
|
$existingGroupId = cache()->get($cacheKey);
|
|
|
|
if ($existingGroupId) {
|
|
// TTL 갱신 (연속 요청 시 그룹 유지)
|
|
cache()->put($cacheKey, $existingGroupId, $groupTtl);
|
|
|
|
return $existingGroupId;
|
|
}
|
|
|
|
// 새 그룹 ID 생성
|
|
$newGroupId = now()->format('Ymd_His_').substr(md5(uniqid((string) mt_rand(), true)), 0, 8);
|
|
cache()->put($cacheKey, $newGroupId, $groupTtl);
|
|
|
|
return $newGroupId;
|
|
}
|
|
}
|