diff --git a/cmd/api/main.go b/cmd/api/main.go index 45941bd..63a3c96 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -56,6 +56,7 @@ func main() { // 3) 自动建表/迁移(开发阶段很方便;生产环境可改为手动迁移) if err := database.AutoMigrate( &adminmodule.Admin{}, + &adminmodule.SystemConfig{}, &model.MiniProgram{}, &model.User{}, &model.UserMembership{}, diff --git a/internal/admin/admin.go b/internal/admin/admin.go index e5eeb19..92334fb 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -12,6 +12,7 @@ import ( ) type Admin = adminmodel.Admin +type SystemConfig = adminmodel.SystemConfig type Handler = adminhandler.Handler type Service = adminservice.Service type Claims = adminservice.Claims diff --git a/internal/admin/handler/settings_handler.go b/internal/admin/handler/settings_handler.go new file mode 100644 index 0000000..f282327 --- /dev/null +++ b/internal/admin/handler/settings_handler.go @@ -0,0 +1,170 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + adminservice "wx_service/internal/admin/service" + "wx_service/internal/model" +) + +type updatePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` +} + +type updateProfileRequest struct { + DisplayName string `json:"display_name" binding:"required"` + Email string `json:"email"` + Phone string `json:"phone"` + Timezone string `json:"timezone"` +} + +type updateSystemConfigRequest struct { + SiteName string `json:"site_name" binding:"required"` + AllowRegister bool `json:"allow_register"` + LoginFailLimit int `json:"login_fail_limit"` + DefaultPageSize int `json:"default_page_size"` + AuditLogRetentionDays int `json:"audit_log_retention_days"` +} + +func (h *Handler) GetSettings(c *gin.Context) { + claims, ok := CurrentAdminClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "unauthorized")) + return + } + + data, err := h.svc.GetAdminSettings(c.Request.Context(), claims.AdminID) + if err != nil { + switch { + case errors.Is(err, adminservice.ErrAdminUserNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "admin not found")) + case errors.Is(err, adminservice.ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid admin id")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load admin settings failed")) + } + return + } + + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdateProfile(c *gin.Context) { + claims, ok := CurrentAdminClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "unauthorized")) + return + } + + var req updateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + + data, err := h.svc.UpdateAdminProfile(c.Request.Context(), claims.AdminID, adminservice.UpdateAdminProfileInput{ + DisplayName: req.DisplayName, + Email: req.Email, + Phone: req.Phone, + Timezone: req.Timezone, + }) + if err != nil { + switch { + case errors.Is(err, adminservice.ErrAdminUserNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "admin not found")) + case errors.Is(err, adminservice.ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "display_name required and email format must be valid")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update admin profile failed")) + } + return + } + + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetSystemConfig(c *gin.Context) { + if _, ok := CurrentAdminClaims(c); !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "unauthorized")) + return + } + + data, err := h.svc.GetSystemConfig(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load system config failed")) + return + } + + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdateSystemConfig(c *gin.Context) { + if _, ok := CurrentAdminClaims(c); !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "unauthorized")) + return + } + + var req updateSystemConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + + data, err := h.svc.UpdateSystemConfig(c.Request.Context(), adminservice.SystemConfigPayload{ + SiteName: req.SiteName, + AllowRegister: req.AllowRegister, + LoginFailLimit: req.LoginFailLimit, + DefaultPageSize: req.DefaultPageSize, + AuditLogRetentionDays: req.AuditLogRetentionDays, + }) + if err != nil { + switch { + case errors.Is(err, adminservice.ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid system config payload")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update system config failed")) + } + return + } + + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdatePassword(c *gin.Context) { + claims, ok := CurrentAdminClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "unauthorized")) + return + } + + var req updatePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + + err := h.svc.UpdatePassword(c.Request.Context(), claims.AdminID, req.OldPassword, req.NewPassword) + if err != nil { + switch { + case errors.Is(err, adminservice.ErrAdminUserNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "admin not found")) + case errors.Is(err, adminservice.ErrInvalidCredentials): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "当前密码错误")) + case errors.Is(err, adminservice.ErrPasswordPolicyViolation): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "新密码至少6位且不能与当前密码相同")) + case errors.Is(err, adminservice.ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "old_password/new_password are required")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update password failed")) + } + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{ + "message": "密码更新成功", + })) +} diff --git a/internal/admin/model/admin.go b/internal/admin/model/admin.go index 6c1de50..ce794c4 100644 --- a/internal/admin/model/admin.go +++ b/internal/admin/model/admin.go @@ -15,6 +15,10 @@ type Admin struct { Username string `gorm:"size:50;uniqueIndex;not null;comment:用户名" json:"username"` Password string `gorm:"size:255;not null;comment:密码(bcrypt)" json:"-"` Role string `gorm:"size:20;default:admin;comment:角色" json:"role"` + DisplayName string `gorm:"size:100;not null;default:'';comment:显示名称" json:"display_name"` + Email string `gorm:"size:120;not null;default:'';comment:邮箱" json:"email"` + Phone string `gorm:"size:30;not null;default:'';comment:手机号" json:"phone"` + Timezone string `gorm:"size:60;not null;default:'Asia/Shanghai';comment:时区" json:"timezone"` LastLoginAt *time.Time `gorm:"comment:最后登录时间" json:"last_login_at,omitempty"` } diff --git a/internal/admin/model/system_config.go b/internal/admin/model/system_config.go new file mode 100644 index 0000000..5f5c0a6 --- /dev/null +++ b/internal/admin/model/system_config.go @@ -0,0 +1,22 @@ +package model + +import "time" + +type SystemConfig struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + SiteName string `gorm:"size:120;not null;default:'多小程序管理后台';comment:后台名称" json:"site_name"` + AllowRegister bool `gorm:"not null;default:false;comment:是否允许注册" json:"allow_register"` + LoginFailLimit int `gorm:"not null;default:5;comment:登录失败锁定阈值" json:"login_fail_limit"` + DefaultPageSize int `gorm:"not null;default:20;comment:默认分页大小" json:"default_page_size"` + AuditLogRetentionDays int `gorm:"not null;default:180;comment:操作日志保留天数" json:"audit_log_retention_days"` +} + +func (SystemConfig) TableName() string { + return "admin_system_configs" +} + +func (SystemConfig) TableComment() string { + return "后台系统配置" +} diff --git a/internal/admin/service/auth_service.go b/internal/admin/service/auth_service.go index 78a9d4b..ca1172b 100644 --- a/internal/admin/service/auth_service.go +++ b/internal/admin/service/auth_service.go @@ -38,9 +38,11 @@ func (s *Service) EnsureDefaultAdmin(ctx context.Context) error { } record := &adminmodel.Admin{ - Username: username, - Password: string(hashedPassword), - Role: "super_admin", + Username: username, + Password: string(hashedPassword), + Role: "super_admin", + DisplayName: username, + Timezone: "Asia/Shanghai", } if err := s.db.WithContext(ctx).Create(record).Error; err != nil { if isDuplicateError(err) { diff --git a/internal/admin/service/settings_profile_service.go b/internal/admin/service/settings_profile_service.go new file mode 100644 index 0000000..362b5a3 --- /dev/null +++ b/internal/admin/service/settings_profile_service.go @@ -0,0 +1,182 @@ +package service + +import ( + "context" + "errors" + "strings" + + adminmodel "wx_service/internal/admin/model" + + "gorm.io/gorm" +) + +type AdminSettings struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Phone string `json:"phone"` + Timezone string `json:"timezone"` +} + +type UpdateAdminProfileInput struct { + DisplayName string + Email string + Phone string + Timezone string +} + +type SystemConfigPayload struct { + SiteName string `json:"site_name"` + AllowRegister bool `json:"allow_register"` + LoginFailLimit int `json:"login_fail_limit"` + DefaultPageSize int `json:"default_page_size"` + AuditLogRetentionDays int `json:"audit_log_retention_days"` +} + +func (s *Service) GetAdminSettings(ctx context.Context, adminID uint) (*AdminSettings, error) { + if adminID == 0 { + return nil, ErrInvalidInput + } + + var admin adminmodel.Admin + if err := s.db.WithContext(ctx). + Select("username", "display_name", "email", "phone", "timezone"). + Where("id = ?", adminID). + First(&admin).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAdminUserNotFound + } + return nil, err + } + + return &AdminSettings{ + Username: admin.Username, + DisplayName: admin.DisplayName, + Email: admin.Email, + Phone: admin.Phone, + Timezone: normalizeTimezone(admin.Timezone), + }, nil +} + +func (s *Service) UpdateAdminProfile(ctx context.Context, adminID uint, input UpdateAdminProfileInput) (*AdminSettings, error) { + if adminID == 0 { + return nil, ErrInvalidInput + } + + displayName := strings.TrimSpace(input.DisplayName) + email := strings.TrimSpace(input.Email) + phone := strings.TrimSpace(input.Phone) + timezone := normalizeTimezone(input.Timezone) + + if displayName == "" { + return nil, ErrInvalidInput + } + if email != "" && !strings.Contains(email, "@") { + return nil, ErrInvalidInput + } + + updates := map[string]interface{}{ + "display_name": displayName, + "email": email, + "phone": phone, + "timezone": timezone, + } + + result := s.db.WithContext(ctx). + Model(&adminmodel.Admin{}). + Where("id = ?", adminID). + Updates(updates) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, ErrAdminUserNotFound + } + + return s.GetAdminSettings(ctx, adminID) +} + +func (s *Service) GetSystemConfig(ctx context.Context) (*SystemConfigPayload, error) { + config, err := s.ensureSystemConfig(ctx) + if err != nil { + return nil, err + } + + return &SystemConfigPayload{ + SiteName: config.SiteName, + AllowRegister: config.AllowRegister, + LoginFailLimit: config.LoginFailLimit, + DefaultPageSize: config.DefaultPageSize, + AuditLogRetentionDays: config.AuditLogRetentionDays, + }, nil +} + +func (s *Service) UpdateSystemConfig(ctx context.Context, input SystemConfigPayload) (*SystemConfigPayload, error) { + siteName := strings.TrimSpace(input.SiteName) + if siteName == "" { + return nil, ErrInvalidInput + } + if input.LoginFailLimit < 3 || input.LoginFailLimit > 20 { + return nil, ErrInvalidInput + } + if input.DefaultPageSize < 10 || input.DefaultPageSize > 200 { + return nil, ErrInvalidInput + } + if input.AuditLogRetentionDays < 7 || input.AuditLogRetentionDays > 3650 { + return nil, ErrInvalidInput + } + + config, err := s.ensureSystemConfig(ctx) + if err != nil { + return nil, err + } + + config.SiteName = siteName + config.AllowRegister = input.AllowRegister + config.LoginFailLimit = input.LoginFailLimit + config.DefaultPageSize = input.DefaultPageSize + config.AuditLogRetentionDays = input.AuditLogRetentionDays + + if err := s.db.WithContext(ctx).Save(config).Error; err != nil { + return nil, err + } + + return &SystemConfigPayload{ + SiteName: config.SiteName, + AllowRegister: config.AllowRegister, + LoginFailLimit: config.LoginFailLimit, + DefaultPageSize: config.DefaultPageSize, + AuditLogRetentionDays: config.AuditLogRetentionDays, + }, nil +} + +func (s *Service) ensureSystemConfig(ctx context.Context) (*adminmodel.SystemConfig, error) { + var config adminmodel.SystemConfig + err := s.db.WithContext(ctx).Order("id ASC").First(&config).Error + if err == nil { + return &config, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + config = adminmodel.SystemConfig{ + SiteName: "多小程序管理后台", + AllowRegister: false, + LoginFailLimit: 5, + DefaultPageSize: 20, + AuditLogRetentionDays: 180, + } + if err := s.db.WithContext(ctx).Create(&config).Error; err != nil { + return nil, err + } + return &config, nil +} + +func normalizeTimezone(value string) string { + timezone := strings.TrimSpace(value) + if timezone == "" { + return "Asia/Shanghai" + } + return timezone +} diff --git a/internal/admin/service/settings_service.go b/internal/admin/service/settings_service.go new file mode 100644 index 0000000..6ce5814 --- /dev/null +++ b/internal/admin/service/settings_service.go @@ -0,0 +1,52 @@ +package service + +import ( + "context" + "errors" + "strings" + + adminmodel "wx_service/internal/admin/model" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +func (s *Service) UpdatePassword(ctx context.Context, adminID uint, oldPassword, newPassword string) error { + oldPassword = strings.TrimSpace(oldPassword) + newPassword = strings.TrimSpace(newPassword) + if adminID == 0 || oldPassword == "" || newPassword == "" { + return ErrInvalidInput + } + if len(newPassword) < 6 { + return ErrPasswordPolicyViolation + } + if oldPassword == newPassword { + return ErrPasswordPolicyViolation + } + + var admin adminmodel.Admin + if err := s.db.WithContext(ctx).Where("id = ?", adminID).First(&admin).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrAdminUserNotFound + } + return err + } + + if err := bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(oldPassword)); err != nil { + return ErrInvalidCredentials + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + if err := s.db.WithContext(ctx). + Model(&adminmodel.Admin{}). + Where("id = ?", adminID). + Update("password", string(hashedPassword)).Error; err != nil { + return err + } + + return nil +} diff --git a/internal/admin/service/types.go b/internal/admin/service/types.go index 78a765a..e69026c 100644 --- a/internal/admin/service/types.go +++ b/internal/admin/service/types.go @@ -19,6 +19,7 @@ var ( ErrAdminUserNotFound = errors.New("admin user not found") ErrMembershipRedeemCodeNotFound = errors.New("membership redeem code not found") ErrInvalidInput = errors.New("invalid input") + ErrPasswordPolicyViolation = errors.New("password policy violation") ) type Claims struct { diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index 1605a99..f1366f6 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -42,6 +42,12 @@ func registerAdminRoutes( protected.GET("/users", handler.ListUsers) protected.GET("/users/:id", handler.GetUserDetail) + protected.GET("/settings", handler.GetSettings) + protected.PUT("/settings/profile", handler.UpdateProfile) + protected.GET("/settings/system", handler.GetSystemConfig) + protected.PUT("/settings/system", handler.UpdateSystemConfig) + protected.PUT("/settings/password", handler.UpdatePassword) + protected.GET("/memberships/overview", handler.MembershipOverview) protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes) protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes)