feat: 多用户管理系统,管理员可分配用户

- 新增 users 数据库表,存储用户名、密码哈希、角色
- 启动时自动从 .env 创建初始管理员账号
- 登录改为数据库校验,不再硬编码比对
- 新增 /api/users 管理接口(仅管理员)
- ConfigView 新增用户管理页(创建、改密码、切换角色、删除)
This commit is contained in:
shen 2026-06-08 21:08:16 +08:00
parent 1cecf66d94
commit 48fdca12bd
6 changed files with 327 additions and 10 deletions

View File

@ -34,7 +34,24 @@ def _migrate_db():
pass # 列已存在则忽略 pass # 列已存在则忽略
def _seed_admin():
"""初始化管理员账号(从 .env 配置)"""
from .models import User
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
import hashlib
db = SessionLocal()
try:
if not db.query(User).filter(User.username == ADMIN_USERNAME).first():
pw_hash = hashlib.sha256(ADMIN_PASSWORD.encode()).hexdigest()
db.add(User(id="admin-001", username=ADMIN_USERNAME, password_hash=pw_hash, is_admin=True))
db.commit()
finally:
db.close()
def init_db(): def init_db():
"""初始化数据库""" """初始化数据库"""
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_db() _migrate_db()
_seed_admin()

View File

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from .database import init_db from .database import init_db
from .routers import config, apps, tasks, auth from .routers import config, apps, tasks, auth, users
from .services.log_streamer import log_streamer from .services.log_streamer import log_streamer
from .config import BACKEND_PORT from .config import BACKEND_PORT
@ -38,6 +38,7 @@ app.add_middleware(
# 路由 # 路由
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(users.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(apps.router) app.include_router(apps.router)
app.include_router(tasks.router) app.include_router(tasks.router)

View File

@ -49,3 +49,14 @@ class BuildConfig(Base):
key = Column(String, primary_key=True) key = Column(String, primary_key=True)
value = Column(String) value = Column(String)
updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(Base):
"""用户表"""
__tablename__ = "users"
id = Column(String, primary_key=True)
username = Column(String, unique=True, nullable=False)
password_hash = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, default=datetime.utcnow)

View File

@ -2,10 +2,13 @@
import jwt import jwt
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..schemas import LoginRequest, LoginResponse from ..schemas import LoginRequest, LoginResponse
from ..config import ADMIN_USERNAME, ADMIN_PASSWORD, JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS from ..config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
from ..deps import get_current_user from ..deps import get_current_user
from ..database import get_db
from ..models import User
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -20,13 +23,21 @@ def _create_token(username: str, is_admin: bool) -> str:
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _verify_password(password: str, password_hash: str) -> bool:
"""校验密码"""
import hashlib
return hashlib.sha256(password.encode()).hexdigest() == password_hash
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest): async def login(request: LoginRequest, db: Session = Depends(get_db)):
"""管理员登录""" """用户登录"""
if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD: user = db.query(User).filter(User.username == request.username).first()
token = _create_token(request.username, is_admin=True) if not user or not _verify_password(request.password, user.password_hash):
return LoginResponse(token=token, username=request.username, is_admin=True) raise HTTPException(status_code=401, detail="用户名或密码错误")
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = _create_token(user.username, user.is_admin)
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
@router.get("/me") @router.get("/me")

103
backend/routers/users.py Normal file
View File

