添加其他字段
This commit is contained in:
parent
1e11507ef4
commit
4e4fec48c4
|
|
@ -3,3 +3,6 @@ MERCHANT_API_TOKEN=your-admin-api-token
|
||||||
MERCHANT_API_TIMEOUT=60
|
MERCHANT_API_TIMEOUT=60
|
||||||
|
|
||||||
OFFLINE_ORDER_PAGE_LIMIT=100
|
OFFLINE_ORDER_PAGE_LIMIT=100
|
||||||
|
|
||||||
|
APP_LOG_ENABLED=true
|
||||||
|
APP_LOG_DIR=var/logs
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
.env
|
.env
|
||||||
vendor/
|
vendor/
|
||||||
.idea/
|
.idea/
|
||||||
|
var/logs/
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,15 @@ MERCHANT_API_TOKEN=your-admin-api-token
|
||||||
MERCHANT_API_BASE_URI=https://tpoint.agrimedia.cn
|
MERCHANT_API_BASE_URI=https://tpoint.agrimedia.cn
|
||||||
MERCHANT_API_TIMEOUT=60
|
MERCHANT_API_TIMEOUT=60
|
||||||
OFFLINE_ORDER_PAGE_LIMIT=100
|
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.
|
`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.
|
`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 <token>`, that token is used for the upstream merchant API request first. If the header is missing, `MERCHANT_API_TOKEN` from `.env` is used.
|
If the request contains `Authorization: Bearer <token>`, that token is used for the upstream merchant API request first. If the header is missing, `MERCHANT_API_TOKEN` from `.env` is used.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use App\Controller\HealthController;
|
||||||
use App\Controller\StatsController;
|
use App\Controller\StatsController;
|
||||||
use App\Http\HttpRequest;
|
use App\Http\HttpRequest;
|
||||||
use App\Http\JsonResponse;
|
use App\Http\JsonResponse;
|
||||||
|
use App\Support\FileLogger;
|
||||||
use App\Tools\AnalyzeOfflineOrdersTool;
|
use App\Tools\AnalyzeOfflineOrdersTool;
|
||||||
use App\Tools\MerchantRankingTool;
|
use App\Tools\MerchantRankingTool;
|
||||||
use App\Tools\OrderSummaryTool;
|
use App\Tools\OrderSummaryTool;
|
||||||
|
|
@ -22,6 +23,13 @@ final readonly class ApiKernel
|
||||||
|
|
||||||
public function handle(HttpRequest $request): void
|
public function handle(HttpRequest $request): void
|
||||||
{
|
{
|
||||||
|
FileLogger::info('inbound_request', [
|
||||||
|
'method' => $request->method,
|
||||||
|
'path' => $request->path,
|
||||||
|
'headers' => $request->headers,
|
||||||
|
'input' => $request->input,
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
match ($request->path) {
|
match ($request->path) {
|
||||||
'/', '/health' => $this->ok($this->healthController->show()),
|
'/', '/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/order-summary' => $this->post($request, fn (): array => $this->statsController->orderSummary($request)),
|
||||||
'/api/offline-stats' => $this->post($request, fn (): array => $this->statsController->offlineStats($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)),
|
'/api/merchant-ranking' => $this->post($request, fn (): array => $this->statsController->merchantRanking($request)),
|
||||||
default => JsonResponse::send([
|
default => $this->notFound($request),
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'not_found',
|
|
||||||
'message' => 'API route not found.',
|
|
||||||
],
|
|
||||||
], 404),
|
|
||||||
};
|
};
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
JsonResponse::send([
|
$payload = [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => [
|
'error' => [
|
||||||
'code' => 'server_error',
|
'code' => 'server_error',
|
||||||
'message' => $exception->getMessage(),
|
'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<string, mixed> $handler
|
* @param \Closure(): array<string, mixed> $handler
|
||||||
*/
|
*/
|
||||||
private function post(HttpRequest $request, \Closure $handler): void
|
private function post(HttpRequest $request, \Closure $handler): void
|
||||||
{
|
{
|
||||||
if ($request->method !== 'POST') {
|
if ($request->method !== 'POST') {
|
||||||
JsonResponse::send([
|
$payload = [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => [
|
'error' => [
|
||||||
'code' => 'method_not_allowed',
|
'code' => 'method_not_allowed',
|
||||||
'message' => 'This endpoint only supports POST.',
|
'message' => 'This endpoint only supports POST.',
|
||||||
],
|
],
|
||||||
], 405);
|
];
|
||||||
|
|
||||||
|
FileLogger::info('inbound_response', [
|
||||||
|
'path' => $request->path,
|
||||||
|
'statusCode' => 405,
|
||||||
|
'response' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
JsonResponse::send($payload, 405);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -73,10 +111,17 @@ final readonly class ApiKernel
|
||||||
*/
|
*/
|
||||||
private function ok(array $data): void
|
private function ok(array $data): void
|
||||||
{
|
{
|
||||||
JsonResponse::send([
|
$payload = [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
|
];
|
||||||
|
|
||||||
|
FileLogger::info('inbound_response', [
|
||||||
|
'statusCode' => 200,
|
||||||
|
'response' => $payload,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
JsonResponse::send($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace App\Http;
|
namespace App\Http;
|
||||||
|
|
||||||
use App\Config\ApiConfig;
|
use App\Config\ApiConfig;
|
||||||
|
use App\Support\FileLogger;
|
||||||
use GuzzleHttp\Client;
|
use GuzzleHttp\Client;
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
|
||||||
|
|
@ -26,24 +27,54 @@ final class MerchantApiClient
|
||||||
*/
|
*/
|
||||||
public function getOfflineOrders(array $query): array
|
public function getOfflineOrders(array $query): array
|
||||||
{
|
{
|
||||||
try {
|
$path = '/adminapi/offline/list';
|
||||||
$response = $this->client->get('/adminapi/offline/list', [
|
$headers = [
|
||||||
'headers' => [
|
|
||||||
'accept' => 'application/json',
|
'accept' => 'application/json',
|
||||||
'authori-zation' => 'Bearer ' . $this->config->token,
|
'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($path, [
|
||||||
|
'headers' => $headers,
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
]);
|
]);
|
||||||
} catch (GuzzleException $exception) {
|
} 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);
|
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)) {
|
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.');
|
throw new \RuntimeException('Merchant API returned invalid JSON.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileLogger::info('upstream_response', [
|
||||||
|
'statusCode' => $response->getStatusCode(),
|
||||||
|
'response' => $data,
|
||||||
|
]);
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
final class FileLogger
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function info(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::write('info', $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function error(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::write('error', $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue