feat(admin): complete settings APIs and password update flow

This commit is contained in:
root
2026-03-10 16:40:51 +08:00
parent 59508efb05
commit 6f1f75d983
10 changed files with 444 additions and 3 deletions
+1
View File
@@ -56,6 +56,7 @@ func main() {
// 3) 自动建表/迁移(开发阶段很方便;生产环境可改为手动迁移)
if err := database.AutoMigrate(
&adminmodule.Admin{},
&adminmodule.SystemConfig{},
&model.MiniProgram{},
&model.User{},
&model.UserMembership{},
+1
View File
@@ -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
+170
View File
@@ -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": "密码更新成功",
}))
}
+4
View File
@@ -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"`
}
+22
View File
@@ -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 "后台系统配置"
}
+5 -3
View File
@@ -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
}
+1
View File
@@ -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 {
+6
View File
@@ -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)