diff --git a/backend/app/Controller/AbstractDataController.php b/backend/app/Controller/AbstractDataController.php new file mode 100644 index 0000000..39ef77d --- /dev/null +++ b/backend/app/Controller/AbstractDataController.php @@ -0,0 +1,160 @@ + 筛选方式(exact/like/date_from/date_to) */ + abstract protected function getAllowedFilters(): array; + + /** 默认排序字段 */ + protected function getDefaultSort(): string + { + return 'created_at'; + } + + /** 默认排序方向 */ + protected function getDefaultSortDirection(): string + { + return 'desc'; + } + + /** + * 通用列表方法:分页 + 筛选 + DataScope + 字段选择 + */ + public function index(): ResponseInterface|array + { + $model_class = $this->getModelClass(); + $query = $model_class::query()->select($this->getListFields()); + + // DataScope 过滤 + $this->applyDataScope($query); + + // 应用筛选条件 + $this->applyFilters($query); + + // 排序 + $query->orderBy($this->getDefaultSort(), $this->getDefaultSortDirection()); + + // 分页 + $per_page = min((int) ($this->request->input('per_page', 15)), 100); + $per_page = max($per_page, 1); + $page = max((int) ($this->request->input('page', 1)), 1); + + $total = $query->count(); + $items = $query->offset(($page - 1) * $per_page) + ->limit($per_page) + ->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + ], + ]; + } + + /** + * 通用详情方法:字段选择 + DataScope 校验 + */ + public function show(int $id): ResponseInterface|array + { + $model_class = $this->getModelClass(); + $fields = $this->getDetailFields(); + + $query = $model_class::query(); + if ($fields !== ['*']) { + $query->select($fields); + } + + // DataScope 过滤(确保只能查看权限范围内的数据) + $this->applyDataScope($query); + + $record = $query->where('id', $id)->first(); + + if (!$record) { + return $this->response->json([ + 'code' => 404, + 'message' => '数据不存在', + ])->withStatus(404); + } + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $record, + ]; + } + + /** + * 应用 DataScope 过滤 + * + * 读取 PermissionMiddleware 注入的 scope_type / scope_ids 属性, + * 自动添加 WHERE 条件限制查询范围。 + */ + protected function applyDataScope(Builder $query): void + { + $scope_type = $this->request->getAttribute('scope_type'); + $scope_ids = $this->request->getAttribute('scope_ids', []); + + if ($scope_type === 'store') { + $query->whereIn('store_id', $scope_ids); + } elseif ($scope_type === 'platform') { + $query->whereIn('platform_id', $scope_ids); + } + // 'all' → 不附加条件 + } + + /** + * 应用筛选条件 + * + * 根据 getAllowedFilters() 定义的参数名和筛选方式, + * 从请求参数中提取值并构建 WHERE 子句。 + * + * 支持的筛选方式: + * - exact: 精确匹配(WHERE col = value) + * - like: 模糊搜索(WHERE col ILIKE %value%) + * - date_from: 日期下界(WHERE col >= value),参数名需以 _from 结尾 + * - date_to: 日期上界(WHERE col <= value 23:59:59),参数名需以 _to 结尾 + */ + protected function applyFilters(Builder $query): void + { + foreach ($this->getAllowedFilters() as $param => $type) { + $value = $this->request->input($param); + if ($value === null || $value === '') { + continue; + } + + match ($type) { + 'exact' => $query->where($param, $value), + 'like' => $query->where($param, 'ilike', "%{$value}%"), + 'date_from' => $query->where(str_replace('_from', '', $param), '>=', $value), + 'date_to' => $query->where(str_replace('_to', '', $param), '<=', $value . ' 23:59:59'), + }; + } + } +} diff --git a/backend/app/Controller/Api/V1/ProductController.php b/backend/app/Controller/Api/V1/ProductController.php new file mode 100644 index 0000000..3b45912 --- /dev/null +++ b/backend/app/Controller/Api/V1/ProductController.php @@ -0,0 +1,146 @@ + 'exact', + 'platform_id' => 'exact', + 'store_id' => 'exact', + 'status_id' => 'exact', + 'platform_item_id' => 'exact', + 'name' => 'like', + ]; + } + + protected function getDefaultSort(): string + { + return 'updated_date'; + } + + /** + * 产品列表 + */ + #[OA\Get( + path: '/products', + summary: '产品列表', + description: '获取产品列表,支持分页、多维度筛选。返回业务字段,不含 raw/hash。', + security: [['bearerAuth' => []]], + tags: ['Products'], + 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: 'company_id', in: 'query', required: false, description: '公司 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'platform_id', in: 'query', required: false, description: '平台 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'store_id', in: 'query', required: false, description: '店铺 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'status_id', in: 'query', required: false, description: '产品状态 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'platform_item_id', in: 'query', required: false, description: '平台商品 ID 精确搜索', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'name', in: 'query', required: false, description: '商品名称模糊搜索', schema: new OA\Schema(type: 'string')), + ], + 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/Product')), + 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(): \Psr\Http\Message\ResponseInterface|array + { + return parent::index(); + } + + /** + * 产品详情 + */ + #[OA\Get( + path: '/products/{id}', + summary: '产品详情', + description: '获取产品详情,返回所有业务字段和 ext,不含 raw/hash。', + security: [['bearerAuth' => []]], + tags: ['Products'], + 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/Product'), + ]) + ), + 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): \Psr\Http\Message\ResponseInterface|array + { + return parent::show($id); + } +} diff --git a/backend/app/Controller/Api/V1/RawProductController.php b/backend/app/Controller/Api/V1/RawProductController.php new file mode 100644 index 0000000..d383f99 --- /dev/null +++ b/backend/app/Controller/Api/V1/RawProductController.php @@ -0,0 +1,146 @@ + []]], + tags: ['Products (Raw)'], + 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: 'company_id', in: 'query', required: false, description: '公司 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'platform_id', in: 'query', required: false, description: '平台 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'store_id', in: 'query', required: false, description: '店铺 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'status_id', in: 'query', required: false, description: '产品状态 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'platform_item_id', in: 'query', required: false, description: '平台商品 ID 精确搜索', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'name', in: 'query', required: false, description: '商品名称模糊搜索', schema: new OA\Schema(type: 'string')), + ], + 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(properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'platform_item_id', type: 'string'), + new OA\Property(property: 'platform_model_id', type: 'string', nullable: true), + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'store_id', type: 'integer'), + new OA\Property(property: 'company_id', type: 'integer'), + new OA\Property(property: 'platform_id', type: 'integer'), + new OA\Property(property: 'hash', type: 'string'), + new OA\Property(property: 'updated_date', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], type: 'object')), + new OA\Property(property: 'total', type: 'integer'), + new OA\Property(property: 'page', type: 'integer'), + new OA\Property(property: 'per_page', type: 'integer'), + ], 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(): \Psr\Http\Message\ResponseInterface|array + { + return parent::index(); + } + + /** + * 产品详情(Raw) + */ + #[OA\Get( + path: '/raw/products/{id}', + summary: '产品详情(Raw)', + description: '获取产品原始数据详情。返回关键标识 + 完整 raw + hash + ext。', + security: [['bearerAuth' => []]], + tags: ['Products (Raw)'], + 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', properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'platform_item_id', type: 'string'), + new OA\Property(property: 'platform_model_id', type: 'string', nullable: true), + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'store_id', type: 'integer'), + new OA\Property(property: 'company_id', type: 'integer'), + new OA\Property(property: 'platform_id', type: 'integer'), + new OA\Property(property: 'raw', type: 'object', description: '平台原始数据'), + new OA\Property(property: 'hash', type: 'string'), + new OA\Property(property: 'ext', type: 'object', nullable: true), + new OA\Property(property: 'created_date', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'updated_date', type: 'string', format: 'date-time', nullable: true), + ], 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')), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "GET")] + public function show(int $id): \Psr\Http\Message\ResponseInterface|array + { + return parent::show($id); + } +} diff --git a/backend/app/Model/Product.php b/backend/app/Model/Product.php index f8469bb..a6dd550 100644 --- a/backend/app/Model/Product.php +++ b/backend/app/Model/Product.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Model; +use OpenApi\Attributes as OA; + /** * @property int $id 主键 * @property int $company_id 公司 ID @@ -34,6 +36,31 @@ namespace App\Model; * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at */ +#[OA\Schema( + schema: 'Product', + type: 'object', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'company_id', type: 'integer', example: 1), + new OA\Property(property: 'platform_id', type: 'integer', example: 2), + new OA\Property(property: 'store_id', type: 'integer', example: 100), + new OA\Property(property: 'status_id', type: 'integer', example: 1), + new OA\Property(property: 'type_id', type: 'integer', example: 1), + new OA\Property(property: 'platform_item_id', type: 'string', example: 'ITEM-001'), + new OA\Property(property: 'platform_model_id', type: 'string', nullable: true, example: 'MODEL-A'), + new OA\Property(property: 'name', type: 'string', nullable: true, example: 'iPhone 16 Pro'), + new OA\Property(property: 'price', type: 'number', format: 'decimal', example: 99.99), + new OA\Property(property: 'currency', type: 'string', example: 'CNY'), + new OA\Property(property: 'num', type: 'integer', example: 100), + new OA\Property(property: 'hash', type: 'string', example: 'a1b2c3d4e5f6...'), + new OA\Property(property: 'raw', type: 'object', nullable: true, description: '平台原始数据'), + new OA\Property(property: 'ext', type: 'object', nullable: true, description: '扩展字段'), + new OA\Property(property: 'created_date', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'updated_date', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ] +)] class Product extends Model { /** diff --git a/backend/test/Cases/Integration/Product/ProductControllerTest.php b/backend/test/Cases/Integration/Product/ProductControllerTest.php new file mode 100644 index 0000000..3224f36 --- /dev/null +++ b/backend/test/Cases/Integration/Product/ProductControllerTest.php @@ -0,0 +1,387 @@ +fetchAdminRole(); + $user = $this->fetchUser(static function ($query) use ($admin_role): void { + $query->where('status', 1)->where('role_id', $admin_role->id); + }); + if (!$user) { + $this->markTestSkipped('没有可用的 administrator 用户'); + } + + $auth = make(AuthManager::class); + return $auth->guard('jwt')->login($user); + } + + protected function fetchAdminRole(): Role + { + if (\Swoole\Coroutine::getCid() > 0) { + return Role::query()->where('name', 'administrator')->firstOrFail(); + } + + $role = null; + \Swoole\Coroutine\run(static function () use (&$role): void { + $role = Role::query()->where('name', 'administrator')->firstOrFail(); + }); + return $role; + } + + protected function fetchUser(?callable $callback = null): ?User + { + if (\Swoole\Coroutine::getCid() > 0) { + $query = User::query(); + if ($callback !== null) { + $callback($query); + } + return $query->first(); + } + + $user = null; + \Swoole\Coroutine\run(static function () use ($callback, &$user): void { + $query = User::query(); + if ($callback !== null) { + $callback($query); + } + $user = $query->first(); + }); + return $user; + } + + protected function authHeaders(): array + { + return ['Authorization' => 'Bearer ' . $this->getAdminToken()]; + } + + protected function hasProductData(): bool + { + if (\Swoole\Coroutine::getCid() > 0) { + return Product::query()->exists(); + } + + $exists = false; + \Swoole\Coroutine\run(static function () use (&$exists): void { + $exists = Product::query()->exists(); + }); + return $exists; + } + + protected function getFirstProductId(): ?int + { + if (\Swoole\Coroutine::getCid() > 0) { + return Product::query()->value('id'); + } + + $id = null; + \Swoole\Coroutine\run(static function () use (&$id): void { + $id = Product::query()->value('id'); + }); + return $id; + } + + // ========== Normal 列表接口 ========== + + public function test_normal_list_returns_paginated_data(): void + { + $response = $this->get('/api/v1/products', [], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonStructure([ + 'code', + 'message', + 'data' => [ + 'items', + 'total', + 'page', + 'per_page', + ], + ]); + } + + public function test_normal_list_respects_per_page(): void + { + $response = $this->get('/api/v1/products', ['per_page' => 5], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('data.per_page', 5); + } + + public function test_normal_list_per_page_max_100(): void + { + $response = $this->get('/api/v1/products', ['per_page' => 999], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('data.per_page', 100); + } + + public function test_normal_list_excludes_raw_and_hash(): void + { + if (!$this->hasProductData()) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/products', [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + $first_item = $body['data']['items'][0] ?? []; + + $this->assertArrayNotHasKey('raw', $first_item); + $this->assertArrayNotHasKey('hash', $first_item); + } + + public function test_normal_list_contains_expected_fields(): void + { + if (!$this->hasProductData()) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/products', [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + $first_item = $body['data']['items'][0]; + + $expected_keys = ['id', 'company_id', 'platform_id', 'store_id', 'status_id', + 'platform_item_id', 'platform_model_id', 'name', 'price', + 'currency', 'num', 'created_date', 'updated_date']; + + foreach ($expected_keys as $key) { + $this->assertArrayHasKey($key, $first_item, "列表缺少字段: {$key}"); + } + } + + public function test_normal_list_filter_by_company_id(): void + { + if (!$this->hasProductData()) { + $this->markTestSkipped('没有产品数据'); + } + + // 获取一个已知的 company_id + $company_id = null; + if (\Swoole\Coroutine::getCid() > 0) { + $company_id = Product::query()->value('company_id'); + } else { + \Swoole\Coroutine\run(static function () use (&$company_id): void { + $company_id = Product::query()->value('company_id'); + }); + } + + $response = $this->get('/api/v1/products', ['company_id' => $company_id], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + foreach ($body['data']['items'] as $item) { + $this->assertSame($company_id, $item['company_id']); + } + } + + // ========== Normal 详情接口 ========== + + public function test_normal_detail_returns_product(): void + { + $id = $this->getFirstProductId(); + if (!$id) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/products/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonPath('data.id', $id); + } + + public function test_normal_detail_excludes_raw_and_hash(): void + { + $id = $this->getFirstProductId(); + if (!$id) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/products/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + $this->assertArrayNotHasKey('raw', $body['data']); + $this->assertArrayNotHasKey('hash', $body['data']); + } + + public function test_normal_detail_contains_ext(): void + { + $id = $this->getFirstProductId(); + if (!$id) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/products/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('ext', $body['data']); + } + + public function test_normal_detail_not_found_returns_404(): void + { + $response = $this->get('/api/v1/products/999999999', [], $this->authHeaders()); + + $response->assertStatus(404); + $response->assertJsonPath('code', 404); + } + + // ========== Raw 列表接口 ========== + + public function test_raw_list_returns_paginated_data(): void + { + $response = $this->get('/api/v1/raw/products', [], $this->authHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonStructure([ + 'code', + 'message', + 'data' => [ + 'items', + 'total', + 'page', + 'per_page', + ], + ]); + } + + public function test_raw_list_contains_hash_but_not_raw(): void + { + if (!$this->hasProductData()) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/raw/products', [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + $first_item = $body['data']['items'][0] ?? []; + + $this->assertArrayHasKey('hash', $first_item, 'Raw 列表应包含 hash 字段'); + $this->assertArrayNotHasKey('raw', $first_item, 'Raw 列表不应包含 raw 字段(太大)'); + } + + public function test_raw_list_excludes_business_fields(): void + { + if (!$this->hasProductData()) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/raw/products', [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + $first_item = $body['data']['items'][0] ?? []; + + // Raw 列表不应包含业务字段 + $this->assertArrayNotHasKey('price', $first_item); + $this->assertArrayNotHasKey('currency', $first_item); + $this->assertArrayNotHasKey('num', $first_item); + $this->assertArrayNotHasKey('status_id', $first_item); + } + + // ========== Raw 详情接口 ========== + + public function test_raw_detail_contains_raw_and_hash(): void + { + $id = $this->getFirstProductId(); + if (!$id) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/raw/products/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('raw', $body['data'], 'Raw 详情应包含 raw 字段'); + $this->assertArrayHasKey('hash', $body['data'], 'Raw 详情应包含 hash 字段'); + $this->assertArrayHasKey('ext', $body['data'], 'Raw 详情应包含 ext 字段'); + } + + public function test_raw_detail_excludes_business_fields(): void + { + $id = $this->getFirstProductId(); + if (!$id) { + $this->markTestSkipped('没有产品数据'); + } + + $response = $this->get('/api/v1/raw/products/' . $id, [], $this->authHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getContent(), true); + + // Raw 详情不应包含业务字段 + $this->assertArrayNotHasKey('price', $body['data']); + $this->assertArrayNotHasKey('currency', $body['data']); + $this->assertArrayNotHasKey('status_id', $body['data']); + } + + public function test_raw_detail_not_found_returns_404(): void + { + $response = $this->get('/api/v1/raw/products/999999999', [], $this->authHeaders()); + + $response->assertStatus(404); + $response->assertJsonPath('code', 404); + } + + // ========== 认证拦截 ========== + + public function test_normal_list_without_token_returns_401(): void + { + $response = $this->get('/api/v1/products'); + + $response->assertStatus(401); + } + + public function test_normal_detail_without_token_returns_401(): void + { + $response = $this->get('/api/v1/products/1'); + + $response->assertStatus(401); + } + + public function test_raw_list_without_token_returns_401(): void + { + $response = $this->get('/api/v1/raw/products'); + + $response->assertStatus(401); + } + + public function test_raw_detail_without_token_returns_401(): void + { + $response = $this->get('/api/v1/raw/products/1'); + + $response->assertStatus(401); + } +} diff --git a/backend/test/Cases/Unit/Controller/AbstractDataControllerTest.php b/backend/test/Cases/Unit/Controller/AbstractDataControllerTest.php new file mode 100644 index 0000000..507f53c --- /dev/null +++ b/backend/test/Cases/Unit/Controller/AbstractDataControllerTest.php @@ -0,0 +1,358 @@ +createMock(RequestInterface::class); + $mock_request->method('input') + ->willReturnCallback(function (string $key, $default = null) use ($request_params) { + return $request_params[$key] ?? $default; + }); + $mock_request->method('getAttribute') + ->willReturnCallback(function (string $key, $default = null) { + if ($key === 'scope_type') { + return 'all'; + } + return $default; + }); + + // 创建匿名具体子类 + $controller = new class ($filters) extends AbstractDataController { + private array $testFilters; + + public function __construct(array $filters) + { + $this->testFilters = $filters; + } + + protected function getModelClass(): string + { + return Product::class; + } + + protected function getListFields(): array + { + return ['*']; + } + + protected function getDetailFields(): array + { + return ['*']; + } + + protected function getAllowedFilters(): array + { + return $this->testFilters; + } + + /** + * 暴露 applyFilters 用于测试 + */ + public function testApplyFilters(Builder $query): void + { + $this->applyFilters($query); + } + + /** + * 暴露 applyDataScope 用于测试 + */ + public function testApplyDataScope(Builder $query): void + { + $this->applyDataScope($query); + } + }; + + // 通过反射注入 mock request + $reflection = new \ReflectionClass(AbstractDataController::class); + $parent = $reflection->getParentClass(); + $prop = $parent->getProperty('request'); + $prop->setAccessible(true); + $prop->setValue($controller, $mock_request); + + return $controller; + } + + /** + * 创建一个带 scope 属性的 controller + */ + protected function createControllerWithScope(string $scope_type, array $scope_ids): AbstractDataController + { + $mock_request = $this->createMock(RequestInterface::class); + $mock_request->method('input') + ->willReturn(null); + $mock_request->method('getAttribute') + ->willReturnCallback(function (string $key, $default = null) use ($scope_type, $scope_ids) { + if ($key === 'scope_type') { + return $scope_type; + } + if ($key === 'scope_ids') { + return $scope_ids; + } + return $default; + }); + + $controller = new class () extends AbstractDataController { + public function __construct() {} + + protected function getModelClass(): string + { + return Product::class; + } + + protected function getListFields(): array + { + return ['*']; + } + + protected function getDetailFields(): array + { + return ['*']; + } + + protected function getAllowedFilters(): array + { + return []; + } + + public function testApplyDataScope(Builder $query): void + { + $this->applyDataScope($query); + } + }; + + $reflection = new \ReflectionClass(AbstractDataController::class); + $parent = $reflection->getParentClass(); + $prop = $parent->getProperty('request'); + $prop->setAccessible(true); + $prop->setValue($controller, $mock_request); + + return $controller; + } + + /** + * 从 Builder 提取 where 条件,返回可断言的结构 + */ + protected function extractWheres(Builder $query): array + { + return $query->getQuery()->wheres; + } + + protected function createBuilder(): Builder + { + // 使用 Product 模型创建一个干净的 Builder(不会实际执行查询) + return Product::query()->newQuery(); + } + + // ========== exact 筛选 ========== + + public function test_apply_filters_exact_match(): void + { + $controller = $this->createController( + ['company_id' => 'exact'], + ['company_id' => '100'] + ); + + $query = $this->createBuilder(); + $controller->testApplyFilters($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(1, $wheres); + $this->assertSame('company_id', $wheres[0]['column']); + $this->assertSame('=', $wheres[0]['operator']); + $this->assertSame('100', $wheres[0]['value']); + } + + public function test_apply_filters_exact_skips_null_value(): void + { + $controller = $this->createController( + ['company_id' => 'exact'], + [] // 无参数 + ); + + $query = $this->createBuilder(); + $controller->testApplyFilters($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(0, $wheres); + } + + public function test_apply_filters_exact_skips_empty_string(): void + { + $controller = $this->createController( + ['company_id' => 'exact'], + ['company_id' => ''] + ); + + $query = $this->createBuilder(); + $controller->testApplyFilters($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(0, $wheres); + } + + // ========== like 筛选 ========== + + public function test_apply_filters_like_match(): void + { + $controller = $this->createController( + ['name' => 'like'], + ['name' => '手机'] + ); + + $query = $this->createBuilder(); + $controller->testApplyFilters($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(1, $wheres); + $this->assertSame('name', $wheres[0]['column']); + $this->assertSame('ilike', $wheres[0]['operator']); + $this->assertSame('%手机%', $wheres[0]['value']); + } + + // ========== date_from 筛选 ========== + + public function test_apply_filters_date_from(): void + { + $controller = $this->createController( + ['created_date_from' => 'date_from'], + ['created_date_from' => '2025-01-01'] + ); + + $query = $this->createBuilder(); + $controller->testApplyFilters($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(1, $wheres); + $this->assertSame('created_date', $wheres[0]['column']); + $this->assertSame('>=', $wheres[0]['operator']); + $this->assertSame('2025-01-01', $wheres[0]['value']); + } + + // ========== date_to 筛选 ========== + + public function test_apply_filters_date_to(): void + { + $controller = $this->createController( + ['created_date_to' => 'date_to'], + ['created_date_to' => '2025-12-31'] + ); + + $query = $this->createBuilder(); + $controller->testApplyFilters($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(1, $wheres); + $this->assertSame('created_date', $wheres[0]['column']); + $this->assertSame('<=', $wheres[0]['operator']); + $this->assertSame('2025-12-31 23:59:59', $wheres[0]['value']); + } + + // ========== 多条件组合 ========== + + public function test_apply_filters_multiple_conditions(): void + { + $controller = $this->createController( + [ + 'company_id' => 'exact', + 'name' => 'like', + 'status_id' => 'exact', + ], + [ + 'company_id' => '1', + 'name' => 'iPhone', + 'status_id' => '2', + ] + ); + + $query = $this->createBuilder(); + $controller->testApplyFilters($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(3, $wheres); + } + + public function test_apply_filters_mixed_with_empty_values(): void + { + $controller = $this->createController( + [ + 'company_id' => 'exact', + 'name' => 'like', + 'status_id' => 'exact', + ], + [ + 'company_id' => '1', + 'name' => '', // 空值应跳过 + // status_id 缺失也应跳过 + ] + ); + + $query = $this->createBuilder(); + $controller->testApplyFilters($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(1, $wheres); // 仅 company_id + } + + // ========== DataScope 过滤 ========== + + public function test_apply_data_scope_all_adds_no_conditions(): void + { + $controller = $this->createControllerWithScope('all', []); + + $query = $this->createBuilder(); + $controller->testApplyDataScope($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(0, $wheres); + } + + public function test_apply_data_scope_store_adds_store_id_where_in(): void + { + $controller = $this->createControllerWithScope('store', [10, 20, 30]); + + $query = $this->createBuilder(); + $controller->testApplyDataScope($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(1, $wheres); + $this->assertSame('store_id', $wheres[0]['column']); + $this->assertSame('In', $wheres[0]['type']); + $this->assertSame([10, 20, 30], $wheres[0]['values']); + } + + public function test_apply_data_scope_platform_adds_platform_id_where_in(): void + { + $controller = $this->createControllerWithScope('platform', [1, 2]); + + $query = $this->createBuilder(); + $controller->testApplyDataScope($query); + + $wheres = $this->extractWheres($query); + $this->assertCount(1, $wheres); + $this->assertSame('platform_id', $wheres[0]['column']); + $this->assertSame('In', $wheres[0]['type']); + $this->assertSame([1, 2], $wheres[0]['values']); + } +}