diff --git a/docs/docs.go b/docs/docs.go index 08dae68b..2be2799b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index b9d5226e..8741a303 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2041e936..f3dfbacc 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/frontend/components/common/admin/users.tsx b/frontend/components/common/admin/users.tsx index 85fe32d3..0b015516 100644 --- a/frontend/components/common/admin/users.tsx +++ b/frontend/components/common/admin/users.tsx @@ -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(null) const [detailOpen, setDetailOpen] = useState(false) + const [detailLoading, setDetailLoading] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(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() { 上次登陆 注册时间 上次更新 - 操作 + 操作 @@ -394,6 +441,26 @@ export function UsersManager() { + + {!user.is_admin && ( + + + + + + + 删除用户 + + + + )} @@ -435,6 +502,13 @@ export function UsersManager() { + {detailLoading && ( +
+ + 正在刷新详情 +
+ )} +
注册时间 @@ -445,9 +519,64 @@ export function UsersManager() {
+
+

个人资料

+
+
+ + + 邮箱 + + {displayValue(selectedUser.email)} +
+
+ + + 手机 + + {displayValue(selectedUser.phone)} +
+
+ + + 性别 + + {displayValue(selectedUser.gender)} +
+
+ + + 所在地 + + {displayValue(selectedUser.location)} +
+
+ + + 网站 + + {displayValue(selectedUser.website)} +
+
+ 简介 + {displayValue(selectedUser.bio)} +
+
+
+

系统记录

+
+ 账户状态 + + {selectedUser.is_active ? "正常" : "禁用"} + +
+
+ 管理员 + {selectedUser.is_admin ? "是" : "否"} +
最后登录 {formatDateTime(selectedUser.last_login_at)} @@ -468,7 +597,7 @@ export function UsersManager() {
{!selectedUser.is_admin && ( -
+
+
)} @@ -498,6 +635,24 @@ export function UsersManager() { + !open && !deleteLoading && setDeleteTarget(null)}> + + + 确认删除用户 + + 确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。 + + + + 取消 + + {deleteLoading && } + 确认删除 + + + + + setCreateModalOpen(false)} />
) diff --git a/frontend/contexts/admin-users-context.tsx b/frontend/contexts/admin-users-context.tsx index 444a2779..1706bd94 100644 --- a/frontend/contexts/admin-users-context.tsx +++ b/frontend/contexts/admin-users-context.tsx @@ -37,8 +37,10 @@ interface AdminUsersContextState { fetchUsers: (force?: boolean) => Promise refresh: () => Promise + getUserDetail: (id: string) => Promise updateUserStatus: (user: AdminUser) => Promise createUser: (req: CreateUserRequest) => Promise + deleteUser: (user: AdminUser) => Promise } 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 ( diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index 1d2f37d2..53d66d2f 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -289,6 +289,15 @@ export class AdminService extends BaseService { return this.get('/users', request as unknown as Record); } + /** + * 获取用户详情 + * @param id - 用户 ID + * @returns 用户完整资料 + */ + static async getUser(id: string): Promise { + return this.get(`/users/${ id }`); + } + /** * 更新用户状态 * @param id - 用户 ID @@ -326,6 +335,14 @@ export class AdminService extends BaseService { return this.post('/users', request); } + /** + * 删除用户 + * @param id - 用户 ID + */ + static async deleteUser(id: string): Promise { + return this.delete(`/users/${ id }`); + } + /** * 获取系统状态 * @returns 系统状态指标数据 diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index 1f85b1cf..5ea4118d 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -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; } - diff --git a/internal/apps/admin/user/errs.go b/internal/apps/admin/user/errs.go index b49ec5ce..19d3b7ab 100644 --- a/internal/apps/admin/user/errs.go +++ b/internal/apps/admin/user/errs.go @@ -21,7 +21,10 @@ package user const ( userNotFound = "用户不存在" cannotDisable = "不能禁用管理员用户" + cannotDelete = "不能删除管理员用户" + cannotDeleteSelf = "不能删除当前登录用户" updateUserFailed = "更新用户状态失败" + deleteUserFailed = "删除用户失败" usernameExists = "用户名已存在" usernameRequired = "用户名不能为空" passwordTooShort = "密码长度不能少于 8 位" diff --git a/internal/apps/admin/user/routers.go b/internal/apps/admin/user/routers.go index f4f79254..3247af92 100644 --- a/internal/apps/admin/user/routers.go +++ b/internal/apps/admin/user/routers.go @@ -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))) } diff --git a/internal/apps/admin/user/routers_test.go b/internal/apps/admin/user/routers_test.go index e0cff3ea..20b836d6 100644 --- a/internal/apps/admin/user/routers_test.go +++ b/internal/apps/admin/user/routers_test.go @@ -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(®ularUser).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()) + } + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 0a836b1c..b0d7edb6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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)