Skip to content

Commit 40fb3c3

Browse files
dsx1377ac9d42
authored andcommitted
feat(users): 添加批量注册与批量修改用户功能;
revert:恢复dev分支错误相关提交 新增支持通过 CSV 文件批量注册用户的功能,以及批量修改用户账号类型和角色的功能。 - 添加接口用于批量注册用户,并使用Content-Type区分单个与批量注册请求 - 添加 `/api/users/category` 和 `/api/users/role` 接口用于批量修改用户属性 - 引入 207 Multi-Status 响应机制处理部分失败的批量操作 - 更新错误处理逻辑,细化多种业务错误类型 - 完善 Swagger 文档,补充新接口的定义和说明 - README 中增加新功能使用说明及快速上手示例
1 parent 4d9a56d commit 40fb3c3

8 files changed

Lines changed: 1198 additions & 32 deletions

File tree

README.md

Lines changed: 0 additions & 3 deletions
This file was deleted.

backend/pkg/controller/users.go

Lines changed: 305 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package controller
22

33
import (
4+
"encoding/csv"
5+
"io"
46
"net/http"
7+
"strings"
58

69
"asynclab.club/asynx/backend/pkg/security"
710
"asynclab.club/asynx/backend/pkg/service"
@@ -22,6 +25,8 @@ func NewControllerUser(g *gin.RouterGroup, serviceManager *service.ServiceManage
2225
g.PUT("/:uid/password", security.GuardMiddleware(security.RoleRestricted), gggin.ToGinHandler(ctl.HandleChangePassword))
2326
g.PUT("/:uid/category", security.GuardMiddleware(security.RoleAdmin), gggin.ToGinHandler(ctl.HandleModifyCategory))
2427
g.PUT("/:uid/role", security.GuardMiddleware(security.RoleAdmin), gggin.ToGinHandler(ctl.HandleModifyRole))
28+
g.PATCH("/category", security.GuardMiddleware(security.RoleAdmin), gggin.ToGinHandler(ctl.HandleBatchModifyCategory))
29+
g.PATCH("/role", security.GuardMiddleware(security.RoleAdmin), gggin.ToGinHandler(ctl.HandleBatchModifyRole))
2530

2631
// Deprecated
2732
g.GET("/:uid/category", security.GuardMiddleware(security.RoleRestricted), gggin.ToGinHandler(ctl.HandleGetCategory))
@@ -128,7 +133,6 @@ func (ctl *ControllerUser) HandleChangePassword(c *gin.Context) (*gggin.Response
128133
err = ctl.serviceManager.ChangePassword(uid, req.Password)
129134
if err != nil {
130135
return nil, service.MapErrorToHttp(err)
131-
132136
}
133137

134138
return gggin.Ok, nil
@@ -230,24 +234,52 @@ type RequestRegister struct {
230234
}
231235

232236
// @Summary 注册新用户
233-
// @Description 创建新用户账号。需要 ADMIN 角色权限。
237+
// @Description 创建新用户账号。支持两种方式:1) JSON格式单个注册 2) CSV文件批量注册。需要 ADMIN 角色权限。
238+
// @Description
239+
// @Description **单个注册 (application/json)**
240+
// @Description 发送JSON格式的单个用户数据
241+
// @Description
242+
// @Description **批量注册 (multipart/form-data 或 text/csv)**
243+
// @Description 上传CSV文件,CSV格式为:username,surName,givenName,mail,category,role
244+
// @Description 示例:user001,张,三,zhangsan@example.com,member,default
234245
// @Tags users
235-
// @Accept json
246+
// @Accept json,multipart/form-data,text/csv
236247
// @Produce json
237-
// @Param body body RequestRegister true "注册用户请求"
238-
// @Success 200 {object} object{data=string} "成功注册用户,返回 'ok'"
248+
// @Param body body RequestRegister false "单个注册请求 (Content-Type: application/json)"
249+
// @Param file formData file false "批量注册CSV文件 (Content-Type: multipart/form-data)"
250+
// @Success 200 {object} object{data=string} "单个注册成功,返回 'ok'"
251+
// @Success 200 {object} object{data=BatchRegisterResult} "批量注册全部成功"
252+
// @Success 207 {object} object{data=BatchRegisterResult} "批量注册部分失败"
239253
// @Failure 400 {object} object{data=string} "请求参数错误"
240254
// @Failure 401 {object} object{data=string} "未授权访问"
241255
// @Failure 403 {object} object{data=string} "权限不足"
242256
// @Failure 409 {object} object{data=string} "用户已存在"
257+
// @Failure 415 {object} object{data=string} "不支持的媒体类型"
243258
// @Failure 500 {object} object{data=string} "服务器内部错误"
244259
// @Router /users [post]
245260
// @Security BearerAuth
246-
func (ctl *ControllerUser) HandleRegister(c *gin.Context) (*gggin.Response[string], *gggin.HttpError) {
261+
func (ctl *ControllerUser) HandleRegister(c *gin.Context) (*gggin.Response[any], *gggin.HttpError) {
247262
_, ok := gggin.Get[*security.GuardResult](c, "guard")
248263
if !ok {
249264
return nil, ErrHttpGuardFail
250265
}
266+
267+
contentType := c.ContentType()
268+
269+
// 根据 Content-Type 区分单个注册还是批量注册
270+
if contentType == "application/json" {
271+
// 单个用户注册
272+
return ctl.handleSingleRegister(c)
273+
} else if strings.HasPrefix(contentType, "multipart/form-data") || contentType == "text/csv" {
274+
// CSV 批量注册
275+
return ctl.handleBatchRegisterFromCSV(c)
276+
}
277+
278+
return nil, gggin.NewHttpError(http.StatusUnsupportedMediaType, "不支持的 Content-Type,请使用 application/json (单个注册) 或 multipart/form-data (批量注册)")
279+
}
280+
281+
// handleSingleRegister 处理单个用户注册(JSON格式)
282+
func (ctl *ControllerUser) handleSingleRegister(c *gin.Context) (*gggin.Response[any], *gggin.HttpError) {
251283
req, err := gggin.ShouldBindJSON[RequestRegister](c)
252284
if err != nil {
253285
return nil, gggin.NewHttpError(http.StatusBadRequest, err.Error())
@@ -258,7 +290,126 @@ func (ctl *ControllerUser) HandleRegister(c *gin.Context) (*gggin.Response[strin
258290
return nil, service.MapErrorToHttp(err)
259291
}
260292

261-
return gggin.Ok, nil
293+
return gggin.NewResponse[any]("ok"), nil
294+
}
295+
296+
// handleBatchRegisterFromCSV 处理CSV文件批量注册
297+
func (ctl *ControllerUser) handleBatchRegisterFromCSV(c *gin.Context) (*gggin.Response[any], *gggin.HttpError) {
298+
file, err := c.FormFile("file")
299+
if err != nil {
300+
return nil, gggin.NewHttpError(http.StatusBadRequest, "未找到上传的文件,请使用 'file' 字段上传CSV文件")
301+
}
302+
303+
src, err := file.Open()
304+
if err != nil {
305+
return nil, gggin.NewHttpError(http.StatusInternalServerError, "无法读取上传的文件")
306+
}
307+
defer src.Close()
308+
309+
reader := csv.NewReader(src)
310+
311+
header, err := reader.Read()
312+
if err != nil {
313+
return nil, gggin.NewHttpError(http.StatusBadRequest, "CSV文件格式错误:无法读取表头")
314+
}
315+
316+
// 验证表头格式
317+
expectedHeaders := []string{"username", "surName", "givenName", "mail", "category", "role"}
318+
hasHeader := false
319+
if len(header) == len(expectedHeaders) {
320+
// 检查前3个关键字段来判断是否有表头,避免误判
321+
h0 := strings.ToLower(header[0])
322+
h3 := strings.ToLower(header[3])
323+
h4 := strings.ToLower(header[4])
324+
if h0 == "username" && h3 == "mail" && h4 == "category" {
325+
hasHeader = true
326+
}
327+
}
328+
329+
var result BatchRegisterResult
330+
rowNum := 1
331+
332+
if !hasHeader {
333+
if err := ctl.processCSVRow(header, &result, rowNum); err != nil {
334+
return nil, err
335+
}
336+
rowNum++
337+
}
338+
339+
for {
340+
record, err := reader.Read()
341+
if err == io.EOF {
342+
break
343+
}
344+
if err != nil {
345+
result.Failed++
346+
result.Total++
347+
result.Failures = append(result.Failures, BatchFailureInfo{
348+
Row: rowNum,
349+
Username: "",
350+
Error: "CSV行格式错误: " + err.Error(),
351+
})
352+
rowNum++
353+
continue
354+
}
355+
356+
if err := ctl.processCSVRow(record, &result, rowNum); err != nil {
357+
return nil, err
358+
}
359+
rowNum++
360+
}
361+
362+
if result.Failed > 0 && result.Success > 0 {
363+
// 部分失败:使用207 Multi-Status
364+
return gggin.NewResponseWithStatusCode[any](http.StatusMultiStatus, result), nil
365+
}
366+
367+
return gggin.NewResponse[any](result), nil
368+
}
369+
370+
func (ctl *ControllerUser) processCSVRow(record []string, result *BatchRegisterResult, rowNum int) *gggin.HttpError {
371+
result.Total++
372+
373+
if len(record) != 6 {
374+
result.Failed++
375+
result.Failures = append(result.Failures, BatchFailureInfo{
376+
Row: rowNum,
377+
Username: "",
378+
Error: "CSV格式错误:应包含6个字段 (username,surName,givenName,mail,category,role)",
379+
})
380+
return nil
381+
}
382+
383+
username := strings.TrimSpace(record[0])
384+
surName := strings.TrimSpace(record[1])
385+
givenName := strings.TrimSpace(record[2])
386+
mail := strings.TrimSpace(record[3])
387+
category := strings.TrimSpace(record[4])
388+
role := strings.TrimSpace(record[5])
389+
390+
if username == "" || mail == "" || category == "" || role == "" {
391+
result.Failed++
392+
result.Failures = append(result.Failures, BatchFailureInfo{
393+
Row: rowNum,
394+
Username: username,
395+
Error: "必填字段不能为空 (username, mail, category, role)",
396+
})
397+
return nil
398+
}
399+
400+
err := ctl.serviceManager.Register(username, surName, givenName, mail, category, role)
401+
if err != nil {
402+
result.Failed++
403+
result.Failures = append(result.Failures, BatchFailureInfo{
404+
Row: rowNum,
405+
Username: username,
406+
Error: err.Error(),
407+
})
408+
} else {
409+
result.Success++
410+
}
411+
412+
return nil
262413
}
263414

264415
// @Summary 删除用户
@@ -372,3 +523,150 @@ func (ctl *ControllerUser) HandleGetCategory(c *gin.Context) (*gggin.Response[se
372523

373524
return gggin.NewResponse(category), nil
374525
}
526+
527+
// ======== 批量操作相关类型定义 ========
528+
529+
// BatchRegisterResult 批量注册结果
530+
type BatchRegisterResult struct {
531+
Success int `json:"success"`
532+
Failed int `json:"failed"`
533+
Total int `json:"total"`
534+
Failures []BatchFailureInfo `json:"failures"`
535+
}
536+
537+
// BatchFailureInfo 批量操作失败信息
538+
type BatchFailureInfo struct {
539+
Row int `json:"row"`
540+
Username string `json:"username"`
541+
Error string `json:"error"`
542+
}
543+
544+
// RequestBatchModifyCategory 批量修改类别请求体
545+
type RequestBatchModifyCategory struct {
546+
UserIds []string `json:"userIds" binding:"required"`
547+
Category string `json:"category" binding:"required"`
548+
}
549+
550+
// RequestBatchModifyRole 批量修改角色请求体
551+
type RequestBatchModifyRole struct {
552+
UserIds []string `json:"userIds" binding:"required"`
553+
Role string `json:"role" binding:"required"`
554+
}
555+
556+
// BatchModifyResult 批量修改结果
557+
type BatchModifyResult struct {
558+
Success int `json:"success"`
559+
Failed int `json:"failed"`
560+
Total int `json:"total"`
561+
Failures []BatchModifyFailure `json:"failures"`
562+
}
563+
564+
// BatchModifyFailure 批量修改失败信息
565+
type BatchModifyFailure struct {
566+
UserId string `json:"userId"`
567+
Error string `json:"error"`
568+
}
569+
570+
// ======== 批量操作功能实现 ========
571+
572+
// @Summary 批量修改账号类型
573+
// @Description 批量修改指定用户的账号类型。需要 ADMIN 角色权限。
574+
// @Tags users
575+
// @Accept json
576+
// @Produce json
577+
// @Param body body RequestBatchModifyCategory true "批量修改账号类型请求"
578+
// @Success 200 {object} object{data=BatchModifyResult} "全部成功:批量修改结果"
579+
// @Success 207 {object} object{data=BatchModifyResult} "部分失败:批量修改结果"
580+
// @Failure 400 {object} object{data=string} "请求参数错误"
581+
// @Failure 401 {object} object{data=string} "未授权访问"
582+
// @Failure 403 {object} object{data=string} "权限不足"
583+
// @Failure 500 {object} object{data=string} "服务器内部错误"
584+
// @Router /users/category [patch]
585+
// @Security BearerAuth
586+
func (ctl *ControllerUser) HandleBatchModifyCategory(c *gin.Context) (*gggin.Response[BatchModifyResult], *gggin.HttpError) {
587+
_, ok := gggin.Get[*security.GuardResult](c, "guard")
588+
if !ok {
589+
return nil, ErrHttpGuardFail
590+
}
591+
592+
req, err := gggin.ShouldBindJSON[RequestBatchModifyCategory](c)
593+
if err != nil {
594+
return nil, gggin.NewHttpError(http.StatusBadRequest, err.Error())
595+
}
596+
597+
var result BatchModifyResult
598+
result.Total = len(req.UserIds)
599+
600+
for _, uid := range req.UserIds {
601+
err := ctl.serviceManager.ModifyCategory(uid, req.Category)
602+
if err != nil {
603+
result.Failed++
604+
result.Failures = append(result.Failures, BatchModifyFailure{
605+
UserId: uid,
606+
Error: err.Error(),
607+
})
608+
} else {
609+
result.Success++
610+
}
611+
}
612+
613+
// 根据结果决定返回方式:全部成功返回200,部分失败返回207
614+
if result.Failed > 0 && result.Success > 0 {
615+
// 部分失败:使用207 Multi-Status
616+
return gggin.NewResponseWithStatusCode(http.StatusMultiStatus, result), nil
617+
}
618+
619+
// 全部成功或全部失败:使用200
620+
return gggin.NewResponse(result), nil
621+
}
622+
623+
// @Summary 批量修改角色权限
624+
// @Description 批量修改指定用户的角色权限。需要 ADMIN 角色权限。
625+
// @Tags users
626+
// @Accept json
627+
// @Produce json
628+
// @Param body body RequestBatchModifyRole true "批量修改角色权限请求"
629+
// @Success 200 {object} object{data=BatchModifyResult} "全部成功:批量修改结果"
630+
// @Success 207 {object} object{data=BatchModifyResult} "部分失败:批量修改结果"
631+
// @Failure 400 {object} object{data=string} "请求参数错误"
632+
// @Failure 401 {object} object{data=string} "未授权访问"
633+
// @Failure 403 {object} object{data=string} "权限不足"
634+
// @Failure 500 {object} object{data=string} "服务器内部错误"
635+
// @Router /users/role [patch]
636+
// @Security BearerAuth
637+
func (ctl *ControllerUser) HandleBatchModifyRole(c *gin.Context) (*gggin.Response[BatchModifyResult], *gggin.HttpError) {
638+
_, ok := gggin.Get[*security.GuardResult](c, "guard")
639+
if !ok {
640+
return nil, ErrHttpGuardFail
641+
}
642+
643+
req, err := gggin.ShouldBindJSON[RequestBatchModifyRole](c)
644+
if err != nil {
645+
return nil, gggin.NewHttpError(http.StatusBadRequest, err.Error())
646+
}
647+
648+
var result BatchModifyResult
649+
result.Total = len(req.UserIds)
650+
651+
for _, uid := range req.UserIds {
652+
err := ctl.serviceManager.GrantRoleByUidAndRoleName(uid, req.Role)
653+
if err != nil {
654+
result.Failed++
655+
result.Failures = append(result.Failures, BatchModifyFailure{
656+
UserId: uid,
657+
Error: err.Error(),
658+
})
659+
} else {
660+
result.Success++
661+
}
662+
}
663+
664+
// 根据结果决定返回方式:全部成功返回200,部分失败返回207
665+
if result.Failed > 0 && result.Success > 0 {
666+
// 部分失败:使用207 Multi-Status
667+
return gggin.NewResponseWithStatusCode(http.StatusMultiStatus, result), nil
668+
}
669+
670+
// 全部成功或全部失败:使用200
671+
return gggin.NewResponse(result), nil
672+
}

backend/pkg/repository/user.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import (
77
"asynclab.club/asynx/backend/pkg/config"
88
"asynclab.club/asynx/backend/pkg/entity"
99
"asynclab.club/asynx/backend/pkg/transfer"
10-
11-
1210
)
1311

1412
type RepositoryUser struct {

0 commit comments

Comments
 (0)