feat: 完成后台Issue#4 管理员认证接口模块

This commit is contained in:
root
2026-03-09 19:25:44 +08:00
parent 172a543a5e
commit 54cf7ea37f
13 changed files with 466 additions and 3 deletions
+77
View File
@@ -0,0 +1,77 @@
package admin
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"wx_service/internal/model"
)
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload"))
return
}
result, err := h.svc.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
switch {
case errors.Is(err, ErrInvalidCredentials):
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "用户名或密码错误"))
case errors.Is(err, ErrMissingJWTSecret):
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "管理员认证未配置,请检查 JWT_SECRET"))
case errors.Is(err, ErrInvalidInput):
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "用户名和密码不能为空"))
default:
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "admin login failed"))
}
return
}
c.JSON(http.StatusOK, model.Success(gin.H{
"token": result.Token,
"admin": gin.H{
"id": result.Admin.ID,
"username": result.Admin.Username,
"role": result.Admin.Role,
"last_login_at": result.Admin.LastLoginAt,
},
}))
}
func (h *Handler) Profile(c *gin.Context) {
claims, ok := CurrentAdminClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "unauthorized"))
return
}
admin, err := h.svc.GetProfile(c.Request.Context(), claims.AdminID)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load profile failed"))
return
}
c.JSON(http.StatusOK, model.Success(gin.H{
"id": admin.ID,
"username": admin.Username,
"role": admin.Role,
"last_login_at": admin.LastLoginAt,
"created_at": admin.CreatedAt,
"updated_at": admin.UpdatedAt,
}))
}
func (h *Handler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, model.Success(gin.H{
"message": "ok",
}))
}
+144
View File
@@ -0,0 +1,144 @@
package admin
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func (s *Service) EnsureDefaultAdmin(ctx context.Context) error {
var count int64
if err := s.db.WithContext(ctx).Model(&Admin{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
username := s.defaultUsername
if username == "" {
username = "admin"
}
password := strings.TrimSpace(s.defaultPassword)
if password == "" {
password = "admin123"
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
record := &Admin{
Username: username,
Password: string(hashedPassword),
Role: "super_admin",
}
if err := s.db.WithContext(ctx).Create(record).Error; err != nil {
if isDuplicateError(err) {
return nil
}
return err
}
return nil
}
type LoginResult struct {
Token string `json:"token"`
Admin *Admin `json:"admin"`
}
func (s *Service) Login(ctx context.Context, username, password string) (*LoginResult, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return nil, ErrInvalidInput
}
var admin Admin
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
token, err := s.generateToken(&admin)
if err != nil {
return nil, err
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&admin).Update("last_login_at", now).Error; err != nil {
return nil, err
}
admin.LastLoginAt = &now
admin.Password = ""
return &LoginResult{
Token: token,
Admin: &admin,
}, nil
}
func (s *Service) ParseToken(token string) (*Claims, error) {
if len(s.jwtSecret) == 0 {
return nil, ErrMissingJWTSecret
}
claims := &Claims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return s.jwtSecret, nil
})
if err != nil || parsed == nil || !parsed.Valid {
return nil, ErrInvalidToken
}
if claims.AdminID == 0 {
return nil, ErrInvalidToken
}
return claims, nil
}
func (s *Service) generateToken(admin *Admin) (string, error) {
if len(s.jwtSecret) == 0 {
return "", ErrMissingJWTSecret
}
now := time.Now()
claims := &Claims{
AdminID: admin.ID,
Username: admin.Username,
Role: admin.Role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", admin.ID),
ExpiresAt: jwt.NewNumericDate(now.Add(s.tokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString(s.jwtSecret)
}
func (s *Service) GetProfile(ctx context.Context, adminID uint) (*Admin, error) {
var admin Admin
if err := s.db.WithContext(ctx).
Select("id", "username", "role", "last_login_at", "created_at", "updated_at").
Where("id = ?", adminID).
First(&admin).Error; err != nil {
return nil, err
}
return &admin, nil
}
+29
View File
@@ -0,0 +1,29 @@
package admin
import (
"errors"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) AuthMiddleware() gin.HandlerFunc {
return AuthMiddleware(h.svc)
}
func parseUintID(raw string) (uint, error) {
id, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64)
if err != nil || id == 0 {
return 0, errors.New("invalid id")
}
return uint(id), nil
}
+54
View File
@@ -0,0 +1,54 @@
package admin
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"wx_service/internal/model"
)
const ContextAdminClaimsKey = "adminClaims"
func AuthMiddleware(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
token := extractBearerToken(c.GetHeader("Authorization"))
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "missing authorization header"))
return
}
claims, err := svc.ParseToken(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "invalid admin token"))
return
}
c.Set(ContextAdminClaimsKey, claims)
c.Next()
}
}
func CurrentAdminClaims(c *gin.Context) (*Claims, bool) {
value, ok := c.Get(ContextAdminClaimsKey)
if !ok {
return nil, false
}
claims, ok := value.(*Claims)
return claims, ok
}
func extractBearerToken(authHeader string) string {
if authHeader == "" {
return ""
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
return ""
}
if !strings.EqualFold(parts[0], "Bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}
+27
View File
@@ -0,0 +1,27 @@
package admin
import (
"time"
"gorm.io/gorm"
)
type Admin struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
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"`
LastLoginAt *time.Time `gorm:"comment:最后登录时间" json:"last_login_at,omitempty"`
}
func (Admin) TableName() string {
return "admins"
}
func (Admin) TableComment() string {
return "管理员表"
}
+61
View File
@@ -0,0 +1,61 @@
package admin
import (
"errors"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidToken = errors.New("invalid token")
ErrMissingJWTSecret = errors.New("missing jwt secret")
ErrMiniProgramNotFound = errors.New("mini program not found")
ErrMiniProgramHasUsers = errors.New("mini program has users")
ErrMiniProgramAppIDUsed = errors.New("mini program app_id already exists")
ErrInvalidInput = errors.New("invalid input")
)
type Claims struct {
AdminID uint `json:"admin_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type Service struct {
db *gorm.DB
jwtSecret []byte
tokenTTL time.Duration
defaultUsername string
defaultPassword string
}
func NewService(db *gorm.DB, jwtSecret string, tokenTTL time.Duration, defaultUsername, defaultPassword string) *Service {
if tokenTTL <= 0 {
tokenTTL = 24 * time.Hour
}
return &Service{
db: db,
jwtSecret: []byte(strings.TrimSpace(jwtSecret)),
tokenTTL: tokenTTL,
defaultUsername: strings.TrimSpace(defaultUsername),
defaultPassword: defaultPassword,
}
}
type groupedCountRow struct {
MiniProgramID uint `gorm:"column:mini_program_id"`
Count int64 `gorm:"column:count"`
}
func isDuplicateError(err error) bool {
if err == nil {
return false
}
errText := strings.ToLower(err.Error())
return strings.Contains(errText, "duplicate") || strings.Contains(errText, "unique")
}
+25
View File
@@ -0,0 +1,25 @@
package routes
import (
"github.com/gin-gonic/gin"
adminhandler "wx_service/internal/admin"
)
func registerAdminRoutes(router *gin.Engine, handler *adminhandler.Handler) {
if handler == nil {
return
}
admin := router.Group("/api/admin")
{
admin.POST("/login", handler.Login)
protected := admin.Group("")
protected.Use(handler.AuthMiddleware())
{
protected.GET("/profile", handler.Profile)
protected.POST("/logout", handler.Logout)
}
}
}
+4
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
adminhandler "wx_service/internal/admin"
authhandler "wx_service/internal/common/auth/handler"
qiniuhandler "wx_service/internal/common/qiniu/handler"
rediscache "wx_service/internal/common/redis/cache"
@@ -31,6 +32,7 @@ func Register(
sessionCache *rediscache.SessionUserCache,
lawyerHandler *lawyerhandler.LawyerHandler,
expiryHandler *expiryhandler.Handler,
adminHandler *adminhandler.Handler,
adminToken string,
marketingCategoryHandler *marketinghandler.CategoryHandler,
marketingTemplateHandler *marketinghandler.TemplateHandler,
@@ -66,6 +68,8 @@ func Register(
registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
}
registerAdminRoutes(router, adminHandler)
// 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。
expiryAPI := router.Group("/api/expiry")
{