增加用户与用户组模块及初始管理员
- users/user_groups 模型与 CRUD 接口,组成员关系手动维护(规避 GORM 零值主键问题) - 迁移 v2~v4:用户组、用户表、初始 admin 用户 - 初始密码随机生成,仅终端打印一次并写入 data/admin_password.txt
This commit is contained in:
13 files changed
+1235
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("用户组不存在")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": "服务器内部错误"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("两次生成的密码不应相同")
|
||||
}
|
||||
}
|
||||
@@ -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 记录已应用的迁移版本。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user