feat: add user center with RBAC, OAuth2 multi-mode and collapsible sidebar

- Add user management with roles and permissions (RBAC)
- Implement OAuth2 service provider supporting 4 grant types:
  authorization_code, password, client_credentials, refresh_token
- Add JWT authentication with 7-day expiry
- Add admin API for users, roles and OAuth clients management
- Add CLI tool for user management (scripts/user-cli.js)
- Add collapsible sidebar layout with login dialog
- Add user management page and OAuth client management page
- Add server middleware for auth token verification
- Add seed script for initial data (admin/admin123)
This commit is contained in:
Administrator 2026-03-19 17:19:57 +08:00
parent 6f1fa04b1e
commit 8117958bd6
25 changed files with 3283 additions and 35 deletions

View File

@ -1,12 +1,23 @@
<template> <template>
<el-container class="layout-container"> <el-container class="layout-container">
<el-aside width="200px" class="sidebar desktop-only"> <el-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only">
<div class="logo"> <div class="logo">
<h2>运动会记分板</h2> <h2 v-if="!collapsed">运动会记分板</h2>
<el-icon v-else :size="24"><Trophy /></el-icon>
</div>
<div class="sidebar-toggle">
<el-button text @click="toggleCollapse" size="small">
<el-icon>
<ArrowLeft v-if="!collapsed" />
<ArrowRight v-else />
</el-icon>
</el-button>
</div> </div>
<el-menu <el-menu
:default-active="activeMenu" :default-active="activeMenu"
class="sidebar-menu" class="sidebar-menu"
:collapse="collapsed"
:collapse-transition="false"
> >
<el-menu-item <el-menu-item
v-for="item in menuItems" v-for="item in menuItems"
@ -17,6 +28,21 @@
<el-icon><component :is="item.icon" /></el-icon> <el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</el-menu-item> </el-menu-item>
<el-sub-menu v-if="hasAdminMenu" index="admin">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/admin/users" @click="onMenuClick('/admin/users')">
<el-icon><UserFilled /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/oauth" @click="onMenuClick('/admin/oauth')">
<el-icon><Key /></el-icon>
<span>OAuth 客户端</span>
</el-menu-item>
</el-sub-menu>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container> <el-container>
@ -27,6 +53,24 @@
</el-button> </el-button>
<h3>运动会管理系统</h3> <h3>运动会管理系统</h3>
</div> </div>
<div class="header-right">
<el-dropdown v-if="currentUser" @command="handleUserCommand">
<span class="user-info">
<el-avatar :size="32">
<el-icon><UserFilled /></el-icon>
</el-avatar>
<span class="username">{{ currentUser.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-else type="primary" @click="showLoginDialog = true">登录</el-button>
</div>
</el-header> </el-header>
<el-main class="main-content"> <el-main class="main-content">
<slot /> <slot />
@ -60,16 +104,74 @@
<el-icon><component :is="item.icon" /></el-icon> <el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</el-menu-item> </el-menu-item>
<el-sub-menu v-if="hasAdminMenu" index="admin">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/admin/users" @click="onMenuClick('/admin/users', true)">
<el-icon><UserFilled /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/oauth" @click="onMenuClick('/admin/oauth', true)">
<el-icon><Key /></el-icon>
<span>OAuth 客户端</span>
</el-menu-item>
</el-sub-menu>
</el-menu> </el-menu>
</el-drawer> </el-drawer>
<el-dialog v-model="showLoginDialog" title="登录" width="400px">
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showLoginDialog = false">取消</el-button>
<el-button type="primary" @click="handleLogin" :loading="logging">登录</el-button>
</template>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu } from '@element-plus/icons-vue' import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu, Setting, Key, ArrowLeft, ArrowRight, ArrowDown } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const activeMenu = computed(() => route.path) const activeMenu = computed(() => route.path)
const drawerOpen = ref(false) const drawerOpen = ref(false)
const showLoginDialog = ref(false)
const logging = ref(false)
const loginFormRef = ref()
const collapsed = ref(false)
const currentUser = ref<any>(null)
const hasAdminMenu = computed(() => currentUser.value?.role === 'admin')
const loginForm = ref({
username: '',
password: ''
})
const loginRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const menuItems = [ const menuItems = [
{ path: '/', label: '首页', icon: HomeFilled }, { path: '/', label: '首页', icon: HomeFilled },
@ -79,26 +181,81 @@ const menuItems = [
{ path: '/scoreboard', label: '记分板', icon: DataLine } { path: '/scoreboard', label: '记分板', icon: DataLine }
] ]
const toggleCollapse = () => {
collapsed.value = !collapsed.value
localStorage.setItem('sidebar-collapsed', String(collapsed.value))
}
const onMenuClick = (path: string, closeDrawer = false) => { const onMenuClick = (path: string, closeDrawer = false) => {
navigateTo(path) navigateTo(path)
if (closeDrawer) { if (closeDrawer) {
drawerOpen.value = false drawerOpen.value = false
} }
} }
const handleUserCommand = async (command: string) => {
if (command === 'logout') {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('user')
currentUser.value = null
ElMessage.success('已退出登录')
}
}
const handleLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate(async (valid: boolean) => {
if (!valid) return
logging.value = true
try {
const res = await $fetch('/api/auth/login', {
method: 'POST',
body: loginForm.value
})
if (res.success) {
localStorage.setItem('token', res.data.accessToken)
localStorage.setItem('refreshToken', res.data.refreshToken)
localStorage.setItem('user', JSON.stringify(res.data.user))
currentUser.value = res.data.user
showLoginDialog.value = false
loginForm.value = { username: '', password: '' }
ElMessage.success('登录成功')
}
} catch (error: any) {
ElMessage.error(error.data?.message || '登录失败')
} finally {
logging.value = false
}
})
}
onMounted(() => {
const savedCollapsed = localStorage.getItem('sidebar-collapsed')
if (savedCollapsed !== null) {
collapsed.value = savedCollapsed === 'true'
}
const savedUser = localStorage.getItem('user')
if (savedUser) {
currentUser.value = JSON.parse(savedUser)
}
})
</script> </script>
<style scoped> <style scoped>
.layout-container {
min-height: 100vh;
}
.sidebar { .sidebar {
background-color: #304156; background-color: #304156;
color: #fff; color: #fff;
transition: width 0.3s;
overflow: hidden;
} }
.logo { .logo {
height: 60px; height: 56px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -107,8 +264,16 @@ const onMenuClick = (path: string, closeDrawer = false) => {
.logo h2 { .logo h2 {
color: #fff; color: #fff;
font-size: 18px; font-size: 16px;
margin: 0; margin: 0;
white-space: nowrap;
}
.sidebar-toggle {
display: flex;
justify-content: flex-end;
padding: 4px 8px;
border-bottom: 1px solid #263445;
} }
.sidebar-menu { .sidebar-menu {
@ -116,12 +281,19 @@ const onMenuClick = (path: string, closeDrawer = false) => {
background-color: #304156; background-color: #304156;
} }
.sidebar-menu .el-menu-item { .sidebar-menu:not(.el-menu--collapse) {
width: 200px;
}
.sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title {
color: #bfcbd9; color: #bfcbd9;
} }
.sidebar-menu .el-menu-item:hover, .sidebar-menu .el-menu-item:hover,
.sidebar-menu .el-menu-item.is-active { .sidebar-menu .el-sub-menu__title:hover,
.sidebar-menu .el-menu-item.is-active,
.sidebar-menu .el-sub-menu .el-menu-item.is-active {
background-color: #263445; background-color: #263445;
color: #409eff; color: #409eff;
} }
@ -131,6 +303,7 @@ const onMenuClick = (path: string, closeDrawer = false) => {
border-bottom: 1px solid #e6e6e6; border-bottom: 1px solid #e6e6e6;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding: 0 20px; padding: 0 20px;
height: 56px; height: 56px;
} }
@ -141,11 +314,27 @@ const onMenuClick = (path: string, closeDrawer = false) => {
gap: 8px; gap: 8px;
} }
.header-right {
display: flex;
align-items: center;
}
.header h3 { .header h3 {
margin: 0; margin: 0;
color: #303133; color: #303133;
} }
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.username {
color: #303133;
}
.main-content { .main-content {
background-color: #f0f2f5; background-color: #f0f2f5;
padding: 20px; padding: 20px;

354
app/pages/admin/oauth.vue Normal file
View File

@ -0,0 +1,354 @@
<template>
<div class="oauth-container">
<el-card>
<template #header>
<div class="card-header">
<span>OAuth 客户端管理</span>
<el-button type="primary" @click="showAddDialog = true">创建客户端</el-button>
</div>
</template>
<el-table :data="clients" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="clientName" label="应用名称" />
<el-table-column prop="clientId" label="Client ID" width="200">
<template #default="{ row }">
<el-tooltip :content="row.clientId" placement="top">
<code class="client-id">{{ row.clientId }}</code>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="{ row }">
<el-tag>{{ getPlatformText(row.platform) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="grantTypes" label="授权模式" width="200">
<template #default="{ row }">
<el-tag
v-for="grant in row.grantTypes"
:key="grant"
size="small"
class="grant-tag"
>
{{ getGrantText(grant) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="isActive" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.isActive"
@change="toggleStatus(row)"
:disabled="row.platform === 'web'"
/>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ new Date(row.createdAt).toLocaleString('zh-CN') }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small" @click="deleteClient(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showAddDialog" title="创建 OAuth 客户端" width="600px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="应用名称" prop="clientName">
<el-input v-model="form.clientName" placeholder="请输入应用名称" />
</el-form-item>
<el-form-item label="平台" prop="platform">
<el-radio-group v-model="form.platform">
<el-radio value="web">网页应用</el-radio>
<el-radio value="mobile">移动应用</el-radio>
<el-radio value="desktop">桌面应用</el-radio>
<el-radio value="other">其他</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="授权模式" prop="grantTypes">
<el-checkbox-group v-model="form.grantTypes">
<el-checkbox value="authorization_code">授权码模式</el-checkbox>
<el-checkbox value="password">密码模式</el-checkbox>
<el-checkbox value="client_credentials">客户端凭据模式</el-checkbox>
<el-checkbox value="refresh_token">刷新令牌</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="重定向 URI" prop="redirectUris">
<el-input
v-model="redirectUrisText"
type="textarea"
:rows="3"
placeholder="多个 URI 用换行分隔"
/>
<div class="form-tip">每行一个 URI</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="submitForm">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="showSecretDialog" title="Client Secret" width="500px">
<el-alert
title="请妥善保存 Client Secret"
type="warning"
description="Client Secret 只会显示一次,请立即复制保存"
:closable="false"
show-icon
/>
<div class="secret-display">
<p><strong>Client ID:</strong></p>
<code class="secret-value">{{ newClient.clientId }}</code>
<p><strong>Client Secret:</strong></p>
<code class="secret-value">{{ newClient.clientSecret }}</code>
<el-button type="primary" class="copy-btn" @click="copySecret">复制到剪贴板</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default'
})
const clients = ref<any[]>([])
const loading = ref(false)
const showAddDialog = ref(false)
const showSecretDialog = ref(false)
const redirectUrisText = ref('')
const formRef = ref()
const form = ref({
clientName: '',
platform: 'web',
grantTypes: ['authorization_code'] as string[]
})
const newClient = ref({
clientId: '',
clientSecret: ''
})
const rules = {
clientName: [
{ required: true, message: '请输入应用名称', trigger: 'blur' }
],
grantTypes: [
{ required: true, message: '请至少选择一种授权模式', trigger: 'change' }
]
}
const getPlatformText = (platform: string) => {
const texts: Record<string, string> = {
web: '网页',
mobile: '移动',
desktop: '桌面',
other: '其他'
}
return texts[platform] || platform
}
const getGrantText = (grant: string) => {
const texts: Record<string, string> = {
authorization_code: '授权码',
password: '密码',
client_credentials: '客户端',
refresh_token: '刷新'
}
return texts[grant] || grant
}
const loadClients = async () => {
const token = localStorage.getItem('token')
if (!token) {
ElMessage.warning('请先登录')
navigateTo('/')
return
}
loading.value = true
try {
const res = await $fetch('/api/admin/oauth', {
headers: { Authorization: `Bearer ${token}` }
})
clients.value = res.data
} catch (error: any) {
ElMessage.error(error.data?.message || '加载失败')
} finally {
loading.value = false
}
}
const toggleStatus = async (client: any) => {
const token = localStorage.getItem('token')
if (!token) return
try {
await $fetch(`/api/admin/oauth?clientId=${client.clientId}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: { isActive: client.isActive }
})
ElMessage.success('状态更新成功')
} catch (error: any) {
ElMessage.error(error.data?.message || '更新失败')
loadClients()
}
}
const deleteClient = async (client: any) => {
const token = localStorage.getItem('token')
if (!token) return
try {
await ElMessageBox.confirm(`确定删除客户端 "${client.clientName}" 吗?`, '提示', {
type: 'warning'
})
await $fetch(`/api/admin/oauth?clientId=${client.clientId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
})
ElMessage.success('删除成功')
loadClients()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.data?.message || '删除失败')
}
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
const token = localStorage.getItem('token')
if (!token) return
const redirectUris = redirectUrisText.value
.split('\n')
.map(uri => uri.trim())
.filter(uri => uri)
if (redirectUris.length === 0) {
ElMessage.error('请至少输入一个重定向 URI')
return
}
try {
const res = await $fetch('/api/admin/oauth', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: {
...form.value,
redirectUris,
allowedScopes: ['read', 'write']
}
})
newClient.value = {
clientId: res.data.clientId,
clientSecret: res.data.clientSecret
}
showAddDialog.value = false
showSecretDialog.value = true
resetForm()
loadClients()
} catch (error: any) {
ElMessage.error(error.data?.message || '创建失败')
}
})
}
const resetForm = () => {
form.value = {
clientName: '',
platform: 'web',
grantTypes: ['authorization_code']
}
redirectUrisText.value = ''
formRef.value?.resetFields()
}
const copySecret = () => {
const text = `Client ID: ${newClient.value.clientId}\nClient Secret: ${newClient.value.clientSecret}`
navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
}
onMounted(() => {
loadClients()
})
</script>
<style scoped>
.oauth-container {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.client-id {
font-size: 12px;
color: #409eff;
cursor: pointer;
}
.grant-tag {
margin-right: 4px;
margin-bottom: 2px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.secret-display {
margin-top: 20px;
}
.secret-display p {
margin: 16px 0 8px;
font-size: 14px;
}
.secret-value {
display: block;
padding: 12px;
background-color: #f5f7fa;
border-radius: 4px;
word-break: break-all;
font-size: 13px;
}
.copy-btn {
margin-top: 20px;
width: 100%;
}
@media (max-width: 900px) {
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>

315
app/pages/admin/users.vue Normal file
View File

@ -0,0 +1,315 @@
<template>
<div class="users-container">
<el-card>
<template #header>
<div class="card-header">
<span>用户管理</span>
<el-button type="primary" @click="showAddDialog = true">添加用户</el-button>
</div>
</template>
<el-form :inline="true" class="filter-form">
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable @change="loadUsers">
<el-option label="正常" value="active" />
<el-option label="停用" value="inactive" />
<el-option label="封禁" value="banned" />
</el-select>
</el-form-item>
</el-form>
<el-table :data="users" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="realName" label="姓名" />
<el-table-column prop="roleName" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="row.roleName === 'admin' ? 'danger' : 'info'">
{{ row.roleName }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastLogin" label="最后登录" width="180">
<template #default="{ row }">
{{ row.lastLogin ? new Date(row.lastLogin).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="editUser(row)">编辑</el-button>
<el-button
type="danger"
size="small"
@click="deleteUser(row)"
:disabled="row.roleName === 'admin'"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showAddDialog" title="添加用户" width="500px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
<div class="form-tip">密码至少6位不能为纯数字</div>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱(可选)" />
</el-form-item>
<el-form-item label="姓名" prop="realName">
<el-input v-model="form.realName" placeholder="请输入姓名(可选)" />
</el-form-item>
<el-form-item label="角色" prop="roleId">
<el-select v-model="form.roleId" placeholder="请选择角色">
<el-option
v-for="role in roles"
:key="role.id"
:label="role.description || role.name"
:value="role.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default'
})
const users = ref<any[]>([])
const roles = ref<any[]>([])
const loading = ref(false)
const showAddDialog = ref(false)
const filters = ref({ status: '' })
const form = ref({
id: null as number | null,
username: '',
password: '',
email: '',
realName: '',
roleId: null as number | null
})
const formRef = ref()
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '用户名2-20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' }
],
roleId: [
{ required: true, message: '请选择角色', trigger: 'change' }
]
}
const getStatusType = (status: string) => {
const types: Record<string, string> = {
active: 'success',
inactive: 'warning',
banned: 'danger'
}
return types[status] || 'info'
}
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
active: '正常',
inactive: '停用',
banned: '封禁'
}
return texts[status] || status
}
const loadUsers = async () => {
const token = localStorage.getItem('token')
if (!token) {
ElMessage.warning('请先登录')
navigateTo('/')
return
}
loading.value = true
try {
const params = new URLSearchParams()
if (filters.value.status) params.append('status', filters.value.status)
const res = await $fetch(`/api/admin/users?${params}`, {
headers: { Authorization: `Bearer ${token}` }
})
users.value = res.data
} catch (error: any) {
ElMessage.error(error.data?.message || '加载用户失败')
} finally {
loading.value = false
}
}
const loadRoles = async () => {
const token = localStorage.getItem('token')
if (!token) return
try {
const res = await $fetch('/api/admin/roles', {
headers: { Authorization: `Bearer ${token}` }
})
roles.value = res.data.roles
} catch (error) {
console.error('Failed to load roles:', error)
}
}
const editUser = (user: any) => {
form.value = {
id: user.id,
username: user.username,
password: '',
email: user.email || '',
realName: user.realName || '',
roleId: user.roleId
}
showAddDialog.value = true
}
const deleteUser = async (user: any) => {
const token = localStorage.getItem('token')
if (!token) return
try {
await ElMessageBox.confirm(`确定删除用户 "${user.username}" 吗?`, '提示', {
type: 'warning'
})
await $fetch(`/api/admin/users/${user.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
})
ElMessage.success('删除成功')
loadUsers()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.data?.message || '删除失败')
}
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
const token = localStorage.getItem('token')
if (!token) return
try {
if (form.value.id) {
await $fetch(`/api/admin/users/${form.value.id}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: {
email: form.value.email,
realName: form.value.realName,
roleId: form.value.roleId
}
})
ElMessage.success('更新成功')
} else {
await $fetch('/api/admin/users', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form.value
})
ElMessage.success('添加成功')
}
showAddDialog.value = false
resetForm()
loadUsers()
} catch (error: any) {
ElMessage.error(error.data?.message || '操作失败')
}
})
}
const resetForm = () => {
form.value = {
id: null,
username: '',
password: '',
email: '',
realName: '',
roleId: null
}
formRef.value?.resetFields()
}
onMounted(() => {
loadUsers()
loadRoles()
})
</script>
<style scoped>
.users-container {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-form {
margin-bottom: 20px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
@media (max-width: 900px) {
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.filter-form :deep(.el-form-item) {
margin-right: 0;
width: 100%;
}
.filter-form :deep(.el-select) {
width: 100%;
}
}
</style>

View File

@ -7,22 +7,31 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"user:cli": "node scripts/user-cli.js",
"user:init": "node scripts/user-cli.js init"
}, },
"dependencies": { "dependencies": {
"@element-plus/nuxt": "1.0.10", "@element-plus/nuxt": "1.0.10",
"@pinia/nuxt": "0.11.3", "@pinia/nuxt": "0.11.3",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"element-plus": "2.9.2", "element-plus": "2.9.2",
"jsonwebtoken": "^9.0.3",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"opencode-ai": "^1.2.27", "opencode-ai": "^1.2.27",
"pinia": "2.3.1", "pinia": "2.3.1",
"uuid": "^13.0.0",
"vue": "^3.5.29", "vue": "^3.5.29",
"vue-router": "^4.6.4" "vue-router": "^4.6.4",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint": "1.15.2", "@nuxt/eslint": "1.15.2",
"@nuxt/icon": "2.2.1" "@nuxt/icon": "2.2.1",
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/uuid": "^11.0.0"
}, },
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac" "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
} }

