// Package user 提供用户接口及密码、组成员关系等公共能力。 package user import ( "context" "errors" "net/http" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "rill/internal/httpx" "rill/internal/model" ) const groupMembersTable = "user_group_members" // ErrGroupsNotFound 用户组不存在。 var ErrGroupsNotFound = errors.New("user group not found") // CreateRequest 创建用户请求。 type CreateRequest 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"` Gender string `json:"gender" binding:"omitempty,oneof=male female other" example:"male"` Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"` Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"` GroupIDs []uint `json:"group_ids" example:"1"` } // UpdateRequest 更新用户请求,仅更新请求中提供的字段。 type UpdateRequest struct { Nickname string `json:"nickname" binding:"max=50" example:"Alice"` Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"` Gender string `json:"gender" binding:"omitempty,oneof=male female other" example:"male"` Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"` 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"` } // ListResponse 用户分页列表响应。 type ListResponse 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 List users with their groups, ordered by id DESC. page starts at 1; page_size is 1-100, default 20. // @Tags admin // @Produce json // @Param page query int false "Page number, default 1" example(1) // @Param page_size query int false "Page size, default 20, max 100" example(20) // @Success 200 {object} user.ListResponse // @Failure 500 {object} httpx.ErrorResponse // @Security BearerAuth // @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" // @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled" // @Router /users [get] func List(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { page, pageSize := httpx.ParsePagination(c) ctx := c.Request.Context() var total int64 if err := db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil { httpx.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 { httpx.RespondDBError(c, err) return } if err := AttachGroups(ctx, db, users); err != nil { httpx.RespondDBError(c, err) return } c.JSON(http.StatusOK, ListResponse{ Items: users, Total: total, Page: page, PageSize: pageSize, }) } } // @Summary Create a user // @Description Create a user and assign groups. username and email are unique; password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD and cannot be in the future; defaults to the regular user group (id 1) when group_ids is omitted. // @Tags admin // @Accept json // @Produce json // @Param user body user.CreateRequest true "User payload" // @Success 201 {object} model.User // @Failure 400 {object} httpx.ErrorResponse "invalid request or user group not found" // @Failure 409 {object} httpx.ErrorResponse "username or email already exists" // @Failure 500 {object} httpx.ErrorResponse // @Security BearerAuth // @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" // @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled" // @Router /users [post] func Create(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var req CreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()}) return } ctx := c.Request.Context() hash, err := HashPassword(req.Password) if err != nil { httpx.RespondServerError(c, err, "生成密码哈希失败") return } birthday, err := NormalizeBirthday(req.Birthday) if err != nil { c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()}) 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, httpx.ErrorResponse{Error: ErrGroupsNotFound.Error()}) return } httpx.RespondDBError(c, err) return } user := model.User{ Username: req.Username, Email: req.Email, PasswordHash: string(hash), Nickname: req.Nickname, Avatar: req.Avatar, Gender: req.Gender, Birthday: birthday, Status: status, } err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&user).Error; err != nil { return err } return ReplaceGroups(tx, user.ID, groupIDs) }) if err != nil { httpx.RespondDuplicateOrDBError(c, err, "username or email already exists") return } user.Groups = groups c.JSON(http.StatusCreated, user) } } // @Summary Get a user // @Description Get a user by id, including groups. // @Tags admin // @Produce json // @Param id path int true "User ID" example(1) // @Success 200 {object} model.User // @Failure 400 {object} httpx.ErrorResponse "invalid id" // @Failure 404 {object} httpx.ErrorResponse "record not found" // @Failure 500 {object} httpx.ErrorResponse // @Security BearerAuth // @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" // @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled" // @Router /users/{id} [get] func Get(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := httpx.ParseID(c) if !ok { return } ctx := c.Request.Context() var user model.User if err := db.WithContext(ctx).First(&user, id).Error; err != nil { httpx.RespondGetError(c, err) return } groups, err := LoadGroups(ctx, db, user.ID) if err != nil { httpx.RespondDBError(c, err) return } user.Groups = groups c.JSON(http.StatusOK, user) } } // @Summary Update a user // @Description Update user fields. group_ids replaces all groups; a non-empty password resets the password; birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value. // @Tags admin // @Accept json // @Produce json // @Param id path int true "User ID" example(1) // @Param user body user.UpdateRequest true "Fields to update" // @Success 200 {object} model.User // @Failure 400 {object} httpx.ErrorResponse "invalid request, id, or user group not found" // @Failure 404 {object} httpx.ErrorResponse "record not found" // @Failure 500 {object} httpx.ErrorResponse // @Security BearerAuth // @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" // @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled" // @Router /users/{id} [put] func Update(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := httpx.ParseID(c) if !ok { return } var req UpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()}) return } ctx := c.Request.Context() var user model.User if err := db.WithContext(ctx).First(&user, id).Error; err != nil { httpx.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, httpx.ErrorResponse{Error: ErrGroupsNotFound.Error()}) return } httpx.RespondDBError(c, err) return } } updates := map[string]any{ "nickname": req.Nickname, "avatar": req.Avatar, "gender": req.Gender, } var birthday model.Date if req.Birthday != nil { value, err := NormalizeBirthday(req.Birthday) if err != nil { c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()}) return } birthday = value if birthday.IsZero() { updates["birthday"] = nil } else { updates["birthday"] = birthday } } if req.Status != nil { updates["status"] = *req.Status } if req.Password != "" { hash, err := HashPassword(req.Password) if err != nil { httpx.RespondServerError(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 ReplaceGroups(tx, user.ID, *req.GroupIDs) } return nil }) if err != nil { httpx.RespondDBError(c, err) return } groups, err := LoadGroups(ctx, db, user.ID) if err != nil { httpx.RespondDBError(c, err) return } user.Nickname = req.Nickname user.Avatar = req.Avatar user.Gender = req.Gender if req.Birthday != nil { user.Birthday = birthday } if req.Status != nil { user.Status = *req.Status } user.Groups = groups c.JSON(http.StatusOK, user) } } // @Summary Delete a user // @Description Delete a user and their group memberships; returns 204 with no body on success. // @Tags admin // @Produce json // @Param id path int true "User ID" example(1) // @Success 204 "Deleted" // @Failure 400 {object} httpx.ErrorResponse "invalid id" // @Failure 404 {object} httpx.ErrorResponse "record not found" // @Failure 500 {object} httpx.ErrorResponse // @Security BearerAuth // @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" // @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled" // @Router /users/{id} [delete] func Delete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := httpx.ParseID(c) if !ok { return } ctx := c.Request.Context() var user model.User if err := db.WithContext(ctx).First(&user, id).Error; err != nil { httpx.RespondGetError(c, err) return } err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Exec("DELETE FROM "+groupMembersTable+" WHERE user_id = ?", id).Error; err != nil { return err } return tx.Delete(&user).Error }) if err != nil { httpx.RespondDBError(c, err) return } c.Status(http.StatusNoContent) } } // HashPassword 生成 bcrypt 密码哈希。 func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(hash), nil } // LoadGroups 查询单个用户所属的用户组。 func LoadGroups(ctx context.Context, db *gorm.DB, userID uint) ([]model.UserGroup, error) { var groupIDs []uint if err := db.WithContext(ctx).Table(groupMembersTable). 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 } // AttachGroups 为一批用户批量填充所属用户组,避免逐条查询。 func AttachGroups(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 } // ReplaceGroups 以给定的组 ID 整体替换用户的组成员关系,需在事务中调用。 func ReplaceGroups(tx *gorm.DB, userID uint, groupIDs []uint) error { if err := tx.Exec("DELETE FROM "+groupMembersTable+" WHERE user_id = ?", userID).Error; err != nil { return err } for _, groupID := range dedupeIDs(groupIDs) { if err := tx.Exec( "INSERT INTO "+groupMembersTable+" (user_id, user_group_id) VALUES (?, ?)", userID, groupID, ).Error; err != nil { return err } } return nil } // NormalizeBirthday 校验生日格式与范围,空串表示清空(返回零值)。 func NormalizeBirthday(value *string) (model.Date, error) { if value == nil || *value == "" { return model.Date{}, nil } birthday, err := time.Parse("2006-01-02", *value) if err != nil { return model.Date{}, errors.New("invalid birthday") } if birthday.After(time.Now()) { return model.Date{}, errors.New("birthday cannot be in the future") } return model.Date{Time: birthday}, 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 }