mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2025-12-06 14:59:18 +08:00
1109 lines
34 KiB
Go
1109 lines
34 KiB
Go
package wafdb
|
||
|
||
import (
|
||
"SamWaf/common/uuid"
|
||
"SamWaf/common/zlog"
|
||
"SamWaf/customtype"
|
||
"SamWaf/global"
|
||
"SamWaf/innerbean"
|
||
"SamWaf/model"
|
||
"SamWaf/model/baseorm"
|
||
"SamWaf/utils"
|
||
"bufio"
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm/logger"
|
||
|
||
//"github.com/kangarooxin/gorm-webplugin-crypto"
|
||
//"github.com/kangarooxin/gorm-webplugin-crypto/strategy"
|
||
gowxsqlite3 "github.com/samwafgo/go-wxsqlite3"
|
||
sqlite "github.com/samwafgo/sqlitedriver"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func InitCoreDb(currentDir string) (bool, error) {
|
||
if currentDir == "" {
|
||
currentDir = utils.GetCurrentDir()
|
||
}
|
||
// 判断备份目录是否存在,不存在则创建
|
||
if _, err := os.Stat(currentDir + "/data/"); os.IsNotExist(err) {
|
||
if err := os.MkdirAll(currentDir+"/data/", os.ModePerm); err != nil {
|
||
zlog.Error("创建data目录失败:", err)
|
||
return false, err
|
||
}
|
||
}
|
||
if global.GWAF_LOCAL_DB == nil {
|
||
path := currentDir + "/data/local.db"
|
||
// 检查数据库文件是否存在
|
||
isNewDb := false
|
||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||
isNewDb = true
|
||
zlog.Debug("本地主数据库文件不存在,将创建新数据库")
|
||
}
|
||
// 检查文件是否存在
|
||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||
// 文件存在的逻辑,使用工具函数进行备份
|
||
backupDir := currentDir + "/data/backups"
|
||
_, err := utils.BackupFile(path, backupDir, "local_backup", 10)
|
||
if err != nil {
|
||
zlog.Error("备份数据库文件失败:", err)
|
||
}
|
||
}
|
||
|
||
key := url.QueryEscape(global.GWAF_PWD_COREDB)
|
||
dns := fmt.Sprintf("%s?_db_key=%s", path, key)
|
||
db, err := gorm.Open(sqlite.Open(dns), &gorm.Config{})
|
||
if err != nil {
|
||
panic("failed to connect database")
|
||
}
|
||
// 启用 WAL 模式
|
||
_ = db.Exec("PRAGMA journal_mode=WAL;")
|
||
|
||
// 创建自定义日志记录器
|
||
gormLogger := NewGormZLogger()
|
||
if global.GWAF_LOG_DEBUG_DB_ENABLE == true {
|
||
gormLogger = gormLogger.LogMode(logger.Info).(*GormZLogger)
|
||
// 启用调试模式
|
||
db = db.Session(&gorm.Session{
|
||
Logger: gormLogger,
|
||
})
|
||
}
|
||
global.GWAF_LOCAL_DB = db
|
||
s, err := db.DB()
|
||
s.Ping()
|
||
//db.Use(crypto.NewCryptoPlugin())
|
||
// 注册默认的AES加解密策略
|
||
//crypto.RegisterCryptoStrategy(strategy.NewAesCryptoStrategy("3Y)(27EtO^tK8Bj~"))
|
||
|
||
// ============ 使用 gormigrate 替代 AutoMigrate(完全向后兼容) ============
|
||
zlog.Info("开始执行core数据库迁移...")
|
||
if err := RunCoreDBMigrations(db); err != nil {
|
||
// 记录详细的错误信息
|
||
errStr := fmt.Sprintf("%v", err)
|
||
zlog.Error("core数据库迁移失败", "error_string", errStr, "error_type", fmt.Sprintf("%T", err))
|
||
zlog.Error("core数据库迁移失败详细信息: " + errStr)
|
||
panic("core database migration failed: " + errStr)
|
||
}
|
||
// ============ 迁移代码结束 ============
|
||
|
||
global.GWAF_LOCAL_DB.Callback().Query().Before("gorm:query").Register("tenant_plugin:before_query", before_query)
|
||
global.GWAF_LOCAL_DB.Callback().Query().Before("gorm:update").Register("tenant_plugin:before_update", before_update)
|
||
|
||
//重启需要删除无效规则
|
||
db.Where("user_code = ? and rule_status = 999", global.GWAF_USER_CODE).Delete(model.Rules{})
|
||
|
||
// 执行数据补丁和默认值初始化(幂等操作,每次启动都执行)
|
||
pathCoreSql(db)
|
||
return isNewDb, nil
|
||
} else {
|
||
return false, nil
|
||
}
|
||
}
|
||
|
||
func InitLogDb(currentDir string) (bool, error) {
|
||
if currentDir == "" {
|
||
currentDir = utils.GetCurrentDir()
|
||
}
|
||
if global.GWAF_LOCAL_LOG_DB == nil {
|
||
path := currentDir + "/data/local_log.db"
|
||
|
||
// 检查数据库文件是否存在
|
||
isNewDb := false
|
||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||
isNewDb = true
|
||
zlog.Debug("本地日志数据库文件不存在,将创建新数据库")
|
||
}
|
||
|
||
key := url.QueryEscape(global.GWAF_PWD_LOGDB)
|
||
dns := fmt.Sprintf("%s?_db_key=%s", path, key)
|
||
db, err := gorm.Open(sqlite.Open(dns), &gorm.Config{})
|
||
if err != nil {
|
||
panic("failed to connect database")
|
||
}
|
||
// 启用 WAL 模式
|
||
_ = db.Exec("PRAGMA journal_mode=WAL;")
|
||
// 创建自定义日志记录器
|
||
gormLogger := NewGormZLogger()
|
||
if global.GWAF_LOG_DEBUG_DB_ENABLE == true {
|
||
gormLogger = gormLogger.LogMode(logger.Info).(*GormZLogger)
|
||
// 启用调试模式
|
||
db = db.Session(&gorm.Session{
|
||
Logger: logger.Default.LogMode(logger.Info), // 设置为Info表示启用调试模式
|
||
})
|
||
}
|
||
global.GWAF_LOCAL_LOG_DB = db
|
||
//logDB.Use(crypto.NewCryptoPlugin())
|
||
// 注册默认的AES加解密策略
|
||
//crypto.RegisterCryptoStrategy(strategy.NewAesCryptoStrategy("3Y)(27EtO^tK8Bj~"))
|
||
|
||
// ============ 使用 gormigrate 替代 AutoMigrate(完全向后兼容) ============
|
||
zlog.Info("开始执行log数据库迁移...")
|
||
if err := RunLogDBMigrations(db); err != nil {
|
||
errStr := fmt.Sprintf("%v", err)
|
||
zlog.Error("log数据库迁移失败", "error_string", errStr, "error_type", fmt.Sprintf("%T", err))
|
||
zlog.Error("log数据库迁移失败详细信息: " + errStr)
|
||
panic("log database migration failed: " + errStr)
|
||
}
|
||
// ============ 迁移代码结束 ============
|
||
|
||
global.GWAF_LOCAL_LOG_DB.Callback().Query().Before("gorm:query").Register("tenant_plugin:before_query", before_query)
|
||
global.GWAF_LOCAL_LOG_DB.Callback().Query().Before("gorm:update").Register("tenant_plugin:before_update", before_update)
|
||
|
||
pathLogSql(db)
|
||
var total int64 = 0
|
||
global.GWAF_LOCAL_DB.Model(&model.ShareDb{}).Count(&total)
|
||
if total == 0 {
|
||
|
||
var logtotal int64 = 0
|
||
global.GWAF_LOCAL_LOG_DB.Model(&innerbean.WebLog{}).Count(&logtotal)
|
||
|
||
sharDbBean := model.ShareDb{
|
||
BaseOrm: baseorm.BaseOrm{
|
||
Id: uuid.GenUUID(),
|
||
USER_CODE: global.GWAF_USER_CODE,
|
||
Tenant_ID: global.GWAF_TENANT_ID,
|
||
CREATE_TIME: customtype.JsonTime(time.Now()),
|
||
UPDATE_TIME: customtype.JsonTime(time.Now()),
|
||
},
|
||
DbLogicType: "log",
|
||
StartTime: customtype.JsonTime(time.Now()),
|
||
EndTime: customtype.JsonTime(time.Now()),
|
||
FileName: "local_log.db",
|
||
Cnt: logtotal,
|
||
}
|
||
global.GWAF_LOCAL_DB.Create(sharDbBean)
|
||
}
|
||
|
||
return isNewDb, nil
|
||
} else {
|
||
return false, nil
|
||
}
|
||
}
|
||
|
||
// 手工切换日志数据源
|
||
func InitManaulLogDb(currentDir string, custFileName string) {
|
||
if currentDir == "" {
|
||
currentDir = utils.GetCurrentDir()
|
||
}
|
||
if global.GDATA_CURRENT_LOG_DB_MAP[custFileName] == nil {
|
||
zlog.Debug("初始化自定义的库", custFileName)
|
||
path := currentDir + "/data/" + custFileName
|
||
key := url.QueryEscape(global.GWAF_PWD_LOGDB)
|
||
dns := fmt.Sprintf("%s?_db_key=%s", path, key)
|
||
db, err := gorm.Open(sqlite.Open(dns), &gorm.Config{})
|
||
if err != nil {
|
||
panic("failed to connect database")
|
||
}
|
||
// 启用 WAL 模式
|
||
_ = db.Exec("PRAGMA journal_mode=WAL;")
|
||
// 创建自定义日志记录器
|
||
gormLogger := NewGormZLogger()
|
||
if global.GWAF_LOG_DEBUG_DB_ENABLE == true {
|
||
gormLogger = gormLogger.LogMode(logger.Info).(*GormZLogger)
|
||
// 启用调试模式
|
||
db = db.Session(&gorm.Session{
|
||
Logger: logger.Default.LogMode(logger.Info), // 设置为Info表示启用调试模式
|
||
})
|
||
}
|
||
global.GDATA_CURRENT_LOG_DB_MAP[custFileName] = db
|
||
//logDB.Use(crypto.NewCryptoPlugin())
|
||
// 注册默认的AES加解密策略
|
||
//crypto.RegisterCryptoStrategy(strategy.NewAesCryptoStrategy("3Y)(27EtO^tK8Bj~"))
|
||
|
||
// ============ 使用 gormigrate 替代 AutoMigrate(完全向后兼容) ============
|
||
zlog.Info("开始执行手动log数据库迁移...", "file", custFileName)
|
||
if err := RunLogDBMigrations(db); err != nil {
|
||
errStr := fmt.Sprintf("%v", err)
|
||
zlog.Error("手动log数据库迁移失败", "file", custFileName, "error_string", errStr, "error_type", fmt.Sprintf("%T", err))
|
||
zlog.Error("手动log数据库迁移失败详细信息: " + errStr)
|
||
panic("manual log database migration failed: " + errStr)
|
||
}
|
||
// ============ 迁移代码结束 ============
|
||
|
||
global.GDATA_CURRENT_LOG_DB_MAP[custFileName].Callback().Query().Before("gorm:query").Register("tenant_plugin:before_query", before_query)
|
||
global.GDATA_CURRENT_LOG_DB_MAP[custFileName].Callback().Query().Before("gorm:update").Register("tenant_plugin:before_update", before_update)
|
||
|
||
} else {
|
||
zlog.Debug("自定义的库已存在", custFileName)
|
||
}
|
||
}
|
||
|
||
func InitStatsDb(currentDir string) (bool, error) {
|
||
if currentDir == "" {
|
||
currentDir = utils.GetCurrentDir()
|
||
}
|
||
if global.GWAF_LOCAL_STATS_DB == nil {
|
||
path := currentDir + "/data/local_stats.db"
|
||
// 检查数据库文件是否存在
|
||
isNewDb := false
|
||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||
isNewDb = true
|
||
zlog.Debug("本地统计数据库文件不存在,将创建新数据库")
|
||
}
|
||
key := url.QueryEscape(global.GWAF_PWD_STATDB)
|
||
dns := fmt.Sprintf("%s?_db_key=%s", path, key)
|
||
db, err := gorm.Open(sqlite.Open(dns), &gorm.Config{})
|
||
if err != nil {
|
||
panic("failed to connect database")
|
||
}
|
||
// 启用 WAL 模式
|
||
_ = db.Exec("PRAGMA journal_mode=WAL;")
|
||
// 创建自定义日志记录器
|
||
gormLogger := NewGormZLogger()
|
||
if global.GWAF_LOG_DEBUG_DB_ENABLE == true {
|
||
gormLogger = gormLogger.LogMode(logger.Info).(*GormZLogger)
|
||
// 启用调试模式
|
||
db = db.Session(&gorm.Session{
|
||
Logger: logger.Default.LogMode(logger.Info), // 设置为Info表示启用调试模式
|
||
})
|
||
}
|
||
global.GWAF_LOCAL_STATS_DB = db
|
||
//db.Use(crypto.NewCryptoPlugin())
|
||
// 注册默认的AES加解密策略
|
||
//crypto.RegisterCryptoStrategy(strategy.NewAesCryptoStrategy("3Y)(27EtO^tK8Bj~"))
|
||
|
||
// ============ 使用 gormigrate 替代 AutoMigrate(完全向后兼容) ============
|
||
zlog.Info("开始执行stats数据库迁移...")
|
||
if err := RunStatsDBMigrations(db); err != nil {
|
||
errStr := fmt.Sprintf("%v", err)
|
||
zlog.Error("stats数据库迁移失败", "error_string", errStr, "error_type", fmt.Sprintf("%T", err))
|
||
zlog.Error("stats数据库迁移失败详细信息: " + errStr)
|
||
panic("stats database migration failed: " + errStr)
|
||
}
|
||
// ============ 迁移代码结束 ============
|
||
|
||
global.GWAF_LOCAL_STATS_DB.Callback().Query().Before("gorm:query").Register("tenant_plugin:before_query", before_query)
|
||
global.GWAF_LOCAL_STATS_DB.Callback().Query().Before("gorm:update").Register("tenant_plugin:before_update", before_update)
|
||
|
||
pathStatsSql(db)
|
||
|
||
return isNewDb, nil
|
||
} else {
|
||
return false, nil
|
||
}
|
||
}
|
||
|
||
func before_query(db *gorm.DB) {
|
||
db.Where("tenant_id = ? and user_code=? ", global.GWAF_TENANT_ID, global.GWAF_USER_CODE)
|
||
}
|
||
func before_update(db *gorm.DB) {
|
||
}
|
||
|
||
// 在线备份
|
||
func BackupDatabase(db *gorm.DB, backupFile string) error {
|
||
// 获取底层的 sql.DB 对象
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 获取源数据库的连接
|
||
srcConn, err := sqlDB.Conn(context.Background())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer srcConn.Close()
|
||
|
||
// 获取底层的 SQLiteConn 对象
|
||
var srcSQLiteConn *gowxsqlite3.SQLiteConn
|
||
err = srcConn.Raw(func(driverConn interface{}) error {
|
||
// 将 driverConn 转换为 *wxsqlite3.SQLiteConn
|
||
sqliteConn, ok := driverConn.(*gowxsqlite3.SQLiteConn)
|
||
if !ok {
|
||
return fmt.Errorf("not a SQLite connection")
|
||
}
|
||
srcSQLiteConn = sqliteConn
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 打开目标数据库连接
|
||
destConn, err := sql.Open("sqlite3", backupFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer destConn.Close()
|
||
|
||
// 获取目标数据库的连接
|
||
destSqlConn, err := destConn.Conn(context.Background())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer destSqlConn.Close()
|
||
|
||
// 获取目标数据库的 SQLiteConn 对象
|
||
var destSQLiteConn *gowxsqlite3.SQLiteConn
|
||
err = destSqlConn.Raw(func(driverConn interface{}) error {
|
||
// 将 driverConn 转换为 *wxsqlite3.SQLiteConn
|
||
sqliteConn, ok := driverConn.(*gowxsqlite3.SQLiteConn)
|
||
if !ok {
|
||
return fmt.Errorf("not a SQLite connection")
|
||
}
|
||
destSQLiteConn = sqliteConn
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 执行备份
|
||
backup, err := destSQLiteConn.Backup("main", srcSQLiteConn, "main")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer backup.Finish()
|
||
|
||
// 执行备份步骤 (-1 代表全部备份)
|
||
for {
|
||
b, stepErr := backup.Step(-1) // 备份指定多个页面 -1 是所有
|
||
if b == false {
|
||
zlog.Debug("backup fail", stepErr)
|
||
if stepErr != nil {
|
||
return stepErr
|
||
}
|
||
} else {
|
||
break
|
||
}
|
||
|
||
}
|
||
|
||
fmt.Println("Backup completed successfully")
|
||
return nil
|
||
}
|
||
|
||
// cleanupOldBackups 清理旧的备份文件,只保留最新的n个
|
||
func cleanupOldBackups(backupDir string, keepCount int) {
|
||
// 获取备份目录中的所有文件
|
||
files, err := os.ReadDir(backupDir)
|
||
if err != nil {
|
||
zlog.Error("读取备份目录失败:", err)
|
||
return
|
||
}
|
||
|
||
// 筛选出数据库备份文件
|
||
var backupFiles []os.DirEntry
|
||
for _, file := range files {
|
||
if !file.IsDir() && strings.HasPrefix(file.Name(), "local_backup_") && filepath.Ext(file.Name()) == ".db" {
|
||
backupFiles = append(backupFiles, file)
|
||
}
|
||
}
|
||
|
||
// 如果备份文件数量不超过保留数量,则不需要删除
|
||
if len(backupFiles) <= keepCount {
|
||
return
|
||
}
|
||
|
||
// 按文件修改时间排序(从旧到新)
|
||
sort.Slice(backupFiles, func(i, j int) bool {
|
||
infoI, err := backupFiles[i].Info()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
infoJ, err := backupFiles[j].Info()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return infoI.ModTime().Before(infoJ.ModTime())
|
||
})
|
||
|
||
// 删除多余的旧文件
|
||
for i := 0; i < len(backupFiles)-keepCount; i++ {
|
||
filePath := filepath.Join(backupDir, backupFiles[i].Name())
|
||
err := os.Remove(filePath)
|
||
if err != nil {
|
||
zlog.Error("删除旧备份文件失败:", err, filePath)
|
||
} else {
|
||
zlog.Info("已删除旧备份文件:", filePath)
|
||
}
|
||
}
|
||
}
|
||
|
||
// RepairDatabase 修复损坏的SQLite数据库
|
||
// dbPath: 数据库文件路径
|
||
// password: 数据库密码(如果有加密)
|
||
func RepairDatabase(dbPath string, password string) error {
|
||
zlog.Info("========================================")
|
||
zlog.Info("开始修复数据库:", dbPath)
|
||
zlog.Info("========================================")
|
||
|
||
// 检查数据库文件是否存在
|
||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||
return fmt.Errorf("数据库文件不存在: %s", dbPath)
|
||
}
|
||
|
||
// 创建备份文件名
|
||
backupPath := dbPath + ".backup_before_repair_" + time.Now().Format("20060102150405")
|
||
|
||
// 1. 先备份原数据库
|
||
zlog.Info("步骤1: 备份原数据库...")
|
||
input, err := os.ReadFile(dbPath)
|
||
if err != nil {
|
||
return fmt.Errorf("读取数据库文件失败: %w", err)
|
||
}
|
||
if err := os.WriteFile(backupPath, input, 0644); err != nil {
|
||
return fmt.Errorf("创建备份失败: %w", err)
|
||
}
|
||
zlog.Info("✓ 备份成功:", backupPath)
|
||
|
||
// 2. 打开数据库进行检查
|
||
zlog.Info("步骤2: 检查数据库完整性...")
|
||
key := url.QueryEscape(password)
|
||
dns := fmt.Sprintf("%s?_db_key=%s", dbPath, key)
|
||
db, err := gorm.Open(sqlite.Open(dns), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
if err != nil {
|
||
zlog.Error("✗ 无法打开数据库,尝试使用 dump 方式修复...")
|
||
return repairDatabaseByDump(dbPath, password, backupPath)
|
||
}
|
||
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
return fmt.Errorf("获取数据库连接失败: %w", err)
|
||
}
|
||
defer sqlDB.Close()
|
||
|
||
// 3. 运行完整性检查
|
||
var result string
|
||
err = db.Raw("PRAGMA integrity_check;").Scan(&result).Error
|
||
if err != nil {
|
||
zlog.Error("✗ 完整性检查失败:", err)
|
||
return repairDatabaseByDump(dbPath, password, backupPath)
|
||
}
|
||
|
||
zlog.Info("完整性检查结果:", result)
|
||
|
||
if result == "ok" {
|
||
zlog.Info("✓ 数据库完整性良好,尝试优化...")
|
||
|
||
// 4. 尝试 VACUUM 重建数据库
|
||
zlog.Info("步骤3: 执行 VACUUM 优化...")
|
||
if err := db.Exec("VACUUM;").Error; err != nil {
|
||
zlog.Warn("✗ VACUUM 失败:", err)
|
||
} else {
|
||
zlog.Info("✓ VACUUM 成功")
|
||
}
|
||
|
||
// 5. 重新索引
|
||
zlog.Info("步骤4: 重建索引...")
|
||
if err := db.Exec("REINDEX;").Error; err != nil {
|
||
zlog.Warn("✗ REINDEX 失败:", err)
|
||
} else {
|
||
zlog.Info("✓ REINDEX 成功")
|
||
}
|
||
|
||
zlog.Info("========================================")
|
||
zlog.Info("✓ 数据库修复完成!")
|
||
zlog.Info("========================================")
|
||
return nil
|
||
} else {
|
||
zlog.Error("✗ 数据库存在完整性问题,尝试使用 dump 方式修复...")
|
||
return repairDatabaseByDump(dbPath, password, backupPath)
|
||
}
|
||
}
|
||
|
||
// repairDatabaseByDump 使用 dump 和重建的方式修复数据库
|
||
func repairDatabaseByDump(dbPath string, password string, backupPath string) error {
|
||
zlog.Info("使用导出重建方式修复数据库...")
|
||
|
||
// 创建临时修复文件
|
||
repairedPath := dbPath + ".repaired_" + time.Now().Format("20060102150405")
|
||
|
||
key := url.QueryEscape(password)
|
||
|
||
// 1. 尝试打开源数据库(忽略错误继续)
|
||
zlog.Info("步骤1: 读取源数据库数据...")
|
||
srcDns := fmt.Sprintf("%s?_db_key=%s", dbPath, key)
|
||
srcDB, err := gorm.Open(sqlite.Open(srcDns), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("无法打开源数据库进行修复: %w", err)
|
||
}
|
||
|
||
srcSqlDB, err := srcDB.DB()
|
||
if err != nil {
|
||
return fmt.Errorf("获取源数据库连接失败: %w", err)
|
||
}
|
||
defer srcSqlDB.Close()
|
||
|
||
// 2. 创建新的数据库
|
||
zlog.Info("步骤2: 创建新数据库...")
|
||
dstDns := fmt.Sprintf("%s?_db_key=%s", repairedPath, key)
|
||
dstDB, err := gorm.Open(sqlite.Open(dstDns), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("创建新数据库失败: %w", err)
|
||
}
|
||
|
||
dstSqlDB, err := dstDB.DB()
|
||
if err != nil {
|
||
return fmt.Errorf("获取新数据库连接失败: %w", err)
|
||
}
|
||
defer dstSqlDB.Close()
|
||
|
||
// 3. 获取所有表名
|
||
zlog.Info("步骤3: 读取表结构...")
|
||
var tables []string
|
||
err = srcDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;").Scan(&tables).Error
|
||
if err != nil {
|
||
return fmt.Errorf("获取表列表失败: %w", err)
|
||
}
|
||
|
||
zlog.Info(fmt.Sprintf("找到 %d 个表", len(tables)))
|
||
|
||
// 4. 逐表复制数据
|
||
successCount := 0
|
||
errorCount := 0
|
||
for _, tableName := range tables {
|
||
zlog.Info("正在处理表:", tableName)
|
||
|
||
// 获取建表语句
|
||
var createSQL string
|
||
err := srcDB.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", tableName).Scan(&createSQL).Error
|
||
if err != nil {
|
||
zlog.Error(fmt.Sprintf("✗ 获取表 %s 的建表语句失败: %v", tableName, err))
|
||
errorCount++
|
||
continue
|
||
}
|
||
|
||
// 在新数据库中创建表
|
||
if err := dstDB.Exec(createSQL).Error; err != nil {
|
||
zlog.Error(fmt.Sprintf("✗ 创建表 %s 失败: %v", tableName, err))
|
||
errorCount++
|
||
continue
|
||
}
|
||
|
||
// 复制数据
|
||
var count int64
|
||
if err := srcDB.Table(tableName).Count(&count).Error; err != nil {
|
||
zlog.Warn(fmt.Sprintf("✗ 获取表 %s 记录数失败: %v", tableName, err))
|
||
} else {
|
||
zlog.Info(fmt.Sprintf(" - 表 %s 有 %d 条记录", tableName, count))
|
||
}
|
||
|
||
// 附加源数据库并复制
|
||
attachSQL := fmt.Sprintf("ATTACH DATABASE '%s' AS source", dbPath)
|
||
if err := dstDB.Exec(attachSQL).Error; err != nil {
|
||
zlog.Error(fmt.Sprintf("✗ 附加源数据库失败: %v", err))
|
||
errorCount++
|
||
continue
|
||
}
|
||
|
||
copyDataSQL := fmt.Sprintf("INSERT INTO main.%s SELECT * FROM source.%s", tableName, tableName)
|
||
if err := dstDB.Exec(copyDataSQL).Error; err != nil {
|
||
zlog.Warn(fmt.Sprintf("✗ 复制表 %s 数据失败: %v", tableName, err))
|
||
errorCount++
|
||
} else {
|
||
zlog.Info(fmt.Sprintf("✓ 表 %s 复制成功", tableName))
|
||
successCount++
|
||
}
|
||
|
||
// 分离数据库
|
||
dstDB.Exec("DETACH DATABASE source")
|
||
}
|
||
|
||
// 5. 复制索引和其他对象
|
||
zlog.Info("步骤4: 复制索引...")
|
||
var indexes []string
|
||
err = srcDB.Raw("SELECT sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL ORDER BY name;").Scan(&indexes).Error
|
||
if err == nil {
|
||
for _, indexSQL := range indexes {
|
||
if err := dstDB.Exec(indexSQL).Error; err != nil {
|
||
zlog.Warn("创建索引失败:", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. 关闭数据库连接
|
||
srcSqlDB.Close()
|
||
dstSqlDB.Close()
|
||
|
||
// 7. 替换原数据库
|
||
if successCount > 0 {
|
||
zlog.Info("步骤5: 替换原数据库...")
|
||
|
||
// 删除原数据库
|
||
if err := os.Remove(dbPath); err != nil {
|
||
return fmt.Errorf("删除原数据库失败: %w", err)
|
||
}
|
||
|
||
// 重命名修复后的数据库
|
||
if err := os.Rename(repairedPath, dbPath); err != nil {
|
||
return fmt.Errorf("重命名修复后数据库失败: %w", err)
|
||
}
|
||
|
||
zlog.Info("========================================")
|
||
zlog.Info(fmt.Sprintf("✓ 数据库修复完成! 成功: %d 个表, 失败: %d 个表", successCount, errorCount))
|
||
zlog.Info("原数据库备份在:", backupPath)
|
||
zlog.Info("========================================")
|
||
return nil
|
||
} else {
|
||
// 清理修复文件
|
||
os.Remove(repairedPath)
|
||
return fmt.Errorf("修复失败: 没有成功复制任何表")
|
||
}
|
||
}
|
||
|
||
// ExecuteSQLCommand 执行SQL命令工具
|
||
func ExecuteSQLCommand(currentDir string) {
|
||
if currentDir == "" {
|
||
currentDir = utils.GetCurrentDir()
|
||
}
|
||
|
||
// 初始化审计日志
|
||
auditLogger, auditLogPath := initSQLAuditLogger(currentDir)
|
||
if auditLogger != nil {
|
||
defer auditLogger.Close()
|
||
writeAuditLog(auditLogger, "INFO", "SQL执行工具启动", "")
|
||
fmt.Printf("📝 审计日志: %s\n", auditLogPath)
|
||
}
|
||
|
||
fmt.Println("================================================")
|
||
fmt.Println(" SamWaf SQL 执行工具")
|
||
fmt.Println("================================================")
|
||
fmt.Println("\n可以在以下数据库上执行 SQL 语句:")
|
||
fmt.Println("1. 核心数据库 (local.db) - 存储配置、规则等")
|
||
fmt.Println("2. 日志数据库 (local_log.db) - 存储访问日志")
|
||
fmt.Println("3. 统计数据库 (local_stats.db) - 存储统计数据")
|
||
fmt.Println("\n⚠️ 警告:")
|
||
fmt.Println("- 执行前请确保已备份数据库")
|
||
fmt.Println("- UPDATE/DELETE 操作会直接修改数据,请谨慎使用")
|
||
fmt.Println("- 不当的 SQL 可能导致数据丢失或系统异常")
|
||
fmt.Println("- 所有操作将被记录到审计日志")
|
||
|
||
fmt.Print("\n请选择数据库 (1-3),或输入 'q' 退出: ")
|
||
var input string
|
||
fmt.Scanln(&input)
|
||
|
||
if input == "q" || input == "Q" {
|
||
fmt.Println("已退出 SQL 执行工具")
|
||
writeAuditLog(auditLogger, "INFO", "用户退出SQL执行工具", "")
|
||
return
|
||
}
|
||
|
||
var db *gorm.DB
|
||
var dbName string
|
||
var dbPath string
|
||
var password string
|
||
|
||
switch input {
|
||
case "1":
|
||
dbPath = currentDir + "/data/local.db"
|
||
dbName = "核心数据库 (local.db)"
|
||
password = global.GWAF_PWD_COREDB
|
||
case "2":
|
||
dbPath = currentDir + "/data/local_log.db"
|
||
dbName = "日志数据库 (local_log.db)"
|
||
password = global.GWAF_PWD_LOGDB
|
||
case "3":
|
||
dbPath = currentDir + "/data/local_stats.db"
|
||
dbName = "统计数据库 (local_stats.db)"
|
||
password = global.GWAF_PWD_STATDB
|
||
default:
|
||
fmt.Println("✗ 无效的选择")
|
||
writeAuditLog(auditLogger, "ERROR", "无效的数据库选择", fmt.Sprintf("输入: %s", input))
|
||
return
|
||
}
|
||
|
||
// 检查文件是否存在
|
||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||
fmt.Printf("✗ 数据库文件不存在: %s\n", dbPath)
|
||
writeAuditLog(auditLogger, "ERROR", "数据库文件不存在", dbPath)
|
||
return
|
||
}
|
||
|
||
// 记录连接数据库
|
||
writeAuditLog(auditLogger, "INFO", fmt.Sprintf("连接数据库: %s", dbName), dbPath)
|
||
|
||
// 打开数据库
|
||
fmt.Printf("\n正在连接到 %s...\n", dbName)
|
||
key := url.QueryEscape(password)
|
||
dns := fmt.Sprintf("%s?_db_key=%s", dbPath, key)
|
||
var err error
|
||
db, err = gorm.Open(sqlite.Open(dns), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
if err != nil {
|
||
fmt.Printf("✗ 连接数据库失败: %v\n", err)
|
||
writeAuditLog(auditLogger, "ERROR", "连接数据库失败", fmt.Sprintf("数据库: %s, 错误: %v", dbName, err))
|
||
return
|
||
}
|
||
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
fmt.Printf("✗ 获取数据库连接失败: %v\n", err)
|
||
writeAuditLog(auditLogger, "ERROR", "获取数据库连接失败", fmt.Sprintf("错误: %v", err))
|
||
return
|
||
}
|
||
defer sqlDB.Close()
|
||
|
||
fmt.Printf("✓ 已连接到 %s\n", dbName)
|
||
writeAuditLog(auditLogger, "INFO", "成功连接数据库", dbName)
|
||
|
||
fmt.Println("\n================================================")
|
||
fmt.Println("SQL 执行模式")
|
||
fmt.Println("================================================")
|
||
fmt.Println("提示:")
|
||
fmt.Println("- 输入 SQL 语句并按回车执行")
|
||
fmt.Println("- 输入 'tables' 查看所有表")
|
||
fmt.Println("- 输入 'quit' 或 'exit' 退出")
|
||
fmt.Println("- 示例: SELECT * FROM account LIMIT 10")
|
||
fmt.Println("================================================")
|
||
|
||
// 创建输入扫描器
|
||
scanner := bufio.NewScanner(os.Stdin)
|
||
|
||
// 交互式执行 SQL
|
||
for {
|
||
fmt.Print("SQL> ")
|
||
|
||
if !scanner.Scan() {
|
||
break
|
||
}
|
||
|
||
sqlInput := scanner.Text()
|
||
sqlInput = strings.TrimSpace(sqlInput)
|
||
|
||
// 跳过空行
|
||
if sqlInput == "" {
|
||
continue
|
||
}
|
||
|
||
// 特殊命令处理
|
||
switch strings.ToLower(sqlInput) {
|
||
case "quit", "exit", "q":
|
||
fmt.Println("\n已退出 SQL 执行工具")
|
||
writeAuditLog(auditLogger, "INFO", "用户退出SQL执行工具", "")
|
||
return
|
||
case "tables":
|
||
writeAuditLog(auditLogger, "INFO", "查看表列表", dbName)
|
||
listTables(db)
|
||
continue
|
||
case "help", "?":
|
||
fmt.Println("\n可用命令:")
|
||
fmt.Println(" tables - 显示所有表")
|
||
fmt.Println(" help/? - 显示此帮助")
|
||
fmt.Println(" quit/exit/q - 退出")
|
||
fmt.Println("\nSQL 语句示例:")
|
||
fmt.Println(" SELECT * FROM account LIMIT 10;")
|
||
fmt.Println(" UPDATE hosts SET status=1 WHERE code='xxx';")
|
||
fmt.Println(" DELETE FROM web_logs WHERE unix_add_time < 1234567890;")
|
||
fmt.Println("")
|
||
continue
|
||
}
|
||
|
||
// 执行 SQL(带审计日志)
|
||
executeSingleSQLWithAudit(db, sqlInput, dbName, auditLogger)
|
||
fmt.Println("")
|
||
}
|
||
|
||
if err := scanner.Err(); err != nil {
|
||
fmt.Printf("✗ 读取输入错误: %v\n", err)
|
||
writeAuditLog(auditLogger, "ERROR", "读取输入错误", fmt.Sprintf("错误: %v", err))
|
||
}
|
||
}
|
||
|
||
// initSQLAuditLogger 初始化SQL审计日志
|
||
func initSQLAuditLogger(currentDir string) (*os.File, string) {
|
||
// 确保logs目录存在
|
||
logsDir := filepath.Join(currentDir, "logs")
|
||
if err := os.MkdirAll(logsDir, os.ModePerm); err != nil {
|
||
fmt.Printf("⚠️ 警告: 无法创建logs目录: %v\n", err)
|
||
return nil, ""
|
||
}
|
||
|
||
// 创建审计日志文件
|
||
logPath := filepath.Join(logsDir, "db.log")
|
||
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||
if err != nil {
|
||
fmt.Printf("⚠️ 警告: 无法创建审计日志文件: %v\n", err)
|
||
return nil, ""
|
||
}
|
||
|
||
return file, logPath
|
||
}
|
||
|
||
// writeAuditLog 写入审计日志
|
||
func writeAuditLog(logger *os.File, level, action, detail string) {
|
||
if logger == nil {
|
||
return
|
||
}
|
||
|
||
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
|
||
logLine := fmt.Sprintf("[%s] [%s] %s", timestamp, level, action)
|
||
if detail != "" {
|
||
logLine += fmt.Sprintf(" | %s", detail)
|
||
}
|
||
logLine += "\n"
|
||
|
||
if _, err := logger.WriteString(logLine); err != nil {
|
||
fmt.Printf("⚠️ 警告: 写入审计日志失败: %v\n", err)
|
||
}
|
||
}
|
||
|
||
// executeSingleSQLWithAudit 执行单条 SQL 语句(带审计日志)
|
||
func executeSingleSQLWithAudit(db *gorm.DB, sqlStr string, dbName string, auditLogger *os.File) {
|
||
sqlStr = strings.TrimSpace(sqlStr)
|
||
if sqlStr == "" {
|
||
return
|
||
}
|
||
|
||
// 记录SQL执行
|
||
writeAuditLog(auditLogger, "INFO", fmt.Sprintf("执行SQL [数据库: %s]", dbName), sqlStr)
|
||
|
||
// 判断 SQL 类型
|
||
sqlUpper := strings.ToUpper(sqlStr)
|
||
isQuery := strings.HasPrefix(sqlUpper, "SELECT") ||
|
||
strings.HasPrefix(sqlUpper, "PRAGMA") ||
|
||
strings.HasPrefix(sqlUpper, "SHOW")
|
||
|
||
if isQuery {
|
||
// 查询语句
|
||
executeQuerySQLWithAudit(db, sqlStr, dbName, auditLogger)
|
||
} else {
|
||
// 修改语句(UPDATE/DELETE/INSERT等)
|
||
executeModifySQLWithAudit(db, sqlStr, dbName, auditLogger)
|
||
}
|
||
}
|
||
|
||
// listTables 列出所有表
|
||
func listTables(db *gorm.DB) {
|
||
var tables []string
|
||
err := db.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error
|
||
if err != nil {
|
||
fmt.Printf("✗ 查询表列表失败: %v\n", err)
|
||
return
|
||
}
|
||
|
||
fmt.Println("\n数据库中的表:")
|
||
fmt.Println("----------------------------------------")
|
||
for i, table := range tables {
|
||
// 获取表的记录数
|
||
var count int64
|
||
db.Table(table).Count(&count)
|
||
fmt.Printf("%2d. %-30s (记录数: %d)\n", i+1, table, count)
|
||
}
|
||
fmt.Println("----------------------------------------")
|
||
}
|
||
|
||
// executeQuerySQLWithAudit 执行查询语句(带审计)
|
||
func executeQuerySQLWithAudit(db *gorm.DB, sqlStr string, dbName string, auditLogger *os.File) {
|
||
rows, err := db.Raw(sqlStr).Rows()
|
||
if err != nil {
|
||
fmt.Printf("✗ 执行查询失败: %v\n", err)
|
||
writeAuditLog(auditLogger, "ERROR", fmt.Sprintf("查询执行失败 [数据库: %s]", dbName), fmt.Sprintf("错误: %v", err))
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
// 获取列名
|
||
columns, err := rows.Columns()
|
||
if err != nil {
|
||
fmt.Printf("✗ 获取列信息失败: %v\n", err)
|
||
writeAuditLog(auditLogger, "ERROR", fmt.Sprintf("获取列信息失败 [数据库: %s]", dbName), fmt.Sprintf("错误: %v", err))
|
||
return
|
||
}
|
||
|
||
fmt.Println("\n查询结果:")
|
||
fmt.Println("----------------------------------------")
|
||
|
||
// 打印列名
|
||
for i, col := range columns {
|
||
if i > 0 {
|
||
fmt.Print(" | ")
|
||
}
|
||
fmt.Printf("%-20s", col)
|
||
}
|
||
fmt.Println()
|
||
fmt.Println(strings.Repeat("-", len(columns)*23))
|
||
|
||
// 打印数据行
|
||
rowCount := 0
|
||
for rows.Next() {
|
||
// 创建接收数据的切片
|
||
values := make([]interface{}, len(columns))
|
||
valuePtrs := make([]interface{}, len(columns))
|
||
for i := range values {
|
||
valuePtrs[i] = &values[i]
|
||
}
|
||
|
||
if err := rows.Scan(valuePtrs...); err != nil {
|
||
fmt.Printf("✗ 读取数据失败: %v\n", err)
|
||
writeAuditLog(auditLogger, "ERROR", fmt.Sprintf("读取数据失败 [数据库: %s]", dbName), fmt.Sprintf("错误: %v", err))
|
||
return
|
||
}
|
||
|
||
// 打印每一列的值
|
||
for i, val := range values {
|
||
if i > 0 {
|
||
fmt.Print(" | ")
|
||
}
|
||
// 处理 nil 值
|
||
if val == nil {
|
||
fmt.Printf("%-20s", "NULL")
|
||
} else {
|
||
// 将值转换为字符串
|
||
strVal := fmt.Sprintf("%v", val)
|
||
if len(strVal) > 20 {
|
||
strVal = strVal[:17] + "..."
|
||
}
|
||
fmt.Printf("%-20s", strVal)
|
||
}
|
||
}
|
||
fmt.Println()
|
||
rowCount++
|
||
|
||
// 限制显示行数,避免输出过多
|
||
if rowCount >= 100 {
|
||
fmt.Println("... (仅显示前100行)")
|
||
break
|
||
}
|
||
}
|
||
|
||
fmt.Println("----------------------------------------")
|
||
fmt.Printf("✓ 查询完成,共 %d 行\n", rowCount)
|
||
|
||
// 记录审计日志
|
||
writeAuditLog(auditLogger, "SUCCESS", fmt.Sprintf("查询执行成功 [数据库: %s]", dbName), fmt.Sprintf("返回 %d 行", rowCount))
|
||
}
|
||
|
||
// executeModifySQLWithAudit 执行修改语句(带审计)
|
||
func executeModifySQLWithAudit(db *gorm.DB, sqlStr string, dbName string, auditLogger *os.File) {
|
||
// 二次确认
|
||
fmt.Print("\n⚠️ 您即将执行修改操作,是否继续?(yes/no): ")
|
||
|
||
scanner := bufio.NewScanner(os.Stdin)
|
||
if !scanner.Scan() {
|
||
fmt.Println("✗ 操作已取消")
|
||
writeAuditLog(auditLogger, "CANCEL", fmt.Sprintf("用户取消修改操作 [数据库: %s]", dbName), "读取确认失败")
|
||
return
|
||
}
|
||
|
||
confirm := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
||
|
||
if confirm != "yes" && confirm != "y" {
|
||
fmt.Println("✗ 操作已取消")
|
||
writeAuditLog(auditLogger, "CANCEL", fmt.Sprintf("用户取消修改操作 [数据库: %s]", dbName), fmt.Sprintf("用户输入: %s", confirm))
|
||
return
|
||
}
|
||
|
||
// 记录用户确认执行
|
||
writeAuditLog(auditLogger, "CONFIRM", fmt.Sprintf("用户确认执行修改 [数据库: %s]", dbName), "")
|
||
|
||
result := db.Exec(sqlStr)
|
||
if result.Error != nil {
|
||
fmt.Printf("✗ 执行失败: %v\n", result.Error)
|
||
writeAuditLog(auditLogger, "ERROR", fmt.Sprintf("修改执行失败 [数据库: %s]", dbName), fmt.Sprintf("错误: %v", result.Error))
|
||
return
|
||
}
|
||
|
||
fmt.Printf("✓ 执行成功,影响 %d 行\n", result.RowsAffected)
|
||
writeAuditLog(auditLogger, "SUCCESS", fmt.Sprintf("修改执行成功 [数据库: %s]", dbName), fmt.Sprintf("影响 %d 行", result.RowsAffected))
|
||
}
|
||
|
||
// RepairAllDatabases 修复所有数据库
|
||
func RepairAllDatabases(currentDir string) {
|
||
if currentDir == "" {
|
||
currentDir = utils.GetCurrentDir()
|
||
}
|
||
|
||
databases := []struct {
|
||
Path string
|
||
Name string
|
||
Password string
|
||
}{
|
||
{
|
||
Path: currentDir + "/data/local.db",
|
||
Name: "核心数据库 (local.db)",
|
||
Password: global.GWAF_PWD_COREDB,
|
||
},
|
||
{
|
||
Path: currentDir + "/data/local_log.db",
|
||
Name: "日志数据库 (local_log.db)",
|
||
Password: global.GWAF_PWD_LOGDB,
|
||
},
|
||
{
|
||
Path: currentDir + "/data/local_stats.db",
|
||
Name: "统计数据库 (local_stats.db)",
|
||
Password: global.GWAF_PWD_STATDB,
|
||
},
|
||
}
|
||
|
||
fmt.Println("\n================================================")
|
||
fmt.Println(" SamWaf 数据库修复工具")
|
||
fmt.Println("================================================")
|
||
fmt.Println("\n将检查并修复以下数据库:")
|
||
for i, db := range databases {
|
||
fmt.Printf("%d. %s\n", i+1, db.Name)
|
||
fmt.Printf(" 路径: %s\n", db.Path)
|
||
}
|
||
fmt.Println("\n⚠️ 警告:修复前会自动备份数据库")
|
||
fmt.Print("\n是否继续?请输入数据库编号 (1-3),或输入 'all' 修复全部,输入 'q' 退出: ")
|
||
|
||
var input string
|
||
fmt.Scanln(&input)
|
||
|
||
if input == "q" || input == "Q" {
|
||
fmt.Println("已取消修复操作")
|
||
return
|
||
}
|
||
|
||
var selectedDBs []int
|
||
if input == "all" || input == "ALL" {
|
||
selectedDBs = []int{0, 1, 2}
|
||
} else {
|
||
// 解析用户输入的数字
|
||
var dbIndex int
|
||
_, err := fmt.Sscanf(input, "%d", &dbIndex)
|
||
if err != nil || dbIndex < 1 || dbIndex > 3 {
|
||
fmt.Println("✗ 无效的输入")
|
||
return
|
||
}
|
||
selectedDBs = []int{dbIndex - 1}
|
||
}
|
||
|
||
// 执行修复
|
||
successCount := 0
|
||
errorCount := 0
|
||
|
||
for _, idx := range selectedDBs {
|
||
db := databases[idx]
|
||
fmt.Printf("\n正在修复: %s\n", db.Name)
|
||
|
||
// 检查文件是否存在
|
||
if _, err := os.Stat(db.Path); os.IsNotExist(err) {
|
||
fmt.Printf("✗ 数据库文件不存在,跳过: %s\n", db.Path)
|
||
continue
|
||
}
|
||
|
||
err := RepairDatabase(db.Path, db.Password)
|
||
if err != nil {
|
||
fmt.Printf("✗ 修复失败: %v\n", err)
|
||
errorCount++
|
||
} else {
|
||
fmt.Printf("✓ 修复成功\n")
|
||
successCount++
|
||
}
|
||
}
|
||
|
||
fmt.Println("\n================================================")
|
||
fmt.Printf("修复完成! 成功: %d, 失败: %d\n", successCount, errorCount)
|
||
fmt.Println("================================================")
|
||
|
||
if errorCount > 0 {
|
||
fmt.Println("\n⚠️ 部分数据库修复失败,请检查日志")
|
||
}
|
||
}
|