- @Tags 由功能维度改为 public/user/admin 权限维度 - main.go 增加全局 tag 声明与权限说明(需放在 @securitydefinitions 之前,否则会被解析器吞掉) - 重新生成 docs/,公开 4 个、需登录 7 个、管理员 11 个接口
501 lines
15 KiB
Go
501 lines
15 KiB
Go
// 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
|
|
}
|