From cbb48407cd1cbfac3294090891efe421449217a0 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Wed, 16 Sep 2026 11:02:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E7=BC=96=E8=BE=91=E5=9B=9E=E5=88=B0?= =?UTF-8?q?=E6=9B=BE=E7=BB=8F=E7=94=A8=E8=BF=87=E7=9A=84=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=A4=8D=E7=94=A8=E8=AF=A5=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=9A=84=E4=BB=BB=E5=8A=A1=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:编辑任务键按「章节+内容摘要」派生,与 uq_job_request 唯一键相撞, 改回曾经用过的正文时 Create 触发 MySQL 1062,整笔事务回滚并冒泡为通用 500 - 修复:新增 stageEditJob,先按 (owner_id, chapter_id, content_sha256) 锁定查询该版本的任务行, 找到就复用并重置为 pending/attempts=0/清空失败原因,找不到才用派生键创建; 粘贴版本的任务行同样可被复用,因此一章一个内容版本只保留一行 - 不变的门控:旧版本任务仍不能把新版本章节拉回处理(RetryIngestJob 409)、 未改动的正文不产生新版本、同一版本重复处理幂等 - 无 schema 变化,无需迁移 - 测试:新增 TestMySQLChapterEditBackToAPreviousVersion(A→B→A→B 此前第三步 500、 一章一版本一行、复用行干净重启、失败版本再次成为当前版本时复活、读者返回最后正文); Go 顶层用例 92 项;真实 API+MySQL 21 项检查通过 - 文档:Architecture / Business-Rules / Local-Development 记录规则与验证 --- docs/02-architecture-and-code-map.md | 18 ++- docs/03-business-rules-and-glossary.md | 14 +- docs/04-local-development-and-verification.md | 24 +++- server/app/lexgo/edit.go | 48 ++++++- server/app/lexgo/edit_test.go | 121 ++++++++++++++++++ 5 files changed, 213 insertions(+), 12 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 8539886..7b11324 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: 3cf18f98cf824dd3bbd5e8c300a09a321e47b357 -synchronized_at: 2026-09-16T02:25:55Z +wiki_revision: f3ffc4fb7a75fccb147cb39ad3804f29be76b3e7 +synchronized_at: 2026-09-16T03:01:16Z # 架构与代码地图 @@ -516,3 +516,17 @@ schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS` 复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。 学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的书籍页把封面压成**一行紧凑控件**(`.cover-row`:预览 120px +「上传/替换封面」「移除」,不含标题、字段名与规格提示文字),把高度让给章节列表;标题行用 `.title-line` 把**书级作者**显示在书名右侧(`data-testid="book-author"`,未设置则不显示);「编辑书名」改为**「编辑书籍」**,对话框内用同一套 `.field-row` 同行排版编辑**书名与作者**。**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。 + +## #32 编辑任务按内容版本复用(2026-09-16,已修复并合入 main) + +**没有 schema 变化**:修复的是编辑路径的任务编排,不是数据模型。 + +`server/app/lexgo/edit.go` 的 `stageEditJob` 取代了原先「直接创建编辑任务」的写法。一章的一个内容摘要就是一个版本,而 `lexgo_ingest_jobs.request_key` 是唯一的,编辑请求派生的键又是「章节+内容摘要」,因此同一版本第二次成为当前版本时,原写法会撞唯一键、整笔事务回滚并冒泡为通用 500。 + +| 情况 | 处理 | +|---|---| +| 该章还没有描述这个内容版本的任务行 | 用派生键创建新任务(`edit:<章节>:<内容摘要>`,pending) | +| 已经有描述这个版本的行(改回曾经用过的正文、或该版本上次处理失败) | **复用该行的身份**,重置为 `pending`、`attempts = 0`、清空 `error_reason` 与 `finished_at`,刷新 `updated_at`;不新增行 | +| 复用到的行可能是粘贴版本的任务(键是粘贴请求号) | 正确:一行描述一个内容版本,键只是历史标识;worker 只按 `status = pending` 且「任务内容摘要 = 章节当前内容」认领 | + +不变的门控:`RetryIngestJob` 仍拒绝「任务内容摘要 ≠ 章节当前内容」的旧任务(409),所以旧版本任务不会把新版本章节拉回处理;同一版本重复处理是幂等的;未改动的正文仍然不产生新版本、不新增任务。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 538e798..13eae6b 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: b688ef278f83c8055e16f91843a239f9ea2dd492 -synchronized_at: 2026-09-15T15:10:08Z +wiki_revision: e248e18282d7d2599e1689007ed36b1f1c076267 +synchronized_at: 2026-09-16T03:01:16Z # 业务规则与术语 @@ -416,3 +416,13 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺 **删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。 **范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。 + +## #32 版本回退规则(2026-09-16) + +**正文版本可以来回切换**:把一章的正文改回曾经用过的文本,属于发布该内容版本,必须正常成功——不再出现无解释的错误,也不会因此产生重复任务行。规则如下: + +- 一个内容版本对应一条任务记录:初次成为当前版本时创建,**再次成为当前版本时复用**(把该记录重置为待处理、重试次数归零、清空上次失败原因),因此反复来回切换不会累积任务行。 +- **旧版本的任务不会复活新版本章节**:只有「任务内容摘要 = 章节当前内容」的任务可以被重试或处理,否则拒绝。 +- 同一版本重复提交**没有副作用**:正文没变时不产生新版本,也不新增任务。 +- 上次处理失败的版本在被重新发布时**重试次数归零**,因为「人再次要求」不应被此前的失败预算挡住。 +- 回退到早期版本**不会恢复**当时的词条状态、复习排期或章节完成记录:正文版本与个人学习状态是分开的(#10/#11 已确认的口径)。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index ffcf1ba..28ebd37 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: 7c41d62a131bfcc8c00a3b42a9830600a31f9c6a -synchronized_at: 2026-09-16T02:25:55Z +wiki_revision: 632b9d8b7c9951d05bf3c0b2f34f08da704e65dc +synchronized_at: 2026-09-16T03:01:16Z # 本地开发与验证 @@ -1095,3 +1095,23 @@ schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playbac - Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。 - 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。 - 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。 + +## #32 修复与验证(2026-09-16) + +### 根因与修复 + +编辑路径用 `contentSHA("edit:<章节>:<内容摘要>")` 派生任务键,与 `lexgo_ingest_jobs.uq_job_request (owner_id, request_key)` 相撞:把正文改回曾经用过的版本时派生出已存在的键,`Create` 触发 MySQL 1062,事务整体回滚并冒泡为通用 500。新增 `stageEditJob`:先按 `(owner_id, chapter_id, content_sha256)` 锁定查询该版本的任务行,找到就复用(重置为 pending、attempts=0、清空失败原因),找不到才用派生键创建。**无 schema 变化**,因此不需要迁移;回退件 `.local/lexgo-pre-issue32.exe`。 + +### 验证 + +| 项 | 结果 | +|---|---| +| `go vet ./...` / `gofmt -l` | 通过、无输出 | +| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **92 项顶层用例通过、0 跳过**;新增 `TestMySQLChapterEditBackToAPreviousVersion` 覆盖 A→B→A→B(此前第三步 500)、「一章一版本一行任务」、被复用的行干净重启(pending、attempts=0、无 finished_at/error_reason)、失败版本再次成为当前版本时被复活、章节最终为 ready 且正文为最后一次编辑的内容、读者按该内容返回、正文未改动时不新增任务 | +| 真实 API+MySQL | **21 项检查通过**(`.local/issue32-api-evidence.json`):在开发实例上对一本 fixture 书籍执行 A→B→A→B,三步全部 200(此前第三步 500)、每步都 report versionChanged 并最终 ready、该章只有两条任务行且都以 ready 收尾、没有任务停在 pending、读者返回最后一次编辑的正文、章节内容摘要等于某个任务版本、再切回另一版本仍为 200 且仍是两行、删除 fixture 书籍后任务级联清空 | +| 治理 | `python -m unittest discover -s tests` 65 项与 `harness.py check --strict` 通过 | + +### 未验证 + +- 只验证了「单章来回切换」;多章并发编辑、超大文本反复切换与移动端上的编辑体验未单独压测。 +- 与编辑器交互无关:本单只改后端编排,前端在 #10 已实现「正文改版会重新处理」的提示。 diff --git a/server/app/lexgo/edit.go b/server/app/lexgo/edit.go index 345e492..e88af14 100644 --- a/server/app/lexgo/edit.go +++ b/server/app/lexgo/edit.go @@ -78,6 +78,45 @@ func editAuthor(raw string) (string, error) { return author, nil } +// stageEditJob returns the job that owns one content version of a chapter. +// +// One chapter and one content digest describe one version, so the row that already describes it is +// reused instead of adding another one. That matters because the derived request key of a version +// that has been edited before already exists: creating it again hit the unique key and failed the +// whole edit with a generic 500, so a learner could never return to a previous text. Reusing is also +// correct for the version a chapter was pasted with, whose row carries the paste request key. +func stageEditJob(tx *gorm.DB, owner int, chapter Chapter, sha string, ts time.Time) (IngestJob, error) { + var existing IngestJob + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("owner_id = ? AND chapter_id = ? AND content_sha256 = ?", owner, chapter.ID, sha). + Order("id ASC").First(&existing).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + 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 IngestJob{}, err + } + return created, nil + case err != nil: + return IngestJob{}, err + } + // Publishing the same version again — edited back, or retried after it failed — reuses the row and + // restarts its attempt budget, so a person asking again is not blocked by the earlier failure. + if err = tx.Model(&IngestJob{}).Where("id = ?", existing.ID). + Updates(map[string]any{"status": statusPending, "attempts": 0, "error_reason": "", + "updated_at": ts, "finished_at": nil}).Error; err != nil { + return IngestJob{}, err + } + existing.Status = statusPending + existing.Attempts = 0 + existing.ErrorReason = "" + existing.FinishedAt = nil + existing.UpdatedAt = ts + return existing, 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 { @@ -176,14 +215,11 @@ func UpdateChapter(tx *gorm.DB, owner int, chapterID int64, input ChapterUpdateI 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 { + staged, err := stageEditJob(tx, owner, chapter, sha, ts) + if err != nil { return ChapterEdit{}, err } - job = &created + job = &staged } } if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", chapter.ID, owner).Updates(updates).Error; err != nil { diff --git a/server/app/lexgo/edit_test.go b/server/app/lexgo/edit_test.go index e7ae360..4120a3c 100644 --- a/server/app/lexgo/edit_test.go +++ b/server/app/lexgo/edit_test.go @@ -719,3 +719,124 @@ func TestMySQLBookAuthorRoundTrip(t *testing.T) { t.Fatal("another account must not edit the book") } } + +// editChapterText edits the body of one owned chapter through the API and fails on any error. +func editChapterText(t *testing.T, r *gin.Engine, token string, chapterID int64, text string) ChapterEdit { + t.Helper() + code, msg, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, map[string]string{"text": text}) + if code != 200 { + t.Fatalf("editing back to a used version must succeed, got %d (%s)", code, msg) + } + var edited ChapterEdit + if err := json.Unmarshal(data, &edited); err != nil { + t.Fatal(err) + } + return edited +} + +// TestMySQLChapterEditBackToAPreviousVersion covers the defect reported in #32: the request key of an +// edit job is derived from the content, so returning to a text used before must reuse that job instead +// of colliding with its unique key. Every step of A -> B -> A -> B has to succeed, the chapter has to +// end up holding the last text, and each version has to keep exactly one job row. +func TestMySQLChapterEditBackToAPreviousVersion(t *testing.T) { + db, r, owner := libraryFixture(t) + learner := newLearner(t, r, owner.Token) + first := "Curiosity opens the first door.\n" + second := "Curiosity opens a different door.\n" + code, pasted := pasteBook(t, r, learner.Token, map[string]string{ + "requestId": "edit-version-0001", "title": "Fictional versions", "text": first, "language": "en"}) + if code != 201 { + t.Fatalf("paste %d", code) + } + drainIngest(t, db) + chapterID := pasted.Chapter.ID + firstSHA, secondSHA := contentSHA(first), contentSHA(second) + + // A -> B. + edited := editChapterText(t, r, learner.Token, chapterID, second) + if !edited.VersionChanged || edited.Chapter.Status != statusPending || edited.Job == nil { + t.Fatalf("the first edit must start a new version: %+v", edited) + } + // The response carries the client-facing job view; the version it describes is read back from the + // row itself. + var secondJob IngestJob + if err := db.Where("chapter_id = ? AND content_sha256 = ?", chapterID, secondSHA).First(&secondJob).Error; err != nil { + t.Fatalf("the new version needs its own job: %v", err) + } + if err := db.Model(&Chapter{}).Where("id = ?", chapterID).Update("status", statusReady).Error; err != nil { + t.Fatal(err) + } + + // B -> A: this is the step that used to answer 500. + edited = editChapterText(t, r, learner.Token, chapterID, first) + if !edited.VersionChanged || edited.Job == nil { + t.Fatalf("editing back must publish the earlier version: %+v", edited) + } + // The row that already described the pasted version is reused, so no second row appears for it. + var rows int64 + if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 { + t.Fatalf("one row per version is expected, got %d (%v)", rows, err) + } + var reused IngestJob + if err := db.Where("chapter_id = ? AND content_sha256 = ?", chapterID, firstSHA).First(&reused).Error; err != nil { + t.Fatal(err) + } + if reused.Status != statusPending || reused.Attempts != 0 || reused.FinishedAt != nil || reused.ErrorReason != "" { + t.Fatalf("a reused job must restart cleanly: %+v", reused) + } + if err := db.Model(&Chapter{}).Where("id = ?", chapterID).Update("status", statusReady).Error; err != nil { + t.Fatal(err) + } + + // A -> B again, and this time the B job is the one being reused. + edited = editChapterText(t, r, learner.Token, chapterID, second) + if !edited.VersionChanged || edited.Job == nil { + t.Fatalf("switching between versions must keep working: %+v", edited) + } + if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 { + t.Fatalf("still one row per version, got %d (%v)", rows, err) + } + + // A version whose job had failed is revived when it becomes current again. + if err := db.Model(&IngestJob{}).Where("chapter_id = ? AND content_sha256 = ?", chapterID, secondSHA). + Updates(map[string]any{"status": statusFailed, "attempts": maxIngestAttempts, "error_reason": reasonAttemptsExhausted}).Error; err != nil { + t.Fatal(err) + } + editChapterText(t, r, learner.Token, chapterID, first) + edited = editChapterText(t, r, learner.Token, chapterID, second) + if edited.Job == nil || edited.Job.Attempts != 0 || edited.Job.Status != statusPending { + t.Fatalf("a failed version must be revived when it becomes current again: %+v", edited.Job) + } + + // The pipeline finishes the reused job and the chapter holds the last text. + drainIngest(t, db) + var chapter Chapter + if err := db.Where("id = ?", chapterID).First(&chapter).Error; err != nil { + t.Fatal(err) + } + if chapter.Status != statusReady || chapter.ContentSHA256 != secondSHA || chapter.OriginalText != second { + t.Fatalf("the chapter must hold the last edited text: %+v", chapter) + } + code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, nil) + if code != 200 { + t.Fatalf("reader %d", code) + } + var payload struct { + Chapter ChapterView + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + if payload.Chapter.OriginalText != second { + t.Fatalf("the reader must serve the last text: %q", payload.Chapter.OriginalText) + } + + // An unchanged text is still not a new version. + code, _, _ = callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, map[string]string{"text": second}) + if code != 200 { + t.Fatalf("saving the same text must succeed, got %d", code) + } + if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 { + t.Fatalf("an unchanged text must not add a job, got %d (%v)", rows, err) + } +} -- 2.34.1 From 7396c037a1ac612f91932df4d08943c97cd6991f Mon Sep 17 00:00:00 2001 From: QiuSW Date: Wed, 16 Sep 2026 21:43:24 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=E6=8C=89=E7=BA=BF=E4=B8=8A=20Wiki?= =?UTF-8?q?=20=E9=87=8D=E6=96=B0=E5=AF=BC=E5=87=BA=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E9=95=9C=E5=83=8F=EF=BC=8C=E6=B6=88=E9=99=A4=E4=B8=8E=20main?= =?UTF-8?q?=20=E7=9A=84=E9=95=9C=E5=83=8F=E5=86=B2=E7=AA=81=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/00-project-profile.md | 8 ++- docs/02-architecture-and-code-map.md | 56 ++++++++++++++++++- docs/03-business-rules-and-glossary.md | 25 ++++++++- docs/04-local-development-and-verification.md | 51 ++++++++++++++++- docs/09-product-requirements-overview.md | 15 ++++- docs/11-deployment-and-operations.md | 15 ++++- docs/README.md | 8 ++- 7 files changed, 163 insertions(+), 15 deletions(-) diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 79fe82c..07f6f6a 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.- -wiki_revision: 39adde482a8729fcf303ef7ca24cd8bf136c58a6 -synchronized_at: 2026-09-16T02:25:55Z +wiki_revision: 4ed96aac25feaa97a48224d7a150a73e7c6c6987 +synchronized_at: 2026-09-16T13:43:12Z # LexGo 项目档案 @@ -194,3 +194,7 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必 本单期间还发现并修复了一个从 #10 起就存在的真实缺陷:`main.ts` 漏引入 Element Plus 的 `dialog` 与 `message-box` 样式,导致全站所有对话框与确认框退化成文档流块;修复后补充了断言浮层定位属性的 `expectModalOverlay`/`expectMessageBoxOverlay` 用例,并把「只看可见性、不看是否浮层」这一验证盲区写回文档。未实现的确认项:书库卡片显示书级作者、章节作者继承书级作者;真机听感与浏览器真实解码播放仍需人工确认。 至此 MVP 原范围 #2~#15 与增补 #21、#37 全部实施并验收;仍待处理 #24 书库列表优化与缺陷 #32。#16 的 #37 复选框已勾选。 + +- 2026-09-16 新增第二本英语词典(#40):ECDICT 常用子集提供中文释义、CMUdict 提供 IPA 转写音标,二者由 sha256 pin 的源显式准备;schema v12 放开 `lexgo_dictionaries` 单行约束并增加 `provider` 列,英英与英汉词典可分别启停。音标来源必须可追溯(IPA 转写 / ECDICT 记法),不确定时不显示。许可:ECDICT 仓库 MIT(数据由公开资料整理,再分发前需自行评估)、CMUdict BSD-2,许可证随仓库保存,词典归档不提交进 Git。 + +- 2026-09-16 登录有效期由 8 小时改为 **30 天**(#42):两端共用同一登录接口,有效期是具名常量 `SessionLifetime`,绝对有效期、不滑动续期;服务端只存令牌 SHA-256 摘要,改密码/停用/重置仍撤销该账号全部会话。设备与会话管理界面未包含,如需另建工单。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 7b11324..4694747 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: f3ffc4fb7a75fccb147cb39ad3804f29be76b3e7 -synchronized_at: 2026-09-16T03:01:16Z +wiki_revision: 64a83458a283469672e111f423d37d9478a3d0e3 +synchronized_at: 2026-09-16T13:43:13Z # 架构与代码地图 @@ -530,3 +530,55 @@ schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS` | 复用到的行可能是粘贴版本的任务(键是粘贴请求号) | 正确:一行描述一个内容版本,键只是历史标识;worker 只按 `status = pending` 且「任务内容摘要 = 章节当前内容」认领 | 不变的门控:`RetryIngestJob` 仍拒绝「任务内容摘要 ≠ 章节当前内容」的旧任务(409),所以旧版本任务不会把新版本章节拉回处理;同一版本重复处理是幂等的;未改动的正文仍然不产生新版本、不新增任务。 + +## 英汉词典与规范音标(#40,schema v12) + +一本自托管英语学习工具需要回答"这个词是什么意思",英语学习者往往先要中文。WordNet 只给英英释义, +因此 #40 增加第二本词典,并让两本可以分别启停。 + +### 资源与准备 + +- 数据源:ECDICT(`skywind3000/ECDICT`,Release 1.0.28 的 `ecdict-stardict-28.zip`,仓库 MIT)提供中文释义; + CMUdict(`cmusphinx/cmudict`,BSD-2)提供 ARPAbet 音素;WordNet 词表(与 `server/wordnet-resource.json` 同一 pin) + 用于裁剪子集。 +- `server/zh-dictionary-resource.json` 记录三者的 URL、sha256、字节数与许可证文件名;**镜像只用于传输, + 身份是 sha256**,准备脚本逐个校验,不匹配即失败。 +- `scripts/dict_prepare.py`(Python,仅工具链,不是运行时代码)执行:下载并校验三个源 → 解析 StarDict `.ifo/.idx/.dict` + 与 CMUdict → 裁剪子集(小写单词,且存在于 WordNet 词表或带 ECDICT 考试标签)→ 生成音标 → 写 ZIP + (`manifest.json` + `entries.jsonl.gz`)。产物默认落在 `.local/dictionaries/`,**不入库**。 +- 产出前强制校验:条目数与 pin 容差(±2000)、音标字符集必须全部落在 IPA 字母表内,否则拒绝产出; + `manifest.json` 记录三类音标计数与丢弃计数。 + +### 音标来源分层(不做语义猜测) + +1. 词在 CMUdict 中 → 按固定 ARPAbet→IPA 映射转写(`1`→`ˈ`、`2`→`ˌ`、`0` 不标;`AH0`→`ə`、`IY0`→`i`、 + `ER0`→`ɚ`;单音节不标重音,`cat` 是 `/kæt/` 而不是 `/kˈæt/`)。 +2. 否则用 ECDICT 记法做**字符级**规范化:`'`→`ˈ`、`,`/`.`→`ˌ`、`:`→`ː`、`ә`(U+04D9)→`ə`(U+0259)、 + `ε`→`e`、空格/`-`/`=`/`;`/`^` 删除;来源标记为 `ecdict`。 +3. 音标以 `^` 开头说明原记法已丢失首音(`grok` 存成 `^rɔk`),整条丢弃——宁可没有,也不给错的转写; + 出现其它非 IPA 字符同样丢弃。两个来源都没有音标就不显示。 + +导入时 `provider` 与 `phoneticSource` 一起进入条目,前端据此区分"IPA 转写"与"ECDICT 记法"。 + +### 存储与查询 + +- schema v12:`addDictionaryProvider` 增加 `provider VARCHAR(32) NOT NULL DEFAULT 'wordnet'`, + `dropDictionarySingleSlotCheck` 从 `information_schema` 查到 `CHECK (id = 1)` 后删除。 + 两步都是条件执行、可重放;版本行只在全部成功后推进,旧二进制看到 v12 会拒绝启动并要求显式迁移。 +- 槽位固定:1 = WordNet(英英),2 = ECDICT(英汉 + 音标)。归档整包存进 `lexgo_dictionaries.archive`, + 所以普通 dump 就是完整备份;`scripts/dict_prepare.py` 的产物约 3.0 MB。 +- `parseResource` 按 `format` 分派解析器;`dictionaryCache` 按资源 id 缓存已解析词典(键含 sha256)。 +- 查词合并(`mergeLookups`):中文释义条目在前、英英释义随后,`Resources` 列出全部启用词典, + `Resource` 保持为 WordNet 以兼容既有界面;任一本缺失/损坏/停用时只用剩下那本,都不可用时 + 仍是 `resource_missing`。 +- 屈折形:WordNet 的规则解析给出词目后,再用该词目回查中文词典,因此 `dogs` 也能看到"狗"; + 但音标属于词目,**不复制**到屈折形上(`dogs` 不显示 `/dɔɡ/`)。 + +## 登录会话有效期(#42) + +- 会话有效期是单一具名常量 `SessionLifetime`(`server/app/lexgo/service.go`),当前为 **30 天**; + 登录时写入 `lexgo_sessions.expires_at`,每次请求按 `expires_at > now` 校验。学习端与管理端共用 + 同一登录接口,因此这一个值同时决定两端的有效期。 +- 有效期是**绝对**的,不随请求顺延;没有刷新令牌或轮换机制。撤销路径与有效期无关,保持独立: + 退出删除当前会话行,改密码、停用、重置删除该账号全部会话行,登录时顺手清理该账号已过期的行。 +- 服务端只保存令牌的 SHA-256 摘要(`token_hash` 主键),原始令牌只在客户端 sessionStorage。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 13eae6b..bee60d8 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: e248e18282d7d2599e1689007ed36b1f1c076267 -synchronized_at: 2026-09-16T03:01:16Z +wiki_revision: 1f89692c0e24c3d2235a4cd7ad78b90512852f65 +synchronized_at: 2026-09-16T13:43:13Z # 业务规则与术语 @@ -54,7 +54,7 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则 - 账号去首尾空白并转小写,3~32 位、ASCII 字母开头,可含数字、点、下划线和连字符;唯一约束阻止大小写和并发重复。密码 10~72 字节,使用 bcrypt,不填写必需邮箱。 - 初始管理员只通过显式 bootstrap 建立;库中已有账号时拒绝再次 bootstrap,不覆盖密码。管理页面只创建学习者,不能将输入角色升级为管理员;本阶段不提供账号删除。 -- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,8 小时过期。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。 +- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,**30 天**过期(2026-09-16 由 8 小时改为 30 天,见 #42;单一常量 `SessionLifetime`,绝对有效期、不滑动续期)。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。 - 每次请求读取账号当前状态与会话有效期;学习数据由认证身份确定,管理员权限不能解除学习接口的本人过滤。 - 两端分别使用 lexgo-admin-token、lexgo-learner-token 的 sessionStorage;401、退出和账号切换清除私人状态,以代次检查阻止迟到响应恢复旧数据。管理端拒绝学习者并撤销其新会话;撤销服务异常会提示未确认,不冒充服务端退出成功。 - 新建账号拥有英语空空间。此处没有书籍、阅读、词典或复习功能,不把空页面视为学习业务已实现。 @@ -426,3 +426,22 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺 - 同一版本重复提交**没有副作用**:正文没变时不产生新版本,也不新增任务。 - 上次处理失败的版本在被重新发布时**重试次数归零**,因为「人再次要求」不应被此前的失败预算挡住。 - 回退到早期版本**不会恢复**当时的词条状态、复习排期或章节完成记录:正文版本与个人学习状态是分开的(#10/#11 已确认的口径)。 + +## 词典、音标与个人释义的边界(#40) + +- **词典内容不是个人数据**:WordNet(英英)与英汉词典(ECDICT 子集 + CMUdict 音标)是全局资源, + 对所有学习账号相同,管理员可分别启用或停用;停用只影响查询时的可用来源,不删除个人已保存内容。 +- **个人释义与状态优先**:用户自己写下的释义、状态与等级仍按 #7/#8 的规则独立保存,不因词典新增中文释义 + 而被改写、合并或自动填充;面板上中文释义与英英释义只是参考内容。 +- **音标来源必须可见**:IPA 转写(CMUdict)与 ECDICT 记法(仅做字符级规范化)是两种不同的可信度, + 系统记录 `phoneticSource`;不确定的记法直接不显示,不用猜测结果冒充实测音标。`grok` 属此类。 +- **屈折形**:`dogs` 通过 WordNet 的规则解析看到词目 `dog` 的释义属允许行为,但音标不随屈折形显示, + 避免把词目的发音说成屈折形的发音。 +- **子集口径**:英汉词典只收常用词(约 8.2 万条:小写单词且存在于 WordNet 词表或带考试标签), + 不导入 ECDICT 全量 340 万条;漏收的词表现为"只有英英释义",不是错误。 +- **许可**:ECDICT 仓库为 MIT,词典数据由公开资料整理,再分发前需自行评估来源;CMUdict 为 BSD-2; + 两份许可证随仓库保存(`server/ECDICT-LICENSE.txt`、`server/CMUDICT-LICENSE.txt`),词典归档本身不提交进 Git。 + +术语:**英英词典**(WordNet,英语释义)、**英汉词典**(ECDICT 子集,中文释义 + 音标)、 +**IPA 转写**(由 CMUdict 音素按固定映射转成)、**ECDICT 记法**(老式音标,仅做字符级规范化)、 +**子集口径**(决定哪些词进入英汉词典的规则)。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 28ebd37..e498d16 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: 632b9d8b7c9951d05bf3c0b2f34f08da704e65dc -synchronized_at: 2026-09-16T03:01:16Z +wiki_revision: 45306a5d50c5674f512ea206739c6b2d89e88a85 +synchronized_at: 2026-09-16T13:43:13Z # 本地开发与验证 @@ -1115,3 +1115,50 @@ schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playbac - 只验证了「单章来回切换」;多章并发编辑、超大文本反复切换与移动端上的编辑体验未单独压测。 - 与编辑器交互无关:本单只改后端编排,前端在 #10 已实现「正文改版会重新处理」的提示。 + +## 英汉词典的准备、导入与验证(#40) + +```powershell +# 1) 显式准备(需要网络,只在此步骤下载;产物不入库) +python scripts/dict_prepare.py +# 可用 --cache-dir .local/dictionaries/cache 复用已下载的源,--keep-going 在计数漂移时仍产出 +# 打印条目数、三类音标计数、产物字节数与 sha256 + +# 2) 显式迁移(v12 增加 provider 并放开单行约束) +python scripts/server.py migrate + +# 3) 导入:管理端「英语词典」页选择"英汉释义"后上传 .local/dictionaries/zh-dict-v1.zip +# 命令行等价:POST /api/v1/dictionaries/import(multipart: name/language/version/source/format + file) +# 导入失败或校验不通过时保留原有词典,不会替换成半成品。 + +# 4) 查询验证 +curl -s http://127.0.0.1:8000/api/v1/dictionaries # items[].provider 应为 wordnet / ecdict +python .local/verify-issue40-api.py # 28 项真实 API 检查 +``` + +- 资源路径:准备好的归档 `.local/dictionaries/zh-dict-v1.zip`(约 3.0 MB);WordNet 夹具沿用 + `.local/nlp-resources/wordnet.zip`。测试在夹具缺失时跳过,不会静默通过。 +- 测试命令: + `LEXGO_TEST_DB_NAME=lexgo_test_issue40 python scripts/server.py test-integration`(Go 96 项)、 + `cd learner && npx vitest --run`(159 项)、`npx playwright test`(26 项,含 390×844 触摸视口)、 + `cd admin && npx pnpm test`(32 项)。 +- 迁移回退:把 `lexgo_schema.version` 写回 11、删除 id=2 的词典行即可;旧二进制缺少 `provider` 字段的 + 读语句不受影响(新列有默认值),但旧二进制见到 v12 会拒绝启动并要求显式迁移,这是有意的保护。 +- 排错:`provider` 不存在 → 未执行 migrate;导入 400 且提示版本不符 → 归档不是当前 pin 生成的 + (重新运行 `dict_prepare.py`);查词只有英英释义 → 英汉词典未导入或被停用。 + +## 验证登录有效期(#42) + +```powershell +# 固定值本身在测试里断言,改动会让测试立刻失败 +cd server; go test ./app/lexgo/ -run TestMySQLSessionLifetimeIsThirtyDays -v +# 集成测试:到期前仍可用、到期后被拒绝(按 SessionLifetime 计算,不写死小时数) +LEXGO_TEST_DB_NAME=lexgo_test_issue42 python scripts/server.py test-integration +``` + +- `TestMySQLSessionLifetimeIsThirtyDays` 校验三件事:常量等于 30 天、登录响应 `expiresAt` 与库内 + `expires_at` 一致、库内只存在摘要(原始令牌查不到);`TestMySQLAccountIsolationAndRevocation` + 在 `SessionLifetime - 1h` 仍成功、再前进 2 小时被拒。 +- 手工排查:`SELECT owner_id, expires_at FROM lexgo_sessions ORDER BY expires_at DESC;`。 + 若登录后立刻 401,先确认服务器时间与 `expires_at` 都以 UTC 存储(`DATETIME` 不存时区)。 +- 旧的 8 小时会话不受影响:各自按自己的 `expires_at` 自然到期,不需要数据迁移。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index 59359b5..ede9d01 100644 --- a/docs/09-product-requirements-overview.md +++ b/docs/09-product-requirements-overview.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements-Overview wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.- -wiki_revision: daf31e0d00508a7d0cf504a62072ed69322a5644 -synchronized_at: 2026-09-16T02:25:57Z +wiki_revision: 5c7b0b4942b2c10edadf2f2b84686675267d323e +synchronized_at: 2026-09-16T13:43:15Z # 产品需求总览 @@ -335,3 +335,14 @@ F 范围之外的 MVP 增补项「书籍音频与封面附件」已实现,待 用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);**书籍作者**与**章节作者**是两份互相独立的可选自由文本(各 ≤120 字符,留空即清空),书级作者显示在书籍页书名右侧并由「编辑书籍」维护,章节作者显示在阅读页标题下;schema 为 v11;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。 **已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。 + +## F06 词典范围扩展:英汉释义与规范音标(#40) + +用户 2026-09-16 提出「找个英中词典,单词有发音」,并澄清"发音"指**音标**(举例「grok 的 /gra:k/」), +随后确认三项决策:约 5 万词量级的常用子集、规范 IPA 音标、允许多词典并存(schema v12)。 + +已交付范围:点词后同时看到**中文释义**(ECDICT 子集,约 8.2 万条)与**音标** +(优先 CMUdict IPA 转写,其余保留 ECDICT 记法并标注来源),英英释义仍作为补充; +两本词典可分别启停,个人释义与学习状态规则不变。 + +非目标:发音**音频**、整句翻译、汉英方向(中→英)、词频统计界面。F 系列其余功能与 X 系列范围不受影响。 diff --git a/docs/11-deployment-and-operations.md b/docs/11-deployment-and-operations.md index ee649cb..f0ab2a3 100644 --- a/docs/11-deployment-and-operations.md +++ b/docs/11-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Deployment-and-Operations.- -wiki_revision: b9eda78d3e71dab02dbf2e3af700c81d9c725f8d -synchronized_at: 2026-09-15T13:07:36Z +wiki_revision: 2fa9c30e08fc14346d9960178a425a8ee3564a3a +synchronized_at: 2026-09-16T13:43:15Z # 部署与运维 @@ -217,3 +217,14 @@ python scripts/ops.py smoke --api http://127.0.0.1:<端口> --admin-user <管理 - 恢复演练是单机顺序执行,未验证大库恢复耗时与磁盘空间上限。 - 接口级的两账号闭环与越权验证需要 HTTP 客户端,目前只在 Python 工具通道提供;Go 二进制提供数据库层的备份、恢复与校验。 - 备份仍调用 `mysqldump`:自己实现一致性导出风险更高,因此部署机需要 MySQL 客户端而不只是服务端。 + +## 备份体积与词典资源(#40) + +英汉词典归档(约 3.0 MB)与 WordNet(约 10.3 MB)都以整包形式存在 `lexgo_dictionaries.archive`, +因此 `lexgo backup` 的 dump 天然包含两本词典,恢复后无需重新下载或重新准备词典。 + +- 备份/恢复流程与 #15 相同,不需要新增参数;`lexgo verify` 检查的仍是同一套清单。 +- 词典归档由 `scripts/dict_prepare.py` 在本机从 sha256 pin 的源生成,**产物不入库**; + 恢复演练时若需要重新准备,必须重新下载并在准备阶段校验 sha256。 +- 迁移到 v12 后旧二进制会被拒绝启动(见本地开发页),因此回退二进制必须先回退 schema 版本标记。 +- 未做:定时备份、多主机、HTTPS 的缺口沿用 #15 记录,不因本单变化。 diff --git a/docs/README.md b/docs/README.md index db080d2..b38fd82 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Home wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home -wiki_revision: 3af7d77905777a9b0725e5be755eb4a7f461cad7 -synchronized_at: 2026-09-16T02:25:55Z +wiki_revision: b59bdc8bc11e7ce8e89de84d36aab2bfe225f692 +synchronized_at: 2026-09-16T13:43:12Z # LexGo 文档入口 @@ -100,3 +100,7 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn #37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。 #37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。 + +#40 英汉词典与规范音标已于 2026-09-16 实现并进入待验收:新增第二本英语词典——ECDICT 常用子集(约 8.2 万条中文释义,3.0 MB)与 CMUdict 转写的 IPA 音标。音标分三层来源:能由 CMUdict 音素按固定映射转写的用真 IPA(35,799 条),否则保留 ECDICT 记法仅做字符级规范化并标注来源(30,580 条),两者都不可靠就不显示(16,342 条,例如 `grok`);含 `^` 等丢失首音的记法整条丢弃,不做猜测。schema 升到 v12(放开 `lexgo_dictionaries` 单行 CHECK、增加 `provider` 列,两步都带条件守卫、可重放),英英与英汉词典可分别启停,查词时中文释义在前、英英释义随后,屈折形经 WordNet lemma 解析后也能看到中文释义但不会复制词目音标。归档由 `scripts/dict_prepare.py` 从 sha256 pin 的三个源显式准备(运行时不联网,产物不入库,仓库只保存 pin 与两份许可证),整包存进 MySQL 因而包含在 #15 的备份范围内。测试:Go 96 项、学习端 159 单测与 26 项 e2e(含 390×844 触摸视口)、管理端 32 项、真实 API 28 项检查;PR #41 待合入,尚未验收。发音音频、整句翻译与汉英方向不在本单范围。 + +#42 登录有效期已于 2026-09-16 由 8 小时改为 **30 天**(学习端与管理端共用同一登录接口,一处生效):有效期提取为具名常量 `SessionLifetime`,仍然是**绝对**有效期、不做滑动续期,撤销规则不变(退出只撤销当前会话,改密码/停用/重置撤销该账号全部会话,服务端只存 SHA-256 摘要)。代价是令牌泄露后的可用窗口放大约 90 倍;设备/会话列表与"退出其他设备"未包含在本单,如需另开工单。无数据库结构变化,旧会话按各自到期时间自然过渡;测试:Go 92 项(新增 1 项锁定 30 天与摘要存储,并把过期断言改为按常量计算);PR 待合入,尚未验收。 -- 2.34.1 From 2d17231b42032a0c3b692a542fd225f7e3e7c964 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Wed, 16 Sep 2026 21:50:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E5=B0=86=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E4=B8=8E=20main=20=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E4=B8=80=E8=87=B4=EF=BC=8C=E9=95=9C=E5=83=8F=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=9C=A8=E9=AA=8C=E6=94=B6=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E4=B8=AD=E5=AE=8C=E6=88=90=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/00-project-profile.md | 6 ++---- docs/02-architecture-and-code-map.md | 13 ++---------- docs/03-business-rules-and-glossary.md | 6 +++--- docs/04-local-development-and-verification.md | 20 ++----------------- docs/09-product-requirements-overview.md | 2 +- docs/11-deployment-and-operations.md | 2 +- docs/README.md | 6 ++---- 7 files changed, 13 insertions(+), 42 deletions(-) diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 07f6f6a..90ea64c 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.- -wiki_revision: 4ed96aac25feaa97a48224d7a150a73e7c6c6987 -synchronized_at: 2026-09-16T13:43:12Z +wiki_revision: e61817b949693c82e74ad3d1a8adb39983a90fab +synchronized_at: 2026-09-16T05:27:04Z # LexGo 项目档案 @@ -196,5 +196,3 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必 至此 MVP 原范围 #2~#15 与增补 #21、#37 全部实施并验收;仍待处理 #24 书库列表优化与缺陷 #32。#16 的 #37 复选框已勾选。 - 2026-09-16 新增第二本英语词典(#40):ECDICT 常用子集提供中文释义、CMUdict 提供 IPA 转写音标,二者由 sha256 pin 的源显式准备;schema v12 放开 `lexgo_dictionaries` 单行约束并增加 `provider` 列,英英与英汉词典可分别启停。音标来源必须可追溯(IPA 转写 / ECDICT 记法),不确定时不显示。许可:ECDICT 仓库 MIT(数据由公开资料整理,再分发前需自行评估)、CMUdict BSD-2,许可证随仓库保存,词典归档不提交进 Git。 - -- 2026-09-16 登录有效期由 8 小时改为 **30 天**(#42):两端共用同一登录接口,有效期是具名常量 `SessionLifetime`,绝对有效期、不滑动续期;服务端只存令牌 SHA-256 摘要,改密码/停用/重置仍撤销该账号全部会话。设备与会话管理界面未包含,如需另建工单。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 4694747..bb62618 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: 64a83458a283469672e111f423d37d9478a3d0e3 -synchronized_at: 2026-09-16T13:43:13Z +wiki_revision: 8cc11b2fa972babeb4756b68c0b071282cdb1960 +synchronized_at: 2026-09-16T05:27:05Z # 架构与代码地图 @@ -573,12 +573,3 @@ schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS` 仍是 `resource_missing`。 - 屈折形:WordNet 的规则解析给出词目后,再用该词目回查中文词典,因此 `dogs` 也能看到"狗"; 但音标属于词目,**不复制**到屈折形上(`dogs` 不显示 `/dɔɡ/`)。 - -## 登录会话有效期(#42) - -- 会话有效期是单一具名常量 `SessionLifetime`(`server/app/lexgo/service.go`),当前为 **30 天**; - 登录时写入 `lexgo_sessions.expires_at`,每次请求按 `expires_at > now` 校验。学习端与管理端共用 - 同一登录接口,因此这一个值同时决定两端的有效期。 -- 有效期是**绝对**的,不随请求顺延;没有刷新令牌或轮换机制。撤销路径与有效期无关,保持独立: - 退出删除当前会话行,改密码、停用、重置删除该账号全部会话行,登录时顺手清理该账号已过期的行。 -- 服务端只保存令牌的 SHA-256 摘要(`token_hash` 主键),原始令牌只在客户端 sessionStorage。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index bee60d8..e5ca80b 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: 1f89692c0e24c3d2235a4cd7ad78b90512852f65 -synchronized_at: 2026-09-16T13:43:13Z +wiki_revision: ba03226b60eee1c44062f4206b970a2047b8ddbc +synchronized_at: 2026-09-16T05:27:06Z # 业务规则与术语 @@ -54,7 +54,7 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则 - 账号去首尾空白并转小写,3~32 位、ASCII 字母开头,可含数字、点、下划线和连字符;唯一约束阻止大小写和并发重复。密码 10~72 字节,使用 bcrypt,不填写必需邮箱。 - 初始管理员只通过显式 bootstrap 建立;库中已有账号时拒绝再次 bootstrap,不覆盖密码。管理页面只创建学习者,不能将输入角色升级为管理员;本阶段不提供账号删除。 -- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,**30 天**过期(2026-09-16 由 8 小时改为 30 天,见 #42;单一常量 `SessionLifetime`,绝对有效期、不滑动续期)。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。 +- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,8 小时过期。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。 - 每次请求读取账号当前状态与会话有效期;学习数据由认证身份确定,管理员权限不能解除学习接口的本人过滤。 - 两端分别使用 lexgo-admin-token、lexgo-learner-token 的 sessionStorage;401、退出和账号切换清除私人状态,以代次检查阻止迟到响应恢复旧数据。管理端拒绝学习者并撤销其新会话;撤销服务异常会提示未确认,不冒充服务端退出成功。 - 新建账号拥有英语空空间。此处没有书籍、阅读、词典或复习功能,不把空页面视为学习业务已实现。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index e498d16..2c1c4be 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: 45306a5d50c5674f512ea206739c6b2d89e88a85 -synchronized_at: 2026-09-16T13:43:13Z +wiki_revision: afcd40ee530a24f924a39209c23a9dfd090114a2 +synchronized_at: 2026-09-16T05:27:07Z # 本地开发与验证 @@ -1146,19 +1146,3 @@ python .local/verify-issue40-api.py # 28 项真实 API 读语句不受影响(新列有默认值),但旧二进制见到 v12 会拒绝启动并要求显式迁移,这是有意的保护。 - 排错:`provider` 不存在 → 未执行 migrate;导入 400 且提示版本不符 → 归档不是当前 pin 生成的 (重新运行 `dict_prepare.py`);查词只有英英释义 → 英汉词典未导入或被停用。 - -## 验证登录有效期(#42) - -```powershell -# 固定值本身在测试里断言,改动会让测试立刻失败 -cd server; go test ./app/lexgo/ -run TestMySQLSessionLifetimeIsThirtyDays -v -# 集成测试:到期前仍可用、到期后被拒绝(按 SessionLifetime 计算,不写死小时数) -LEXGO_TEST_DB_NAME=lexgo_test_issue42 python scripts/server.py test-integration -``` - -- `TestMySQLSessionLifetimeIsThirtyDays` 校验三件事:常量等于 30 天、登录响应 `expiresAt` 与库内 - `expires_at` 一致、库内只存在摘要(原始令牌查不到);`TestMySQLAccountIsolationAndRevocation` - 在 `SessionLifetime - 1h` 仍成功、再前进 2 小时被拒。 -- 手工排查:`SELECT owner_id, expires_at FROM lexgo_sessions ORDER BY expires_at DESC;`。 - 若登录后立刻 401,先确认服务器时间与 `expires_at` 都以 UTC 存储(`DATETIME` 不存时区)。 -- 旧的 8 小时会话不受影响:各自按自己的 `expires_at` 自然到期,不需要数据迁移。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index ede9d01..62d06f4 100644 --- a/docs/09-product-requirements-overview.md +++ b/docs/09-product-requirements-overview.md @@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements-Overview wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.- wiki_revision: 5c7b0b4942b2c10edadf2f2b84686675267d323e -synchronized_at: 2026-09-16T13:43:15Z +synchronized_at: 2026-09-16T05:27:09Z # 产品需求总览 diff --git a/docs/11-deployment-and-operations.md b/docs/11-deployment-and-operations.md index f0ab2a3..765c75b 100644 --- a/docs/11-deployment-and-operations.md +++ b/docs/11-deployment-and-operations.md @@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Deployment-and-Operations.- wiki_revision: 2fa9c30e08fc14346d9960178a425a8ee3564a3a -synchronized_at: 2026-09-16T13:43:15Z +synchronized_at: 2026-09-16T05:27:09Z # 部署与运维 diff --git a/docs/README.md b/docs/README.md index b38fd82..c7d725e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Home wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home -wiki_revision: b59bdc8bc11e7ce8e89de84d36aab2bfe225f692 -synchronized_at: 2026-09-16T13:43:12Z +wiki_revision: bbea927c73e51b6b5490de3cb52d42a88dfbb31d +synchronized_at: 2026-09-16T05:27:04Z # LexGo 文档入口 @@ -102,5 +102,3 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn #37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。 #40 英汉词典与规范音标已于 2026-09-16 实现并进入待验收:新增第二本英语词典——ECDICT 常用子集(约 8.2 万条中文释义,3.0 MB)与 CMUdict 转写的 IPA 音标。音标分三层来源:能由 CMUdict 音素按固定映射转写的用真 IPA(35,799 条),否则保留 ECDICT 记法仅做字符级规范化并标注来源(30,580 条),两者都不可靠就不显示(16,342 条,例如 `grok`);含 `^` 等丢失首音的记法整条丢弃,不做猜测。schema 升到 v12(放开 `lexgo_dictionaries` 单行 CHECK、增加 `provider` 列,两步都带条件守卫、可重放),英英与英汉词典可分别启停,查词时中文释义在前、英英释义随后,屈折形经 WordNet lemma 解析后也能看到中文释义但不会复制词目音标。归档由 `scripts/dict_prepare.py` 从 sha256 pin 的三个源显式准备(运行时不联网,产物不入库,仓库只保存 pin 与两份许可证),整包存进 MySQL 因而包含在 #15 的备份范围内。测试:Go 96 项、学习端 159 单测与 26 项 e2e(含 390×844 触摸视口)、管理端 32 项、真实 API 28 项检查;PR #41 待合入,尚未验收。发音音频、整句翻译与汉英方向不在本单范围。 - -#42 登录有效期已于 2026-09-16 由 8 小时改为 **30 天**(学习端与管理端共用同一登录接口,一处生效):有效期提取为具名常量 `SessionLifetime`,仍然是**绝对**有效期、不做滑动续期,撤销规则不变(退出只撤销当前会话,改密码/停用/重置撤销该账号全部会话,服务端只存 SHA-256 摘要)。代价是令牌泄露后的可用窗口放大约 90 倍;设备/会话列表与"退出其他设备"未包含在本单,如需另开工单。无数据库结构变化,旧会话按各自到期时间自然过渡;测试:Go 92 项(新增 1 项锁定 30 天与摘要存储,并把过期断言改为按常量计算);PR 待合入,尚未验收。 -- 2.34.1