用户详情

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
+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())
}
})
}