388
scripts/user-cli.js Normal file
View File

@ -0,0 +1,388 @@
#!/usr/bin/env node
import Database from 'better-sqlite3'
import bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs'
import readline from 'readline'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, '..')
const dataDir = join(projectRoot, 'data')
const dbPath = join(dataDir, 'sports.db')
if (!fs.existsSync(dbPath)) {
console.error('❌ 数据库文件不存在,请先运行项目')
process.exit(1)
}
const db = new Database(dbPath)
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
function prompt(question) {
return new Promise((resolve) => rl.question(question, resolve))
}
function validatePassword(password) {
if (password.length < 6) {
return { valid: false, message: '密码长度至少6位' }
}
if (/^\d+$/.test(password)) {
return { valid: false, message: '密码不能为纯数字' }
}
return { valid: true }
}
function initRoles() {
const existingRoles = db.prepare('SELECT COUNT(*) as count FROM roles').get()
if (existingRoles.count === 0) {
console.log('📝 初始化默认角色...')
const defaultRoles = [
{
name: 'admin',
description: '系统管理员',
permissions: JSON.stringify([
'user:create', 'user:read', 'user:update', 'user:delete',
'event:create', 'event:read', 'event:update', 'event:delete',
'result:create', 'result:read', 'result:update', 'result:delete',
'team:create', 'team:read', 'team:update', 'team:delete',
'admin:access', 'role:manage',
'oauth:client:create', 'oauth:client:read', 'oauth:client:update', 'oauth:client:delete'
]),
is_system: 1
},
{
name: 'user',
description: '普通用户',
permissions: JSON.stringify([
'event:read', 'result:read', 'team:read'
]),
is_system: 1
},
{
name: 'guest',
description: '访客',
permissions: JSON.stringify(['event:read']),
is_system: 1
}
]
const stmt = db.prepare('INSERT INTO roles (name, description, permissions, is_system) VALUES (?, ?, ?, ?)')
for (const role of defaultRoles) {
stmt.run(role.name, role.description, role.permissions, role.is_system)
}
console.log('✅ 默认角色初始化完成')
}
}
async function listUsers() {
console.log('\n📋 用户列表:\n')
console.log('ID | 用户名 | 邮箱 | 角色 | 状态 | 创建时间')
console.log('-'.repeat(70))
const users = db.prepare(`
SELECT u.*, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
ORDER BY u.created_at DESC
`).all()
if (users.length === 0) {
console.log('暂无用户')
return
}
for (const user of users) {
console.log(
`${user.id} | ${user.username} | ${user.email || '-'} | ${user.role_name} | ${user.status} | ${user.created_at}`
)
}
}
async function createUser() {
const username = await prompt('用户名: ')
if (!username || username.trim().length < 2) {
console.log('❌ 用户名至少2个字符')
return
}
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
if (existing) {
console.log('❌ 用户名已存在')
return
}
let password
while (true) {
password = await prompt('密码: ')
const validation = validatePassword(password)
if (validation.valid) break
console.log(`${validation.message}`)
}
const email = await prompt('邮箱 (可选): ')
let roleId = 2
const roles = db.prepare('SELECT id, name FROM roles').all()
if (roles.length > 0) {
console.log('\n可选角色:')
for (const role of roles) {
console.log(` ${role.id}. ${role.name}`)
}
const roleInput = await prompt('角色 ID (默认: 2): ')
if (roleInput && roles.find(r => r.id === parseInt(roleInput))) {
roleId = parseInt(roleInput)
}
}
const passwordHash = bcrypt.hashSync(password, 10)
const stmt = db.prepare(
'INSERT INTO users (username, password_hash, email, role_id) VALUES (?, ?, ?, ?)'
)
const result = stmt.run(username, passwordHash, email || null, roleId)
console.log(`\n✅ 用户创建成功 (ID: ${result.lastInsertRowid})`)
}
async function deleteUser() {
const username = await prompt('要删除的用户名: ')
const user = db.prepare('SELECT id, username, role_id FROM users WHERE username = ?').get(username)
if (!user) {
console.log('❌ 用户不存在')
return
}
const role = db.prepare('SELECT is_system FROM roles WHERE id = ?').get(user.role_id)
if (role?.is_system) {
console.log('❌ 不能删除系统内置用户')
return
}
const confirm = await prompt(`确认删除用户 "${username}" (y/N): `)
if (confirm.toLowerCase() !== 'y') {
console.log('已取消')
return
}
db.prepare('DELETE FROM users WHERE id = ?').run(user.id)
console.log('✅ 用户已删除')
}
async function resetPassword() {
const username = await prompt('用户名: ')
const user = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
if (!user) {
console.log('❌ 用户不存在')
return
}
let password
while (true) {
password = await prompt('新密码: ')
const validation = validatePassword(password)
if (validation.valid) break
console.log(`${validation.message}`)
}
const passwordHash = bcrypt.hashSync(password, 10)
db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(passwordHash, user.id)
console.log('✅ 密码重置成功')
}
async function setRole() {
const username = await prompt('用户名: ')
const user = db.prepare('SELECT id, role_id FROM users WHERE username = ?').get(username)
if (!user) {
console.log('❌ 用户不存在')
return
}
const roles = db.prepare('SELECT id, name FROM roles').all()
console.log('\n可选角色:')
for (const role of roles) {
console.log(` ${role.id}. ${role.name}`)
}
const roleInput = await prompt('新角色 ID: ')
const newRole = roles.find(r => r.id === parseInt(roleInput))
if (!newRole) {
console.log('❌ 无效的角色 ID')
return
}
db.prepare('UPDATE users SET role_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(newRole.id, user.id)
console.log(`✅ 已将 ${username} 的角色设置为 ${newRole.name}`)
}
async function setStatus() {
const username = await prompt('用户名: ')
const user = db.prepare('SELECT id, status FROM users WHERE username = ?').get(username)
if (!user) {
console.log('❌ 用户不存在')
return
}
console.log('\n可选状态:')
console.log(' 1. active (正常)')
console.log(' 2. inactive (停用)')
console.log(' 3. banned (封禁)')
const statusMap = { '1': 'active', '2': 'inactive', '3': 'banned' }
const input = await prompt('新状态: ')
const newStatus = statusMap[input]
if (!newStatus) {
console.log('❌ 无效的状态')
return
}
db.prepare('UPDATE users SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(newStatus, user.id)
console.log(`✅ 用户状态已更新为 ${newStatus}`)
}
async function createOAuthClient() {
const clientName = await prompt('应用名称: ')
if (!clientName) {
console.log('❌ 应用名称不能为空')
return
}
const redirectUrisInput = await prompt('允许的重定向 URI (多个用逗号分隔): ')
const redirectUris = redirectUrisInput.split(',').map(uri => uri.trim()).filter(uri => uri)
console.log('\n支持的授权模式:')
console.log(' 1. authorization_code (授权码模式)')
console.log(' 2. password (密码模式)')
console.log(' 3. client_credentials (客户端凭据模式)')
console.log(' 4. refresh_token (刷新令牌)')
const grantTypeMap = {
'1': 'authorization_code',
'2': 'password',
'3': 'client_credentials',
'4': 'refresh_token'
}
const grantInput = await prompt('授权模式 (多个用逗号分隔默认1): ')
const selectedGrants = grantInput
? grantInput.split(',').map(g => grantTypeMap[g.trim()]).filter(g => g)
: ['authorization_code']
console.log('\n支持的平台:')
console.log(' 1. web (网页应用)')
console.log(' 2. mobile (移动应用)')
console.log(' 3. desktop (桌面应用)')
console.log(' 4. other (其他)')
const platformMap = { '1': 'web', '2': 'mobile', '3': 'desktop', '4': 'other' }
const platformInput = await prompt('平台 (默认1): ')
const platform = platformMap[platformInput] || 'web'
const clientId = uuidv4().replace(/-/g, '')
const clientSecret = uuidv4()
const clientSecretHash = bcrypt.hashSync(clientSecret, 10)
const stmt = db.prepare(`
INSERT INTO oauth_clients
(client_id, client_secret_hash, client_name, redirect_uris, allowed_scopes, grant_types, platform)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
stmt.run(
clientId,
clientSecretHash,
clientName,
JSON.stringify(redirectUris),
JSON.stringify(['read', 'write']),
JSON.stringify(selectedGrants),
platform
)
console.log('\n✅ OAuth 客户端创建成功!\n')
console.log('='.repeat(50))
console.log('Client ID:')
console.log(clientId)
console.log('\nClient Secret:')
console.log(clientSecret)
console.log('='.repeat(50))
console.log('\n⚠ 请妥善保存 Client Secret它不会再次显示!\n')
}
async function showHelp() {
console.log(`
🔧 运动会管理系统 - 用户管理工具
用法: node scripts/user-cli.js <command>
命令:
list 列出所有用户
create 创建新用户
delete 删除用户
reset-password 重置用户密码
set-role 设置用户角色
set-status 设置用户状态
create-client 创建 OAuth 客户端
init 初始化默认角色
help 显示帮助信息
示例:
node scripts/user-cli.js list
node scripts/user-cli.js create
node scripts/user-cli.js reset-password
`)
}
async function main() {
const args = process.argv.slice(2)
const command = args[0] || 'help'
console.log('🏃 运动会管理系统 - 用户管理 CLI\n')
switch (command) {
case 'list':
await listUsers()
break
case 'create':
await createUser()
break
case 'delete':
await deleteUser()
break
case 'reset-password':
await resetPassword()
break
case 'set-role':
await setRole()
break
case 'set-status':
await setStatus()
break
case 'create-client':
await createOAuthClient()
break
case 'init':
initRoles()
break
case 'help':
default:
await showHelp()
}
rl.close()
}
main().catch(console.error)

View File

@ -0,0 +1,150 @@
import { getClients, createClient, updateClient, deleteClient } from '../../../modules/oauth'
import { verifyToken } from '../../../utils/jwt'
import { z } from 'zod'
const createClientSchema = z.object({
clientName: z.string().min(1, '应用名称不能为空'),
redirectUris: z.array(z.string().url('请输入有效的URL')),
allowedScopes: z.array(z.string()),
grantTypes: z.array(z.enum(['authorization_code', 'password', 'client_credentials', 'refresh_token'])),
platform: z.enum(['web', 'mobile', 'desktop', 'other']).optional()
})
const updateClientSchema = z.object({
clientName: z.string().min(1).optional(),
redirectUris: z.array(z.string().url()).optional(),
allowedScopes: z.array(z.string()).optional(),
grantTypes: z.array(z.enum(['authorization_code', 'password', 'client_credentials', 'refresh_token'])).optional(),
platform: z.enum(['web', 'mobile', 'desktop', 'other']).optional(),
isActive: z.boolean().optional()
})
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({ statusCode: 401, message: '请先登录' })
}
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access' || payload.role !== 'admin') {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
if (event.method === 'GET') {
const clients = getClients()
return {
success: true,
data: clients.map(c => ({
id: c.id,
clientId: c.client_id,
clientName: c.client_name,
redirectUris: c.redirect_uris,
allowedScopes: c.allowed_scopes,
grantTypes: c.grant_types,
platform: c.platform,
isActive: c.is_active === 1,
createdAt: c.created_at
}))
}
}
if (event.method === 'POST') {
const body = await readBody(event)
const result = createClientSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.errors[0].message
})
}
const { clientName, redirectUris, allowedScopes, grantTypes, platform } = result.data
const { client, clientSecret } = createClient(clientName, redirectUris, allowedScopes, grantTypes, platform)
return {
success: true,
data: {
id: client.id,
clientId: client.client_id,
clientName: client.client_name,
clientSecret,
redirectUris: client.redirect_uris,
allowedScopes: client.allowed_scopes,
grantTypes: client.grant_types,
platform: client.platform,
isActive: client.is_active === 1
},
message: 'OAuth 客户端创建成功,请妥善保存 Client Secret'
}
}
if (event.method === 'PUT') {
const body = await readBody(event)
const query = getQuery(event)
const clientId = query.clientId as string
if (!clientId) {
throw createError({ statusCode: 400, message: '缺少 clientId 参数' })
}
const result = updateClientSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.errors[0].message
})
}
const updated = updateClient(clientId, {
client_name: result.data.clientName,
redirect_uris: result.data.redirectUris,
allowed_scopes: result.data.allowedScopes,
grant_types: result.data.grantTypes,
platform: result.data.platform,
is_active: result.data.isActive !== undefined ? (result.data.isActive ? 1 : 0) : undefined
})
if (!updated) {
throw createError({ statusCode: 404, message: '客户端不存在' })
}
return {
success: true,
data: {
clientId: updated!.client_id,
clientName: updated!.client_name,
redirectUris: updated!.redirect_uris,
allowedScopes: updated!.allowed_scopes,
grantTypes: updated!.grant_types,
platform: updated!.platform,
isActive: updated!.is_active === 1
},
message: '客户端更新成功'
}
}
if (event.method === 'DELETE') {
const query = getQuery(event)
const clientId = query.clientId as string
if (!clientId) {
throw createError({ statusCode: 400, message: '缺少 clientId 参数' })
}
const deleted = deleteClient(clientId)
if (!deleted) {
throw createError({ statusCode: 404, message: '客户端不存在' })
}
return {
success: true,
message: '客户端删除成功'
}
}
})

View File

@ -0,0 +1,91 @@
import { getRoles, createRole } from '../../../modules/auth/user'
import { DEFAULT_ROLES, getPermissionGroups } from '../../../modules/auth/permissions'
import { verifyToken } from '../../../utils/jwt'
import { z } from 'zod'
import db from '../../../db'
const createRoleSchema = z.object({
name: z.string().min(2, '角色名称至少2个字符').max(20, '角色名称最多20个字符'),
description: z.string().optional(),
permissions: z.array(z.string())
})
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({ statusCode: 401, message: '请先登录' })
}
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access' || payload.role !== 'admin') {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
const query = getQuery(event)
if (query.action === 'init') {
const existingRoles = getRoles()
if (existingRoles.length === 0) {
for (const role of Object.values(DEFAULT_ROLES)) {
createRole(role.name, role.description, role.permissions, role.isSystem)
}
}
const roles = getRoles()
const permissionGroups = getPermissionGroups()
return {
success: true,
data: {
roles,
permissionGroups
}
}
}
if (event.method === 'GET') {
const roles = getRoles()
const permissionGroups = getPermissionGroups()
return {
success: true,
data: {
roles,
permissionGroups
}
}
}
if (event.method === 'POST') {
const body = await readBody(event)
const result = createRoleSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.errors[0].message
})
}
const { name, description, permissions } = result.data
const existingRole = db.prepare('SELECT id FROM roles WHERE name = ?').get(name)
if (existingRole) {
throw createError({
statusCode: 409,
message: '角色名称已存在'
})
}
const role = createRole(name, description || '', permissions)
return {
success: true,
data: role,
message: '角色创建成功'
}
}
})

View File

@ -0,0 +1,40 @@
import { getUsers } from '../../../modules/auth/user'
import type { User } from '../../../modules/auth/types'
import { verifyToken } from '../../../utils/jwt'
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({ statusCode: 401, message: '请先登录' })
}
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access' || payload.role !== 'admin') {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
const query = getQuery(event)
const roleId = query.roleId ? Number(query.roleId) : undefined
const status = query.status as string | undefined
const users = getUsers({ roleId, status })
return {
success: true,
data: users.map((u: User) => ({
id: u.id,
username: u.username,
email: u.email,
realName: u.real_name,
avatar: u.avatar,
roleId: u.role_id,
roleName: u.role_name,
status: u.status,
lastLogin: u.last_login,
createdAt: u.created_at
}))
}
})

View File

@ -0,0 +1,81 @@
import { z } from 'zod'
import { getUserByUsername, getUserByEmail, createUser, hashPassword, validatePassword } from '../../../modules/auth/user'
import { verifyToken } from '../../../utils/jwt'
const createUserSchema = z.object({
username: z.string().min(2, '用户名至少2个字符').max(20, '用户名最多20个字符'),
password: z.string(),
email: z.string().email('请输入有效的邮箱').optional().or(z.literal('')),
realName: z.string().optional(),
roleId: z.number().int().positive().optional()
})
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({ statusCode: 401, message: '请先登录' })
}
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access' || payload.role !== 'admin') {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
const body = await readBody(event)
const result = createUserSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.errors[0].message
})
}
const { username, password, email, realName, roleId } = result.data
const passwordValidation = validatePassword(password)
if (!passwordValidation.valid) {
throw createError({
statusCode: 400,
message: passwordValidation.message!
})
}
const existingUser = getUserByUsername(username)
if (existingUser) {
throw createError({
statusCode: 409,
message: '用户名已存在'
})
}
if (email) {
const existingEmail = getUserByEmail(email)
if (existingEmail) {
throw createError({
statusCode: 409,
message: '邮箱已被注册'
})
}
}
const passwordHash = hashPassword(password)
const user = await createUser(username, passwordHash, email, realName, roleId)
return {
success: true,
data: {
id: user.id,
username: user.username,
email: user.email,
realName: user.real_name,
roleId: user.role_id,
roleName: user.role_name,
createdAt: user.created_at
},
message: '用户创建成功'
}
})

View File

@ -0,0 +1,98 @@
import { z } from 'zod'
import { getUserByUsername, verifyPassword, resetLoginAttempts, updateLastLogin, incrementLoginAttempts, validatePassword } from '../../modules/auth/user'
import { createToken } from '../../modules/oauth'
import { generateAccessToken, generateRefreshToken } from '../../utils/jwt'
const loginSchema = z.object({
username: z.string().min(1, '用户名不能为空'),
password: z.string().min(1, '密码不能为空')
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = loginSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.errors[0].message
})
}
const { username, password } = result.data
const user = getUserByUsername(username)
if (!user) {
throw createError({
statusCode: 401,
message: '用户名或密码错误'
})
}
if (user.status !== 'active') {
throw createError({
statusCode: 403,
message: '账户已被禁用'
})
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
throw createError({
statusCode: 423,
message: `账户已被锁定,请于 ${new Date(user.locked_until).toLocaleString()} 后重试`
})
}
if (!verifyPassword(password, user.password_hash!)) {
incrementLoginAttempts(user.id)
const attempts = (user.login_attempts || 0) + 1
if (attempts >= 5) {
const lockedUntil = new Date(Date.now() + 30 * 60 * 1000)
incrementLoginAttempts(user.id, lockedUntil)
throw createError({
statusCode: 423,
message: '密码错误次数过多账户已被锁定30分钟'
})
}
throw createError({
statusCode: 401,
message: `用户名或密码错误(剩余${5 - attempts}次)`
})
}
resetLoginAttempts(user.id)
updateLastLogin(user.id)
const payload = {
sub: user.username,
userId: user.id,
username: user.username,
role: user.role_name!,
permissions: user.permissions || []
}
const { accessToken, refreshToken } = createToken(user.id, 'system')
const jwtAccessToken = generateAccessToken(payload)
const jwtRefreshToken = generateRefreshToken(payload)
return {
success: true,
data: {
accessToken: jwtAccessToken,
refreshToken: jwtRefreshToken,
expiresIn: 7 * 24 * 60 * 60,
tokenType: 'Bearer',
user: {
id: user.id,
username: user.username,
email: user.email,
realName: user.real_name,
avatar: user.avatar,
role: user.role_name,
permissions: user.permissions
}
}
}
})

View File

@ -0,0 +1,21 @@
import { verifyToken } from '../../utils/jwt'
import { revokeToken } from '../../modules/oauth'
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7)
revokeToken(token, 'access')
}
const body = await readBody(event).catch(() => ({}))
if (body.refreshToken) {
revokeToken(body.refreshToken, 'refresh')
}
return {
success: true,
message: '登出成功'
}
})

53
server/api/auth/me.get.ts Normal file
View File

@ -0,0 +1,53 @@
import { verifyToken } from '../../utils/jwt'
import { getUserById } from '../../modules/auth/user'
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: '未登录'
})
}
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access') {
throw createError({
statusCode: 401,
message: '无效的访问令牌'
})
}
const user = getUserById(payload.userId)
if (!user) {
throw createError({
statusCode: 404,
message: '用户不存在'
})
}
if (user.status !== 'active') {
throw createError({
statusCode: 403,
message: '账户已被禁用'
})
}
return {
success: true,
data: {
id: user.id,
username: user.username,
email: user.email,
realName: user.real_name,
avatar: user.avatar,
role: user.role_name,
permissions: user.permissions,
lastLogin: user.last_login,
createdAt: user.created_at
}
}
})

View File

@ -0,0 +1,59 @@
import { z } from 'zod'
import { verifyToken, generateAccessToken, generateRefreshToken } from '../../utils/jwt'
import { getUserById } from '../../modules/auth/user'
import { refreshAccessToken } from 'server/modules/oauth'
const refreshSchema = z.object({
refreshToken: z.string().min(1, '刷新令牌不能为空')
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = refreshSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.errors[0].message
})
}
const { refreshToken } = result.data
const payload = verifyToken(refreshToken)
if (!payload || payload.type !== 'refresh') {
throw createError({
statusCode: 401,
message: '无效的刷新令牌'
})
}
const user = getUserById(payload.userId)
if (!user || user.status !== 'active') {
throw createError({
statusCode: 401,
message: '用户不存在或已被禁用'
})
}
const newPayload = {
sub: user.username,
userId: user.id,
username: user.username,
role: user.role_name!,
permissions: user.permissions || []
}
const accessToken = generateAccessToken(newPayload)
const newRefreshToken = generateRefreshToken(newPayload)
return {
success: true,
data: {
accessToken,
refreshToken: newRefreshToken,
expiresIn: 7 * 24 * 60 * 60,
tokenType: 'Bearer'
}
}
})

View File

@ -0,0 +1,65 @@
import { z } from 'zod'
import { getUserByUsername, getUserByEmail, createUser, hashPassword, validatePassword } from '../../modules/auth/user'
const registerSchema = z.object({
username: z.string().min(2, '用户名至少2个字符').max(20, '用户名最多20个字符'),
password: z.string(),
email: z.string().email('请输入有效的邮箱').optional().or(z.literal('')),
realName: z.string().optional()
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = registerSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.errors[0].message
})
}
const { username, password, email, realName } = result.data
const passwordValidation = validatePassword(password)
if (!passwordValidation.valid) {
throw createError({
statusCode: 400,
message: passwordValidation.message!
})
}
const existingUser = getUserByUsername(username)
if (existingUser) {
throw createError({
statusCode: 409,
message: '用户名已存在'
})
}
if (email) {
const existingEmail = getUserByEmail(email)
if (existingEmail) {
throw createError({
statusCode: 409,
message: '邮箱已被注册'
})
}
}
const passwordHash = hashPassword(password)
const user = await createUser(username, passwordHash, email, realName)
return {
success: true,
data: {
id: user.id,
username: user.username,
email: user.email,
realName: user.real_name,
role: user.role_name,
createdAt: user.created_at
},
message: '注册成功'
}
})

View File

@ -0,0 +1,126 @@
import { z } from 'zod'
import { getClientById, validateRedirectUri, validateScope } from '../../modules/oauth'
import { createAuthorizationCode } from '../../modules/oauth'
const authorizeSchema = z.object({
response_type: z.enum(['code', 'token']),
client_id: z.string().min(1),
redirect_uri: z.string().min(1),
scope: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.enum(['S256', 'plain']).optional()
})
export default defineEventHandler(async (event) => {
const query = getQuery(event) as Record<string, string>
const result = authorizeSchema.safeParse({
response_type: query.response_type,
client_id: query.client_id,
redirect_uri: query.redirect_uri,
scope: query.scope,
state: query.state,
code_challenge: query.code_challenge,
code_challenge_method: query.code_challenge_method
})
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.errors[0].message
})
}
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = result.data
const client = getClientById(client_id)
if (!client) {
throw createError({
statusCode: 400,
message: '无效的客户端'
})
}
if (!client.is_active) {
throw createError({
statusCode: 400,
message: '客户端已被禁用'
})
}
if (!validateRedirectUri(client, redirect_uri)) {
throw createError({
statusCode: 400,
message: '无效的重定向地址'
})
}
if (scope && !validateScope(client, scope)) {
throw createError({
statusCode: 400,
message: '无效的授权范围'
})
}
if (!client.grant_types.includes('authorization_code') && response_type === 'code') {
throw createError({
statusCode: 400,
message: '客户端不支持授权码模式'
})
}
if (!client.grant_types.includes('implicit') && response_type === 'token') {
throw createError({
statusCode: 400,
message: '客户端不支持隐式授权模式'
})
}
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: '请先登录'
})
}
const { verifyToken } = await import('../../utils/jwt')
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access') {
throw createError({
statusCode: 401,
message: '无效的访问令牌'
})
}
if (response_type === 'code') {
const code = createAuthorizationCode(
payload.userId,
client_id,
redirect_uri,
scope,
code_challenge,
code_challenge_method
)
const params = new URLSearchParams({ code })
if (state) params.append('state', state)
return sendRedirect(event, `${redirect_uri}?${params.toString()}`)
} else {
const { createTokenWithoutRefresh } = await import('../../modules/oauth')
const { accessToken, expiresAt } = createTokenWithoutRefresh(payload.userId, client_id, scope)
const fragmentParams = new URLSearchParams({
access_token: accessToken,
token_type: 'Bearer',
expires_in: String(Math.floor((expiresAt.getTime() - Date.now()) / 1000))
})
if (state) fragmentParams.append('state', state)
return sendRedirect(event, `${redirect_uri}#${fragmentParams.toString()}`)
}
})

View File

@ -0,0 +1,217 @@
import { z } from 'zod'
import {
getClientById,
verifyClientSecret,
createToken,
createClientCredentialsToken,
createTokenWithoutRefresh,
getAuthorizationCode,
useAuthorizationCode,
validateRedirectUri,
getTokenByRefreshToken,
revokeToken
} from '../../modules/oauth'
import { getUserByUsername, verifyPassword } from '../../modules/auth/user'
const tokenSchema = z.object({
grant_type: z.enum(['authorization_code', 'password', 'client_credentials', 'refresh_token']),
client_id: z.string().min(1),
client_secret: z.string().optional()
})
const authCodeSchema = z.object({
code: z.string().min(1),
redirect_uri: z.string().min(1)
})
const passwordSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
scope: z.string().optional()
})
const clientCredsSchema = z.object({
scope: z.string().optional()
})
const refreshSchema = z.object({
refresh_token: z.string().min(1)
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const baseResult = tokenSchema.safeParse(body)
if (!baseResult.success) {
throw createError({
statusCode: 400,
message: baseResult.error.errors[0].message
})
}
const { grant_type, client_id, client_secret } = baseResult.data
const client = getClientById(client_id)
if (!client) {
throw createError({
statusCode: 400,
message: '无效的客户端'
})
}
if (client_secret !== undefined && !verifyClientSecret(client, client_secret)) {
throw createError({
statusCode: 401,
message: '客户端密钥错误'
})
}
switch (grant_type) {
case 'authorization_code': {
if (!client.grant_types.includes('authorization_code')) {
throw createError({ statusCode: 400, message: '客户端不支持授权码模式' })
}
const codeResult = authCodeSchema.safeParse(body)
if (!codeResult.success) {
throw createError({ statusCode: 400, message: codeResult.error.errors[0].message })
}
const { code, redirect_uri } = codeResult.data
const authCode = getAuthorizationCode(code)
if (!authCode) {
throw createError({ statusCode: 400, message: '无效或已过期的授权码' })
}
if (authCode.client_id !== client_id) {
throw createError({ statusCode: 400, message: '授权码与客户端不匹配' })
}
if (!validateRedirectUri(client, redirect_uri)) {
throw createError({ statusCode: 400, message: '重定向地址不匹配' })
}
if (authCode.code_challenge) {
throw createError({ statusCode: 400, message: '需要 PKCE 验证' })
}
useAuthorizationCode(code)
const { accessToken, refreshToken, expiresAt } = createToken(
authCode.user_id,
client_id,
authCode.scope || undefined
)
return {
success: true,
data: {
access_token: accessToken,
token_type: 'Bearer',
expires_in: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
refresh_token: refreshToken,
scope: authCode.scope
}
}
}
case 'password': {
if (!client.grant_types.includes('password')) {
throw createError({ statusCode: 400, message: '客户端不支持密码模式' })
}
const pwdResult = passwordSchema.safeParse(body)
if (!pwdResult.success) {
throw createError({ statusCode: 400, message: pwdResult.error.errors[0].message })
}
const { username, password, scope } = pwdResult.data
const user = getUserByUsername(username)
if (!user || !verifyPassword(password, user.password_hash!)) {
throw createError({ statusCode: 401, message: '用户名或密码错误' })
}
if (user.status !== 'active') {
throw createError({ statusCode: 403, message: '账户已被禁用' })
}
const { accessToken, refreshToken, expiresAt } = createToken(user.id, client_id, scope)
return {
success: true,
data: {
access_token: accessToken,
token_type: 'Bearer',
expires_in: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
refresh_token: refreshToken,
scope
}
}
}
case 'client_credentials': {
if (!client.grant_types.includes('client_credentials')) {
throw createError({ statusCode: 400, message: '客户端不支持客户端凭据模式' })
}
const credsResult = clientCredsSchema.safeParse(body)
if (!credsResult.success) {
throw createError({ statusCode: 400, message: credsResult.error.errors[0].message })
}
const { accessToken, expiresAt } = createClientCredentialsToken(client_id, credsResult.data.scope)
return {
success: true,
data: {
access_token: accessToken,
token_type: 'Bearer',
expires_in: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
scope: credsResult.data.scope
}
}
}
case 'refresh_token': {
if (!client.grant_types.includes('refresh_token')) {
throw createError({ statusCode: 400, message: '客户端不支持刷新令牌模式' })
}
const refreshResult = refreshSchema.safeParse(body)
if (!refreshResult.success) {
throw createError({ statusCode: 400, message: refreshResult.error.errors[0].message })
}
const { refresh_token } = refreshResult.data
const oldToken = getTokenByRefreshToken(refresh_token)
if (!oldToken || oldToken.client_id !== client_id) {
throw createError({ statusCode: 400, message: '无效的刷新令牌' })
}
revokeToken(refresh_token, 'refresh')
const { accessToken, refreshToken: newRefreshToken, expiresAt } = createToken(
oldToken.user_id!,
client_id,
oldToken.scope || undefined
)
return {
success: true,
data: {
access_token: accessToken,
token_type: 'Bearer',
expires_in: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
refresh_token: newRefreshToken,
scope: oldToken.scope
}
}
}
default:
throw createError({ statusCode: 400, message: '不支持的授权类型' })
}
})

