Compare commits
2 Commits
1e11507ef4
...
c62cb3ef40
| Author | SHA1 | Date |
|---|---|---|
|
|
c62cb3ef40 | |
|
|
4e4fec48c4 |
|
|
@ -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,3 +1,4 @@
|
|||
.env
|
||||
vendor/
|
||||
.idea/
|
||||
var/logs/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -324,6 +324,17 @@ AI 选择规则:
|
|||
| `sortBy` | string | 排序字段 |
|
||||
| `limit` | integer | 返回商户数量,默认 `10`,最大 `100` |
|
||||
|
||||
`/api/merchant-ranking` 返回中会包含:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `merchantCount` | 当前筛选条件下聚合出的商户总数 |
|
||||
| `returnedCount` | 本次实际返回的排行条数 |
|
||||
| `limit` | 本次排行返回上限 |
|
||||
| `ranking` | 排行列表 |
|
||||
|
||||
AI 解读时不要把 `returnedCount` 当成总商户数。应使用 `merchantCount` 表示“共有多少家活跃商户”,使用 `returnedCount` 表示“本次展示多少家”。
|
||||
|
||||
`sortBy` 可选值:
|
||||
|
||||
| 值 | 含义 |
|
||||
|
|
|
|||
|
|
@ -296,6 +296,9 @@ AI 必须严格遵守:
|
|||
"success": true,
|
||||
"data": {
|
||||
"sortBy": "totalRealPay",
|
||||
"merchantCount": 28,
|
||||
"returnedCount": 10,
|
||||
"limit": 10,
|
||||
"ranking": [
|
||||
{
|
||||
"merchantId": 1,
|
||||
|
|
@ -318,6 +321,9 @@ AI 必须严格遵守:
|
|||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `sortBy` | 当前排行榜排序字段 |
|
||||
| `merchantCount` | 当前筛选条件下聚合出的商户总数 |
|
||||
| `returnedCount` | 本次实际返回的排行条数 |
|
||||
| `limit` | 本次排行返回上限 |
|
||||
| `ranking` | 商户排行列表 |
|
||||
| `merchantId` | 商户 ID |
|
||||
| `merchantName` | 商户名称,可能为空 |
|
||||
|
|
@ -331,7 +337,9 @@ AI 必须严格遵守:
|
|||
```text
|
||||
本次查询时间范围:{_debug_query.time}
|
||||
|
||||
商户排行榜如下,排序字段:{sortBy}
|
||||
当前筛选条件下共有 {merchantCount} 家活跃商户,本次返回 {returnedCount} 家,排序字段:{sortBy}。
|
||||
|
||||
商户排行榜如下:
|
||||
|
||||
1. {merchantName 或 merchantId}
|
||||
- 订单数:{orderCount}
|
||||
|
|
@ -355,6 +363,8 @@ AI 必须严格遵守:
|
|||
```text
|
||||
本次查询时间范围:{_debug_query.time}
|
||||
|
||||
当前筛选条件下共有 {merchantCount} 家活跃商户,本次返回 {returnedCount} 家。
|
||||
|
||||
活跃商户如下:
|
||||
1. {merchantName 或 merchantId}
|
||||
2. {merchantName 或 merchantId}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ final readonly class MerchantRankingTool
|
|||
});
|
||||
|
||||
$maxItems = $limit !== null && $limit > 0 ? min($limit, 100) : 10;
|
||||
$merchantCount = count($items);
|
||||
$items = array_slice($items, 0, $maxItems);
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
|
|
@ -76,6 +77,9 @@ final readonly class MerchantRankingTool
|
|||
|
||||
return [
|
||||
'sortBy' => $sortField,
|
||||
'merchantCount' => $merchantCount,
|
||||
'returnedCount' => count($items),
|
||||
'limit' => $maxItems,
|
||||
'ranking' => $items,
|
||||
'_debug_query' => $query,
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in New Issue