feat: permissions CRUD browser integration + E2E enhancements

Backend:
- permission_handler: 完善权限 CRUD 接口(列表/创建/更新/删除)
- auth_handler: 修复认证处理逻辑
- router: 新增权限管理路由
- handler_test: 新增权限 handler 测试覆盖

Frontend:
- permissions.ts/test.ts: 权限服务层完整实现
- profile/settings/service_tests: 服务适配器修正
- client.ts: HTTP 客户端健壮性增强
- vite.config.js: 构建配置优化
- E2E 脚本: run-playwright-cdp-e2e 大幅增强(权限流程覆盖)

Docs:
- REAL_PROJECT_STATUS: 状态更新
- PRODUCTION_CHECKLIST/QUALITY_STANDARD/TECHNICAL_GUIDE/PROJECT_EXPERIENCE_SUMMARY: 团队规范完善
- plans/2026-04-23: 权限浏览器 CRUD 设计方案

验证: go build 0错误
This commit is contained in:
2026-04-24 07:30:18 +08:00
parent 3f3bb82f1d
commit 9b1cea246e
25 changed files with 1868 additions and 133 deletions

View File

@@ -104,18 +104,19 @@ function Get-BrowserArguments {
$arguments = @(
"--remote-debugging-port=$Port",
"--user-data-dir=$ProfileDir",
'--no-sandbox'
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-sync',
'--disable-gpu'
)
if (Test-HeadlessShellBrowser -BrowserPath $BrowserPath) {
$arguments += '--single-process'
} else {
$arguments += @(
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-sync',
'--headless=new'
)
}

View File

@@ -103,6 +103,28 @@ function Wait-UrlReady {
throw "$Label did not become ready: $Url"
}
function Sync-AdminBootstrapExpectation {
param(
[Parameter(Mandatory = $true)][string]$BackendBaseUrl
)
$capabilitiesUrl = "$BackendBaseUrl/api/v1/auth/capabilities"
$response = Invoke-RestMethod -Uri $capabilitiesUrl -Method Get -TimeoutSec 15
$requiresBootstrap = $false
if ($response -and $response.data -and $null -ne $response.data.admin_bootstrap_required) {
$requiresBootstrap = [bool]$response.data.admin_bootstrap_required
}
if ($requiresBootstrap) {
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
} else {
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
}
Write-Host "playwright e2e admin bootstrap expected: $requiresBootstrap"
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
@@ -280,7 +302,6 @@ try {
$env:E2E_LOGIN_PASSWORD = $AdminPassword
$env:E2E_LOGIN_EMAIL = $AdminEmail
$env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
$env:E2E_EXTERNAL_WEB_SERVER = '1'
$env:E2E_BASE_URL = $frontendBaseUrl
$env:E2E_API_BASE_URL = "$backendBaseUrl/api/v1"
@@ -289,7 +310,7 @@ try {
Push-Location $frontendRoot
try {
$lastError = $null
$suiteAttempts = 2
$suiteAttempts = 3
if ($env:E2E_SUITE_ATTEMPTS) {
$parsedSuiteAttempts = 0
if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) {
@@ -299,6 +320,7 @@ try {
for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) {
try {
Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')

File diff suppressed because it is too large Load Diff

View File

@@ -269,6 +269,31 @@ describe('http client', () => {
})
})
it('uses the current non-expired access token even when another refresh is still in flight', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules()
setAccessToken('still-valid-access-token', 3600)
startRefreshing()
setRefreshPromise(new Promise(() => {}))
const requestPromise = get('/protected')
await Promise.resolve()
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'Bearer still-valid-access-token',
})
await expect(requestPromise).resolves.toEqual({ ok: true })
})
it('clears the local session when refresh fails before the business request is sent', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }))

View File

@@ -188,6 +188,10 @@ async function resolveAuthorizationHeader(auth: boolean): Promise<string | null>
}
let token = getAccessToken()
if (token && !isAccessTokenExpired()) {
return token
}
if (isRefreshing()) {
const promise = getRefreshPromise()
if (promise) {

View File

@@ -22,7 +22,7 @@ describe('permissions service', () => {
it('gets permission tree', async () => {
const mockTree = [
{ id: 1, name: 'dashboard', children: [{ id: 2, name: 'view' }] },
{ id: 1, name: 'dashboard', type: 0, children: [{ id: 2, name: 'view', type: 2 }] },
]
getMock.mockResolvedValue(mockTree)
@@ -30,13 +30,15 @@ describe('permissions service', () => {
const result = await getPermissionTree()
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
expect(result).toEqual(mockTree)
expect(result).toEqual([
{ id: 1, name: 'dashboard', type: 'menu', children: [{ id: 2, name: 'view', type: 'api' }] },
])
})
it('lists all permissions', async () => {
const mockPermissions = [
{ id: 1, name: 'view dashboard', code: 'dashboard:view' },
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit' },
{ id: 1, name: 'view dashboard', code: 'dashboard:view', type: 0 },
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit', type: 1 },
]
getMock.mockResolvedValue(mockPermissions)
@@ -44,40 +46,46 @@ describe('permissions service', () => {
const result = await listPermissions()
expect(getMock).toHaveBeenCalledWith('/permissions')
expect(result).toEqual(mockPermissions)
expect(result).toEqual([
{ id: 1, name: 'view dashboard', code: 'dashboard:view', type: 'menu' },
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit', type: 'button' },
])
})
it('gets a single permission', async () => {
getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view' })
getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view', type: 2 })
const { getPermission } = await import('./permissions')
const result = await getPermission(5)
expect(getMock).toHaveBeenCalledWith('/permissions/5')
expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view' })
expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view', type: 'api' })
})
it('creates a permission', async () => {
const newPermission = { name: 'new permission', code: 'new:code', type: 'button' as const }
const created = { id: 10, ...newPermission }
const created = { id: 10, ...newPermission, type: 1 }
postMock.mockResolvedValue(created)
const { createPermission } = await import('./permissions')
const result = await createPermission(newPermission)
expect(postMock).toHaveBeenCalledWith('/permissions', newPermission)
expect(result).toEqual(created)
expect(postMock).toHaveBeenCalledWith('/permissions', {
...newPermission,
type: 1,
})
expect(result).toEqual({ id: 10, name: 'new permission', code: 'new:code', type: 'button' })
})
it('updates a permission', async () => {
const updateData = { name: 'updated name' }
putMock.mockResolvedValue({ id: 3, ...updateData })
putMock.mockResolvedValue({ id: 3, ...updateData, type: 0 })
const { updatePermission } = await import('./permissions')
const result = await updatePermission(3, updateData)
expect(putMock).toHaveBeenCalledWith('/permissions/3', updateData)
expect(result).toEqual({ id: 3, name: 'updated name' })
expect(result).toEqual({ id: 3, name: 'updated name', type: 'menu' })
})
it('deletes a permission', async () => {

View File

@@ -5,14 +5,58 @@
*/
import { get, post, put, del } from '@/lib/http/client'
import type { Permission, CreatePermissionRequest, UpdatePermissionRequest } from '@/types/permission'
import type {
Permission,
CreatePermissionRequest,
UpdatePermissionRequest,
PermissionType,
} from '@/types/permission'
type RawPermissionType = 0 | 1 | 2
interface RawPermission extends Omit<Permission, 'type' | 'children'> {
type: RawPermissionType
children?: RawPermission[]
}
function normalizePermissionType(type: RawPermissionType): PermissionType {
switch (type) {
case 0:
return 'menu'
case 1:
return 'button'
case 2:
return 'api'
default:
return 'api'
}
}
function serializePermissionType(type: PermissionType): RawPermissionType {
switch (type) {
case 'menu':
return 0
case 'button':
return 1
case 'api':
return 2
}
}
function normalizePermission(permission: RawPermission): Permission {
return {
...permission,
type: normalizePermissionType(permission.type),
children: permission.children?.map(normalizePermission),
}
}
/**
* 获取权限树
* GET /api/v1/permissions/tree
*/
export function getPermissionTree(): Promise<Permission[]> {
return get<Permission[]>('/permissions/tree')
return get<RawPermission[]>('/permissions/tree').then((permissions) => permissions.map(normalizePermission))
}
/**
@@ -20,7 +64,7 @@ export function getPermissionTree(): Promise<Permission[]> {
* GET /api/v1/permissions
*/
export function listPermissions(): Promise<Permission[]> {
return get<Permission[]>('/permissions')
return get<RawPermission[]>('/permissions').then((permissions) => permissions.map(normalizePermission))
}
/**
@@ -28,7 +72,7 @@ export function listPermissions(): Promise<Permission[]> {
* GET /api/v1/permissions/:id
*/
export function getPermission(id: number): Promise<Permission> {
return get<Permission>(`/permissions/${id}`)
return get<RawPermission>(`/permissions/${id}`).then(normalizePermission)
}
/**
@@ -36,7 +80,10 @@ export function getPermission(id: number): Promise<Permission> {
* POST /api/v1/permissions
*/
export function createPermission(data: CreatePermissionRequest): Promise<Permission> {
return post<Permission>('/permissions', data)
return post<RawPermission>('/permissions', {
...data,
type: serializePermissionType(data.type),
}).then(normalizePermission)
}
/**
@@ -44,7 +91,7 @@ export function createPermission(data: CreatePermissionRequest): Promise<Permiss
* PUT /api/v1/permissions/:id
*/
export function updatePermission(id: number, data: UpdatePermissionRequest): Promise<Permission> {
return put<Permission>(`/permissions/${id}`, data)
return put<RawPermission>(`/permissions/${id}`, data).then(normalizePermission)
}
/**

View File

@@ -76,9 +76,8 @@ describe('profile service', () => {
})
expect(putMock).toHaveBeenCalledWith('/users/1/password', {
current_password: 'OldPass123',
old_password: 'OldPass123',
new_password: 'NewPass123',
confirm_password: 'NewPass123',
})
})

View File

@@ -50,7 +50,10 @@ export function uploadAvatar(userId: number, file: File): Promise<AvatarUploadRe
}
export function updatePassword(userId: number, data: UpdatePasswordRequest): Promise<void> {
return put<void>(`/users/${userId}/password`, data)
return put<void>(`/users/${userId}/password`, {
old_password: data.current_password,
new_password: data.new_password,
})
}
export function getTOTPStatus(): Promise<TOTPStatusResponse> {

View File

@@ -74,10 +74,22 @@ describe('additional service adapters', () => {
.mockResolvedValueOnce([{ id: 9 }, { id: 11 }])
.mockResolvedValueOnce({ items: [], total: 0, page: 1, page_size: 20 })
.mockResolvedValueOnce({ id: 3 })
.mockResolvedValueOnce([{ id: 1, name: 'menu:view' }])
.mockResolvedValueOnce([{ id: 2, name: 'menu:edit' }])
.mockResolvedValueOnce([{ id: 1, name: 'menu:view', type: 0 }])
.mockResolvedValueOnce([{ id: 2, name: 'menu:edit', type: 1 }])
.mockResolvedValueOnce({ total_users: 10 })
.mockResolvedValueOnce({ active_users: 8 })
postMock.mockImplementation(async (url: string, payload: Record<string, unknown>) => {
if (url === '/permissions') {
return { id: 6, ...payload }
}
return { id: 5, ...payload }
})
putMock.mockImplementation(async (url: string, payload: Record<string, unknown>) => {
if (url === '/permissions/6') {
return { id: 6, ...payload, type: 0 }
}
return undefined
})
const {
listRoles,
@@ -156,7 +168,7 @@ describe('additional service adapters', () => {
expect(postMock).toHaveBeenCalledWith('/permissions', {
name: 'view dashboard',
code: 'dashboard:view',
type: 'menu',
type: 0,
})
await updatePermission(6, { name: 'updated permission' })
@@ -243,9 +255,8 @@ describe('additional service adapters', () => {
confirm_password: 'NewPass123',
})
expect(putMock).toHaveBeenCalledWith('/users/1/password', {
current_password: 'CurrentPass123',
old_password: 'CurrentPass123',
new_password: 'NewPass123',
confirm_password: 'NewPass123',
})
await expect(getTOTPStatus()).resolves.toEqual({ totp_enabled: true })

View File

@@ -80,7 +80,26 @@ describe('permissions service', () => {
it('gets permission tree', async () => {
const mockPermissions = [
{ id: 1, name: 'Users', code: 'users', children: [{ id: 2, name: 'View', code: 'users:view' }] },
{
id: 1,
name: 'Users',
code: 'users',
type: 0,
children: [
{ id: 2, name: 'View', code: 'users:view', type: 2 },
],
},
]
const expectedPermissions = [
{
id: 1,
name: 'Users',
code: 'users',
type: 'menu',
children: [
{ id: 2, name: 'View', code: 'users:view', type: 'api', children: undefined },
],
},
]
getMock.mockResolvedValue(mockPermissions)
@@ -88,7 +107,7 @@ describe('permissions service', () => {
const result = await getPermissionTree()
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
expect(result).toEqual(mockPermissions)
expect(result).toEqual(expectedPermissions)
expect(result[0].children?.[0]?.name).toBe('View')
})
@@ -119,14 +138,15 @@ describe('permissions service', () => {
it('creates a permission', async () => {
const newPermission = { name: 'Test', code: 'test', type: 'button' as const }
const createdPermission = { id: 10, ...newPermission }
const createdPermission = { id: 10, ...newPermission, type: 1 }
postMock.mockResolvedValue(createdPermission)
const { createPermission } = await import('./permissions')
const result = await createPermission(newPermission)
expect(postMock).toHaveBeenCalledWith('/permissions', newPermission)
expect(postMock).toHaveBeenCalledWith('/permissions', { ...newPermission, type: 1 })
expect(result.id).toBe(10)
expect(result.type).toBe('button')
})
it('updates a permission', async () => {

View File

@@ -13,37 +13,35 @@ describe('settings service', () => {
it('gets system settings', async () => {
const mockSettings = {
data: {
system: {
name: 'UserSystem',
version: '1.0.0',
environment: 'production',
description: 'User management system',
},
security: {
password_min_length: 8,
password_require_uppercase: true,
password_require_lowercase: true,
password_require_numbers: true,
password_require_symbols: true,
password_history: 5,
totp_enabled: true,
login_fail_lock: true,
login_fail_threshold: 5,
login_fail_duration: 30,
session_timeout: 3600,
device_trust_duration: 2592000,
},
features: {
email_verification: true,
phone_verification: false,
oauth_providers: ['google', 'github'],
sso_enabled: false,
operation_log_enabled: true,
login_log_enabled: true,
data_export_enabled: true,
data_import_enabled: true,
},
system: {
name: 'UserSystem',
version: '1.0.0',
environment: 'production',
description: 'User management system',
},
security: {
password_min_length: 8,
password_require_uppercase: true,
password_require_lowercase: true,
password_require_numbers: true,
password_require_symbols: true,
password_history: 5,
totp_enabled: true,
login_fail_lock: true,
login_fail_threshold: 5,
login_fail_duration: 30,
session_timeout: 3600,
device_trust_duration: 2592000,
},
features: {
email_verification: true,
phone_verification: false,
oauth_providers: ['google', 'github'],
sso_enabled: false,
operation_log_enabled: true,
login_log_enabled: true,
data_export_enabled: true,
data_import_enabled: true,
},
}
@@ -53,6 +51,6 @@ describe('settings service', () => {
const result = await getSettings()
expect(getMock).toHaveBeenCalledWith('/admin/settings')
expect(result).toEqual(mockSettings.data)
expect(result).toEqual(mockSettings)
})
})

View File

@@ -45,14 +45,10 @@ export interface SystemSettings {
features: FeaturesInfo
}
interface SettingsResponse {
data: SystemSettings
}
/**
* 获取系统设置
* GET /api/v1/admin/settings
*/
export function getSettings(): Promise<SystemSettings> {
return get<SettingsResponse>('/admin/settings').then(res => res.data)
return get<SystemSettings>('/admin/settings')
}

View File

@@ -8,11 +8,7 @@ const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:80
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: 'index.html',
},
},
root: __dirname,
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),