From 63786e5876de7dc882e24dddd94b994a24e56d78 Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Fri, 13 Mar 2026 09:07:42 +0800 Subject: [PATCH] update permission and scope gate --- .../Controller/api/v1/CompanyController.php | 102 ++++ .../Controller/api/v1/DataScopeController.php | 298 ++++++++++ .../Controller/api/v1/PlatformController.php | 63 ++ .../app/Controller/api/v1/RoleController.php | 559 +++++++++++++++++ .../api/v1/RouteGroupController.php | 562 ++++++++++++++++++ .../app/Controller/api/v1/StoreController.php | 109 ++++ .../Permission/PermissionApiTest.php | 551 +++++++++++++++++ 7 files changed, 2244 insertions(+) create mode 100644 backend/app/Controller/api/v1/CompanyController.php create mode 100644 backend/app/Controller/api/v1/DataScopeController.php create mode 100644 backend/app/Controller/api/v1/PlatformController.php create mode 100644 backend/app/Controller/api/v1/RoleController.php create mode 100644 backend/app/Controller/api/v1/RouteGroupController.php create mode 100644 backend/app/Controller/api/v1/StoreController.php create mode 100644 backend/test/Cases/Integration/Permission/PermissionApiTest.php diff --git a/backend/app/Controller/api/v1/CompanyController.php b/backend/app/Controller/api/v1/CompanyController.php new file mode 100644 index 0000000..c00ddc9 --- /dev/null +++ b/backend/app/Controller/api/v1/CompanyController.php @@ -0,0 +1,102 @@ + []]], + tags: ['Companies'], + parameters: [ + new OA\Parameter(name: 'name', in: 'query', required: false, description: '公司名称模糊搜索(匹配 name 或 label)', 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', type: 'array', items: new OA\Items(properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'acme'), + new OA\Property(property: 'label', type: 'string', example: '阿克米公司'), + new OA\Property(property: 'enabled', type: 'boolean', example: true), + new OA\Property(property: 'ext', type: 'object', nullable: true), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ])), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function index(): array + { + $scope_type = $this->request->getAttribute('scope_type'); + $scope_ids = $this->request->getAttribute('scope_ids', []); + + $query = Company::query(); + + // scope 过滤 + if ($scope_type === 'store') { + // 从 store_ids 反查 company_ids + $company_ids = Store::query() + ->whereIn('id', $scope_ids) + ->distinct() + ->pluck('company_id') + ->toArray(); + $query->whereIn('id', $company_ids); + } elseif ($scope_type === 'platform') { + $company_ids = Store::query() + ->whereIn('platform_id', $scope_ids) + ->distinct() + ->pluck('company_id') + ->toArray(); + $query->whereIn('id', $company_ids); + } + // 'all' → 不附加条件 + + // 按 name 模糊搜索 + $name = $this->request->input('name'); + if ($name !== null && $name !== '') { + $query->where(function ($q) use ($name): void { + $q->where('name', 'ilike', "%{$name}%") + ->orWhere('label', 'ilike', "%{$name}%"); + }); + } + + $companies = $query->orderBy('id')->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $companies, + ]; + } +} diff --git a/backend/app/Controller/api/v1/DataScopeController.php b/backend/app/Controller/api/v1/DataScopeController.php new file mode 100644 index 0000000..ac90695 --- /dev/null +++ b/backend/app/Controller/api/v1/DataScopeController.php @@ -0,0 +1,298 @@ + []]], + tags: ['DataScope'], + 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: 'user_id', type: 'integer', example: 1), + new OA\Property(property: 'role', type: 'string', example: 'admin'), + new OA\Property( + property: 'scopes', + type: 'array', + items: new OA\Items(properties: [ + new OA\Property(property: 'scope_type', type: 'string', example: 'company'), + new OA\Property(property: 'scope_id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: '示例公司'), + ], type: 'object') + ), + new OA\Property(property: 'resolved_store_ids', type: 'array', items: new OA\Items(type: 'integer'), example: [1, 2, 3]), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, 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}/data-scope", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function show(int $id): ResponseInterface|array + { + $user = User::query()->with('role')->find($id); + + if (!$user) { + return $this->response->json([ + 'code' => 404, + 'message' => '用户不存在', + ])->withStatus(404); + } + + $scopes = UserDataScope::query()->where('user_id', $id)->get(); + + // 批量查询实体名称,避免 N+1 + $scope_data = $this->enrichScopes($scopes->toArray()); + + // 解析最终 store_ids + $resolved_store_ids = []; + if ($user->role && $user->role->name !== 'administrator') { + $bitmap = $this->bitmapService->buildForUser($id); + $resolved_store_ids = $this->bitmapService->decode($bitmap); + } + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => [ + 'user_id' => $id, + 'role' => $user->role?->name, + 'scopes' => $scope_data, + 'resolved_store_ids' => $resolved_store_ids, + ], + ]; + } + + /** + * 设置用户数据权限 + * + * 全量替换用户的 scope 绑定,并重建 bitmap + * + * @param int $id 用户 ID + */ + #[OA\Put( + path: '/users/{id}/data-scope', + summary: '设置用户数据权限', + description: '全量替换用户的 scope 绑定,并重建 bitmap', + security: [['bearerAuth' => []]], + tags: ['DataScope'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '用户 ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['scopes'], + properties: [ + new OA\Property( + property: 'scopes', + type: 'array', + items: new OA\Items( + required: ['scope_type', 'scope_id'], + properties: [ + new OA\Property(property: 'scope_type', type: 'string', enum: ['company', 'platform', 'store'], example: 'company'), + new OA\Property(property: 'scope_id', type: 'integer', example: 1), + ], + type: 'object' + ) + ), + ] + ) + ), + 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\Response(response: 400, description: '参数校验失败(无效 scope_type 等)', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, 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}/data-scope", methods: "PUT")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function update(int $id): ResponseInterface|array + { + $user = User::query()->with('role')->find($id); + + if (!$user) { + return $this->response->json([ + 'code' => 404, + 'message' => '用户不存在', + ])->withStatus(404); + } + + $body = $this->request->getParsedBody(); + + if (!array_key_exists('scopes', $body)) { + return $this->response->json([ + 'code' => 400, + 'message' => '缺少 scopes 参数', + ])->withStatus(400); + } + + $scopes = $body['scopes']; + + if (!is_array($scopes)) { + return $this->response->json([ + 'code' => 400, + 'message' => 'scopes 必须为数组', + ])->withStatus(400); + } + + // 校验每条 scope + $valid_scope_types = ['company', 'platform', 'store']; + $records = []; + foreach ($scopes as $item) { + if (!is_array($item) || !isset($item['scope_type']) || !isset($item['scope_id'])) { + return $this->response->json([ + 'code' => 400, + 'message' => '每条 scope 必须包含 scope_type 和 scope_id', + ])->withStatus(400); + } + + if (!in_array($item['scope_type'], $valid_scope_types, true)) { + return $this->response->json([ + 'code' => 400, + 'message' => "scope_type 必须为 company、platform 或 store", + ])->withStatus(400); + } + + $records[] = [ + 'user_id' => $id, + 'scope_type' => $item['scope_type'], + 'scope_id' => (int) $item['scope_id'], + 'created_at' => date('Y-m-d H:i:s'), + ]; + } + + // 事务内全量替换 + Db::beginTransaction(); + try { + UserDataScope::query()->where('user_id', $id)->delete(); + + if (!empty($records)) { + UserDataScope::query()->insert($records); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollBack(); + throw $e; + } + + // 重建 bitmap 并更新 Swoole\Table + $this->scopeTableManager->rebuildUserScope($id); + + return [ + 'code' => 0, + 'message' => '数据权限更新成功', + ]; + } + + /** + * 批量查询 scope 关联的实体名称 + * + * @param array $scopes scope 记录数组 + * @return array 带名称的 scope 列表 + */ + protected function enrichScopes(array $scopes): array + { + if (empty($scopes)) { + return []; + } + + // 按 scope_type 分组收集 IDs + $company_ids = []; + $platform_ids = []; + $store_ids = []; + + foreach ($scopes as $scope) { + match ($scope['scope_type']) { + 'company' => $company_ids[] = $scope['scope_id'], + 'platform' => $platform_ids[] = $scope['scope_id'], + 'store' => $store_ids[] = $scope['scope_id'], + }; + } + + // 批量查询名称 + $company_names = !empty($company_ids) + ? Company::query()->whereIn('id', $company_ids)->pluck('label', 'id')->toArray() + : []; + $platform_names = !empty($platform_ids) + ? Platform::query()->whereIn('id', $platform_ids)->pluck('id', 'id')->toArray() + : []; + $store_names = !empty($store_ids) + ? Store::query()->whereIn('id', $store_ids)->pluck('label', 'id')->toArray() + : []; + + // 组装结果 + return array_map(function (array $scope) use ($company_names, $platform_names, $store_names): array { + $name = match ($scope['scope_type']) { + 'company' => $company_names[$scope['scope_id']] ?? null, + 'platform' => isset($platform_names[$scope['scope_id']]) ? "Platform #{$scope['scope_id']}" : null, + 'store' => $store_names[$scope['scope_id']] ?? null, + }; + + return [ + 'scope_type' => $scope['scope_type'], + 'scope_id' => $scope['scope_id'], + 'name' => $name, + ]; + }, $scopes); + } +} diff --git a/backend/app/Controller/api/v1/PlatformController.php b/backend/app/Controller/api/v1/PlatformController.php new file mode 100644 index 0000000..ca15a17 --- /dev/null +++ b/backend/app/Controller/api/v1/PlatformController.php @@ -0,0 +1,63 @@ + []]], + tags: ['Platforms'], + 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', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'developer_id', type: 'integer', example: 1), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], + type: 'object' + )), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function index(): array + { + $platforms = Platform::query()->orderBy('id')->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $platforms, + ]; + } +} diff --git a/backend/app/Controller/api/v1/RoleController.php b/backend/app/Controller/api/v1/RoleController.php new file mode 100644 index 0000000..f566f01 --- /dev/null +++ b/backend/app/Controller/api/v1/RoleController.php @@ -0,0 +1,559 @@ + []]], + tags: ['Roles'], + 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', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'editor'), + new OA\Property(property: 'users_count', type: 'integer', example: 5), + ], + type: 'object' + )), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function index(): array + { + $roles = Role::query() + ->withCount('users') + ->orderBy('id') + ->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $roles, + ]; + } + + /** + * 分配用户角色 + * + * administrator 不允许降级自己的角色(防止锁死) + * 分配角色后触发 bitmap 重建 + * + * @param int $id 用户 ID + */ + #[OA\Put( + path: '/users/{id}/role', + summary: '分配用户角色', + description: '为指定用户分配角色,administrator 不允许降级自己的角色', + security: [['bearerAuth' => []]], + tags: ['Roles'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '用户 ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['role_id'], + properties: [ + new OA\Property(property: 'role_id', type: 'integer', description: '角色 ID', example: 2), + ] + ) + ), + 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/User'), + ]) + ), + new OA\Response(response: 400, description: '不允许降级自己的管理员角色', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, 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: "/api/v1/users/{id}/role", methods: "PUT")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function assignRole(int $id, AuthManager $auth): ResponseInterface|array + { + $target_user = User::query()->with('role')->find($id); + + if (!$target_user) { + return $this->response->json([ + 'code' => 404, + 'message' => '用户不存在', + ])->withStatus(404); + } + + $body = $this->request->getParsedBody(); + + if (!isset($body['role_id'])) { + return $this->response->json([ + 'code' => 400, + 'message' => '缺少 role_id 参数', + ])->withStatus(400); + } + + $role_id = (int) $body['role_id']; + $new_role = Role::query()->find($role_id); + + if (!$new_role) { + return $this->response->json([ + 'code' => 404, + 'message' => '角色不存在', + ])->withStatus(404); + } + + // 防止 administrator 降级自己 + $current_user = $auth->guard('jwt')->user(); + if ($current_user instanceof User + && $current_user->id === $id + && $target_user->isAdministrator() + && $new_role->name !== 'administrator' + ) { + return $this->response->json([ + 'code' => 400, + 'message' => '不允许降级自己的管理员角色', + ])->withStatus(400); + } + + $target_user->role_id = $role_id; + $target_user->save(); + + // 角色变化影响 scope 计算,重建 bitmap + $this->scopeTableManager->rebuildUserScope($id); + + $target_user->refresh(); + $target_user->load('role'); + + return [ + 'code' => 0, + 'message' => '角色分配成功', + 'data' => $target_user, + ]; + } + + /** + * 查看角色已授权的路由组 + * + * @param int $id 角色 ID + */ + #[OA\Get( + path: '/roles/{id}/route-groups', + summary: '查看角色已授权的路由组', + description: '获取指定角色已授权的路由组列表', + security: [['bearerAuth' => []]], + tags: ['Roles'], + 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', type: 'array', items: new OA\Items(type: 'object')), + ]) + ), + new OA\Response(response: 401, 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}/route-groups", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function getRouteGroups(int $id): ResponseInterface|array + { + $role = Role::query()->find($id); + + if (!$role) { + return $this->response->json([ + 'code' => 404, + 'message' => '角色不存在', + ])->withStatus(404); + } + + $groups = $role->routeGroups()->withCount('routes')->orderBy('sort_order')->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $groups, + ]; + } + + /** + * 设置角色的路由组授权(全量替换) + * + * 请求体:{ "group_ids": [1, 3, 5] } + * + * @param int $id 角色 ID + */ + #[OA\Put( + path: '/roles/{id}/route-groups', + summary: '设置角色的路由组授权', + description: '全量替换指定角色的路由组授权,administrator 角色不允许修改', + security: [['bearerAuth' => []]], + tags: ['Roles'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '角色 ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['group_ids'], + properties: [ + new OA\Property(property: 'group_ids', type: 'array', items: new OA\Items(type: 'integer'), example: [1, 3, 5]), + ] + ) + ), + 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', type: 'array', items: new OA\Items(type: 'object')), + ]) + ), + new OA\Response(response: 400, description: 'administrator 角色不允许修改或参数错误', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, 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}/route-groups", methods: "PUT")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function setRouteGroups(int $id): ResponseInterface|array + { + $role = Role::query()->find($id); + + if (!$role) { + return $this->response->json([ + 'code' => 404, + 'message' => '角色不存在', + ])->withStatus(404); + } + + // 不允许修改 administrator 角色的路由授权(administrator 拥有全部权限) + if ($role->name === 'administrator') { + return $this->response->json([ + 'code' => 400, + 'message' => 'administrator 角色拥有全部权限,无需设置路由组授权', + ])->withStatus(400); + } + + $body = $this->request->getParsedBody(); + + if (!array_key_exists('group_ids', $body)) { + return $this->response->json([ + 'code' => 400, + 'message' => '缺少 group_ids 参数', + ])->withStatus(400); + } + + $group_ids = $body['group_ids']; + + if (!is_array($group_ids)) { + return $this->response->json([ + 'code' => 400, + 'message' => 'group_ids 必须为数组', + ])->withStatus(400); + } + + // 过滤并校验 group_ids + $group_ids = array_map('intval', $group_ids); + $group_ids = array_unique($group_ids); + + // 使用 sync 全量替换 + $role->routeGroups()->sync($group_ids); + + // 返回更新后的路由组列表 + $groups = $role->routeGroups()->withCount('routes')->orderBy('sort_order')->get(); + + return [ + 'code' => 0, + 'message' => '路由组授权更新成功', + 'data' => $groups, + ]; + } + + /** + * 查看角色的单条路由覆盖 + * + * @param int $id 角色 ID + */ + #[OA\Get( + path: '/roles/{id}/route-overrides', + summary: '查看角色的路由覆盖', + description: '获取指定角色的路由覆盖列表,包含路由详情', + security: [['bearerAuth' => []]], + tags: ['Roles'], + 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', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'role_id', type: 'integer'), + new OA\Property(property: 'route_id', type: 'integer'), + new OA\Property(property: 'allowed', type: 'boolean'), + new OA\Property(property: 'route', type: 'object'), + ], + type: 'object' + )), + ]) + ), + new OA\Response(response: 401, 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}/route-overrides", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function getRouteOverrides(int $id): ResponseInterface|array + { + $role = Role::query()->find($id); + + if (!$role) { + return $this->response->json([ + 'code' => 404, + 'message' => '角色不存在', + ])->withStatus(404); + } + + $overrides = RoleRouteOverride::query() + ->where('role_id', $id) + ->with('route') + ->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $overrides, + ]; + } + + /** + * 设置角色的单条路由覆盖(全量替换) + * + * 请求体:{ "overrides": [{ "route_id": 12, "allowed": false }, ...] } + * + * @param int $id 角色 ID + */ + #[OA\Put( + path: '/roles/{id}/route-overrides', + summary: '设置角色的路由覆盖', + description: '全量替换指定角色的路由覆盖规则,administrator 角色不允许修改', + security: [['bearerAuth' => []]], + tags: ['Roles'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '角色 ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['overrides'], + properties: [ + new OA\Property( + property: 'overrides', + type: 'array', + items: new OA\Items( + required: ['route_id', 'allowed'], + properties: [ + new OA\Property(property: 'route_id', type: 'integer', description: '路由 ID', example: 12), + new OA\Property(property: 'allowed', type: 'boolean', description: '是否允许', example: false), + ], + type: 'object' + ) + ), + ] + ) + ), + 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', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'role_id', type: 'integer'), + new OA\Property(property: 'route_id', type: 'integer'), + new OA\Property(property: 'allowed', type: 'boolean'), + new OA\Property(property: 'route', type: 'object'), + ], + type: 'object' + )), + ]) + ), + new OA\Response(response: 400, description: 'route_id 重复、不存在或 administrator 角色不允许修改', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, 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}/route-overrides", methods: "PUT")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function setRouteOverrides(int $id): ResponseInterface|array + { + $role = Role::query()->find($id); + + if (!$role) { + return $this->response->json([ + 'code' => 404, + 'message' => '角色不存在', + ])->withStatus(404); + } + + // 不允许修改 administrator 角色的路由覆盖 + if ($role->name === 'administrator') { + return $this->response->json([ + 'code' => 400, + 'message' => 'administrator 角色拥有全部权限,无需设置路由覆盖', + ])->withStatus(400); + } + + $body = $this->request->getParsedBody(); + + if (!array_key_exists('overrides', $body)) { + return $this->response->json([ + 'code' => 400, + 'message' => '缺少 overrides 参数', + ])->withStatus(400); + } + + $overrides = $body['overrides']; + + if (!is_array($overrides)) { + return $this->response->json([ + 'code' => 400, + 'message' => 'overrides 必须为数组', + ])->withStatus(400); + } + + // 校验每条 override 格式 + $records = []; + $seen_route_ids = []; + foreach ($overrides as $item) { + if (!is_array($item) || !isset($item['route_id']) || !isset($item['allowed'])) { + return $this->response->json([ + 'code' => 400, + 'message' => '每条覆盖规则必须包含 route_id 和 allowed 字段', + ])->withStatus(400); + } + + $route_id = (int) $item['route_id']; + + // 检查重复 + if (in_array($route_id, $seen_route_ids, true)) { + return $this->response->json([ + 'code' => 400, + 'message' => "route_id {$route_id} 重复", + ])->withStatus(400); + } + $seen_route_ids[] = $route_id; + + $records[] = [ + 'role_id' => $id, + 'route_id' => $route_id, + 'allowed' => (bool) $item['allowed'], + ]; + } + + // 校验 route_ids 都存在 + if (!empty($seen_route_ids)) { + $existing_count = Route::query()->whereIn('id', $seen_route_ids)->count(); + if ($existing_count !== count($seen_route_ids)) { + return $this->response->json([ + 'code' => 400, + 'message' => '包含不存在的 route_id', + ])->withStatus(400); + } + } + + // 事务内全量替换 + Db::beginTransaction(); + try { + RoleRouteOverride::query()->where('role_id', $id)->delete(); + + if (!empty($records)) { + RoleRouteOverride::query()->insert($records); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollBack(); + throw $e; + } + + // 返回更新后的覆盖列表 + $result = RoleRouteOverride::query() + ->where('role_id', $id) + ->with('route') + ->get(); + + return [ + 'code' => 0, + 'message' => '路由覆盖更新成功', + 'data' => $result, + ]; + } +} diff --git a/backend/app/Controller/api/v1/RouteGroupController.php b/backend/app/Controller/api/v1/RouteGroupController.php new file mode 100644 index 0000000..6e75696 --- /dev/null +++ b/backend/app/Controller/api/v1/RouteGroupController.php @@ -0,0 +1,562 @@ + []]], + tags: ['RouteGroups'], + 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', type: 'array', items: new OA\Items(properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'user-management'), + new OA\Property(property: 'label', type: 'string', nullable: true, example: '用户管理'), + new OA\Property(property: 'description', type: 'string', nullable: true, example: '用户相关路由'), + new OA\Property(property: 'sort_order', type: 'integer', example: 0), + new OA\Property(property: 'routes_count', type: 'integer', example: 5), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ])), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function index(): array + { + $groups = RouteGroup::query() + ->withCount('routes') + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $groups, + ]; + } + + /** + * 创建路由组 + */ + #[OA\Post( + path: '/route-groups', + summary: '创建路由组', + security: [['bearerAuth' => []]], + tags: ['RouteGroups'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['name'], + properties: [ + new OA\Property(property: 'name', type: 'string', maxLength: 100, example: 'user-management'), + new OA\Property(property: 'label', type: 'string', maxLength: 200, nullable: true, example: '用户管理'), + new OA\Property(property: 'description', type: 'string', nullable: true, example: '用户相关路由'), + new OA\Property(property: 'sort_order', type: 'integer', default: 0, example: 0), + ] + ) + ), + 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', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'user-management'), + new OA\Property(property: 'label', type: 'string', nullable: true, example: '用户管理'), + new OA\Property(property: 'description', type: 'string', nullable: true), + new OA\Property(property: 'sort_order', type: 'integer', example: 0), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 400, description: '参数校验失败或名称重复', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "POST")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function store(): ResponseInterface|array + { + $name = $this->request->input('name'); + $label = $this->request->input('label'); + $description = $this->request->input('description'); + $sort_order = $this->request->input('sort_order'); + + // 校验 name + if (!is_string($name) || trim($name) === '') { + return $this->response->json([ + 'code' => 400, + 'message' => 'name 不能为空', + ])->withStatus(400); + } + + $name = trim($name); + if (strlen($name) > 100) { + return $this->response->json([ + 'code' => 400, + 'message' => 'name 长度不能超过 100 个字符', + ])->withStatus(400); + } + + if (RouteGroup::query()->where('name', $name)->exists()) { + return $this->response->json([ + 'code' => 400, + 'message' => '路由组名称已存在', + ])->withStatus(400); + } + + // 校验 label + if ($label !== null && $label !== '') { + if (!is_string($label)) { + return $this->response->json([ + 'code' => 400, + 'message' => 'label 必须为字符串', + ])->withStatus(400); + } + $label = trim($label); + if (strlen($label) > 200) { + return $this->response->json([ + 'code' => 400, + 'message' => 'label 长度不能超过 200 个字符', + ])->withStatus(400); + } + } else { + $label = null; + } + + // 校验 sort_order + if ($sort_order !== null && $sort_order !== '') { + if (!is_numeric($sort_order)) { + return $this->response->json([ + 'code' => 400, + 'message' => 'sort_order 必须为整数', + ])->withStatus(400); + } + $sort_order = (int) $sort_order; + } else { + $sort_order = 0; + } + + $group = RouteGroup::query()->create([ + 'name' => $name, + 'label' => $label, + 'description' => is_string($description) ? trim($description) : null, + 'sort_order' => $sort_order, + ]); + + return [ + 'code' => 0, + 'message' => '创建成功', + 'data' => $group, + ]; + } + + /** + * 更新路由组 + * + * @param int $id 路由组 ID + */ + #[OA\Put( + path: '/route-groups/{id}', + summary: '更新路由组', + security: [['bearerAuth' => []]], + tags: ['RouteGroups'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '路由组 ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'name', type: 'string', maxLength: 100, example: 'user-management'), + new OA\Property(property: 'label', type: 'string', maxLength: 200, nullable: true, example: '用户管理'), + new OA\Property(property: 'description', type: 'string', nullable: true, example: '用户相关路由'), + new OA\Property(property: 'sort_order', type: 'integer', example: 0), + ]) + ), + 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', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'user-management'), + new OA\Property(property: 'label', type: 'string', nullable: true, example: '用户管理'), + new OA\Property(property: 'description', type: 'string', nullable: true), + new OA\Property(property: 'sort_order', type: 'integer', example: 0), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 400, description: '参数校验失败或名称重复', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, 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: "PUT")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function update(int $id): ResponseInterface|array + { + $group = RouteGroup::query()->find($id); + + if (!$group) { + return $this->response->json([ + 'code' => 404, + 'message' => '路由组不存在', + ])->withStatus(404); + } + + $name = $this->request->input('name'); + $label = $this->request->input('label'); + $description = $this->request->input('description'); + $sort_order = $this->request->input('sort_order'); + $updates = []; + + if ($name !== null) { + if (!is_string($name) || trim($name) === '') { + return $this->response->json([ + 'code' => 400, + 'message' => 'name 不能为空', + ])->withStatus(400); + } + $name = trim($name); + if (strlen($name) > 100) { + return $this->response->json([ + 'code' => 400, + 'message' => 'name 长度不能超过 100 个字符', + ])->withStatus(400); + } + if (RouteGroup::query()->where('name', $name)->where('id', '!=', $group->id)->exists()) { + return $this->response->json([ + 'code' => 400, + 'message' => '路由组名称已存在', + ])->withStatus(400); + } + $updates['name'] = $name; + } + + if ($label !== null) { + if (!is_string($label)) { + return $this->response->json([ + 'code' => 400, + 'message' => 'label 必须为字符串', + ])->withStatus(400); + } + $updates['label'] = trim($label); + } + + if ($description !== null) { + $updates['description'] = is_string($description) ? trim($description) : null; + } + + if ($sort_order !== null) { + if (!is_numeric($sort_order)) { + return $this->response->json([ + 'code' => 400, + 'message' => 'sort_order 必须为整数', + ])->withStatus(400); + } + $updates['sort_order'] = (int) $sort_order; + } + + if ($updates === []) { + return $this->response->json([ + 'code' => 400, + 'message' => '缺少可更新字段', + ])->withStatus(400); + } + + $group->fill($updates); + $group->save(); + $group->refresh(); + + return [ + 'code' => 0, + 'message' => '更新成功', + 'data' => $group, + ]; + } + + /** + * 删除路由组 + * + * 组内路由 group_id 自动设为 NULL(ON DELETE SET NULL) + * role_route_groups 关联自动级联删除(ON DELETE CASCADE) + * + * @param int $id 路由组 ID + */ + #[OA\Delete( + path: '/route-groups/{id}', + summary: '删除路由组', + description: '删除路由组,组内路由 group_id 自动设为 NULL,role_route_groups 关联自动级联删除', + security: [['bearerAuth' => []]], + tags: ['RouteGroups'], + 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\Response(response: 401, 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: "DELETE")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function destroy(int $id): ResponseInterface|array + { + $group = RouteGroup::query()->find($id); + + if (!$group) { + return $this->response->json([ + 'code' => 404, + 'message' => '路由组不存在', + ])->withStatus(404); + } + + $group->delete(); + + return [ + 'code' => 0, + 'message' => '删除成功', + ]; + } + + /** + * 全部路由列表 + * + * 含分组信息,支持按 group_id 筛选(传 0 或 "ungrouped" 筛选未分组路由) + */ + #[OA\Get( + path: '/routes', + summary: '路由列表', + description: '获取全部路由列表,含分组信息,支持按 group_id、method、path 筛选', + security: [['bearerAuth' => []]], + tags: ['RouteGroups'], + parameters: [ + new OA\Parameter(name: 'group_id', in: 'query', required: false, description: '路由组 ID,传 0 或 "ungrouped" 筛选未分组路由', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'method', in: 'query', required: false, description: 'HTTP 方法筛选', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'path', 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', type: 'array', items: new OA\Items(properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'method', type: 'string', example: 'GET'), + new OA\Property(property: 'path', type: 'string', example: '/api/v1/users'), + new OA\Property(property: 'group_id', type: 'integer', nullable: true, example: 1), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'group', nullable: true, properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'user-management'), + new OA\Property(property: 'label', type: 'string', nullable: true, example: '用户管理'), + ], type: 'object'), + ])), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "/api/v1/routes", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function routes(): array + { + $query = Route::query()->with('group'); + + // 按 group_id 筛选 + $group_id = $this->request->input('group_id'); + if ($group_id !== null && $group_id !== '') { + if ($group_id === '0' || $group_id === 'ungrouped') { + // 未分组路由 + $query->whereNull('group_id'); + } else { + $query->where('group_id', (int) $group_id); + } + } + + // 按 method 筛选 + $method = $this->request->input('method'); + if ($method !== null && $method !== '') { + $query->where('method', strtoupper($method)); + } + + // 按 path 模糊搜索 + $path = $this->request->input('path'); + if ($path !== null && $path !== '') { + $query->where('path', 'like', '%' . $path . '%'); + } + + $routes = $query->orderBy('path')->orderBy('method')->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $routes, + ]; + } + + /** + * 将路由分配到路由组 + * + * 传 group_id=null 表示从分组中移出 + * + * @param int $id 路由 ID + */ + #[OA\Put( + path: '/routes/{id}/group', + summary: '分配路由到路由组', + description: '将路由分配到指定路由组,传 group_id=null 表示从分组中移出', + security: [['bearerAuth' => []]], + tags: ['RouteGroups'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '路由 ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['group_id'], + properties: [ + new OA\Property(property: 'group_id', type: 'integer', nullable: true, description: '路由组 ID,传 null 移出分组', example: 1), + ] + ) + ), + 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', example: 1), + new OA\Property(property: 'method', type: 'string', example: 'GET'), + new OA\Property(property: 'path', type: 'string', example: '/api/v1/users'), + new OA\Property(property: 'group_id', type: 'integer', nullable: true, example: 1), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'group', nullable: true, properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'user-management'), + new OA\Property(property: 'label', type: 'string', nullable: true, example: '用户管理'), + ], type: 'object'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 400, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, 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: "/api/v1/routes/{id}/group", methods: "PUT")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function assignRouteGroup(int $id): ResponseInterface|array + { + $route = Route::query()->find($id); + + if (!$route) { + return $this->response->json([ + 'code' => 404, + 'message' => '路由不存在', + ])->withStatus(404); + } + + // group_id 可以为 null(移出分组)或整数(分配到指定分组) + $body = $this->request->getParsedBody(); + if (!array_key_exists('group_id', $body)) { + return $this->response->json([ + 'code' => 400, + 'message' => '缺少 group_id 参数', + ])->withStatus(400); + } + + $group_id = $body['group_id']; + + if ($group_id !== null) { + if (!is_numeric($group_id)) { + return $this->response->json([ + 'code' => 400, + 'message' => 'group_id 必须为整数或 null', + ])->withStatus(400); + } + + $group_id = (int) $group_id; + + // 校验目标路由组存在 + if (!RouteGroup::query()->where('id', $group_id)->exists()) { + return $this->response->json([ + 'code' => 404, + 'message' => '目标路由组不存在', + ])->withStatus(404); + } + } + + $route->group_id = $group_id; + $route->save(); + $route->load('group'); + + return [ + 'code' => 0, + 'message' => $group_id === null ? '已从分组中移出' : '分配成功', + 'data' => $route, + ]; + } +} diff --git a/backend/app/Controller/api/v1/StoreController.php b/backend/app/Controller/api/v1/StoreController.php new file mode 100644 index 0000000..8a0f5fd --- /dev/null +++ b/backend/app/Controller/api/v1/StoreController.php @@ -0,0 +1,109 @@ + []]], + tags: ['Stores'], + parameters: [ + 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: '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', type: 'array', items: new OA\Items(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: 1), + new OA\Property(property: 'platform_store_id', type: 'string', example: 'SHOP-001'), + new OA\Property(property: 'name', type: 'string', example: 'my-store'), + new OA\Property(property: 'label', type: 'string', example: '我的店铺'), + new OA\Property(property: 'enabled', type: 'boolean', example: true), + new OA\Property(property: 'warehouse_id', type: 'integer', example: 1), + new OA\Property(property: 'currency_id', type: 'integer', example: 1), + new OA\Property(property: 'timezone', type: 'integer', example: 8), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], type: 'object')), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function index(): array + { + $scope_type = $this->request->getAttribute('scope_type'); + $scope_ids = $this->request->getAttribute('scope_ids', []); + + $query = Store::query(); + + // scope 过滤 + if ($scope_type === 'store') { + $query->whereIn('id', $scope_ids); + } elseif ($scope_type === 'platform') { + $query->whereIn('platform_id', $scope_ids); + } + // 'all' → 不附加条件 + + // 按 company_id 筛选 + $company_id = $this->request->input('company_id'); + if ($company_id !== null && $company_id !== '') { + $query->where('company_id', (int) $company_id); + } + + // 按 platform_id 筛选 + $platform_id = $this->request->input('platform_id'); + if ($platform_id !== null && $platform_id !== '') { + $query->where('platform_id', (int) $platform_id); + } + + // 按 name 模糊搜索 + $name = $this->request->input('name'); + if ($name !== null && $name !== '') { + $query->where(function ($q) use ($name): void { + $q->where('name', 'ilike', "%{$name}%") + ->orWhere('label', 'ilike', "%{$name}%"); + }); + } + + $stores = $query->orderBy('id')->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $stores, + ]; + } +} diff --git a/backend/test/Cases/Integration/Permission/PermissionApiTest.php b/backend/test/Cases/Integration/Permission/PermissionApiTest.php new file mode 100644 index 0000000..18b227e --- /dev/null +++ b/backend/test/Cases/Integration/Permission/PermissionApiTest.php @@ -0,0 +1,551 @@ +guard('jwt')->login($user); + } + + protected function authHeaders(User $user): array + { + return ['Authorization' => 'Bearer ' . $this->getAuthToken($user)]; + } + + protected function createTestUser(string $role_name, array $overrides = []): User + { + $role = Role::query()->where('name', $role_name)->firstOrFail(); + $suffix = bin2hex(random_bytes(4)); + + return User::query()->create(array_merge([ + 'username' => 'perm_api_' . $suffix, + 'email' => 'perm_api_' . $suffix . '@example.com', + 'password' => 'Pass_' . $suffix, + 'status' => 1, + 'role_id' => $role->id, + ], $overrides)); + } + + protected function getAdmin(): User + { + $admin_role = Role::query()->where('name', 'administrator')->firstOrFail(); + $user = User::query()->where('status', 1)->where('role_id', $admin_role->id)->first(); + if (!$user) { + $user = $this->createTestUser('administrator'); + } + return $user; + } + + // ========== 路由组 CRUD ========== + + public function test_admin_can_list_route_groups(): void + { + $admin = $this->getAdmin(); + + $response = $this->get('/api/v1/route-groups', [], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_admin_can_create_route_group(): void + { + $admin = $this->getAdmin(); + $name = 'test_group_' . bin2hex(random_bytes(4)); + + $response = $this->post('/api/v1/route-groups', [ + 'name' => $name, + 'label' => '测试路由组', + 'description' => '测试用', + 'sort_order' => 10, + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonPath('data.name', $name); + $response->assertJsonPath('data.sort_order', 10); + + // 清理 + $body = json_decode($response->getContent(), true); + RouteGroup::query()->find($body['data']['id'])?->delete(); + } + + public function test_create_route_group_duplicate_name_returns_400(): void + { + $admin = $this->getAdmin(); + $name = 'dup_group_' . bin2hex(random_bytes(4)); + + $group = RouteGroup::query()->create(['name' => $name, 'label' => 'dup']); + + try { + $response = $this->post('/api/v1/route-groups', [ + 'name' => $name, + 'label' => '重复', + ], $this->authHeaders($admin)); + + $response->assertStatus(400); + } finally { + $group->delete(); + } + } + + public function test_admin_can_update_route_group(): void + { + $admin = $this->getAdmin(); + $group = RouteGroup::query()->create([ + 'name' => 'upd_group_' . bin2hex(random_bytes(4)), + 'label' => '原始', + ]); + + try { + $response = $this->put('/api/v1/route-groups/' . $group->id, [ + 'label' => '已更新', + 'sort_order' => 5, + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonPath('data.label', '已更新'); + $response->assertJsonPath('data.sort_order', 5); + } finally { + $group->delete(); + } + } + + public function test_admin_can_delete_route_group(): void + { + $admin = $this->getAdmin(); + $group = RouteGroup::query()->create([ + 'name' => 'del_group_' . bin2hex(random_bytes(4)), + 'label' => '待删除', + ]); + + $response = $this->delete('/api/v1/route-groups/' . $group->id, [], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + $this->assertNull(RouteGroup::query()->find($group->id)); + } + + public function test_delete_nonexistent_group_returns_404(): void + { + $admin = $this->getAdmin(); + + $response = $this->delete('/api/v1/route-groups/999999', [], $this->authHeaders($admin)); + $response->assertStatus(404); + } + + // ========== 路由列表与分组分配 ========== + + public function test_admin_can_list_routes(): void + { + $admin = $this->getAdmin(); + + $response = $this->get('/api/v1/routes', [], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_admin_can_filter_ungrouped_routes(): void + { + $admin = $this->getAdmin(); + + $response = $this->get('/api/v1/routes', ['group_id' => 'ungrouped'], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + // 验证返回的路由都没有 group_id + $body = json_decode($response->getContent(), true); + foreach ($body['data'] as $route) { + $this->assertNull($route['group_id']); + } + } + + public function test_admin_can_assign_route_to_group(): void + { + $admin = $this->getAdmin(); + + $route = Route::query()->first(); + if (!$route) { + $this->markTestSkipped('routes 表中无数据'); + } + + $group = RouteGroup::query()->create([ + 'name' => 'assign_grp_' . bin2hex(random_bytes(4)), + 'label' => '分配测试', + ]); + + $old_group_id = $route->group_id; + + try { + $response = $this->put('/api/v1/routes/' . $route->id . '/group', [ + 'group_id' => $group->id, + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonPath('data.group_id', $group->id); + } finally { + $route->group_id = $old_group_id; + $route->save(); + $group->delete(); + } + } + + public function test_admin_can_remove_route_from_group(): void + { + $admin = $this->getAdmin(); + + $route = Route::query()->whereNotNull('group_id')->first(); + if (!$route) { + $this->markTestSkipped('routes 表中无已分组路由'); + } + + $old_group_id = $route->group_id; + + try { + $response = $this->put('/api/v1/routes/' . $route->id . '/group', [ + 'group_id' => null, + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('data.group_id', null); + } finally { + $route->group_id = $old_group_id; + $route->save(); + } + } + + // ========== 角色路由授权 ========== + + public function test_admin_can_get_role_route_groups(): void + { + $admin = $this->getAdmin(); + $role = Role::query()->where('name', 'developer')->firstOrFail(); + + $response = $this->get('/api/v1/roles/' . $role->id . '/route-groups', [], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_admin_can_set_role_route_groups(): void + { + $admin = $this->getAdmin(); + $role = Role::query()->where('name', 'developer')->firstOrFail(); + + $group = RouteGroup::query()->create([ + 'name' => 'auth_grp_' . bin2hex(random_bytes(4)), + 'label' => '授权测试', + ]); + + try { + $response = $this->put('/api/v1/roles/' . $role->id . '/route-groups', [ + 'group_ids' => [$group->id], + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + $body = json_decode($response->getContent(), true); + $returned_ids = array_column($body['data'], 'id'); + $this->assertContains($group->id, $returned_ids); + } finally { + // 清理授权 + $role->routeGroups()->detach($group->id); + $group->delete(); + } + } + + public function test_set_admin_route_groups_returns_400(): void + { + $admin = $this->getAdmin(); + $admin_role = Role::query()->where('name', 'administrator')->firstOrFail(); + + $response = $this->put('/api/v1/roles/' . $admin_role->id . '/route-groups', [ + 'group_ids' => [1], + ], $this->authHeaders($admin)); + + $response->assertStatus(400); + } + + public function test_admin_can_get_role_route_overrides(): void + { + $admin = $this->getAdmin(); + $role = Role::query()->where('name', 'accessor')->firstOrFail(); + + $response = $this->get('/api/v1/roles/' . $role->id . '/route-overrides', [], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_admin_can_set_role_route_overrides(): void + { + $admin = $this->getAdmin(); + $role = Role::query()->where('name', 'accessor')->firstOrFail(); + $route = Route::query()->first(); + + if (!$route) { + $this->markTestSkipped('routes 表中无数据'); + } + + try { + $response = $this->put('/api/v1/roles/' . $role->id . '/route-overrides', [ + 'overrides' => [ + ['route_id' => $route->id, 'allowed' => true], + ], + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + // 验证数据库 + $override = RoleRouteOverride::query() + ->where('role_id', $role->id) + ->where('route_id', $route->id) + ->first(); + $this->assertNotNull($override); + $this->assertTrue($override->allowed); + } finally { + RoleRouteOverride::query() + ->where('role_id', $role->id) + ->where('route_id', $route->id) + ->delete(); + } + } + + public function test_set_route_overrides_duplicate_route_id_returns_400(): void + { + $admin = $this->getAdmin(); + $role = Role::query()->where('name', 'accessor')->firstOrFail(); + $route = Route::query()->first(); + + if (!$route) { + $this->markTestSkipped('routes 表中无数据'); + } + + $response = $this->put('/api/v1/roles/' . $role->id . '/route-overrides', [ + 'overrides' => [ + ['route_id' => $route->id, 'allowed' => true], + ['route_id' => $route->id, 'allowed' => false], + ], + ], $this->authHeaders($admin)); + + $response->assertStatus(400); + } + + // ========== 用户数据范围 ========== + + public function test_admin_can_get_user_data_scope(): void + { + $admin = $this->getAdmin(); + $user = $this->createTestUser('accessor'); + + $response = $this->get('/api/v1/users/' . $user->id . '/data-scope', [], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + $body = json_decode($response->getContent(), true); + $this->assertSame($user->id, $body['data']['user_id']); + $this->assertArrayHasKey('scopes', $body['data']); + $this->assertArrayHasKey('resolved_store_ids', $body['data']); + } + + public function test_admin_can_set_user_data_scope(): void + { + $admin = $this->getAdmin(); + $user = $this->createTestUser('accessor'); + + try { + $response = $this->put('/api/v1/users/' . $user->id . '/data-scope', [ + 'scopes' => [ + ['scope_type' => 'company', 'scope_id' => 1], + ['scope_type' => 'store', 'scope_id' => 1], + ], + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + // 验证数据库 + $scopes = UserDataScope::query()->where('user_id', $user->id)->get(); + $this->assertCount(2, $scopes); + } finally { + UserDataScope::query()->where('user_id', $user->id)->delete(); + } + } + + public function test_set_data_scope_with_empty_array_clears_scopes(): void + { + $admin = $this->getAdmin(); + $user = $this->createTestUser('accessor'); + + // 先设置一些 scope + UserDataScope::query()->create([ + 'user_id' => $user->id, + 'scope_type' => 'store', + 'scope_id' => 1, + ]); + + $response = $this->put('/api/v1/users/' . $user->id . '/data-scope', [ + 'scopes' => [], + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + + $scopes = UserDataScope::query()->where('user_id', $user->id)->get(); + $this->assertCount(0, $scopes); + } + + public function test_set_data_scope_invalid_scope_type_returns_400(): void + { + $admin = $this->getAdmin(); + $user = $this->createTestUser('accessor'); + + $response = $this->put('/api/v1/users/' . $user->id . '/data-scope', [ + 'scopes' => [ + ['scope_type' => 'invalid', 'scope_id' => 1], + ], + ], $this->authHeaders($admin)); + + $response->assertStatus(400); + } + + // ========== 角色管理 ========== + + public function test_admin_can_list_roles(): void + { + $admin = $this->getAdmin(); + + $response = $this->get('/api/v1/roles', [], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + $body = json_decode($response->getContent(), true); + $this->assertGreaterThanOrEqual(3, count($body['data'])); // administrator, developer, accessor + } + + public function test_admin_can_assign_role(): void + { + $admin = $this->getAdmin(); + $user = $this->createTestUser('accessor'); + $developer_role = Role::query()->where('name', 'developer')->firstOrFail(); + + $old_role_id = $user->role_id; + + try { + $response = $this->put('/api/v1/users/' . $user->id . '/role', [ + 'role_id' => $developer_role->id, + ], $this->authHeaders($admin)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonPath('data.role_id', $developer_role->id); + } finally { + $user->role_id = $old_role_id; + $user->save(); + } + } + + public function test_admin_cannot_downgrade_self(): void + { + $admin = $this->getAdmin(); + $accessor_role = Role::query()->where('name', 'accessor')->firstOrFail(); + + $response = $this->put('/api/v1/users/' . $admin->id . '/role', [ + 'role_id' => $accessor_role->id, + ], $this->authHeaders($admin)); + + $response->assertStatus(400); + } + + public function test_assign_nonexistent_role_returns_404(): void + { + $admin = $this->getAdmin(); + $user = $this->createTestUser('accessor'); + + $response = $this->put('/api/v1/users/' . $user->id . '/role', [ + 'role_id' => 999999, + ], $this->authHeaders($admin)); + + $response->assertStatus(404); + } + + // ========== 基础数据接口 ========== + + public function test_admin_can_get_companies(): void + { + $admin = $this->getAdmin(); + + $response = $this->get('/api/v1/companies', [], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_admin_can_get_platforms(): void + { + $admin = $this->getAdmin(); + + $response = $this->get('/api/v1/platforms', [], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_admin_can_get_stores(): void + { + $admin = $this->getAdmin(); + + $response = $this->get('/api/v1/stores', [], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_stores_support_company_filter(): void + { + $admin = $this->getAdmin(); + + $response = $this->get('/api/v1/stores', ['company_id' => 1], $this->authHeaders($admin)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + // ========== 认证拦截 ========== + + public function test_unauthenticated_returns_401(): void + { + $response = $this->get('/api/v1/route-groups'); + $response->assertStatus(401); + + $response = $this->get('/api/v1/roles'); + $response->assertStatus(401); + + $response = $this->get('/api/v1/companies'); + $response->assertStatus(401); + } +}