feat: 多班级版 v2.0 - Go后端重写 + 43轮代码审查
- 后端从 Python FastAPI 重写为 Go Gin(端口 56789) - 多班级完全隔离 - 超级管理员独立登录 - 课代表作业管理、排行榜分项排行 - 角色加减分上下限可配置 - 家长改密功能(可开关) - 周度/月度重置功能 - MySQL 5.7 兼容 - 43轮代码审查+全部修复 - Apache 2.0 许可证
This commit is contained in:
57
backend-go/internal/middleware/access_log.go
Normal file
57
backend-go/internal/middleware/access_log.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// AccessLog 访问日志中间件
|
||||
func AccessLog() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
// 处理请求
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
clientIP := c.ClientIP()
|
||||
method := c.Request.Method
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
if query != "" {
|
||||
path = path + "?" + query
|
||||
}
|
||||
|
||||
// 获取用户信息(如已认证)
|
||||
userID, _ := c.Get(CtxUserID)
|
||||
username, _ := c.Get(CtxUsername)
|
||||
|
||||
logger.Sugared.Infow("请求日志",
|
||||
"status", status,
|
||||
"method", method,
|
||||
"path", path,
|
||||
"ip", clientIP,
|
||||
"latency", latency.String(),
|
||||
"user_agent", userAgent,
|
||||
"user_id", userID,
|
||||
"username", username,
|
||||
)
|
||||
}
|
||||
}
|
||||
227
backend-go/internal/middleware/auth.go
Normal file
227
backend-go/internal/middleware/auth.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
||||
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// 上下文 Key 常量
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
CtxUserType = "user_type"
|
||||
CtxStudentID = "student_id"
|
||||
CtxRole = "role"
|
||||
CtxRealName = "real_name"
|
||||
CtxClassID = "class_id"
|
||||
)
|
||||
|
||||
// 公开路径(不需要认证)
|
||||
var publicPaths = map[string]bool{
|
||||
"/": true,
|
||||
"/health": true,
|
||||
"/api/auth/login": true,
|
||||
}
|
||||
|
||||
// RegisterPublicPath 注册额外的公开路径(需在路由初始化阶段调用)
|
||||
func RegisterPublicPath(path string) {
|
||||
publicPaths[path] = true
|
||||
}
|
||||
|
||||
// AuthRequired JWT 认证中间件
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 公开路径跳过
|
||||
if publicPaths[path] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.AppConfig
|
||||
|
||||
// 获取 Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Unauthorized(c, "缺少认证令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Bearer Token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
response.Unauthorized(c, "认证格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
tokenStr := parts[1]
|
||||
|
||||
// 验证 JWT
|
||||
claims, err := appJwt.VerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
logger.Sugared.Warnf("JWT 验证失败: path=%s, err=%v", path, err)
|
||||
response.Unauthorized(c, "令牌无效或已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Redis 中的 Token
|
||||
ctx := context.Background()
|
||||
storedToken, err := database.GetUserToken(ctx, claims.UserID)
|
||||
if err != nil || storedToken != tokenStr {
|
||||
logger.Sugared.Warnf("Redis Token 不匹配: path=%s, user_id=%d", path, claims.UserID)
|
||||
// 主动清理 Redis 中的旧 Token,避免残留
|
||||
if err == nil && storedToken != "" && storedToken != tokenStr {
|
||||
_ = database.DeleteUserToken(ctx, claims.UserID)
|
||||
}
|
||||
response.Unauthorized(c, "令牌已失效,请重新登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 刷新 Token 过期时间(空闲超时)
|
||||
_ = database.ExpireToken(ctx, claims.UserID, cfg.JWTIdleTimeoutMinutes)
|
||||
|
||||
// 将用户信息写入 Gin 上下文
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUsername, claims.Username)
|
||||
c.Set(CtxUserType, claims.UserType)
|
||||
c.Set(CtxRealName, claims.RealName)
|
||||
if claims.StudentID != nil {
|
||||
c.Set(CtxStudentID, *claims.StudentID)
|
||||
}
|
||||
c.Set(CtxRole, claims.Role)
|
||||
if claims.ClassID != nil {
|
||||
c.Set(CtxClassID, *claims.ClassID)
|
||||
}
|
||||
|
||||
logger.Sugared.Debugf("认证成功: %s %s, user_id=%d, username=%s",
|
||||
c.Request.Method, path, claims.UserID, claims.Username)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole 角色权限中间件
|
||||
func RequireRole(roles ...string) gin.HandlerFunc {
|
||||
roleSet := make(map[string]bool, len(roles))
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
userType, _ := c.Get(CtxUserType)
|
||||
role, _ := c.Get(CtxRole)
|
||||
|
||||
// 超级管理员直接通过
|
||||
if userType == "super_admin" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 user_type
|
||||
if ut, ok := userType.(string); ok && roleSet[ut] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 role(admin_roles.role_type)
|
||||
if r, ok := role.(string); ok && roleSet[r] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
response.Forbidden(c, "权限不足")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID 从上下文获取用户 ID
|
||||
func GetUserID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxUserID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUsername 从上下文获取用户名
|
||||
func GetUsername(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxUsername); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUserType 从上下文获取用户类型
|
||||
func GetUserType(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxUserType); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetRole 从上下文获取角色
|
||||
func GetRole(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxRole); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetClassID 从上下文获取班级 ID
|
||||
func GetClassID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxClassID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetStudentID 从上下文获取学生 ID
|
||||
func GetStudentID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxStudentID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetRealName 从上下文获取真实姓名
|
||||
func GetRealName(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxRealName); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
131
backend-go/internal/middleware/sanitize.go
Normal file
131
backend-go/internal/middleware/sanitize.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Sanitize 输入清理中间件(路径遍历防护 + 长度限制)
|
||||
func Sanitize() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 处理 POST、PUT、PATCH 请求体
|
||||
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err == nil && len(body) > 0 {
|
||||
var data interface{}
|
||||
if json.Unmarshal(body, &data) == nil {
|
||||
cleaned := sanitizeData(data)
|
||||
newBody, _ := json.Marshal(cleaned)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(newBody))
|
||||
c.Request.ContentLength = int64(len(newBody))
|
||||
} else {
|
||||
// 非 JSON 请求体,恢复原始 body
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理查询参数(GET 等请求的 URL query string)
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
params := c.Request.URL.Query()
|
||||
dirty := false
|
||||
for key, values := range params {
|
||||
for i, v := range values {
|
||||
cleaned := sanitizeString(v)
|
||||
if cleaned != v {
|
||||
values[i] = cleaned
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
params[key] = values
|
||||
}
|
||||
if dirty {
|
||||
c.Request.URL.RawQuery = params.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeData 递归清理数据
|
||||
func sanitizeData(data interface{}) interface{} {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]interface{}, len(v))
|
||||
for key, val := range v {
|
||||
result[key] = sanitizeData(val)
|
||||
}
|
||||
return result
|
||||
case []interface{}:
|
||||
result := make([]interface{}, len(v))
|
||||
for i, val := range v {
|
||||
result[i] = sanitizeData(val)
|
||||
}
|
||||
return result
|
||||
case string:
|
||||
return sanitizeString(v)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeString 清理字符串
|
||||
func sanitizeString(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// 路径遍历防护(循环解码直到稳定,防止多层编码绕过)
|
||||
for {
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil || decoded == value {
|
||||
break
|
||||
}
|
||||
value = decoded
|
||||
}
|
||||
// 大小写无关的路径遍历模式清理(循环移除直到无匹配)
|
||||
lower := strings.ToLower(value)
|
||||
for strings.Contains(lower, "../") || strings.Contains(lower, "..\\") {
|
||||
replaced := false
|
||||
for _, pattern := range []string{"../", "..\\"} {
|
||||
if idx := strings.Index(lower, pattern); idx >= 0 {
|
||||
value = value[:idx] + value[idx+len(pattern):]
|
||||
lower = lower[:idx] + lower[idx+len(pattern):]
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 限制长度(按 rune 截断,避免切断多字节 UTF-8 字符)
|
||||
runes := []rune(value)
|
||||
if len(runes) > 1000 {
|
||||
value = string(runes[:1000])
|
||||
}
|
||||
|
||||
// SQL 注入由 GORM 参数化查询防护,无需正则替换(避免破坏合法输入)
|
||||
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user