refactor: 清理旧版兼容性,升级为 bcrypt 密码算法
- 密码哈希从 MD5+SHA1 升级为 bcrypt - 删除 super_admins/users 表中的 salt 字段 - 删除旧版升级文件(upgrade.php, check_upgrade, execute_upgrade, sql/upgrades/) - 删除 PASSWORD_SALT 配置项 - 清理所有'兼容 Python 版'注释 - 新项目独立,无历史包袱
This commit is contained in:
@@ -43,13 +43,6 @@ JWT_ALGORITHM=HS256
|
|||||||
JWT_EXPIRE_MINUTES=60
|
JWT_EXPIRE_MINUTES=60
|
||||||
JWT_IDLE_TIMEOUT_MINUTES=10
|
JWT_IDLE_TIMEOUT_MINUTES=10
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# 密码加密配置(与 Python 版兼容)
|
|
||||||
# 算法: MD5(SHA1(password) + SALT)
|
|
||||||
# ===========================================
|
|
||||||
|
|
||||||
PASSWORD_SALT=your-fixed-salt-string
|
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# 系统管理员配置
|
# 系统管理员配置
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.31.0
|
||||||
gorm.io/driver/mysql v1.5.7
|
gorm.io/driver/mysql v1.5.7
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ type Config struct {
|
|||||||
JWTExpireMinutes int
|
JWTExpireMinutes int
|
||||||
JWTIdleTimeoutMinutes int
|
JWTIdleTimeoutMinutes int
|
||||||
|
|
||||||
// 密码加密(兼容 Python 版)
|
|
||||||
PasswordSalt string
|
|
||||||
|
|
||||||
// 系统管理员配置
|
// 系统管理员配置
|
||||||
SuperAdminLoginPath string
|
SuperAdminLoginPath string
|
||||||
SuperAdminDefaultUser string
|
SuperAdminDefaultUser string
|
||||||
@@ -98,8 +95,6 @@ func Load() (*Config, error) {
|
|||||||
JWTExpireMinutes: getEnvInt("JWT_EXPIRE_MINUTES", 60),
|
JWTExpireMinutes: getEnvInt("JWT_EXPIRE_MINUTES", 60),
|
||||||
JWTIdleTimeoutMinutes: getEnvInt("JWT_IDLE_TIMEOUT_MINUTES", 10),
|
JWTIdleTimeoutMinutes: getEnvInt("JWT_IDLE_TIMEOUT_MINUTES", 10),
|
||||||
|
|
||||||
PasswordSalt: getEnv("PASSWORD_SALT", ""),
|
|
||||||
|
|
||||||
SuperAdminLoginPath: getEnv("SUPER_ADMIN_LOGIN_PATH", "/super-admin"),
|
SuperAdminLoginPath: getEnv("SUPER_ADMIN_LOGIN_PATH", "/super-admin"),
|
||||||
SuperAdminDefaultUser: getEnv("SUPER_ADMIN_DEFAULT_USERNAME", "admin"),
|
SuperAdminDefaultUser: getEnv("SUPER_ADMIN_DEFAULT_USERNAME", "admin"),
|
||||||
// 安全警告:默认密码仅用于首次部署初始化,上线前必须在 .env 中修改 SUPER_ADMIN_DEFAULT_PASSWORD。
|
// 安全警告:默认密码仅用于首次部署初始化,上线前必须在 .env 中修改 SUPER_ADMIN_DEFAULT_PASSWORD。
|
||||||
@@ -110,14 +105,11 @@ func Load() (*Config, error) {
|
|||||||
LogFile: getEnv("LOG_FILE", "logs/app.log"),
|
LogFile: getEnv("LOG_FILE", "logs/app.log"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验必填项
|
||||||
// 校验必填项
|
// 校验必填项
|
||||||
if cfg.JWTSecretKey == "" {
|
if cfg.JWTSecretKey == "" {
|
||||||
return nil, fmt.Errorf("配置 JWT_SECRET_KEY 不能为空")
|
return nil, fmt.Errorf("配置 JWT_SECRET_KEY 不能为空")
|
||||||
}
|
}
|
||||||
if cfg.PasswordSalt == "" {
|
|
||||||
return nil, fmt.Errorf("配置 PASSWORD_SALT 不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
AppConfig = cfg
|
AppConfig = cfg
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,14 @@ import "time"
|
|||||||
|
|
||||||
// SuperAdmin 超级管理员模型,对应 super_admins 表
|
// SuperAdmin 超级管理员模型,对应 super_admins 表
|
||||||
type SuperAdmin struct {
|
type SuperAdmin struct {
|
||||||
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||||
Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"`
|
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:"-"`
|
||||||
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"`
|
||||||
RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"`
|
Status int8 `gorm:"column:status;default:1" json:"status"`
|
||||||
Status int8 `gorm:"column:status;default:1" json:"status"`
|
NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"`
|
||||||
NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"`
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import "time"
|
|||||||
type User struct {
|
type User struct {
|
||||||
UserID int `gorm:"column:user_id;primaryKey;autoIncrement" json:"user_id"`
|
UserID int `gorm:"column:user_id;primaryKey;autoIncrement" json:"user_id"`
|
||||||
Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"`
|
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"`
|
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"`
|
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"`
|
StudentID *int `gorm:"column:student_id" json:"student_id"`
|
||||||
|
|||||||
@@ -54,20 +54,12 @@ func (r *SuperAdminRepo) Create(admin *model.SuperAdmin) (int, error) {
|
|||||||
return admin.ID, nil
|
return admin.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePassword 更新超级管理员密码
|
// UpdatePassword 更新超级管理员密码并清除强制改密标记
|
||||||
func (r *SuperAdminRepo) UpdatePassword(id int, passwordHash string) error {
|
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{}).
|
return r.db.Model(&model.SuperAdmin{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"password_hash": passwordHash,
|
"password_hash": passwordHash,
|
||||||
"salt": salt,
|
|
||||||
"need_change_password": 0,
|
"need_change_password": 0,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
@@ -98,11 +90,10 @@ func (r *SuperAdminRepo) UpdateStatus(id int, status int8) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnsureDefaultAdmin 确保默认超级管理员存在(使用 INSERT IGNORE 避免并发竞态)
|
// 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{
|
admin := model.SuperAdmin{
|
||||||
Username: username,
|
Username: username,
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: passwordHash,
|
||||||
Salt: salt,
|
|
||||||
RealName: realName,
|
RealName: realName,
|
||||||
Status: 1,
|
Status: 1,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"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/model"
|
||||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
"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/crypto"
|
||||||
@@ -100,10 +99,17 @@ func (s *AdminService) getInitialPassword(classID int) (string, error) {
|
|||||||
return pwd, nil
|
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 新增学生
|
// AddStudent 新增学生
|
||||||
func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) {
|
func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) {
|
||||||
cfg := config.AppConfig
|
|
||||||
|
|
||||||
// 校验宿舍号格式
|
// 校验宿舍号格式
|
||||||
if !validateDormitoryNumber(dormitoryNumber) {
|
if !validateDormitoryNumber(dormitoryNumber) {
|
||||||
return map[string]interface{}{"success": false, "message": "宿舍号格式不正确,应为如 东1-101 的格式"}, nil
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
passwordHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt)
|
passwordHash := hashPasswordOrPanic(defaultPassword)
|
||||||
_, err = s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID)
|
_, err = s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Sugared.Errorf("创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err)
|
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 != "" {
|
if parentAccount != nil && *parentAccount != "" {
|
||||||
exists, _ := s.userRepo.CheckUsernameExists(*parentAccount)
|
exists, _ := s.userRepo.CheckUsernameExists(*parentAccount)
|
||||||
if !exists {
|
if !exists {
|
||||||
parentHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt)
|
parentHash := hashPasswordOrPanic(defaultPassword)
|
||||||
parentRealName := fmt.Sprintf("%s家长", name)
|
parentRealName := fmt.Sprintf("%s家长", name)
|
||||||
if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil {
|
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)
|
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 层未暴露事务接口,全量事务包裹需要较大重构;
|
// 未使用数据库事务的原因:Repository 层未暴露事务接口,全量事务包裹需要较大重构;
|
||||||
// 且批量导入场景下允许部分成功是合理的业务权衡(用户可修正失败记录后重新导入)。
|
// 且批量导入场景下允许部分成功是合理的业务权衡(用户可修正失败记录后重新导入)。
|
||||||
func (s *AdminService) ImportStudents(students []map[string]interface{}, classID int) (map[string]interface{}, error) {
|
func (s *AdminService) ImportStudents(students []map[string]interface{}, classID int) (map[string]interface{}, error) {
|
||||||
cfg := config.AppConfig
|
|
||||||
successCount := 0
|
successCount := 0
|
||||||
failedCount := 0
|
failedCount := 0
|
||||||
var details []map[string]interface{}
|
var details []map[string]interface{}
|
||||||
@@ -262,7 +267,7 @@ func (s *AdminService) ImportStudents(students []map[string]interface{}, classID
|
|||||||
existingSet[studentNo] = true
|
existingSet[studentNo] = true
|
||||||
|
|
||||||
// 创建学生登录账号
|
// 创建学生登录账号
|
||||||
passwordHash := crypto.HashPassword(password, cfg.PasswordSalt)
|
passwordHash := hashPasswordOrPanic(password)
|
||||||
if _, err := s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID); err != nil {
|
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)
|
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] {
|
if parentAccount != nil && *parentAccount != "" && !usernameSet[*parentAccount] {
|
||||||
parentHash := crypto.HashPassword(password, cfg.PasswordSalt)
|
parentHash := hashPasswordOrPanic(password)
|
||||||
parentRealName := fmt.Sprintf("%s家长", name)
|
parentRealName := fmt.Sprintf("%s家长", name)
|
||||||
if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil {
|
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)
|
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 {
|
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||||
return fmt.Errorf("%s", msg)
|
return fmt.Errorf("%s", msg)
|
||||||
}
|
}
|
||||||
cfg := config.AppConfig
|
|
||||||
student, err := s.studentRepo.GetByID(studentID)
|
student, err := s.studentRepo.GetByID(studentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("学生不存在")
|
return fmt.Errorf("学生不存在")
|
||||||
@@ -356,14 +360,15 @@ func (s *AdminService) ResetStudentPassword(studentID int, newPassword string) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("学生登录账号不存在")
|
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)
|
return s.userRepo.UpdatePassword(user.UserID, passwordHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAdmin 添加管理员
|
// AddAdmin 添加管理员
|
||||||
func (s *AdminService) AddAdmin(username, realName, password, roleType string, classID int, subjectID *int) (map[string]interface{}, error) {
|
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)
|
exists, _ := s.userRepo.CheckUsernameExists(username)
|
||||||
if exists {
|
if exists {
|
||||||
return map[string]interface{}{"success": false, "message": "用户名已存在"}, nil
|
return map[string]interface{}{"success": false, "message": "用户名已存在"}, nil
|
||||||
@@ -377,7 +382,10 @@ func (s *AdminService) AddAdmin(username, realName, password, roleType string, c
|
|||||||
password = pwd
|
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)
|
userID, err := s.userRepo.CreateAdmin(username, passwordHash, realName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -436,8 +444,10 @@ func (s *AdminService) ResetAdminPassword(userID int, newPassword string) error
|
|||||||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||||
return fmt.Errorf("%s", msg)
|
return fmt.Errorf("%s", msg)
|
||||||
}
|
}
|
||||||
cfg := config.AppConfig
|
passwordHash, err := crypto.HashPassword(newPassword)
|
||||||
passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt)
|
if err != nil {
|
||||||
|
return fmt.Errorf("密码加密失败")
|
||||||
|
}
|
||||||
return s.userRepo.UpdatePassword(userID, passwordHash)
|
return s.userRepo.UpdatePassword(userID, passwordHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import (
|
|||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"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/model"
|
||||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
"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/crypto"
|
||||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
||||||
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
|
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/logger"
|
||||||
|
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthService 认证服务
|
// 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)
|
return s.tryParentLogin(username, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证密码(使用全局 PASSWORD_SALT,与 Python 版兼容。
|
// 验证密码(bcrypt)
|
||||||
// 已知设计局限:全局共享盐值,若泄露则所有普通用户密码面临风险。
|
if !crypto.VerifyPassword(password, user.PasswordHash) {
|
||||||
// 后续迁移计划:为每个用户生成独立盐值,存储在 users 表中。)
|
|
||||||
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
|
|
||||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||||
}
|
}
|
||||||
@@ -222,7 +220,7 @@ func (s *AuthService) loginAsStudent(student *model.Student, password, ip, userA
|
|||||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
|
if !crypto.VerifyPassword(password, user.PasswordHash) {
|
||||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +286,7 @@ func (s *AuthService) tryParentLogin(username, password, ip, userAgent string, c
|
|||||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
|
if !crypto.VerifyPassword(password, user.PasswordHash) {
|
||||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,8 +338,6 @@ func (s *AuthService) Logout(userID int) error {
|
|||||||
|
|
||||||
// ChangePassword 修改密码
|
// ChangePassword 修改密码
|
||||||
func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string, force bool) error {
|
func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string, force bool) error {
|
||||||
cfg := config.AppConfig
|
|
||||||
|
|
||||||
user, err := s.userRepo.GetByUserID(userID)
|
user, err := s.userRepo.GetByUserID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("用户不存在")
|
return fmt.Errorf("用户不存在")
|
||||||
@@ -349,7 +345,7 @@ func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string
|
|||||||
|
|
||||||
// 验证原密码(强制改密时跳过)
|
// 验证原密码(强制改密时跳过)
|
||||||
if !force {
|
if !force {
|
||||||
if !crypto.VerifyPassword(oldPassword, user.PasswordHash, cfg.PasswordSalt) {
|
if !crypto.VerifyPassword(oldPassword, user.PasswordHash) {
|
||||||
return fmt.Errorf("原密码错误")
|
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 {
|
if err := s.userRepo.UpdatePassword(userID, newHash); err != nil {
|
||||||
return fmt.Errorf("密码修改失败")
|
return fmt.Errorf("密码修改失败")
|
||||||
}
|
}
|
||||||
@@ -458,4 +457,3 @@ func getPasswordChangePath(userType string) string {
|
|||||||
return "/"
|
return "/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,16 +40,13 @@ func (s *SuperAdminService) EnsureDefaultAdmin() error {
|
|||||||
|
|
||||||
logger.Sugared.Warnf("⚠️ 当前使用默认超级管理员密码,部署环境请务必修改 SUPER_ADMIN_DEFAULT_PASSWORD 并重启服务")
|
logger.Sugared.Warnf("⚠️ 当前使用默认超级管理员密码,部署环境请务必修改 SUPER_ADMIN_DEFAULT_PASSWORD 并重启服务")
|
||||||
|
|
||||||
// 为超级管理员生成独立的随机 Salt
|
passwordHash, err := crypto.HashPassword(cfg.SuperAdminDefaultPass)
|
||||||
salt, err := crypto.GenerateRandomPassword(16)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("生成随机盐值失败: %w", err)
|
return fmt.Errorf("密码哈希失败: %w", err)
|
||||||
}
|
}
|
||||||
passwordHash := crypto.HashPassword(cfg.SuperAdminDefaultPass, salt)
|
|
||||||
if err := s.superAdminRepo.EnsureDefaultAdmin(
|
if err := s.superAdminRepo.EnsureDefaultAdmin(
|
||||||
cfg.SuperAdminDefaultUser,
|
cfg.SuperAdminDefaultUser,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
salt,
|
|
||||||
"系统管理员",
|
"系统管理员",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("创建默认超级管理员失败: %w", err)
|
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
|
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, "用户名或密码错误")
|
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||||||
return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil
|
return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil
|
||||||
}
|
}
|
||||||
@@ -120,7 +117,7 @@ func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword 超级管理员修改密码(操作 super_admins 表,使用独立 salt)
|
// ChangePassword 超级管理员修改密码
|
||||||
func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword string, force bool) error {
|
func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword string, force bool) error {
|
||||||
admin, err := s.superAdminRepo.GetByID(adminID)
|
admin, err := s.superAdminRepo.GetByID(adminID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +126,7 @@ func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword
|
|||||||
|
|
||||||
// 验证原密码(强制改密时跳过)
|
// 验证原密码(强制改密时跳过)
|
||||||
if !force {
|
if !force {
|
||||||
if !crypto.VerifyPassword(oldPassword, admin.PasswordHash, admin.Salt) {
|
if !crypto.VerifyPassword(oldPassword, admin.PasswordHash) {
|
||||||
return fmt.Errorf("原密码错误")
|
return fmt.Errorf("原密码错误")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,14 +136,12 @@ func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword
|
|||||||
return fmt.Errorf("%s", msg)
|
return fmt.Errorf("%s", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成新的独立 salt
|
newHash, err := crypto.HashPassword(newPassword)
|
||||||
newSalt, err := crypto.GenerateRandomPassword(16)
|
|
||||||
if err != nil {
|
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("密码修改失败")
|
return fmt.Errorf("密码修改失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,41 +12,29 @@
|
|||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HashPassword 密码哈希(与 Python 版完全兼容)
|
// HashPassword 使用 bcrypt 对密码进行哈希
|
||||||
// 算法: MD5(SHA1(password) + salt)
|
// bcrypt 自带盐值管理,无需外部 salt
|
||||||
// Python 参考: backend/utils/security.py -> sha1_md5_password()
|
func HashPassword(password string) (string, error) {
|
||||||
// 已知弱算法:MD5 和 SHA1 均不适合密码哈希场景,保留此实现仅为兼容 Python 版数据。
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
// 后续迁移计划:迁移到 bcrypt/scrypt/argon2,并提供兼容层逐步过渡。
|
if err != nil {
|
||||||
func HashPassword(password string, salt string) string {
|
return "", fmt.Errorf("密码哈希失败: %w", err)
|
||||||
// 第一层: SHA1(password)
|
}
|
||||||
sha1Hash := sha1.Sum([]byte(password))
|
return string(hash), nil
|
||||||
sha1Hex := hex.EncodeToString(sha1Hash[:])
|
|
||||||
|
|
||||||
// 加盐: SHA1_hex + salt
|
|
||||||
salted := sha1Hex + salt
|
|
||||||
|
|
||||||
// 第二层: MD5(salted)
|
|
||||||
md5Hash := md5.Sum([]byte(salted))
|
|
||||||
return hex.EncodeToString(md5Hash[:])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPassword 验证密码(使用常量时间比较,防止时序攻击)
|
// VerifyPassword 验证密码是否与 bcrypt 哈希匹配
|
||||||
func VerifyPassword(plainPassword, hashedPassword, salt string) bool {
|
func VerifyPassword(password, hash string) bool {
|
||||||
computed := HashPassword(plainPassword, salt)
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
return subtle.ConstantTimeCompare([]byte(computed), []byte(hashedPassword)) == 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateRandomPassword 生成随机密码
|
// GenerateRandomPassword 生成随机密码
|
||||||
// 与 Python 版 SecurityUtils.generate_random_password() 兼容
|
|
||||||
func GenerateRandomPassword(length int) (string, error) {
|
func GenerateRandomPassword(length int) (string, error) {
|
||||||
alphabet := "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
alphabet := "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
result := make([]byte, length)
|
result := make([]byte, length)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func InitRedis(cfg *config.Config) (*redis.Client, error) {
|
|||||||
return rdb, nil
|
return rdb, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Token 存储操作(兼容 Python 版 Redis Token 管理) ---
|
// --- Token 存储操作 ---
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tokenKeyPrefix = "user_token:"
|
tokenKeyPrefix = "user_token:"
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* 检查数据库版本是否需要升级(代理至后端 API)
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../config.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
|
|
||||||
// 只有班主任才能检查升级
|
|
||||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
|
||||||
echo json_encode(['error' => '未授权']);
|
|
||||||
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);
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* 执行单个升级步骤(代理至后端 API)
|
|
||||||
*/
|
|
||||||
require_once __DIR__ . '/../config.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
|
|
||||||
// 验证登录和权限(admin 班主任 或 super_admin)
|
|
||||||
if (!isset($_SESSION['user_id']) || !in_array($_SESSION['user_type'], ['admin', 'super_admin'])) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(['success' => 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);
|
|
||||||
11
sql/init.sql
11
sql/init.sql
@@ -1,5 +1,5 @@
|
|||||||
-- ===========================================
|
-- ===========================================
|
||||||
-- 多班级版班级管理系统 - 数据库初始化脚本 (v2.0 Go重写版)
|
-- 多班级版班级管理系统 - 数据库初始化脚本 (v2.2 Go重写版)
|
||||||
-- 数据库: classmanagerdb
|
-- 数据库: classmanagerdb
|
||||||
-- 字符集: utf8mb4
|
-- 字符集: utf8mb4
|
||||||
-- MySQL 兼容: 5.7+(不使用 CHECK 约束、窗口函数等 8.0+ 特性)
|
-- MySQL 兼容: 5.7+(不使用 CHECK 约束、窗口函数等 8.0+ 特性)
|
||||||
@@ -86,7 +86,7 @@ CREATE TABLE IF NOT EXISTS `students` (
|
|||||||
CREATE TABLE IF NOT EXISTS `users` (
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
`user_id` INT PRIMARY KEY AUTO_INCREMENT,
|
`user_id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`username` VARCHAR(50) NOT NULL UNIQUE,
|
`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,
|
`real_name` VARCHAR(50) NOT NULL,
|
||||||
`user_type` ENUM('student', 'parent', 'admin', 'super_admin') NOT NULL,
|
`user_type` ENUM('student', 'parent', 'admin', 'super_admin') NOT NULL,
|
||||||
`student_id` INT DEFAULT NULL,
|
`student_id` INT DEFAULT NULL,
|
||||||
@@ -104,8 +104,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
|||||||
CREATE TABLE IF NOT EXISTS `super_admins` (
|
CREATE TABLE IF NOT EXISTS `super_admins` (
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名',
|
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名',
|
||||||
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(MD5(SHA1(password)+salt))',
|
`password_hash` VARCHAR(60) NOT NULL COMMENT 'bcrypt 哈希(60字符)',
|
||||||
`salt` VARCHAR(64) NOT NULL COMMENT '密码盐值',
|
|
||||||
`real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名',
|
`real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名',
|
||||||
`status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用',
|
`status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用',
|
||||||
`need_change_password` 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`)
|
INSERT INTO `system_settings` (`setting_key`, `setting_value`)
|
||||||
VALUES ('db_version', '2.0')
|
VALUES ('db_version', '2.2')
|
||||||
ON DUPLICATE KEY UPDATE `setting_value` = '2.0';
|
ON DUPLICATE KEY UPDATE `setting_value` = '2.2';
|
||||||
|
|
||||||
-- 系统管理员独立登录路径
|
-- 系统管理员独立登录路径
|
||||||
INSERT INTO `system_settings` (`setting_key`, `setting_value`)
|
INSERT INTO `system_settings` (`setting_key`, `setting_value`)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
719
upgrade.php
719
upgrade.php
@@ -1,719 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* 多班级版班级管理系统 - 自动升级脚本
|
|
||||||
*
|
|
||||||
* 读取 VERSION 文件确定目标版本,自动检测数据库当前版本,
|
|
||||||
* 依次执行增量 SQL 升级脚本。
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ===========================================
|
|
||||||
// 辅助函数
|
|
||||||
// ===========================================
|
|
||||||
|
|
||||||
// 版本升级列表(唯一数据源)
|
|
||||||
$UPGRADE_VERSIONS = [
|
|
||||||
'1.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();
|
|
||||||
}
|
|
||||||
|
|
||||||
?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>系统升级 - 多班级版班级管理系统</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
background: #f5f7fa;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 40px auto;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: #fff;
|
|
||||||
padding: 24px 32px;
|
|
||||||
}
|
|
||||||
.header h1 { font-size: 20px; font-weight: 600; }
|
|
||||||
.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }
|
|
||||||
.content { padding: 24px 32px; }
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.info-row:last-child { border-bottom: none; }
|
|
||||||
.info-label { color: #888; }
|
|
||||||
.info-value { font-weight: 600; }
|
|
||||||
.section-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 20px 0 12px;
|
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
.step {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 10px 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-left: 3px solid #ddd;
|
|
||||||
}
|
|
||||||
.step.success { border-left-color: #52c41a; background: #f6ffed; }
|
|
||||||
.step.error { border-left-color: #ff4d4f; background: #fff2f0; }
|
|
||||||
.step.pending { border-left-color: #faad14; background: #fffbe6; }
|
|
||||||
.step-icon { margin-right: 10px; font-size: 16px; flex-shrink: 0; }
|
|
||||||
.step.success .step-icon { color: #52c41a; }
|
|
||||||
.step.error .step-icon { color: #ff4d4f; }
|
|
||||||
.step.pending .step-icon { color: #faad14; }
|
|
||||||
.step-message { word-break: break-all; }
|
|
||||||
.error-box {
|
|
||||||
background: #fff2f0;
|
|
||||||
border: 1px solid #ffccc7;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
color: #cf1322;
|
|
||||||
font-size: 13px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.warning-box {
|
|
||||||
background: #fffbe6;
|
|
||||||
border: 1px solid #ffe58f;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
color: #ad6800;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.btn-upgrade {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 32px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 20px;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
.btn-upgrade:hover { opacity: 0.9; }
|
|
||||||
.btn-upgrade:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.btn-upgrade.loading {
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
.action-area {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.result-area {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
padding: 16px 32px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.success-box {
|
|
||||||
background: #f6ffed;
|
|
||||||
border: 1px solid #b7eb8f;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: #389e0d;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>多班级版班级管理系统 - 数据库升级</h1>
|
|
||||||
<p>自动检测版本并执行增量升级</p>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">当前数据库版本</span>
|
|
||||||
<span class="info-value" id="currentVersion"><?php echo htmlspecialchars($currentVersion); ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">目标版本</span>
|
|
||||||
<span class="info-value" id="targetVersion"><?php echo htmlspecialchars($targetVersion); ?></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ($hasError): ?>
|
|
||||||
<div class="error-box">
|
|
||||||
<strong>错误:</strong><?php echo htmlspecialchars($errorMessage); ?>
|
|
||||||
</div>
|
|
||||||
<?php if ($hasError && strpos($errorMessage, '配置文件不存在') !== false): ?>
|
|
||||||
<div class="warning-box">
|
|
||||||
<strong>💡 解决方法:</strong><br>
|
|
||||||
1. 进入 <code>backend/</code> 目录<br>
|
|
||||||
2. 复制配置模板:<code>cp .env.example .env</code><br>
|
|
||||||
3. 编辑 <code>.env</code> 文件,填入实际的数据库连接信息<br>
|
|
||||||
4. 刷新此页面
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php elseif ($isUpToDate): ?>
|
|
||||||
<div class="success-box">
|
|
||||||
✓ 数据库已是最新版本,无需升级。
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="section-title">待执行升级步骤</div>
|
|
||||||
<?php foreach ($upgradeSteps as $version => $sqlFile): ?>
|
|
||||||
<div class="step pending" id="step-<?php echo htmlspecialchars($version); ?>">
|
|
||||||
<span class="step-icon">○</span>
|
|
||||||
<span class="step-message">升级至 v<?php echo htmlspecialchars($version); ?> (<?php echo htmlspecialchars(basename($sqlFile)); ?>)</span>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<div class="warning-box">
|
|
||||||
⚠️ 升级前请确保已备份数据库,升级过程中请勿关闭页面。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-area">
|
|
||||||
<button class="btn-upgrade" id="btnUpgrade" onclick="executeUpgrade()">立即升级</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-area" id="resultArea" style="display:none;"></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
多班级版班级管理系统 v<?php echo htmlspecialchars($targetVersion); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (!$hasError && !$isUpToDate): ?>
|
|
||||||
<script>
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (typeof str !== 'string') return '';
|
|
||||||
var div = document.createElement('div');
|
|
||||||
div.appendChild(document.createTextNode(str));
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeUpgrade() {
|
|
||||||
var btn = document.getElementById('btnUpgrade');
|
|
||||||
var resultArea = document.getElementById('resultArea');
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.classList.add('loading');
|
|
||||||
btn.textContent = '升级中...';
|
|
||||||
resultArea.style.display = 'none';
|
|
||||||
|
|
||||||
// 收集所有待执行步骤
|
|
||||||
var steps = [];
|
|
||||||
<?php foreach ($upgradeSteps as $version => $sqlFile): ?>
|
|
||||||
steps.push('<?php echo htmlspecialchars($version); ?>');
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
var currentIndex = 0;
|
|
||||||
|
|
||||||
function executeNextStep() {
|
|
||||||
if (currentIndex >= steps.length) {
|
|
||||||
btn.classList.remove('loading');
|
|
||||||
btn.textContent = '升级完成';
|
|
||||||
resultArea.style.display = 'block';
|
|
||||||
resultArea.innerHTML = '<div class="success-box">✓ 升级成功!数据库已更新至最新版本<br><br><small style="color:#888">建议升级完成后删除 upgrade.php 文件</small></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var version = steps[currentIndex];
|
|
||||||
fetch('?action=step&version=' + encodeURIComponent(version), { method: 'POST' })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
var el = document.getElementById('step-' + version);
|
|
||||||
if (el) {
|
|
||||||
el.className = 'step ' + (data.success ? 'success' : 'error');
|
|
||||||
el.querySelector('.step-icon').textContent = data.success ? '✓' : '✗';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
currentIndex++;
|
|
||||||
executeNextStep();
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('loading');
|
|
||||||
btn.textContent = '升级失败';
|
|
||||||
btn.disabled = false;
|
|
||||||
resultArea.style.display = 'block';
|
|
||||||
resultArea.innerHTML = '<div class="error-box"><strong>升级失败:</strong>' + escapeHtml(data.error || '未知错误') + '</div>';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
var el = document.getElementById('step-' + version);
|
|
||||||
if (el) {
|
|
||||||
el.className = 'step error';
|
|
||||||
el.querySelector('.step-icon').textContent = '✗';
|
|
||||||
}
|
|
||||||
btn.classList.remove('loading');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '立即升级';
|
|
||||||
resultArea.style.display = 'block';
|
|
||||||
resultArea.innerHTML = '<div class="error-box"><strong>请求失败:</strong>' + escapeHtml(err.message) + '</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
executeNextStep();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<?php endif; ?>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user