| package storage |
|
|
| import ( |
| "context" |
| "fmt" |
| "log" |
| "strings" |
| "time" |
|
|
| sqlstore "ccLoad/internal/storage/sql" |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| type SyncManager struct { |
| mysql *sqlstore.SQLStore |
| sqlite *sqlstore.SQLStore |
| } |
|
|
| |
| func NewSyncManager(mysql, sqlite *sqlstore.SQLStore) *SyncManager { |
| return &SyncManager{ |
| mysql: mysql, |
| sqlite: sqlite, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| func (sm *SyncManager) RestoreOnStartup(ctx context.Context, logDays int) error { |
| start := time.Now() |
|
|
| |
| configTables := []string{ |
| "system_settings", |
| "channels", |
| "channel_models", |
| "api_keys", |
| "auth_tokens", |
| } |
|
|
| log.Printf("[INFO] 开始恢复配置表(共 %d 个表)...", len(configTables)) |
| for _, table := range configTables { |
| if err := sm.restoreTable(ctx, table); err != nil { |
| return fmt.Errorf("恢复表 %s 失败: %w", table, err) |
| } |
| } |
|
|
| log.Printf("[INFO] 配置表恢复完成,耗时: %v", time.Since(start)) |
|
|
| |
| |
| if logDays != 0 { |
| logsStart := time.Now() |
| if err := sm.restoreLogsIncremental(ctx, logDays); err != nil { |
| |
| log.Printf("[WARN] 日志恢复失败: %v(历史日志可能不完整)", err) |
| } else { |
| log.Printf("[INFO] 日志恢复完成,耗时: %v", time.Since(logsStart)) |
| } |
| } |
|
|
| log.Printf("[INFO] 数据恢复完成,总耗时: %v", time.Since(start)) |
| return nil |
| } |
|
|
| |
| |
| |
| |
| |
| func (sm *SyncManager) restoreTable(ctx context.Context, tableName string) error { |
| const maxConfigRows = 10000 |
|
|
| |
| var rowCount int64 |
| countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName) |
| if err := sm.mysql.QueryRowContext(ctx, countQuery).Scan(&rowCount); err != nil { |
| return fmt.Errorf("统计行数失败: %w", err) |
| } |
| if rowCount > maxConfigRows { |
| return fmt.Errorf("表 %s 行数 %d 超过限制 %d,请检查数据或使用分批恢复", tableName, rowCount, maxConfigRows) |
| } |
|
|
| |
| sqliteCols, err := sm.getTableColumns(ctx, sm.sqlite, tableName) |
| if err != nil { |
| return fmt.Errorf("获取 SQLite 表列失败: %w", err) |
| } |
| sqliteColSet := make(map[string]bool, len(sqliteCols)) |
| for _, col := range sqliteCols { |
| sqliteColSet[col] = true |
| } |
|
|
| |
| mysqlCols, err := sm.getTableColumns(ctx, sm.mysql, tableName) |
| if err != nil { |
| return fmt.Errorf("获取 MySQL 表列失败: %w", err) |
| } |
|
|
| |
| var commonCols []string |
| var mysqlColIndices []int |
| for i, col := range mysqlCols { |
| if sqliteColSet[col] { |
| commonCols = append(commonCols, col) |
| mysqlColIndices = append(mysqlColIndices, i) |
| } |
| } |
|
|
| if len(commonCols) == 0 { |
| return fmt.Errorf("表 %s 无共同列,无法恢复", tableName) |
| } |
|
|
| |
| query := fmt.Sprintf("SELECT * FROM %s", tableName) |
| rows, err := sm.mysql.QueryContext(ctx, query) |
| if err != nil { |
| return fmt.Errorf("MySQL 查询失败: %w", err) |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| |
| var records [][]any |
| for rows.Next() { |
| |
| scanArgs := make([]any, len(mysqlCols)) |
| scanVals := make([]any, len(mysqlCols)) |
| for i := range scanVals { |
| scanArgs[i] = &scanVals[i] |
| } |
|
|
| if err := rows.Scan(scanArgs...); err != nil { |
| return fmt.Errorf("扫描行失败: %w", err) |
| } |
|
|
| |
| |
| |
| record := make([]any, len(commonCols)) |
| for i, idx := range mysqlColIndices { |
| val := scanVals[idx] |
| |
| if b, ok := val.([]byte); ok { |
| record[i] = string(b) |
| } else { |
| record[i] = val |
| } |
| } |
| records = append(records, record) |
| } |
|
|
| if err := rows.Err(); err != nil { |
| return fmt.Errorf("读取数据失败: %w", err) |
| } |
|
|
| if len(records) == 0 { |
| log.Printf("[INFO] 表 %s 为空,跳过恢复", tableName) |
| return nil |
| } |
|
|
| |
| tx, err := sm.sqlite.BeginTx(ctx, nil) |
| if err != nil { |
| return fmt.Errorf("开启事务失败: %w", err) |
| } |
| defer func() { _ = tx.Rollback() }() |
|
|
| deleteQuery := fmt.Sprintf("DELETE FROM %s", tableName) |
| if _, err := tx.ExecContext(ctx, deleteQuery); err != nil { |
| return fmt.Errorf("清空 SQLite 表失败: %w", err) |
| } |
|
|
| |
| |
| colNames := strings.Join(commonCols, ", ") |
| placeholders := strings.Repeat("?,", len(commonCols)) |
| placeholders = placeholders[:len(placeholders)-1] |
| insertQuery := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", tableName, colNames, placeholders) |
|
|
| stmt, err := tx.Prepare(insertQuery) |
| if err != nil { |
| return fmt.Errorf("准备插入语句失败: %w", err) |
| } |
| defer func() { _ = stmt.Close() }() |
|
|
| for _, record := range records { |
| if _, err := stmt.Exec(record...); err != nil { |
| return fmt.Errorf("插入数据失败: %w", err) |
| } |
| } |
|
|
| if err := tx.Commit(); err != nil { |
| return fmt.Errorf("提交事务失败: %w", err) |
| } |
|
|
| log.Printf("[INFO] 表 %s 恢复完成,共 %d 条记录(%d/%d 列)", tableName, len(records), len(commonCols), len(mysqlCols)) |
| return nil |
| } |
|
|
| |
| func (sm *SyncManager) getTableColumns(ctx context.Context, store *sqlstore.SQLStore, tableName string) ([]string, error) { |
| |
| query := fmt.Sprintf("SELECT * FROM %s LIMIT 0", tableName) |
| rows, err := store.QueryContext(ctx, query) |
| if err != nil { |
| return nil, err |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| return rows.Columns() |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| func (sm *SyncManager) restoreLogsIncremental(ctx context.Context, days int) error { |
| |
| var maxID int64 |
| if err := sm.sqlite.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM logs").Scan(&maxID); err != nil { |
| return fmt.Errorf("获取 SQLite 最大 ID 失败: %w", err) |
| } |
|
|
| |
| var startTime int64 |
| if days < 0 { |
| startTime = 0 |
| log.Printf("[INFO] 准备增量恢复 logs 表(从 id > %d)...", maxID) |
| } else { |
| startTime = time.Now().AddDate(0, 0, -days).UnixMilli() |
| log.Printf("[INFO] 准备增量恢复最近 %d 天的日志(从 id > %d)...", days, maxID) |
| } |
|
|
| |
| var count int64 |
| countQuery := "SELECT COUNT(*) FROM logs WHERE id > ? AND time >= ?" |
| if err := sm.mysql.QueryRowContext(ctx, countQuery, maxID, startTime).Scan(&count); err != nil { |
| return fmt.Errorf("统计日志数量失败: %w", err) |
| } |
|
|
| if count == 0 { |
| if maxID > 0 { |
| log.Print("[INFO] SQLite 日志已是最新,无需恢复") |
| } else { |
| log.Print("[INFO] MySQL 无日志需要恢复") |
| } |
| return nil |
| } |
|
|
| log.Printf("[INFO] 预计恢复 %d 条日志", count) |
|
|
| |
| sqliteCols, err := sm.getTableColumns(ctx, sm.sqlite, "logs") |
| if err != nil { |
| return fmt.Errorf("获取 SQLite logs 表列失败: %w", err) |
| } |
| sqliteColSet := make(map[string]bool, len(sqliteCols)) |
| for _, col := range sqliteCols { |
| sqliteColSet[col] = true |
| } |
|
|
| mysqlCols, err := sm.getTableColumns(ctx, sm.mysql, "logs") |
| if err != nil { |
| return fmt.Errorf("获取 MySQL logs 表列失败: %w", err) |
| } |
|
|
| |
| var commonCols []string |
| var mysqlColIndices []int |
| for i, col := range mysqlCols { |
| if sqliteColSet[col] { |
| commonCols = append(commonCols, col) |
| mysqlColIndices = append(mysqlColIndices, i) |
| } |
| } |
|
|
| if len(commonCols) == 0 { |
| return fmt.Errorf("logs 表无共同列,无法恢复") |
| } |
|
|
| |
| const batchSize = 5000 |
| lastID := maxID |
| totalRestored := 0 |
|
|
| for { |
| |
| query := "SELECT * FROM logs WHERE id > ? AND time >= ? ORDER BY id LIMIT ?" |
| rows, err := sm.mysql.QueryContext(ctx, query, lastID, startTime, batchSize) |
| if err != nil { |
| return fmt.Errorf("查询日志失败: %w", err) |
| } |
|
|
| |
| batchCount, batchLastID, err := sm.insertLogBatchWithLastID(ctx, rows, len(mysqlCols), commonCols, mysqlColIndices) |
| _ = rows.Close() |
| if err != nil { |
| return fmt.Errorf("批量插入日志失败: %w", err) |
| } |
|
|
| if batchCount == 0 { |
| break |
| } |
|
|
| lastID = batchLastID |
| totalRestored += batchCount |
|
|
| |
| if totalRestored%50000 == 0 { |
| log.Printf("[INFO] 已恢复 %d 条日志...", totalRestored) |
| } |
|
|
| |
| if batchCount < batchSize { |
| break |
| } |
| } |
|
|
| log.Printf("[INFO] 日志恢复完成,共 %d 条(%d/%d 列)", totalRestored, len(commonCols), len(mysqlCols)) |
| return nil |
| } |
|
|
| |
| |
| |
| |
| func (sm *SyncManager) insertLogBatchWithLastID(ctx context.Context, rows interface { |
| Next() bool |
| Scan(...any) error |
| Err() error |
| }, mysqlColCount int, commonCols []string, mysqlColIndices []int) (count int, lastID int64, err error) { |
| |
| idColIdx := -1 |
| for i, col := range commonCols { |
| if col == "id" { |
| idColIdx = i |
| break |
| } |
| } |
| if idColIdx < 0 { |
| return 0, 0, fmt.Errorf("commonCols 中缺少 id 列") |
| } |
|
|
| |
| var records [][]any |
| for rows.Next() { |
| |
| scanArgs := make([]any, mysqlColCount) |
| scanVals := make([]any, mysqlColCount) |
| for i := range scanVals { |
| scanArgs[i] = &scanVals[i] |
| } |
|
|
| if err := rows.Scan(scanArgs...); err != nil { |
| return 0, 0, fmt.Errorf("扫描行失败: %w", err) |
| } |
|
|
| |
| |
| |
| record := make([]any, len(commonCols)) |
| for i, idx := range mysqlColIndices { |
| val := scanVals[idx] |
| if b, ok := val.([]byte); ok { |
| record[i] = string(b) |
| } else { |
| record[i] = val |
| } |
| } |
| records = append(records, record) |
| } |
|
|
| if err := rows.Err(); err != nil { |
| return 0, 0, fmt.Errorf("读取日志失败: %w", err) |
| } |
|
|
| if len(records) == 0 { |
| return 0, 0, nil |
| } |
|
|
| |
| lastRecord := records[len(records)-1] |
| switch v := lastRecord[idColIdx].(type) { |
| case int64: |
| lastID = v |
| case int: |
| lastID = int64(v) |
| default: |
| return 0, 0, fmt.Errorf("无法解析 id 列值: %T", v) |
| } |
|
|
| |
| tx, err := sm.sqlite.BeginTx(ctx, nil) |
| if err != nil { |
| return 0, 0, fmt.Errorf("开启事务失败: %w", err) |
| } |
| defer func() { _ = tx.Rollback() }() |
|
|
| |
| colNames := strings.Join(commonCols, ", ") |
| placeholders := strings.Repeat("?,", len(commonCols)) |
| placeholders = placeholders[:len(placeholders)-1] |
| insertQuery := fmt.Sprintf("INSERT INTO logs (%s) VALUES (%s)", colNames, placeholders) |
|
|
| stmt, err := tx.Prepare(insertQuery) |
| if err != nil { |
| return 0, 0, fmt.Errorf("准备插入语句失败: %w", err) |
| } |
| defer func() { _ = stmt.Close() }() |
|
|
| for _, record := range records { |
| if _, err := stmt.Exec(record...); err != nil { |
| return 0, 0, fmt.Errorf("插入数据失败: %w", err) |
| } |
| } |
|
|
| if err := tx.Commit(); err != nil { |
| return 0, 0, fmt.Errorf("提交事务失败: %w", err) |
| } |
|
|
| return len(records), lastID, nil |
| } |
|
|