From 4193a1a1532264b67bb106f1f2218109102490a8 Mon Sep 17 00:00:00 2001 From: canglan Date: Mon, 22 Jun 2026 10:45:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=B8=85=E7=90=86=E6=97=A7?= =?UTF-8?q?=E7=89=88=E5=85=BC=E5=AE=B9=E6=80=A7=EF=BC=8C=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=20bcrypt=20=E5=AF=86=E7=A0=81=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 密码哈希从 MD5+SHA1 升级为 bcrypt - 删除 super_admins/users 表中的 salt 字段 - 删除旧版升级文件(upgrade.php, check_upgrade, execute_upgrade, sql/upgrades/) - 删除 PASSWORD_SALT 配置项 - 清理所有'兼容 Python 版'注释 - 新项目独立,无历史包袱 --- backend-go/.env.example | 7 - backend-go/go.mod | 1 + backend-go/internal/config/config.go | 10 +- backend-go/internal/model/super_admin.go | 17 +- backend-go/internal/model/user.go | 2 +- .../internal/repository/super_admin_repo.go | 15 +- backend-go/internal/service/admin_service.go | 40 +- backend-go/internal/service/auth_service.go | 22 +- .../internal/service/super_admin_service.go | 21 +- backend-go/pkg/crypto/password.go | 38 +- backend-go/pkg/database/redis.go | 2 +- frontend/api/check_upgrade.php | 69 -- frontend/api/execute_upgrade.php | 104 --- sql/init.sql | 11 +- sql/upgrades/v1.0.sql | 264 ------- sql/upgrades/v2.1.sql | 53 -- upgrade.php | 719 ------------------ 17 files changed, 76 insertions(+), 1319 deletions(-) delete mode 100644 frontend/api/check_upgrade.php delete mode 100644 frontend/api/execute_upgrade.php delete mode 100644 sql/upgrades/v1.0.sql delete mode 100644 sql/upgrades/v2.1.sql delete mode 100644 upgrade.php diff --git a/backend-go/.env.example b/backend-go/.env.example index 9184370..a1cc205 100644 --- a/backend-go/.env.example +++ b/backend-go/.env.example @@ -43,13 +43,6 @@ JWT_ALGORITHM=HS256 JWT_EXPIRE_MINUTES=60 JWT_IDLE_TIMEOUT_MINUTES=10 -# =========================================== -# 密码加密配置(与 Python 版兼容) -# 算法: MD5(SHA1(password) + SALT) -# =========================================== - -PASSWORD_SALT=your-fixed-salt-string - # =========================================== # 系统管理员配置 # =========================================== diff --git a/backend-go/go.mod b/backend-go/go.mod index d748f33..ff3d56a 100644 --- a/backend-go/go.mod +++ b/backend-go/go.mod @@ -8,6 +8,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.7.0 go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.31.0 gorm.io/driver/mysql v1.5.7 gorm.io/gorm v1.25.12 ) diff --git a/backend-go/internal/config/config.go b/backend-go/internal/config/config.go index b2ddcc2..4887008 100644 --- a/backend-go/internal/config/config.go +++ b/backend-go/internal/config/config.go @@ -51,9 +51,6 @@ type Config struct { JWTExpireMinutes int JWTIdleTimeoutMinutes int - // 密码加密(兼容 Python 版) - PasswordSalt string - // 系统管理员配置 SuperAdminLoginPath string SuperAdminDefaultUser string @@ -98,8 +95,6 @@ func Load() (*Config, error) { JWTExpireMinutes: getEnvInt("JWT_EXPIRE_MINUTES", 60), JWTIdleTimeoutMinutes: getEnvInt("JWT_IDLE_TIMEOUT_MINUTES", 10), - PasswordSalt: getEnv("PASSWORD_SALT", ""), - SuperAdminLoginPath: getEnv("SUPER_ADMIN_LOGIN_PATH", "/super-admin"), SuperAdminDefaultUser: getEnv("SUPER_ADMIN_DEFAULT_USERNAME", "admin"), // 安全警告:默认密码仅用于首次部署初始化,上线前必须在 .env 中修改 SUPER_ADMIN_DEFAULT_PASSWORD。 @@ -110,14 +105,11 @@ func Load() (*Config, error) { LogFile: getEnv("LOG_FILE", "logs/app.log"), } + // 校验必填项 // 校验必填项 if cfg.JWTSecretKey == "" { return nil, fmt.Errorf("配置 JWT_SECRET_KEY 不能为空") } - if cfg.PasswordSalt == "" { - return nil, fmt.Errorf("配置 PASSWORD_SALT 不能为空") - } - AppConfig = cfg return cfg, nil } diff --git a/backend-go/internal/model/super_admin.go b/backend-go/internal/model/super_admin.go index ec7c4b0..06ca6ba 100644 --- a/backend-go/internal/model/super_admin.go +++ b/backend-go/internal/model/super_admin.go @@ -15,15 +15,14 @@ import "time" // SuperAdmin 超级管理员模型,对应 super_admins 表 type SuperAdmin struct { - ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"` - PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` - Salt string `gorm:"column:salt;type:varchar(64);not null" json:"-"` - RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"` - Status int8 `gorm:"column:status;default:1" json:"status"` - NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"` - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` + ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"column:password_hash;type:varchar(60);not null" json:"-"` + RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"` + Status int8 `gorm:"column:status;default:1" json:"status"` + NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` } // TableName 指定表名 diff --git a/backend-go/internal/model/user.go b/backend-go/internal/model/user.go index 7a28061..f563690 100644 --- a/backend-go/internal/model/user.go +++ b/backend-go/internal/model/user.go @@ -17,7 +17,7 @@ import "time" type User struct { UserID int `gorm:"column:user_id;primaryKey;autoIncrement" json:"user_id"` Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"` - PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` + PasswordHash string `gorm:"column:password_hash;type:varchar(60);not null" json:"-"` RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"` UserType string `gorm:"column:user_type;type:enum('student','parent','admin','super_admin');not null" json:"user_type"` StudentID *int `gorm:"column:student_id" json:"student_id"` diff --git a/backend-go/internal/repository/super_admin_repo.go b/backend-go/internal/repository/super_admin_repo.go index 67e3855..3bea016 100644 --- a/backend-go/internal/repository/super_admin_repo.go +++ b/backend-go/internal/repository/super_admin_repo.go @@ -54,20 +54,12 @@ func (r *SuperAdminRepo) Create(admin *model.SuperAdmin) (int, error) { return admin.ID, nil } -// UpdatePassword 更新超级管理员密码 +// UpdatePassword 更新超级管理员密码并清除强制改密标记 func (r *SuperAdminRepo) UpdatePassword(id int, passwordHash string) error { - return r.db.Model(&model.SuperAdmin{}). - Where("id = ?", id). - Update("password_hash", passwordHash).Error -} - -// UpdatePasswordWithSalt 更新超级管理员密码和盐值,并清除强制改密标记 -func (r *SuperAdminRepo) UpdatePasswordWithSalt(id int, passwordHash, salt string) error { return r.db.Model(&model.SuperAdmin{}). Where("id = ?", id). Updates(map[string]interface{}{ - "password_hash": passwordHash, - "salt": salt, + "password_hash": passwordHash, "need_change_password": 0, }).Error } @@ -98,11 +90,10 @@ func (r *SuperAdminRepo) UpdateStatus(id int, status int8) error { } // EnsureDefaultAdmin 确保默认超级管理员存在(使用 INSERT IGNORE 避免并发竞态) -func (r *SuperAdminRepo) EnsureDefaultAdmin(username, passwordHash, salt, realName string) error { +func (r *SuperAdminRepo) EnsureDefaultAdmin(username, passwordHash, realName string) error { admin := model.SuperAdmin{ Username: username, PasswordHash: passwordHash, - Salt: salt, RealName: realName, Status: 1, } diff --git a/backend-go/internal/service/admin_service.go b/backend-go/internal/service/admin_service.go index eb385c0..8c01937 100644 --- a/backend-go/internal/service/admin_service.go +++ b/backend-go/internal/service/admin_service.go @@ -16,7 +16,6 @@ import ( "fmt" "regexp" - "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto" @@ -100,10 +99,17 @@ func (s *AdminService) getInitialPassword(classID int) (string, error) { return pwd, nil } +// hashPassword 对密码进行 bcrypt 哈希,失败时 panic(不应发生) +func hashPasswordOrPanic(password string) string { + hash, err := crypto.HashPassword(password) + if err != nil { + logger.Sugared.Fatalf("密码哈希失败: %v", err) + } + return hash +} + // AddStudent 新增学生 func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) { - cfg := config.AppConfig - // 校验宿舍号格式 if !validateDormitoryNumber(dormitoryNumber) { return map[string]interface{}{"success": false, "message": "宿舍号格式不正确,应为如 东1-101 的格式"}, nil @@ -134,7 +140,7 @@ func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, if err != nil { return nil, err } - passwordHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt) + passwordHash := hashPasswordOrPanic(defaultPassword) _, err = s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID) if err != nil { logger.Sugared.Errorf("创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err) @@ -147,7 +153,7 @@ func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, if parentAccount != nil && *parentAccount != "" { exists, _ := s.userRepo.CheckUsernameExists(*parentAccount) if !exists { - parentHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt) + parentHash := hashPasswordOrPanic(defaultPassword) parentRealName := fmt.Sprintf("%s家长", name) if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil { logger.Sugared.Warnf("创建家长账号失败(学生记录已保留): parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err) @@ -167,7 +173,6 @@ func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, // 未使用数据库事务的原因:Repository 层未暴露事务接口,全量事务包裹需要较大重构; // 且批量导入场景下允许部分成功是合理的业务权衡(用户可修正失败记录后重新导入)。 func (s *AdminService) ImportStudents(students []map[string]interface{}, classID int) (map[string]interface{}, error) { - cfg := config.AppConfig successCount := 0 failedCount := 0 var details []map[string]interface{} @@ -262,7 +267,7 @@ func (s *AdminService) ImportStudents(students []map[string]interface{}, classID existingSet[studentNo] = true // 创建学生登录账号 - passwordHash := crypto.HashPassword(password, cfg.PasswordSalt) + passwordHash := hashPasswordOrPanic(password) if _, err := s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID); err != nil { logger.Sugared.Errorf("批量导入-创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err) // 回滚学生记录 @@ -277,7 +282,7 @@ func (s *AdminService) ImportStudents(students []map[string]interface{}, classID // 创建家长账号 if parentAccount != nil && *parentAccount != "" && !usernameSet[*parentAccount] { - parentHash := crypto.HashPassword(password, cfg.PasswordSalt) + parentHash := hashPasswordOrPanic(password) parentRealName := fmt.Sprintf("%s家长", name) if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil { logger.Sugared.Errorf("批量导入-创建家长账号失败: parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err) @@ -346,7 +351,6 @@ func (s *AdminService) ResetStudentPassword(studentID int, newPassword string) e if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { return fmt.Errorf("%s", msg) } - cfg := config.AppConfig student, err := s.studentRepo.GetByID(studentID) if err != nil { return fmt.Errorf("学生不存在") @@ -356,14 +360,15 @@ func (s *AdminService) ResetStudentPassword(studentID int, newPassword string) e if err != nil { return fmt.Errorf("学生登录账号不存在") } - passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + passwordHash, err := crypto.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("密码加密失败") + } return s.userRepo.UpdatePassword(user.UserID, passwordHash) } // AddAdmin 添加管理员 func (s *AdminService) AddAdmin(username, realName, password, roleType string, classID int, subjectID *int) (map[string]interface{}, error) { - cfg := config.AppConfig - exists, _ := s.userRepo.CheckUsernameExists(username) if exists { return map[string]interface{}{"success": false, "message": "用户名已存在"}, nil @@ -377,7 +382,10 @@ func (s *AdminService) AddAdmin(username, realName, password, roleType string, c password = pwd } - passwordHash := crypto.HashPassword(password, cfg.PasswordSalt) + passwordHash, err := crypto.HashPassword(password) + if err != nil { + return nil, fmt.Errorf("密码加密失败: %w", err) + } userID, err := s.userRepo.CreateAdmin(username, passwordHash, realName) if err != nil { return nil, err @@ -436,8 +444,10 @@ func (s *AdminService) ResetAdminPassword(userID int, newPassword string) error if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { return fmt.Errorf("%s", msg) } - cfg := config.AppConfig - passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + passwordHash, err := crypto.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("密码加密失败") + } return s.userRepo.UpdatePassword(userID, passwordHash) } diff --git a/backend-go/internal/service/auth_service.go b/backend-go/internal/service/auth_service.go index 8d4a69a..4e373bf 100644 --- a/backend-go/internal/service/auth_service.go +++ b/backend-go/internal/service/auth_service.go @@ -17,13 +17,13 @@ import ( "github.com/redis/go-redis/v9" - "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto" "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/internal/config" ) // AuthService 认证服务 @@ -124,10 +124,8 @@ func (s *AuthService) Login(username, password, ip, userAgent string) *LoginResu return s.tryParentLogin(username, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey) } - // 验证密码(使用全局 PASSWORD_SALT,与 Python 版兼容。 - // 已知设计局限:全局共享盐值,若泄露则所有普通用户密码面临风险。 - // 后续迁移计划:为每个用户生成独立盐值,存储在 users 表中。) - if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + // 验证密码(bcrypt) + if !crypto.VerifyPassword(password, user.PasswordHash) { s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") return &LoginResult{Success: false, Message: "用户名或密码错误"} } @@ -222,7 +220,7 @@ func (s *AuthService) loginAsStudent(student *model.Student, password, ip, userA return &LoginResult{Success: false, Message: "用户名或密码错误"} } - if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + if !crypto.VerifyPassword(password, user.PasswordHash) { return &LoginResult{Success: false, Message: "用户名或密码错误"} } @@ -288,7 +286,7 @@ func (s *AuthService) tryParentLogin(username, password, ip, userAgent string, c return &LoginResult{Success: false, Message: "用户名或密码错误"} } - if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + if !crypto.VerifyPassword(password, user.PasswordHash) { return &LoginResult{Success: false, Message: "用户名或密码错误"} } @@ -340,8 +338,6 @@ func (s *AuthService) Logout(userID int) error { // ChangePassword 修改密码 func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string, force bool) error { - cfg := config.AppConfig - user, err := s.userRepo.GetByUserID(userID) if err != nil { return fmt.Errorf("用户不存在") @@ -349,7 +345,7 @@ func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string // 验证原密码(强制改密时跳过) if !force { - if !crypto.VerifyPassword(oldPassword, user.PasswordHash, cfg.PasswordSalt) { + if !crypto.VerifyPassword(oldPassword, user.PasswordHash) { return fmt.Errorf("原密码错误") } } @@ -360,7 +356,10 @@ func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string } // 更新密码 - newHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + newHash, err := crypto.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("密码加密失败") + } if err := s.userRepo.UpdatePassword(userID, newHash); err != nil { return fmt.Errorf("密码修改失败") } @@ -458,4 +457,3 @@ func getPasswordChangePath(userType string) string { return "/" } } - diff --git a/backend-go/internal/service/super_admin_service.go b/backend-go/internal/service/super_admin_service.go index aed8c2b..81a9d15 100644 --- a/backend-go/internal/service/super_admin_service.go +++ b/backend-go/internal/service/super_admin_service.go @@ -40,16 +40,13 @@ func (s *SuperAdminService) EnsureDefaultAdmin() error { logger.Sugared.Warnf("⚠️ 当前使用默认超级管理员密码,部署环境请务必修改 SUPER_ADMIN_DEFAULT_PASSWORD 并重启服务") - // 为超级管理员生成独立的随机 Salt - salt, err := crypto.GenerateRandomPassword(16) + passwordHash, err := crypto.HashPassword(cfg.SuperAdminDefaultPass) if err != nil { - return fmt.Errorf("生成随机盐值失败: %w", err) + return fmt.Errorf("密码哈希失败: %w", err) } - passwordHash := crypto.HashPassword(cfg.SuperAdminDefaultPass, salt) if err := s.superAdminRepo.EnsureDefaultAdmin( cfg.SuperAdminDefaultUser, passwordHash, - salt, "系统管理员", ); err != nil { return fmt.Errorf("创建默认超级管理员失败: %w", err) @@ -82,7 +79,7 @@ func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil } - if !crypto.VerifyPassword(password, admin.PasswordHash, admin.Salt) { + if !crypto.VerifyPassword(password, admin.PasswordHash) { s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil } @@ -120,7 +117,7 @@ func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map }, nil } -// ChangePassword 超级管理员修改密码(操作 super_admins 表,使用独立 salt) +// ChangePassword 超级管理员修改密码 func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword string, force bool) error { admin, err := s.superAdminRepo.GetByID(adminID) if err != nil { @@ -129,7 +126,7 @@ func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword // 验证原密码(强制改密时跳过) if !force { - if !crypto.VerifyPassword(oldPassword, admin.PasswordHash, admin.Salt) { + if !crypto.VerifyPassword(oldPassword, admin.PasswordHash) { return fmt.Errorf("原密码错误") } } @@ -139,14 +136,12 @@ func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword return fmt.Errorf("%s", msg) } - // 生成新的独立 salt - newSalt, err := crypto.GenerateRandomPassword(16) + newHash, err := crypto.HashPassword(newPassword) if err != nil { - return fmt.Errorf("生成随机盐值失败: %w", err) + return fmt.Errorf("密码加密失败: %w", err) } - newHash := crypto.HashPassword(newPassword, newSalt) - if err := s.superAdminRepo.UpdatePasswordWithSalt(adminID, newHash, newSalt); err != nil { + if err := s.superAdminRepo.UpdatePassword(adminID, newHash); err != nil { return fmt.Errorf("密码修改失败") } diff --git a/backend-go/pkg/crypto/password.go b/backend-go/pkg/crypto/password.go index ce05356..473e255 100644 --- a/backend-go/pkg/crypto/password.go +++ b/backend-go/pkg/crypto/password.go @@ -12,41 +12,29 @@ package crypto import ( - "crypto/md5" "crypto/rand" - "crypto/sha1" - "crypto/subtle" - "encoding/hex" "fmt" "math/big" + + "golang.org/x/crypto/bcrypt" ) -// HashPassword 密码哈希(与 Python 版完全兼容) -// 算法: MD5(SHA1(password) + salt) -// Python 参考: backend/utils/security.py -> sha1_md5_password() -// 已知弱算法:MD5 和 SHA1 均不适合密码哈希场景,保留此实现仅为兼容 Python 版数据。 -// 后续迁移计划:迁移到 bcrypt/scrypt/argon2,并提供兼容层逐步过渡。 -func HashPassword(password string, salt string) string { - // 第一层: SHA1(password) - sha1Hash := sha1.Sum([]byte(password)) - sha1Hex := hex.EncodeToString(sha1Hash[:]) - - // 加盐: SHA1_hex + salt - salted := sha1Hex + salt - - // 第二层: MD5(salted) - md5Hash := md5.Sum([]byte(salted)) - return hex.EncodeToString(md5Hash[:]) +// HashPassword 使用 bcrypt 对密码进行哈希 +// bcrypt 自带盐值管理,无需外部 salt +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("密码哈希失败: %w", err) + } + return string(hash), nil } -// VerifyPassword 验证密码(使用常量时间比较,防止时序攻击) -func VerifyPassword(plainPassword, hashedPassword, salt string) bool { - computed := HashPassword(plainPassword, salt) - return subtle.ConstantTimeCompare([]byte(computed), []byte(hashedPassword)) == 1 +// VerifyPassword 验证密码是否与 bcrypt 哈希匹配 +func VerifyPassword(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } // GenerateRandomPassword 生成随机密码 -// 与 Python 版 SecurityUtils.generate_random_password() 兼容 func GenerateRandomPassword(length int) (string, error) { alphabet := "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" result := make([]byte, length) diff --git a/backend-go/pkg/database/redis.go b/backend-go/pkg/database/redis.go index 94160f3..30b8806 100644 --- a/backend-go/pkg/database/redis.go +++ b/backend-go/pkg/database/redis.go @@ -49,7 +49,7 @@ func InitRedis(cfg *config.Config) (*redis.Client, error) { return rdb, nil } -// --- Token 存储操作(兼容 Python 版 Redis Token 管理) --- +// --- Token 存储操作 --- const ( tokenKeyPrefix = "user_token:" diff --git a/frontend/api/check_upgrade.php b/frontend/api/check_upgrade.php deleted file mode 100644 index 357ffc4..0000000 --- a/frontend/api/check_upgrade.php +++ /dev/null @@ -1,69 +0,0 @@ - '未授权']); - exit(); -} - -$role = $_SESSION['role'] ?? ''; -if ($role !== '班主任') { - echo json_encode(['needs_upgrade' => false]); - exit(); -} - -// 从 session 获取 JWT token -$token = $_SESSION['jwt_token'] ?? ''; -if (empty($token)) { - echo json_encode(['error' => '会话已过期,请重新登录']); - exit(); -} - -// 调用后端 API -$apiUrl = API_BASE_URL . '/api/upgrade/check'; - -$ch = curl_init(); -curl_setopt_array($ch, [ - CURLOPT_URL => $apiUrl, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => API_TIMEOUT, - CURLOPT_HTTPHEADER => [ - 'Authorization: Bearer ' . $token, - 'Content-Type: application/json' - ], - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2 -]); - -$apiResponse = curl_exec($ch); -$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); - -if (empty($apiResponse)) { - echo json_encode(['error' => '无法连接升级服务']); - exit(); -} - -$result = json_decode($apiResponse, true); -if (!$result) { - echo json_encode(['error' => '升级服务返回数据格式错误']); - exit(); -} - -// 后端返回非200时,尝试解析实际错误信息 -if ($httpCode !== 200 || !isset($result['success']) || !$result['success']) { - $errorMsg = $result['message'] ?? ($result['error'] ?? '升级检查失败'); - echo json_encode(['error' => $errorMsg]); - exit(); -} - -// 转发后端返回的升级数据 -$data = $result['data'] ?? []; -echo json_encode($data); diff --git a/frontend/api/execute_upgrade.php b/frontend/api/execute_upgrade.php deleted file mode 100644 index d1d26e3..0000000 --- a/frontend/api/execute_upgrade.php +++ /dev/null @@ -1,104 +0,0 @@ - false, 'error' => '未授权']); - exit(); -} - -$userType = $_SESSION['user_type']; -$role = $_SESSION['role'] ?? ''; -if ($userType === 'admin' && $role !== '班主任') { - http_response_code(403); - echo json_encode(['success' => false, 'error' => '权限不足']); - exit(); -} - -// 只接受 POST -if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(400); - echo json_encode(['success' => false, 'error' => '无效请求']); - exit(); -} - -$input = json_decode(file_get_contents('php://input'), true); -$stepVersion = $input['version'] ?? ''; -if (empty($stepVersion)) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => '缺少版本号参数']); - exit(); -} - -// 从 session 获取 JWT token -$token = $_SESSION['jwt_token'] ?? ''; -if (empty($token)) { - http_response_code(401); - echo json_encode(['success' => false, 'error' => '会话已过期,请重新登录']); - exit(); -} - -// 调用后端 API -$apiUrl = API_BASE_URL . '/api/upgrade/step'; - -$ch = curl_init(); -curl_setopt_array($ch, [ - CURLOPT_URL => $apiUrl, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode(['version' => $stepVersion]), - CURLOPT_TIMEOUT => API_TIMEOUT, - CURLOPT_HTTPHEADER => [ - 'Authorization: Bearer ' . $token, - 'Content-Type: application/json' - ], - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2 -]); - -$apiResponse = curl_exec($ch); -$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); - -if (empty($apiResponse)) { - http_response_code(500); - echo json_encode([ - 'success' => false, - 'version' => $stepVersion, - 'error' => '无法连接升级服务' - ]); - exit(); -} - -$result = json_decode($apiResponse, true); -if (!$result) { - http_response_code(500); - echo json_encode([ - 'success' => false, - 'version' => $stepVersion, - 'error' => '升级服务返回数据格式错误' - ]); - exit(); -} - -// 后端返回非200或 success=false 时,提取实际错误信息 -if ($httpCode !== 200 || !isset($result['success']) || !$result['success']) { - $errorMsg = $result['message'] ?? ($result['error'] ?? '升级失败'); - http_response_code(500); - echo json_encode([ - 'success' => false, - 'version' => $stepVersion, - 'error' => $errorMsg - ]); - exit(); -} - -// 转发后端返回的数据 -$data = $result['data'] ?? []; -echo json_encode($data); diff --git a/sql/init.sql b/sql/init.sql index 8718899..f9ade96 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -1,5 +1,5 @@ -- =========================================== --- 多班级版班级管理系统 - 数据库初始化脚本 (v2.0 Go重写版) +-- 多班级版班级管理系统 - 数据库初始化脚本 (v2.2 Go重写版) -- 数据库: classmanagerdb -- 字符集: utf8mb4 -- MySQL 兼容: 5.7+(不使用 CHECK 约束、窗口函数等 8.0+ 特性) @@ -86,7 +86,7 @@ CREATE TABLE IF NOT EXISTS `students` ( CREATE TABLE IF NOT EXISTS `users` ( `user_id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, - `password_hash` VARCHAR(255) NOT NULL, + `password_hash` VARCHAR(60) NOT NULL COMMENT 'bcrypt 哈希(60字符)', `real_name` VARCHAR(50) NOT NULL, `user_type` ENUM('student', 'parent', 'admin', 'super_admin') NOT NULL, `student_id` INT DEFAULT NULL, @@ -104,8 +104,7 @@ CREATE TABLE IF NOT EXISTS `users` ( CREATE TABLE IF NOT EXISTS `super_admins` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名', - `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(MD5(SHA1(password)+salt))', - `salt` VARCHAR(64) NOT NULL COMMENT '密码盐值', + `password_hash` VARCHAR(60) NOT NULL COMMENT 'bcrypt 哈希(60字符)', `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', `status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用', `need_change_password` TINYINT DEFAULT 1 COMMENT '是否需要修改密码:1=需要,0=不需要', @@ -411,8 +410,8 @@ INSERT IGNORE INTO `subjects` (`subject_name`, `subject_code`, `sort_order`) VAL -- 初始化系统版本号 INSERT INTO `system_settings` (`setting_key`, `setting_value`) -VALUES ('db_version', '2.0') -ON DUPLICATE KEY UPDATE `setting_value` = '2.0'; +VALUES ('db_version', '2.2') +ON DUPLICATE KEY UPDATE `setting_value` = '2.2'; -- 系统管理员独立登录路径 INSERT INTO `system_settings` (`setting_key`, `setting_value`) diff --git a/sql/upgrades/v1.0.sql b/sql/upgrades/v1.0.sql deleted file mode 100644 index 34e1036..0000000 --- a/sql/upgrades/v1.0.sql +++ /dev/null @@ -1,264 +0,0 @@ --- =========================================== --- 多班级版班级管理系统 - v1.0 升级脚本 --- 从旧版单班级系统升级到多班级版 --- --- 开发者: Canglan --- 联系方式: admin@sea-studio.top --- 版权归属: Sea Network Technology Studio --- 许可证: Apache License 2.0 --- --- 版权所有 © Sea Network Technology Studio --- --- 升级说明: --- 1. 创建班级表、班级设置表、班级功能开关表 --- 2. 将现有学生迁移到默认班级 --- 3. 扩展管理员角色枚举(科任老师、课代表) --- 4. 修改学生表字段(parent_phone → parent_account) --- 5. 为所有现有表添加 class_id 字段 --- 6. 创建系统管理员表(super_admins) --- 7. 添加班级设置预置项和功能开关预置项 --- 8. 添加系统管理员登录路径配置 --- =========================================== - -USE `classmanagerdb`; - -SET FOREIGN_KEY_CHECKS = 0; - --- =========================================== --- 1. 创建班级表 --- =========================================== -CREATE TABLE IF NOT EXISTS `classes` ( - `class_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '班级ID', - `class_name` VARCHAR(100) NOT NULL COMMENT '班级名称,如 高一(1)班', - `grade` VARCHAR(50) DEFAULT NULL COMMENT '年级', - `description` VARCHAR(255) DEFAULT NULL COMMENT '班级描述', - `status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用', - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY `uk_class_name` (`class_name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- =========================================== --- 2. 创建班级设置表 --- =========================================== -CREATE TABLE IF NOT EXISTS `class_settings` ( - `setting_id` INT PRIMARY KEY AUTO_INCREMENT, - `class_id` INT NOT NULL COMMENT '所属班级ID', - `setting_key` VARCHAR(50) NOT NULL COMMENT '设置项键名', - `setting_value` VARCHAR(255) NOT NULL COMMENT '设置项值', - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE CASCADE, - UNIQUE KEY `uk_class_setting` (`class_id`, `setting_key`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- =========================================== --- 3. 创建班级功能开关表 --- =========================================== -CREATE TABLE IF NOT EXISTS `class_features` ( - `feature_id` INT PRIMARY KEY AUTO_INCREMENT, - `class_id` INT NOT NULL COMMENT '所属班级ID', - `feature_key` VARCHAR(50) NOT NULL COMMENT '功能标识', - `enabled` TINYINT DEFAULT 1 COMMENT '是否启用:1=启用,0=禁用', - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE CASCADE, - UNIQUE KEY `uk_class_feature` (`class_id`, `feature_key`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- =========================================== --- 4. 创建系统管理员表(super_admins) --- =========================================== -CREATE TABLE IF NOT EXISTS `super_admins` ( - `id` INT PRIMARY KEY AUTO_INCREMENT, - `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名', - `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(MD5(SHA1(password)+salt))', - `salt` VARCHAR(64) NOT NULL COMMENT '密码盐值', - `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', - `status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用', - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- =========================================== --- 5. 插入默认班级(现有数据迁移) --- =========================================== -INSERT INTO `classes` (`class_name`, `grade`, `description`) -VALUES ('默认班级', NULL, '系统升级时自动创建的默认班级') -ON DUPLICATE KEY UPDATE `class_id` = `class_id`; - --- 获取默认班级ID -SET @default_class_id = (SELECT `class_id` FROM `classes` WHERE `class_name` = '默认班级' LIMIT 1); - --- =========================================== --- 6. 学生表:添加 class_id 字段 --- =========================================== -ALTER TABLE `students` - ADD COLUMN `class_id` INT NOT NULL DEFAULT 1 COMMENT '所属班级ID' AFTER `student_no`, - ADD FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT; - --- 将现有学生关联到默认班级 -UPDATE `students` SET `class_id` = @default_class_id WHERE `class_id` = 1; - --- 修改唯一约束(学号+班级联合唯一) -ALTER TABLE `students` DROP INDEX `student_no`; -ALTER TABLE `students` ADD UNIQUE KEY `uk_student_no_class` (`student_no`, `class_id`); - --- =========================================== --- 7. 学生表:parent_phone → parent_account --- =========================================== -ALTER TABLE `students` CHANGE COLUMN `parent_phone` `parent_account` VARCHAR(50) DEFAULT NULL COMMENT '家长登录账号(推荐手机号)'; - --- 更新宿舍号字段注释(格式:南0-000) -ALTER TABLE `students` MODIFY COLUMN `dormitory_number` VARCHAR(20) DEFAULT NULL COMMENT '宿舍号,格式:南0-000'; - --- =========================================== --- 8. 用户表:扩展 user_type 枚举 --- =========================================== -ALTER TABLE `users` MODIFY COLUMN `user_type` ENUM('student', 'parent', 'admin', 'super_admin') NOT NULL; - --- =========================================== --- 9. 管理员角色表:添加 class_id 和扩展枚举 --- =========================================== -ALTER TABLE `admin_roles` - ADD COLUMN `class_id` INT NOT NULL DEFAULT 1 COMMENT '所属班级ID' AFTER `user_id`, - ADD FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT; - --- 将现有管理员关联到默认班级 -UPDATE `admin_roles` SET `class_id` = @default_class_id WHERE `class_id` = 1; - --- 扩展角色枚举 -ALTER TABLE `admin_roles` MODIFY COLUMN `role_type` ENUM('班主任', '班长', '学习委员', '考勤委员', '劳动委员', '志愿委员', '科任老师', '课代表') NOT NULL; - --- 添加班级级唯一约束 -ALTER TABLE `admin_roles` ADD UNIQUE KEY `uk_user_class` (`user_id`, `class_id`); - --- =========================================== --- 10. 作业表:添加 class_id --- =========================================== -ALTER TABLE `assignments` - ADD COLUMN `class_id` INT NOT NULL DEFAULT 1 COMMENT '所属班级ID' AFTER `assignment_id`, - ADD FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT; - -UPDATE `assignments` SET `class_id` = @default_class_id WHERE `class_id` = 1; - --- =========================================== --- 11. 学期归档表:添加 class_id --- =========================================== -ALTER TABLE `semester_archives` - ADD COLUMN `class_id` INT NOT NULL DEFAULT 1 COMMENT '所属班级ID' AFTER `semester_id`, - ADD FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT; - -UPDATE `semester_archives` SET `class_id` = @default_class_id WHERE `class_id` = 1; - --- =========================================== --- 12. 操作日志表:添加 class_id --- =========================================== -ALTER TABLE `operation_logs` - ADD COLUMN `class_id` INT DEFAULT NULL COMMENT '操作所属班级ID' AFTER `operator_role`; - --- =========================================== --- 13. 添加新索引(MySQL 5.7 兼容:使用存储过程检查索引是否存在) --- =========================================== -DROP PROCEDURE IF EXISTS `add_index_if_not_exists`; -DELIMITER // -CREATE PROCEDURE `add_index_if_not_exists`( - IN p_table VARCHAR(64), - IN p_index VARCHAR(64), - IN p_columns VARCHAR(512) -) -BEGIN - DECLARE index_count INT DEFAULT 0; - SELECT COUNT(*) INTO index_count - FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND table_name = p_table - AND index_name = p_index; - IF index_count = 0 THEN - SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `', p_index, '` (', p_columns, ')'); - PREPARE stmt FROM @sql; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END // -DELIMITER ; - -CALL `add_index_if_not_exists`('students', 'idx_student_class', '`class_id`'); -CALL `add_index_if_not_exists`('admin_roles', 'idx_admin_role_class', '`class_id`'); -CALL `add_index_if_not_exists`('assignments', 'idx_assignment_class', '`class_id`'); -CALL `add_index_if_not_exists`('assignments', 'idx_assignment_subject', '`subject_id`'); -CALL `add_index_if_not_exists`('semester_archives', 'idx_archive_class', '`class_id`'); -CALL `add_index_if_not_exists`('operation_logs', 'idx_operation_class', '`class_id`'); -CALL `add_index_if_not_exists`('conduct_records', 'idx_conduct_type_semester', '`semester_id`, `related_type`, `student_id`'); -CALL `add_index_if_not_exists`('conduct_records', 'idx_conduct_semester', '`semester_id`'); -CALL `add_index_if_not_exists`('conduct_records', 'idx_conduct_student', '`student_id`'); -CALL `add_index_if_not_exists`('conduct_records', 'idx_student_created', '`student_id`, `created_at`'); -CALL `add_index_if_not_exists`('conduct_records', 'idx_recorder_id', '`recorder_id`'); -CALL `add_index_if_not_exists`('attendance_records', 'idx_attendance_semester', '`semester_id`'); -CALL `add_index_if_not_exists`('attendance_records', 'idx_date', '`date`'); -CALL `add_index_if_not_exists`('login_logs', 'idx_username_created', '`username`, `created_at`'); -CALL `add_index_if_not_exists`('operation_logs', 'idx_operator_created', '`operator_id`, `created_at`'); -CALL `add_index_if_not_exists`('semester_archives', 'idx_semester_id', '`semester_id`'); - -DROP PROCEDURE IF EXISTS `add_index_if_not_exists`; - -SET FOREIGN_KEY_CHECKS = 1; - --- =========================================== --- 14. 为默认班级插入预置班级设置 --- =========================================== -INSERT INTO `class_settings` (`class_id`, `setting_key`, `setting_value`) VALUES --- 角色加减分上下限 -(@default_class_id, 'point_limit_班主任_max', '10'), -(@default_class_id, 'point_limit_班主任_min', '-10'), -(@default_class_id, 'point_limit_班长_max', '5'), -(@default_class_id, 'point_limit_班长_min', '-5'), -(@default_class_id, 'point_limit_学习委员_max', '5'), -(@default_class_id, 'point_limit_学习委员_min', '-5'), -(@default_class_id, 'point_limit_考勤委员_max', '8'), -(@default_class_id, 'point_limit_考勤委员_min', '-8'), -(@default_class_id, 'point_limit_劳动委员_max', '1'), -(@default_class_id, 'point_limit_劳动委员_min', '-1'), -(@default_class_id, 'point_limit_志愿委员_max', '5'), -(@default_class_id, 'point_limit_志愿委员_min', '0'), -(@default_class_id, 'point_limit_科任老师_max', '5'), -(@default_class_id, 'point_limit_科任老师_min', '-5'), --- 操行分初始值 -(@default_class_id, 'initial_points', '60'), --- 扣分规则 -(@default_class_id, 'deduction_homework_not_submit', '2'), -(@default_class_id, 'deduction_homework_late', '1'), -(@default_class_id, 'deduction_attendance_absent', '3'), -(@default_class_id, 'deduction_attendance_late', '1'), -(@default_class_id, 'deduction_attendance_leave', '0'), --- 家长相关 -(@default_class_id, 'parent_password_change_enabled', 'true'), -(@default_class_id, 'parent_account_enabled', 'true') -ON DUPLICATE KEY UPDATE `setting_value` = VALUES(`setting_value`); - --- =========================================== --- 15. 为默认班级插入预置功能开关 --- =========================================== -INSERT INTO `class_features` (`class_id`, `feature_key`, `enabled`) VALUES -(@default_class_id, 'role_班长_enabled', 1), -(@default_class_id, 'role_学习委员_enabled', 1), -(@default_class_id, 'role_考勤委员_enabled', 1), -(@default_class_id, 'role_劳动委员_enabled', 1), -(@default_class_id, 'role_志愿委员_enabled', 1), -(@default_class_id, 'role_科任老师_enabled', 1), -(@default_class_id, 'role_课代表_enabled', 1) -ON DUPLICATE KEY UPDATE `enabled` = VALUES(`enabled`); - --- =========================================== --- 16. 添加系统管理员登录路径配置 --- =========================================== -INSERT INTO `system_settings` (`setting_key`, `setting_value`) -VALUES ('super_admin_login_path', '/super-admin/login') -ON DUPLICATE KEY UPDATE `setting_value` = '/super-admin/login'; - --- =========================================== --- 17. 更新系统版本号 --- =========================================== -INSERT INTO `system_settings` (`setting_key`, `setting_value`) -VALUES ('db_version', '1.0') -ON DUPLICATE KEY UPDATE `setting_value` = '1.0'; - --- 升级完成 -SELECT CONCAT('升级完成!版本: v1.0,默认班级ID: ', @default_class_id) AS message; diff --git a/sql/upgrades/v2.1.sql b/sql/upgrades/v2.1.sql deleted file mode 100644 index 5891ce8..0000000 --- a/sql/upgrades/v2.1.sql +++ /dev/null @@ -1,53 +0,0 @@ --- =========================================== --- 多班级版班级管理系统 - v2.1 升级脚本 --- 修复 review-report-v14 中发现的安全和逻辑问题 --- --- 开发者: Canglan --- 联系方式: admin@sea-studio.top --- 版权归属: Sea Network Technology Studio --- 许可证: Apache License 2.0 --- --- 版权所有 © Sea Network Technology Studio --- --- 升级说明: --- 1. 为 super_admins 表添加 need_change_password 字段 --- 2. 为 existing 超级管理员设置默认值(首次登录强制改密) --- =========================================== - -USE `classmanagerdb`; - --- 为 super_admins 表添加 need_change_password 字段 --- 已存在则跳过(使用存储过程检测) -DROP PROCEDURE IF EXISTS `add_column_if_not_exists`; -DELIMITER // -CREATE PROCEDURE `add_column_if_not_exists`( - IN p_table VARCHAR(64), - IN p_column VARCHAR(64), - IN p_definition VARCHAR(512) -) -BEGIN - DECLARE col_count INT DEFAULT 0; - SELECT COUNT(*) INTO col_count - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table - AND column_name = p_column; - IF col_count = 0 THEN - SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); - PREPARE stmt FROM @sql; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END // -DELIMITER ; - -CALL `add_column_if_not_exists`('super_admins', 'need_change_password', 'TINYINT DEFAULT 1 COMMENT \'是否需要修改密码:1=需要,0=不需要\' AFTER `status`'); - -DROP PROCEDURE IF EXISTS `add_column_if_not_exists`; - --- 更新系统版本号 -INSERT INTO `system_settings` (`setting_key`, `setting_value`) -VALUES ('db_version', '2.1') -ON DUPLICATE KEY UPDATE `setting_value` = '2.1'; - -SELECT '升级完成!版本: v2.1' AS message; diff --git a/upgrade.php b/upgrade.php deleted file mode 100644 index 79fc5c8..0000000 --- a/upgrade.php +++ /dev/null @@ -1,719 +0,0 @@ - __DIR__ . '/sql/upgrades/v1.0.sql', - '1.1' => __DIR__ . '/sql/upgrades/v1.1.sql', - '1.2' => __DIR__ . '/sql/upgrades/v1.2.sql', - '1.3' => __DIR__ . '/sql/upgrades/v1.3.sql', - '1.4' => __DIR__ . '/sql/upgrades/v1.4.sql', - '1.5' => __DIR__ . '/sql/upgrades/v1.5.sql', - '1.6' => __DIR__ . '/sql/upgrades/v1.6.sql', - '1.7' => __DIR__ . '/sql/upgrades/v1.7.sql', - '1.8' => __DIR__ . '/sql/upgrades/v1.8.sql', - '2.0' => __DIR__ . '/sql/upgrades/v2.0.sql', - '2.0.1' => __DIR__ . '/sql/upgrades/v2.0.1.sql', - '2.1' => __DIR__ . '/sql/upgrades/v2.1.sql', - '2.2' => __DIR__ . '/sql/upgrades/v2.2.sql', - '2.3' => __DIR__ . '/sql/upgrades/v2.3.sql', - '2.4' => __DIR__ . '/sql/upgrades/v2.4.sql', - '2.5' => __DIR__ . '/sql/upgrades/v2.5.sql', - '2.5.1' => __DIR__ . '/sql/upgrades/v2.5.1.sql', - '2.6' => __DIR__ . '/sql/upgrades/v2.6.sql', - '2.7' => __DIR__ . '/sql/upgrades/v2.7.sql', -]; - -/** - * 读取 backend/.env 文件并解析数据库配置 - */ -function readEnvConfig($envPath) { - if (!file_exists($envPath)) { - throw new RuntimeException('配置文件不存在: ' . $envPath); - } - - $lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - $config = []; - - foreach ($lines as $line) { - $line = trim($line); - if ($line === '' || strpos($line, '#') === 0) { - continue; - } - if (strpos($line, '=') !== false) { - list($key, $value) = explode('=', $line, 2); - $config[trim($key)] = trim($value); - } - } - - $required = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']; - foreach ($required as $key) { - if (!isset($config[$key]) || $config[$key] === '') { - throw new RuntimeException("缺少必要的数据库配置: {$key}"); - } - } - - return $config; -} - -/** - * 检测数据库当前版本 - */ -function detectCurrentVersion($pdo) { - try { - $stmt = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - return $row ? $row['setting_value'] : '0.0.0'; - } catch (PDOException $e) { - return '0.0.0'; - } -} - -/** - * 获取需要执行的升级步骤 - */ -function getUpgradeSteps($currentVersion, $targetVersion) { - global $UPGRADE_VERSIONS; - - $steps = []; - foreach ($UPGRADE_VERSIONS as $version => $sqlFile) { - if (version_compare($version, $currentVersion, '>') && - version_compare($version, $targetVersion, '<=')) { - $steps[$version] = $sqlFile; - } - } - - uksort($steps, 'version_compare'); - return $steps; -} - -/** - * 执行 SQL 内容,处理包含 DELIMITER 的存储过程脚本 - * - * DELIMITER 是 MySQL 客户端指令,MySQL 服务器不认识它, - * 必须在客户端侧解析并拆分为独立的语句后逐条执行。 - */ -function executeSqlContent($pdo, $sqlContent) { - $sqlContent = trim($sqlContent); - if ($sqlContent === '' || $sqlContent === '--') { - return; - } - - // 检查是否包含 DELIMITER 指令 - if (stripos($sqlContent, 'DELIMITER') !== false) { - $lines = explode("\n", $sqlContent); - $currentBlock = []; - $inProcedure = false; - $buffer = ''; - - foreach ($lines as $line) { - $trimmed = trim($line); - - // 跳过纯注释行(存储过程内部注释保留) - if (!$inProcedure && (strpos($trimmed, '--') === 0 || strpos($trimmed, '#') === 0)) { - continue; - } - - if (strtoupper(substr($trimmed, 0, 12)) === 'DELIMITER $$') { - // 开始存储过程定义 - $inProcedure = true; - $currentBlock = []; - continue; - } elseif (strtoupper($trimmed) === 'DELIMITER ;') { - // 执行累积的存储过程块 - if (!empty($currentBlock)) { - $procSql = trim(implode("\n", $currentBlock)); - if ($procSql !== '') { - // 移除存储过程结尾的 $$ 定界符(发送给 MySQL 服务器时不需要) - $procSql = preg_replace('/\$\$\s*$/', '', $procSql); - $pdo->exec($procSql); - } - } - $inProcedure = false; - $currentBlock = []; - continue; - } elseif (strtoupper(substr($trimmed, 0, 9)) === 'DELIMITER') { - // 其他 DELIMITER 指令,跳过 - continue; - } - - if ($inProcedure) { - $currentBlock[] = $line; - } else { - // 普通 SQL,累积直到遇到分号 - if ($trimmed !== '') { - $buffer .= ($buffer !== '' ? ' ' : '') . $trimmed; - - if (rtrim($buffer) !== '' && substr(rtrim($buffer), -1) === ';') { - $stmt = rtrim(rtrim($buffer), ';'); - $stmt = trim($stmt); - if ($stmt !== '' && $stmt !== '--') { - $pdo->exec($stmt); - } - $buffer = ''; - } - } - } - } - - // 处理缓冲区中剩余的语句 - if ($buffer !== '') { - $stmt = rtrim(rtrim($buffer), ';'); - $stmt = trim($stmt); - if ($stmt !== '' && $stmt !== '--') { - $pdo->exec($stmt); - } - } - } else { - // 无 DELIMITER,按分号+换行分割语句 - $statements = preg_split('/;\s*\n/', $sqlContent); - foreach ($statements as $stmt) { - $stmt = trim($stmt); - if ($stmt !== '' && $stmt !== '--') { - $pdo->exec($stmt); - } - } - } -} - -/** - * 验证升级结果:检查版本号是否已正确更新 - * - * @return array ['ok' => bool, 'message' => string] - */ -function verifyUpgrade($pdo, $expectedVersion) { - // 检查 system_settings 表是否存在 - try { - $check = $pdo->query("SELECT 1 FROM system_settings LIMIT 1"); - } catch (PDOException $e) { - return ['ok' => false, 'message' => 'system_settings 表不存在,升级脚本可能未正确执行']; - } - - // 检查版本号是否匹配 - $stmt = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"); - $stmt->execute(); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - if (!$row) { - return ['ok' => false, 'message' => 'db_version 记录不存在']; - } - - if ($row['setting_value'] !== $expectedVersion) { - return ['ok' => false, 'message' => "版本号不匹配:期望 {$expectedVersion},实际 {$row['setting_value']}"]; - } - - return ['ok' => true, 'message' => '验证通过']; -} - -/** - * 执行单个版本的升级 SQL(含验证与重试) - * - * @param PDO $pdo 数据库连接 - * @param string $version 目标版本号 - * @param string $sqlFile SQL 文件路径 - * @param int $maxRetries 最大重试次数 - * @throws RuntimeException 升级失败时抛出 - */ -function executeUpgrade($pdo, $version, $sqlFile, $maxRetries = 2) { - if (!file_exists($sqlFile)) { - throw new RuntimeException("SQL 文件不存在: {$sqlFile}"); - } - - $sql = file_get_contents($sqlFile); - $isEmpty = (trim($sql) === '' || trim($sql) === '--'); - - $lastError = null; - - for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { - try { - if (!$isEmpty) { - executeSqlContent($pdo, $sql); - } - - // 更新版本号(使用预处理语句防止 SQL 注入) - $stmt = $pdo->prepare( - "INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', :version) - ON DUPLICATE KEY UPDATE setting_value = :version" - ); - $stmt->execute([':version' => $version]); - - // 验证版本号是否正确写入 - $verify = verifyUpgrade($pdo, $version); - if ($verify['ok']) { - return; // 成功 - } - - // 验证失败,准备重试 - $lastError = "升级验证失败: {$verify['message']}"; - if ($attempt < $maxRetries) { - // 回滚版本号到升级前状态,以便重试 - $prevStmt = $pdo->prepare( - "UPDATE system_settings SET setting_value = :ver WHERE setting_key = 'db_version'" - ); - // 获取升级前版本(从 getUpgradeSteps 推断,这里用 0.0.0 作为安全回退) - $prevStmt->execute([':ver' => '0.0.0']); - continue; - } - } catch (PDOException $e) { - $lastError = "SQL 执行失败: " . $e->getMessage(); - if ($attempt < $maxRetries) { - continue; - } - } catch (Exception $e) { - $lastError = $e->getMessage(); - if ($attempt < $maxRetries) { - continue; - } - } - - // 所有重试均失败 - break; - } - - throw new RuntimeException("升级至 v{$version} 失败 (尝试 {$maxRetries} 次): {$lastError}"); -} - -// =========================================== -// 主逻辑 -// =========================================== - -// POST 模式:执行单个升级步骤(依次执行) -if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_GET['action'] ?? '') === 'step') { - header('Content-Type: application/json; charset=utf-8'); - - $stepVersion = $_GET['version'] ?? ''; - if (empty($stepVersion)) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => '缺少版本号参数']); - exit(); - } - - try { - $envPath = __DIR__ . '/backend/.env'; - $config = readEnvConfig($envPath); - - $dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4"; - $pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION - ]); - - // 获取该版本对应的 SQL 文件 - if (!isset($UPGRADE_VERSIONS[$stepVersion])) { - throw new RuntimeException("未知版本: {$stepVersion}"); - } - - $sqlFile = $UPGRADE_VERSIONS[$stepVersion]; - $shortFile = basename($sqlFile); - - executeUpgrade($pdo, $stepVersion, $sqlFile); - - // 重新检测当前版本 - $newVersion = detectCurrentVersion($pdo); - - echo json_encode([ - 'success' => true, - 'version' => $stepVersion, - 'message' => "升级至 v{$stepVersion} 成功 ({$shortFile})", - 'current' => $newVersion - ]); - } catch (Exception $e) { - http_response_code(500); - echo json_encode([ - 'success' => false, - 'version' => $stepVersion, - 'error' => "升级至 v{$stepVersion} 失败: " . $e->getMessage() - ]); - } - exit(); -} - -// POST 模式:执行升级(AJAX 请求) -if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_GET['action'] ?? '') === 'execute') { - header('Content-Type: application/json; charset=utf-8'); - - $upgradeLog = []; - $currentVersion = '未知'; - $targetVersion = '未知'; - - try { - $envPath = __DIR__ . '/backend/.env'; - $config = readEnvConfig($envPath); - - $dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4"; - $pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION - ]); - - $currentVersion = detectCurrentVersion($pdo); - - $versionFile = __DIR__ . '/VERSION'; - if (!file_exists($versionFile)) { - throw new RuntimeException('VERSION 文件不存在'); - } - $targetVersion = trim(file_get_contents($versionFile)); - - $upgradeSteps = getUpgradeSteps($currentVersion, $targetVersion); - - if (empty($upgradeSteps)) { - echo json_encode([ - 'success' => true, - 'current' => $currentVersion, - 'target' => $targetVersion, - 'steps' => [['version' => '', 'status' => 'uptodate', 'message' => '数据库已是最新版本,无需升级。']] - ]); - exit(); - } - - $pdo->beginTransaction(); - try { - foreach ($upgradeSteps as $version => $sqlFile) { - $shortFile = basename($sqlFile); - try { - executeUpgrade($pdo, $version, $sqlFile); - $upgradeLog[] = [ - 'version' => $version, - 'status' => 'success', - 'message' => "升级至 v{$version} 成功 ({$shortFile})" - ]; - } catch (Exception $e) { - $upgradeLog[] = [ - 'version' => $version, - 'status' => 'error', - 'message' => "升级至 v{$version} 失败 ({$shortFile}): " . $e->getMessage() - ]; - throw $e; - } - } - $pdo->commit(); - } catch (Exception $e) { - $pdo->rollBack(); - throw $e; - } - - echo json_encode([ - 'success' => true, - 'current' => $currentVersion, - 'target' => $targetVersion, - 'steps' => $upgradeLog - ]); - } catch (Exception $e) { - http_response_code(500); - echo json_encode([ - 'success' => false, - 'current' => $currentVersion, - 'target' => $targetVersion, - 'steps' => $upgradeLog, - 'error' => $e->getMessage() - ]); - } - exit(); -} - -// GET 模式:显示升级信息页面 -$currentVersion = '未知'; -$targetVersion = '未知'; -$upgradeSteps = []; -$hasError = false; -$errorMessage = ''; -$isUpToDate = false; - -try { - $envPath = __DIR__ . '/backend/.env'; - $config = readEnvConfig($envPath); - - $dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4"; - $pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION - ]); - - $currentVersion = detectCurrentVersion($pdo); - - $versionFile = __DIR__ . '/VERSION'; - if (!file_exists($versionFile)) { - throw new RuntimeException('VERSION 文件不存在: ' . $versionFile); - } - $targetVersion = trim(file_get_contents($versionFile)); - - $upgradeSteps = getUpgradeSteps($currentVersion, $targetVersion); - $isUpToDate = empty($upgradeSteps); -} catch (Exception $e) { - $hasError = true; - $errorMessage = $e->getMessage(); -} - -?> - - - - - - 系统升级 - 多班级版班级管理系统 - - - -
-
-

多班级版班级管理系统 - 数据库升级

-

自动检测版本并执行增量升级

-
-
-
- 当前数据库版本 - -
-
- 目标版本 - -
- - -
- 错误: -
- -
- 💡 解决方法:
- 1. 进入 backend/ 目录
- 2. 复制配置模板:cp .env.example .env
- 3. 编辑 .env 文件,填入实际的数据库连接信息
- 4. 刷新此页面 -
- - -
- ✓ 数据库已是最新版本,无需升级。 -
- -
待执行升级步骤
- $sqlFile): ?> -
- - 升级至 v () -
- - -
- ⚠️ 升级前请确保已备份数据库,升级过程中请勿关闭页面。 -
- -
- -
- - - -
- -
- - - - - -