update request log

This commit is contained in:
2026-03-17 11:40:07 +08:00
parent 66abe9ce45
commit 2ebe78833e
5 changed files with 779 additions and 0 deletions
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Model\ApiRequestLog;
use App\Utils\Log;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Qbhy\HyperfAuth\AuthManager;
use Swoole\Coroutine;
class RequestLogMiddleware implements MiddlewareInterface
{
public function __construct(
protected AuthManager $auth
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$start = microtime(true);
$response = $handler->handle($request);
$duration_ms = (int) ((microtime(true) - $start) * 1000);
// 在父协程中提取 user_id(子协程不继承 Swoole Context
$user_id = null;
try {
$user_id = $this->auth->guard('jwt')->user()?->getId();
} catch (\Throwable) {
// 未认证请求,user_id 保持 null
}
$method = $request->getMethod();
$path = $request->getUri()->getPath();
$status_code = $response->getStatusCode();
$ip = self::getClientIp($request);
$user_agent = $request->getHeaderLine('User-Agent') ?: null;
// GET/DELETE 请求不记录请求体
$request_body = null;
if (!in_array($method, ['GET', 'DELETE', 'HEAD', 'OPTIONS'], true)) {
$parsed_body = $request->getParsedBody();
if (is_array($parsed_body) && !empty($parsed_body)) {
$request_body = self::sanitizeBody($parsed_body);
}
}
$response_code = self::extractResponseCode($response);
// 异步协程写库,不阻塞响应返回
Coroutine::create(static function () use (
$user_id, $method, $path, $status_code, $ip,
$user_agent, $request_body, $response_code, $duration_ms
): void {
try {
ApiRequestLog::query()->create([
'user_id' => $user_id,
'method' => $method,
'path' => $path,
'status_code' => $status_code,
'ip' => $ip,
'user_agent' => $user_agent,
'request_body' => $request_body,
'response_code' => $response_code,
'duration_ms' => $duration_ms,
]);
} catch (\Throwable $e) {
Log::get()->error('RequestLogMiddleware: 写入请求日志失败', [
'error' => $e->getMessage(),
'path' => $path,
]);
}
});
return $response;
}
/**
* 递归替换敏感字段为 ***
*/
public static function sanitizeBody(array $body): array
{
$sensitive_keys = ['password', 'old_password', 'new_password', 'password_confirmation'];
foreach ($body as $key => $value) {
if (in_array($key, $sensitive_keys, true)) {
$body[$key] = '***';
} elseif (is_array($value)) {
$body[$key] = self::sanitizeBody($value);
}
}
return $body;
}
/**
* 从 JSON 响应体中提取 code 字段
*/
public static function extractResponseCode(ResponseInterface $response): ?int
{
$content_type = $response->getHeaderLine('Content-Type');
if (stripos($content_type, 'application/json') === false) {
return null;
}
try {
$body = (string) $response->getBody();
$data = json_decode($body, true);
if (is_array($data) && isset($data['code'])) {
return (int) $data['code'];
}
} catch (\Throwable) {
// 解析失败返回 null
}
return null;
}
/**
* 获取客户端真实 IP
*
* 优先级:X-Forwarded-For → X-Real-IP → ServerParams remote_addr
*/
public static function getClientIp(ServerRequestInterface $request): ?string
{
// X-Forwarded-For 可能包含多个 IP,取第一个
$forwarded_for = $request->getHeaderLine('X-Forwarded-For');
if ($forwarded_for !== '') {
$ips = explode(',', $forwarded_for);
return trim($ips[0]);
}
$real_ip = $request->getHeaderLine('X-Real-IP');
if ($real_ip !== '') {
return trim($real_ip);
}
$server_params = $request->getServerParams();
return $server_params['remote_addr'] ?? null;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* API 请求日志模型
*
* 记录所有 HTTP 请求的基本信息、耗时和响应状态
*
* @property int $id
* @property int|null $user_id
* @property string $method
* @property string $path
* @property int $status_code
* @property string|null $ip
* @property string|null $user_agent
* @property array|null $request_body
* @property int|null $response_code
* @property int $duration_ms
* @property \Carbon\Carbon $created_at
*/
class ApiRequestLog extends Model
{
protected ?string $table = 'api_request_logs';
public bool $timestamps = true;
public const UPDATED_AT = null;
protected array $fillable = [
'user_id', 'method', 'path', 'status_code', 'ip',
'user_agent', 'request_body', 'response_code', 'duration_ms',
];
protected array $casts = [
'id' => 'integer',
'user_id' => 'integer',
'status_code' => 'integer',
'response_code' => 'integer',
'duration_ms' => 'integer',
'request_body' => 'json',
'created_at' => 'datetime',
];
}
+45
View File
@@ -121,6 +121,51 @@ use OpenApi\Attributes as OA;
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
] ]
)] )]
#[OA\Schema(
schema: 'DashboardOverview',
type: 'object',
description: 'Dashboard 概览统计',
properties: [
new OA\Property(property: 'today', properties: [
new OA\Property(property: 'success', type: 'integer', example: 120),
new OA\Property(property: 'failed', type: 'integer', example: 3),
], type: 'object', description: '今日统计'),
new OA\Property(property: 'this_week', properties: [
new OA\Property(property: 'success', type: 'integer', example: 850),
new OA\Property(property: 'failed', type: 'integer', example: 15),
], type: 'object', description: '本周统计'),
new OA\Property(property: 'this_month', properties: [
new OA\Property(property: 'success', type: 'integer', example: 3200),
new OA\Property(property: 'failed', type: 'integer', example: 42),
], type: 'object', description: '本月统计'),
new OA\Property(property: 'by_type', type: 'array', items: new OA\Items(properties: [
new OA\Property(property: 'data_type', type: 'string', enum: ['order', 'product', 'refund', 'inventory'], example: 'order'),
new OA\Property(property: 'success', type: 'integer', example: 1000),
new OA\Property(property: 'failed', type: 'integer', example: 10),
], type: 'object'), description: '按数据类型分组统计(本月窗口)'),
]
)]
#[OA\Schema(
schema: 'DashboardTrendItem',
type: 'object',
description: '趋势数据点',
properties: [
new OA\Property(property: 'date', type: 'string', format: 'date', example: '2026-03-17'),
new OA\Property(property: 'success', type: 'integer', example: 120),
new OA\Property(property: 'failed', type: 'integer', example: 3),
]
)]
#[OA\Schema(
schema: 'DashboardBreakdownItem',
type: 'object',
description: '分组统计项',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1, description: '维度 ID(公司/平台/店铺)'),
new OA\Property(property: 'name', type: 'string', example: 'Tmall', description: '维度名称'),
new OA\Property(property: 'success', type: 'integer', example: 500),
new OA\Property(property: 'failed', type: 'integer', example: 8),
]
)]
class OpenApiSpec class OpenApiSpec
{ {
} }
@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
namespace HyperfTest\Cases\Integration\System;
use App\Model\FailedMessage;
use App\Model\Order;
use App\Model\Product;
use App\Model\Refund;
use HyperfTest\TestCase;
use HyperfTest\Traits\AuthenticatedTestTrait;
/**
* DashboardController 集成测试
*
* 覆盖 overview/trend/breakdown 三个端点的正常返回、参数校验、401
*
* @internal
* @coversNothing
*/
class DashboardControllerTest extends TestCase
{
use AuthenticatedTestTrait;
private static array $testIds = [];
/**
* 确保有测试数据
*/
protected function ensureTestData(): void
{
if (!empty(self::$testIds)) {
return;
}
self::$testIds = $this->runInCoroutine(static function (): array {
$ids = [];
// 插入 order
$order = Order::query()->create([
'company_id' => 1,
'platform_id' => 1,
'store_id' => 1,
'order_status_id' => 1,
'platform_order_id' => 'DASH_TEST_' . uniqid(),
'buyer_user_id' => 'test_buyer',
'payment_method_id' => 1,
'presale' => false,
'total_amount' => '100.00',
'total_paid' => '100.00',
'total_discount' => '0.00',
'total_received' => '100.00',
'freight_fee' => '0.00',
'tax_fee' => '0.00',
'discount_fee' => '0.00',
'commission_fee' => '0.00',
'coupon_amount' => '0.00',
'voucher_amount' => '0.00',
'order_type_id' => 1,
'created_date' => '2026-03-17 10:00:00',
'hash' => md5('dash_test_order'),
'raw' => '{}',
'ext' => '{}',
]);
$ids['order'] = $order->id;
// 插入 product
$product = Product::query()->create([
'company_id' => 1,
'platform_id' => 1,
'store_id' => 1,
'status_id' => 1,
'type_id' => 1,
'platform_item_id' => 'DASH_PROD_' . uniqid(),
'price' => '50.00',
'currency' => 'CNY',
'num' => 10,
'hash' => md5('dash_test_product'),
'raw' => '{}',
]);
$ids['product'] = $product->id;
// 插入 refund
$refund = Refund::query()->create([
'company_id' => 1,
'platform_id' => 1,
'store_id' => 1,
'platform_order_id' => 'DASH_TEST_ORD',
'platform_refund_id' => 'DASH_RF_' . uniqid(),
'refund_status_id' => 1,
'refund_type_id' => 1,
'refund_amount' => '50.00',
'freight_refund' => '0.00',
'refund_total' => '50.00',
'currency' => 'CNY',
'buyer_user_id' => 'test_buyer',
'order_created_date' => '2026-03-17 09:00:00',
'order_paid_date' => '2026-03-17 09:30:00',
'created_date' => '2026-03-17 10:00:00',
'hash' => md5('dash_test_refund'),
'raw' => '{}',
]);
$ids['refund'] = $refund->id;
// 插入 failed_message
$failed = FailedMessage::query()->create([
'error_id' => 'err_dash_' . uniqid(),
'data_type' => 'order',
'platform' => 'JD',
'platform_id' => 1,
'company_id' => 1,
'store_id' => 1,
'error_type' => 'RuntimeException',
'error_message' => 'Dashboard test error',
'error_code' => 0,
'error_trace' => '#0 test',
'original_message' => ['test' => true],
'retry_count' => 0,
'failed_at' => '2026-03-17 10:00:00+08',
]);
$ids['failed'] = $failed->id;
return $ids;
});
}
public static function tearDownAfterClass(): void
{
if (!empty(self::$testIds)) {
$ids = self::$testIds;
$cleanup = static function () use ($ids): void {
if (isset($ids['order'])) {
Order::query()->where('id', $ids['order'])->delete();
}
if (isset($ids['product'])) {
Product::query()->where('id', $ids['product'])->delete();
}
if (isset($ids['refund'])) {
Refund::query()->where('id', $ids['refund'])->delete();
}
if (isset($ids['failed'])) {
FailedMessage::query()->where('id', $ids['failed'])->delete();
}
};
if (\Swoole\Coroutine::getCid() > 0) {
$cleanup();
} else {
\Swoole\Coroutine\run($cleanup);
}
self::$testIds = [];
}
parent::tearDownAfterClass();
}
// ========== Overview ==========
public function test_overview_returns_correct_structure(): void
{
$this->ensureTestData();
$response = $this->get('/api/v1/dashboard/overview', [], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
$response->assertJsonStructure([
'code',
'message',
'data' => [
'today' => ['success', 'failed'],
'this_week' => ['success', 'failed'],
'this_month' => ['success', 'failed'],
'by_type',
],
]);
}
public function test_overview_by_type_contains_all_data_types(): void
{
$this->ensureTestData();
$response = $this->get('/api/v1/dashboard/overview', [], $this->authHeaders());
$response->assertStatus(200);
$by_type = $response->json('data.by_type');
$types = array_column($by_type, 'data_type');
$this->assertContains('order', $types);
$this->assertContains('product', $types);
$this->assertContains('refund', $types);
$this->assertContains('inventory', $types);
}
// ========== Trend ==========
public function test_trend_returns_array(): void
{
$this->ensureTestData();
$response = $this->get('/api/v1/dashboard/trend', [
'from' => '2026-03-01',
'to' => '2026-03-31',
], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
$this->assertIsArray($response->json('data'));
}
public function test_trend_with_data_type_filter(): void
{
$this->ensureTestData();
$response = $this->get('/api/v1/dashboard/trend', [
'from' => '2026-03-01',
'to' => '2026-03-31',
'data_type' => 'order',
], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
}
public function test_trend_invalid_group_by_returns_400(): void
{
$response = $this->get('/api/v1/dashboard/trend', [
'group_by' => 'invalid',
], $this->authHeaders());
$response->assertStatus(400);
$this->assertSame(400, $response->json('code'));
}
public function test_trend_invalid_data_type_returns_400(): void
{
$response = $this->get('/api/v1/dashboard/trend', [
'data_type' => 'invalid',
], $this->authHeaders());
$response->assertStatus(400);
$this->assertSame(400, $response->json('code'));
}
public function test_trend_invalid_date_format_returns_400(): void
{
$response = $this->get('/api/v1/dashboard/trend', [
'from' => '2026/03/01',
], $this->authHeaders());
$response->assertStatus(400);
$this->assertSame(400, $response->json('code'));
}
public function test_trend_from_after_to_returns_400(): void
{
$response = $this->get('/api/v1/dashboard/trend', [
'from' => '2026-03-31',
'to' => '2026-03-01',
], $this->authHeaders());
$response->assertStatus(400);
$this->assertSame(400, $response->json('code'));
}
// ========== Breakdown ==========
public function test_breakdown_by_company_returns_correct_structure(): void
{
$this->ensureTestData();
$response = $this->get('/api/v1/dashboard/breakdown', [
'dimension' => 'company',
'from' => '2026-03-01',
'to' => '2026-03-31',
], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
$data = $response->json('data');
$this->assertIsArray($data);
if (!empty($data)) {
$first = $data[0];
$this->assertArrayHasKey('id', $first);
$this->assertArrayHasKey('name', $first);
$this->assertArrayHasKey('success', $first);
$this->assertArrayHasKey('failed', $first);
}
}
public function test_breakdown_by_platform(): void
{
$this->ensureTestData();
$response = $this->get('/api/v1/dashboard/breakdown', [
'dimension' => 'platform',
'from' => '2026-03-01',
'to' => '2026-03-31',
], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
}
public function test_breakdown_by_store(): void
{
$this->ensureTestData();
$response = $this->get('/api/v1/dashboard/breakdown', [
'dimension' => 'store',
'from' => '2026-03-01',
'to' => '2026-03-31',
], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
}
public function test_breakdown_missing_dimension_returns_400(): void
{
$response = $this->get('/api/v1/dashboard/breakdown', [], $this->authHeaders());
$response->assertStatus(400);
$this->assertSame(400, $response->json('code'));
$this->assertStringContainsString('dimension', $response->json('message'));
}
public function test_breakdown_invalid_dimension_returns_400(): void
{
$response = $this->get('/api/v1/dashboard/breakdown', [
'dimension' => 'invalid',
], $this->authHeaders());
$response->assertStatus(400);
$this->assertSame(400, $response->json('code'));
}
public function test_breakdown_with_data_type_filter(): void
{
$this->ensureTestData();
$response = $this->get('/api/v1/dashboard/breakdown', [
'dimension' => 'company',
'data_type' => 'order',
'from' => '2026-03-01',
'to' => '2026-03-31',
], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
}
// ========== 认证检查 ==========
public function test_overview_without_token_returns_401(): void
{
$response = $this->get('/api/v1/dashboard/overview');
$response->assertStatus(401);
}
public function test_trend_without_token_returns_401(): void
{
$response = $this->get('/api/v1/dashboard/trend');
$response->assertStatus(401);
}
public function test_breakdown_without_token_returns_401(): void
{
$response = $this->get('/api/v1/dashboard/breakdown');
$response->assertStatus(401);
}
}
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace HyperfTest\Cases\Unit\Service;
use App\Service\DashboardStatsService;
use PHPUnit\Framework\TestCase;
/**
* DashboardStatsService 单元测试
*
* 覆盖常量定义、枚举校验等纯逻辑(聚合查询由集成测试覆盖)
*
* @internal
* @coversNothing
*/
class DashboardStatsServiceTest extends TestCase
{
private DashboardStatsService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new DashboardStatsService();
}
// ========== 常量校验 ==========
public function test_valid_data_types_contains_all_expected_types(): void
{
$types = DashboardStatsService::VALID_DATA_TYPES;
$this->assertContains('order', $types);
$this->assertContains('product', $types);
$this->assertContains('refund', $types);
$this->assertContains('inventory', $types);
$this->assertCount(4, $types);
}
public function test_valid_group_by_contains_all_expected_values(): void
{
$values = DashboardStatsService::VALID_GROUP_BY;
$this->assertContains('day', $values);
$this->assertContains('week', $values);
$this->assertContains('month', $values);
$this->assertCount(3, $values);
}
public function test_valid_dimensions_contains_all_expected_values(): void
{
$values = DashboardStatsService::VALID_DIMENSIONS;
$this->assertContains('company', $values);
$this->assertContains('platform', $values);
$this->assertContains('store', $values);
$this->assertCount(3, $values);
}
// ========== buildScopeCondition(通过反射测试私有方法) ==========
public function test_build_scope_condition_all_returns_empty(): void
{
$bindings = ['2026-01-01', '2026-01-31'];
$result = $this->invokeBuildScope('all', [], $bindings);
$this->assertSame('', $result);
$this->assertCount(2, $bindings);
}
public function test_build_scope_condition_store_returns_in_clause(): void
{
$bindings = ['2026-01-01', '2026-01-31'];
$result = $this->invokeBuildScope('store', [1, 2, 3], $bindings);
$this->assertStringContainsString('store_id IN', $result);
$this->assertCount(5, $bindings); // 2 原始 + 3 scope_ids
$this->assertSame(1, $bindings[2]);
$this->assertSame(2, $bindings[3]);
$this->assertSame(3, $bindings[4]);
}
public function test_build_scope_condition_platform_returns_in_clause(): void
{
$bindings = ['2026-01-01', '2026-01-31'];
$result = $this->invokeBuildScope('platform', [10, 20], $bindings);
$this->assertStringContainsString('platform_id IN', $result);
$this->assertCount(4, $bindings);
}
public function test_build_scope_condition_empty_ids_returns_empty(): void
{
$bindings = ['2026-01-01', '2026-01-31'];
$result = $this->invokeBuildScope('store', [], $bindings);
$this->assertSame('', $result);
$this->assertCount(2, $bindings);
}
// ========== getDateTrunc(通过反射测试私有方法) ==========
public function test_date_trunc_day(): void
{
$result = $this->invokeGetDateTrunc('day');
$this->assertStringContainsString("'day'", $result);
$this->assertStringContainsString('DATE_TRUNC', $result);
}
public function test_date_trunc_week(): void
{
$result = $this->invokeGetDateTrunc('week');
$this->assertStringContainsString("'week'", $result);
}
public function test_date_trunc_month(): void
{
$result = $this->invokeGetDateTrunc('month');
$this->assertStringContainsString("'month'", $result);
}
// ========== TYPE_TABLE_MAP 映射 ==========
public function test_type_table_map_covers_order_product_refund(): void
{
$reflection = new \ReflectionClass(DashboardStatsService::class);
$constant = $reflection->getReflectionConstant('TYPE_TABLE_MAP');
$map = $constant->getValue();
$this->assertSame('orders', $map['order']);
$this->assertSame('products', $map['product']);
$this->assertSame('refunds', $map['refund']);
$this->assertArrayNotHasKey('inventory', $map);
}
public function test_dimension_table_map_covers_all_dimensions(): void
{
$reflection = new \ReflectionClass(DashboardStatsService::class);
$constant = $reflection->getReflectionConstant('DIMENSION_TABLE_MAP');
$map = $constant->getValue();
$this->assertArrayHasKey('company', $map);
$this->assertArrayHasKey('platform', $map);
$this->assertArrayHasKey('store', $map);
foreach ($map as $config) {
$this->assertArrayHasKey('table', $config);
$this->assertArrayHasKey('name_field', $config);
}
}
// ========== Helper ==========
private function invokeBuildScope(string $scope_type, array $scope_ids, array &$bindings): string
{
$method = new \ReflectionMethod(DashboardStatsService::class, 'buildScopeCondition');
$args = [$scope_type, $scope_ids, &$bindings];
return $method->invokeArgs($this->service, $args);
}
private function invokeGetDateTrunc(string $group_by): string
{
$method = new \ReflectionMethod(DashboardStatsService::class, 'getDateTrunc');
$method->setAccessible(true);
return $method->invoke($this->service, $group_by);
}
}