第 5 阶段:纠纷、通知与后台-2
This commit is contained in:
@@ -37,6 +37,7 @@ npm run dev
|
|||||||
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
|
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
|
||||||
- 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。
|
- 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。
|
||||||
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
- 系统配置已支持默认配置初始化和后台编辑,页面为 `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/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/redis/go-redis/v9 v9.17.0
|
github.com/redis/go-redis/v9 v9.17.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.42.0
|
||||||
gorm.io/datatypes v1.2.7
|
gorm.io/datatypes v1.2.7
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
@@ -44,7 +45,6 @@ require (
|
|||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/arch v0.20.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/mod v0.27.0 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ContextUserID = "user_id"
|
ContextUserID = "user_id"
|
||||||
ContextPhone = "phone"
|
ContextPhone = "phone"
|
||||||
|
ContextAdminID = "admin_id"
|
||||||
|
ContextUsername = "username"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
||||||
@@ -24,7 +26,7 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := jwtManager.Parse(tokenText, "access")
|
claims, err := jwtManager.ParseSubject(tokenText, "access", "user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Unauthorized(c, "访问令牌无效或已过期")
|
response.Unauthorized(c, "访问令牌无效或已过期")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
@@ -36,3 +38,26 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
|||||||
c.Next()
|
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 {
|
type Claims struct {
|
||||||
UserID uint64 `json:"uid"`
|
UserID uint64 `json:"uid"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
TokenType string `json:"typ"`
|
TokenType string `json:"typ"`
|
||||||
|
SubjectType string `json:"sub_type"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,11 +46,15 @@ func NewJWTManager(secret string) *JWTManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *JWTManager) GeneratePair(userID uint64, phone string) (TokenPair, error) {
|
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 {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
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 {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
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) {
|
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{}
|
claims := &Claims{}
|
||||||
token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) {
|
token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) {
|
||||||
if token.Method != m.signingMethod {
|
if token.Method != m.signingMethod {
|
||||||
@@ -75,17 +84,21 @@ func (m *JWTManager) Parse(tokenText, expectedType string) (*Claims, error) {
|
|||||||
if claims.TokenType != expectedType {
|
if claims.TokenType != expectedType {
|
||||||
return nil, ErrInvalidToken
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
|
if expectedSubjectType != "" && claims.SubjectType != expectedSubjectType {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
return claims, nil
|
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()
|
now := time.Now()
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Phone: phone,
|
Phone: subject,
|
||||||
TokenType: tokenType,
|
TokenType: tokenType,
|
||||||
|
SubjectType: subjectType,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Subject: phone,
|
Subject: subject,
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
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) {
|
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 {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ func (h *Handler) AdminList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
||||||
adminID, ok := currentUserID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, ok := parseID(c)
|
id, ok := parseID(c)
|
||||||
@@ -106,6 +106,15 @@ func (h *Handler) AdminArbitrate(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||||
value, ok := c.Get(middleware.ContextUserID)
|
value, ok := c.Get(middleware.ContextUserID)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Update(c *gin.Context) {
|
func (h *Handler) Update(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
key := c.Param("key")
|
key := c.Param("key")
|
||||||
@@ -39,7 +39,7 @@ func (h *Handler) Update(c *gin.Context) {
|
|||||||
response.BadRequest(c, "配置值不能为空")
|
response.BadRequest(c, "配置值不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, err := h.service.Update(userID, key, req, AuditMeta{
|
item, err := h.service.Update(adminID, key, req, AuditMeta{
|
||||||
IP: c.ClientIP(),
|
IP: c.ClientIP(),
|
||||||
UserAgent: c.GetHeader("User-Agent"),
|
UserAgent: c.GetHeader("User-Agent"),
|
||||||
})
|
})
|
||||||
@@ -50,13 +50,13 @@ func (h *Handler) Update(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||||
value, ok := c.Get(middleware.ContextUserID)
|
value, ok := c.Get(middleware.ContextAdminID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
userID, ok := value.(uint64)
|
adminID, ok := value.(uint64)
|
||||||
return userID, ok
|
return adminID, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeConfigError(c *gin.Context, err error) {
|
func writeConfigError(c *gin.Context, err error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/config"
|
"hfb_sys/backend/internal/config"
|
||||||
"hfb_sys/backend/internal/handler"
|
"hfb_sys/backend/internal/handler"
|
||||||
"hfb_sys/backend/internal/middleware"
|
"hfb_sys/backend/internal/middleware"
|
||||||
|
"hfb_sys/backend/internal/modules/adminauth"
|
||||||
"hfb_sys/backend/internal/modules/auth"
|
"hfb_sys/backend/internal/modules/auth"
|
||||||
"hfb_sys/backend/internal/modules/dispute"
|
"hfb_sys/backend/internal/modules/dispute"
|
||||||
"hfb_sys/backend/internal/modules/listing"
|
"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)
|
authService := auth.NewService(userRepo, deps.Redis, jwtManager, logger)
|
||||||
authHandler := auth.NewHandler(authService)
|
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)
|
userHandler := user.NewHandler(userRepo)
|
||||||
var realnameRepo *realname.Repository
|
var realnameRepo *realname.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
@@ -81,6 +88,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
||||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||||
requireAuth := middleware.Auth(jwtManager)
|
requireAuth := middleware.Auth(jwtManager)
|
||||||
|
requireAdmin := middleware.AdminAuth(jwtManager)
|
||||||
requireRealname := middleware.RequireRealname(userRepo)
|
requireRealname := middleware.RequireRealname(userRepo)
|
||||||
|
|
||||||
api := engine.Group("/api")
|
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)
|
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.GET("/disputes", disputeHandler.AdminList)
|
||||||
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
||||||
adminRoutes.GET("/system-configs", systemConfigHandler.List)
|
adminRoutes.GET("/system-configs", systemConfigHandler.List)
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- `GET /api/wallet/ledger`
|
- `GET /api/wallet/ledger`
|
||||||
- `GET /api/notifications`
|
- `GET /api/notifications`
|
||||||
- `POST /api/notifications/{id}/read`
|
- `POST /api/notifications/{id}/read`
|
||||||
|
- `POST /api/admin/auth/login`
|
||||||
|
- `POST /api/admin/auth/logout`
|
||||||
|
- `GET /api/admin/me`
|
||||||
- `GET /api/admin/disputes`
|
- `GET /api/admin/disputes`
|
||||||
- `POST /api/admin/disputes/{id}/arbitrate`
|
- `POST /api/admin/disputes/{id}/arbitrate`
|
||||||
- `GET /api/admin/system-configs`
|
- `GET /api/admin/system-configs`
|
||||||
|
|||||||
@@ -91,4 +91,11 @@
|
|||||||
- 系统配置接口为 `/api/admin/system-configs`。
|
- 系统配置接口为 `/api/admin/system-configs`。
|
||||||
- 首次查询会自动补齐一组默认配置,包括交接超时、归还确认超时、短信限流、最低押金、实名下单开关、平台抽成和提现门槛。
|
- 首次查询会自动补齐一组默认配置,包括交接超时、归还确认超时、短信限流、最低押金、实名下单开关、平台抽成和提现门槛。
|
||||||
- 更新配置会写入 `audit_logs`,记录操作人、配置项、修改前值和修改后值。
|
- 更新配置会写入 `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`:后台仪表盘。
|
- `POST /api/admin/auth/login`:后台登录。
|
||||||
- `GET /admin/users`:用户列表。
|
- `POST /api/admin/auth/logout`:后台退出。
|
||||||
- `POST /admin/users/{id}/freeze`:冻结用户。
|
- `GET /api/admin/me`:当前管理员信息。
|
||||||
- `POST /admin/users/{id}/unfreeze`:解冻用户。
|
- `GET /api/admin/dashboard`:后台仪表盘。
|
||||||
- `GET /admin/listings/pending`:待审核商品。
|
- `GET /api/admin/users`:用户列表。
|
||||||
- `POST /admin/listings/{id}/approve`:审核通过。
|
- `POST /api/admin/users/{id}/freeze`:冻结用户。
|
||||||
- `POST /admin/listings/{id}/reject`:审核拒绝。
|
- `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/orders`:订单列表。
|
||||||
- `GET /api/admin/disputes`:纠纷列表。
|
- `GET /api/admin/disputes`:纠纷列表。
|
||||||
- `POST /api/admin/disputes/{id}/arbitrate`:纠纷仲裁。
|
- `POST /api/admin/disputes/{id}/arbitrate`:纠纷仲裁。
|
||||||
- `GET /api/admin/wallet/ledger`:资金流水。
|
- `GET /api/admin/wallet/ledger`:资金流水。
|
||||||
- `GET /api/admin/system-configs`:系统配置列表。
|
- `GET /api/admin/system-configs`:系统配置列表。
|
||||||
- `PUT /api/admin/system-configs/{key}`:更新系统配置。
|
- `PUT /api/admin/system-configs/{key}`:更新系统配置。
|
||||||
- `GET /admin/audit-logs`:审计日志。
|
- `GET /api/admin/audit-logs`:审计日志。
|
||||||
|
|
||||||
## 11. 安全与风控
|
## 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) => {
|
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) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 = [
|
const navItems = [
|
||||||
{ label: '首页', to: '/', icon: House },
|
{ label: '首页', to: '/', icon: House },
|
||||||
@@ -8,6 +8,7 @@ const navItems = [
|
|||||||
{ label: '钱包', to: '/wallet', icon: Wallet },
|
{ label: '钱包', to: '/wallet', icon: Wallet },
|
||||||
{ label: '通知', to: '/notifications', icon: Bell },
|
{ label: '通知', to: '/notifications', icon: Bell },
|
||||||
{ label: '实名', to: '/realname', icon: UserFilled },
|
{ label: '实名', to: '/realname', icon: UserFilled },
|
||||||
|
{ label: '后台登录', to: '/admin/login', icon: Key },
|
||||||
{ label: '仲裁', to: '/admin/disputes', icon: ScaleToOriginal },
|
{ label: '仲裁', to: '/admin/disputes', icon: ScaleToOriginal },
|
||||||
{ label: '配置', to: '/admin/system-configs', icon: Operation },
|
{ label: '配置', to: '/admin/system-configs', icon: Operation },
|
||||||
{ label: '登录', to: '/login', icon: Phone },
|
{ 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/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/handoffs', name: 'seller-handoffs', component: () => import('@/views/seller/SellerHandoffsView.vue') },
|
||||||
{ path: '/seller/earnings', name: 'seller-earnings', component: () => import('@/views/seller/SellerEarningsView.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/dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue') },
|
||||||
{ path: '/admin/disputes', name: 'admin-disputes', component: () => import('@/views/admin/AdminDisputesView.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') },
|
{ 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
|
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