feat(admin): complete settings APIs and password update flow
This commit is contained in:
@@ -56,6 +56,7 @@ func main() {
|
||||
// 3) 自动建表/迁移(开发阶段很方便;生产环境可改为手动迁移)
|
||||
if err := database.AutoMigrate(
|
||||
&adminmodule.Admin{},
|
||||
&adminmodule.SystemConfig{},
|
||||
&model.MiniProgram{},
|
||||
&model.User{},
|
||||
&model.UserMembership{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "密码更新成功",
|
||||
}))
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "后台系统配置"
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user