From 77e7a612fcfca6cdc0e3b8aa663c07a7b258d223 Mon Sep 17 00:00:00 2001 From: laoboli <1293528695@qq.com> Date: Wed, 29 Apr 2026 08:41:23 +0800 Subject: [PATCH] feat: system manager. --- controllers/kindergarten_admin.go | 159 ++++++++++++++ controllers/user_admin.go | 353 ++++++++++++++++++++++++++++++ main.go | 1 + middleware/system_admin.go | 31 +++ models/kindergarten.go | 32 +++ routes/routes.go | 14 ++ 6 files changed, 590 insertions(+) create mode 100644 controllers/kindergarten_admin.go create mode 100644 controllers/user_admin.go create mode 100644 middleware/system_admin.go create mode 100644 models/kindergarten.go diff --git a/controllers/kindergarten_admin.go b/controllers/kindergarten_admin.go new file mode 100644 index 0000000..4965532 --- /dev/null +++ b/controllers/kindergarten_admin.go @@ -0,0 +1,159 @@ +package controllers + +import ( + "errors" + "hr_receiver/config" + "hr_receiver/models" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type KindergartenAdminController struct { + DB *gorm.DB +} + +type kindergartenPayload struct { + Address string `json:"address"` + Name string `json:"name"` + RegionID uint32 `json:"regionId"` +} + +func NewKindergartenAdminController() *KindergartenAdminController { + return &KindergartenAdminController{DB: config.DB} +} + +func (kc *KindergartenAdminController) List(c *gin.Context) { + var items []models.Kindergarten + query := kc.DB.Model(&models.Kindergarten{}).Order("region_id ASC, id ASC") + + if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" { + likeValue := "%" + keyword + "%" + query = query.Where("name LIKE ? OR address LIKE ?", likeValue, likeValue) + } + + if err := query.Find(&items).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to query kindergartens") + return + } + writeSuccess(c, http.StatusOK, "query success", items) +} + +func (kc *KindergartenAdminController) Create(c *gin.Context) { + var payload kindergartenPayload + if err := c.ShouldBindJSON(&payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := validateKindergartenPayload(payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + record := models.Kindergarten{ + Address: strings.TrimSpace(payload.Address), + Name: strings.TrimSpace(payload.Name), + RegionID: payload.RegionID, + } + if err := kc.DB.Create(&record).Error; err != nil { + writeKindergartenDBError(c, err) + return + } + writeSuccess(c, http.StatusCreated, "create success", record) +} + +func (kc *KindergartenAdminController) Update(c *gin.Context) { + record, err := kc.findByID(c.Param("id")) + if err != nil { + respondKindergartenLookupError(c, err) + return + } + + var payload kindergartenPayload + if err := c.ShouldBindJSON(&payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := validateKindergartenPayload(payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + record.Name = strings.TrimSpace(payload.Name) + record.Address = strings.TrimSpace(payload.Address) + record.RegionID = payload.RegionID + + if err := kc.DB.Save(&record).Error; err != nil { + writeKindergartenDBError(c, err) + return + } + writeSuccess(c, http.StatusOK, "update success", record) +} + +func (kc *KindergartenAdminController) Delete(c *gin.Context) { + record, err := kc.findByID(c.Param("id")) + if err != nil { + respondKindergartenLookupError(c, err) + return + } + + var bindings int64 + if err := kc.DB.Model(&models.UserRegionBinding{}).Where("region_id = ?", record.RegionID).Count(&bindings).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to check user bindings") + return + } + if bindings > 0 { + writeError(c, http.StatusConflict, "current kindergarten is bound to users") + return + } + + if err := kc.DB.Delete(&record).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to delete kindergarten") + return + } + writeSuccess(c, http.StatusOK, "delete success", nil) +} + +func (kc *KindergartenAdminController) findByID(id string) (models.Kindergarten, error) { + var record models.Kindergarten + numericID, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64) + if err != nil { + return record, gorm.ErrRecordNotFound + } + if err := kc.DB.First(&record, numericID).Error; err != nil { + return record, err + } + return record, nil +} + +func validateKindergartenPayload(payload kindergartenPayload) error { + if strings.TrimSpace(payload.Name) == "" { + return errors.New("name is required") + } + if strings.TrimSpace(payload.Address) == "" { + return errors.New("address is required") + } + if payload.RegionID == 0 { + return errors.New("regionId is required") + } + return nil +} + +func respondKindergartenLookupError(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(c, http.StatusNotFound, "kindergarten not found") + return + } + writeError(c, http.StatusInternalServerError, "failed to query kindergarten") +} + +func writeKindergartenDBError(c *gin.Context, err error) { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + writeError(c, http.StatusConflict, "regionId already has a kindergarten") + return + } + writeError(c, http.StatusInternalServerError, "failed to persist kindergarten") +} diff --git a/controllers/user_admin.go b/controllers/user_admin.go new file mode 100644 index 0000000..4c73467 --- /dev/null +++ b/controllers/user_admin.go @@ -0,0 +1,353 @@ +package controllers + +import ( + "errors" + "hr_receiver/config" + "hr_receiver/models" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type UserAdminController struct { + DB *gorm.DB +} + +type userAdminPayload struct { + Email *string `json:"email"` + FlavorType models.UserFlavorType `json:"flavorType"` + IsActive *bool `json:"isActive"` + Password string `json:"password"` + Phone *string `json:"phone"` + RegionIDs []uint32 `json:"regionIds"` + Role models.UserRole `json:"role"` + Username string `json:"username"` +} + +type userAdminListItem struct { + CreatedAt int64 `json:"created_at"` + Email *string `json:"email"` + FlavorType models.UserFlavorType `json:"flavorType"` + ID uint `json:"id"` + IsActive bool `json:"isActive"` + KindergartenNames []string `json:"kindergartenNames"` + Phone *string `json:"phone"` + RegionIDs []uint32 `json:"regionIds"` + Regions []models.UserRegionBinding `json:"regions"` + Role models.UserRole `json:"role"` + UpdatedAt int64 `json:"updated_at"` + Username string `json:"username"` +} + +func NewUserAdminController() *UserAdminController { + return &UserAdminController{DB: config.DB} +} + +func (uc *UserAdminController) List(c *gin.Context) { + var users []models.User + query := uc.DB.Preload("Regions").Order("id ASC") + + if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" { + likeValue := "%" + keyword + "%" + query = query.Where("username LIKE ? OR email LIKE ? OR phone LIKE ?", likeValue, likeValue, likeValue) + } + if role := strings.TrimSpace(c.Query("role")); role != "" { + query = query.Where("role = ?", role) + } + if flavorType := strings.TrimSpace(c.Query("flavorType")); flavorType != "" { + query = query.Where("flavor_type = ?", flavorType) + } + + if err := query.Find(&users).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to query users") + return + } + items, err := uc.buildUserAdminItems(users) + if err != nil { + writeError(c, http.StatusInternalServerError, "failed to load kindergarten names") + return + } + writeSuccess(c, http.StatusOK, "query success", items) +} + +func (uc *UserAdminController) Create(c *gin.Context) { + var payload userAdminPayload + if err := c.ShouldBindJSON(&payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := validateCreateUserPayload(payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := validateRegionIDs(payload.RegionIDs); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + user := models.User{ + Email: normalizeOptionalString(payload.Email), + FlavorType: payload.FlavorType, + Phone: normalizeOptionalString(payload.Phone), + Password: payload.Password, + Role: payload.Role, + Username: strings.TrimSpace(payload.Username), + Regions: buildUserRegionBindings(payload.RegionIDs), + } + if payload.IsActive != nil { + user.IsActive = *payload.IsActive + } + + if err := uc.DB.Create(&user).Error; err != nil { + writeUserAdminDBError(c, err) + return + } + if payload.IsActive != nil && !*payload.IsActive { + if err := uc.DB.Model(&models.User{}).Where("id = ?", user.ID).Update("is_active", false).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to set user status") + return + } + } + + if err := uc.DB.Preload("Regions").First(&user, user.ID).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to reload user") + return + } + items, err := uc.buildUserAdminItems([]models.User{user}) + if err != nil { + writeError(c, http.StatusInternalServerError, "failed to load kindergarten names") + return + } + writeSuccess(c, http.StatusCreated, "create success", items[0]) +} + +func (uc *UserAdminController) Update(c *gin.Context) { + user, err := uc.findUserByID(c.Param("id")) + if err != nil { + respondUserLookupError(c, err) + return + } + + var payload userAdminPayload + if err := c.ShouldBindJSON(&payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := validateUpdateUserPayload(payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := validateRegionIDs(payload.RegionIDs); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + user.Username = strings.TrimSpace(payload.Username) + user.Email = normalizeOptionalString(payload.Email) + user.Phone = normalizeOptionalString(payload.Phone) + user.Role = payload.Role + user.FlavorType = payload.FlavorType + if payload.IsActive != nil { + user.IsActive = *payload.IsActive + } + if strings.TrimSpace(payload.Password) != "" { + if err := user.HashPassword(payload.Password); err != nil { + writeError(c, http.StatusInternalServerError, "failed to hash password") + return + } + } + + if err := uc.DB.Transaction(func(tx *gorm.DB) error { + updateData := map[string]interface{}{ + "username": user.Username, + "email": user.Email, + "phone": user.Phone, + "role": user.Role, + "flavor_type": user.FlavorType, + "is_active": user.IsActive, + } + if strings.TrimSpace(payload.Password) != "" { + updateData["password"] = user.Password + } + if err := tx.Model(&models.User{}).Where("id = ?", user.ID).Updates(updateData).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", user.ID).Delete(&models.UserRegionBinding{}).Error; err != nil { + return err + } + bindings := buildUserRegionBindings(payload.RegionIDs) + if len(bindings) > 0 { + for i := range bindings { + bindings[i].UserID = user.ID + } + if err := tx.Create(&bindings).Error; err != nil { + return err + } + } + return nil + }); err != nil { + writeUserAdminDBError(c, err) + return + } + + if err := uc.DB.Preload("Regions").First(&user, user.ID).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to reload user") + return + } + items, err := uc.buildUserAdminItems([]models.User{user}) + if err != nil { + writeError(c, http.StatusInternalServerError, "failed to load kindergarten names") + return + } + writeSuccess(c, http.StatusOK, "update success", items[0]) +} + +func (uc *UserAdminController) Delete(c *gin.Context) { + user, err := uc.findUserByID(c.Param("id")) + if err != nil { + respondUserLookupError(c, err) + return + } + currentUserID, _, ok := currentUser(c) + if ok && currentUserID == user.ID { + writeError(c, http.StatusBadRequest, "cannot delete current user") + return + } + if err := uc.DB.Delete(&user).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to delete user") + return + } + writeSuccess(c, http.StatusOK, "delete success", nil) +} + +func (uc *UserAdminController) findUserByID(id string) (models.User, error) { + var user models.User + numericID, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64) + if err != nil { + return user, gorm.ErrRecordNotFound + } + if err := uc.DB.Preload("Regions").First(&user, numericID).Error; err != nil { + return user, err + } + return user, nil +} + +func (uc *UserAdminController) buildUserAdminItems(users []models.User) ([]userAdminListItem, error) { + regionIDs := make([]uint32, 0) + seenRegions := make(map[uint32]struct{}) + for _, user := range users { + for _, region := range user.Regions { + if _, exists := seenRegions[region.RegionID]; exists { + continue + } + seenRegions[region.RegionID] = struct{}{} + regionIDs = append(regionIDs, region.RegionID) + } + } + + kindergartenMap := make(map[uint32]string) + if len(regionIDs) > 0 { + var kindergartens []models.Kindergarten + if err := uc.DB.Where("region_id IN ?", regionIDs).Find(&kindergartens).Error; err != nil { + return nil, err + } + for _, item := range kindergartens { + kindergartenMap[item.RegionID] = item.Name + } + } + + items := make([]userAdminListItem, 0, len(users)) + for _, user := range users { + item := userAdminListItem{ + CreatedAt: user.CreatedAt, + Email: user.Email, + FlavorType: user.FlavorType, + ID: user.ID, + IsActive: user.IsActive, + Phone: user.Phone, + RegionIDs: user.RegionIDs(), + Regions: user.Regions, + Role: user.Role, + UpdatedAt: user.UpdatedAt, + Username: user.Username, + } + for _, regionID := range item.RegionIDs { + if kindergartenName, exists := kindergartenMap[regionID]; exists { + item.KindergartenNames = append(item.KindergartenNames, kindergartenName) + } + } + items = append(items, item) + } + return items, nil +} + +func validateCreateUserPayload(payload userAdminPayload) error { + if strings.TrimSpace(payload.Username) == "" { + return errors.New("username is required") + } + if strings.TrimSpace(payload.Password) == "" { + return errors.New("password is required") + } + return validateCommonUserPayload(payload) +} + +func validateUpdateUserPayload(payload userAdminPayload) error { + if strings.TrimSpace(payload.Username) == "" { + return errors.New("username is required") + } + return validateCommonUserPayload(payload) +} + +func validateCommonUserPayload(payload userAdminPayload) error { + if payload.Role == "" { + return errors.New("role is required") + } + if payload.FlavorType == "" { + return errors.New("flavorType is required") + } + return nil +} + +func validateRegionIDs(regionIDs []uint32) error { + seen := make(map[uint32]struct{}, len(regionIDs)) + for _, regionID := range regionIDs { + if regionID == 0 { + return errors.New("regionIds contains invalid value") + } + if _, exists := seen[regionID]; exists { + return errors.New("regionIds contains duplicate value") + } + seen[regionID] = struct{}{} + } + return nil +} + +func normalizeOptionalString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func respondUserLookupError(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(c, http.StatusNotFound, "user not found") + return + } + writeError(c, http.StatusInternalServerError, "failed to query user") +} + +func writeUserAdminDBError(c *gin.Context, err error) { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + writeError(c, http.StatusConflict, "username, email or phone already exists") + return + } + writeError(c, http.StatusInternalServerError, "failed to persist user") +} diff --git a/main.go b/main.go index 0dbffcc..31bce31 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,7 @@ func main() { &models.RegressionResult{}, &models.User{}, &models.UserRegionBinding{}, + &models.Kindergarten{}, &models.AppFile{}, &models.AppFileShareCode{}, &models.MqttHeartRateRecord{}, diff --git a/middleware/system_admin.go b/middleware/system_admin.go new file mode 100644 index 0000000..6f545e4 --- /dev/null +++ b/middleware/system_admin.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "hr_receiver/models" + "net/http" + + "github.com/gin-gonic/gin" +) + +func RequireSuperAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + roleValue, exists := c.Get("role") + if !exists { + c.JSON(http.StatusForbidden, gin.H{"error": "missing user role"}) + c.Abort() + return + } + role, ok := roleValue.(models.UserRole) + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "invalid user role"}) + c.Abort() + return + } + if role != models.UserRoleSuperAdmin { + c.JSON(http.StatusForbidden, gin.H{"error": "super admin required"}) + c.Abort() + return + } + c.Next() + } +} diff --git a/models/kindergarten.go b/models/kindergarten.go new file mode 100644 index 0000000..e02ca9d --- /dev/null +++ b/models/kindergarten.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Kindergarten struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:255;not null;index" json:"name"` + Address string `gorm:"size:1024;not null" json:"address"` + RegionID uint32 `gorm:"not null;uniqueIndex" json:"regionId"` + CreatedAt int64 `gorm:"not null" json:"created_at"` + UpdatedAt int64 `gorm:"not null" json:"updated_at"` +} + +func (Kindergarten) TableName() string { + return "kindergartens" +} + +func (k *Kindergarten) BeforeCreate(tx *gorm.DB) (err error) { + now := time.Now().UnixMilli() + k.CreatedAt = now + k.UpdatedAt = now + return nil +} + +func (k *Kindergarten) BeforeUpdate(tx *gorm.DB) (err error) { + k.UpdatedAt = time.Now().UnixMilli() + return nil +} diff --git a/routes/routes.go b/routes/routes.go index 0644fd1..e39e432 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -14,6 +14,8 @@ func SetupRouter() *gin.Engine { trainingController := controllers.NewTrainingController() stepTrainController := controllers.NewStepTrainingController() lessonPlanController := controllers.NewLessonPlanController() + kindergartenAdminController := controllers.NewKindergartenAdminController() + userAdminController := controllers.NewUserAdminController() v1 := r.Group("/api/v1") { @@ -41,6 +43,18 @@ func SetupRouter() *gin.Engine { lessonPlans.POST("/:id/share-code", lessonPlanController.GenerateShareCode) lessonPlans.DELETE("/:id", lessonPlanController.Delete) } + admin := v1.Group("/admin").Use(middleware.JWTAuth(), middleware.RequireSuperAdmin()) + { + admin.GET("/kindergartens", kindergartenAdminController.List) + admin.POST("/kindergartens", kindergartenAdminController.Create) + admin.PUT("/kindergartens/:id", kindergartenAdminController.Update) + admin.DELETE("/kindergartens/:id", kindergartenAdminController.Delete) + + admin.GET("/users", userAdminController.List) + admin.POST("/users", userAdminController.Create) + admin.PUT("/users/:id", userAdminController.Update) + admin.DELETE("/users/:id", userAdminController.Delete) + } v1.GET("/lesson-plans/share/:code/download", lessonPlanController.DownloadByShareCode) public := v1.Group("") {