添加其他字段

This commit is contained in:
1173117610@qq.com 2026-07-01 14:11:37 +08:00
parent 1e11507ef4
commit 4e4fec48c4
6 changed files with 213 additions and 18 deletions

View File

@ -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

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.env
vendor/
.idea/
var/logs/

View File

@ -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 <token>`, that token is used for the upstream merchant API request first. If the header is missing, `MERCHANT_API_TOKEN` from `.env` is used.

View File

@ -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<string, mixed> $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);
}
/**

View File

@ -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;
}
}

110
src/Support/FileLogger.php Normal file
View File

@ -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';
}
}