From 62f02f8a391fe52dd55bf1f2e72bc2615254a5b1 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 19 Sep 2026 17:10:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=A8=E6=88=B7=E4=B8=8E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=BB=84=E6=A8=A1=E5=9D=97=E5=8F=8A=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E7=AE=A1=E7=90=86=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users/user_groups 模型与 CRUD 接口,组成员关系手动维护(规避 GORM 零值主键问题) - 迁移 v2~v4:用户组、用户表、初始 admin 用户 - 初始密码随机生成,仅终端打印一次并写入 data/admin_password.txt --- go.mod | 2 +- internal/api/api.go | 50 ++++ internal/api/notes.go | 9 +- internal/api/notes_test.go | 9 +- internal/api/user_groups.go | 173 +++++++++++++ internal/api/user_groups_test.go | 165 +++++++++++++ internal/api/users.go | 377 +++++++++++++++++++++++++++++ internal/api/users_test.go | 167 +++++++++++++ internal/database/database_test.go | 82 +++++++ internal/database/migrate.go | 39 +++ internal/database/seed.go | 119 +++++++++ internal/model/user.go | 22 ++ internal/model/user_group.go | 31 +++ 13 files changed, 1235 insertions(+), 10 deletions(-) create mode 100644 internal/api/user_groups.go create mode 100644 internal/api/user_groups_test.go create mode 100644 internal/api/users.go create mode 100644 internal/api/users_test.go create mode 100644 internal/database/seed.go create mode 100644 internal/model/user.go create mode 100644 internal/model/user_group.go diff --git a/go.mod b/go.mod index 2347683..5f81053 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 github.com/goccy/go-yaml v1.19.2 + golang.org/x/crypto v0.55.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.2 ) @@ -43,7 +44,6 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect golang.org/x/arch v0.29.0 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/internal/api/api.go b/internal/api/api.go index 01fddfb..439f9a7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2,7 +2,9 @@ package api import ( + "errors" "net/http" + "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -22,6 +24,54 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB) { notes.PUT("/:id", updateNote(db)) notes.DELETE("/:id", deleteNote(db)) } + + users := rg.Group("/users") + { + users.GET("", listUsers(db)) + users.POST("", createUser(db)) + users.GET("/:id", getUser(db)) + users.PUT("/:id", updateUser(db)) + users.DELETE("/:id", deleteUser(db)) + } + + userGroups := rg.Group("/user-groups") + { + userGroups.GET("", listUserGroups(db)) + userGroups.POST("", createUserGroup(db)) + userGroups.GET("/:id", getUserGroup(db)) + userGroups.PUT("/:id", updateUserGroup(db)) + userGroups.DELETE("/:id", deleteUserGroup(db)) + } +} + +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(defaultPageSize))) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > maxPageSize { + pageSize = defaultPageSize + } + return page, pageSize +} + +// respondGetError 查询类错误:记录不存在返回 404,其余按数据库错误处理。 +func respondGetError(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + return + } + respondDBError(c, err) +} + +// respondDuplicateOrDBError 写入类错误:唯一约束冲突返回 409,其余按数据库错误处理。 +func respondDuplicateOrDBError(c *gin.Context, err error, duplicateMsg string) { + if errors.Is(err, gorm.ErrDuplicatedKey) { + c.JSON(http.StatusConflict, gin.H{"error": duplicateMsg}) + return + } + respondDBError(c, err) } func health(db *gorm.DB) gin.HandlerFunc { diff --git a/internal/api/notes.go b/internal/api/notes.go index b11649b..51e309c 100644 --- a/internal/api/notes.go +++ b/internal/api/notes.go @@ -24,14 +24,7 @@ type noteRequest struct { func listNotes(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(defaultPageSize))) - if page < 1 { - page = 1 - } - if pageSize < 1 || pageSize > maxPageSize { - pageSize = defaultPageSize - } + page, pageSize := parsePagination(c) ctx := c.Request.Context() var total int64 diff --git a/internal/api/notes_test.go b/internal/api/notes_test.go index ccc9162..a9bbf02 100644 --- a/internal/api/notes_test.go +++ b/internal/api/notes_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/gin-gonic/gin" + "gorm.io/gorm" "rill/internal/config" "rill/internal/database" @@ -18,6 +19,12 @@ import ( ) func setupRouter(t *testing.T) *gin.Engine { + t.Helper() + r, _ := setupRouterWithDB(t) + return r +} + +func setupRouterWithDB(t *testing.T) (*gin.Engine, *gorm.DB) { t.Helper() gin.SetMode(gin.TestMode) @@ -44,7 +51,7 @@ func setupRouter(t *testing.T) *gin.Engine { r := gin.New() RegisterRoutes(r.Group("/api"), db) - return r + return r, db } func call(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { diff --git a/internal/api/user_groups.go b/internal/api/user_groups.go new file mode 100644 index 0000000..abc2c34 --- /dev/null +++ b/internal/api/user_groups.go @@ -0,0 +1,173 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "rill/internal/model" +) + +type userGroupRequest struct { + Name string `json:"name" binding:"required,max=50"` + Description string `json:"description" binding:"max=255"` +} + +func listUserGroups(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + page, pageSize := parsePagination(c) + ctx := c.Request.Context() + + var total int64 + if err := db.WithContext(ctx).Model(&model.UserGroup{}).Count(&total).Error; err != nil { + respondDBError(c, err) + return + } + + var groups []model.UserGroup + if err := db.WithContext(ctx). + Order("id ASC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&groups).Error; err != nil { + respondDBError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": groups, + "total": total, + "page": page, + "page_size": pageSize, + }) + } +} + +func createUserGroup(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var req userGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + return + } + + ctx := c.Request.Context() + var group model.UserGroup + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var maxID uint + if err := tx.Model(&model.UserGroup{}).Select("COALESCE(MAX(id), 0)").Scan(&maxID).Error; err != nil { + return err + } + group = model.UserGroup{ + ID: maxID + 1, + Name: req.Name, + Description: req.Description, + } + return tx.Create(&group).Error + }) + if err != nil { + respondDuplicateOrDBError(c, err, "用户组名称已存在") + return + } + c.JSON(http.StatusCreated, group) + } +} + +func getUserGroup(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseGroupID(c) + if !ok { + return + } + + var group model.UserGroup + if err := db.WithContext(c.Request.Context()).First(&group, id).Error; err != nil { + respondGetError(c, err) + return + } + c.JSON(http.StatusOK, group) + } +} + +func updateUserGroup(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseGroupID(c) + if !ok { + return + } + + var req userGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + return + } + + ctx := c.Request.Context() + var group model.UserGroup + if err := db.WithContext(ctx).First(&group, id).Error; err != nil { + respondGetError(c, err) + return + } + + group.Name = req.Name + group.Description = req.Description + if err := db.WithContext(ctx).Save(&group).Error; err != nil { + respondDuplicateOrDBError(c, err, "用户组名称已存在") + return + } + c.JSON(http.StatusOK, group) + } +} + +func deleteUserGroup(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseGroupID(c) + if !ok { + return + } + + ctx := c.Request.Context() + var group model.UserGroup + if err := db.WithContext(ctx).First(&group, id).Error; err != nil { + respondGetError(c, err) + return + } + if group.IsSystem { + c.JSON(http.StatusConflict, gin.H{"error": "系统内置组不可删除"}) + return + } + + var members int64 + if err := db.WithContext(ctx).Table(userGroupMembersTable). + Where("user_group_id = ?", id). + Count(&members).Error; err != nil { + respondDBError(c, err) + return + } + if members > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "用户组内仍有用户,无法删除"}) + return + } + + if err := db.WithContext(ctx).Delete(&group).Error; err != nil { + respondDBError(c, err) + return + } + c.Status(http.StatusNoContent) + } +} + +// parseGroupID 解析用户组 id,允许内置组 id 0。 +func parseGroupID(c *gin.Context) (uint, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "id 无效"}) + return 0, false + } + return uint(id), true +} + +var errGroupsNotFound = errors.New("用户组不存在") diff --git a/internal/api/user_groups_test.go b/internal/api/user_groups_test.go new file mode 100644 index 0000000..5ab253c --- /dev/null +++ b/internal/api/user_groups_test.go @@ -0,0 +1,165 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "rill/internal/model" +) + +func decodeUserGroup(t *testing.T, w *httptest.ResponseRecorder) model.UserGroup { + t.Helper() + var group model.UserGroup + if err := json.Unmarshal(w.Body.Bytes(), &group); err != nil { + t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String()) + } + return group +} + +func TestUserGroupSeeds(t *testing.T) { + r := setupRouter(t) + + w := call(t, r, http.MethodGet, "/api/user-groups", nil) + if w.Code != http.StatusOK { + t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + var list struct { + Items []model.UserGroup `json:"items"` + Total int64 `json:"total"` + } + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("解析列表响应失败: %v", err) + } + if list.Total != 2 || len(list.Items) != 2 { + t.Fatalf("内置组数量异常: total=%d, items=%d", list.Total, len(list.Items)) + } + admin, user := list.Items[0], list.Items[1] + if admin.ID != model.GroupIDAdmin || admin.Name != "admin" || !admin.IsSystem { + t.Errorf("admin 组异常: %+v", admin) + } + if user.ID != model.GroupIDUser || user.Name != "user" || !user.IsSystem { + t.Errorf("user 组异常: %+v", user) + } +} + +func TestUserGroupCRUD(t *testing.T) { + r := setupRouter(t) + + w := call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "ops", "description": "运维组"}) + if w.Code != http.StatusCreated { + t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) + } + created := decodeUserGroup(t, w) + if created.ID != 2 || created.Name != "ops" || created.IsSystem { + t.Fatalf("创建结果异常: %+v", created) + } + + w = call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "dev"}) + if w.Code != http.StatusCreated { + t.Fatalf("创建第二个组状态码 = %d, 期望 %d", w.Code, http.StatusCreated) + } + if second := decodeUserGroup(t, w); second.ID != 3 { + t.Errorf("新组 ID = %d, 期望 3", second.ID) + } + + detailPath := fmt.Sprintf("/api/user-groups/%d", created.ID) + w = call(t, r, http.MethodGet, detailPath, nil) + if w.Code != http.StatusOK { + t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + if got := decodeUserGroup(t, w); got.ID != created.ID { + t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID) + } + + w = call(t, r, http.MethodPut, detailPath, map[string]string{"name": "ops2", "description": "更新后"}) + if w.Code != http.StatusOK { + t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + if updated := decodeUserGroup(t, w); updated.Name != "ops2" || updated.Description != "更新后" { + t.Errorf("更新结果异常: %+v", updated) + } + + w = call(t, r, http.MethodDelete, detailPath, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent) + } + w = call(t, r, http.MethodGet, detailPath, nil) + if w.Code != http.StatusNotFound { + t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } +} + +func TestUserGroupProtected(t *testing.T) { + r := setupRouter(t) + + for _, id := range []uint{model.GroupIDAdmin, model.GroupIDUser} { + w := call(t, r, http.MethodDelete, fmt.Sprintf("/api/user-groups/%d", id), nil) + if w.Code != http.StatusConflict { + t.Errorf("删除内置组 %d 状态码 = %d, 期望 %d", id, w.Code, http.StatusConflict) + } + } + + w := call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "admin"}) + if w.Code != http.StatusConflict { + t.Errorf("重复组名状态码 = %d, 期望 %d", w.Code, http.StatusConflict) + } +} + +func TestUserGroupDeleteWithMembers(t *testing.T) { + r := setupRouter(t) + + w := call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "ops"}) + if w.Code != http.StatusCreated { + t.Fatalf("创建组失败: %d, body=%s", w.Code, w.Body.String()) + } + group := decodeUserGroup(t, w) + + w = call(t, r, http.MethodPost, "/api/users", map[string]any{ + "username": "carol", + "email": "carol@example.com", + "password": "secret123", + "group_ids": []uint{group.ID}, + }) + if w.Code != http.StatusCreated { + t.Fatalf("创建用户失败: %d, body=%s", w.Code, w.Body.String()) + } + user := decodeUser(t, w) + + groupPath := fmt.Sprintf("/api/user-groups/%d", group.ID) + w = call(t, r, http.MethodDelete, groupPath, nil) + if w.Code != http.StatusConflict { + t.Fatalf("删除有成员的组状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusConflict, w.Body.String()) + } + + w = call(t, r, http.MethodDelete, fmt.Sprintf("/api/users/%d", user.ID), nil) + if w.Code != http.StatusNoContent { + t.Fatalf("删除用户失败: %d", w.Code) + } + + w = call(t, r, http.MethodDelete, groupPath, nil) + if w.Code != http.StatusNoContent { + t.Errorf("成员移除后删除组状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusNoContent, w.Body.String()) + } +} + +func TestUserGroupValidation(t *testing.T) { + r := setupRouter(t) + + w := call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"description": "缺少名称"}) + if w.Code != http.StatusBadRequest { + t.Errorf("缺少名称状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) + } + + w = call(t, r, http.MethodGet, "/api/user-groups/abc", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) + } + + w = call(t, r, http.MethodGet, "/api/user-groups/99", nil) + if w.Code != http.StatusNotFound { + t.Errorf("不存在组状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } +} diff --git a/internal/api/users.go b/internal/api/users.go new file mode 100644 index 0000000..21410bb --- /dev/null +++ b/internal/api/users.go @@ -0,0 +1,377 @@ +package api + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "rill/internal/model" +) + +const userGroupMembersTable = "user_group_members" + +type userCreateRequest struct { + Username string `json:"username" binding:"required,max=50"` + Email string `json:"email" binding:"required,email,max=255"` + Password string `json:"password" binding:"required,min=6,max=72"` + Nickname string `json:"nickname" binding:"max=50"` + Avatar string `json:"avatar" binding:"max=255"` + Status *int8 `json:"status" binding:"omitempty,oneof=0 1"` + GroupIDs []uint `json:"group_ids"` +} + +type userUpdateRequest struct { + Nickname string `json:"nickname" binding:"max=50"` + Avatar string `json:"avatar" binding:"max=255"` + Status *int8 `json:"status" binding:"omitempty,oneof=0 1"` + Password string `json:"password" binding:"omitempty,min=6,max=72"` + GroupIDs *[]uint `json:"group_ids"` +} + +func listUsers(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + page, pageSize := parsePagination(c) + ctx := c.Request.Context() + + var total int64 + if err := db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil { + respondDBError(c, err) + return + } + + var users []model.User + if err := db.WithContext(ctx). + Order("id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&users).Error; err != nil { + respondDBError(c, err) + return + } + if err := attachUserGroups(ctx, db, users); err != nil { + respondDBError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": users, + "total": total, + "page": page, + "page_size": pageSize, + }) + } +} + +func createUser(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var req userCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + return + } + + ctx := c.Request.Context() + hash, err := hashPassword(req.Password) + if err != nil { + respondHashError(c, err) + return + } + + status := int8(1) + if req.Status != nil { + status = *req.Status + } + + groupIDs := req.GroupIDs + if len(groupIDs) == 0 { + groupIDs = []uint{model.GroupIDUser} + } + groups, err := findGroups(ctx, db, groupIDs) + if err != nil { + if errors.Is(err, errGroupsNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": errGroupsNotFound.Error()}) + return + } + respondDBError(c, err) + return + } + + user := model.User{ + Username: req.Username, + Email: req.Email, + PasswordHash: string(hash), + Nickname: req.Nickname, + Avatar: req.Avatar, + Status: status, + } + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&user).Error; err != nil { + return err + } + return replaceUserGroups(tx, user.ID, groupIDs) + }) + if err != nil { + respondDuplicateOrDBError(c, err, "用户名或邮箱已存在") + return + } + user.Groups = groups + c.JSON(http.StatusCreated, user) + } +} + +func getUser(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + ctx := c.Request.Context() + var user model.User + if err := db.WithContext(ctx).First(&user, id).Error; err != nil { + respondGetError(c, err) + return + } + + groups, err := loadUserGroups(ctx, db, user.ID) + if err != nil { + respondDBError(c, err) + return + } + user.Groups = groups + c.JSON(http.StatusOK, user) + } +} + +func updateUser(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + var req userUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + return + } + + ctx := c.Request.Context() + var user model.User + if err := db.WithContext(ctx).First(&user, id).Error; err != nil { + respondGetError(c, err) + return + } + + if req.GroupIDs != nil { + if _, err := findGroups(ctx, db, *req.GroupIDs); err != nil { + if errors.Is(err, errGroupsNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": errGroupsNotFound.Error()}) + return + } + respondDBError(c, err) + return + } + } + + updates := map[string]any{ + "nickname": req.Nickname, + "avatar": req.Avatar, + } + if req.Status != nil { + updates["status"] = *req.Status + } + if req.Password != "" { + hash, err := hashPassword(req.Password) + if err != nil { + respondHashError(c, err) + return + } + updates["password_hash"] = string(hash) + } + + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&user).Updates(updates).Error; err != nil { + return err + } + if req.GroupIDs != nil { + return replaceUserGroups(tx, user.ID, *req.GroupIDs) + } + return nil + }) + if err != nil { + respondDBError(c, err) + return + } + + groups, err := loadUserGroups(ctx, db, user.ID) + if err != nil { + respondDBError(c, err) + return + } + + user.Nickname = req.Nickname + user.Avatar = req.Avatar + if req.Status != nil { + user.Status = *req.Status + } + user.Groups = groups + c.JSON(http.StatusOK, user) + } +} + +func deleteUser(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + ctx := c.Request.Context() + var user model.User + if err := db.WithContext(ctx).First(&user, id).Error; err != nil { + respondGetError(c, err) + return + } + + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("DELETE FROM "+userGroupMembersTable+" WHERE user_id = ?", id).Error; err != nil { + return err + } + return tx.Delete(&user).Error + }) + if err != nil { + respondDBError(c, err) + return + } + c.Status(http.StatusNoContent) + } +} + +// replaceUserGroups 以给定的组 ID 整体替换用户的组成员关系。 +func replaceUserGroups(tx *gorm.DB, userID uint, groupIDs []uint) error { + if err := tx.Exec("DELETE FROM "+userGroupMembersTable+" WHERE user_id = ?", userID).Error; err != nil { + return err + } + for _, groupID := range dedupeIDs(groupIDs) { + if err := tx.Exec( + "INSERT INTO "+userGroupMembersTable+" (user_id, user_group_id) VALUES (?, ?)", + userID, groupID, + ).Error; err != nil { + return err + } + } + return nil +} + +// loadUserGroups 查询单个用户所属的用户组。 +func loadUserGroups(ctx context.Context, db *gorm.DB, userID uint) ([]model.UserGroup, error) { + var groupIDs []uint + if err := db.WithContext(ctx).Table(userGroupMembersTable). + Where("user_id = ?", userID). + Pluck("user_group_id", &groupIDs).Error; err != nil { + return nil, err + } + + groups := make([]model.UserGroup, 0, len(groupIDs)) + if len(groupIDs) == 0 { + return groups, nil + } + if err := db.WithContext(ctx).Where("id IN ?", dedupeIDs(groupIDs)).Find(&groups).Error; err != nil { + return nil, err + } + return groups, nil +} + +// attachUserGroups 为一批用户批量填充所属用户组,避免逐条查询。 +func attachUserGroups(ctx context.Context, db *gorm.DB, users []model.User) error { + for i := range users { + users[i].Groups = make([]model.UserGroup, 0) + } + if len(users) == 0 { + return nil + } + + ids := make([]uint, 0, len(users)) + positions := make(map[uint][]int, len(users)) + for i := range users { + ids = append(ids, users[i].ID) + positions[users[i].ID] = append(positions[users[i].ID], i) + } + + var members []model.UserGroupMember + if err := db.WithContext(ctx).Where("user_id IN ?", ids).Find(&members).Error; err != nil { + return err + } + if len(members) == 0 { + return nil + } + + groupIDs := make([]uint, 0, len(members)) + for _, member := range members { + groupIDs = append(groupIDs, member.UserGroupID) + } + var groups []model.UserGroup + if err := db.WithContext(ctx).Where("id IN ?", dedupeIDs(groupIDs)).Find(&groups).Error; err != nil { + return err + } + + groupByID := make(map[uint]model.UserGroup, len(groups)) + for _, group := range groups { + groupByID[group.ID] = group + } + for _, member := range members { + group, ok := groupByID[member.UserGroupID] + if !ok { + continue + } + for _, i := range positions[member.UserID] { + users[i].Groups = append(users[i].Groups, group) + } + } + return nil +} + +// findGroups 查询用户组并校验全部存在。 +func findGroups(ctx context.Context, db *gorm.DB, ids []uint) ([]model.UserGroup, error) { + unique := dedupeIDs(ids) + var groups []model.UserGroup + if err := db.WithContext(ctx).Where("id IN ?", unique).Find(&groups).Error; err != nil { + return nil, err + } + if len(groups) != len(unique) { + return nil, errGroupsNotFound + } + return groups, nil +} + +func dedupeIDs(ids []uint) []uint { + seen := make(map[uint]struct{}, len(ids)) + unique := make([]uint, 0, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + return unique +} + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func respondHashError(c *gin.Context, err error) { + slog.ErrorContext(c.Request.Context(), "生成密码哈希失败", "err", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误"}) +} diff --git a/internal/api/users_test.go b/internal/api/users_test.go new file mode 100644 index 0000000..b1b6b89 --- /dev/null +++ b/internal/api/users_test.go @@ -0,0 +1,167 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" + + "rill/internal/model" +) + +func decodeUser(t *testing.T, w *httptest.ResponseRecorder) model.User { + t.Helper() + var user model.User + if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil { + t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String()) + } + return user +} + +func hasGroup(user model.User, groupID uint) bool { + for _, group := range user.Groups { + if group.ID == groupID { + return true + } + } + return false +} + +func TestUserCRUD(t *testing.T) { + r, db := setupRouterWithDB(t) + + w := call(t, r, http.MethodPost, "/api/users", map[string]any{ + "username": "alice", + "email": "alice@example.com", + "password": "secret123", + }) + if w.Code != http.StatusCreated { + t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) + } + if body := w.Body.String(); strings.Contains(body, "password") || strings.Contains(body, "secret123") { + t.Fatalf("响应泄露密码信息: %s", body) + } + created := decodeUser(t, w) + if created.ID == 0 || created.Username != "alice" || created.Status != 1 { + t.Fatalf("创建结果异常: %+v", created) + } + if !hasGroup(created, model.GroupIDUser) { + t.Fatalf("新用户默认组应为 %d, groups=%+v", model.GroupIDUser, created.Groups) + } + + var stored model.User + if err := db.First(&stored, created.ID).Error; err != nil { + t.Fatalf("查询数据库失败: %v", err) + } + if stored.PasswordHash == "secret123" { + t.Fatal("密码未加密存储") + } + if err := bcrypt.CompareHashAndPassword([]byte(stored.PasswordHash), []byte("secret123")); err != nil { + t.Fatalf("密码哈希校验失败: %v", err) + } + + detailPath := fmt.Sprintf("/api/users/%d", created.ID) + w = call(t, r, http.MethodGet, detailPath, nil) + if w.Code != http.StatusOK { + t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + if got := decodeUser(t, w); got.ID != created.ID { + t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID) + } + + w = call(t, r, http.MethodPut, detailPath, map[string]any{ + "nickname": "Alice", + "status": 0, + "group_ids": []uint{model.GroupIDAdmin}, + }) + if w.Code != http.StatusOK { + t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + updated := decodeUser(t, w) + if updated.Nickname != "Alice" || updated.Status != 0 { + t.Errorf("更新结果异常: %+v", updated) + } + if len(updated.Groups) != 1 || updated.Groups[0].ID != model.GroupIDAdmin { + t.Errorf("组替换异常: %+v", updated.Groups) + } + + w = call(t, r, http.MethodGet, "/api/users?page=1&page_size=10", nil) + if w.Code != http.StatusOK { + t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + var list struct { + Items []model.User `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + } + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("解析列表响应失败: %v", err) + } + if list.Total != 2 || len(list.Items) != 2 { + t.Fatalf("列表结果异常: total=%d, items=%d", list.Total, len(list.Items)) + } + if !hasGroup(list.Items[0], model.GroupIDAdmin) { + t.Errorf("列表未预加载组: %+v", list.Items[0].Groups) + } + + w = call(t, r, http.MethodDelete, detailPath, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent) + } + + w = call(t, r, http.MethodGet, detailPath, nil) + if w.Code != http.StatusNotFound { + t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } +} + +func TestUserValidation(t *testing.T) { + r := setupRouter(t) + + cases := []struct { + name string + body map[string]any + }{ + {"缺少用户名", map[string]any{"email": "a@example.com", "password": "secret123"}}, + {"邮箱格式非法", map[string]any{"username": "a", "email": "not-email", "password": "secret123"}}, + {"密码过短", map[string]any{"username": "a", "email": "a@example.com", "password": "123"}}, + {"组不存在", map[string]any{"username": "a", "email": "a@example.com", "password": "secret123", "group_ids": []uint{99}}}, + } + for _, tc := range cases { + w := call(t, r, http.MethodPost, "/api/users", tc.body) + if w.Code != http.StatusBadRequest { + t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String()) + } + } + + w := call(t, r, http.MethodPost, "/api/users", map[string]any{ + "username": "bob", "email": "bob@example.com", "password": "secret123", + }) + if w.Code != http.StatusCreated { + t.Fatalf("创建用户失败: %d, body=%s", w.Code, w.Body.String()) + } + + w = call(t, r, http.MethodPost, "/api/users", map[string]any{ + "username": "bob", "email": "other@example.com", "password": "secret123", + }) + if w.Code != http.StatusConflict { + t.Errorf("重复用户名状态码 = %d, 期望 %d", w.Code, http.StatusConflict) + } + + w = call(t, r, http.MethodPost, "/api/users", map[string]any{ + "username": "bob2", "email": "bob@example.com", "password": "secret123", + }) + if w.Code != http.StatusConflict { + t.Errorf("重复邮箱状态码 = %d, 期望 %d", w.Code, http.StatusConflict) + } + + w = call(t, r, http.MethodGet, "/api/users/abc", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) + } +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 2d45317..5ff8422 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -2,9 +2,12 @@ package database import ( "context" + "os" "path/filepath" + "strings" "testing" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "rill/internal/config" @@ -66,10 +69,66 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) { if !db.Migrator().HasTable(&model.Note{}) { t.Error("notes 表未创建") } + if !db.Migrator().HasTable(&model.User{}) { + t.Error("users 表未创建") + } + if !db.Migrator().HasTable(&model.UserGroup{}) { + t.Error("user_groups 表未创建") + } if !db.Migrator().HasTable(&schemaMigration{}) { t.Error("schema_migrations 表未创建") } + var groups []model.UserGroup + if err := db.WithContext(ctx).Order("id ASC").Find(&groups).Error; err != nil { + t.Fatalf("查询内置组失败: %v", err) + } + if len(groups) != 2 || groups[0].ID != model.GroupIDAdmin || groups[1].ID != model.GroupIDUser { + t.Errorf("内置组异常: %+v", groups) + } + + var admin model.User + if err := db.WithContext(ctx).Where("username = ?", adminUsername).First(&admin).Error; err != nil { + t.Fatalf("初始管理员未创建: %v", err) + } + if admin.Status != 1 { + t.Errorf("初始管理员状态 = %d, 期望 1", admin.Status) + } + if _, err := bcrypt.Cost([]byte(admin.PasswordHash)); err != nil { + t.Errorf("初始管理员密码哈希无效: %v", err) + } + var membership int64 + if err := db.WithContext(ctx).Model(&model.UserGroupMember{}). + Where("user_id = ? AND user_group_id = ?", admin.ID, model.GroupIDAdmin). + Count(&membership).Error; err != nil { + t.Fatalf("查询初始管理员组关系失败: %v", err) + } + if membership != 1 { + t.Errorf("初始管理员未加入 admin 组: count=%d", membership) + } + + passwordFile := adminPasswordPath(db) + raw, err := os.ReadFile(passwordFile) + if err != nil { + t.Fatalf("读取初始密码文件失败: %v", err) + } + if !strings.Contains(string(raw), "删除") { + t.Error("密码文件缺少删除提醒") + } + password := "" + for _, line := range strings.Split(string(raw), "\n") { + if value, ok := strings.CutPrefix(line, "密码: "); ok { + password = strings.TrimSpace(value) + break + } + } + if password == "" { + t.Fatalf("密码文件缺少密码行: %s", raw) + } + if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil { + t.Errorf("密码文件中的密码与数据库哈希不匹配: %v", err) + } + var count int64 if err := db.Model(&schemaMigration{}).Count(&count).Error; err != nil { t.Fatalf("统计迁移记录失败: %v", err) @@ -91,3 +150,26 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) { t.Errorf("查询结果 = %+v, 期望 Title=%q Content=%q", got, note.Title, note.Content) } } + +func TestGeneratePassword(t *testing.T) { + password, err := generatePassword(adminPasswordLen) + if err != nil { + t.Fatalf("生成密码失败: %v", err) + } + if len(password) != adminPasswordLen { + t.Errorf("密码长度 = %d, 期望 %d", len(password), adminPasswordLen) + } + for _, r := range password { + if !strings.ContainsRune(adminPasswordCharset, r) { + t.Errorf("密码包含非法字符 %q", r) + } + } + + other, err := generatePassword(adminPasswordLen) + if err != nil { + t.Fatalf("生成密码失败: %v", err) + } + if password == other { + t.Error("两次生成的密码不应相同") + } +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 685daf0..88e59df 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -27,6 +27,45 @@ var migrations = []Migration{ return tx.AutoMigrate(&model.Note{}) }, }, + { + Version: 2, + Name: "create_user_groups", + Up: func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.UserGroup{}); err != nil { + return err + } + now := time.Now() + seeds := []struct { + id uint + name string + desc string + }{ + {id: model.GroupIDAdmin, name: "admin", desc: "管理员组"}, + {id: model.GroupIDUser, name: "user", desc: "普通用户组"}, + } + for _, seed := range seeds { + if err := tx.Exec( + "INSERT INTO user_groups (id, name, description, is_system, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + seed.id, seed.name, seed.desc, true, now, now, + ).Error; err != nil { + return err + } + } + return nil + }, + }, + { + Version: 3, + Name: "create_users", + Up: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.User{}, &model.UserGroupMember{}) + }, + }, + { + Version: 4, + Name: "seed_admin_user", + Up: seedAdminUser, + }, } // schemaMigration 记录已应用的迁移版本。 diff --git a/internal/database/seed.go b/internal/database/seed.go new file mode 100644 index 0000000..c239721 --- /dev/null +++ b/internal/database/seed.go @@ -0,0 +1,119 @@ +package database + +import ( + "crypto/rand" + "fmt" + "log/slog" + "math/big" + "os" + "path/filepath" + "strings" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "rill/internal/model" +) + +const ( + adminUsername = "admin" + adminEmail = "admin@example.com" + adminPasswordLen = 16 + adminPasswordFilename = "admin_password.txt" +) + +// adminPasswordCharset 去掉了易混淆字符(0/O、1/l/I)。 +const adminPasswordCharset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// seedAdminUser 创建初始管理员用户并加入 admin 组,密码随机生成且仅在迁移时打印一次。 +func seedAdminUser(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&model.User{}).Where("username = ?", adminUsername).Count(&count).Error; err != nil { + return err + } + if count > 0 { + slog.Warn("已存在同名用户,跳过初始管理员创建", "username", adminUsername) + return nil + } + + password, err := generatePassword(adminPasswordLen) + if err != nil { + return err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + user := model.User{ + Username: adminUsername, + Email: adminEmail, + PasswordHash: string(hash), + Nickname: "管理员", + Status: 1, + } + if err := tx.Create(&user).Error; err != nil { + return err + } + if err := tx.Exec( + "INSERT INTO user_group_members (user_id, user_group_id) VALUES (?, ?)", + user.ID, model.GroupIDAdmin, + ).Error; err != nil { + return err + } + + passwordFile := adminPasswordPath(tx) + if err := writeAdminPasswordFile(passwordFile, password); err != nil { + slog.Warn("初始管理员密码文件保存失败", "path", passwordFile, "err", err) + } + fmt.Printf( + "\n初始管理员账号已创建,密码仅显示这一次,请立即保存:\n 用户名: %s\n 密码: %s\n 密码文件: %s(登录后请立即删除该文件)\n\n", + adminUsername, password, passwordFile, + ) + return nil +} + +// adminPasswordPath 密码文件与 SQLite 数据库同目录;其他驱动回落到 ./data。 +func adminPasswordPath(db *gorm.DB) string { + if db.Dialector.Name() == "sqlite" { + if file := sqliteDatabaseFile(db); file != "" && file != ":memory:" { + return filepath.Join(filepath.Dir(file), adminPasswordFilename) + } + } + return filepath.Join("data", adminPasswordFilename) +} + +func sqliteDatabaseFile(db *gorm.DB) string { + var rows []struct { + File string + } + if err := db.Raw("PRAGMA database_list").Scan(&rows).Error; err != nil || len(rows) == 0 { + return "" + } + return rows[0].File +} + +func writeAdminPasswordFile(path, password string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + content := fmt.Sprintf( + "初始管理员账号(密码仅明文保存在此,登录后请立即删除本文件)\n\n用户名: %s\n密码: %s\n", + adminUsername, password, + ) + return os.WriteFile(path, []byte(content), 0o600) +} + +func generatePassword(length int) (string, error) { + limit := big.NewInt(int64(len(adminPasswordCharset))) + var builder strings.Builder + builder.Grow(length) + for i := 0; i < length; i++ { + n, err := rand.Int(rand.Reader, limit) + if err != nil { + return "", fmt.Errorf("生成随机密码失败: %w", err) + } + builder.WriteByte(adminPasswordCharset[n.Int64()]) + } + return builder.String(), nil +} diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..94a71d0 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,22 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// User 用户,与用户组为多对多关系。 +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:50;uniqueIndex;not null" json:"username"` + Email string `gorm:"size:255;uniqueIndex;not null" json:"email"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + Nickname string `gorm:"size:50" json:"nickname"` + Avatar string `gorm:"size:255" json:"avatar"` + Status int8 `gorm:"not null;default:1" json:"status"` + Groups []UserGroup `gorm:"-" json:"groups"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/internal/model/user_group.go b/internal/model/user_group.go new file mode 100644 index 0000000..5eb6339 --- /dev/null +++ b/internal/model/user_group.go @@ -0,0 +1,31 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// 内置用户组 ID:0 为 admin 组,1 为普通用户默认组。 +const ( + GroupIDAdmin = 0 + GroupIDUser = 1 +) + +// UserGroup 用户组。ID 由服务端分配而非自增,以便跨数据库稳定保留 id 0 作为内置 admin 组。 +type UserGroup struct { + ID uint `gorm:"primaryKey;autoIncrement:false" json:"id"` + Name string `gorm:"size:50;uniqueIndex;not null" json:"name"` + Description string `gorm:"size:255" json:"description"` + IsSystem bool `gorm:"not null;default:false" json:"is_system"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// UserGroupMember 用户与用户组的关联表。组 id 0 是合法主键,GORM 关联读写会把 0 视为零值而丢弃, +// 因此关联的读写统一由 API 层手动维护。 +type UserGroupMember struct { + UserID uint `gorm:"primaryKey" json:"user_id"` + UserGroupID uint `gorm:"primaryKey;index" json:"user_group_id"` +}