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:
ila
2026-09-13 23:20:52 +08:00
parent 65b50d5038
commit 35ed692c4b
16 changed files with 1565 additions and 29 deletions
+286
View File
@@ -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
}))
}
+543
View File
@@ -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
View File
@@ -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) {
+10 -1
View File
@@ -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 {
+1
View File
@@ -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
}