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