Files
lexgo/server/app/lexgo/terms.go
T
ila 325849816e fix: 整改 #8 审核问题 R1~R3 (#8)
- R2 并发同键作答:取得词条行锁后加锁复查答案键,stale 插入遇到唯一键冲突转为返回
  已记录结果,不再返回 500;新增两个 goroutine 同键提交的集成用例
- R3 排期:只有新建或状态/等级实际变化才移动 due_at,编辑释义与例句保留原排期,
  逾期词条不会被挤出当天队列
- R3 附带发现:保存未提及等级时保留已获得的等级,阅读器面板不再把 4 级词重置为 1 级
- R1 契约:作答响应 result 只取 applied/stale,另加 duplicate 标记,重放返回首次结果;
  客户端按首次结果计数,本轮只解决卡片而没有新计分时显示完成页而不是空队列
- R4/R5:stale 与重放分别给出角色为 status 的提示,answerId 作用域注释与实现一致
- Wiki 更新 Business-Rules-and-Glossary、Architecture-and-Code-Map、
  Local-Development-and-Verification 并同步镜像
2026-09-11 22:46:23 +08:00

374 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lexgo
import (
"errors"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The four learner-visible statuses are stored explicitly instead of the upstream
// merged status/level code. The mapping stays available for CSV export and for
// migrating an existing LinguaCafe instance: new=2, ignored=1, known=0, and
// learning level N=-N. A level is only meaningful while learning, so every other
// status keeps level 0 and the database check constraints repeat that rule.
const (
termStatusNew = "new"
termStatusLearning = "learning"
termStatusKnown = "known"
termStatusIgnored = "ignored"
)
const (
termFormLimit = 128
termDefinitionLimit = 2000
termExampleLimit = 500
termExamplesLimit = 5
termLevelMax = 7
// One chapter may contain many distinct words, so identity lookups are batched
// instead of sending an unbounded IN list.
termLookupBatch = 500
)
var termStatuses = map[string]bool{
termStatusNew: true,
termStatusLearning: true,
termStatusKnown: true,
termStatusIgnored: true,
}
// Term is one learner's own record for a word form. Identity is the normalized
// form: the original spelling is kept for display only.
type Term struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
OwnerID int
Language string
Term string
OriginalForm string `gorm:"column:original_form"`
Definition string
Examples string
Status string
Level int
CreatedAt time.Time
UpdatedAt time.Time
}
func (Term) TableName() string { return "lexgo_terms" }
type TermView struct {
ID int64 `json:"id"`
Language string `json:"language"`
Term string `json:"term"`
OriginalForm string `json:"originalForm"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// TokenTerm is what a reader token needs to render its own highlight: the entry
// id for the follow-up read, and enough state to style the word.
type TokenTerm struct {
ID int64 `json:"id"`
Status string `json:"status"`
Level int `json:"level"`
}
type TermSave struct {
Term TermView `json:"term"`
Created bool `json:"created"`
}
// splitExamples returns the stored examples as a list; the column holds one per line.
func splitExamples(text string) []string {
if text == "" {
return []string{}
}
return strings.Split(text, "\n")
}
func termView(t Term) TermView {
return TermView{t.ID, t.Language, t.Term, t.OriginalForm, t.Definition, splitExamples(t.Examples), t.Status, t.Level, t.CreatedAt, t.UpdatedAt}
}
// termLevel enforces the documented status/level boundary: only a learning entry carries a
// level, every other status must leave the level at 0, and entering learning starts at 1.
// A save that does not mention a level keeps the level the learner already earned, so
// editing a definition can never roll a word back to level 1.
func termLevel(status string, level *int, previous Term, exists bool) (int, error) {
if !termStatuses[status] {
return 0, failure(400, "词语状态无效")
}
if status == termStatusLearning {
if level != nil && *level != 0 {
if *level < 1 || *level > termLevelMax {
return 0, failure(400, "学习等级须为 1~7")
}
return *level, nil
}
if exists && previous.Status == termStatusLearning && previous.Level >= 1 {
return previous.Level, nil
}
return 1, nil
}
if level != nil && *level != 0 {
return 0, failure(400, "只有学习中的词语可以设置等级")
}
return 0, nil
}
func hasControlRune(text string, allowNewline bool) bool {
for _, r := range text {
if allowNewline && (r == '\n' || r == '\t') {
continue
}
if unicode.IsControl(r) {
return true
}
}
return false
}
// termContent validates the learner's own text and returns it in storage form:
// the definition as typed (trimmed) and examples joined by newline.
func termContent(definition string, examples []string) (string, string, error) {
definition = strings.TrimSpace(definition)
if utf8.RuneCountInString(definition) > termDefinitionLimit {
return "", "", failure(400, "个人释义不能超过 2000 个字符")
}
if hasControlRune(definition, true) {
return "", "", failure(400, "个人释义包含不支持的字符")
}
if len(examples) > termExamplesLimit {
return "", "", failure(400, "例句不能超过 5 条")
}
cleaned := make([]string, 0, len(examples))
for _, example := range examples {
example = strings.TrimSpace(example)
if example == "" {
return "", "", failure(400, "例句不能为空行")
}
if utf8.RuneCountInString(example) > termExampleLimit {
return "", "", failure(400, "每条例句不能超过 500 个字符")
}
if hasControlRune(example, false) {
return "", "", failure(400, "例句包含不支持的字符")
}
cleaned = append(cleaned, example)
}
return definition, strings.Join(cleaned, "\n"), nil
}
// wordAtRange returns the word the learner actually selected. Identity is always
// derived from the server's own tokens, so a client cannot name a word, user or
// language that it did not read from this chapter.
func wordAtRange(chapter Chapter, start, end int) (string, error) {
for _, token := range Tokenize(chapter.OriginalText) {
if token.Kind == "word" && token.Start == start && token.End == end {
if utf8.RuneCountInString(token.Text) <= termFormLimit {
return token.Text, nil
}
break
}
}
return "", failure(400, "请选择不超过 128 个字符的完整单词")
}
// languageOf reads the owner's study language. A missing space row falls back to
// the English default that account creation and login already establish.
func languageOf(tx *gorm.DB, owner int) (string, error) {
var space Space
err := tx.Where("owner_id = ?", owner).First(&space).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "en", nil
}
if err != nil {
return "", err
}
return space.Language, nil
}
// previousTerm reads the row this save is about to change under a lock, so the level and
// the review schedule can be compared with what the learner already had.
func previousTerm(tx *gorm.DB, owner int, language, term string) (Term, bool, error) {
var existing Term
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("owner_id = ? AND language = ? AND term = ?", owner, language, term).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Term{}, false, nil
}
if err != nil {
return Term{}, false, err
}
return existing, true, nil
}
// saveTerm writes one identity with INSERT ... ON DUPLICATE KEY UPDATE: a repeated
// save updates the same row instead of adding a second, conflicting record, and
// two concurrent saves of the same word still leave exactly one.
func saveTerm(tx *gorm.DB, owner int, language, word string, fields TermFields, now time.Time) (TermSave, error) {
when := stamp(now)
row := Term{
OwnerID: owner, Language: language, Term: normalizeWord(word), OriginalForm: word,
Definition: fields.Definition, Examples: fields.Examples, Status: fields.Status, Level: fields.Level,
CreatedAt: when, UpdatedAt: when,
}
// MySQL reports affected rows 1 for an insert and 0 or 2 for an update, so the
// counter distinguishes "created" from "saved again" without a second read.
insert := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner_id"}, {Name: "language"}, {Name: "term"}},
DoUpdates: clause.Assignments(map[string]any{
"original_form": row.OriginalForm, "definition": row.Definition, "examples": row.Examples,
"status": row.Status, "level": row.Level, "updated_at": row.UpdatedAt,
}),
}).Create(&row)
if insert.Error != nil {
return TermSave{}, insert.Error
}
var stored Term
if err := tx.Where("owner_id = ? AND language = ? AND term = ?", owner, language, row.Term).First(&stored).Error; err != nil {
return TermSave{}, err
}
// A saved word always owns a schedule row, but the date only moves for a new word or a
// real status/level change: editing a definition must not push a word out of today's
// queue (decision D2).
reschedule := !fields.Exists || fields.PreviousStatus != stored.Status || fields.PreviousLevel != stored.Level
if err := syncTermReview(tx, stored, reschedule, now); err != nil {
return TermSave{}, err
}
return TermSave{termView(stored), insert.RowsAffected == 1}, nil
}
// attachTerms marks the tokens this learner already saved. Matching is by
// normalized form across the whole vocabulary, so a word saved in another chapter
// is highlighted here with the same status.
func attachTerms(tx *gorm.DB, owner int, language string, tokens []TextToken) error {
keys := make([]string, 0, 16)
seen := map[string]bool{}
for _, token := range tokens {
if token.Kind != "word" || utf8.RuneCountInString(token.Text) > termFormLimit {
continue
}
key := normalizeWord(token.Text)
if !seen[key] {
seen[key] = true
keys = append(keys, key)
}
}
byKey := map[string]TokenTerm{}
for start := 0; start < len(keys); start += termLookupBatch {
end := min(start+termLookupBatch, len(keys))
var rows []Term
if err := tx.Select("id", "term", "status", "level").
Where("owner_id = ? AND language = ? AND term IN ?", owner, language, keys[start:end]).
Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
byKey[row.Term] = TokenTerm{ID: row.ID, Status: row.Status, Level: row.Level}
}
}
for i := range tokens {
if tokens[i].Kind != "word" {
continue
}
if term, ok := byKey[normalizeWord(tokens[i].Text)]; ok {
value := term
tokens[i].Term = &value
}
}
return nil
}
// TermFields carries the validated learner text and state into storage, together with the
// status and level the row had before this save.
type TermFields struct {
Definition string
Examples string
Status string
Level int
PreviousStatus string
PreviousLevel int
Exists bool
}
type TermInput struct {
ChapterID int64 `json:"chapterId"`
Start *int `json:"start"`
End *int `json:"end"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level *int `json:"level"`
}
// registerTermRoutes exposes the learner's own word records. Nothing here is
// written to the audit log: personal learning content stays out of it, and the
// account-level audit already covers administrative changes.
func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.POST("/terms", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input TermInput
if err := decode(c, &input); err != nil {
return nil, err
}
if input.Start == nil || input.End == nil {
return nil, failure(400, "请选择完整单词")
}
chapter, err := readyOwnedChapter(tx, u.UserId, input.ChapterID)
if err != nil {
return nil, err
}
word, err := wordAtRange(chapter, *input.Start, *input.End)
if err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
// The previous row decides whether a missing level keeps the earned one and whether
// the review date may move at all.
previous, exists, err := previousTerm(tx, u.UserId, language, normalizeWord(word))
if err != nil {
return nil, err
}
level, err := termLevel(input.Status, input.Level, previous, exists)
if err != nil {
return nil, err
}
definition, examples, err := termContent(input.Definition, input.Examples)
if err != nil {
return nil, err
}
fields := TermFields{
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
return saveTerm(tx, u.UserId, language, word, fields, now())
}))
v.GET("/terms/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
return nil, failure(404, "词条不存在")
}
// Another account's id and a missing id answer identically, so the response
// never confirms that someone else's entry exists.
var term Term
if err := tx.Where("id = ? AND owner_id = ?", id, u.UserId).First(&term).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return nil, failure(404, "词条不存在")
} else if err != nil {
return nil, err
}
return gin.H{"term": termView(term)}, nil
}))
}