feat: 编辑和删除本人书籍与章节 (#10)
- PATCH /books/:id 改名、GET /chapters/:id/source 读取编辑用原文(任意状态)、 PATCH /chapters/:id 改标题与正文、DELETE /books/:id 与 DELETE /chapters/:id - 版本门控:任务只在 job.content_sha256 与章节版本一致时才能影响章节;过期版本任务 被标为 superseded 且完全不触碰章节,认领与恢复扫描跳过并作废它们,重试旧版本任务 409 - 只有正文变化才重新处理:重复保存或改回原内容不新建任务;只改标题不改状态 - 删除在事务内硬删除并沿用外键级联,章节删除后重排序号;个人词条、复习排期与作答记录保留 - 处理中删除章节后,在途任务不再发布也不报错;并发删除同一章由书籍行锁序列化 - 学习端:书名与章节编辑对话框、删除确认弹窗、章节行编辑入口、书库删除提示 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
admin "go-admin/app/admin/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Editing and deleting are strictly owner scoped. Nothing here touches personal terms,
|
||||
// review rows or answers: those belong to the learner, not to a book or a chapter.
|
||||
|
||||
type BookUpdateInput struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type ChapterUpdateInput struct {
|
||||
Title *string `json:"title"`
|
||||
Text *string `json:"text"`
|
||||
}
|
||||
|
||||
// ChapterSource is the editable text of one owned chapter. It is separate from the reader
|
||||
// contract, which only exposes text once a chapter is ready, so a failed chapter can be
|
||||
// corrected and submitted again.
|
||||
type ChapterSource struct {
|
||||
ID int64 `json:"id"`
|
||||
BookID int64 `json:"bookId"`
|
||||
Ordinal int `json:"ordinal"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"`
|
||||
ContentSHA256 string `json:"contentSha256"`
|
||||
CharCount int `json:"charCount"`
|
||||
}
|
||||
|
||||
type ChapterEdit struct {
|
||||
Chapter ChapterSummary `json:"chapter"`
|
||||
Job *JobView `json:"job"`
|
||||
VersionChanged bool `json:"versionChanged"`
|
||||
}
|
||||
|
||||
type DeletionResult struct {
|
||||
BookID int64 `json:"bookId,omitempty"`
|
||||
ChapterID int64 `json:"chapterId,omitempty"`
|
||||
Chapters int `json:"chapters,omitempty"`
|
||||
Remaining int `json:"remaining"`
|
||||
}
|
||||
|
||||
// editTitle validates a title with the same rules a paste uses, so a renamed book or chapter
|
||||
// stays within the limits the list and reader already rely on.
|
||||
func editTitle(raw string) (string, error) {
|
||||
title := strings.TrimSpace(raw)
|
||||
if title == "" {
|
||||
return "", failure(400, "请填写标题")
|
||||
}
|
||||
if utf8.RuneCountInString(title) > maxTitleRunes {
|
||||
return "", failure(400, "标题最多 120 个字符")
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
// lockOwnedChapter returns the caller's chapter or reports it as missing, so another
|
||||
// account's chapter id is never confirmed to exist.
|
||||
func lockOwnedChapter(tx *gorm.DB, owner int, chapterID int64, chapter *Chapter) error {
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND owner_id = ?", chapterID, owner).First(chapter).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return failure(404, "章节不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func RenameBook(tx *gorm.DB, owner int, bookID int64, input BookUpdateInput, now time.Time) (BookRef, error) {
|
||||
title, err := editTitle(input.Title)
|
||||
if err != nil {
|
||||
return BookRef{}, err
|
||||
}
|
||||
var book Book
|
||||
if err = lockOwnedBook(tx, owner, bookID, &book); err != nil {
|
||||
return BookRef{}, err
|
||||
}
|
||||
book.Title = title
|
||||
book.UpdatedAt = stamp(now)
|
||||
if err = tx.Model(&Book{}).Where("id = ? AND owner_id = ?", book.ID, owner).
|
||||
Updates(map[string]any{"title": title, "updated_at": book.UpdatedAt}).Error; err != nil {
|
||||
return BookRef{}, err
|
||||
}
|
||||
return bookRef(book), nil
|
||||
}
|
||||
|
||||
func ChapterEditSource(tx *gorm.DB, owner int, chapterID int64) (ChapterSource, error) {
|
||||
var chapter Chapter
|
||||
if err := tx.Where("id = ? AND owner_id = ?", chapterID, owner).First(&chapter).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ChapterSource{}, failure(404, "章节不存在")
|
||||
}
|
||||
return ChapterSource{}, err
|
||||
}
|
||||
return ChapterSource{
|
||||
ID: chapter.ID, BookID: chapter.BookID, Ordinal: chapter.Ordinal, Title: chapter.Title,
|
||||
Text: chapter.OriginalText, Status: chapter.Status, ContentSHA256: chapter.ContentSHA256,
|
||||
CharCount: chapter.CharCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateChapter renames a chapter and, when the text really changed, stores it as a new
|
||||
// version and queues a job for it. Only a changed version re-processes: a repeated save of
|
||||
// the same text is idempotent, and a title-only edit never touches the processing state.
|
||||
func UpdateChapter(tx *gorm.DB, owner int, chapterID int64, input ChapterUpdateInput, now time.Time) (ChapterEdit, error) {
|
||||
if input.Title == nil && input.Text == nil {
|
||||
return ChapterEdit{}, failure(400, "请选择要修改的内容")
|
||||
}
|
||||
var chapter Chapter
|
||||
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
|
||||
return ChapterEdit{}, err
|
||||
}
|
||||
ts := stamp(now)
|
||||
updates := map[string]any{"updated_at": ts}
|
||||
if input.Title != nil {
|
||||
title, err := editTitle(*input.Title)
|
||||
if err != nil {
|
||||
return ChapterEdit{}, err
|
||||
}
|
||||
chapter.Title = title
|
||||
updates["title"] = title
|
||||
}
|
||||
var job *IngestJob
|
||||
if input.Text != nil {
|
||||
text := *input.Text
|
||||
if _, sha, count, err := validatePaste(chapter.Title, text); err != nil {
|
||||
return ChapterEdit{}, err
|
||||
} else if sha != chapter.ContentSHA256 {
|
||||
// A new version replaces the text and owns the chapter's state from here on.
|
||||
chapter.OriginalText, chapter.ContentSHA256, chapter.CharCount = text, sha, count
|
||||
chapter.Status, chapter.ErrorReason = statusPending, ""
|
||||
updates["original_text"] = text
|
||||
updates["content_sha256"] = sha
|
||||
updates["char_count"] = count
|
||||
updates["status"] = statusPending
|
||||
updates["error_reason"] = ""
|
||||
// The request key is derived from chapter and version, so one version has one job.
|
||||
key := contentSHA(fmt.Sprintf("edit:%d:%s", chapter.ID, sha))
|
||||
created := IngestJob{OwnerID: owner, BookID: chapter.BookID, ChapterID: chapter.ID,
|
||||
RequestKey: key, ContentSHA256: sha, Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
|
||||
if err := tx.Create(&created).Error; err != nil {
|
||||
return ChapterEdit{}, err
|
||||
}
|
||||
job = &created
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", chapter.ID, owner).Updates(updates).Error; err != nil {
|
||||
return ChapterEdit{}, err
|
||||
}
|
||||
result := ChapterEdit{VersionChanged: job != nil}
|
||||
if job != nil {
|
||||
view := jobView(*job)
|
||||
result.Job = &view
|
||||
}
|
||||
jobID := int64(0)
|
||||
if job != nil {
|
||||
jobID = job.ID
|
||||
}
|
||||
result.Chapter = chapterSummaryWithJob(chapter, &jobID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteBook removes the caller's book with its chapters and their jobs in one transaction.
|
||||
// Personal terms, review schedules and answers are not touched: they belong to the learner.
|
||||
func DeleteBook(tx *gorm.DB, owner int, bookID int64) (DeletionResult, error) {
|
||||
var book Book
|
||||
if err := lockOwnedBook(tx, owner, bookID, &book); err != nil {
|
||||
return DeletionResult{}, err
|
||||
}
|
||||
var chapters int64
|
||||
if err := tx.Model(&Chapter{}).Where("book_id = ? AND owner_id = ?", book.ID, owner).Count(&chapters).Error; err != nil {
|
||||
return DeletionResult{}, err
|
||||
}
|
||||
// Chapters and their jobs go with the book through the foreign keys.
|
||||
if err := tx.Where("id = ? AND owner_id = ?", book.ID, owner).Delete(&Book{}).Error; err != nil {
|
||||
return DeletionResult{}, err
|
||||
}
|
||||
return DeletionResult{BookID: book.ID, Chapters: int(chapters)}, nil
|
||||
}
|
||||
|
||||
// DeleteChapter removes one owned chapter with its jobs and closes the gap in the chapter
|
||||
// order, so "剩余 N 章" and the reader navigation stay contiguous.
|
||||
func DeleteChapter(tx *gorm.DB, owner int, chapterID int64) (DeletionResult, error) {
|
||||
var chapter Chapter
|
||||
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
|
||||
return DeletionResult{}, err
|
||||
}
|
||||
// Lock the book too: two concurrent deletions in one book must not renumber each other.
|
||||
var book Book
|
||||
if err := lockOwnedBook(tx, owner, chapter.BookID, &book); err != nil {
|
||||
return DeletionResult{}, err
|
||||
}
|
||||
if err := tx.Where("id = ? AND owner_id = ?", chapter.ID, owner).Delete(&Chapter{}).Error; err != nil {
|
||||
return DeletionResult{}, err
|
||||
}
|
||||
// Ordering ascending decrements each row into a slot the previous row just freed, which
|
||||
// keeps the unique (book_id, ordinal) key satisfied throughout.
|
||||
if err := tx.Exec("UPDATE lexgo_chapters SET ordinal = ordinal - 1 WHERE book_id = ? AND owner_id = ? AND ordinal > ? ORDER BY ordinal ASC",
|
||||
chapter.BookID, owner, chapter.Ordinal).Error; err != nil {
|
||||
return DeletionResult{}, err
|
||||
}
|
||||
var remaining int64
|
||||
if err := tx.Model(&Chapter{}).Where("book_id = ? AND owner_id = ?", chapter.BookID, owner).Count(&remaining).Error; err != nil {
|
||||
return DeletionResult{}, err
|
||||
}
|
||||
return DeletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Remaining: int(remaining)}, nil
|
||||
}
|
||||
|
||||
func registerEditRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
|
||||
v.PATCH("/books/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
id, err := pathID(c, "书籍不存在")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var input BookUpdateInput
|
||||
if err = decode(c, &input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
book, err := RenameBook(tx, u.UserId, id, input, now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"book": book}, nil
|
||||
}))
|
||||
v.DELETE("/books/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
id, err := pathID(c, "书籍不存在")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := DeleteBook(tx, u.UserId, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"deleted": result}, nil
|
||||
}))
|
||||
v.GET("/chapters/:id/source", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
return nil, failure(400, "正文接口不接受查询参数")
|
||||
}
|
||||
id, err := pathID(c, "章节不存在")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := ChapterEditSource(tx, u.UserId, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"source": source}, nil
|
||||
}))
|
||||
v.PATCH("/chapters/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
id, err := pathID(c, "章节不存在")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var input ChapterUpdateInput
|
||||
if err = decode(c, &input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edited, err := UpdateChapter(tx, u.UserId, id, input, now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return edited, nil
|
||||
}))
|
||||
v.DELETE("/chapters/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
id, err := pathID(c, "章节不存在")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := DeleteChapter(tx, u.UserId, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"deleted": result}, nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const editFixtureText = "Mira opened the workshop.\nThe sign read “A small step…”\n"
|
||||
|
||||
func TestEditTitleRules(t *testing.T) {
|
||||
if title, err := editTitle(" A small step "); err != nil || title != "A small step" {
|
||||
t.Fatalf("trimmed title: %q %v", title, err)
|
||||
}
|
||||
if _, err := editTitle(" "); err == nil {
|
||||
t.Fatal("an empty title must be rejected")
|
||||
}
|
||||
if _, err := editTitle(strings.Repeat("a", maxTitleRunes+1)); err == nil {
|
||||
t.Fatal("a title over the limit must be rejected")
|
||||
}
|
||||
if _, err := editTitle(strings.Repeat("a", maxTitleRunes)); err != nil {
|
||||
t.Fatalf("the exact title limit must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func patchResource(t *testing.T, r *gin.Engine, token, path string, body any) (int, string, json.RawMessage) {
|
||||
t.Helper()
|
||||
return callRaw(t, r, "PATCH", path, token, body)
|
||||
}
|
||||
|
||||
func existingTitle(t *testing.T, r *gin.Engine, token string, bookID int64) string {
|
||||
t.Helper()
|
||||
code, detail := bookDetail(t, r, token, bookID)
|
||||
if code != 200 {
|
||||
t.Fatalf("book detail status %d", code)
|
||||
}
|
||||
return detail.Book.Title
|
||||
}
|
||||
|
||||
// TestMySQLRenameBookAndChapter covers renaming only: the text and the processing state stay
|
||||
// exactly as they were.
|
||||
func TestMySQLRenameBookAndChapter(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
other := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "rename-fixture-0001", "title": "Before Rename", "text": editFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
before := chapterRow(t, db, pasted.Chapter.ID)
|
||||
|
||||
code, msg, data := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": " After Rename "})
|
||||
if code != 200 {
|
||||
t.Fatalf("rename book status %d (%s)", code, msg)
|
||||
}
|
||||
var renamed struct {
|
||||
Book struct {
|
||||
ID int64
|
||||
Title string
|
||||
}
|
||||
}
|
||||
json.Unmarshal(data, &renamed)
|
||||
if renamed.Book.Title != "After Rename" || existingTitle(t, r, learner.Token, pasted.Book.ID) != "After Rename" {
|
||||
t.Fatalf("renamed book %+v", renamed.Book)
|
||||
}
|
||||
|
||||
code, msg, data = patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"title": "Chapter Two"})
|
||||
if code != 200 {
|
||||
t.Fatalf("rename chapter status %d (%s)", code, msg)
|
||||
}
|
||||
var edited ChapterEdit
|
||||
json.Unmarshal(data, &edited)
|
||||
if edited.Chapter.Title != "Chapter Two" || edited.VersionChanged || edited.Job != nil {
|
||||
t.Fatalf("rename must not re-process: %+v", edited)
|
||||
}
|
||||
after := chapterRow(t, db, pasted.Chapter.ID)
|
||||
if after.Title != "Chapter Two" || after.Status != statusReady || after.ContentSHA256 != before.ContentSHA256 || after.OriginalText != before.OriginalText {
|
||||
t.Fatalf("rename changed the content state: %+v", after)
|
||||
}
|
||||
var jobs int64
|
||||
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobs)
|
||||
if jobs != 1 {
|
||||
t.Fatalf("a rename created %d jobs", jobs)
|
||||
}
|
||||
|
||||
// Validation and ownership.
|
||||
for _, body := range []map[string]string{{"title": " "}, {"title": strings.Repeat("a", maxTitleRunes+1)}} {
|
||||
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), body); code != 400 {
|
||||
t.Fatalf("invalid rename %v accepted", body)
|
||||
}
|
||||
}
|
||||
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]any{"title": "x", "ownerId": 9}); code != 400 {
|
||||
t.Fatal("an unknown field must be rejected")
|
||||
}
|
||||
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]any{"name": "x"}); code != 400 {
|
||||
t.Fatal("a missing title must be rejected")
|
||||
}
|
||||
if code, _, _ := patchResource(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": "Stolen"}); code != 404 {
|
||||
t.Fatal("another account must not rename this book")
|
||||
}
|
||||
if code, _, _ := patchResource(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"title": "Stolen"}); code != 404 {
|
||||
t.Fatal("another account must not rename this chapter")
|
||||
}
|
||||
if code, _, _ := patchResource(t, r, "", fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": "Anonymous"}); code != 401 {
|
||||
t.Fatal("renaming requires a session")
|
||||
}
|
||||
if title := existingTitle(t, r, learner.Token, pasted.Book.ID); title != "After Rename" {
|
||||
t.Fatalf("a rejected rename changed the book: %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLChapterEditVersioning is the core of the ticket: an edit creates a new version, and
|
||||
// the older run must not fail or publish over it.
|
||||
func TestMySQLChapterEditVersioning(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "edit-fixture-0001", "title": "Editable", "text": editFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
// The first run is claimed but not finished, so the edit lands while it is in flight.
|
||||
clock := time.Now().UTC().Truncate(time.Millisecond)
|
||||
job, claimed, err := ClaimNextIngestJob(db, clock)
|
||||
if err != nil || !claimed || job.ChapterID != pasted.Chapter.ID {
|
||||
t.Fatalf("claim (claimed=%v): %v", claimed, err)
|
||||
}
|
||||
|
||||
newText := "Mira reopened the workshop.\r\n\r\nA newer version of the text.\n"
|
||||
code, msg, data := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText})
|
||||
if code != 200 {
|
||||
t.Fatalf("edit status %d (%s)", code, msg)
|
||||
}
|
||||
var edited ChapterEdit
|
||||
json.Unmarshal(data, &edited)
|
||||
if !edited.VersionChanged || edited.Job == nil || edited.Chapter.Status != statusPending {
|
||||
t.Fatalf("edit must queue a new version: %+v", edited)
|
||||
}
|
||||
row := chapterRow(t, db, pasted.Chapter.ID)
|
||||
if row.OriginalText != newText || row.ContentSHA256 != contentSHA(newText) || row.Status != statusPending || row.CharCount != len([]rune(newText)) {
|
||||
t.Fatalf("stored version: %+v", row)
|
||||
}
|
||||
if row.ID != pasted.Chapter.ID {
|
||||
t.Fatal("an edit must keep the chapter id, so the reading entry stays the same")
|
||||
}
|
||||
|
||||
// The in-flight run belongs to the previous version: it must not touch the chapter.
|
||||
if err := FinishIngestJob(t.Context(), db, job, clock.Add(time.Second)); err != nil {
|
||||
t.Fatalf("finishing the older run must not fail: %v", err)
|
||||
}
|
||||
var stale IngestJob
|
||||
if err := db.First(&stale, job.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stale.Status != statusFailed || stale.ErrorReason != reasonSuperseded {
|
||||
t.Fatalf("the older run must be marked superseded: %+v", stale)
|
||||
}
|
||||
if row = chapterRow(t, db, pasted.Chapter.ID); row.Status != statusPending || row.ErrorReason != "" || row.OriginalText != newText {
|
||||
t.Fatalf("the older run changed the newer version: %+v", row)
|
||||
}
|
||||
// Retrying the superseded job is refused instead of reprocessing old text.
|
||||
code, _, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", stale.ID), learner.Token, nil)
|
||||
if code != 409 {
|
||||
t.Fatalf("retrying a superseded job: %d", code)
|
||||
}
|
||||
|
||||
// The new version publishes normally and the reader shows the new text.
|
||||
drainIngest(t, db)
|
||||
code, reader := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || reader.Chapter.Status != statusReady || reader.Chapter.OriginalText != newText {
|
||||
t.Fatalf("new version: status %d, %+v", code, reader.Chapter)
|
||||
}
|
||||
|
||||
// Saving the same text again is not a new version.
|
||||
var jobsBefore int64
|
||||
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobsBefore)
|
||||
code, _, data = patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText})
|
||||
json.Unmarshal(data, &edited)
|
||||
if code != 200 || edited.VersionChanged || edited.Job != nil {
|
||||
t.Fatalf("an unchanged text must not create a version: %+v", edited)
|
||||
}
|
||||
var jobsAfter int64
|
||||
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobsAfter)
|
||||
if jobsAfter != jobsBefore {
|
||||
t.Fatalf("an unchanged text created a job: %d -> %d", jobsBefore, jobsAfter)
|
||||
}
|
||||
if row = chapterRow(t, db, pasted.Chapter.ID); row.Status != statusReady {
|
||||
t.Fatalf("an unchanged save changed the status: %+v", row)
|
||||
}
|
||||
|
||||
// Text rules match a paste, and an empty body is refused.
|
||||
for _, body := range []map[string]string{{"text": " \n\t "}, {"text": strings.Repeat("a", maxChapterRunes+1)}} {
|
||||
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), body); code != 400 {
|
||||
t.Fatalf("invalid text %v accepted", body)
|
||||
}
|
||||
}
|
||||
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{}); code != 400 {
|
||||
t.Fatal("an empty edit must be rejected")
|
||||
}
|
||||
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]any{"text": "Body.\n", "ownerId": 9}); code != 400 {
|
||||
t.Fatal("an unknown field must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLChapterSourceAnyStatus(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
other := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "source-fixture-0001", "title": "Source", "text": editFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
readSource := func(token string, chapterID int64) (int, ChapterSource) {
|
||||
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source", chapterID), token, nil)
|
||||
var payload struct {
|
||||
Source ChapterSource
|
||||
}
|
||||
if len(data) > 0 {
|
||||
json.Unmarshal(data, &payload)
|
||||
}
|
||||
return code, payload.Source
|
||||
}
|
||||
// A pending chapter has no reading text, but its edit source is available to its owner.
|
||||
if code, source := readSource(learner.Token, pasted.Chapter.ID); code != 200 || source.Text != editFixtureText || source.Status != statusPending || source.ContentSHA256 != contentSHA(editFixtureText) {
|
||||
t.Fatalf("pending source: %d %+v", code, source)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
if code, source := readSource(learner.Token, pasted.Chapter.ID); code != 200 || source.Status != statusReady || source.CharCount != len([]rune(editFixtureText)) {
|
||||
t.Fatalf("ready source: %d %+v", code, source)
|
||||
}
|
||||
if code, _ := readSource(other.Token, pasted.Chapter.ID); code != 404 {
|
||||
t.Fatal("another account must not read this source")
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source?id=1", pasted.Chapter.ID), learner.Token, nil); code != 400 {
|
||||
t.Fatal("the source endpoint must reject query parameters")
|
||||
}
|
||||
if code, _ := readSource(learner.Token, 999999); code != 404 {
|
||||
t.Fatal("an unknown chapter must be 404")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLDeleteChapterRenumbers closes the gap in the order and keeps personal records.
|
||||
func TestMySQLDeleteChapterRenumbers(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
other := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, first := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "delete-fixture-0001", "title": "Three Chapters", "text": editFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("first paste %d", code)
|
||||
}
|
||||
second := pasteInto(t, r, learner.Token, first.Book.ID, "delete-fixture-0002", "Second", "Second chapter body.\n")
|
||||
third := pasteInto(t, r, learner.Token, first.Book.ID, "delete-fixture-0003", "Third", "Third chapter body.\n")
|
||||
drainIngest(t, db)
|
||||
// One personal word in the chapter that will be deleted, plus one in a surviving chapter.
|
||||
saved := saveWord(t, r, learner.Token, second.Chapter.ID, 0, 6, termStatusNew, nil)
|
||||
survivor := saveWord(t, r, learner.Token, first.Chapter.ID, 0, 4, termStatusNew, nil)
|
||||
before := termReviewRow(t, db, saved.Term.ID)
|
||||
|
||||
code, msg, data := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", second.Chapter.ID), learner.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("delete chapter status %d (%s)", code, msg)
|
||||
}
|
||||
var deleted struct {
|
||||
Deleted DeletionResult
|
||||
}
|
||||
json.Unmarshal(data, &deleted)
|
||||
if deleted.Deleted.ChapterID != second.Chapter.ID || deleted.Deleted.Remaining != 2 || deleted.Deleted.BookID != first.Book.ID {
|
||||
t.Fatalf("delete result: %+v", deleted.Deleted)
|
||||
}
|
||||
// The remaining chapters are contiguous and in the original order.
|
||||
code, detail := bookDetail(t, r, learner.Token, first.Book.ID)
|
||||
if code != 200 || len(detail.Chapters) != 2 {
|
||||
t.Fatalf("book detail after delete: %d %+v", code, detail.Chapters)
|
||||
}
|
||||
if detail.Chapters[0].ID != first.Chapter.ID || detail.Chapters[0].Ordinal != 1 || detail.Chapters[1].ID != third.Chapter.ID || detail.Chapters[1].Ordinal != 2 {
|
||||
t.Fatalf("renumbered chapters: %+v", detail.Chapters)
|
||||
}
|
||||
// Navigation follows the new order, and the deleted chapter is gone with its job.
|
||||
code, reader := readChapter(t, r, learner.Token, first.Chapter.ID)
|
||||
if code != 200 || reader.Navigation.NextChapterID == nil || *reader.Navigation.NextChapterID != third.Chapter.ID {
|
||||
t.Fatalf("navigation after delete: %d %+v", code, reader.Navigation)
|
||||
}
|
||||
if code, _ := readChapter(t, r, learner.Token, second.Chapter.ID); code != 404 {
|
||||
t.Fatal("a deleted chapter must be gone")
|
||||
}
|
||||
var jobs int64
|
||||
db.Model(&IngestJob{}).Where("chapter_id = ?", second.Chapter.ID).Count(&jobs)
|
||||
if jobs != 0 {
|
||||
t.Fatalf("the deleted chapter kept %d jobs", jobs)
|
||||
}
|
||||
// Personal records survive the deletion, including the schedule.
|
||||
var terms int64
|
||||
db.Model(&Term{}).Where("id IN ?", []int64{saved.Term.ID, survivor.Term.ID}).Count(&terms)
|
||||
var reviews int64
|
||||
db.Model(&TermReview{}).Where("term_id = ?", saved.Term.ID).Count(&reviews)
|
||||
if terms != 2 || reviews != 1 {
|
||||
t.Fatalf("deleting a chapter changed personal records: terms=%d reviews=%d", terms, reviews)
|
||||
}
|
||||
if after := termReviewRow(t, db, saved.Term.ID); !after.DueAt.Equal(before.DueAt) || after.ReviewCount != before.ReviewCount {
|
||||
t.Fatalf("the review schedule changed: %+v -> %+v", before, after)
|
||||
}
|
||||
// Repeated and foreign deletions.
|
||||
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", second.Chapter.ID), learner.Token, nil); code != 404 {
|
||||
t.Fatal("a repeated delete must be 404")
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), other.Token, nil); code != 404 {
|
||||
t.Fatal("another account must not delete this chapter")
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), "", nil); code != 401 {
|
||||
t.Fatal("deleting requires a session")
|
||||
}
|
||||
if code, detail := bookDetail(t, r, learner.Token, first.Book.ID); code != 200 || len(detail.Chapters) != 2 {
|
||||
t.Fatalf("a rejected delete changed the book: %+v", detail.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
// pasteInto appends one chapter to an owned book.
|
||||
func pasteInto(t *testing.T, r *gin.Engine, token string, bookID int64, requestID, title, text string) pasteResponse {
|
||||
t.Helper()
|
||||
code, pasted := pasteChapter(t, r, token, bookID, map[string]string{"requestId": requestID, "title": title, "text": text})
|
||||
if code != 201 {
|
||||
t.Fatalf("append %s status %d", title, code)
|
||||
}
|
||||
return pasted
|
||||
}
|
||||
|
||||
func TestMySQLDeleteBookKeepsPersonalRecords(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
other := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, first := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "delete-book-0001", "title": "Doomed Book", "text": editFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
pasteInto(t, r, learner.Token, first.Book.ID, "delete-book-0002", "Second", "Second chapter body.\n")
|
||||
drainIngest(t, db)
|
||||
saved := saveWord(t, r, learner.Token, first.Chapter.ID, 0, 4, termStatusNew, nil)
|
||||
survivorBook := pasteInto(t, r, learner.Token, first.Book.ID, "delete-book-0003", "Third", "Third body.\n")
|
||||
otherBook := func() int64 {
|
||||
code, kept := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "delete-book-keep", "title": "Kept Book", "text": "Kept body.\n", "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("kept book %d", code)
|
||||
}
|
||||
return kept.Book.ID
|
||||
}()
|
||||
drainIngest(t, db)
|
||||
|
||||
code, msg, data := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", first.Book.ID), learner.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("delete book status %d (%s)", code, msg)
|
||||
}
|
||||
var deleted struct {
|
||||
Deleted DeletionResult
|
||||
}
|
||||
json.Unmarshal(data, &deleted)
|
||||
if deleted.Deleted.BookID != first.Book.ID || deleted.Deleted.Chapters != 3 {
|
||||
t.Fatalf("delete result: %+v", deleted.Deleted)
|
||||
}
|
||||
var books, chapters, jobs int64
|
||||
db.Model(&Book{}).Where("id = ?", first.Book.ID).Count(&books)
|
||||
db.Model(&Chapter{}).Where("book_id = ?", first.Book.ID).Count(&chapters)
|
||||
db.Model(&IngestJob{}).Where("book_id = ?", first.Book.ID).Count(&jobs)
|
||||
if books != 0 || chapters != 0 || jobs != 0 {
|
||||
t.Fatalf("the deleted book left rows: books=%d chapters=%d jobs=%d", books, chapters, jobs)
|
||||
}
|
||||
// The other book of the same account is untouched.
|
||||
if code, detail := bookDetail(t, r, learner.Token, otherBook); code != 200 || len(detail.Chapters) != 1 {
|
||||
t.Fatalf("the other book changed: %d %+v", code, detail.Chapters)
|
||||
}
|
||||
// Personal records and their schedule survive.
|
||||
var terms, reviews int64
|
||||
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&terms)
|
||||
db.Model(&TermReview{}).Where("term_id = ?", saved.Term.ID).Count(&reviews)
|
||||
if terms != 1 || reviews != 1 {
|
||||
t.Fatalf("deleting a book changed personal records: terms=%d reviews=%d", terms, reviews)
|
||||
}
|
||||
code, queue := reviewQueue(t, r, learner.Token)
|
||||
if code != 200 || len(queue.Items) != 1 || queue.Items[0].ID != saved.Term.ID {
|
||||
t.Fatalf("the saved word left the review queue: %d %+v", code, queue.Items)
|
||||
}
|
||||
// The deleted chapter and book are no longer readable, and repeats are 404.
|
||||
if code, _ := readChapter(t, r, learner.Token, survivorBook.Chapter.ID); code != 404 {
|
||||
t.Fatal("a chapter of the deleted book must be gone")
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", first.Book.ID), learner.Token, nil); code != 404 {
|
||||
t.Fatal("a repeated delete must be 404")
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", otherBook), other.Token, nil); code != 404 {
|
||||
t.Fatal("another account must not delete this book")
|
||||
}
|
||||
_, list := bookList(t, r, other.Token)
|
||||
if len(list.Items) != 0 {
|
||||
t.Fatalf("the other account must not see this library: %+v", list.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLDeleteDuringProcessing proves a deleted chapter cannot come back through a run
|
||||
// that was already in flight.
|
||||
func TestMySQLDeleteDuringProcessing(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "delete-processing-01", "title": "In Flight", "text": editFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
clock := time.Now().UTC().Truncate(time.Millisecond)
|
||||
job, claimed, err := ClaimNextIngestJob(db, clock)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
|
||||
t.Fatalf("deleting a processing chapter: %d (%s)", code, msg)
|
||||
}
|
||||
// The in-flight run finds nothing to publish and reports no error.
|
||||
if err := FinishIngestJob(t.Context(), db, job, clock.Add(time.Second)); err != nil {
|
||||
t.Fatalf("finishing a run for a deleted chapter: %v", err)
|
||||
}
|
||||
var chapters, jobs int64
|
||||
db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Count(&chapters)
|
||||
db.Model(&IngestJob{}).Where("id = ?", job.ID).Count(&jobs)
|
||||
if chapters != 0 || jobs != 0 {
|
||||
t.Fatalf("a deleted chapter came back: chapters=%d jobs=%d", chapters, jobs)
|
||||
}
|
||||
// The book stays, with no chapters and a clear empty state for the client.
|
||||
code, detail := bookDetail(t, r, learner.Token, pasted.Book.ID)
|
||||
if code != 200 || len(detail.Chapters) != 0 {
|
||||
t.Fatalf("book after deleting its only chapter: %d %+v", code, detail.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLConcurrentChapterDelete checks two deletions of one chapter in one book.
|
||||
func TestMySQLConcurrentChapterDelete(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, first := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "race-delete-0001", "title": "Race", "text": editFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
pasteInto(t, r, learner.Token, first.Book.ID, "race-delete-0002", "Second", "Second body.\n")
|
||||
drainIngest(t, db)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
codes := make(chan int, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), learner.Token, nil)
|
||||
codes <- c
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(codes)
|
||||
ok, missing := 0, 0
|
||||
for c := range codes {
|
||||
switch c {
|
||||
case 200:
|
||||
ok++
|
||||
case 404:
|
||||
missing++
|
||||
}
|
||||
}
|
||||
if ok != 1 || missing != 1 {
|
||||
t.Fatalf("concurrent deletes: ok=%d missing=%d", ok, missing)
|
||||
}
|
||||
// The single surviving chapter keeps ordinal 1.
|
||||
var remaining []Chapter
|
||||
db.Where("book_id = ?", first.Book.ID).Order("ordinal ASC").Find(&remaining)
|
||||
if len(remaining) != 1 || remaining[0].Ordinal != 1 {
|
||||
t.Fatalf("ordinals after concurrent delete: %+v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLRecoverySkipsSupersededJobs proves the recovery sweep leaves a newer version alone.
|
||||
func TestMySQLRecoverySkipsSupersededJobs(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "recovery-fix-0001", "title": "Recovery", "text": editFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
clock := time.Now().UTC().Truncate(time.Millisecond)
|
||||
job, claimed, err := ClaimNextIngestJob(db, clock)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
newText := "A replacement body.\n"
|
||||
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText}); code != 200 {
|
||||
t.Fatalf("edit status %d (%s)", code, msg)
|
||||
}
|
||||
// The old job looks stale to the sweep; it must be abandoned, not requeued.
|
||||
requeued, err := RequeueStaleIngestJobs(db, clock.Add(ingestStaleAfter+time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requeued != 0 {
|
||||
t.Fatalf("the sweep requeued %d superseded job(s)", requeued)
|
||||
}
|
||||
var stale IngestJob
|
||||
if err := db.First(&stale, job.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stale.Status != statusFailed || stale.ErrorReason != reasonSuperseded {
|
||||
t.Fatalf("the sweep left the old job %+v", stale)
|
||||
}
|
||||
if row := chapterRow(t, db, pasted.Chapter.ID); row.Status != statusPending || row.OriginalText != newText {
|
||||
t.Fatalf("the sweep changed the new version: %+v", row)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
if row := chapterRow(t, db, pasted.Chapter.ID); row.Status != statusReady || row.OriginalText != newText {
|
||||
t.Fatalf("the new version did not publish: %+v", row)
|
||||
}
|
||||
}
|
||||
+58
-12
@@ -47,13 +47,19 @@ func requeueStaleIngestJobs(db *gorm.DB, now time.Time, staleAfter time.Duration
|
||||
cutoff := stamp(now.Add(-staleAfter))
|
||||
var requeued int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := abandonSupersededJobs(tx, ts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exhaustIngestJobs(tx, ts); err != nil {
|
||||
return err
|
||||
}
|
||||
stale := []int64{}
|
||||
if err := tx.Model(&IngestJob{}).
|
||||
Where("status = ? AND attempts < ? AND updated_at <= ?", statusProcessing, maxIngestAttempts, cutoff).
|
||||
Pluck("id", &stale).Error; err != nil {
|
||||
// Only jobs that still describe the chapter's current version may be requeued: an
|
||||
// interrupted run of an older version must not pull the newer text back into processing.
|
||||
if err := tx.Table("lexgo_ingest_jobs AS j").
|
||||
Joins("JOIN lexgo_chapters AS c ON c.id = j.chapter_id AND c.content_sha256 = j.content_sha256").
|
||||
Where("j.status = ? AND j.attempts < ? AND j.updated_at <= ?", statusProcessing, maxIngestAttempts, cutoff).
|
||||
Pluck("j.id", &stale).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(stale) == 0 {
|
||||
@@ -111,10 +117,21 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
|
||||
ts := stamp(now)
|
||||
var job IngestJob
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("status = ? AND attempts < ?", statusPending, maxIngestAttempts).
|
||||
Order("id ASC").First(&job).Error; err != nil {
|
||||
if err := abandonSupersededJobs(tx, ts); err != nil {
|
||||
return err
|
||||
}
|
||||
// A job may only process the version it was created for, so the chapter join is part
|
||||
// of the claim and an edited chapter is never dragged back to processing.
|
||||
if err := tx.Table("lexgo_ingest_jobs AS j").
|
||||
Select("j.id, j.owner_id, j.book_id, j.chapter_id, j.request_key, j.content_sha256, j.status, j.attempts, j.error_reason, j.created_at, j.updated_at, j.finished_at").
|
||||
Joins("JOIN lexgo_chapters AS c ON c.id = j.chapter_id AND c.content_sha256 = j.content_sha256").
|
||||
Where("j.status = ? AND j.attempts < ?", statusPending, maxIngestAttempts).
|
||||
Order("j.id ASC").Limit(1).Find(&job).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if job.ID == 0 {
|
||||
return errNoIngestJob
|
||||
}
|
||||
claim := tx.Model(&IngestJob{}).Where("id = ? AND status = ?", job.ID, statusPending).
|
||||
Updates(map[string]any{"status": statusProcessing, "attempts": gorm.Expr("attempts + 1"), "updated_at": ts})
|
||||
if claim.Error != nil {
|
||||
@@ -123,7 +140,7 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
|
||||
if claim.RowsAffected != 1 {
|
||||
return errJobTaken
|
||||
}
|
||||
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).
|
||||
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ? AND content_sha256 = ?", job.ChapterID, job.OwnerID, job.ContentSHA256).
|
||||
Updates(map[string]any{"status": statusProcessing, "updated_at": ts}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -132,7 +149,7 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
|
||||
job.UpdatedAt = ts
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) || errors.Is(err, errNoIngestJob) {
|
||||
return IngestJob{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -143,6 +160,9 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
|
||||
|
||||
var errJobTaken = errors.New("ingestion job already claimed")
|
||||
|
||||
// errNoIngestJob reports an empty queue, which is not a failure.
|
||||
var errNoIngestJob = errors.New("no ingestion job to claim")
|
||||
|
||||
// FinishIngestJob validates the persisted chapter and publishes it, or records a fixed
|
||||
// failure reason. The check runs again here because a worker must not trust that content
|
||||
// reached the table through the paste API.
|
||||
@@ -150,12 +170,27 @@ func FinishIngestJob(ctx context.Context, db *gorm.DB, job IngestJob, now time.T
|
||||
ts := stamp(now)
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var chapter Chapter
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).First(&chapter).Error; err != nil {
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).First(&chapter).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// The chapter was deleted while this run was in flight; the cascade removed its
|
||||
// jobs too, so there is nothing left to publish.
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if chapter.ContentSHA256 != job.ContentSHA256 {
|
||||
// The chapter moved to a newer version: publish nothing and leave its state, which
|
||||
// belongs to the newer job, untouched.
|
||||
return tx.Model(&IngestJob{}).Where("id = ?", job.ID).Updates(map[string]any{
|
||||
"status": statusFailed, "error_reason": reasonSuperseded, "updated_at": ts, "finished_at": ts}).Error
|
||||
}
|
||||
var book Book
|
||||
if err := tx.Where("id = ? AND owner_id = ?", job.BookID, job.OwnerID).First(&book).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if reason := unprocessableReason(book, chapter, job); reason != "" {
|
||||
@@ -185,14 +220,25 @@ func unprocessableReason(book Book, chapter Chapter, job IngestJob) string {
|
||||
if utf8.RuneCountInString(chapter.OriginalText) > maxChapterRunes {
|
||||
return reasonTooLong
|
||||
}
|
||||
// The job accepted a specific content version; a chapter changed after submission is a
|
||||
// different paste and must be submitted again rather than silently processed.
|
||||
if contentSHA(chapter.OriginalText) != job.ContentSHA256 {
|
||||
// The stored text and its stored version must agree. The job-versus-chapter version check
|
||||
// ran before this function, so a direct write that changed the text without updating its
|
||||
// version is the remaining case, and it is a different paste rather than this version.
|
||||
if contentSHA(chapter.OriginalText) != chapter.ContentSHA256 {
|
||||
return reasonContentChanged
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// abandonSupersededJobs fails jobs whose version is no longer the chapter's version. They
|
||||
// must never publish or fail the chapter, because another job owns its current state. It is
|
||||
// one multi-table statement: GORM's Updates does not carry a Joins clause into an UPDATE.
|
||||
func abandonSupersededJobs(tx *gorm.DB, ts time.Time) error {
|
||||
return tx.Exec(`UPDATE lexgo_ingest_jobs j JOIN lexgo_chapters c ON c.id = j.chapter_id
|
||||
SET j.status = ?, j.error_reason = ?, j.updated_at = ?, j.finished_at = ?
|
||||
WHERE j.status IN (?, ?) AND j.content_sha256 <> c.content_sha256`,
|
||||
statusFailed, reasonSuperseded, ts, ts, statusPending, statusProcessing).Error
|
||||
}
|
||||
|
||||
// ProcessIngestJobs drains up to limit pending jobs. Claiming and finishing each use their
|
||||
// own transaction, so an interrupted run simply leaves a job for recovery.
|
||||
func ProcessIngestJobs(ctx context.Context, db *gorm.DB, now func() time.Time, limit int) (int, error) {
|
||||
|
||||
@@ -30,7 +30,9 @@ const (
|
||||
reasonTooLong = "too_long"
|
||||
reasonEmptyText = "empty_text"
|
||||
reasonContentChanged = "content_changed"
|
||||
reasonAttemptsExhausted = "attempts_exhausted"
|
||||
// reasonSuperseded marks a job whose chapter already moved to a newer content version.
|
||||
reasonSuperseded = "superseded"
|
||||
reasonAttemptsExhausted = "attempts_exhausted"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -53,6 +55,8 @@ func reasonMessage(reason string) string {
|
||||
return "内容超过单章上限(100000 个字符)"
|
||||
case reasonEmptyText:
|
||||
return "章节内容为空"
|
||||
case reasonSuperseded:
|
||||
return "章节内容已更新为新版本,本次处理已作废"
|
||||
case reasonContentChanged:
|
||||
return "内容在处理前发生变化,请重新提交"
|
||||
case reasonAttemptsExhausted:
|
||||
@@ -640,6 +644,11 @@ func RetryIngestJob(db *gorm.DB, owner int, jobID int64, now time.Time) (JobView
|
||||
Where("id = ? AND owner_id = ?", job.ChapterID, owner).First(&chapter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// A job of an older content version is gone for good: retrying it would process text
|
||||
// the chapter no longer holds, so the newer job owns the chapter instead.
|
||||
if chapter.ContentSHA256 != job.ContentSHA256 {
|
||||
return failure(409, "该任务对应的是旧版本,请刷新后重试当前版本")
|
||||
}
|
||||
if err := tx.Model(&IngestJob{}).Where("id = ?", job.ID).
|
||||
Updates(map[string]any{"status": statusPending, "error_reason": "", "attempts": 0,
|
||||
"updated_at": ts, "finished_at": nil}).Error; err != nil {
|
||||
|
||||
@@ -305,6 +305,7 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
|
||||
registerTermRoutes(v, protect, now)
|
||||
registerReviewRoutes(v, protect, now)
|
||||
registerUploadRoutes(v, protect, now)
|
||||
registerEditRoutes(v, protect, now)
|
||||
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
|
||||
return r
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user