update permission and scope gate

This commit is contained in:
2026-03-13 09:07:42 +08:00
parent 26caea9a05
commit 63786e5876
7 changed files with 2244 additions and 0 deletions
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Middleware\PermissionMiddleware;
use App\Model\Company;
use App\Model\Store;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use OpenApi\Attributes as OA;
#[OA\Tag(name: 'Companies', description: '公司管理')]
#[Controller(prefix: "/api/v1/companies")]
class CompanyController extends AbstractController
{
/**
* 公司列表(受 scope 过滤)
*
* administrator 可见全部,developer/accessor 仅可见权限范围内的公司
*/
#[OA\Get(
path: '/companies',
summary: '公司列表',
description: '获取公司列表,支持按 name/label 模糊搜索,受 scope 过滤',
security: [['bearerAuth' => []]],
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,
];
}
}
@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Middleware\PermissionMiddleware;
use App\Model\Company;
use App\Model\Platform;
use App\Model\Store;
use App\Model\User;
use App\Model\UserDataScope;
use App\Service\ScopeBitmapService;
use App\Service\ScopeTableManager;
use Hyperf\DbConnection\Db;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
#[OA\Tag(name: 'DataScope', description: '用户数据范围管理')]
#[Controller(prefix: "/api/v1/users")]
class DataScopeController extends AbstractController
{
public function __construct(
protected readonly ScopeTableManager $scopeTableManager,
protected readonly ScopeBitmapService $bitmapService,
) {
}
/**
* 查看用户数据权限
*
* 返回用户的 scope 列表(含实体名称)和解析后的 store_ids
*
* @param int $id 用户 ID
*/
#[OA\Get(
path: '/users/{id}/data-scope',
summary: '查看用户数据权限',
description: '返回用户的 scope 列表(含实体名称)和解析后的 store_ids',
security: [['bearerAuth' => []]],
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);
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Middleware\PermissionMiddleware;
use App\Model\Platform;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use OpenApi\Attributes as OA;
#[OA\Tag(name: 'Platforms', description: '平台管理')]
#[Controller(prefix: "/api/v1/platforms")]
class PlatformController extends AbstractController
{
/**
* 平台列表(全局数据,不过滤)
*/
#[OA\Get(
path: '/platforms',
summary: '平台列表',
description: '获取全部平台列表(全局数据,不过滤)',
security: [['bearerAuth' => []]],
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,
];
}
}
@@ -0,0 +1,559 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Middleware\PermissionMiddleware;
use App\Model\Role;
use App\Model\RoleRouteOverride;
use App\Model\Route;
use App\Model\User;
use App\Service\ScopeTableManager;
use Hyperf\DbConnection\Db;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
use Qbhy\HyperfAuth\AuthManager;
#[OA\Tag(name: 'Roles', description: '角色与授权管理')]
#[Controller(prefix: "/api/v1/roles")]
class RoleController extends AbstractController
{
public function __construct(
protected readonly ScopeTableManager $scopeTableManager,
) {
}
/**
* 角色列表
*/
#[OA\Get(
path: '/roles',
summary: '角色列表',
description: '获取所有角色,包含每个角色的用户数',
security: [['bearerAuth' => []]],
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,
];
}
}
@@ -0,0 +1,562 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Middleware\PermissionMiddleware;
use App\Model\Route;
use App\Model\RouteGroup;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
#[OA\Tag(name: 'RouteGroups', description: '路由组管理')]
#[Controller(prefix: "/api/v1/route-groups")]
class RouteGroupController extends AbstractController
{
/**
* 路由组列表
*
* 返回所有路由组,包含每组的路由数量
*/
#[OA\Get(
path: '/route-groups',
summary: '路由组列表',
description: '返回所有路由组,包含每组的路由数量',
security: [['bearerAuth' => []]],
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 自动设为 NULLON DELETE SET NULL
* role_route_groups 关联自动级联删除(ON DELETE CASCADE
*
* @param int $id 路由组 ID
*/
#[OA\Delete(
path: '/route-groups/{id}',
summary: '删除路由组',
description: '删除路由组,组内路由 group_id 自动设为 NULLrole_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,
];
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Middleware\PermissionMiddleware;
use App\Model\Store;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use OpenApi\Attributes as OA;
#[OA\Tag(name: 'Stores', description: '店铺管理')]
#[Controller(prefix: "/api/v1/stores")]
class StoreController extends AbstractController
{
/**
* 店铺列表(受 scope 过滤,支持 company_id/platform_id 筛选)
*
* administrator 可见全部,developer/accessor 仅可见权限范围内的店铺
*/
#[OA\Get(
path: '/stores',
summary: '店铺列表',
description: '获取店铺列表,受 scope 过滤,支持 company_id/platform_id 筛选及 name 模糊搜索',
security: [['bearerAuth' => []]],
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,
];
}
}
@@ -0,0 +1,551 @@
<?php
declare(strict_types=1);
namespace HyperfTest\Cases\Integration\Permission;
use App\Model\Role;
use App\Model\RoleRouteOverride;
use App\Model\Route;
use App\Model\RouteGroup;
use App\Model\User;
use App\Model\UserDataScope;
use Hyperf\DbConnection\Db;
use HyperfTest\TestCase;
use Qbhy\HyperfAuth\AuthManager;
use function Hyperf\Support\make;
/**
* 权限管理 API 集成测试(P3.4)
*
* @internal
* @coversNothing
*/
class PermissionApiTest extends TestCase
{
// ========== Helpers ==========
protected function getAuthToken(User $user): string
{
$auth = make(AuthManager::class);
return $auth->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);
}
}