package service import ( "errors" "fmt" "time" "git.gm6.ru/icewind/seadoc/models" "git.gm6.ru/icewind/seadoc/store" jsonpatch "github.com/evanphx/json-patch/v5" "github.com/google/uuid" "gorm.io/gorm" ) // VersionService — слой логики версионирования, использующий интерфейс VersionStorage. type VersionService struct { storage store.IVersionStorage // snapshotInterval (опционально) — через сколько версий делать full snapshot snapshotInterval int } // NewVersionService создаёт новый сервис func NewVersionService(storage store.IVersionStorage) *VersionService { return &VersionService{storage: storage, snapshotInterval: 0} } // SetSnapshotInterval задаёт через сколько версий сохранять snapshot (если >0) func (s *VersionService) SetSnapshotInterval(n int) { s.snapshotInterval = n } // SaveNewVersion создаёт новую версию документа (merge-patch или snapshot) func (s *VersionService) SaveNewVersion(docID uuid.UUID, newJSON []byte) (*models.Version, error) { lastVer, err := s.storage.GetLatestVersion(docID) var patchBytes []byte isSnapshot := false var parentID *uuid.UUID if err != nil { // первая версия — snapshot if errors.Is(err, gorm.ErrRecordNotFound) { isSnapshot = true parentID = nil } else { return nil, err } } else { // есть предыдущая версия — создаём merge-patch parentID = &lastVer.ID var oldJSON []byte if lastVer.IsSnapshot { oldJSON = lastVer.Snapshot } else { oldJSON, err = s.ReconstructVersion(lastVer.ID) if err != nil { return nil, err } } // Создаём JSON Merge Patch (RFC 7386) // CreateMergePatch возвращает []byte с содержимым merge-patch patchBytes, err = jsonpatch.CreateMergePatch(oldJSON, newJSON) if err != nil { return nil, fmt.Errorf("create merge patch: %w", err) } // Если patch пустой (документы равны), можно не сохранять новую версию // В данном примере создаём версию даже если patch пустой — можно изменить поведение } newVer := models.Version{ ID: uuid.New(), DocumentID: docID, ParentID: parentID, IsSnapshot: isSnapshot, Patch: patchBytes, CreatedAt: time.Now(), } if isSnapshot { newVer.Snapshot = newJSON } // Сохраняем версию в хранилище if err := s.storage.CreateVersion(&newVer); err != nil { return nil, err } // Обновляем ссылку на последнюю версию документа if err := s.storage.UpdateLatestVersion(docID, newVer.ID); err != nil { return nil, err } // Опционально: если указан snapshotInterval, проверяем нужно ли создать snapshot if s.snapshotInterval > 0 && !isSnapshot { // считаем количество версий от этого snapshot'а до новой версии // простая реализация: пройти вверх по родителям и посчитать count := 0 curr := &newVer for { count++ if curr.ParentID == nil { break } parent, err := s.storage.GetParentVersion(curr) if err != nil { break } if parent == nil { break } curr = parent if curr.IsSnapshot { break } } if count >= s.snapshotInterval { // делаем snapshot: восстанавливаем полную версию и сохраняем snapshot-версию full, err := s.ReconstructVersion(newVer.ID) if err == nil { snapVer := models.Version{ ID: uuid.New(), DocumentID: docID, ParentID: &newVer.ID, IsSnapshot: true, Snapshot: full, CreatedAt: time.Now(), } // сохраняем snapshot-версию и обновляем latest_version _ = s.storage.CreateVersion(&snapVer) _ = s.storage.UpdateLatestVersion(docID, snapVer.ID) } } } return &newVer, nil } // ReconstructVersion восстанавливает полную JSON-версию по ID // Патчи — в формате JSON Merge Patch (RFC 7386) применяются последовательно к ближайшему snapshot. func (s *VersionService) ReconstructVersion(versionID uuid.UUID) ([]byte, error) { v, err := s.storage.GetVersionByID(versionID) if err != nil { return nil, err } // Если это snapshot — возвращаем сразу if v.IsSnapshot { return v.Snapshot, nil } // Собираем цепочку версий от ближайшего snapshot (включая snapshot) до целевой версии chain := []models.Version{} curr := v for { // prepend chain = append([]models.Version{*curr}, chain...) if curr.ParentID == nil { // дошли до корня break } parent, err := s.storage.GetParentVersion(curr) if err != nil { return nil, err } if parent == nil { break } curr = parent // Если parent — snapshot, добавим его и прервём цикл, т.к. от него будем применять if curr.IsSnapshot { chain = append([]models.Version{*curr}, chain...) break } } // Найдём snapshot в цепочке startIndex := -1 for i, ver := range chain { if ver.IsSnapshot { startIndex = i break } } if startIndex == -1 { return nil, fmt.Errorf("snapshot not found in version chain for %s", versionID) } // Начинаем с snapshot data := chain[startIndex].Snapshot // Применяем последовательно merge-patch'и от snapshot+1 до конца chain for i := startIndex + 1; i < len(chain); i++ { if len(chain[i].Patch) == 0 { continue } // MergePatch принимает исходный документ и merge-patch и возвращает новый документ newData, err := jsonpatch.MergePatch(data, chain[i].Patch) if err != nil { return nil, fmt.Errorf("apply merge patch at version %s: %w", chain[i].ID, err) } data = newData } return data, nil } // DiffVersions создаёт JSON Merge Patch между двумя версиями. // Возвращает JSON-патч (diff), показывающий, какие поля изменились. func (s *VersionService) DiffVersions(v1ID, v2ID uuid.UUID) ([]byte, error) { // Восстанавливаем обе версии полностью data1, err := s.ReconstructVersion(v1ID) if err != nil { return nil, err } data2, err := s.ReconstructVersion(v2ID) if err != nil { return nil, err } // Вычисляем разницу patch, err := jsonpatch.CreateMergePatch(data1, data2) if err != nil { return nil, err } return patch, nil }