增加用户与用户组模块及初始管理员

- users/user_groups 模型与 CRUD 接口,组成员关系手动维护(规避 GORM 零值主键问题)
- 迁移 v2~v4:用户组、用户表、初始 admin 用户
- 初始密码随机生成,仅终端打印一次并写入 data/admin_password.txt
This commit is contained in:
2026-09-19 17:10:54 +08:00
parent 45c24a4bd3
commit 62f02f8a39
13 files changed
+1235 -10

No files matched your search

+1 -1
View File
@@ -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
+50
View File
@@ -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 {
+1 -8
View File
@@ -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
+8 -1
View File
@@ -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 {
+173
View File
@@ -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("用户组不存在")
+165
View File
@@ -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)
}
}
+377
View File
@@ -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": "服务器内部错误"})
}
+167
View File
@@ -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)
}
}
+82
View File
@@ -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("两次生成的密码不应相同")
}
}
+39
View File
@@ -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 记录已应用的迁移版本。
+119
View File
@@ -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
}
+22
View File
@@ -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:"-"`
}
+31
View File
@@ -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"`
}