View File

@ -0,0 +1,53 @@
import { verifyToken } from '../../utils/jwt'
import { getUserById } from '../../modules/auth/user'
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: '访问令牌不能为空'
})
}
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access') {
throw createError({
statusCode: 401,
message: '无效的访问令牌'
})
}
const user = getUserById(payload.userId)
if (!user) {
throw createError({
statusCode: 404,
message: '用户不存在'
})
}
const scope = getQuery(event).scope as string | undefined
const scopes = scope?.split(' ') || []
const userInfo: Record<string, any> = {
sub: String(user.id),
name: user.real_name || user.username,
preferred_username: user.username
}
if (scopes.includes('email') || scopes.length === 0) {
userInfo.email = user.email
}
if (scopes.includes('profile') || scopes.length === 0) {
userInfo.profile = {
name: user.real_name,
picture: user.avatar
}
}
return userInfo
})

View File

@ -1,16 +1,40 @@
import db from '../db' import db from '../db'
import bcrypt from 'bcryptjs'
// 初始化种子数据 const ALL_PERMISSIONS = [
export default defineEventHandler(() => { 'user:create', 'user:read', 'user:update', 'user:delete',
'event:create', 'event:read', 'event:update', 'event:delete',
'result:create', 'result:read', 'result:update', 'result:delete',
'team:create', 'team:read', 'team:update', 'team:delete',
'admin:access', 'role:manage',
'oauth:client:create', 'oauth:client:read', 'oauth:client:update', 'oauth:client:delete'
]
const USER_PERMISSIONS = ['event:read', 'result:read', 'team:read']
const GUEST_PERMISSIONS = ['event:read']
export default defineEventHandler(async () => {
try { try {
// 检查是否已有数据 const checkTeams = db.prepare('SELECT COUNT(*) as count FROM teams').get() as any
const checkTeams = db.prepare('SELECT COUNT(*) as count FROM teams').get()
if (checkTeams.count > 0) { if (checkTeams.count > 0) {
return { success: true, message: '数据已存在,跳过初始化' } return { success: true, message: '数据已存在,跳过初始化' }
} }
// 插入示例队伍 const insertRole = db.prepare('INSERT OR IGNORE INTO roles (name, description, permissions, is_system) VALUES (?, ?, ?, ?)')
insertRole.run('admin', '系统管理员', JSON.stringify(ALL_PERMISSIONS), 1)
insertRole.run('user', '普通用户', JSON.stringify(USER_PERMISSIONS), 1)
insertRole.run('guest', '访客', JSON.stringify(GUEST_PERMISSIONS), 1)
const adminRole = db.prepare('SELECT id FROM roles WHERE name = ?').get('admin') as any
const passwordHash = bcrypt.hashSync('admin123', 10)
db.prepare(
'INSERT INTO users (username, password_hash, role_id) VALUES (?, ?, ?)'
).run('admin', passwordHash, adminRole.id)
const insertTeam = db.prepare('INSERT INTO teams (name, team_group) VALUES (?, ?)')
const insertScore = db.prepare('INSERT INTO team_scores (team_id, total_score, gold_count, silver_count, bronze_count) VALUES (?, 0, 0, 0, 0)')
const teams = [ const teams = [
{ name: '高一1班', group: '文化班甲组' }, { name: '高一1班', group: '文化班甲组' },
{ name: '高一2班', group: '文化班甲组' }, { name: '高一2班', group: '文化班甲组' },
@ -23,52 +47,47 @@ export default defineEventHandler(() => {
{ name: '教师队', group: '教师组' } { name: '教师队', group: '教师组' }
] ]
const insertTeam = db.prepare('INSERT INTO teams (name, team_group) VALUES (?, ?)')
const insertScore = db.prepare('INSERT INTO team_scores (team_id, total_score, gold_count, silver_count, bronze_count) VALUES (?, 0, 0, 0, 0)')
teams.forEach(team => { teams.forEach(team => {
const result = insertTeam.run(team.name, team.group) const result = insertTeam.run(team.name, team.group)
insertScore.run(result.lastInsertRowid) insertScore.run(result.lastInsertRowid)
}) })
// 插入示例比赛项目 const insertEvent = db.prepare('INSERT INTO events (name, category, event_group, unit) VALUES (?, ?, ?, ?)')
const events = [ const events = [
// 田赛(个人赛)
{ name: '跳高', category: '田赛', event_group: '文化班甲组', unit: '米' }, { name: '跳高', category: '田赛', event_group: '文化班甲组', unit: '米' },
{ name: '跳远', category: '田赛', event_group: '文化班甲组', unit: '米' }, { name: '跳远', category: '田赛', event_group: '文化班甲组', unit: '米' },
{ name: '掷铅球', category: '田赛', event_group: '文化班乙组', unit: '米' }, { name: '掷铅球', category: '田赛', event_group: '文化班乙组', unit: '米' },
// 径赛(个人赛)
{ name: '100m', category: '径赛', event_group: '体育班组', unit: '秒' }, { name: '100m', category: '径赛', event_group: '体育班组', unit: '秒' },
{ name: '200m', category: '径赛', event_group: '体育班组', unit: '秒' }, { name: '200m', category: '径赛', event_group: '体育班组', unit: '秒' },
{ name: '400m', category: '径赛', event_group: '体育班组', unit: '秒' }, { name: '400m', category: '径赛', event_group: '体育班组', unit: '秒' },
// 径赛(接力赛/团体赛)
{ name: '4×100m', category: '团体赛', event_group: '航空班组', unit: '秒' }, { name: '4×100m', category: '团体赛', event_group: '航空班组', unit: '秒' },
{ name: '4×400m', category: '团体赛', event_group: '航空班组', unit: '秒' }, { name: '4×400m', category: '团体赛', event_group: '航空班组', unit: '秒' },
{ name: '20×50m', category: '团体赛', event_group: '航空班组', unit: '秒' }, { name: '20×50m', category: '团体赛', event_group: '航空班组', unit: '秒' },
// 团体赛
{ name: '旱地龙舟', category: '团体赛', event_group: '教师组', unit: '秒' }, { name: '旱地龙舟', category: '团体赛', event_group: '教师组', unit: '秒' },
{ name: '跳长绳', category: '团体赛', event_group: '文化班甲组', unit: '次' }, { name: '跳长绳', category: '团体赛', event_group: '文化班甲组', unit: '次' },
{ name: '折返跑', category: '团体赛', event_group: '文化班乙组', unit: '秒' } { name: '折返跑', category: '团体赛', event_group: '文化班乙组', unit: '秒' }
] ]
const insertEvent = db.prepare('INSERT INTO events (name, category, event_group, unit) VALUES (?, ?, ?, ?)')
events.forEach(event => { events.forEach(event => {
insertEvent.run(event.name, event.category, event.group, event.unit) insertEvent.run(event.name, event.category, event.event_group, event.unit)
}) })
return { return {
success: true, success: true,
message: '初始化数据成功', message: '初始化数据成功',
data: { data: {
users: 1,
roles: 3,
teams: teams.length, teams: teams.length,
events: events.length events: events.length,
adminAccount: {
username: 'admin',
password: 'admin123'
}
} }
} }
} catch (error) { } catch (error: any) {
console.error('Seed error:', error) console.error('Seed error:', error)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,

View File

@ -53,6 +53,91 @@ db.exec(`
bronze_count INTEGER DEFAULT 0, bronze_count INTEGER DEFAULT 0,
FOREIGN KEY (team_id) REFERENCES teams(id) FOREIGN KEY (team_id) REFERENCES teams(id)
); );
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
permissions TEXT DEFAULT '[]',
is_system INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
real_name TEXT,
avatar TEXT,
role_id INTEGER DEFAULT 2,
status TEXT DEFAULT 'active',
last_login DATETIME,
login_attempts INTEGER DEFAULT 0,
locked_until DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES roles(id)
);
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
client_name TEXT NOT NULL,
redirect_uris TEXT DEFAULT '[]',
allowed_scopes TEXT DEFAULT '[]',
grant_types TEXT DEFAULT '[]',
platform TEXT DEFAULT 'web',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
client_id TEXT NOT NULL,
access_token TEXT UNIQUE NOT NULL,
refresh_token TEXT UNIQUE,
token_type TEXT DEFAULT 'Bearer',
scope TEXT,
expires_at DATETIME NOT NULL,
revoked INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
);
CREATE TABLE IF NOT EXISTS oauth_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
scope TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
expires_at DATETIME NOT NULL,
used INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
);
CREATE TABLE IF NOT EXISTS oauth_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
client_id TEXT NOT NULL,
device_info TEXT,
ip_address TEXT,
last_activity DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
);
`) `)
export default db export default db

96
server/middleware/auth.ts Normal file
View File

@ -0,0 +1,96 @@
import { verifyToken, decodeToken } from '../utils/jwt'
import { getUserById } from '../modules/auth/user'
import { hasPermission, PERMISSIONS } from '../modules/auth/permissions'
import type { User } from 'server/modules/auth/types'
declare module 'h3' {
interface H3EventContext {
user?: User
}
}
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return
}
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access') {
return
}
const user = getUserById(payload.userId)
if (user && user.status === 'active') {
event.context.user = user
}
})
export function requireAuth(event: any) {
if (!event.context.user) {
throw createError({
statusCode: 401,
message: '请先登录'
})
}
}
export function requireRole(event: any, ...roles: string[]) {
requireAuth(event)
if (!event.context.user?.role_name) {
throw createError({
statusCode: 403,
message: '无权限访问'
})
}
if (!roles.includes(event.context.user.role_name)) {
throw createError({
statusCode: 403,
message: '无权限访问此资源'
})
}
}
export function requirePermission(event: any, permission: string) {
requireAuth(event)
const user = event.context.user
if (!user?.permissions || !hasPermission(user.permissions, permission)) {
if (hasPermission(user?.permissions || [], PERMISSIONS.ADMIN_ACCESS)) {
return
}
throw createError({
statusCode: 403,
message: '无此操作权限'
})
}
}
export function optionalAuth(event: any) {
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return
}
const token = authHeader.substring(7)
const payload = verifyToken(token)
if (!payload || payload.type !== 'access') {
return
}
const user = getUserById(payload.userId)
if (user && user.status === 'active') {
event.context.user = user
}
}
export function getCurrentUser(event: any): User | null {
return event.context.user || null
}

View File

@ -0,0 +1,128 @@
export const PERMISSIONS = {
USER_CREATE: 'user:create',
USER_READ: 'user:read',
USER_UPDATE: 'user:update',
USER_DELETE: 'user:delete',
EVENT_CREATE: 'event:create',
EVENT_READ: 'event:read',
EVENT_UPDATE: 'event:update',
EVENT_DELETE: 'event:delete',
RESULT_CREATE: 'result:create',
RESULT_READ: 'result:read',
RESULT_UPDATE: 'result:update',
RESULT_DELETE: 'result:delete',
TEAM_CREATE: 'team:create',
TEAM_READ: 'team:read',
TEAM_UPDATE: 'team:update',
TEAM_DELETE: 'team:delete',
ADMIN_ACCESS: 'admin:access',
ROLE_MANAGE: 'role:manage',
OAUTH_CLIENT_CREATE: 'oauth:client:create',
OAUTH_CLIENT_READ: 'oauth:client:read',
OAUTH_CLIENT_UPDATE: 'oauth:client:update',
OAUTH_CLIENT_DELETE: 'oauth:client:delete'
} as const
export const DEFAULT_ROLES = {
admin: {
name: 'admin',
description: '系统管理员',
permissions: Object.values(PERMISSIONS),
isSystem: true
},
user: {
name: 'user',
description: '普通用户',
permissions: [
PERMISSIONS.EVENT_READ,
PERMISSIONS.RESULT_READ,
PERMISSIONS.TEAM_READ
],
isSystem: true
},
guest: {
name: 'guest',
description: '访客',
permissions: [
PERMISSIONS.EVENT_READ
],
isSystem: true
}
}
export function hasPermission(userPermissions: string[], requiredPermission: string): boolean {
if (userPermissions.includes(PERMISSIONS.ADMIN_ACCESS)) {
return true
}
return userPermissions.includes(requiredPermission)
}
export function hasAnyPermission(userPermissions: string[], requiredPermissions: string[]): boolean {
if (userPermissions.includes(PERMISSIONS.ADMIN_ACCESS)) {
return true
}
return requiredPermissions.some(p => userPermissions.includes(p))
}
export function hasAllPermissions(userPermissions: string[], requiredPermissions: string[]): boolean {
if (userPermissions.includes(PERMISSIONS.ADMIN_ACCESS)) {
return true
}
return requiredPermissions.every(p => userPermissions.includes(p))
}
export function getPermissionGroups(): Record<string, { name: string; permissions: string[] }> {
return {
user: {
name: '用户管理',
permissions: [
PERMISSIONS.USER_CREATE,
PERMISSIONS.USER_READ,
PERMISSIONS.USER_UPDATE,
PERMISSIONS.USER_DELETE
]
},
event: {
name: '比赛管理',
permissions: [
PERMISSIONS.EVENT_CREATE,
PERMISSIONS.EVENT_READ,
PERMISSIONS.EVENT_UPDATE,
PERMISSIONS.EVENT_DELETE
]
},
result: {
name: '成绩管理',
permissions: [
PERMISSIONS.RESULT_CREATE,
PERMISSIONS.RESULT_READ,
PERMISSIONS.RESULT_UPDATE,
PERMISSIONS.RESULT_DELETE
]
},
team: {
name: '队伍管理',
permissions: [
PERMISSIONS.TEAM_CREATE,
PERMISSIONS.TEAM_READ,
PERMISSIONS.TEAM_UPDATE,
PERMISSIONS.TEAM_DELETE
]
},
system: {
name: '系统管理',
permissions: [
PERMISSIONS.ADMIN_ACCESS,
PERMISSIONS.ROLE_MANAGE,
PERMISSIONS.OAUTH_CLIENT_CREATE,
PERMISSIONS.OAUTH_CLIENT_READ,
PERMISSIONS.OAUTH_CLIENT_UPDATE,
PERMISSIONS.OAUTH_CLIENT_DELETE
]
}
}
}

View File

@ -0,0 +1,100 @@
export interface Role {
id: number
name: string
description?: string
permissions: string[]
is_system?: number
created_at?: string
updated_at?: string
}
export interface User {
id: number
username: string
email?: string
password_hash?: string
real_name?: string
avatar?: string
role_id: number
role_name?: string
permissions?: string[]
status: 'active' | 'inactive' | 'banned'
last_login?: string
login_attempts?: number
locked_until?: string
created_at?: string
updated_at?: string
}
export interface OAuthClient {
id: number
client_id: string
client_secret_hash?: string
client_name: string
redirect_uris: string[]
allowed_scopes: string[]
grant_types: string[]
platform: string
is_active: number
created_at?: string
updated_at?: string
}
export interface OAuthToken {
id: number
user_id?: number
client_id: string
access_token: string
refresh_token?: string
token_type: string
scope?: string
expires_at: string
revoked: number
created_at?: string
}
export interface OAuthCode {
id: number
code: string
user_id: number
client_id: string
redirect_uri: string
scope?: string
code_challenge?: string
code_challenge_method?: string
expires_at: string
used: number
created_at?: string
}
export interface OAuthSession {
id: number
session_id: string
user_id: number
client_id: string
device_info?: string
ip_address?: string
last_activity?: string
created_at?: string
}
export interface JWTPayload {
sub: string
userId: number
username: string
role: string
permissions: string[]
type: 'access' | 'refresh'
iat?: number
exp?: number
}
export interface LoginLog {
id: number
user_id: number
ip_address: string
user_agent: string
success: boolean
fail_reason?: string
created_at: string
}

200
server/modules/auth/user.ts Normal file
View File

@ -0,0 +1,200 @@
import db from '../../db'
import bcrypt from 'bcryptjs'
import type { User, Role } from './types'
export const SALT_ROUNDS = 10
export function getRoles(): Role[] {
const stmt = db.prepare('SELECT * FROM roles ORDER BY is_system DESC, id ASC')
const rows = stmt.all() as any[]
return rows.map(row => ({
...row,
permissions: JSON.parse(row.permissions || '[]')
}))
}
export function getRoleByName(name: string): Role | undefined {
const stmt = db.prepare('SELECT * FROM roles WHERE name = ?')
const row = stmt.get(name) as any
if (!row) return undefined
return {
...row,
permissions: JSON.parse(row.permissions || '[]')
}
}
export function createRole(name: string, description: string, permissions: string[], isSystem = false): Role {
const stmt = db.prepare(
'INSERT INTO roles (name, description, permissions, is_system) VALUES (?, ?, ?, ?)'
)
const result = stmt.run(name, description, JSON.stringify(permissions), isSystem ? 1 : 0)
return {
id: Number(result.lastInsertRowid),
name,
description,
permissions,
is_system: isSystem,
created_at: new Date().toISOString()
}
}
export function getUsers(options?: { roleId?: number; status?: string }): User[] {
let sql = 'SELECT u.*, r.name as role_name, r.permissions as role_permissions FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE 1=1'
const params: any[] = []
if (options?.roleId) {
sql += ' AND u.role_id = ?'
params.push(options.roleId)
}
if (options?.status) {
sql += ' AND u.status = ?'
params.push(options.status)
}
sql += ' ORDER BY u.created_at DESC'
const stmt = db.prepare(sql)
const rows = stmt.all(...params) as any[]
return rows.map(row => ({
...row,
permissions: JSON.parse(row.role_permissions || '[]')
}))
}
export function getUserById(id: number): User | undefined {
const stmt = db.prepare('SELECT u.*, r.name as role_name, r.permissions as role_permissions FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.id = ?')
const row = stmt.get(id) as any
if (!row) return undefined
return {
...row,
permissions: JSON.parse(row.role_permissions || '[]')
}
}
export function getUserByUsername(username: string): User | undefined {
const stmt = db.prepare('SELECT u.*, r.name as role_name, r.permissions as role_permissions FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.username = ?')
const row = stmt.get(username) as any
if (!row) return undefined
return {
...row,
permissions: JSON.parse(row.role_permissions || '[]')
}
}
export function getUserByEmail(email: string): User | undefined {
const stmt = db.prepare('SELECT u.*, r.name as role_name, r.permissions as role_permissions FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.email = ?')
const row = stmt.get(email) as any
if (!row) return undefined
return {
...row,
permissions: JSON.parse(row.role_permissions || '[]')
}
}
export async function createUser(
username: string,
passwordHash: string,
email?: string,
realName?: string,
roleId = 2
): Promise<User> {
const stmt = db.prepare(
'INSERT INTO users (username, password_hash, email, real_name, role_id) VALUES (?, ?, ?, ?, ?)'
)
const result = stmt.run(username, passwordHash, email || null, realName || null, roleId)
return getUserById(Number(result.lastInsertRowid))!
}
export async function updateUser(
id: number,
data: {
email?: string
realName?: string
roleId?: number
status?: string
avatar?: string
}
): Promise<User | undefined> {
const updates: string[] = []
const params: any[] = []
if (data.email !== undefined) {
updates.push('email = ?')
params.push(data.email)
}
if (data.realName !== undefined) {
updates.push('real_name = ?')
params.push(data.realName)
}
if (data.roleId !== undefined) {
updates.push('role_id = ?')
params.push(data.roleId)
}
if (data.status !== undefined) {
updates.push('status = ?')
params.push(data.status)
}
if (data.avatar !== undefined) {
updates.push('avatar = ?')
params.push(data.avatar)
}
if (updates.length === 0) return getUserById(id)
updates.push('updated_at = CURRENT_TIMESTAMP')
params.push(id)
const stmt = db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`)
stmt.run(...params)
return getUserById(id)
}
export async function updatePassword(id: number, newPasswordHash: string): Promise<void> {
const stmt = db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
stmt.run(newPasswordHash, id)
}
export function deleteUser(id: number): boolean {
const stmt = db.prepare('DELETE FROM users WHERE id = ?')
const result = stmt.run(id)
return result.changes > 0
}
export function updateLastLogin(id: number): void {
const stmt = db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?')
stmt.run(id)
}
export function incrementLoginAttempts(id: number, lockedUntil?: Date): void {
if (lockedUntil) {
const stmt = db.prepare('UPDATE users SET login_attempts = login_attempts + 1, locked_until = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
stmt.run(lockedUntil.toISOString(), id)
} else {
const stmt = db.prepare('UPDATE users SET login_attempts = login_attempts + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
stmt.run(id)
}
}
export function resetLoginAttempts(id: number): void {
const stmt = db.prepare('UPDATE users SET login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
stmt.run(id)
}
export function hashPassword(password: string): string {
return bcrypt.hashSync(password, SALT_ROUNDS)
}
export function verifyPassword(password: string, hash: string): boolean {
return bcrypt.compareSync(password, hash)
}
export function validatePassword(password: string): { valid: boolean; message?: string } {
if (password.length < 6) {
return { valid: false, message: '密码长度至少6位' }
}
if (/^\d+$/.test(password)) {
return { valid: false, message: '密码不能为纯数字' }
}
return { valid: true }
}

View File

@ -0,0 +1,266 @@
import db from '../../db'
import { v4 as uuidv4 } from 'uuid'
import bcrypt from 'bcryptjs'
import type { OAuthClient, OAuthToken, OAuthCode } from '../modules/auth/types'
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60 * 1000
const REFRESH_TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000
const CODE_EXPIRY = 10 * 60 * 1000
export function getClients(): OAuthClient[] {
const stmt = db.prepare('SELECT * FROM oauth_clients ORDER BY created_at DESC')
const rows = stmt.all() as any[]
return rows.map(row => ({
...row,
redirect_uris: JSON.parse(row.redirect_uris || '[]'),
allowed_scopes: JSON.parse(row.allowed_scopes || '[]'),
grant_types: JSON.parse(row.grant_types || '[]')
}))
}
export function getClientById(clientId: string): OAuthClient | undefined {
const stmt = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?')
const row = stmt.get(clientId) as any
if (!row) return undefined
return {
...row,
redirect_uris: JSON.parse(row.redirect_uris || '[]'),
allowed_scopes: JSON.parse(row.allowed_scopes || '[]'),
grant_types: JSON.parse(row.grant_types || '[]')
}
}
export function createClient(
clientName: string,
redirectUris: string[],
allowedScopes: string[],
grantTypes: string[],
platform = 'web'
): { client: OAuthClient; clientSecret: string } {
const clientId = uuidv4().replace(/-/g, '')
const clientSecret = uuidv4()
const clientSecretHash = bcrypt.hashSync(clientSecret, 10)
const stmt = db.prepare(`
INSERT INTO oauth_clients
(client_id, client_secret_hash, client_name, redirect_uris, allowed_scopes, grant_types, platform)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
stmt.run(
clientId,
clientSecretHash,
clientName,
JSON.stringify(redirectUris),
JSON.stringify(allowedScopes),
JSON.stringify(grantTypes),
platform
)
return {
client: {
id: 0,
client_id: clientId,
client_name: clientName,
redirect_uris: redirectUris,
allowed_scopes: allowedScopes,
grant_types: grantTypes,
platform,
is_active: 1
},
clientSecret
}
}
export function verifyClientSecret(client: OAuthClient, clientSecret: string): boolean {
return bcrypt.compareSync(clientSecret, client.client_secret_hash || '')
}
export function updateClient(clientId: string, data: Partial<OAuthClient>): OAuthClient | undefined {
const updates: string[] = []
const params: any[] = []
if (data.client_name !== undefined) {
updates.push('client_name = ?')
params.push(data.client_name)
}
if (data.redirect_uris !== undefined) {
updates.push('redirect_uris = ?')
params.push(JSON.stringify(data.redirect_uris))
}
if (data.allowed_scopes !== undefined) {
updates.push('allowed_scopes = ?')
params.push(JSON.stringify(data.allowed_scopes))
}
if (data.grant_types !== undefined) {
updates.push('grant_types = ?')
params.push(JSON.stringify(data.grant_types))
}
if (data.platform !== undefined) {
updates.push('platform = ?')
params.push(data.platform)
}
if (data.is_active !== undefined) {
updates.push('is_active = ?')
params.push(data.is_active)
}
if (updates.length === 0) return getClientById(clientId)
updates.push('updated_at = CURRENT_TIMESTAMP')
params.push(clientId)
const stmt = db.prepare(`UPDATE oauth_clients SET ${updates.join(', ')} WHERE client_id = ?`)
stmt.run(...params)
return getClientById(clientId)
}
export function deleteClient(clientId: string): boolean {
const stmt = db.prepare('DELETE FROM oauth_clients WHERE client_id = ?')
const result = stmt.run(clientId)
return result.changes > 0
}
export function createToken(
userId: number,
clientId: string,
scope?: string
): { accessToken: string; refreshToken: string; expiresAt: Date } {
const accessToken = uuidv4().replace(/-/g, '')
const refreshToken = uuidv4().replace(/-/g, '')
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY)
const stmt = db.prepare(`
INSERT INTO oauth_tokens
(user_id, client_id, access_token, refresh_token, scope, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`)
stmt.run(userId, clientId, accessToken, refreshToken, scope || null, expiresAt.toISOString())
return { accessToken, refreshToken, expiresAt }
}
export function createTokenWithoutRefresh(
userId: number,
clientId: string,
scope?: string
): { accessToken: string; expiresAt: Date } {
const accessToken = uuidv4().replace(/-/g, '')
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY)
const stmt = db.prepare(`
INSERT INTO oauth_tokens
(user_id, client_id, access_token, scope, expires_at)
VALUES (?, ?, ?, ?, ?)
`)
stmt.run(userId, clientId, accessToken, scope || null, expiresAt.toISOString())
return { accessToken, expiresAt }
}
export function createClientCredentialsToken(
clientId: string,
scope?: string
): { accessToken: string; expiresAt: Date } {
const accessToken = uuidv4().replace(/-/g, '')
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY)
const stmt = db.prepare(`
INSERT INTO oauth_tokens
(client_id, access_token, scope, expires_at)
VALUES (?, ?, ?, ?)
`)
stmt.run(clientId, accessToken, scope || null, expiresAt.toISOString())
return { accessToken, expiresAt }
}
export function getTokenByAccessToken(accessToken: string): OAuthToken | undefined {
const stmt = db.prepare('SELECT * FROM oauth_tokens WHERE access_token = ? AND revoked = 0')
const row = stmt.get(accessToken) as any
if (!row) return undefined
return {
...row,
refresh_token: row.refresh_token || undefined,
scope: row.scope || undefined
}
}
export function getTokenByRefreshToken(refreshToken: string): OAuthToken | undefined {
const stmt = db.prepare('SELECT * FROM oauth_tokens WHERE refresh_token = ? AND revoked = 0')
const row = stmt.get(refreshToken) as any
if (!row) return undefined
return {
...row,
scope: row.scope || undefined
}
}
export function refreshAccessToken(refreshToken: string): { accessToken: string; refreshToken: string; expiresAt: Date } | null {
const oldToken = getTokenByRefreshToken(refreshToken)
if (!oldToken) return null
revokeToken(refreshToken, 'refresh')
return createToken(oldToken.user_id!, oldToken.client_id, oldToken.scope || undefined)
}
export function revokeToken(token: string, type: 'access' | 'refresh' = 'access'): boolean {
const column = type === 'access' ? 'access_token' : 'refresh_token'
const stmt = db.prepare(`UPDATE oauth_tokens SET revoked = 1 WHERE ${column} = ?`)
const result = stmt.run(token)
return result.changes > 0
}
export function revokeAllUserTokens(userId: number): number {
const stmt = db.prepare('UPDATE oauth_tokens SET revoked = 1 WHERE user_id = ?')
const result = stmt.run(userId)
return result.changes
}
export function createAuthorizationCode(
userId: number,
clientId: string,
redirectUri: string,
scope?: string,
codeChallenge?: string,
codeChallengeMethod?: string
): string {
const code = uuidv4().replace(/-/g, '')
const expiresAt = new Date(Date.now() + CODE_EXPIRY)
const stmt = db.prepare(`
INSERT INTO oauth_codes
(code, user_id, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
stmt.run(code, userId, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, expiresAt.toISOString())
return code
}
export function getAuthorizationCode(code: string): OAuthCode | undefined {
const stmt = db.prepare('SELECT * FROM oauth_codes WHERE code = ? AND used = 0 AND expires_at > datetime("now")')
const row = stmt.get(code) as any
if (!row) return undefined
return {
...row,
scope: row.scope || undefined,
code_challenge: row.code_challenge || undefined,
code_challenge_method: row.code_challenge_method || undefined
}
}
export function useAuthorizationCode(code: string): boolean {
const stmt = db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?')
const result = stmt.run(code)
return result.changes > 0
}
export function validateRedirectUri(client: OAuthClient, redirectUri: string): boolean {
return client.redirect_uris.includes(redirectUri)
}
export function validateScope(client: OAuthClient, scope: string): boolean {
if (!scope) return true
const requestedScopes = scope.split(' ')
return requestedScopes.every(s => client.allowed_scopes.includes(s))
}

45
server/utils/jwt.ts Normal file
View File

@ -0,0 +1,45 @@
import jwt from 'jsonwebtoken'
import type { JWTPayload } from '../modules/auth/types'
const JWT_SECRET = process.env.JWT_SECRET || 'sport-meeting-admin-secret-key-2026'
const ACCESS_TOKEN_EXPIRY = '7d'
const REFRESH_TOKEN_EXPIRY = '30d'
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp' | 'type'>): string {
return jwt.sign({ ...payload, type: 'access' }, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY })
}
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp' | 'type'>): string {
return jwt.sign({ ...payload, type: 'refresh' }, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY })
}
export function verifyToken(token: string): JWTPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload
return decoded
} catch {
return null
}
}
export function decodeToken(token: string): JWTPayload | null {
try {
return jwt.decode(token) as JWTPayload
} catch {
return null
}
}
export function isAccessToken(token: JWTPayload): boolean {
return token.type === 'access'
}
export function isRefreshToken(token: JWTPayload): boolean {
return token.type === 'refresh'
}
export function getTokenExpiry(token: string): Date | null {
const decoded = decodeToken(token)
if (!decoded?.exp) return null
return new Date(decoded.exp * 1000)
}