第 5 阶段:纠纷、通知与后台-2

This commit is contained in:
yml
2026-05-22 16:50:33 +08:00
parent 99f9df7bcd
commit aee3455e9a
22 changed files with 519 additions and 36 deletions
+25
View File
@@ -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)
}
+23 -10
View File
@@ -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)),
},
+1 -1
View File
@@ -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
}
+11 -2
View File
@@ -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) {