diff --git a/README.md b/README.md
index 2cf139c..db7e0bb 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ npm run dev
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
- 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
-- 后台已使用独立登录和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
+- 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。
- 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。
diff --git a/backend/internal/modules/adminauth/dto.go b/backend/internal/modules/adminauth/dto.go
index a03c04f..4eb3a9d 100644
--- a/backend/internal/modules/adminauth/dto.go
+++ b/backend/internal/modules/adminauth/dto.go
@@ -15,11 +15,19 @@ type AdminDTO struct {
}
type LoginRequest struct {
- Username string `json:"username" binding:"required"`
- Password string `json:"password" binding:"required"`
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+ CaptchaID string `json:"captcha_id" binding:"required"`
+ CaptchaCode string `json:"captcha_code" binding:"required"`
}
type LoginResult struct {
Admin AdminDTO `json:"admin"`
Tokens auth.TokenPair `json:"tokens"`
}
+
+type CaptchaDTO struct {
+ CaptchaID string `json:"captcha_id"`
+ Image string `json:"image"`
+ ExpiresIn int64 `json:"expires_in"`
+}
diff --git a/backend/internal/modules/adminauth/handler.go b/backend/internal/modules/adminauth/handler.go
index bf047ee..498e681 100644
--- a/backend/internal/modules/adminauth/handler.go
+++ b/backend/internal/modules/adminauth/handler.go
@@ -19,13 +19,22 @@ func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
+func (h *Handler) Captcha(c *gin.Context) {
+ item, err := h.service.Captcha()
+ if err != nil {
+ writeAdminAuthError(c, err)
+ return
+ }
+ response.OK(c, item)
+}
+
func (h *Handler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "用户名和密码不能为空")
+ response.BadRequest(c, "用户名、密码和验证码不能为空")
return
}
- result, err := h.service.Login(strings.TrimSpace(req.Username), req.Password)
+ result, err := h.service.Login(strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode))
if err != nil {
writeAdminAuthError(c, err)
return
@@ -62,6 +71,8 @@ func writeAdminAuthError(c *gin.Context, err error) {
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidCredential):
response.BadRequest(c, "用户名或密码错误")
+ case errors.Is(err, ErrCaptchaInvalid):
+ response.BadRequest(c, "验证码错误或已过期")
case errors.Is(err, ErrAdminDisabled):
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
default:
diff --git a/backend/internal/modules/adminauth/repository.go b/backend/internal/modules/adminauth/repository.go
index d62a0ee..bfff456 100644
--- a/backend/internal/modules/adminauth/repository.go
+++ b/backend/internal/modules/adminauth/repository.go
@@ -1,12 +1,20 @@
package adminauth
import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
"errors"
+ "fmt"
+ "html"
+ "math/big"
+ "strings"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/auth"
+ "github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
@@ -14,18 +22,46 @@ import (
const (
defaultAdminUsername = "admin"
defaultAdminPassword = "admin123456"
+ captchaTTL = 3 * time.Minute
)
type Repository struct {
- db *gorm.DB
- jwt *auth.JWTManager
+ db *gorm.DB
+ redis *redis.Client
+ jwt *auth.JWTManager
}
-func NewRepository(db *gorm.DB, jwt *auth.JWTManager) *Repository {
- return &Repository{db: db, jwt: jwt}
+func NewRepository(db *gorm.DB, redis *redis.Client, jwt *auth.JWTManager) *Repository {
+ return &Repository{db: db, redis: redis, jwt: jwt}
}
-func (r *Repository) Login(username string, password string) (LoginResult, error) {
+func (r *Repository) Captcha() (*CaptchaDTO, error) {
+ if r.redis == nil {
+ return nil, ErrDependencyUnavailable
+ }
+ captchaID, err := randomToken(16)
+ if err != nil {
+ return nil, err
+ }
+ code, err := randomCaptchaCode(4)
+ if err != nil {
+ return nil, err
+ }
+ ctx := context.Background()
+ if err := r.redis.Set(ctx, captchaKey(captchaID), strings.ToUpper(code), captchaTTL).Err(); err != nil {
+ return nil, err
+ }
+ return &CaptchaDTO{
+ CaptchaID: captchaID,
+ Image: captchaImageDataURL(code),
+ ExpiresIn: int64(captchaTTL.Seconds()),
+ }, nil
+}
+
+func (r *Repository) Login(username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
+ if err := r.verifyCaptcha(captchaID, captchaCode); err != nil {
+ return LoginResult{}, err
+ }
if err := r.ensureDefaultAdmin(); err != nil {
return LoginResult{}, err
}
@@ -54,6 +90,26 @@ func (r *Repository) Login(username string, password string) (LoginResult, error
return LoginResult{Admin: toDTO(admin), Tokens: tokens}, nil
}
+func (r *Repository) verifyCaptcha(captchaID string, captchaCode string) error {
+ if r.redis == nil {
+ return ErrDependencyUnavailable
+ }
+ ctx := context.Background()
+ key := captchaKey(captchaID)
+ stored, err := r.redis.Get(ctx, key).Result()
+ if errors.Is(err, redis.Nil) {
+ return ErrCaptchaInvalid
+ }
+ if err != nil {
+ return err
+ }
+ _ = r.redis.Del(ctx, key).Err()
+ if strings.ToUpper(strings.TrimSpace(captchaCode)) != stored {
+ return ErrCaptchaInvalid
+ }
+ return nil
+}
+
func (r *Repository) FindByID(id uint64) (*AdminDTO, error) {
var admin model.AdminUser
if err := r.db.First(&admin, id).Error; err != nil {
@@ -96,3 +152,39 @@ func toDTO(admin model.AdminUser) AdminDTO {
LastLoginAt: admin.LastLoginAt,
}
}
+
+func captchaKey(id string) string {
+ return "admin:captcha:" + id
+}
+
+func randomToken(length int) (string, error) {
+ buf := make([]byte, length)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+func randomCaptchaCode(length int) (string, error) {
+ const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
+ result := make([]byte, length)
+ for i := range result {
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
+ if err != nil {
+ return "", err
+ }
+ result[i] = alphabet[n.Int64()]
+ }
+ return string(result), nil
+}
+
+func captchaImageDataURL(code string) string {
+ safeCode := html.EscapeString(strings.ToUpper(code))
+ svg := fmt.Sprintf(``, safeCode)
+ return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
+}
diff --git a/backend/internal/modules/adminauth/service.go b/backend/internal/modules/adminauth/service.go
index 3018d02..0866b74 100644
--- a/backend/internal/modules/adminauth/service.go
+++ b/backend/internal/modules/adminauth/service.go
@@ -5,6 +5,7 @@ import "errors"
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidCredential = errors.New("invalid credential")
+ ErrCaptchaInvalid = errors.New("captcha invalid")
ErrAdminDisabled = errors.New("admin disabled")
)
@@ -16,14 +17,21 @@ func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
-func (s *Service) Login(username string, password string) (LoginResult, error) {
+func (s *Service) Captcha() (*CaptchaDTO, error) {
+ if s.repo == nil {
+ return nil, ErrDependencyUnavailable
+ }
+ return s.repo.Captcha()
+}
+
+func (s *Service) Login(username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
if s.repo == nil {
return LoginResult{}, ErrDependencyUnavailable
}
- if username == "" || password == "" {
+ if username == "" || password == "" || captchaID == "" || captchaCode == "" {
return LoginResult{}, ErrInvalidCredential
}
- return s.repo.Login(username, password)
+ return s.repo.Login(username, password, captchaID, captchaCode)
}
func (s *Service) Me(adminID uint64) (*AdminDTO, error) {
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 64ad49a..d7ddc57 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -41,7 +41,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
authHandler := auth.NewHandler(authService)
var adminAuthRepo *adminauth.Repository
if deps.DB != nil {
- adminAuthRepo = adminauth.NewRepository(deps.DB, jwtManager)
+ adminAuthRepo = adminauth.NewRepository(deps.DB, deps.Redis, jwtManager)
}
adminAuthService := adminauth.NewService(adminAuthRepo)
adminAuthHandler := adminauth.NewHandler(adminAuthService)
@@ -168,6 +168,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminAuthRoutes := api.Group("/admin/auth")
{
+ adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha)
adminAuthRoutes.POST("/login", adminAuthHandler.Login)
}
diff --git a/docs/api.md b/docs/api.md
index 6f82592..852340e 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -46,6 +46,7 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
- `GET /api/wallet/ledger`
- `GET /api/notifications`
- `POST /api/notifications/{id}/read`
+- `GET /api/admin/auth/captcha`
- `POST /api/admin/auth/login`
- `POST /api/admin/auth/logout`
- `GET /api/admin/me`
diff --git a/docs/business-rules.md b/docs/business-rules.md
index a25a4b9..98d3b33 100644
--- a/docs/business-rules.md
+++ b/docs/business-rules.md
@@ -99,6 +99,8 @@
- 后台登录接口为 `/api/admin/auth/login`,前端页面为 `/admin/login`。
- 后台页面使用独立管理布局,不再混用用户端侧边栏。
+- 后台登录前必须先请求 `/api/admin/auth/captcha` 获取图形验证码。
+- 图形验证码为后端生成的 SVG,验证码答案保存在 Redis,默认 3 分钟过期,校验后立即删除。
- 后台 JWT 与普通用户 JWT 区分 `admin` 和 `user`,普通用户 Token 不能访问 `/api/admin/*`。
- 开发态首次后台登录会自动初始化默认管理员:用户名 `admin`,密码 `admin123456`。
- 默认管理员只用于本地开发;正式部署前必须改为初始化脚本、强密码和管理员密码修改流程。
diff --git a/frontend/src/api/adminAuth.ts b/frontend/src/api/adminAuth.ts
index f5291c2..133b491 100644
--- a/frontend/src/api/adminAuth.ts
+++ b/frontend/src/api/adminAuth.ts
@@ -20,14 +20,30 @@ export interface AdminLoginData {
tokens: AdminTokenPair
}
+export interface AdminCaptcha {
+ captcha_id: string
+ image: string
+ expires_in: number
+}
+
interface ApiResponse {
code: string
message: string
data: T
}
-export async function loginAdmin(username: string, password: string) {
- const { data } = await apiClient.post>('/admin/auth/login', { username, password })
+export async function fetchAdminCaptcha() {
+ const { data } = await apiClient.get>('/admin/auth/captcha')
+ return data.data
+}
+
+export async function loginAdmin(username: string, password: string, captchaId: string, captchaCode: string) {
+ const { data } = await apiClient.post>('/admin/auth/login', {
+ username,
+ password,
+ captcha_id: captchaId,
+ captcha_code: captchaCode,
+ })
return data.data
}
diff --git a/frontend/src/stores/adminSession.ts b/frontend/src/stores/adminSession.ts
index 49aae3b..5b707bb 100644
--- a/frontend/src/stores/adminSession.ts
+++ b/frontend/src/stores/adminSession.ts
@@ -11,8 +11,8 @@ export const useAdminSessionStore = defineStore('adminSession', {
nickname: '',
}),
actions: {
- async login(username: string, password: string) {
- const result = await loginAdmin(username, password)
+ async login(username: string, password: string, captchaId: string, captchaCode: string) {
+ const result = await loginAdmin(username, password, captchaId, captchaCode)
this.applySession(result.admin, result.tokens.access_token, result.tokens.refresh_token)
return result
},
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css
index a888fbc..98153d0 100644
--- a/frontend/src/styles/base.css
+++ b/frontend/src/styles/base.css
@@ -199,6 +199,37 @@ a {
color: #52616f;
}
+.admin-captcha-row {
+ display: grid;
+ grid-template-columns: 1fr 132px;
+ gap: 10px;
+ width: 100%;
+}
+
+.captcha-image-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 44px;
+ border: 1px solid #dcdfe6;
+ border-radius: 6px;
+ background: #ffffff;
+ padding: 0;
+ cursor: pointer;
+ overflow: hidden;
+}
+
+.captcha-image-button:disabled {
+ cursor: wait;
+ opacity: 0.7;
+}
+
+.captcha-image-button img {
+ display: block;
+ width: 132px;
+ height: 44px;
+}
+
.page {
max-width: 1120px;
}
diff --git a/frontend/src/views/admin/AdminLoginView.vue b/frontend/src/views/admin/AdminLoginView.vue
index 8fdf41c..2574a69 100644
--- a/frontend/src/views/admin/AdminLoginView.vue
+++ b/frontend/src/views/admin/AdminLoginView.vue
@@ -1,26 +1,45 @@