Хранение времени: RFC 3339 (UTC) + таймзона отображения в конфиге
Метки времени в SQLite переведены с формата datetime('now')
(«2006-01-02 15:04:05») на RFC 3339 всегда-UTC («2006-01-02T15:04:05Z»):
самоописываемое хранилище (зона в значении), валидный ISO 8601, единый
формат с логами. Фиксированная ширина сохраняет лексикографическую
сортировку TEXT = хронологию (COALESCE(source_added_at, created_at)).
- Единая точка генерации времени в Go: store.Now()/FormatTime; DEFAULT
(datetime('now')) снят со всех колонок — время всегда пишет приложение
(зеркально ident.NewID для id), fail-loud при забытой вставке (NOT NULL).
Все INSERT-сайты в store передают created_at/updated_at явно.
- Миграция 0008 (rebuild 7 таблиц без DEFAULT + backfill strftime, FK/PK/
индексы сохранены байт-в-байт по образцу 0006); симметричная down.
- Новая секция конфига [general] с полем timezone (дефолт UTC) — зона
ОТОБРАЖЕНИЯ в веб-UI; хранение остаётся UTC. Жёсткая валидация зоны на
старте; zoneinfo встроен (time/tzdata), заменён зашитый Europe/Moscow.
- Тесты: round-trip миграции (up/down, NULL source_added_at), валидация
зоны, сдвиг даты по зоне; обновлены фикстуры и TestUlidMigration.
- Docs: конвенции database/config, ER-схема; спека web-ui (таймзона).
OpenSpec change time-storage-rfc3339 (заархивирован).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+27
-22
@@ -115,7 +115,7 @@ type Download struct {
|
||||
SourceMissCount int `db:"source_miss_count"`
|
||||
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
|
||||
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||
// формате sqliteTimeLayout (UTC), как created_at.
|
||||
// формате RFC 3339 (UTC, суффикс Z), как created_at.
|
||||
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
@@ -148,22 +148,25 @@ func (d Download) PrimaryInfohash() string {
|
||||
return d.Infohashes[0].Infohash
|
||||
}
|
||||
|
||||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
||||
const sqliteTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// ParseTime разбирает временную метку SQLite (datetime('now'), всегда UTC).
|
||||
// ParseTime разбирает временную метку хранилища (RFC 3339, всегда UTC).
|
||||
func ParseTime(s string) (time.Time, error) {
|
||||
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
|
||||
return time.Parse(time.RFC3339, s)
|
||||
}
|
||||
|
||||
// FormatTime форматирует время в формат меток SQLite (datetime('now'), UTC) —
|
||||
// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи-
|
||||
// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в
|
||||
// сортировке списка) давало верный хронологический порядок.
|
||||
// FormatTime форматирует время в формат меток хранилища — RFC 3339 в UTC
|
||||
// (суффикс Z), напр. «2006-01-02T15:04:05Z». Единый формат всех меток; DEFAULT
|
||||
// в схеме нет, время всегда пишет приложение через Now (см. ниже). Фиксированная
|
||||
// ширина сохраняет лексикографическое сравнение строк времени = хронологию
|
||||
// (COALESCE(source_added_at, created_at) в сортировке списка).
|
||||
func FormatTime(t time.Time) string {
|
||||
return t.UTC().Format(sqliteTimeLayout)
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Now — единая точка получения «сейчас» для меток времени store (UTC). Всё время
|
||||
// в БД генерирует приложение через неё (аналогично ident.NewID для id), а не
|
||||
// SQLite DEFAULT: один источник формата, тестируемая точка.
|
||||
func Now() time.Time { return time.Now().UTC() }
|
||||
|
||||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
||||
|
||||
@@ -182,6 +185,7 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
|
||||
if len(norm) == 0 {
|
||||
return nil, fmt.Errorf("create download: no infohash")
|
||||
}
|
||||
now := FormatTime(Now())
|
||||
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -200,8 +204,8 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
|
||||
// нему создал бы вторую активную задачу.
|
||||
for _, h := range norm {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
existing.ID, h, HashKind(h)); err != nil {
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||
existing.ID, h, HashKind(h), now); err != nil {
|
||||
return nil, fmt.Errorf("create download: top up infohash: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -216,17 +220,17 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
|
||||
|
||||
d.ID = ident.NewID()
|
||||
const q = `
|
||||
INSERT INTO download (id, source_type, source_ref, display_name, context, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO download (id, source_type, source_ref, display_name, context, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State); err != nil {
|
||||
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State, now, now); err != nil {
|
||||
return nil, fmt.Errorf("insert download: %w", err)
|
||||
}
|
||||
d.Infohashes = d.Infohashes[:0]
|
||||
for _, h := range norm {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
d.ID, h, HashKind(h)); err != nil {
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||
d.ID, h, HashKind(h), now); err != nil {
|
||||
return nil, fmt.Errorf("insert download infohash: %w", err)
|
||||
}
|
||||
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
|
||||
@@ -286,6 +290,7 @@ func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []s
|
||||
if len(norm) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := FormatTime(Now())
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add infohashes to %s: begin tx: %w", downloadID, err)
|
||||
@@ -303,8 +308,8 @@ func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []s
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
downloadID, h, HashKind(h)); err != nil {
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
|
||||
downloadID, h, HashKind(h), now); err != nil {
|
||||
return fmt.Errorf("add infohash %s to %s: %w", h, downloadID, err)
|
||||
}
|
||||
}
|
||||
@@ -507,9 +512,9 @@ UPDATE download
|
||||
SET state = ?,
|
||||
error_code = ?,
|
||||
error_msg = ?,
|
||||
updated_at = datetime('now')
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
args := []any{string(state), nullArg(errCode), nullArg(errMsg), id}
|
||||
args := []any{string(state), nullArg(errCode), nullArg(errMsg), FormatTime(Now()), id}
|
||||
if !reviveOK && !state.IsTerminal() {
|
||||
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
|
||||
}
|
||||
|
||||
@@ -320,8 +320,8 @@ func TestActivateExcludesSelf(t *testing.T) {
|
||||
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
|
||||
// такого не создаст, но обязан не маскировать).
|
||||
if _, err := st.DB.ExecContext(ctx,
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, 'v1')`,
|
||||
newer, h); err != nil {
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, 'v1', ?)`,
|
||||
newer, h, FormatTime(Now())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -97,7 +98,9 @@ func TestUlidMigration(t *testing.T) {
|
||||
if len(three.Infohashes) != 0 {
|
||||
t.Fatalf("хеши #3 = %+v, want пусто", three.Infohashes)
|
||||
}
|
||||
if one.CreatedAt != "2026-01-01 10:00:00" || one.UpdatedAt != "2026-01-01 11:00:00" {
|
||||
// Метки переформатированы миграцией 0008 в RFC 3339 (UTC, суффикс Z) —
|
||||
// момент сохранён; заодно канарейка, что backfill 0008 отработал на данных.
|
||||
if one.CreatedAt != "2026-01-01T10:00:00Z" || one.UpdatedAt != "2026-01-01T11:00:00Z" {
|
||||
t.Fatalf("метки #1 = %q / %q", one.CreatedAt, one.UpdatedAt)
|
||||
}
|
||||
|
||||
@@ -140,3 +143,58 @@ func TestUlidMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRFC3339TimeMigration проверяет миграцию 0008: при Up метки переформатиру-
|
||||
// ются из datetime('now') (пробел) в RFC 3339 (UTC, суффикс Z), при Down — обра-
|
||||
// тно; NULL source_added_at не затрагивается, момент времени сохраняется.
|
||||
func TestRFC3339TimeMigration(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/time.db"
|
||||
db, err := sqlx.Connect("sqlite",
|
||||
"file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
goose.SetLogger(goose.NopLogger())
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Схема до 0008: DEFAULT (datetime('now')), формат меток с пробелом.
|
||||
if err := goose.UpTo(db.DB, "migrations", 7); err != nil {
|
||||
t.Fatalf("up to 7: %v", err)
|
||||
}
|
||||
// Строка со старой меткой; source_added_at = NULL (проверяем щажение).
|
||||
if _, err := db.Exec(`INSERT INTO download (id, source_type, source_ref, state, created_at, updated_at)
|
||||
VALUES ('d1', 'magnet', 'magnet:x', 'downloading', '2026-03-01 12:00:00', '2026-03-01 13:00:00')`); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
read := func() (string, string, sql.NullString) {
|
||||
var c, u string
|
||||
var sa sql.NullString
|
||||
if err := db.QueryRowx(
|
||||
`SELECT created_at, updated_at, source_added_at FROM download WHERE id='d1'`).
|
||||
Scan(&c, &u, &sa); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
return c, u, sa
|
||||
}
|
||||
|
||||
// Up 0008 → RFC 3339.
|
||||
if err := goose.UpTo(db.DB, "migrations", 8); err != nil {
|
||||
t.Fatalf("up to 8: %v", err)
|
||||
}
|
||||
if c, u, sa := read(); c != "2026-03-01T12:00:00Z" || u != "2026-03-01T13:00:00Z" || sa.Valid {
|
||||
t.Fatalf("после up: %q / %q / sa.Valid=%v, want RFC 3339 и NULL sa", c, u, sa.Valid)
|
||||
}
|
||||
|
||||
// Down 0008 → формат с пробелом.
|
||||
if err := goose.DownTo(db.DB, "migrations", 7); err != nil {
|
||||
t.Fatalf("down to 7: %v", err)
|
||||
}
|
||||
if c, u, sa := read(); c != "2026-03-01 12:00:00" || u != "2026-03-01 13:00:00" || sa.Valid {
|
||||
t.Fatalf("после down: %q / %q / sa.Valid=%v, want формат с пробелом и NULL sa", c, u, sa.Valid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upRFC3339Time, downRFC3339Time)
|
||||
}
|
||||
|
||||
// upRFC3339Time переводит хранение временных меток на RFC 3339 (UTC, суффикс Z,
|
||||
// напр. «2006-01-02T15:04:05Z») и убирает `DEFAULT (datetime('now'))` со всех
|
||||
// колонок: время отныне пишет только приложение (store.Now/FormatTime) — единая
|
||||
// точка генерации, fail-loud при забытой вставке (NOT NULL без DEFAULT).
|
||||
//
|
||||
// SQLite не умеет ALTER COLUMN DROP DEFAULT, поэтому перестраиваем 7 таблиц по
|
||||
// образцу миграции 0006 (FK включены): создаём *_new без DEFAULT на метках,
|
||||
// копируем данные с переформатированием меток через strftime (id и связи НЕ
|
||||
// меняются — момент времени сохраняется, ULID-корреляция цела), удаляем старые
|
||||
// (дети первыми), переименовываем (SQLite ≥3.25 переписывает REFERENCES),
|
||||
// пересоздаём индексы и проверяем целостность FK.
|
||||
func upRFC3339Time(ctx context.Context, tx *sql.Tx) error {
|
||||
return rebuildTimeColumns(ctx, tx, "", rfc3339Expr)
|
||||
}
|
||||
|
||||
// downRFC3339Time возвращает прежний формат меток (`YYYY-MM-DD HH:MM:SS`) и
|
||||
// `DEFAULT (datetime('now'))` на колонках. Обратима без потерь: strftime
|
||||
// разбирает RFC 3339 (T/Z) и момент времени сохраняется.
|
||||
func downRFC3339Time(ctx context.Context, tx *sql.Tx) error {
|
||||
return rebuildTimeColumns(ctx, tx, " DEFAULT (datetime('now'))", sqliteExpr)
|
||||
}
|
||||
|
||||
// rfc3339Expr / sqliteExpr — SQL-выражение переформатирования метки col в
|
||||
// целевой формат (strftime корректно разбирает и пробел-, и T/Z-форму на входе).
|
||||
func rfc3339Expr(col string) string {
|
||||
return fmt.Sprintf("strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', %s)", col)
|
||||
}
|
||||
|
||||
func sqliteExpr(col string) string {
|
||||
return fmt.Sprintf("strftime('%%Y-%%m-%%d %%H:%%M:%%S', %s)", col)
|
||||
}
|
||||
|
||||
// rebuildTimeColumns перестраивает все таблицы с временными метками: tsDefault —
|
||||
// суффикс DEFAULT для колонок created_at/updated_at ("" — без дефолта); ts —
|
||||
// выражение переформатирования метки при копировании.
|
||||
func rebuildTimeColumns(ctx context.Context, tx *sql.Tx, tsDefault string, ts func(string) string) error {
|
||||
stmts := make([]string, 0, 28)
|
||||
stmts = append(stmts, createTimeTables(tsDefault)...)
|
||||
stmts = append(stmts, copyTimeTables(ts)...)
|
||||
stmts = append(stmts, dropRenameReindex...)
|
||||
for _, stmt := range stmts {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("rfc3339 time migration: %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
return checkForeignKeys(ctx, tx)
|
||||
}
|
||||
|
||||
// createTimeTables — CREATE *_new для всех таблиц; d подставляет DEFAULT на
|
||||
// метки created_at/updated_at (прочие DEFAULT — ”, 0, '[]', 1 — неизменны).
|
||||
func createTimeTables(d string) []string {
|
||||
return []string{
|
||||
fmt.Sprintf(`CREATE TABLE download_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_type TEXT NOT NULL,
|
||||
source_ref TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
context TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
error_msg TEXT,
|
||||
source_miss_count INTEGER NOT NULL DEFAULT 0,
|
||||
source_added_at TEXT,
|
||||
created_at TEXT NOT NULL%[1]s,
|
||||
updated_at TEXT NOT NULL%[1]s
|
||||
)`, d),
|
||||
fmt.Sprintf(`CREATE TABLE download_infohash_new (
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
infohash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL%[1]s,
|
||||
PRIMARY KEY (infohash, download_id)
|
||||
)`, d),
|
||||
fmt.Sprintf(`CREATE TABLE recognition_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
attempt_no INTEGER NOT NULL DEFAULT 1,
|
||||
is_current INTEGER NOT NULL DEFAULT 1,
|
||||
media_type TEXT,
|
||||
title TEXT,
|
||||
original_title TEXT,
|
||||
year INTEGER,
|
||||
provider TEXT,
|
||||
provider_id TEXT,
|
||||
confidence REAL,
|
||||
reasons TEXT NOT NULL DEFAULT '[]',
|
||||
raw_llm TEXT,
|
||||
plan TEXT,
|
||||
created_at TEXT NOT NULL%[1]s
|
||||
)`, d),
|
||||
fmt.Sprintf(`CREATE TABLE hint_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL%[1]s
|
||||
)`, d),
|
||||
fmt.Sprintf(`CREATE TABLE override_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL%[1]s,
|
||||
UNIQUE (download_id, field)
|
||||
)`, d),
|
||||
fmt.Sprintf(`CREATE TABLE metadata_candidate_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
recognition_id TEXT NOT NULL REFERENCES recognition_new (id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
chosen INTEGER NOT NULL DEFAULT 0,
|
||||
url TEXT,
|
||||
created_at TEXT NOT NULL%[1]s
|
||||
)`, d),
|
||||
fmt.Sprintf(`CREATE TABLE file_link_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
apply_batch_id TEXT NOT NULL,
|
||||
src_path TEXT NOT NULL,
|
||||
dst_path TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL%[1]s
|
||||
)`, d),
|
||||
}
|
||||
}
|
||||
|
||||
// copyTimeTables — INSERT ... SELECT в *_new с переформатированием меток через
|
||||
// ts(col). Списки колонок явные; strftime(NULL) = NULL — nullable source_added_at
|
||||
// корректно щадится.
|
||||
func copyTimeTables(ts func(string) string) []string {
|
||||
return []string{
|
||||
fmt.Sprintf(`INSERT INTO download_new
|
||||
(id, source_type, source_ref, display_name, context, state, error_code,
|
||||
error_msg, source_miss_count, source_added_at, created_at, updated_at)
|
||||
SELECT id, source_type, source_ref, display_name, context, state, error_code,
|
||||
error_msg, source_miss_count, %s, %s, %s
|
||||
FROM download`, ts("source_added_at"), ts("created_at"), ts("updated_at")),
|
||||
fmt.Sprintf(`INSERT INTO download_infohash_new (download_id, infohash, kind, created_at)
|
||||
SELECT download_id, infohash, kind, %s FROM download_infohash`, ts("created_at")),
|
||||
fmt.Sprintf(`INSERT INTO recognition_new
|
||||
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
|
||||
SELECT id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||
year, provider, provider_id, confidence, reasons, raw_llm, plan, %s
|
||||
FROM recognition`, ts("created_at")),
|
||||
fmt.Sprintf(`INSERT INTO hint_new (id, download_id, text, created_at)
|
||||
SELECT id, download_id, text, %s FROM hint`, ts("created_at")),
|
||||
fmt.Sprintf(`INSERT INTO override_new (id, download_id, field, value, created_at)
|
||||
SELECT id, download_id, field, value, %s FROM override`, ts("created_at")),
|
||||
fmt.Sprintf(`INSERT INTO metadata_candidate_new
|
||||
(id, recognition_id, provider, provider_id, title, year, chosen, url, created_at)
|
||||
SELECT id, recognition_id, provider, provider_id, title, year, chosen, url, %s
|
||||
FROM metadata_candidate`, ts("created_at")),
|
||||
fmt.Sprintf(`INSERT INTO file_link_new
|
||||
(id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
|
||||
SELECT id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, %s
|
||||
FROM file_link`, ts("created_at")),
|
||||
}
|
||||
}
|
||||
|
||||
// dropRenameReindex — удаление старых таблиц (дети первыми, FK включены),
|
||||
// переименование *_new и пересоздание индексов (канонический набор из 0006).
|
||||
var dropRenameReindex = []string{
|
||||
`DROP TABLE file_link`,
|
||||
`DROP TABLE metadata_candidate`,
|
||||
`DROP TABLE override`,
|
||||
`DROP TABLE hint`,
|
||||
`DROP TABLE recognition`,
|
||||
`DROP TABLE download_infohash`,
|
||||
`DROP TABLE download`,
|
||||
`ALTER TABLE download_new RENAME TO download`,
|
||||
`ALTER TABLE download_infohash_new RENAME TO download_infohash`,
|
||||
`ALTER TABLE recognition_new RENAME TO recognition`,
|
||||
`ALTER TABLE hint_new RENAME TO hint`,
|
||||
`ALTER TABLE override_new RENAME TO override`,
|
||||
`ALTER TABLE metadata_candidate_new RENAME TO metadata_candidate`,
|
||||
`ALTER TABLE file_link_new RENAME TO file_link`,
|
||||
`CREATE INDEX idx_download_state ON download (state)`,
|
||||
`CREATE INDEX idx_download_infohash_download ON download_infohash (download_id)`,
|
||||
`CREATE INDEX idx_recognition_download ON recognition (download_id)`,
|
||||
`CREATE INDEX idx_hint_download ON hint (download_id)`,
|
||||
`CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id)`,
|
||||
`CREATE INDEX idx_file_link_download ON file_link (download_id)`,
|
||||
`CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id)`,
|
||||
}
|
||||
@@ -71,11 +71,12 @@ func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons [
|
||||
const q = `
|
||||
INSERT INTO recognition
|
||||
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||
year, provider, provider_id, confidence, reasons, raw_llm, plan)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan); err != nil {
|
||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
|
||||
FormatTime(Now())); err != nil {
|
||||
return "", fmt.Errorf("insert recognition: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -105,8 +106,8 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*
|
||||
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
|
||||
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
`INSERT INTO hint (id, download_id, text) VALUES (?, ?, ?)`,
|
||||
ident.NewID(), downloadID, text); err != nil {
|
||||
`INSERT INTO hint (id, download_id, text, created_at) VALUES (?, ?, ?, ?)`,
|
||||
ident.NewID(), downloadID, text, FormatTime(Now())); err != nil {
|
||||
return fmt.Errorf("add hint: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -127,9 +128,9 @@ func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, err
|
||||
// SetOverride пиннит значение поля (upsert по (download_id, field)).
|
||||
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
|
||||
const q = `
|
||||
INSERT INTO override (id, download_id, field, value) VALUES (?, ?, ?, ?)
|
||||
INSERT INTO override (id, download_id, field, value, created_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
|
||||
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value); err != nil {
|
||||
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value, FormatTime(Now())); err != nil {
|
||||
return fmt.Errorf("set override %q: %w", field, err)
|
||||
}
|
||||
return nil
|
||||
@@ -181,12 +182,13 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
now := FormatTime(Now())
|
||||
const q = `
|
||||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for _, l := range links {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size); err != nil {
|
||||
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size, now); err != nil {
|
||||
return fmt.Errorf("insert file_link: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -328,12 +330,13 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
now := FormatTime(Now())
|
||||
const q = `
|
||||
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for _, c := range cands {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
|
||||
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL, now); err != nil {
|
||||
return fmt.Errorf("insert candidate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user