diff --git a/.env.example b/.env.example index 59395f2..3a68571 100755 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/cmd/api/main.go b/cmd/api/main.go index 4883e7a..45941bd 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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 diff --git a/config/config.go b/config/config.go index 8679776..d6b5d3b 100755 --- a/config/config.go +++ b/config/config.go @@ -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", ""), diff --git a/go.mod b/go.mod index 9586edc..e8addcb 100755 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9f8d4b9..0281eb3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/admin/auth_handler.go b/internal/admin/auth_handler.go new file mode 100644 index 0000000..d4ddd5f --- /dev/null +++ b/internal/admin/auth_handler.go @@ -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", + })) +} diff --git a/internal/admin/auth_service.go b/internal/admin/auth_service.go new file mode 100644 index 0000000..10d9a6f --- /dev/null +++ b/internal/admin/auth_service.go @@ -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 +} diff --git a/internal/admin/handler_base.go b/internal/admin/handler_base.go new file mode 100644 index 0000000..0ad54cf --- /dev/null +++ b/internal/admin/handler_base.go @@ -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 +} diff --git a/internal/admin/middleware.go b/internal/admin/middleware.go new file mode 100644 index 0000000..71dae02 --- /dev/null +++ b/internal/admin/middleware.go @@ -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]) +} diff --git a/internal/admin/model.go b/internal/admin/model.go new file mode 100644 index 0000000..71a2c09 --- /dev/null +++ b/internal/admin/model.go @@ -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 "管理员表" +} diff --git a/internal/admin/types.go b/internal/admin/types.go new file mode 100644 index 0000000..bf96591 --- /dev/null +++ b/internal/admin/types.go @@ -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") +} diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go new file mode 100644 index 0000000..96aa148 --- /dev/null +++ b/internal/routes/admin_routes.go @@ -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) + } + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index fb4da7d..817cabc 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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") {