Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase, internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится. Инвариант «не более одной активной загрузки на infohash» вместо снятого unique-индекса держат guarded-методы store в одной write-транзакции (_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет терминал→активное как механический бэкстоп. Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при включённых FK, backfill ULID с timestamp из created_at (хронология id сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id в URL/логах/Telegram, REST-поля id (string) и infohashes (список). Новая конвенция docs/conventions/database.md (без числовых PK), корреляция в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены ревью дизайна и кода (по 8 углов), все находки исправлены с регрессионными тестами. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+31
-28
@@ -4,12 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
@@ -30,13 +30,13 @@ type Ingestor interface {
|
||||
|
||||
// Reviewer — операции ревью (worker.Worker).
|
||||
type Reviewer interface {
|
||||
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id int64) error
|
||||
Refine(ctx context.Context, id int64, hint string) error
|
||||
SetType(ctx context.Context, id int64, mediaType string) error
|
||||
Defer(ctx context.Context, id int64) error
|
||||
Cancel(ctx context.Context, id int64) error
|
||||
Retry(ctx context.Context, id int64) error
|
||||
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id string) error
|
||||
Refine(ctx context.Context, id string, hint string) error
|
||||
SetType(ctx context.Context, id string, mediaType string) error
|
||||
Defer(ctx context.Context, id string) error
|
||||
Cancel(ctx context.Context, id string) error
|
||||
Retry(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Config — параметры бота.
|
||||
@@ -54,8 +54,8 @@ type Bot struct {
|
||||
webBase string
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex // защищает pending
|
||||
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
|
||||
mu sync.Mutex // защищает pending
|
||||
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
||||
}
|
||||
|
||||
// New собирает бота поверх клиента Telegram.
|
||||
@@ -71,7 +71,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
|
||||
allowed: allowed,
|
||||
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||
log: log,
|
||||
pending: map[int64]int64{},
|
||||
pending: map[int64]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||||
return
|
||||
}
|
||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
|
||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
|
||||
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
|
||||
if res.Deduplicated {
|
||||
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
|
||||
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State)
|
||||
}
|
||||
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||
}
|
||||
@@ -173,8 +173,10 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
}
|
||||
|
||||
action, id, val := parseCallback(cq.Data)
|
||||
if id == 0 {
|
||||
b.answer(cq.ID, "")
|
||||
if id == "" {
|
||||
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
|
||||
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
|
||||
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
||||
return
|
||||
}
|
||||
chatID := cq.Message.Chat.ID
|
||||
@@ -201,7 +203,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
case "refine":
|
||||
b.setPending(chatID, id)
|
||||
b.answer(cq.ID, "Жду подсказку")
|
||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
|
||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
|
||||
return
|
||||
default:
|
||||
b.answer(cq.ID, "")
|
||||
@@ -218,7 +220,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
}
|
||||
|
||||
// refreshCard перечитывает задачу и обновляет карточку на месте.
|
||||
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
|
||||
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id string) {
|
||||
rd, err := b.reviewer.ReviewData(ctx, id)
|
||||
if err != nil {
|
||||
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
|
||||
@@ -239,7 +241,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
|
||||
// --- Notifier (worker.Notifier) ---
|
||||
|
||||
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
|
||||
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
|
||||
func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.NotifyEvent) {
|
||||
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
||||
if err != nil {
|
||||
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
|
||||
@@ -281,13 +283,13 @@ func (b *Bot) answer(callbackID, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) setPending(chatID, id int64) {
|
||||
func (b *Bot) setPending(chatID int64, id string) {
|
||||
b.mu.Lock()
|
||||
b.pending[chatID] = id
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bot) takePending(chatID int64) (int64, bool) {
|
||||
func (b *Bot) takePending(chatID int64) (string, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
id, ok := b.pending[chatID]
|
||||
@@ -300,20 +302,21 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
|
||||
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
||||
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
||||
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
||||
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
|
||||
func opErr(msg string, downloadID int64) string {
|
||||
if downloadID > 0 {
|
||||
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
|
||||
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
|
||||
func opErr(msg string, downloadID string) string {
|
||||
if downloadID != "" {
|
||||
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
|
||||
}
|
||||
return msg + "."
|
||||
}
|
||||
|
||||
// parseCallback разбирает "action[:id[:value]]".
|
||||
func parseCallback(data string) (action string, id int64, value string) {
|
||||
// parseCallback разбирает "action[:id[:value]]". id валидируется как ULID
|
||||
// (входная граница); невалидный/устаревший (числовой) → пустая строка.
|
||||
func parseCallback(data string) (action string, id string, value string) {
|
||||
parts := strings.Split(data, ":")
|
||||
action = parts[0]
|
||||
if len(parts) > 1 {
|
||||
id, _ = strconv.ParseInt(parts[1], 10, 64)
|
||||
id, _ = ident.Parse(parts[1])
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
value = parts[2]
|
||||
|
||||
Reference in New Issue
Block a user