用户详情

This commit is contained in:
ryan
2026-06-09 15:30:12 +08:00
parent 92664273ed
commit 73b220de3c
11 changed files with 1005 additions and 20 deletions
+177
View File
@@ -253,6 +253,7 @@ const docTemplate = `{
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "认证源 ID 或名称",
"name": "id",
"in": "path",
@@ -330,6 +331,7 @@ const docTemplate = `{
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "认证源 ID 或名称",
"name": "id",
"in": "path",
@@ -397,6 +399,7 @@ const docTemplate = `{
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "认证源 ID 或名称",
"name": "id",
"in": "path",
@@ -1948,6 +1951,156 @@ const docTemplate = `{
}
}
},
"/api/v1/admin/users/{id}": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "获取用户详情",
"parameters": [
{
"type": "integer",
"description": "用户 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "用户详情",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/user.user"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"404": {
"description": "用户不存在",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
},
"delete": {
"security": [
{
"SessionCookie": []
}
],
"description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "删除用户",
"parameters": [
{
"type": "integer",
"description": "用户 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "删除成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限、尝试删除管理员或当前用户",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"404": {
"description": "用户不存在",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/admin/users/{id}/status": {
"put": {
"security": [
@@ -2225,6 +2378,7 @@ const docTemplate = `{
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "外部帐号绑定记录 ID",
"name": "id",
"in": "path",
@@ -4084,6 +4238,11 @@ const docTemplate = `{
"UploadStatusPending": "待使用",
"UploadStatusUsed": "已使用"
},
"x-enum-descriptions": [
"待使用",
"已使用",
"已删除"
],
"x-enum-varnames": [
"UploadStatusPending",
"UploadStatusUsed",
@@ -4723,9 +4882,18 @@ const docTemplate = `{
"avatar_url": {
"type": "string"
},
"bio": {
"type": "string"
},
"created_at": {
"type": "string"
},
"email": {
"type": "string"
},
"gender": {
"type": "string"
},
"id": {
"type": "integer"
},
@@ -4738,14 +4906,23 @@ const docTemplate = `{
"last_login_at": {
"type": "string"
},
"location": {
"type": "string"
},
"nickname": {
"type": "string"
},
"phone": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"username": {
"type": "string"
},
"website": {
"type": "string"
}
}
},
+177
View File
@@ -246,6 +246,7 @@
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "认证源 ID 或名称",
"name": "id",
"in": "path",
@@ -323,6 +324,7 @@
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "认证源 ID 或名称",
"name": "id",
"in": "path",
@@ -390,6 +392,7 @@
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "认证源 ID 或名称",
"name": "id",
"in": "path",
@@ -1941,6 +1944,156 @@
}
}
},
"/api/v1/admin/users/{id}": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "获取用户详情",
"parameters": [
{
"type": "integer",
"description": "用户 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "用户详情",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/user.user"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"404": {
"description": "用户不存在",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
},
"delete": {
"security": [
{
"SessionCookie": []
}
],
"description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "删除用户",
"parameters": [
{
"type": "integer",
"description": "用户 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "删除成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限、尝试删除管理员或当前用户",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"404": {
"description": "用户不存在",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/admin/users/{id}/status": {
"put": {
"security": [
@@ -2218,6 +2371,7 @@
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "外部帐号绑定记录 ID",
"name": "id",
"in": "path",
@@ -4077,6 +4231,11 @@
"UploadStatusPending": "待使用",
"UploadStatusUsed": "已使用"
},
"x-enum-descriptions": [
"待使用",
"已使用",
"已删除"
],
"x-enum-varnames": [
"UploadStatusPending",
"UploadStatusUsed",
@@ -4716,9 +4875,18 @@
"avatar_url": {
"type": "string"
},
"bio": {
"type": "string"
},
"created_at": {
"type": "string"
},
"email": {
"type": "string"
},
"gender": {
"type": "string"
},
"id": {
"type": "integer"
},
@@ -4731,14 +4899,23 @@
"last_login_at": {
"type": "string"
},
"location": {
"type": "string"
},
"nickname": {
"type": "string"
},
"phone": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"username": {
"type": "string"
},
"website": {
"type": "string"
}
}
},
+111
View File
@@ -409,6 +409,10 @@ definitions:
UploadStatusDeleted: 已删除
UploadStatusPending: 待使用
UploadStatusUsed: 已使用
x-enum-descriptions:
- 待使用
- 已使用
- 已删除
x-enum-varnames:
- UploadStatusPending
- UploadStatusUsed
@@ -841,8 +845,14 @@ definitions:
properties:
avatar_url:
type: string
bio:
type: string
created_at:
type: string
email:
type: string
gender:
type: string
id:
type: integer
is_active:
@@ -851,12 +861,18 @@ definitions:
type: boolean
last_login_at:
type: string
location:
type: string
nickname:
type: string
phone:
type: string
updated_at:
type: string
username:
type: string
website:
type: string
type: object
util.ResponseAny:
properties:
@@ -1010,6 +1026,7 @@ paths:
description: 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限
parameters:
- description: 认证源 ID 或名称
format: int64
in: path
name: id
required: true
@@ -1049,6 +1066,7 @@ paths:
description: 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限
parameters:
- description: 认证源 ID 或名称
format: int64
in: path
name: id
required: true
@@ -1099,6 +1117,7 @@ paths:
description: 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限
parameters:
- description: 认证源 ID 或名称
format: int64
in: path
name: id
required: true
@@ -2038,6 +2057,97 @@ paths:
summary: 创建用户
tags:
- admin
/api/v1/admin/users/{id}:
delete:
description: 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户
parameters:
- description: 用户 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 删除成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
type: string
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/util.ResponseAny'
"401":
description: 未登录
schema:
$ref: '#/definitions/util.ResponseAny'
"403":
description: 无管理员权限、尝试删除管理员或当前用户
schema:
$ref: '#/definitions/util.ResponseAny'
"404":
description: 用户不存在
schema:
$ref: '#/definitions/util.ResponseAny'
"500":
description: 内部错误
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 删除用户
tags:
- admin
get:
description: 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段
parameters:
- description: 用户 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 用户详情
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
$ref: '#/definitions/user.user'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/util.ResponseAny'
"401":
description: 未登录
schema:
$ref: '#/definitions/util.ResponseAny'
"403":
description: 无管理员权限
schema:
$ref: '#/definitions/util.ResponseAny'
"404":
description: 用户不存在
schema:
$ref: '#/definitions/util.ResponseAny'
"500":
description: 内部错误
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 获取用户详情
tags:
- admin
/api/v1/admin/users/{id}/status:
put:
consumes:
@@ -2235,6 +2345,7 @@ paths:
description: 解除当前登录用户与指定外部帐号的绑定关系,需要登录
parameters:
- description: 外部帐号绑定记录 ID
format: int64
in: path
name: id
required: true
+159 -4
View File
@@ -14,16 +14,32 @@ import {
ChevronRight,
Eye,
Filter,
Globe,
Layers,
Loader2,
Mail,
MapPin,
Plus,
Search,
ShieldCheck,
Smartphone,
Trash2,
UserCheck,
UserX,
VenusAndMars,
X
} from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog"
import {AdminUser} from "@/lib/services"
import {cn, formatDateTime} from "@/lib/utils"
@@ -51,11 +67,16 @@ export function UsersManager() {
setSearchUsername,
setStatusFilter,
fetchUsers,
updateUserStatus
getUserDetail,
updateUserStatus,
deleteUser
} = useAdminUsers()
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
useEffect(() => {
@@ -70,12 +91,38 @@ export function UsersManager() {
}
}
const handleShowDetail = (user: AdminUser) => {
const handleShowDetail = async (user: AdminUser) => {
setSelectedUser(user)
setDetailOpen(true)
setDetailLoading(true)
try {
const detail = await getUserDetail(user.id)
setSelectedUser(detail)
} catch {
setSelectedUser(user)
} finally {
setDetailLoading(false)
}
}
const handleDeleteUser = async () => {
if (!deleteTarget) return
setDeleteLoading(true)
try {
await deleteUser(deleteTarget)
if (selectedUser?.id === deleteTarget.id) {
setDetailOpen(false)
setSelectedUser(null)
}
setDeleteTarget(null)
} finally {
setDeleteLoading(false)
}
}
const displayValue = (value?: string) => value && value.trim() ? value : "-"
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
@@ -318,7 +365,7 @@ export function UsersManager() {
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[80px] py-2 h-8">操作</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -394,6 +441,26 @@ export function UsersManager() {
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!user.is_admin && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(user)}
>
<Trash2 className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
删除用户
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
@@ -435,6 +502,13 @@ export function UsersManager() {
</div>
</div>
{detailLoading && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
正在刷新详情
</div>
)}
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
@@ -445,9 +519,64 @@ export function UsersManager() {
</div>
<div className="p-6 space-y-6">
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">个人资料</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Mail className="size-3" />
邮箱
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.email)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Smartphone className="size-3" />
手机
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.phone)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<VenusAndMars className="size-3" />
性别
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.gender)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<MapPin className="size-3" />
所在地
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.location)}</span>
</div>
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Globe className="size-3" />
网站
</span>
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.website)}</span>
</div>
<div className="flex flex-col gap-2 p-3.5 text-sm">
<span className="text-[10px] text-muted-foreground">简介</span>
<span className="break-words text-[10px] leading-5">{displayValue(selectedUser.bio)}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">账户状态</span>
<Badge variant={selectedUser.is_active ? "secondary" : "outline"} className="text-[10px]">
{selectedUser.is_active ? "正常" : "禁用"}
</Badge>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">管理员</span>
<span className="font-mono text-[10px]">{selectedUser.is_admin ? "是" : "否"}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后登录</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
@@ -468,7 +597,7 @@ export function UsersManager() {
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className={cn(
@@ -491,6 +620,14 @@ export function UsersManager() {
</>
)}
</Button>
<Button
variant="outline"
className="w-full h-9 text-xs font-medium"
onClick={() => setDeleteTarget(selectedUser)}
>
<Trash2 className="size-3 mr-1" />
删除用户
</Button>
</div>
)}
</>
@@ -498,6 +635,24 @@ export function UsersManager() {
</SheetContent>
</Sheet>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
</div>
)
+22 -1
View File
@@ -37,8 +37,10 @@ interface AdminUsersContextState {
fetchUsers: (force?: boolean) => Promise<void>
refresh: () => Promise<void>
getUserDetail: (id: string) => Promise<AdminUser>
updateUserStatus: (user: AdminUser) => Promise<void>
createUser: (req: CreateUserRequest) => Promise<AdminUser>
deleteUser: (user: AdminUser) => Promise<void>
}
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes cache
@@ -189,6 +191,10 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
}
}
const getUserDetail = async (id: string) => {
return AdminService.getUser(id)
}
const createUser = async (req: CreateUserRequest) => {
try {
const newUser = await AdminService.createUser(req)
@@ -204,6 +210,19 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
}
}
const deleteUser = async (user: AdminUser) => {
try {
await AdminService.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
setTotal(prev => Math.max(0, prev - 1))
cacheRef.current = {}
toast.success(`已删除用户 ${ user.username }`)
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除用户失败')
throw err
}
}
const value = {
users,
total,
@@ -221,8 +240,10 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
setStatusFilter,
fetchUsers,
refresh,
getUserDetail,
updateUserStatus,
createUser
createUser,
deleteUser
}
return (
@@ -289,6 +289,15 @@ export class AdminService extends BaseService {
return this.get<ListUsersResponse>('/users', request as unknown as Record<string, unknown>);
}
/**
* 获取用户详情
* @param id - 用户 ID
* @returns 用户完整资料
*/
static async getUser(id: string): Promise<AdminUser> {
return this.get<AdminUser>(`/users/${ id }`);
}
/**
* 更新用户状态
* @param id - 用户 ID
@@ -326,6 +335,14 @@ export class AdminService extends BaseService {
return this.post<AdminUser>('/users', request);
}
/**
* 删除用户
* @param id - 用户 ID
*/
static async deleteUser(id: string): Promise<void> {
return this.delete<void>(`/users/${ id }`);
}
/**
* 获取系统状态
* @returns 系统状态指标数据
+12 -1
View File
@@ -194,12 +194,24 @@ export interface AdminUser {
username: string;
/** 昵称 */
nickname: string;
/** 邮箱 */
email?: string;
/** 头像 URL */
avatar_url: string;
/** 是否激活 */
is_active: boolean;
/** 是否管理员 */
is_admin: boolean;
/** 个人简介 */
bio?: string;
/** 手机号 */
phone?: string;
/** 性别 */
gender?: string;
/** 个人网站 */
website?: string;
/** 所在地 */
location?: string;
/** 最后登录时间 */
last_login_at: string;
/** 创建时间 */
@@ -363,4 +375,3 @@ export interface UpdateTemplateRequest {
content: string;
description: string;
}
+3
View File
@@ -21,7 +21,10 @@ package user
const (
userNotFound = "用户不存在"
cannotDisable = "不能禁用管理员用户"
cannotDelete = "不能删除管理员用户"
cannotDeleteSelf = "不能删除当前登录用户"
updateUserFailed = "更新用户状态失败"
deleteUserFailed = "删除用户失败"
usernameExists = "用户名已存在"
usernameRequired = "用户名不能为空"
passwordTooShort = "密码长度不能少于 8 位"
+143 -14
View File
@@ -19,9 +19,11 @@ package user
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/db/idgen"
"github.com/Rain-kl/Wavelet/internal/model"
@@ -45,9 +47,15 @@ type user struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
Bio string `json:"bio"`
Phone string `json:"phone"`
Gender string `json:"gender"`
Website string `json:"website"`
Location string `json:"location"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -59,6 +67,35 @@ type listUsersResponse struct {
Total int64 `json:"total"`
}
func parseUserID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, util.Err(userNotFound))
return 0, false
}
return id, true
}
func toUser(u model.User) user {
return user{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Email: u.Email,
AvatarURL: u.AvatarURL,
IsActive: u.IsActive,
IsAdmin: u.IsAdmin,
Bio: u.Bio,
Phone: u.Phone,
Gender: u.Gender,
Website: u.Website,
Location: u.Location,
LastLoginAt: u.LastLoginAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// ListUsers 获取用户列表
// @Summary 获取用户列表
// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
@@ -118,6 +155,43 @@ func ListUsers(c *gin.Context) {
}))
}
// GetUser 获取用户详情
// @Summary 获取用户详情
// @Description 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path int true "用户 ID"
// @Success 200 {object} util.ResponseAny{data=user.user} "用户详情"
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 404 {object} util.ResponseAny "用户不存在"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/users/{id} [get]
func GetUser(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
var targetUser model.User
if err := db.DB(c.Request.Context()).
Select("id, username, nickname, email, avatar_url, is_active, is_admin, "+
"bio, phone, gender, website, location, last_login_at, created_at, updated_at").
Where("id = ?", id).
First(&targetUser).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, util.Err(userNotFound))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(toUser(targetUser)))
}
// updateUserStatusRequest 更新用户状态请求
type updateUserStatusRequest struct {
IsActive bool `json:"is_active"`
@@ -146,7 +220,10 @@ func UpdateUserStatus(c *gin.Context) {
return
}
id := c.Param("id")
id, ok := parseUserID(c)
if !ok {
return
}
var targetUser struct {
ID uint64 `gorm:"column:id"`
@@ -181,6 +258,70 @@ func UpdateUserStatus(c *gin.Context) {
c.JSON(http.StatusOK, util.OKNil())
}
// DeleteUser 删除用户
// @Summary 删除用户
// @Description 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path int true "用户 ID"
// @Success 200 {object} util.ResponseAny{data=string} "删除成功"
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限、尝试删除管理员或当前用户"
// @Failure 404 {object} util.ResponseAny "用户不存在"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/users/{id} [delete]
func DeleteUser(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if currUser != nil && currUser.ID == id {
c.JSON(http.StatusForbidden, util.Err(cannotDeleteSelf))
return
}
var targetUser struct {
ID uint64 `gorm:"column:id"`
IsAdmin bool `gorm:"column:is_admin"`
}
if err := db.DB(c.Request.Context()).
Table("users").
Select("id, is_admin").
Where("id = ?", id).
First(&targetUser).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, util.Err(userNotFound))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if targetUser.IsAdmin {
c.JSON(http.StatusForbidden, util.Err(cannotDelete))
return
}
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", id).Delete(&model.AccessToken{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", id).Delete(&model.ExternalAccount{}).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Delete(&model.User{}).Error
}); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(deleteUserFailed))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// createUserRequest 创建用户请求
type createUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
@@ -257,17 +398,5 @@ func CreateUser(c *gin.Context) {
return
}
res := user{
ID: newUser.ID,
Username: newUser.Username,
Nickname: newUser.Nickname,
AvatarURL: newUser.AvatarURL,
IsActive: newUser.IsActive,
IsAdmin: newUser.IsAdmin,
LastLoginAt: newUser.LastLoginAt,
CreatedAt: newUser.CreatedAt,
UpdatedAt: newUser.UpdatedAt,
}
c.JSON(http.StatusOK, util.OK(res))
c.JSON(http.StatusOK, util.OK(toUser(newUser)))
}
+182
View File
@@ -47,7 +47,9 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
adminGroup.GET("/users", ListUsers)
adminGroup.POST("/users", CreateUser)
adminGroup.GET("/users/:id", GetUser)
adminGroup.PUT("/users/:id/status", UpdateUserStatus)
adminGroup.DELETE("/users/:id", DeleteUser)
return r
}
@@ -170,6 +172,72 @@ func TestListUsers(t *testing.T) {
})
}
func TestGetUser(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
targetUser := model.User{
ID: 1001,
Username: "alice",
Password: "secret-hash",
Nickname: "Alice Nickname",
Email: "alice@example.com",
AvatarURL: "https://example.com/avatar.png",
IsActive: true,
IsAdmin: false,
Bio: "hello",
Phone: "123456",
Gender: "female",
Website: "https://example.com",
Location: "Shanghai",
}
if err := dbConn.Create(&targetUser).Error; err != nil {
t.Fatalf("failed to seed user: %v", err)
}
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("get full user profile", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/users/1001", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
dataBytes, _ := json.Marshal(resp.Data)
var resUser user
if err := json.Unmarshal(dataBytes, &resUser); err != nil {
t.Fatalf("failed to parse response data: %v", err)
}
if resUser.Email != targetUser.Email || resUser.Bio != targetUser.Bio || resUser.Phone != targetUser.Phone ||
resUser.Gender != targetUser.Gender || resUser.Website != targetUser.Website || resUser.Location != targetUser.Location {
t.Errorf("profile fields were not returned correctly: %+v", resUser)
}
if bytes.Contains(dataBytes, []byte("secret-hash")) {
t.Error("response should not include password")
}
})
t.Run("get non-existent user", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/users/9999", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String())
}
})
}
func TestUpdateUserStatus(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
@@ -349,3 +417,117 @@ func TestCreateUser(t *testing.T) {
}
})
}
func TestDeleteUser(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
regularUser := model.User{
ID: 1001,
Username: "alice",
IsActive: true,
IsAdmin: false,
}
adminUser := model.User{
ID: 1002,
Username: "bob",
IsActive: true,
IsAdmin: true,
}
selfUser := model.User{
ID: 1003,
Username: "charlie",
IsActive: true,
IsAdmin: false,
}
if err := dbConn.Create(&regularUser).Error; err != nil {
t.Fatalf("failed to seed regular user: %v", err)
}
if err := dbConn.Create(&adminUser).Error; err != nil {
t.Fatalf("failed to seed admin user: %v", err)
}
if err := dbConn.Create(&selfUser).Error; err != nil {
t.Fatalf("failed to seed self user: %v", err)
}
if err := dbConn.Create(&model.AccessToken{
UserID: regularUser.ID,
Name: "api",
TokenHash: "hash-for-delete-user-test",
MaskedToken: "at_****test",
}).Error; err != nil {
t.Fatalf("failed to seed access token: %v", err)
}
if err := dbConn.Create(&model.ExternalAccount{
ID: 5001,
AuthSourceID: 1,
UserID: regularUser.ID,
ExternalID: "external-alice",
}).Error; err != nil {
t.Fatalf("failed to seed external account: %v", err)
}
router := setupTestRouter(&selfUser)
t.Run("delete regular user successfully", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1001", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var count int64
if err := dbConn.Model(&model.User{}).Where("id = ?", 1001).Count(&count).Error; err != nil {
t.Fatalf("failed to count deleted user: %v", err)
}
if count != 0 {
t.Errorf("expected deleted user count 0, got %d", count)
}
if err := dbConn.Model(&model.AccessToken{}).Where("user_id = ?", 1001).Count(&count).Error; err != nil {
t.Fatalf("failed to count deleted access tokens: %v", err)
}
if count != 0 {
t.Errorf("expected deleted access token count 0, got %d", count)
}
if err := dbConn.Model(&model.ExternalAccount{}).Where("user_id = ?", 1001).Count(&count).Error; err != nil {
t.Fatalf("failed to count deleted external accounts: %v", err)
}
if count != 0 {
t.Errorf("expected deleted external account count 0, got %d", count)
}
})
t.Run("cannot delete admin user", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1002", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String())
}
})
t.Run("cannot delete current user", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1003", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String())
}
})
t.Run("delete non-existent user", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/9999", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String())
}
})
}
+2
View File
@@ -215,7 +215,9 @@ func Serve() {
// Users
adminRouter.GET("/users", admin_user.ListUsers)
adminRouter.POST("/users", admin_user.CreateUser)
adminRouter.GET("/users/:id", admin_user.GetUser)
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
adminRouter.DELETE("/users/:id", admin_user.DeleteUser)
// System Config
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)