@ -0,0 +1,103 @@
"""用户管理 API仅管理员"""
import uuid
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User
from ..deps import get_current_user
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
def _require_admin(user: dict):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
@router.get("")
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取用户列表"""
_require_admin(user)
users = db.query(User).order_by(User.created_at.desc()).all()
return [{"id": u.id, "username": u.username, "is_admin": u.is_admin, "created_at": str(u.created_at) if u.created_at else None} for u in users]
@router.post("")
async def create_user(data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""创建用户"""
_require_admin(user)
username = data.get("username", "").strip()
password = data.get("password", "").strip()
is_admin = data.get("is_admin", False)
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
if len(password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6 位")
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
id=str(uuid.uuid4()),
username=username,
password_hash=_hash_password(password),
is_admin=is_admin,
)
db.add(new_user)
db.commit()
return {"message": "用户已创建", "id": new_user.id}
@router.put("/{user_id}/password")
async def change_password(user_id: str, data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""修改密码(管理员可改任意用户)"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
new_password = data.get("password", "").strip()
if not new_password or len(new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6 位")
target.password_hash = _hash_password(new_password)
db.commit()
return {"message": "密码已更新"}
@router.put("/{user_id}/role")
async def change_role(user_id: str, data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""切换管理员状态"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
target.is_admin = data.get("is_admin", not target.is_admin)
db.commit()
return {"message": "角色已更新", "is_admin": target.is_admin}
@router.delete("/{user_id}")
async def delete_user(user_id: str, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除用户"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
if target.username == user["username"]:
raise HTTPException(status_code=400, detail="不能删除自己")
db.delete(target)
db.commit()
return {"message": "用户已删除"}

View File

@ -9,6 +9,7 @@
<div class="sidebar"> <div class="sidebar">
<h3>配置管理</h3> <h3>配置管理</h3>
<ul class="sidebar-menu"> <ul class="sidebar-menu">
<li :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li> <li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li> <li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li> <li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
@ -19,6 +20,42 @@
</ul> </ul>
</div> </div>
<div class="main-content"> <div class="main-content">
<!-- 用户管理 -->
<div v-if="tab === 'users'">
<h2>用户管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理员可以创建和管理普通用户账号</p>
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openUserModal()">+ 新增用户</button>
<table class="config-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><strong>{{ u.username }}</strong></td>
<td>
<span :class="['build-type-badge', u.is_admin ? 'badge-appstore' : 'badge-adhoc']">
{{ u.is_admin ? '管理员' : '普通用户' }}
</span>
</td>
<td>{{ u.created_at || '-' }}</td>
<td class="action-btns">
<button class="action-btn" @click="openPasswordModal(u)">改密码</button>
<button class="action-btn" @click="toggleUserRole(u)">{{ u.is_admin ? '取消管理员' : '设为管理员' }}</button>
<button class="action-btn delete" @click="deleteUser(u)">删除</button>
</td>
</tr>
<tr v-if="!users.length">
<td colspan="4" style="text-align: center; color: #999;">暂无用户</td>
</tr>
</tbody>
</table>
</div>
<!-- 服务器环境配置 --> <!-- 服务器环境配置 -->
<div v-if="tab === 'servers'"> <div v-if="tab === 'servers'">
<h2>服务器环境配置</h2> <h2>服务器环境配置</h2>
@ -275,6 +312,52 @@
</div> </div>
</div> </div>
<!-- 新增用户弹窗 -->
<div v-if="showUserModal" class="modal-overlay" @click.self="showUserModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>新增用户</h3>
<button class="modal-close" @click="showUserModal = false">&times;</button>
</div>
<div class="form-group">
<label>用户名 *</label>
<input v-model="userForm.username" type="text" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码 *</label>
<input v-model="userForm.password" type="password" placeholder="至少 6 位">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="userForm.is_admin" style="width: 16px; height: 16px;">
管理员权限
</label>
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showUserModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="createUser">创建</button>
</div>
</div>
</div>
<!-- 修改密码弹窗 -->
<div v-if="showPasswordModal" class="modal-overlay" @click.self="showPasswordModal = false">
<div class="modal-content" style="width: 400px;">
<div class="modal-header">
<h3>修改密码 - {{ passwordTarget?.username }}</h3>
<button class="modal-close" @click="showPasswordModal = false">&times;</button>
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" placeholder="至少 6 位">
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="changePassword">确认</button>
</div>
</div>
</div>
<!-- 服务器环境编辑弹窗 --> <!-- 服务器环境编辑弹窗 -->
<div v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false"> <div v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false">
<div class="modal-content"> <div class="modal-content">
@ -449,7 +532,15 @@ const showLogin = inject('showLogin')
const getToken = inject('getToken') const getToken = inject('getToken')
const isLoggedIn = inject('isLoggedIn') const isLoggedIn = inject('isLoggedIn')
const isAdmin = inject('isAdmin') const isAdmin = inject('isAdmin')
const tab = ref('servers') const tab = ref('users')
//
const users = ref([])
const showUserModal = ref(false)
const userForm = ref({ username: '', password: '', is_admin: false })
const showPasswordModal = ref(false)
const passwordTarget = ref(null)
const newPassword = ref('')
// fetch // fetch
const authFetch = (url, options = {}) => { const authFetch = (url, options = {}) => {
@ -486,7 +577,7 @@ onMounted(async () => {
}) })
const loadData = async () => { const loadData = async () => {
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes] = await Promise.all([ const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'), authFetch('/api/config/apps'),
authFetch('/api/config/schemes'), authFetch('/api/config/schemes'),
authFetch('/api/config/servers'), authFetch('/api/config/servers'),
@ -495,6 +586,7 @@ const loadData = async () => {
authFetch('/api/config/upload'), authFetch('/api/config/upload'),
authFetch('/api/config'), authFetch('/api/config'),
authFetch('/api/config/versions'), authFetch('/api/config/versions'),
authFetch('/api/users'),
]) ])
apps.value = await appsRes.json() apps.value = await appsRes.json()
schemes.value = await schemesRes.json() schemes.value = await schemesRes.json()
@ -504,6 +596,88 @@ const loadData = async () => {
uploadConfig.value = await uploadRes.json() uploadConfig.value = await uploadRes.json()
jsonContent.value = JSON.stringify(await configRes.json(), null, 2) jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
versions.value = await versionsRes.json() versions.value = await versionsRes.json()
if (usersRes.ok) users.value = await usersRes.json()
}
//
const openUserModal = () => {
userForm.value = { username: '', password: '', is_admin: false }
showUserModal.value = true
}
const createUser = async () => {
if (!userForm.value.username || !userForm.value.password) {
alert('请填写用户名和密码')
return
}
try {
const res = await authFetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userForm.value),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '创建失败')
}
showUserModal.value = false
await loadData()
} catch (e) {
alert('创建失败: ' + e.message)
}
}
const openPasswordModal = (u) => {
passwordTarget.value = u
newPassword.value = ''
showPasswordModal.value = true
}
const changePassword = async () => {
if (!newPassword.value || newPassword.value.length < 6) {
alert('密码至少 6 位')
return
}
try {
const res = await authFetch(`/api/users/${passwordTarget.value.id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword.value }),
})
if (!res.ok) throw new Error('修改失败')
showPasswordModal.value = false
alert('密码已更新')
} catch (e) {
alert('修改失败: ' + e.message)
}
}
const toggleUserRole = async (u) => {
try {
const res = await authFetch(`/api/users/${u.id}/role`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: !u.is_admin }),
})
if (!res.ok) throw new Error('操作失败')
await loadData()
} catch (e) {
alert('操作失败: ' + e.message)
}
}
const deleteUser = async (u) => {
if (!confirm(`确定删除用户「${u.username}」?`)) return
try {
const res = await authFetch(`/api/users/${u.id}`, { method: 'DELETE' })
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '删除失败')
}
await loadData()
} catch (e) {
alert('删除失败: ' + e.message)
}
} }
// //