Files
rill/internal/api/users.go
T
2026-09-19 17:26:07 +08:00

440 lines
12 KiB
Go

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"
// UserCreateRequest 创建用户请求。
type UserCreateRequest struct {
Username string `json:"username" binding:"required,max=50" example:"alice"`
Email string `json:"email" binding:"required,email,max=255" example:"alice@example.com"`
Password string `json:"password" binding:"required,min=6,max=72" example:"secret123"`
Nickname string `json:"nickname" binding:"max=50" example:"Alice"`
Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"`
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
GroupIDs []uint `json:"group_ids" example:"1"`
}
// UserUpdateRequest 更新用户请求,仅更新请求中提供的字段。
type UserUpdateRequest struct {
Nickname string `json:"nickname" binding:"max=50" example:"Alice"`
Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"`
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
Password string `json:"password" binding:"omitempty,min=6,max=72" example:"secret123"`
GroupIDs *[]uint `json:"group_ids" example:"1"`
}
// UserListResponse 用户分页列表响应。
type UserListResponse struct {
Items []model.User `json:"items"`
Total int64 `json:"total" example:"42"`
Page int `json:"page" example:"1"`
PageSize int `json:"page_size" example:"20"`
}
// @Summary List users
// @Description 分页查询用户列表(含所属用户组),按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。
// @Tags users
// @Produce json
// @Param page query int false "页码,默认 1" example(1)
// @Param page_size query int false "每页数量,默认 20,最大 100" example(20)
// @Success 200 {object} api.UserListResponse
// @Failure 500 {object} api.ErrorResponse
// @Router /users [get]
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, UserListResponse{
Items: users,
Total: total,
Page: page,
PageSize: pageSize,
})
}
}
// @Summary Create a user
// @Description 创建用户并关联用户组。username、email 唯一,password 长度 6-72;不传 group_ids 时默认加入普通用户组(id 1)。
// @Tags users
// @Accept json
// @Produce json
// @Param user body api.UserCreateRequest true "用户信息"
// @Success 201 {object} model.User
// @Failure 400 {object} api.ErrorResponse "参数无效或用户组不存在"
// @Failure 409 {object} api.ErrorResponse "用户名或邮箱已存在"
// @Failure 500 {object} api.ErrorResponse
// @Router /users [post]
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, ErrorResponse{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, ErrorResponse{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)
}
}
// @Summary Get a user
// @Description 按 id 查询用户(含所属用户组)。
// @Tags users
// @Produce json
// @Param id path int true "用户 ID" example(1)
// @Success 200 {object} model.User
// @Failure 400 {object} api.ErrorResponse "id 无效"
// @Failure 404 {object} api.ErrorResponse "记录不存在"
// @Failure 500 {object} api.ErrorResponse
// @Router /users/{id} [get]
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)
}
}
// @Summary Update a user
// @Description 更新用户信息,仅更新请求中提供的字段。传 group_ids 会整体替换用户组;password 非空时重置密码。
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "用户 ID" example(1)
// @Param user body api.UserUpdateRequest true "待更新字段"
// @Success 200 {object} model.User
// @Failure 400 {object} api.ErrorResponse "参数无效、id 无效或用户组不存在"
// @Failure 404 {object} api.ErrorResponse "记录不存在"
// @Failure 500 {object} api.ErrorResponse
// @Router /users/{id} [put]
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, ErrorResponse{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, ErrorResponse{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)
}
}
// @Summary Delete a user
// @Description 按 id 删除用户及其用户组成员关系,成功时返回 204 且无响应体。
// @Tags users
// @Produce json
// @Param id path int true "用户 ID" example(1)
// @Success 204 "删除成功"
// @Failure 400 {object} api.ErrorResponse "id 无效"
// @Failure 404 {object} api.ErrorResponse "记录不存在"
// @Failure 500 {object} api.ErrorResponse
// @Router /users/{id} [delete]
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, ErrorResponse{Error: "服务器内部错误"})
}