diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 8b54727..e1a3d4f 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.- -wiki_revision: fa7d3d66e6e68164ee97a23dc20a737fcaba7a11 -synchronized_at: 2026-09-10T16:35:18Z +wiki_revision: f981b86a7c6e5d2e353142d3006dcd6e0636c3ee +synchronized_at: 2026-09-11T03:01:48Z # 架构与代码地图 @@ -210,3 +210,14 @@ WordNet 使用 ZIP 内原始 index/data/exception 文件,不使用 SysDict 或 lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordinal, title, original_text MEDIUMTEXT, char_count, content_sha256, status, error_reason) 与 lexgo_ingest_jobs(owner_id, book_id, chapter_id, request_key, content_sha256, status, attempts, error_reason, finished_at)。owner_id 在章节与任务上冗余存放,使任何查询都能直接按认证身份过滤而不依赖连接;UNIQUE(book_id, ordinal) 与 UNIQUE(owner_id, request_key) 分别阻止重复章节与重复提交。启动检查要求版本 3,服务不自动迁移。 并发重复提交:请求命中 request_key 唯一键冲突后,用加锁读读取已提交结果,因为该请求事务的快照早于并发提交;因此两个并发相同提交只会产生一个章节,另一个得到 duplicate=true 的首次结果。 + +## #5 审核整改(R1~R4,2026-09-11) + +提交见工单 #5 的整改评论;本条记录实现与验证方式。 + +- 追加契约(R1):学习端把新建与追加拆成两个请求类型,追加不发送 language;后端保持严格解码,并新增回归测试断言“追加带 language 返回 400、不带则 201”,学习端单测断言追加请求体只有 requestId/title/text。 +- 运行期任务恢复(R2):`server/app/lexgo/ingest.go` 的恢复逻辑合并为一处——启动恢复使用阈值 0,运行期每轮清扫使用 15 秒阈值并把超过 5 次尝试的任务置为 failed(原因码 attempts_exhausted);`cmd/lexgo/main.go` 的 worker 每秒先清扫再处理,日志分别说明“已重新入队”与“本批未完成、等待下一次清扫”,不再声称已完成实际跳过的重试。 +- 离页作废在途请求(R3):`closeBook`/`closeChapter` 推进请求序号并清理 loading;`ImportView` 记录是否已卸载,卸载后的成功响应不再触发跳转。 +- 重试自愈(R4):`retryChapter` 先把重试返回的章节状态应用到列表与阅读器并重新安排轮询,再做静默刷新。 + +验证:Go 全量用例 20 项通过(新增运行期恢复与尝试上限两项);学习端单测 38 项通过,其中 7 项在整改前的代码上复现失败;真实联调确认追加路径可用、被中断的任务在运行中被自动恢复(约 0.5 秒,无需重启)、重试在首次刷新失败后仍自动显示最终结果。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index a8e7fab..ab588a8 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.- -wiki_revision: ff2abb673fe97e7eaa6934f76180ffdde701da8d -synchronized_at: 2026-09-10T16:35:18Z +wiki_revision: 4bd843d1f0134f5688407b7072d98333b40b6da1 +synchronized_at: 2026-09-11T03:01:48Z # 业务规则与术语 @@ -127,3 +127,13 @@ POST /lookup 接收 {surface,lemma?}。查词键单独 casefold/NFC/弯撇号转 - 恢复:声明与完成分属两个事务。进程在声明后退出时,重启把 processing 的章节与任务放回 pending 并保留尝试次数,不产生重复章节。 - 未决边界:Go+Python NLP 与全 Go 路线尚未确认。本单只做 Go 校验、分章与发布,不产生 token、lemma 或词典索引;正式接入前必须由用户确认路线,再定义生产 token 契约。 - 已知限制:本单未设置每账号书籍数量或总容量配额,只限制单次正文与请求体大小;删除书籍/章节属 #10,导入失败不会自动重试,只在启动时恢复被中断的 processing 任务。 + +## #5 审核整改(R1~R4,2026-09-11) + +工单 #5 的整改记录见该工单评论;本节只记录长期有效的契约变化。 + +- 追加章节沿用所属书籍的语言:`POST /api/v1/books/:id/chapters` 的请求体只有 requestId、title、text,不接受 language;服务端仍拒绝未知字段,客户端发 language 会得到 400。新建书籍的 `POST /api/v1/books` 才带 language。学习端已按此拆分请求体,避免两个契约共用同一结构。 +- 导入任务的自动重试有上限:同一任务被 worker 领取的次数达到 5 次后,任务与章节转为 failed,原因码 `attempts_exhausted`,提示“处理多次失败,请重试或重新提交”。人工重试(POST /jobs/:id/retry)会重置尝试次数,因此人工操作不受该上限阻塞。 +- 运行期恢复不依赖重启:除了启动时的恢复,运行中的服务每次轮询都会把停留在 processing 且超过 15 秒的任务放回 pending,因此“领取已提交、完成事务失败”不会让章节永久卡在处理中。该阈值必须长于正常的领取到完成窗口;重复处理同一任务不会产生第二个章节,因为任务从不创建章节。 +- 学习端离开页面时作废在途请求:目录页与阅读页在关闭时推进各自的请求序号,晚到的响应不会写回状态或重启轮询;导入页在提交过程中离开后,晚到的成功响应不会把用户导航回书籍页。 +- 重试结果立即生效:重试被接受后先把返回的 pending 状态写入界面并继续轮询,因此紧随其后的一次刷新失败不会让页面停在处理失败。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 0ba40b8..59ee197 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.- -wiki_revision: b8741ac70a524bb79741183f592f19c1206cd71b -synchronized_at: 2026-09-10T16:35:18Z +wiki_revision: 3bce5fae964b1050513c37319262679814d68f14 +synchronized_at: 2026-09-11T03:01:49Z # 本地开发与验证 @@ -282,3 +282,26 @@ node --test spikes/english/view.test.mjs 截图保存在本机 .local/evidence/(library.png、book-after-paste.png、reader-desktop.png、reader-mobile-390.png)并作为工单附件上传,便于人工目视复核;本次会话模型不能读取图片,截图未经 Agent 目视检查,功能断言来自上面的程序化检查。 未验证:处理失败到重试的用户界面路径只由集成测试覆盖(无法通过 API 主动制造处理失败);真实手机浏览器长按、手柄、滚动与虚拟键盘仍属 #4 缺口,本次只用桌面浏览器窄屏检查,不能当作真机结果;Python NLP 未接入,token、lemma 与词典仍为 #3 小样范围;生产并发、容量、备份恢复与部署不在本单范围。 + +## #5 审核整改验证(R1~R4,2026-09-11) + +整改提交与完整证据见工单 #5 的整改评论。本次复核命令与结果(仓库根执行,专用测试库 lexgo_test_issue5): + +| 命令 | 本次结果 | +|---|---| +| `python scripts/server.py test-integration` | 20 个顶层用例全部通过(新增 `TestMySQLIngestRecoveryWithoutRestart`、`TestMySQLIngestAttemptsAreBoundedAndManualRetryRestarts`) | +| `npx --yes pnpm@9.15.1 --dir learner test:unit --run` | 38 项通过(library 21、reading 8、session 9) | +| `npx --yes pnpm@9.15.1 --dir learner build` | 通过(vue-tsc + vite) | +| `npx --yes pnpm@9.15.1 --dir learner test:e2e` | 3 项通过(虚构 API 响应) | + +回归测试的有效性:新增的前端 7 项用例先在整改前的 `library.ts`/`ImportView.vue` 上运行并实际失败(追加发送 language、离页后响应写回、重试后停在失败),改回修复版本后全部通过。 + +真实联调(lexgo_dev,虚构账号 issue5_a): + +- R1 追加:真实学习端从书籍页进入“追加章节”,提交后回到书籍页,新章节就绪后可阅读;抓取到的请求体只有 requestId、title、text,无 language;正文逐字符相等。 +- R2 恢复:新建章节后用 SQL 把任务与章节置为 processing 且 updated_at 早于阈值(UTC 时间),**不重启服务**,运行期清扫在 544 ms 内把任务重新入队并发布为就绪,章节与任务编号不变,正文逐字符相等。 +- R4 重试:SQL 制造真实失败任务(content_changed)后,在浏览器点击“重试”并中断其后的第一次刷新请求,页面立即由“处理失败”变为“处理中”,随后自行变为“已就绪”,无需手工刷新。 + +注意:MySQL 会话时区为 SYSTEM(本机为 UTC+8),而服务按 UTC 存储 DATETIME;核对任务时间时使用 UTC_TIMESTAMP 而不是 NOW(),否则会出现 8 小时的假偏差。 + +未在本轮验证:R3 的真实浏览器时序(离页与响应同时发生)只由单测覆盖;真机手机证据仍属 #4 缺口。 diff --git a/learner/src/__tests__/library.spec.ts b/learner/src/__tests__/library.spec.ts index 13835e6..8182398 100644 --- a/learner/src/__tests__/library.spec.ts +++ b/learner/src/__tests__/library.spec.ts @@ -340,4 +340,106 @@ describe('learner library store', () => { expect(library.books).toHaveLength(1) expect(library.books[0]?.title).toBe('较新的标题') }) + + // Regression R1: the append contract has no language field and the server rejects unknown + // fields, so a client that sent one could never append. + it('sends the language only when creating a book, never when appending', async () => { + await signIn() + const library = useLibraryStore() + fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter({ id: 54 }) })) + await library.submit({ title: ' 新书 ', text: 'New book text.', target: { mode: 'new' } }) + expect(bodyOf(1)).toEqual({ requestId: expect.any(String), title: '新书', text: 'New book text.', language: 'en' }) + + fetchMock().mockResolvedValueOnce(created({ chapter: chapter({ id: 55, ordinal: 2 }) })) + await library.submit({ title: '第二篇', text: 'Appended text.', target: { mode: 'append', bookId: 1 } }) + expect(String(fetchMock().mock.calls[2]?.[0])).toBe('/api/v1/books/1/chapters') + expect(bodyOf(2)).toEqual({ requestId: expect.any(String), title: '第二篇', text: 'Appended text.' }) + expect(bodyOf(2)).not.toHaveProperty('language') + }) + + // Regression R3: leaving a view must invalidate its in-flight request. + it('ignores a book response that arrives after the book view was closed', async () => { + vi.useFakeTimers() + await signIn() + const library = useLibraryStore() + let finish!: (response: Response) => void + fetchMock().mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + + const pending = library.loadBook(1) + library.closeBook() + finish(ok({ book, chapters: [chapter()] })) + await pending + + expect(library.book).toBeNull() + expect(library.chapters).toEqual([]) + expect(library.bookLoading).toBe(false) + const settled = paths().length + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3) + expect(paths().length).toBe(settled) + }) + + it('ignores a chapter response that arrives after the reader was closed', async () => { + vi.useFakeTimers() + await signIn() + const library = useLibraryStore() + let finish!: (response: Response) => void + fetchMock().mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + + const pending = library.loadChapter(55) + library.closeChapter() + finish(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Late text.' }), navigation })) + await pending + + expect(library.chapter).toBeNull() + expect(library.readerText).toBe('') + expect(library.chapterLoading).toBe(false) + const settled = paths().length + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3) + expect(paths().length).toBe(settled) + }) + + // Regression R4: an accepted retry must be visible and tracked even if the refresh fails. + it('keeps tracking a retried chapter when the first refresh fails', async () => { + vi.useFakeTimers() + await signIn() + const library = useLibraryStore() + fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', errorReason: 'content_changed', errorMessage: '内容在处理前发生变化。', jobId: 7 }), navigation })) + await library.loadChapter(55) + expect(library.chapter?.status).toBe('failed') + + fetchMock() + .mockResolvedValueOnce(ok({ job: { ...job, status: 'pending' }, chapter: chapter({ status: 'pending' }) })) + .mockRejectedValueOnce(new Error('network down')) + await library.retryChapter(55) + + expect(paths()).toContain('/api/v1/jobs/7/retry') + expect(library.chapter?.status).toBe('pending') + expect(library.readerText).toBe('') + + // The next poll still tracks the queued chapter and shows the final result. + fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Recovered text.' }), navigation })) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + expect(library.chapter?.status).toBe('ready') + expect(library.readerText).toBe('Recovered text.') + }) + + it('keeps tracking a retried chapter from the book page when the first refresh fails', async () => { + vi.useFakeTimers() + await signIn() + const library = useLibraryStore() + fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', errorReason: 'content_changed', errorMessage: '内容在处理前发生变化。' })] })) + await library.loadBook(1) + expect(library.chapters[0]?.status).toBe('failed') + + fetchMock() + .mockResolvedValueOnce(ok({ job: { ...job, status: 'pending' }, chapter: chapter({ status: 'pending' }) })) + .mockRejectedValueOnce(new Error('network down')) + await library.retryChapter(55) + + expect(library.chapters[0]?.status).toBe('pending') + + fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'ready' })] })) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + expect(library.chapters[0]?.status).toBe('ready') + }) }) diff --git a/learner/src/__tests__/reading.spec.ts b/learner/src/__tests__/reading.spec.ts index 4a92dd5..0fae0bc 100644 --- a/learner/src/__tests__/reading.spec.ts +++ b/learner/src/__tests__/reading.spec.ts @@ -200,4 +200,66 @@ describe('learner reading views', () => { expect(retryCall?.[1]?.method).toBe('POST') wrapper.unmount() }) + + // Regression R1: appending must not send the language field that the append contract rejects. + it('appends to an existing book through the chapter endpoint without a language field', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(ok({ + items: [{ + ...book, + chapterCount: 1, + pendingCount: 0, + processingCount: 0, + readyCount: 1, + failedCount: 0, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }], + })) + .mockResolvedValueOnce(created({ chapter: chapter('pending', { id: 56, ordinal: 2 }), job: { id: 8 } })) + signIn() + const router = await viewAt('/import?book=1') + const wrapper = mount(ImportView, { global: { plugins: [router] } }) + await flushPromises() + + await wrapper.find('input#title').setValue('第二篇') + await wrapper.find('textarea#text').setValue(pasted) + await wrapper.find('form').trigger('submit') + await flushPromises() + + const appendCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/books/1/chapters')) + expect(appendCall?.[1]?.method).toBe('POST') + const body = JSON.parse(String(appendCall?.[1]?.body)) as Record + expect(body).toEqual({ requestId: expect.any(String), title: '第二篇', text: pasted }) + expect(body).not.toHaveProperty('language') + expect(router.currentRoute.value.path).toBe('/books/1') + wrapper.unmount() + }) + + // Regression R3: a submit that finishes after the user left the page must not navigate back. + it('does not navigate after the user left the import page during a submit', async () => { + let finish!: (response: Response) => void + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + signIn() + const router = await viewAt('/import') + const wrapper = mount(ImportView, { global: { plugins: [router] } }) + await flushPromises() + + await wrapper.find('input#title').setValue('虚构样例第一章') + await wrapper.find('textarea#text').setValue(pasted) + await wrapper.find('form').trigger('submit') + await flushPromises() + expect(fetchMock).toHaveBeenCalledTimes(1) + + // The user leaves the page while the request is still open. + wrapper.unmount() + await router.push('/') + await flushPromises() + + finish(created({ book, chapter: chapter('pending'), job: { id: 7 } })) + await flushPromises() + + expect(router.currentRoute.value.path).toBe('/') + }) }) diff --git a/learner/src/stores/library.ts b/learner/src/stores/library.ts index 8f9adf4..12e363f 100644 --- a/learner/src/stores/library.ts +++ b/learner/src/stores/library.ts @@ -108,7 +108,10 @@ export function statusSummary(book: BookSummary): string { } interface LoadOptions { silent?: boolean } -interface SubmitBody { requestId: string; title: string; text: string; language: 'en' } +interface SubmitBookBody { requestId: string; title: string; text: string; language: 'en' } +// An appended chapter owns the language of its book, so the append contract has no language +// field; the server rejects unknown fields, and a client that sends one gets HTTP 400. +interface SubmitChapterBody { requestId: string; title: string; text: string } interface Created { bookId: number; chapter: ChapterSummary } function emptyNavigation(): ChapterNavigation { @@ -310,12 +313,12 @@ export const useLibraryStore = defineStore('library', () => { return target.mode === 'new' ? `new\n${title}\n${text}` : `append:${target.bookId}\n${title}\n${text}` } - async function createBook(body: SubmitBody): Promise { + async function createBook(body: SubmitBookBody): Promise { const result = await session.request<{ book: BookRef; chapter: ChapterSummary }>('books', 'POST', body) return { bookId: result.book.id, chapter: result.chapter } } - async function appendChapter(bookId: number, body: SubmitBody): Promise { + async function appendChapter(bookId: number, body: SubmitChapterBody): Promise { const result = await session.request<{ chapter: ChapterSummary }>(`books/${bookId}/chapters`, 'POST', body) return { bookId: result.chapter.bookId, chapter: result.chapter } } @@ -338,16 +341,17 @@ export const useLibraryStore = defineStore('library', () => { submissionKey = key submissionRequestId = crypto.randomUUID() } - const body: SubmitBody = { requestId: submissionRequestId, title, text, language: LANGUAGE_CODE } + const requestId = submissionRequestId const version = generation const owner = ownerId() submitting.value = true submitError.value = '' try { + // Only the new-book contract carries a language; appending inherits the book's language. const created = input.target.mode === 'new' - ? await createBook(body) - : await appendChapter(input.target.bookId, body) + ? await createBook({ requestId, title, text, language: LANGUAGE_CODE }) + : await appendChapter(input.target.bookId, { requestId, title, text }) if (isStale(version, owner)) throw new Error('登录状态已变化,请重新提交。') // The content was accepted; a later submit must use a fresh requestId. submissionKey = '' @@ -368,6 +372,19 @@ export const useLibraryStore = defineStore('library', () => { return target?.jobId ?? null } + /** + * Applies a chapter summary coming from any response to the chapter list entry and to the + * open reader, so a queued chapter is never displayed with the state or text it had before. + */ + function applyChapterSummary(summary: ChapterSummary): void { + const index = chapters.value.findIndex(item => item.id === summary.id) + if (index >= 0) chapters.value[index] = { ...chapters.value[index], ...summary } + if (chapter.value !== null && chapter.value.id === summary.id) { + const originalText = summary.status === 'ready' ? chapter.value.originalText : undefined + chapter.value = { ...chapter.value, ...summary, originalText } + } + } + async function retryChapter(chapterId: number): Promise { const jobId = jobIdOf(chapterId) if (jobId === null) throw new Error('这一章暂时没有可重试的任务编号。') @@ -375,9 +392,13 @@ export const useLibraryStore = defineStore('library', () => { const owner = ownerId() retryingChapterId.value = chapterId try { - const result = await session.request<{ job: Job; chapter: Pick }>(`jobs/${jobId}/retry`, 'POST') + const result = await session.request<{ job: Job; chapter: ChapterSummary }>(`jobs/${jobId}/retry`, 'POST') if (isStale(version, owner)) return - // The retry answer only carries a summary, so refresh whatever is on screen. + // The retry is accepted, so show the queued chapter and keep tracking it even if the + // refresh below fails: a failed silent refresh must not freeze the view on the old error. + applyChapterSummary(result.chapter) + schedulePolling() + // Refresh whatever is on screen to pick up the newest job state. if (book.value !== null && book.value.id === result.chapter.bookId) await loadBook(result.chapter.bookId, { silent: true }) if (chapter.value !== null && chapter.value.id === result.chapter.id) await loadChapter(result.chapter.id, { silent: true }) } catch (reason) { @@ -388,19 +409,27 @@ export const useLibraryStore = defineStore('library', () => { } } - /** Releases the book view so polling stops when the page is left. */ + /** + * Releases the book view so polling stops when the page is left. The request sequence is + * advanced first, so a response that arrives after this call cannot repopulate the view or + * restart polling for a page the user already left. + */ function closeBook(): void { + bookSeq++ book.value = null chapters.value = [] + bookLoading.value = false bookError.value = '' schedulePolling() } - /** Releases the reader view so polling stops when the page is left. */ + /** Releases the reader view, invalidating in-flight loads the same way. */ function closeChapter(): void { + chapterSeq++ chapter.value = null chapterBook.value = null navigation.value = emptyNavigation() + chapterLoading.value = false chapterError.value = '' schedulePolling() } diff --git a/learner/src/views/ImportView.vue b/learner/src/views/ImportView.vue index 0a0e353..cc34c2d 100644 --- a/learner/src/views/ImportView.vue +++ b/learner/src/views/ImportView.vue @@ -55,6 +55,8 @@ async function submit() { : { mode: 'new' } try { const createdBookId = await library.submit({ title: title.value, text: text.value, target }) + // A response that arrives after the user left this page must not navigate them back. + if (disposed) return // The requestId was consumed by this submission, so the form starts clean. title.value = '' text.value = '' @@ -64,7 +66,12 @@ async function submit() { } } -onUnmounted(() => { library.submitError = '' }) +// The form may still be awaiting its submit when the user navigates away. +let disposed = false +onUnmounted(() => { + disposed = true + library.submitError = '' +})