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 并同步镜像
This commit is contained in:
ila
2026-09-11 22:46:23 +08:00
parent ec5ec2db35
commit 325849816e
12 changed files with 362 additions and 88 deletions
+36 -2
View File
@@ -69,7 +69,7 @@ describe('review store', () => {
expect(review.finished).toBe(false)
})
it('treats a stale or replayed answer as the same action, never as a second review', async () => {
it('treats a stale answer as no new score but finishes the round instead of reporting an empty queue', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/reviews/queue') ? ok({ items: [item()], total: 1 }) : ok(answer({ result: 'stale', requeued: false })))
const review = useReviewStore()
await review.load()
@@ -77,7 +77,33 @@ describe('review store', () => {
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(0)
expect(review.correctCount).toBe(0)
expect(review.empty).toBe(true)
expect(review.resolved).toBe(1)
expect(review.empty).toBe(false)
expect(review.finished).toBe(true)
expect(review.notice).toContain('已在其他页面复习')
})
it('counts a replay of this client own answer after a lost response', async () => {
let sent = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
sent += 1
// The first response never reaches the client, the retry reports the recorded answer.
if (sent === 1) throw new Error('网络中断')
return ok(answer({ result: 'applied', duplicate: true }))
})
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.error).toContain('网络中断')
expect(review.answered).toBe(0)
await review.answer('correct')
expect(review.answered).toBe(1)
expect(review.correctCount).toBe(1)
expect(review.wordsReviewed).toBe(1)
expect(review.empty).toBe(false)
expect(review.finished).toBe(true)
expect(review.notice).toBe('')
})
it('keeps the card and the same answer id when a submission fails, then retries once', async () => {
@@ -203,6 +229,14 @@ describe('review page', () => {
expect(view.text()).toContain('还有 2 个词条到期')
})
it('tells the learner when a card was already reviewed elsewhere', async () => {
const { view } = await open({ items: [item()], total: 1 }, () => ok(answer({ result: 'stale', duplicate: false })))
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-notice"]').text()).toContain('已在其他页面复习')
expect(view.get('[data-testid="review-summary"]').text()).toContain('本轮没有新的计分')
})
it('ends the round without submitting anything', async () => {
const { view, router, fetchMock } = await open({ items: [item()], total: 1 })
await view.get('[data-testid="review-end"]').trigger('click'); await flushPromises()
+28 -7
View File
@@ -3,7 +3,9 @@ import { defineStore } from 'pinia'
import { useSessionStore } from './session'
export type ReviewGrade = 'correct' | 'wrong' | 'again'
export type ReviewResult = 'applied' | 'duplicate' | 'stale'
// The outcome of an answer: applied when the word moved, stale when another screen had
// already reviewed it. A replayed answer repeats the outcome it was given first.
export type ReviewResult = 'applied' | 'stale'
export interface ReviewItem {
id: number
@@ -19,6 +21,7 @@ export interface ReviewItem {
export interface ReviewAnswerResult {
result: ReviewResult
duplicate: boolean
grade: ReviewGrade
requeued: boolean
statusBefore: string
@@ -67,19 +70,25 @@ export const useReviewStore = defineStore('review', () => {
const wrongCount = ref(0)
// Distinct words in this round: a requeued word is answered again but is one word.
const wordsReviewed = ref(0)
// Cards this round took off the queue, counted for every outcome, and the reason a card
// left without a new score.
const resolved = ref(0)
const notice = ref('')
const seen = new Set<number>()
// Words due beyond the fetched page, reported by the server for this round.
const pending = ref(0)
let started = ref(false)
// One answer id per card and grade: a retry of the same action keeps its key, so the
// server can answer it from the first outcome instead of counting twice.
// One answer id per card: a retry of a failed submission reuses its key, so the server
// answers the retry from the first outcome instead of counting the same action twice.
let attempt: { itemId: number; answerId: string } | null = null
let sequence = 0
let generation = 0
const current = computed<ReviewItem | null>(() => queue.value[0] ?? null)
const finished = computed(() => started.value && !loading.value && queue.value.length === 0 && answered.value > 0)
const empty = computed(() => started.value && !loading.value && queue.value.length === 0 && answered.value === 0)
// A round that resolved cards is finished even when every answer turned out to be a
// replay or a stale submission; only a round that never had a card is empty.
const finished = computed(() => started.value && !loading.value && queue.value.length === 0 && resolved.value > 0)
const empty = computed(() => started.value && !loading.value && queue.value.length === 0 && resolved.value === 0)
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
@@ -105,6 +114,8 @@ export const useReviewStore = defineStore('review', () => {
correctCount.value = 0
wrongCount.value = 0
wordsReviewed.value = 0
resolved.value = 0
notice.value = ''
pending.value = 0
started.value = false
attempt = null
@@ -118,6 +129,7 @@ export const useReviewStore = defineStore('review', () => {
loading.value = true
error.value = ''
revealed.value = false
notice.value = ''
attempt = null
try {
const result = await session.request<QueueResponse>('reviews/queue')
@@ -133,6 +145,7 @@ export const useReviewStore = defineStore('review', () => {
correctCount.value = 0
wrongCount.value = 0
wordsReviewed.value = 0
resolved.value = 0
seen.clear()
}
started.value = true
@@ -185,7 +198,11 @@ export const useReviewStore = defineStore('review', () => {
queue.value = queue.value.filter(entry => entry.id !== item.id)
attempt = null
revealed.value = false
resolved.value += 1
if (result.result === 'applied') {
// A replay repeats the first outcome, and the first attempt may be this client's own
// submission whose response was lost, so it counts exactly like that attempt.
notice.value = ''
answered.value += 1
if (!seen.has(item.id)) {
seen.add(item.id)
@@ -193,6 +210,10 @@ export const useReviewStore = defineStore('review', () => {
}
if (result.grade === 'correct') correctCount.value += 1
else wrongCount.value += 1
} else {
notice.value = result.duplicate
? '该词已按上一次的评分记录,未重复计分。'
: '该词已在其他页面复习,本次未计分。'
}
if (result.requeued) {
const next = result.item ?? { ...item, level: result.levelAfter, status: result.statusAfter as ReviewItem['status'], dueAt: result.dueAtAfter }
@@ -206,7 +227,7 @@ export const useReviewStore = defineStore('review', () => {
}
return {
queue, current, loading, error, busy, revealed, answered, correctCount, wrongCount, wordsReviewed, pending,
finished, empty, load, reveal, answer, continueRound, reset,
queue, current, loading, error, busy, revealed, answered, correctCount, wrongCount, wordsReviewed, resolved,
notice, pending, finished, empty, load, reveal, answer, continueRound, reset,
}
})
+1
View File
@@ -128,6 +128,7 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: 34px; }
+3 -1
View File
@@ -38,6 +38,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
</header>
<main class="page review-page">
<h1>到期复习</h1>
<p v-if="review.notice" role="status" class="review-notice" data-testid="review-notice">{{ review.notice }}</p>
<p v-if="review.loading" role="status" class="loading">正在加载…</p>
<div v-else-if="review.error && !review.current" class="notice">
<p role="alert">{{ review.error }}</p>
@@ -45,7 +46,8 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
</div>
<section v-else-if="review.finished" class="review-summary" data-testid="review-summary">
<h2>本次复习完成</h2>
<p>复习了 {{ review.wordsReviewed }} 个词条 · 共 {{ review.answered }} 次作答</p>
<p v-if="review.answered">复习了 {{ review.wordsReviewed }} 个词条 · 共 {{ review.answered }} 次作答</p>
<p v-else class="subtle">本轮没有新的计分:{{ review.resolved }} 个词条已在其他页面复习。</p>
<p class="subtle">答对 {{ review.correctCount }} · 答错或再学 {{ review.wrongCount }} · 已更新复习计划</p>
<p v-if="review.pending" role="status" class="notice">还有 {{ review.pending }} 个词条到期。</p>
<div class="review-grades">