From 2ebe78833e3001db41734adf03173e09da5e8dff Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Tue, 17 Mar 2026 11:40:07 +0800 Subject: [PATCH] update request log --- .../app/Middleware/RequestLogMiddleware.php | 147 +++++++ backend/app/Model/ApiRequestLog.php | 46 +++ backend/app/OpenApiSpec.php | 45 +++ .../System/DashboardControllerTest.php | 373 ++++++++++++++++++ .../Service/DashboardStatsServiceTest.php | 168 ++++++++ 5 files changed, 779 insertions(+) create mode 100644 backend/app/Middleware/RequestLogMiddleware.php create mode 100644 backend/app/Model/ApiRequestLog.php create mode 100644 backend/test/Cases/Integration/System/DashboardControllerTest.php create mode 100644 backend/test/Cases/Unit/Service/DashboardStatsServiceTest.php diff --git a/backend/app/Middleware/RequestLogMiddleware.php b/backend/app/Middleware/RequestLogMiddleware.php new file mode 100644 index 0000000..382a1f2 --- /dev/null +++ b/backend/app/Middleware/RequestLogMiddleware.php @@ -0,0 +1,147 @@ +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; + } +} diff --git a/backend/app/Model/ApiRequestLog.php b/backend/app/Model/ApiRequestLog.php new file mode 100644 index 0000000..fec1f01 --- /dev/null +++ b/backend/app/Model/ApiRequestLog.php @@ -0,0 +1,46 @@ + 'integer', + 'user_id' => 'integer', + 'status_code' => 'integer', + 'response_code' => 'integer', + 'duration_ms' => 'integer', + 'request_body' => 'json', + 'created_at' => 'datetime', + ]; +} diff --git a/backend/app/OpenApiSpec.php b/backend/app/OpenApiSpec.php index f6eb946..25ef5de 100644 --- a/backend/app/OpenApiSpec.php +++ b/backend/app/OpenApiSpec.php @@ -121,6 +121,51 @@ use OpenApi\Attributes as OA; 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 { } diff --git a/backend/test/Cases/Integration/System/DashboardControllerTest.php b/backend/test/Cases/Integration/System/DashboardControllerTest.php new file mode 100644 index 0000000..d9be5f4 --- /dev/null +++ b/backend/test/Cases/Integration/System/DashboardControllerTest.php @@ -0,0 +1,373 @@ +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); + } +} diff --git a/backend/test/Cases/Unit/Service/DashboardStatsServiceTest.php b/backend/test/Cases/Unit/Service/DashboardStatsServiceTest.php new file mode 100644 index 0000000..28df0da --- /dev/null +++ b/backend/test/Cases/Unit/Service/DashboardStatsServiceTest.php @@ -0,0 +1,168 @@ +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); + } +}