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
+3
View File
@@ -42,6 +42,9 @@ AI_TIMEOUT_SECONDS=15
# 简易后台接口鉴权(用于生成兑换码等)
ADMIN_API_TOKEN=replace-with-strong-random-token
# 管理后台默认管理员(首次启动且 admins 表为空时自动创建)
ADMIN_DEFAULT_USERNAME=admin
ADMIN_DEFAULT_PASSWORD=admin123
# 七牛直传配置(Kodo
QINIU_ACCESS_KEY=replace-with-access-key
+33 -1
View File
@@ -1,12 +1,14 @@
package main
import (
"context"
"log"
"time"
"github.com/gin-gonic/gin"
"wx_service/config"
adminmodule "wx_service/internal/admin"
authhandler "wx_service/internal/common/auth/handler"
authservice "wx_service/internal/common/auth/service"
qiniuhandler "wx_service/internal/common/qiniu/handler"
@@ -53,6 +55,7 @@ func main() {
}
// 3) 自动建表/迁移(开发阶段很方便;生产环境可改为手动迁移)
if err := database.AutoMigrate(
&adminmodule.Admin{},
&model.MiniProgram{},
&model.User{},
&model.UserMembership{},
@@ -144,8 +147,37 @@ func main() {
marketingTemplateHandler := marketinghandler.NewTemplateHandler(templateSvc)
marketingDownloadHandler := marketinghandler.NewDownloadHandler(downloadSvc)
adminService := adminmodule.NewService(
database.DB,
config.AppConfig.JWT.Secret,
time.Duration(config.AppConfig.JWT.Expire)*time.Second,
config.AppConfig.Admin.DefaultUsername,
config.AppConfig.Admin.DefaultPassword,
)
if err := adminService.EnsureDefaultAdmin(context.Background()); err != nil {
log.Fatalf("ensure default admin failed: %v", err)
}
adminHandler := adminmodule.NewHandler(adminService)
// 6) 注册路由:把 URL 映射到 handler
routes.Register(router, database.DB, authHandler, videoHandler, smokeHandler, redeemCodeHandler, uploadHandler, oaOAuthHandler, sessionCache, lawyerHandler, expiryHandler, config.AppConfig.Admin.Token, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
routes.Register(
router,
database.DB,
authHandler,
videoHandler,
smokeHandler,
redeemCodeHandler,
uploadHandler,
oaOAuthHandler,
sessionCache,
lawyerHandler,
expiryHandler,
adminHandler,
config.AppConfig.Admin.Token,
marketingCategoryHandler,
marketingTemplateHandler,
marketingDownloadHandler,
)
// 7) 启动监听端口
addr := ":" + config.AppConfig.Server.Port
+6 -2
View File
@@ -66,7 +66,9 @@ type AIConfig struct {
// AdminConfig 用于简单的后台/运维接口鉴权(如生成兑换码)。
type AdminConfig struct {
Token string
Token string
DefaultUsername string
DefaultPassword string
}
// QiniuConfig 用于七牛云(Kodo)直传相关配置。
@@ -145,7 +147,9 @@ func LoadConfig() {
RequestTimeout: time.Duration(getEnvAsInt("AI_TIMEOUT_SECONDS", 15)) * time.Second,
},
Admin: AdminConfig{
Token: getEnv("ADMIN_API_TOKEN", ""),
Token: getEnv("ADMIN_API_TOKEN", ""),
DefaultUsername: getEnv("ADMIN_DEFAULT_USERNAME", "admin"),
DefaultPassword: getEnv("ADMIN_DEFAULT_PASSWORD", "admin123"),
},
Qiniu: QiniuConfig{
AccessKey: getEnv("QINIU_ACCESS_KEY", ""),
+1
View File
@@ -26,6 +26,7 @@ require (
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+2
View File
@@ -39,6 +39,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+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")
{