feat: 完成后台Issue#4 管理员认证接口模块
This commit is contained in:
@@ -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
@@ -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
@@ -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", ""),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 "管理员表"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user