第 5 阶段:纠纷、通知与后台-2
This commit is contained in:
@@ -37,6 +37,7 @@ npm run dev
|
||||
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
|
||||
- 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。
|
||||
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
||||
- 后台已使用独立登录,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
+1
-1
@@ -7,6 +7,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/redis/go-redis/v9 v9.17.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
gorm.io/datatypes v1.2.7
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -44,7 +45,6 @@ require (
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ContextUserID = "user_id"
|
||||
ContextPhone = "phone"
|
||||
ContextUserID = "user_id"
|
||||
ContextPhone = "phone"
|
||||
ContextAdminID = "admin_id"
|
||||
ContextUsername = "username"
|
||||
)
|
||||
|
||||
func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
||||
@@ -24,7 +26,7 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtManager.Parse(tokenText, "access")
|
||||
claims, err := jwtManager.ParseSubject(tokenText, "access", "user")
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "访问令牌无效或已过期")
|
||||
c.Abort()
|
||||
@@ -36,3 +38,26 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
if tokenText == "" || tokenText == header {
|
||||
response.Unauthorized(c, "缺少后台访问令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "后台访问令牌无效或已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ContextAdminID, claims.UserID)
|
||||
c.Set(ContextUsername, claims.Phone)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type AdminUser struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (AdminUser) TableName() string {
|
||||
return "admin_users"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
)
|
||||
|
||||
type AdminDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Status string `json:"status"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
Admin AdminDTO `json:"admin"`
|
||||
Tokens auth.TokenPair `json:"tokens"`
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "用户名和密码不能为空")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Login(strings.TrimSpace(req.Username), req.Password)
|
||||
if err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
value, ok := c.Get(middleware.ContextAdminID)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
adminID, ok := value.(uint64)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "管理员上下文无效")
|
||||
return
|
||||
}
|
||||
admin, err := h.service.Me(adminID)
|
||||
if err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, admin)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
response.OK(c, gin.H{"logged_out": true})
|
||||
}
|
||||
|
||||
func writeAdminAuthError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
case errors.Is(err, ErrInvalidCredential):
|
||||
response.BadRequest(c, "用户名或密码错误")
|
||||
case errors.Is(err, ErrAdminDisabled):
|
||||
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "internal_error", "后台认证服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAdminUsername = "admin"
|
||||
defaultAdminPassword = "admin123456"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
jwt *auth.JWTManager
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB, jwt *auth.JWTManager) *Repository {
|
||||
return &Repository{db: db, jwt: jwt}
|
||||
}
|
||||
|
||||
func (r *Repository) Login(username string, password string) (LoginResult, error) {
|
||||
if err := r.ensureDefaultAdmin(); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
var admin model.AdminUser
|
||||
if err := r.db.Where("username = ?", username).First(&admin).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return LoginResult{}, ErrInvalidCredential
|
||||
}
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if admin.Status != "active" {
|
||||
return LoginResult{}, ErrAdminDisabled
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
||||
return LoginResult{}, ErrInvalidCredential
|
||||
}
|
||||
now := time.Now()
|
||||
admin.LastLoginAt = &now
|
||||
if err := r.db.Save(&admin).Error; err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
tokens, err := r.jwt.GenerateSubjectPair(admin.ID, admin.Username, "admin")
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{Admin: toDTO(admin), Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindByID(id uint64) (*AdminDTO, error) {
|
||||
var admin model.AdminUser
|
||||
if err := r.db.First(&admin, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if admin.Status != "active" {
|
||||
return nil, ErrAdminDisabled
|
||||
}
|
||||
dto := toDTO(admin)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ensureDefaultAdmin() error {
|
||||
var count int64
|
||||
if err := r.db.Model(&model.AdminUser{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(defaultAdminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
admin := model.AdminUser{
|
||||
Username: defaultAdminUsername,
|
||||
PasswordHash: string(hash),
|
||||
Nickname: "超级管理员",
|
||||
Status: "active",
|
||||
}
|
||||
return r.db.Create(&admin).Error
|
||||
}
|
||||
|
||||
func toDTO(admin model.AdminUser) AdminDTO {
|
||||
return AdminDTO{
|
||||
ID: admin.ID,
|
||||
Username: admin.Username,
|
||||
Nickname: admin.Nickname,
|
||||
Status: admin.Status,
|
||||
LastLoginAt: admin.LastLoginAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package adminauth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidCredential = errors.New("invalid credential")
|
||||
ErrAdminDisabled = errors.New("admin disabled")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Login(username string, password string) (LoginResult, error) {
|
||||
if s.repo == nil {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return LoginResult{}, ErrInvalidCredential
|
||||
}
|
||||
return s.repo.Login(username, password)
|
||||
}
|
||||
|
||||
func (s *Service) Me(adminID uint64) (*AdminDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.FindByID(adminID)
|
||||
}
|
||||
@@ -22,9 +22,10 @@ type JWTManager struct {
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uint64 `json:"uid"`
|
||||
Phone string `json:"phone"`
|
||||
TokenType string `json:"typ"`
|
||||
UserID uint64 `json:"uid"`
|
||||
Phone string `json:"phone"`
|
||||
TokenType string `json:"typ"`
|
||||
SubjectType string `json:"sub_type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -45,11 +46,15 @@ func NewJWTManager(secret string) *JWTManager {
|
||||
}
|
||||
|
||||
func (m *JWTManager) GeneratePair(userID uint64, phone string) (TokenPair, error) {
|
||||
accessToken, err := m.generate(userID, phone, tokenTypeAccess, m.accessTTL)
|
||||
return m.GenerateSubjectPair(userID, phone, "user")
|
||||
}
|
||||
|
||||
func (m *JWTManager) GenerateSubjectPair(userID uint64, subject string, subjectType string) (TokenPair, error) {
|
||||
accessToken, err := m.generate(userID, subject, subjectType, tokenTypeAccess, m.accessTTL)
|
||||
if err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
refreshToken, err := m.generate(userID, phone, tokenTypeRefresh, m.refreshTTL)
|
||||
refreshToken, err := m.generate(userID, subject, subjectType, tokenTypeRefresh, m.refreshTTL)
|
||||
if err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
@@ -62,6 +67,10 @@ func (m *JWTManager) GeneratePair(userID uint64, phone string) (TokenPair, error
|
||||
}
|
||||
|
||||
func (m *JWTManager) Parse(tokenText, expectedType string) (*Claims, error) {
|
||||
return m.ParseSubject(tokenText, expectedType, "")
|
||||
}
|
||||
|
||||
func (m *JWTManager) ParseSubject(tokenText, expectedType string, expectedSubjectType string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) {
|
||||
if token.Method != m.signingMethod {
|
||||
@@ -75,17 +84,21 @@ func (m *JWTManager) Parse(tokenText, expectedType string) (*Claims, error) {
|
||||
if claims.TokenType != expectedType {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if expectedSubjectType != "" && claims.SubjectType != expectedSubjectType {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (m *JWTManager) generate(userID uint64, phone string, tokenType string, ttl time.Duration) (string, error) {
|
||||
func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Phone: phone,
|
||||
TokenType: tokenType,
|
||||
UserID: userID,
|
||||
Phone: subject,
|
||||
TokenType: tokenType,
|
||||
SubjectType: subjectType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: phone,
|
||||
Subject: subject,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
|
||||
@@ -109,7 +109,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
|
||||
}
|
||||
|
||||
func (s *Service) RefreshToken(refreshToken string) (TokenPair, error) {
|
||||
claims, err := s.jwt.Parse(refreshToken, tokenTypeRefresh)
|
||||
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
|
||||
if err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
|
||||
@@ -84,9 +84,9 @@ func (h *Handler) AdminList(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
||||
adminID, ok := currentUserID(c)
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c)
|
||||
@@ -106,6 +106,15 @@ func (h *Handler) AdminArbitrate(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextAdminID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
adminID, ok := value.(uint64)
|
||||
return adminID, ok
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
|
||||
@@ -28,9 +28,9 @@ func (h *Handler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
key := c.Param("key")
|
||||
@@ -39,7 +39,7 @@ func (h *Handler) Update(c *gin.Context) {
|
||||
response.BadRequest(c, "配置值不能为空")
|
||||
return
|
||||
}
|
||||
item, err := h.service.Update(userID, key, req, AuditMeta{
|
||||
item, err := h.service.Update(adminID, key, req, AuditMeta{
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
})
|
||||
@@ -50,13 +50,13 @@ func (h *Handler) Update(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextAdminID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := value.(uint64)
|
||||
return userID, ok
|
||||
adminID, ok := value.(uint64)
|
||||
return adminID, ok
|
||||
}
|
||||
|
||||
func writeConfigError(c *gin.Context, err error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"hfb_sys/backend/internal/config"
|
||||
"hfb_sys/backend/internal/handler"
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/modules/adminauth"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
"hfb_sys/backend/internal/modules/dispute"
|
||||
"hfb_sys/backend/internal/modules/listing"
|
||||
@@ -37,6 +38,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
}
|
||||
authService := auth.NewService(userRepo, deps.Redis, jwtManager, logger)
|
||||
authHandler := auth.NewHandler(authService)
|
||||
var adminAuthRepo *adminauth.Repository
|
||||
if deps.DB != nil {
|
||||
adminAuthRepo = adminauth.NewRepository(deps.DB, jwtManager)
|
||||
}
|
||||
adminAuthService := adminauth.NewService(adminAuthRepo)
|
||||
adminAuthHandler := adminauth.NewHandler(adminAuthService)
|
||||
userHandler := user.NewHandler(userRepo)
|
||||
var realnameRepo *realname.Repository
|
||||
if deps.DB != nil {
|
||||
@@ -81,6 +88,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||
requireAuth := middleware.Auth(jwtManager)
|
||||
requireAdmin := middleware.AdminAuth(jwtManager)
|
||||
requireRealname := middleware.RequireRealname(userRepo)
|
||||
|
||||
api := engine.Group("/api")
|
||||
@@ -151,8 +159,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
realnameRoutes.GET("/status", realnameHandler.Status)
|
||||
}
|
||||
|
||||
adminRoutes := api.Group("/admin", requireAuth)
|
||||
adminAuthRoutes := api.Group("/admin/auth")
|
||||
{
|
||||
adminAuthRoutes.POST("/login", adminAuthHandler.Login)
|
||||
}
|
||||
|
||||
adminRoutes := api.Group("/admin", requireAdmin)
|
||||
{
|
||||
adminRoutes.GET("/me", adminAuthHandler.Me)
|
||||
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
|
||||
adminRoutes.GET("/disputes", disputeHandler.AdminList)
|
||||
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
||||
adminRoutes.GET("/system-configs", systemConfigHandler.List)
|
||||
|
||||
@@ -46,6 +46,9 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
||||
- `GET /api/wallet/ledger`
|
||||
- `GET /api/notifications`
|
||||
- `POST /api/notifications/{id}/read`
|
||||
- `POST /api/admin/auth/login`
|
||||
- `POST /api/admin/auth/logout`
|
||||
- `GET /api/admin/me`
|
||||
- `GET /api/admin/disputes`
|
||||
- `POST /api/admin/disputes/{id}/arbitrate`
|
||||
- `GET /api/admin/system-configs`
|
||||
|
||||
@@ -91,4 +91,11 @@
|
||||
- 系统配置接口为 `/api/admin/system-configs`。
|
||||
- 首次查询会自动补齐一组默认配置,包括交接超时、归还确认超时、短信限流、最低押金、实名下单开关、平台抽成和提现门槛。
|
||||
- 更新配置会写入 `audit_logs`,记录操作人、配置项、修改前值和修改后值。
|
||||
- 当前后台接口仍是开发态 mock 权限,只要求登录;后续接入后台管理员、角色和权限后再限制可操作配置项。
|
||||
- 当前后台接口已使用独立管理员登录和后台 JWT;后续接入角色和权限后再限制可操作配置项。
|
||||
|
||||
## 开发态后台登录
|
||||
|
||||
- 后台登录接口为 `/api/admin/auth/login`,前端页面为 `/admin/login`。
|
||||
- 后台 JWT 与普通用户 JWT 区分 `admin` 和 `user`,普通用户 Token 不能访问 `/api/admin/*`。
|
||||
- 开发态首次后台登录会自动初始化默认管理员:用户名 `admin`,密码 `admin123456`。
|
||||
- 默认管理员只用于本地开发;正式部署前必须改为初始化脚本、强密码和管理员密码修改流程。
|
||||
|
||||
+11
-8
@@ -614,20 +614,23 @@
|
||||
|
||||
后台:
|
||||
|
||||
- `GET /admin/dashboard`:后台仪表盘。
|
||||
- `GET /admin/users`:用户列表。
|
||||
- `POST /admin/users/{id}/freeze`:冻结用户。
|
||||
- `POST /admin/users/{id}/unfreeze`:解冻用户。
|
||||
- `GET /admin/listings/pending`:待审核商品。
|
||||
- `POST /admin/listings/{id}/approve`:审核通过。
|
||||
- `POST /admin/listings/{id}/reject`:审核拒绝。
|
||||
- `POST /api/admin/auth/login`:后台登录。
|
||||
- `POST /api/admin/auth/logout`:后台退出。
|
||||
- `GET /api/admin/me`:当前管理员信息。
|
||||
- `GET /api/admin/dashboard`:后台仪表盘。
|
||||
- `GET /api/admin/users`:用户列表。
|
||||
- `POST /api/admin/users/{id}/freeze`:冻结用户。
|
||||
- `POST /api/admin/users/{id}/unfreeze`:解冻用户。
|
||||
- `GET /api/admin/listings/pending`:待审核商品。
|
||||
- `POST /api/admin/listings/{id}/approve`:审核通过。
|
||||
- `POST /api/admin/listings/{id}/reject`:审核拒绝。
|
||||
- `GET /api/admin/orders`:订单列表。
|
||||
- `GET /api/admin/disputes`:纠纷列表。
|
||||
- `POST /api/admin/disputes/{id}/arbitrate`:纠纷仲裁。
|
||||
- `GET /api/admin/wallet/ledger`:资金流水。
|
||||
- `GET /api/admin/system-configs`:系统配置列表。
|
||||
- `PUT /api/admin/system-configs/{key}`:更新系统配置。
|
||||
- `GET /admin/audit-logs`:审计日志。
|
||||
- `GET /api/admin/audit-logs`:审计日志。
|
||||
|
||||
## 11. 安全与风控
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
status: string
|
||||
last_login_at?: string
|
||||
}
|
||||
|
||||
export interface AdminTokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface AdminLoginData {
|
||||
admin: AdminUser
|
||||
tokens: AdminTokenPair
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function loginAdmin(username: string, password: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<AdminLoginData>>('/admin/auth/login', { username, password })
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminMe() {
|
||||
const { data } = await apiClient.get<ApiResponse<AdminUser>>('/admin/me')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function logoutAdmin() {
|
||||
const { data } = await apiClient.post<ApiResponse<{ logged_out: boolean }>>('/admin/auth/logout')
|
||||
return data.data
|
||||
}
|
||||
@@ -6,7 +6,9 @@ export const apiClient = axios.create({
|
||||
})
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const url = config.url || ''
|
||||
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
|
||||
const token = localStorage.getItem(tokenKey)
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Bell, House, Operation, Phone, ScaleToOriginal, Shop, Tickets, UserFilled, Wallet } from '@element-plus/icons-vue'
|
||||
import { Bell, House, Key, Operation, Phone, ScaleToOriginal, Shop, Tickets, UserFilled, Wallet } from '@element-plus/icons-vue'
|
||||
|
||||
const navItems = [
|
||||
{ label: '首页', to: '/', icon: House },
|
||||
@@ -8,6 +8,7 @@ const navItems = [
|
||||
{ label: '钱包', to: '/wallet', icon: Wallet },
|
||||
{ label: '通知', to: '/notifications', icon: Bell },
|
||||
{ label: '实名', to: '/realname', icon: UserFilled },
|
||||
{ label: '后台登录', to: '/admin/login', icon: Key },
|
||||
{ label: '仲裁', to: '/admin/disputes', icon: ScaleToOriginal },
|
||||
{ label: '配置', to: '/admin/system-configs', icon: Operation },
|
||||
{ label: '登录', to: '/login', icon: Phone },
|
||||
|
||||
@@ -17,10 +17,21 @@ const router = createRouter({
|
||||
{ path: '/seller/listings/create', name: 'seller-listing-create', component: () => import('@/views/seller/SellerListingCreateView.vue') },
|
||||
{ path: '/seller/handoffs', name: 'seller-handoffs', component: () => import('@/views/seller/SellerHandoffsView.vue') },
|
||||
{ path: '/seller/earnings', name: 'seller-earnings', component: () => import('@/views/seller/SellerEarningsView.vue') },
|
||||
{ path: '/admin/login', name: 'admin-login', component: () => import('@/views/admin/AdminLoginView.vue') },
|
||||
{ path: '/admin/dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue') },
|
||||
{ path: '/admin/disputes', name: 'admin-disputes', component: () => import('@/views/admin/AdminDisputesView.vue') },
|
||||
{ path: '/admin/system-configs', name: 'admin-system-configs', component: () => import('@/views/admin/AdminSystemConfigsView.vue') },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.path.startsWith('/admin') && to.path !== '/admin/login') {
|
||||
const token = localStorage.getItem('admin_access_token')
|
||||
if (!token) {
|
||||
return '/admin/login'
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { fetchAdminMe, loginAdmin, type AdminUser } from '@/api/adminAuth'
|
||||
|
||||
export const useAdminSessionStore = defineStore('adminSession', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('admin_access_token') || '',
|
||||
refreshToken: localStorage.getItem('admin_refresh_token') || '',
|
||||
adminId: Number(localStorage.getItem('admin_id') || 0),
|
||||
username: localStorage.getItem('admin_username') || '',
|
||||
nickname: '',
|
||||
}),
|
||||
actions: {
|
||||
async login(username: string, password: string) {
|
||||
const result = await loginAdmin(username, password)
|
||||
this.applySession(result.admin, result.tokens.access_token, result.tokens.refresh_token)
|
||||
return result
|
||||
},
|
||||
async loadMe() {
|
||||
const admin = await fetchAdminMe()
|
||||
this.applyAdmin(admin)
|
||||
return admin
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.refreshToken = ''
|
||||
this.adminId = 0
|
||||
this.username = ''
|
||||
this.nickname = ''
|
||||
localStorage.removeItem('admin_access_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
localStorage.removeItem('admin_id')
|
||||
localStorage.removeItem('admin_username')
|
||||
},
|
||||
applySession(admin: AdminUser, accessToken: string, refreshToken: string) {
|
||||
this.token = accessToken
|
||||
this.refreshToken = refreshToken
|
||||
localStorage.setItem('admin_access_token', accessToken)
|
||||
localStorage.setItem('admin_refresh_token', refreshToken)
|
||||
this.applyAdmin(admin)
|
||||
},
|
||||
applyAdmin(admin: AdminUser) {
|
||||
this.adminId = admin.id
|
||||
this.username = admin.username
|
||||
this.nickname = admin.nickname
|
||||
localStorage.setItem('admin_id', String(admin.id))
|
||||
localStorage.setItem('admin_username', admin.username)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
|
||||
const router = useRouter()
|
||||
const adminSession = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
username: 'admin',
|
||||
password: 'admin123456',
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
await adminSession.login(form.username, form.password)
|
||||
ElMessage.success('后台登录成功')
|
||||
await router.push('/admin/dashboard')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '后台登录失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Admin Login</p>
|
||||
<h1>后台登录</h1>
|
||||
<p>开发态首次登录会初始化默认管理员,后续可接入角色权限和密码修改。</p>
|
||||
</div>
|
||||
|
||||
<el-form class="login-form" label-position="top">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="请输入管理员用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入管理员密码" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleLogin">登录后台</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user