Compare commits

..

No commits in common. "c62cb3ef40ef4602a150c363353cd811913f5373" and "1e11507ef42c050850b01cbc909199e6f2411ea2" have entirely different histories.

9 changed files with 19 additions and 239 deletions

View File

@ -3,6 +3,3 @@ 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,4 +1,3 @@
.env
vendor/
.idea/
var/logs/

View File

@ -11,15 +11,10 @@ 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

@ -324,17 +324,6 @@ AI 选择规则:
| `sortBy` | string | 排序字段 |
| `limit` | integer | 返回商户数量,默认 `10`,最大 `100` |
`/api/merchant-ranking` 返回中会包含:
| 字段 | 含义 |
| --- | --- |
| `merchantCount` | 当前筛选条件下聚合出的商户总数 |
| `returnedCount` | 本次实际返回的排行条数 |
| `limit` | 本次排行返回上限 |
| `ranking` | 排行列表 |
AI 解读时不要把 `returnedCount` 当成总商户数。应使用 `merchantCount` 表示“共有多少家活跃商户”,使用 `returnedCount` 表示“本次展示多少家”。
`sortBy` 可选值:
| 值 | 含义 |

View File

@ -296,9 +296,6 @@ AI 必须严格遵守:
"success": true,
"data": {
"sortBy": "totalRealPay",
"merchantCount": 28,
"returnedCount": 10,
"limit": 10,
"ranking": [
{
"merchantId": 1,
@ -321,9 +318,6 @@ AI 必须严格遵守:
| 字段 | 含义 |
| --- | --- |
| `sortBy` | 当前排行榜排序字段 |
| `merchantCount` | 当前筛选条件下聚合出的商户总数 |
| `returnedCount` | 本次实际返回的排行条数 |
| `limit` | 本次排行返回上限 |
| `ranking` | 商户排行列表 |
| `merchantId` | 商户 ID |
| `merchantName` | 商户名称,可能为空 |
@ -337,9 +331,7 @@ AI 必须严格遵守:
```text
本次查询时间范围:{_debug_query.time}
当前筛选条件下共有 {merchantCount} 家活跃商户,本次返回 {returnedCount} 家,排序字段:{sortBy}。
商户排行榜如下:
商户排行榜如下,排序字段:{sortBy}
1. {merchantName 或 merchantId}
- 订单数:{orderCount}
@ -363,8 +355,6 @@ AI 必须严格遵守:
```text
本次查询时间范围:{_debug_query.time}
当前筛选条件下共有 {merchantCount} 家活跃商户,本次返回 {returnedCount} 家。
活跃商户如下:
1. {merchantName 或 merchantId}
2. {merchantName 或 merchantId}

View File

@ -8,7 +8,6 @@ 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;
@ -23,13 +22,6 @@ 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()),
@ -37,68 +29,38 @@ 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 => $this->notFound($request),
default => JsonResponse::send([
'success' => false,
'error' => [
'code' => 'not_found',
'message' => 'API route not found.',
],
], 404),
};
} catch (\Throwable $exception) {
$payload = [
JsonResponse::send([
'success' => false,
'error' => [
'code' => 'server_error',
'message' => $exception->getMessage(),
],
];
FileLogger::error('inbound_response_error', [
'path' => $request->path,
'statusCode' => 500,
'response' => $payload,
'exception' => $exception::class,
]);
JsonResponse::send($payload, 500);
], 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') {
$payload = [
JsonResponse::send([
'success' => false,
'error' => [
'code' => 'method_not_allowed',
'message' => 'This endpoint only supports POST.',
],
];
FileLogger::info('inbound_response', [
'path' => $request->path,
'statusCode' => 405,
'response' => $payload,
]);
JsonResponse::send($payload, 405);
], 405);
return;
}
@ -111,17 +73,10 @@ final readonly class ApiKernel
*/
private function ok(array $data): void
{
$payload = [
JsonResponse::send([
'success' => true,
'data' => $data,
];
FileLogger::info('inbound_response', [
'statusCode' => 200,
'response' => $payload,
]);
JsonResponse::send($payload);
}
/**

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http;
use App\Config\ApiConfig;
use App\Support\FileLogger;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
@ -27,54 +26,24 @@ 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($path, [
'headers' => $headers,
$response = $this->client->get('/adminapi/offline/list', [
'headers' => [
'accept' => 'application/json',
'authori-zation' => 'Bearer ' . $this->config->token,
],
'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);
}
$body = (string) $response->getBody();
$data = json_decode($body, true);
$data = json_decode((string) $response->getBody(), 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;
}
}

View File

@ -1,110 +0,0 @@
<?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';
}
}

View File

@ -67,7 +67,6 @@ 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) {
@ -77,9 +76,6 @@ final readonly class MerchantRankingTool
return [
'sortBy' => $sortField,
'merchantCount' => $merchantCount,
'returnedCount' => count($items),
'limit' => $maxItems,
'ranking' => $items,
'_debug_query' => $query,
];