From 0c013a8c100fc6a5b01cda73a6e6e43e0c6c30a0 Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Fri, 13 Mar 2026 15:01:55 +0800 Subject: [PATCH] update mq controller --- .../Api/V1/FailedMessageController.php | 143 +++++++ backend/app/Model/FailedMessage.php | 53 +++ backend/app/OpenApiSpec.php | 43 ++ backend/app/Platform/OrderConsumer.php | 34 ++ backend/app/Platform/ProductConsumer.php | 34 ++ backend/app/Platform/RefundConsumer.php | 36 +- ...13_100000_create_failed_messages_table.php | 45 ++ backend/storage/openapi.json | 388 ++++++++++++++++++ .../System/FailedMessageControllerTest.php | 216 ++++++++++ 9 files changed, 991 insertions(+), 1 deletion(-) create mode 100644 backend/app/Controller/Api/V1/FailedMessageController.php create mode 100644 backend/app/Model/FailedMessage.php create mode 100644 backend/migrations/2026_03_13_100000_create_failed_messages_table.php create mode 100644 backend/test/Cases/Integration/System/FailedMessageControllerTest.php diff --git a/backend/app/Controller/Api/V1/FailedMessageController.php b/backend/app/Controller/Api/V1/FailedMessageController.php new file mode 100644 index 0000000..6e81978 --- /dev/null +++ b/backend/app/Controller/Api/V1/FailedMessageController.php @@ -0,0 +1,143 @@ + 'exact', + 'platform_id' => 'exact', + 'error_type' => 'like', + 'failed_at_from' => 'date_from', + 'failed_at_to' => 'date_to', + ]; + } + + protected function getDefaultSort(): string + { + return 'failed_at'; + } + + /** + * 失败消息列表 + */ + #[OA\Get( + path: '/api/v1/failed-messages', + summary: '失败消息列表', + description: '获取 Consumer 处理失败的消息列表,支持分页、按数据类型/平台/时间筛选。仅 admin 可访问。', + security: [['bearerAuth' => []]], + tags: ['Failed Messages'], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15, maximum: 100)), + new OA\Parameter(name: 'data_type', in: 'query', required: false, description: '数据类型精确筛选(order/product/refund)', schema: new OA\Schema(type: 'string', enum: ['order', 'product', 'refund'])), + new OA\Parameter(name: 'platform_id', in: 'query', required: false, description: '平台 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'error_type', in: 'query', required: false, description: '异常类名模糊搜索', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'failed_at_from', in: 'query', required: false, description: '失败时间起始(含)', schema: new OA\Schema(type: 'string', format: 'date', example: '2026-01-01')), + new OA\Parameter(name: 'failed_at_to', in: 'query', required: false, description: '失败时间截止(含)', schema: new OA\Schema(type: 'string', format: 'date', example: '2026-12-31')), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'items', type: 'array', items: new OA\Items(ref: '#/components/schemas/FailedMessageList')), + new OA\Property(property: 'total', type: 'integer', example: 100), + new OA\Property(property: 'page', type: 'integer', example: 1), + new OA\Property(property: 'per_page', type: 'integer', example: 15), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + public function index(): ResponseInterface|array + { + return parent::index(); + } + + /** + * 失败消息详情 + */ + #[OA\Get( + path: '/api/v1/failed-messages/{id}', + summary: '失败消息详情', + description: '获取失败消息详情,含完整错误堆栈和原始消息体。仅 admin 可访问。', + security: [['bearerAuth' => []]], + tags: ['Failed Messages'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '失败消息 ID', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', ref: '#/components/schemas/FailedMessageDetail'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "GET")] + public function show(int $id): ResponseInterface|array + { + return parent::show($id); + } +} diff --git a/backend/app/Model/FailedMessage.php b/backend/app/Model/FailedMessage.php new file mode 100644 index 0000000..168ed27 --- /dev/null +++ b/backend/app/Model/FailedMessage.php @@ -0,0 +1,53 @@ + 'integer', + 'platform_id' => 'integer', + 'company_id' => 'integer', + 'store_id' => 'integer', + 'error_code' => 'integer', + 'original_message' => 'json', + 'retry_count' => 'integer', + 'failed_at' => 'datetime', + 'created_at' => 'datetime', + ]; +} diff --git a/backend/app/OpenApiSpec.php b/backend/app/OpenApiSpec.php index f330a52..f6eb946 100644 --- a/backend/app/OpenApiSpec.php +++ b/backend/app/OpenApiSpec.php @@ -78,6 +78,49 @@ use OpenApi\Attributes as OA; new OA\Property(property: 'fetched_at', type: 'string', format: 'date-time', example: '2026-03-13 12:00:00', description: '数据获取时间'), ] )] +#[OA\Schema( + schema: 'FailedMessageList', + type: 'object', + description: '失败消息列表项', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'error_id', type: 'string', example: 'err_67890abc'), + new OA\Property(property: 'data_type', type: 'string', enum: ['order', 'product', 'refund'], example: 'order'), + new OA\Property(property: 'platform', type: 'string', example: 'Tmall', nullable: true), + new OA\Property(property: 'platform_id', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'company_id', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'store_id', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'error_type', type: 'string', example: 'RuntimeException'), + new OA\Property(property: 'error_message', type: 'string', example: 'Connection refused'), + new OA\Property(property: 'retry_count', type: 'integer', example: 3), + new OA\Property(property: 'message_id', type: 'string', example: 'msg_12345', nullable: true), + new OA\Property(property: 'failed_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] +)] +#[OA\Schema( + schema: 'FailedMessageDetail', + type: 'object', + description: '失败消息详情(含完整错误堆栈和原始消息体)', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'error_id', type: 'string', example: 'err_67890abc'), + new OA\Property(property: 'data_type', type: 'string', enum: ['order', 'product', 'refund'], example: 'order'), + new OA\Property(property: 'platform', type: 'string', example: 'Tmall', nullable: true), + new OA\Property(property: 'platform_id', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'company_id', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'store_id', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'error_type', type: 'string', example: 'RuntimeException'), + new OA\Property(property: 'error_message', type: 'string', example: 'Connection refused'), + new OA\Property(property: 'error_code', type: 'integer', example: 0), + new OA\Property(property: 'error_trace', type: 'string', description: '完整异常堆栈'), + new OA\Property(property: 'original_message', type: 'object', description: '原始消息体 JSON'), + new OA\Property(property: 'retry_count', type: 'integer', example: 3), + new OA\Property(property: 'message_id', type: 'string', example: 'msg_12345', nullable: true), + new OA\Property(property: 'failed_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] +)] class OpenApiSpec { } diff --git a/backend/app/Platform/OrderConsumer.php b/backend/app/Platform/OrderConsumer.php index 420cc3b..7c22f84 100644 --- a/backend/app/Platform/OrderConsumer.php +++ b/backend/app/Platform/OrderConsumer.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Platform; use App\Entity\Parse\EntityParseFactory; +use App\Model\FailedMessage; use App\Utils\Log; use Hyperf\Amqp\Annotation\Consumer; use Hyperf\Amqp\Builder\QueueBuilder; @@ -265,6 +266,9 @@ class OrderConsumer extends ConsumerMessage $error_producer = new ErrorProducer($message, $error, $retry_count); $producer->produce($error_producer); + // 同步写入 failed_messages 表 + $this->persistFailedMessage($error_producer->payload); + // 记录日志 Log::get()->warning('Message sent to error queue after exceeding retry limit', [ 'error_id' => $error_producer->payload['error_id'] ?? 'unknown', @@ -453,4 +457,34 @@ class OrderConsumer extends ConsumerMessage } } } + + /** + * 持久化失败消息到数据库 + */ + protected function persistFailedMessage(array $payload): void + { + try { + FailedMessage::query()->create([ + 'error_id' => $payload['error_id'], + 'data_type' => $payload['metadata']['data_type'] ?? 'order', + 'platform' => $payload['metadata']['platform'] ?? null, + 'platform_id' => $payload['metadata']['platform_id'] ?? null, + 'company_id' => $payload['metadata']['company_id'] ?? null, + 'store_id' => $payload['metadata']['store_id'] ?? null, + 'error_type' => $payload['error']['type'] ?? 'Unknown', + 'error_message' => $payload['error']['message'] ?? '', + 'error_code' => $payload['error']['code'] ?? 0, + 'error_trace' => $payload['error']['trace'] ?? '', + 'original_message' => $payload['original_message'] ?? [], + 'retry_count' => $payload['metadata']['retry_count'] ?? 0, + 'message_id' => $payload['metadata']['message_id'] ?? null, + 'failed_at' => $payload['metadata']['failed_at'] ?? date('c'), + ]); + } catch (Throwable $e) { + Log::get()->error('Failed to persist failed message to database', [ + 'error' => $e->getMessage(), + 'error_id' => $payload['error_id'] ?? 'unknown', + ]); + } + } } diff --git a/backend/app/Platform/ProductConsumer.php b/backend/app/Platform/ProductConsumer.php index 5e100ae..a10d7b8 100644 --- a/backend/app/Platform/ProductConsumer.php +++ b/backend/app/Platform/ProductConsumer.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Platform; use App\Entity\Parse\EntityParseFactory; +use App\Model\FailedMessage; use App\Utils\Log; use Hyperf\Amqp\Annotation\Consumer; use Hyperf\Amqp\Builder\QueueBuilder; @@ -191,6 +192,9 @@ class ProductConsumer extends ConsumerMessage $error_producer = new ErrorProducer($message, $error, $retry_count); $producer->produce($error_producer); + // 同步写入 failed_messages 表 + $this->persistFailedMessage($error_producer->payload); + Log::get()->warning('Product message sent to error queue after exceeding retry limit', [ 'error_id' => $error_producer->payload['error_id'] ?? 'unknown', 'retry_count' => $retry_count, @@ -204,4 +208,34 @@ class ProductConsumer extends ConsumerMessage ]); } } + + /** + * 持久化失败消息到数据库 + */ + protected function persistFailedMessage(array $payload): void + { + try { + FailedMessage::query()->create([ + 'error_id' => $payload['error_id'], + 'data_type' => $payload['metadata']['data_type'] ?? 'product', + 'platform' => $payload['metadata']['platform'] ?? null, + 'platform_id' => $payload['metadata']['platform_id'] ?? null, + 'company_id' => $payload['metadata']['company_id'] ?? null, + 'store_id' => $payload['metadata']['store_id'] ?? null, + 'error_type' => $payload['error']['type'] ?? 'Unknown', + 'error_message' => $payload['error']['message'] ?? '', + 'error_code' => $payload['error']['code'] ?? 0, + 'error_trace' => $payload['error']['trace'] ?? '', + 'original_message' => $payload['original_message'] ?? [], + 'retry_count' => $payload['metadata']['retry_count'] ?? 0, + 'message_id' => $payload['metadata']['message_id'] ?? null, + 'failed_at' => $payload['metadata']['failed_at'] ?? date('c'), + ]); + } catch (Throwable $e) { + Log::get()->error('Failed to persist failed message to database', [ + 'error' => $e->getMessage(), + 'error_id' => $payload['error_id'] ?? 'unknown', + ]); + } + } } diff --git a/backend/app/Platform/RefundConsumer.php b/backend/app/Platform/RefundConsumer.php index 635eb57..dd7c6e6 100644 --- a/backend/app/Platform/RefundConsumer.php +++ b/backend/app/Platform/RefundConsumer.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Platform; use App\Entity\Parse\EntityParseFactory; +use App\Model\FailedMessage; +use App\Model\RefundItem; use App\Utils\Log; use Hyperf\Amqp\Annotation\Consumer; use Hyperf\Amqp\Builder\QueueBuilder; @@ -13,7 +15,6 @@ use Hyperf\Amqp\Result; use Hyperf\Amqp\Producer; use PhpAmqpLib\Message\AMQPMessage; use Hyperf\DbConnection\Db; -use App\Model\RefundItem; use Hyperf\Context\ApplicationContext; use Throwable; @@ -233,6 +234,9 @@ class RefundConsumer extends ConsumerMessage $error_producer = new ErrorProducer($message, $error, $retry_count); $producer->produce($error_producer); + // 同步写入 failed_messages 表 + $this->persistFailedMessage($error_producer->payload); + Log::get()->warning('Refund message sent to error queue after exceeding retry limit', [ 'error_id' => $error_producer->payload['error_id'] ?? 'unknown', 'retry_count' => $retry_count, @@ -247,6 +251,36 @@ class RefundConsumer extends ConsumerMessage } } + /** + * 持久化失败消息到数据库 + */ + protected function persistFailedMessage(array $payload): void + { + try { + FailedMessage::query()->create([ + 'error_id' => $payload['error_id'], + 'data_type' => $payload['metadata']['data_type'] ?? 'refund', + 'platform' => $payload['metadata']['platform'] ?? null, + 'platform_id' => $payload['metadata']['platform_id'] ?? null, + 'company_id' => $payload['metadata']['company_id'] ?? null, + 'store_id' => $payload['metadata']['store_id'] ?? null, + 'error_type' => $payload['error']['type'] ?? 'Unknown', + 'error_message' => $payload['error']['message'] ?? '', + 'error_code' => $payload['error']['code'] ?? 0, + 'error_trace' => $payload['error']['trace'] ?? '', + 'original_message' => $payload['original_message'] ?? [], + 'retry_count' => $payload['metadata']['retry_count'] ?? 0, + 'message_id' => $payload['metadata']['message_id'] ?? null, + 'failed_at' => $payload['metadata']['failed_at'] ?? date('c'), + ]); + } catch (Throwable $e) { + Log::get()->error('Failed to persist failed message to database', [ + 'error' => $e->getMessage(), + 'error_id' => $payload['error_id'] ?? 'unknown', + ]); + } + } + /** * 处理退款子项的批量同步 * diff --git a/backend/migrations/2026_03_13_100000_create_failed_messages_table.php b/backend/migrations/2026_03_13_100000_create_failed_messages_table.php new file mode 100644 index 0000000..950d09e --- /dev/null +++ b/backend/migrations/2026_03_13_100000_create_failed_messages_table.php @@ -0,0 +1,45 @@ +id()->comment('主键'); + $table->string('error_id', 50)->unique()->comment('错误唯一标识(err_xxx)'); + $table->string('data_type', 30)->comment('数据类型: order/product/refund'); + $table->string('platform', 50)->nullable()->comment('平台名称'); + $table->unsignedBigInteger('platform_id')->nullable()->comment('平台 ID'); + $table->unsignedBigInteger('company_id')->nullable()->comment('公司 ID'); + $table->unsignedBigInteger('store_id')->nullable()->comment('店铺 ID'); + $table->string('error_type', 255)->comment('异常类名'); + $table->text('error_message')->comment('异常消息'); + $table->integer('error_code')->default(0)->comment('异常代码'); + $table->text('error_trace')->comment('异常堆栈'); + $table->jsonb('original_message')->comment('原始消息体'); + $table->integer('retry_count')->default(0)->comment('重试次数'); + $table->string('message_id', 100)->nullable()->comment('消息 ID'); + $table->timestampTz('failed_at')->comment('失败时间'); + $table->timestampTz('created_at')->useCurrent()->comment('创建时间'); + + $table->index('data_type'); + $table->index('platform_id'); + $table->index('failed_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('failed_messages'); + } +}; diff --git a/backend/storage/openapi.json b/backend/storage/openapi.json index 4e0c28d..1ceaeca 100644 --- a/backend/storage/openapi.json +++ b/backend/storage/openapi.json @@ -12,6 +12,242 @@ } ], "paths": { + "/api/v1/failed-messages": { + "get": { + "tags": [ + "Failed Messages" + ], + "summary": "失败消息列表", + "description": "获取 Consumer 处理失败的消息列表,支持分页、按数据类型/平台/时间筛选。仅 admin 可访问。", + "operationId": "6dac9bdff8b35f75565ed5c6b8638937", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 15, + "maximum": 100 + } + }, + { + "name": "data_type", + "in": "query", + "description": "数据类型精确筛选(order/product/refund)", + "required": false, + "schema": { + "type": "string", + "enum": [ + "order", + "product", + "refund" + ] + } + }, + { + "name": "platform_id", + "in": "query", + "description": "平台 ID 精确筛选", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "error_type", + "in": "query", + "description": "异常类名模糊搜索", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "failed_at_from", + "in": "query", + "description": "失败时间起始(含)", + "required": false, + "schema": { + "type": "string", + "format": "date", + "example": "2026-01-01" + } + }, + { + "name": "failed_at_to", + "in": "query", + "description": "失败时间截止(含)", + "required": false, + "schema": { + "type": "string", + "format": "date", + "example": "2026-12-31" + } + } + ], + "responses": { + "200": { + "description": "获取成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "message": { + "type": "string", + "example": "获取成功" + }, + "data": { + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FailedMessageList" + } + }, + "total": { + "type": "integer", + "example": 100 + }, + "page": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 15 + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "未认证", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "无权限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/failed-messages/{id}": { + "get": { + "tags": [ + "Failed Messages" + ], + "summary": "失败消息详情", + "description": "获取失败消息详情,含完整错误堆栈和原始消息体。仅 admin 可访问。", + "operationId": "4f6b7410222ebc0fa65afdc1d168c15a", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "失败消息 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "获取成功", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "message": { + "type": "string", + "example": "获取成功" + }, + "data": { + "$ref": "#/components/schemas/FailedMessageDetail" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "未认证", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "无权限", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "数据不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/mq/status": { "get": { "tags": [ @@ -6733,6 +6969,154 @@ } }, "type": "object" + }, + "FailedMessageList": { + "description": "失败消息列表项", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "error_id": { + "type": "string", + "example": "err_67890abc" + }, + "data_type": { + "type": "string", + "example": "order", + "enum": [ + "order", + "product", + "refund" + ] + }, + "platform": { + "type": "string", + "example": "Tmall", + "nullable": true + }, + "platform_id": { + "type": "integer", + "example": 1, + "nullable": true + }, + "company_id": { + "type": "integer", + "example": 1, + "nullable": true + }, + "store_id": { + "type": "integer", + "example": 1, + "nullable": true + }, + "error_type": { + "type": "string", + "example": "RuntimeException" + }, + "error_message": { + "type": "string", + "example": "Connection refused" + }, + "retry_count": { + "type": "integer", + "example": 3 + }, + "message_id": { + "type": "string", + "example": "msg_12345", + "nullable": true + }, + "failed_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, + "FailedMessageDetail": { + "description": "失败消息详情(含完整错误堆栈和原始消息体)", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "error_id": { + "type": "string", + "example": "err_67890abc" + }, + "data_type": { + "type": "string", + "example": "order", + "enum": [ + "order", + "product", + "refund" + ] + }, + "platform": { + "type": "string", + "example": "Tmall", + "nullable": true + }, + "platform_id": { + "type": "integer", + "example": 1, + "nullable": true + }, + "company_id": { + "type": "integer", + "example": 1, + "nullable": true + }, + "store_id": { + "type": "integer", + "example": 1, + "nullable": true + }, + "error_type": { + "type": "string", + "example": "RuntimeException" + }, + "error_message": { + "type": "string", + "example": "Connection refused" + }, + "error_code": { + "type": "integer", + "example": 0 + }, + "error_trace": { + "description": "完整异常堆栈", + "type": "string" + }, + "original_message": { + "description": "原始消息体 JSON", + "type": "object" + }, + "retry_count": { + "type": "integer", + "example": 3 + }, + "message_id": { + "type": "string", + "example": "msg_12345", + "nullable": true + }, + "failed_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" } }, "securitySchemes": { @@ -6749,6 +7133,10 @@ } }, "tags": [ + { + "name": "Failed Messages", + "description": "失败消息查看" + }, { "name": "MQ Status", "description": "消息队列状态监控" diff --git a/backend/test/Cases/Integration/System/FailedMessageControllerTest.php b/backend/test/Cases/Integration/System/FailedMessageControllerTest.php new file mode 100644 index 0000000..a81531d --- /dev/null +++ b/backend/test/Cases/Integration/System/FailedMessageControllerTest.php @@ -0,0 +1,216 @@ +runInCoroutine(static function (): int { + $record = FailedMessage::query()->create([ + 'error_id' => 'err_test_' . uniqid(), + 'data_type' => 'order', + 'platform' => 'Tmall', + 'platform_id' => 1, + 'company_id' => 1, + 'store_id' => 1, + 'error_type' => 'RuntimeException', + 'error_message' => 'Test error message for integration test', + 'error_code' => 0, + 'error_trace' => '#0 test trace line 1\n#1 test trace line 2', + 'original_message' => ['data_type' => 'order', 'data' => ['tid' => '12345']], + 'retry_count' => 3, + 'message_id' => 'msg_test_123', + 'failed_at' => '2026-03-13 10:00:00+08', + ]); + return $record->id; + }); + + self::$testRecordId = $id; + return $id; + } + + /** + * 清理测试数据 + */ + protected function tearDown(): void + { + // 最后一个测试方法执行完毕后清理 + parent::tearDown(); + } + + public static function tearDownAfterClass(): void + { + if (self::$testRecordId !== null) { + // co-phpunit 已在协程中,直接使用 runInCoroutine 兼容模式 + $id = self::$testRecordId; + if (\Swoole\Coroutine::getCid() > 0) { + FailedMessage::query()->where('id', $id)->delete(); + } else { + \Swoole\Coroutine\run(static function () use ($id): void { + FailedMessage::query()->where('id', $id)->delete(); + }); + } + self::$testRecordId = null; + } + parent::tearDownAfterClass(); + } + + // ========== 列表接口 ========== + + public function test_list_returns_paginated_data(): void + { + $this->ensureTestData(); + + $response = $this->get('/api/v1/failed-messages', [], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonStructure([ + 'code', + 'message', + 'data' => [ + 'items', + 'total', + 'page', + 'per_page', + ], + ]); + } + + public function test_list_respects_per_page(): void + { + $this->ensureTestData(); + + $response = $this->get('/api/v1/failed-messages', ['per_page' => 5], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('data.per_page', 5); + } + + public function test_list_excludes_trace_and_original_message(): void + { + $this->ensureTestData(); + + $response = $this->get('/api/v1/failed-messages', [], $this->authHeaders()); + + $response->assertStatus(200); + $items = $response->json('data.items'); + if (!empty($items)) { + $first = $items[0]; + $this->assertArrayNotHasKey('error_trace', $first); + $this->assertArrayNotHasKey('original_message', $first); + $this->assertArrayHasKey('error_type', $first); + $this->assertArrayHasKey('error_message', $first); + } + } + + // ========== 筛选 ========== + + public function test_list_filter_by_data_type(): void + { + $this->ensureTestData(); + + $response = $this->get('/api/v1/failed-messages', ['data_type' => 'order'], $this->authHeaders()); + + $response->assertStatus(200); + $items = $response->json('data.items'); + foreach ($items as $item) { + $this->assertSame('order', $item['data_type']); + } + } + + public function test_list_filter_by_platform_id(): void + { + $this->ensureTestData(); + + $response = $this->get('/api/v1/failed-messages', ['platform_id' => 1], $this->authHeaders()); + + $response->assertStatus(200); + $items = $response->json('data.items'); + foreach ($items as $item) { + $this->assertSame(1, $item['platform_id']); + } + } + + public function test_list_filter_by_failed_at_range(): void + { + $this->ensureTestData(); + + $response = $this->get('/api/v1/failed-messages', [ + 'failed_at_from' => '2026-03-01', + 'failed_at_to' => '2026-03-31', + ], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + // ========== 详情接口 ========== + + public function test_detail_contains_trace_and_original_message(): void + { + $id = $this->ensureTestData(); + + $response = $this->get("/api/v1/failed-messages/{$id}", [], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + $data = $response->json('data'); + $this->assertArrayHasKey('error_trace', $data); + $this->assertArrayHasKey('original_message', $data); + $this->assertArrayHasKey('error_type', $data); + $this->assertArrayHasKey('error_code', $data); + $this->assertSame('RuntimeException', $data['error_type']); + } + + public function test_detail_not_found_returns_404(): void + { + $response = $this->get('/api/v1/failed-messages/999999', [], $this->authHeaders()); + + $response->assertStatus(404); + $this->assertSame(404, $response->json('code')); + } + + // ========== 认证检查 ========== + + public function test_list_without_token_returns_401(): void + { + $response = $this->get('/api/v1/failed-messages'); + + $response->assertStatus(401); + } + + public function test_detail_without_token_returns_401(): void + { + $response = $this->get('/api/v1/failed-messages/1'); + + $response->assertStatus(401); + } +}