- 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 并同步镜像
374 lines
13 KiB
Go
374 lines
13 KiB
Go
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
|
||
}))
|
||
}
|