package projectindex import ( "context" "database/sql" "fmt" "strings" "time" ) // timeLayout 是每个已存 created_at/updated_at 值使用的定宽 // 时间戳布局。与 time.RFC3339Nano 不同,它从不丢弃末尾 // 小数零,因此每个值恰好 storedTimeLen 字节宽;SQLite // 把这些 TEXT 列按普通字符串比较,而等宽正是让 // 该比较与时间顺序一致的原因。UTC 值把时区渲染为 // 单个 "Z"。 const timeLayout = "2006-01-02T15:04:05.000000000Z07:00" // storedTimeLen 是用 timeLayout 格式化的 UTC 值的字节长度: // "2006-01-02T15:04:05.000000000Z" —— Z07:00 时区在 UTC 下坍缩为 "Z"。 const storedTimeLen = 30 // 打开时需要规范化的表名;它们必须与 store.go 中的 DDL 一致。 const ( projectTable = "project" projectChapterTable = "project_chapter" ) // formatTime 用定宽 timeLayout 把 t 编码为 UTC。始终写 // UTC 使排序与往返在各进程间保持稳定,而定宽 // 正是 ORDER BY created_at 能按时间排序这些 TEXT 列的原因。 func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) } // parseTime 解码一个已存时间戳。time.RFC3339Nano 同时接受 // 本包写入的定宽值与旧的变宽值,因此同一个解析器既服务 // 读取、也服务打开时的规范化。空输入 // 得到零值时间。 func parseTime(s string) (time.Time, error) { if strings.TrimSpace(s) == "" { return time.Time{}, nil } t, err := time.Parse(time.RFC3339Nano, s) if err != nil { return time.Time{}, fmt.Errorf("projectindex: parse time %q: %w", s, err) } return t, nil } // normalizeTimestamps 把每个非 storedTimeLen 字节宽的 project / // project_chapter 时间戳重写为定宽 timeLayout。布局存在之前写入的行 // 携带变宽 RFC3339Nano 文本,其字符串 // 顺序并非时间顺序;这把它们拉齐。 // // 它是幂等的:只选取列长度与 storedTimeLen 不同的行, // 因此在已规范化的表上再跑一次是空操作。值解析不了的行 // 原样保留 —— 一个坏的遗留值不该 // 妨碍存储打开。 func normalizeTimestamps(ctx context.Context, db *sql.DB) error { for _, table := range []string{projectTable, projectChapterTable} { for _, col := range []string{"created_at", "updated_at"} { if err := normalizeTimeColumn(ctx, db, table, col); err != nil { return err } } } return nil } // normalizeTimeColumn 重写 table.col 中每个非 storedTimeLen // 字节宽的值。table 与 col 是编译期常量,绝非用户输入,因此 // 把它们拼进语句是安全的;长度比较与每个 // 写入值都参数化。所有重写都在任何 UPDATE // 执行前收集完毕,因此 SELECT 游标仍打开时不会发生写入。 func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) error { fixes, err := collectLegacyTimestamps(ctx, db, table, col) if err != nil { return err } for _, f := range fixes { if _, err := db.ExecContext(ctx, "UPDATE "+f.table+" SET "+f.col+" = ? WHERE id = ?", f.value, f.id); err != nil { return fmt.Errorf("projectindex: normalize %s.%s: %w", f.table, f.col, err) } } return nil } // timeFix 是一条待执行的时间戳重写:哪张表哪一列、哪一行 // (按主键 id),以及要写入的定宽值。 type timeFix struct { table string col string id string value string } // collectLegacyTimestamps 读取 table.col 中值非 storedTimeLen // 字节宽的行,把可解析的作为待重写项返回。 // 不可解析的值跳过,因此规范化绝不会让某行失败: // 唯一的错误是真正的读取失败。 func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string) ([]timeFix, error) { rows, err := db.QueryContext(ctx, "SELECT id, "+col+" FROM "+table+" WHERE length("+col+") != ?", storedTimeLen) if err != nil { return nil, fmt.Errorf("projectindex: query legacy %s.%s: %w", table, col, err) } defer func() { _ = rows.Close() }() fixes := make([]timeFix, 0) for rows.Next() { var id, raw string if err := rows.Scan(&id, &raw); err != nil { return nil, fmt.Errorf("projectindex: scan legacy %s.%s: %w", table, col, err) } fixed, ok := reformatTime(raw) if !ok { continue } fixes = append(fixes, timeFix{table: table, col: col, id: id, value: fixed}) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("projectindex: iterate legacy %s.%s: %w", table, col, err) } return fixes, nil } // reformatTime 用 timeLayout 重新编码 raw。raw 已经 // storedTimeLen 宽、或无法解析时 bool 为 false,此时调用方 // 保持存储值不动。 func reformatTime(raw string) (string, bool) { if len(raw) == storedTimeLen { return "", false } t, err := parseTime(raw) if err != nil || t.IsZero() { return "", false } return formatTime(t), true }