diff --git a/.env.example b/.env.example index bf7b370..296c36e 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ MERCHANT_API_TOKEN=your-admin-api-token MERCHANT_API_TIMEOUT=60 OFFLINE_ORDER_PAGE_LIMIT=100 + +APP_LOG_ENABLED=true +APP_LOG_DIR=var/logs diff --git a/.gitignore b/.gitignore index 6549667..127dc02 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env vendor/ .idea/ +var/logs/ diff --git a/README.md b/README.md index 5eb5666..d3432fd 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,15 @@ MERCHANT_API_TOKEN=your-admin-api-token MERCHANT_API_BASE_URI=https://tpoint.agrimedia.cn MERCHANT_API_TIMEOUT=60 OFFLINE_ORDER_PAGE_LIMIT=100 +APP_LOG_ENABLED=true +APP_LOG_DIR=var/logs ``` `MERCHANT_API_TOKEN` is required. `MERCHANT_API_BASE_URI` and `MERCHANT_API_TIMEOUT` are optional. `OFFLINE_ORDER_PAGE_LIMIT` controls each upstream pagination request size. +`APP_LOG_ENABLED` controls request logging. `APP_LOG_DIR` controls the log directory. + +Logs are written as JSON lines to `var/logs/app-YYYY-MM-DD.log` by default. Authorization tokens are masked automatically. If the request contains `Authorization: Bearer `, that token is used for the upstream merchant API request first. If the header is missing, `MERCHANT_API_TOKEN` from `.env` is used. diff --git a/src/Api/ApiKernel.php b/src/Api/ApiKernel.php index f9beaed..5449703 100644 --- a/src/Api/ApiKernel.php +++ b/src/Api/ApiKernel.php @@ -8,6 +8,7 @@ use App\Controller\HealthController; use App\Controller\StatsController; use App\Http\HttpRequest; use App\Http\JsonResponse; +use App\Support\FileLogger; use App\Tools\AnalyzeOfflineOrdersTool; use App\Tools\MerchantRankingTool; use App\Tools\OrderSummaryTool; @@ -22,6 +23,13 @@ final readonly class ApiKernel public function handle(HttpRequest $request): void { + FileLogger::info('inbound_request', [ + 'method' => $request->method, + 'path' => $request->path, + 'headers' => $request->headers, + 'input' => $request->input, + ]); + try { match ($request->path) { '/', '/health' => $this->ok($this->healthController->show()), @@ -29,38 +37,68 @@ final readonly class ApiKernel '/api/order-summary' => $this->post($request, fn (): array => $this->statsController->orderSummary($request)), '/api/offline-stats' => $this->post($request, fn (): array => $this->statsController->offlineStats($request)), '/api/merchant-ranking' => $this->post($request, fn (): array => $this->statsController->merchantRanking($request)), - default => JsonResponse::send([ - 'success' => false, - 'error' => [ - 'code' => 'not_found', - 'message' => 'API route not found.', - ], - ], 404), + default => $this->notFound($request), }; } catch (\Throwable $exception) { - JsonResponse::send([ + $payload = [ 'success' => false, 'error' => [ 'code' => 'server_error', 'message' => $exception->getMessage(), ], - ], 500); + ]; + + FileLogger::error('inbound_response_error', [ + 'path' => $request->path, + 'statusCode' => 500, + 'response' => $payload, + 'exception' => $exception::class, + ]); + + JsonResponse::send($payload, 500); } } + private function notFound(HttpRequest $request): void + { + $payload = [ + 'success' => false, + 'error' => [ + 'code' => 'not_found', + 'message' => 'API route not found.', + ], + ]; + + FileLogger::info('inbound_response', [ + 'path' => $request->path, + 'statusCode' => 404, + 'response' => $payload, + ]); + + JsonResponse::send($payload, 404); + } + /** * @param \Closure(): array $handler */ private function post(HttpRequest $request, \Closure $handler): void { if ($request->method !== 'POST') { - JsonResponse::send([ + $payload = [ 'success' => false, 'error' => [ 'code' => 'method_not_allowed', 'message' => 'This endpoint only supports POST.', ], - ], 405); + ]; + + FileLogger::info('inbound_response', [ + 'path' => $request->path, + 'statusCode' => 405, + 'response' => $payload, + ]); + + JsonResponse::send($payload, 405); return; } @@ -73,10 +111,17 @@ final readonly class ApiKernel */ private function ok(array $data): void { - JsonResponse::send([ + $payload = [ 'success' => true, 'data' => $data, + ]; + + FileLogger::info('inbound_response', [ + 'statusCode' => 200, + 'response' => $payload, ]); + + JsonResponse::send($payload); } /** diff --git a/src/Http/MerchantApiClient.php b/src/Http/MerchantApiClient.php index 6a8f9d2..f179866 100644 --- a/src/Http/MerchantApiClient.php +++ b/src/Http/MerchantApiClient.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http; use App\Config\ApiConfig; +use App\Support\FileLogger; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; @@ -26,24 +27,54 @@ final class MerchantApiClient */ public function getOfflineOrders(array $query): array { + $path = '/adminapi/offline/list'; + $headers = [ + 'accept' => 'application/json', + 'authori-zation' => 'Bearer ' . $this->config->token, + ]; + + FileLogger::info('upstream_request', [ + 'method' => 'GET', + 'url' => $this->config->baseUri . $path, + 'headers' => $headers, + 'query' => $query, + ]); + try { - $response = $this->client->get('/adminapi/offline/list', [ - 'headers' => [ - 'accept' => 'application/json', - 'authori-zation' => 'Bearer ' . $this->config->token, - ], + $response = $this->client->get($path, [ + 'headers' => $headers, 'query' => $query, ]); } catch (GuzzleException $exception) { + FileLogger::error('upstream_request_failed', [ + 'method' => 'GET', + 'url' => $this->config->baseUri . $path, + 'headers' => $headers, + 'query' => $query, + 'exception' => $exception::class, + 'message' => $exception->getMessage(), + ]); + throw new \RuntimeException('Merchant API request failed: ' . $exception->getMessage(), 0, $exception); } - $data = json_decode((string) $response->getBody(), true); + $body = (string) $response->getBody(); + $data = json_decode($body, true); if (!is_array($data)) { + FileLogger::error('upstream_response_invalid_json', [ + 'statusCode' => $response->getStatusCode(), + 'body' => mb_substr($body, 0, 2000), + ]); + throw new \RuntimeException('Merchant API returned invalid JSON.'); } + FileLogger::info('upstream_response', [ + 'statusCode' => $response->getStatusCode(), + 'response' => $data, + ]); + return $data; } } diff --git a/src/Support/FileLogger.php b/src/Support/FileLogger.php new file mode 100644 index 0000000..a58658b --- /dev/null +++ b/src/Support/FileLogger.php @@ -0,0 +1,110 @@ + $context + */ + public static function info(string $message, array $context = []): void + { + self::write('info', $message, $context); + } + + /** + * @param array $context + */ + public static function error(string $message, array $context = []): void + { + self::write('error', $message, $context); + } + + /** + * @param array $context + */ + private static function write(string $level, string $message, array $context): void + { + if (!self::enabled()) { + return; + } + + $directory = self::directory(); + + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + $record = [ + 'time' => date('Y-m-d H:i:s'), + 'level' => $level, + 'message' => $message, + 'context' => self::sanitize($context), + ]; + + file_put_contents( + $directory . '/app-' . date('Y-m-d') . '.log', + json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, + FILE_APPEND | LOCK_EX, + ); + } + + /** + * @param mixed $value + * @return mixed + */ + private static function sanitize(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $sanitized = []; + + foreach ($value as $key => $item) { + $keyString = is_string($key) ? strtolower($key) : (string) $key; + + if (in_array($keyString, ['authorization', 'authori-zation', 'token', 'api_key', 'apikey'], true)) { + $sanitized[$key] = self::mask((string) $item); + continue; + } + + $sanitized[$key] = self::sanitize($item); + } + + return $sanitized; + } + + private static function mask(string $value): string + { + if ($value === '') { + return ''; + } + + if (strlen($value) <= 12) { + return '***'; + } + + return substr($value, 0, 8) . '***' . substr($value, -4); + } + + private static function enabled(): bool + { + $enabled = getenv('APP_LOG_ENABLED'); + + return $enabled === false || !in_array(strtolower((string) $enabled), ['0', 'false', 'off', 'no'], true); + } + + private static function directory(): string + { + $directory = getenv('APP_LOG_DIR'); + + if (is_string($directory) && trim($directory) !== '') { + return rtrim($directory, '/\\'); + } + + return dirname(__DIR__, 2) . '/var/logs'; + } +}