feat: 完成后台Issue#4 管理员认证接口模块
This commit is contained in:
@@ -42,6 +42,9 @@ AI_TIMEOUT_SECONDS=15
|
|||||||
|
|
||||||
# 简易后台接口鉴权(用于生成兑换码等)
|
# 简易后台接口鉴权(用于生成兑换码等)
|
||||||
ADMIN_API_TOKEN=replace-with-strong-random-token
|
ADMIN_API_TOKEN=replace-with-strong-random-token
|
||||||
|
# 管理后台默认管理员(首次启动且 admins 表为空时自动创建)
|
||||||
|
ADMIN_DEFAULT_USERNAME=admin
|
||||||
|
ADMIN_DEFAULT_PASSWORD=admin123
|
||||||
|
|
||||||
# 七牛直传配置(Kodo)
|
# 七牛直传配置(Kodo)
|
||||||
QINIU_ACCESS_KEY=replace-with-access-key
|
QINIU_ACCESS_KEY=replace-with-access-key
|
||||||
|
|||||||
+33
-1
@@ -1,12 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"wx_service/config"
|
"wx_service/config"
|
||||||
|
adminmodule "wx_service/internal/admin"
|
||||||
authhandler "wx_service/internal/common/auth/handler"
|
authhandler "wx_service/internal/common/auth/handler"
|
||||||
authservice "wx_service/internal/common/auth/service"
|
authservice "wx_service/internal/common/auth/service"
|
||||||
qiniuhandler "wx_service/internal/common/qiniu/handler"
|
qiniuhandler "wx_service/internal/common/qiniu/handler"
|
||||||
@@ -53,6 +55,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
// 3) 自动建表/迁移(开发阶段很方便;生产环境可改为手动迁移)
|
// 3) 自动建表/迁移(开发阶段很方便;生产环境可改为手动迁移)
|
||||||
if err := database.AutoMigrate(
|
if err := database.AutoMigrate(
|
||||||
|
&adminmodule.Admin{},
|
||||||
&model.MiniProgram{},
|
&model.MiniProgram{},
|
||||||
&model.User{},
|
&model.User{},
|
||||||
&model.UserMembership{},
|
&model.UserMembership{},
|
||||||
@@ -144,8 +147,37 @@ func main() {
|
|||||||
marketingTemplateHandler := marketinghandler.NewTemplateHandler(templateSvc)
|
marketingTemplateHandler := marketinghandler.NewTemplateHandler(templateSvc)
|
||||||
marketingDownloadHandler := marketinghandler.NewDownloadHandler(downloadSvc)
|
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
|
// 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) 启动监听端口
|
// 7) 启动监听端口
|
||||||
addr := ":" + config.AppConfig.Server.Port
|
addr := ":" + config.AppConfig.Server.Port
|
||||||
|
|||||||
+6
-2
@@ -66,7 +66,9 @@ type AIConfig struct {
|
|||||||
|
|
||||||
// AdminConfig 用于简单的后台/运维接口鉴权(如生成兑换码)。
|
// AdminConfig 用于简单的后台/运维接口鉴权(如生成兑换码)。
|
||||||
type AdminConfig struct {
|
type AdminConfig struct {
|
||||||
Token string
|
Token string
|
||||||
|
DefaultUsername string
|
||||||
|
DefaultPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
// QiniuConfig 用于七牛云(Kodo)直传相关配置。
|
// QiniuConfig 用于七牛云(Kodo)直传相关配置。
|
||||||
@@ -145,7 +147,9 @@ func LoadConfig() {
|
|||||||
RequestTimeout: time.Duration(getEnvAsInt("AI_TIMEOUT_SECONDS", 15)) * time.Second,
|
RequestTimeout: time.Duration(getEnvAsInt("AI_TIMEOUT_SECONDS", 15)) * time.Second,
|
||||||
},
|
},
|
||||||
Admin: AdminConfig{
|
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{
|
Qiniu: QiniuConfig{
|
||||||
AccessKey: getEnv("QINIU_ACCESS_KEY", ""),
|
AccessKey: getEnv("QINIU_ACCESS_KEY", ""),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // 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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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-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 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
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"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
adminhandler "wx_service/internal/admin"
|
||||||
authhandler "wx_service/internal/common/auth/handler"
|
authhandler "wx_service/internal/common/auth/handler"
|
||||||
qiniuhandler "wx_service/internal/common/qiniu/handler"
|
qiniuhandler "wx_service/internal/common/qiniu/handler"
|
||||||
rediscache "wx_service/internal/common/redis/cache"
|
rediscache "wx_service/internal/common/redis/cache"
|
||||||
@@ -31,6 +32,7 @@ func Register(
|
|||||||
sessionCache *rediscache.SessionUserCache,
|
sessionCache *rediscache.SessionUserCache,
|
||||||
lawyerHandler *lawyerhandler.LawyerHandler,
|
lawyerHandler *lawyerhandler.LawyerHandler,
|
||||||
expiryHandler *expiryhandler.Handler,
|
expiryHandler *expiryhandler.Handler,
|
||||||
|
adminHandler *adminhandler.Handler,
|
||||||
adminToken string,
|
adminToken string,
|
||||||
marketingCategoryHandler *marketinghandler.CategoryHandler,
|
marketingCategoryHandler *marketinghandler.CategoryHandler,
|
||||||
marketingTemplateHandler *marketinghandler.TemplateHandler,
|
marketingTemplateHandler *marketinghandler.TemplateHandler,
|
||||||
@@ -66,6 +68,8 @@ func Register(
|
|||||||
registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
|
registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerAdminRoutes(router, adminHandler)
|
||||||
|
|
||||||
// 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。
|
// 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。
|
||||||
expiryAPI := router.Group("/api/expiry")
|
expiryAPI := router.Group("/api/expiry")
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user