fix(study): 클라이언트 카운터 신뢰 X — 서버 max+1 자동 채움 (user-edited dirty flag)

이전 fix(effect→onchange)에도 race 재발 (id 306,307 qnum=1,2 로 또 들어감).
근본 해결 — 클라이언트의 f_qnum 표시값과 실제 저장값을 분리.

변경:
- f_qnum_user_edited dirty flag 추가
- input 에 oninput → user_edited=true (사용자가 직접 박스 수정한 경우)
- onMount fallback / onRoundChange / applyNewRound / 저장 후 → user_edited=false
- POST body 의 exam_question_number: user_edited=true 면 명시 전송, false 면
  null → 서버가 같은 회차 max+1 자동 채움 (PR-6 의 기존 서버 로직)
- POST 응답의 실제 저장 qnum 으로 화면 동기화 (saved.exam_question_number)
  → 표시값이 어긋났어도 저장 후 정확하게 갱신
- applyNewRound 에서 이미 존재하는 회차명 입력 시 next_question_number 적용
  (사용자가 dropdown 대신 새 회차 모드로 같은 이름 다시 입력해도 1번부터 다시 시작 X)

이제 클라이언트가 어떤 표시값을 보여주든 실제 저장은 항상 정확. 사용자가
직접 박스를 수정한 경우만 명시 전송.
This commit is contained in:
Hyungi Ahn
2026-04-28 13:25:08 +09:00
parent 0d66107743
commit a7b3164f78
@@ -48,7 +48,11 @@
let f_exam_round = $state('');
let f_exam_round_mode = $state('select'); // 'select' | 'new'
let f_exam_round_new = $state('');
let f_qnum = $state(1); // 문항 번호
let f_qnum = $state(1); // 문항 번호 (표시용 — 저장 시 서버 max+1 우선)
// PR-7: 사용자가 input 박스를 직접 편집했는지 추적. true 면 저장 시 명시 전송,
// false 면 null 보내고 서버가 같은 회차 max+1 자동 채움. 회차 변경/저장 후/onMount
// 에서 false 로 reset.
let f_qnum_user_edited = $state(false);
// 본문 자동 reset 안 되는 메타 (편의성 — 비워둠이 default)
let explanation = $state('');
@@ -146,6 +150,8 @@
f_qnum = found.next_question_number;
}
}
// 진입 시점은 사용자가 input 박스를 만진 적 없음 — false 로 reset.
f_qnum_user_edited = false;
// $effect 의 lastExamRound 를 현재 값으로 sync — 첫 실행이 또 reset 하지 않도록.
lastExamRound = f_exam_round;
});
@@ -176,6 +182,7 @@
} else if (!found) {
f_qnum = 1;
}
f_qnum_user_edited = false;
lastExamRound = f_exam_round;
refreshCompleteFlag();
}
@@ -200,11 +207,20 @@
addToast('error', '회차명을 입력하세요');
return;
}
f_exam_round = f_exam_round_new.trim();
const newRound = f_exam_round_new.trim();
f_exam_round = newRound;
f_exam_round_new = '';
f_exam_round_mode = 'select';
f_qnum = 1;
lastExamRound = f_exam_round;
// PR-7 fix: 사용자가 "새 회차" 모드에 이미 존재하는 회차명을 입력했으면
// next_question_number 로 시작 (1번부터 다시 시작 X). dropdown 선택과 동일 동작.
const found = examRounds.find((r) => r.exam_round === newRound);
if (found && found.next_question_number) {
f_qnum = found.next_question_number;
} else {
f_qnum = 1;
}
f_qnum_user_edited = false;
lastExamRound = newRound;
refreshCompleteFlag();
}
@@ -234,6 +250,9 @@
persist();
try {
const subj = effectiveSubject();
// PR-7 fix: 클라이언트 카운터를 신뢰하지 않고 서버가 항상 max+1 결정.
// 사용자가 직접 input 박스를 다른 값으로 수정한 경우만 명시 전송.
const userEditedQnum = f_qnum_user_edited && f_exam_round;
const body = {
question_text: q_text.trim(),
choice_1: c1.trim(),
@@ -245,18 +264,25 @@
scope: f_scope || null,
exam_name: f_exam_name || null,
exam_round: f_exam_round || null,
exam_question_number: f_exam_round ? Number(f_qnum) : null,
// 사용자가 직접 수정 안 했으면 null → 서버가 같은 회차 max+1 자동 채움
exam_question_number: userEditedQnum ? Number(f_qnum) : null,
explanation: explanation || null,
source_note: autoSourceNote() || null,
};
await api(`/study-topics/${topicId}/questions`, {
const saved = await api(`/study-topics/${topicId}/questions`, {
method: 'POST',
body: JSON.stringify(body),
});
addToast('success', `문제 저장됨${f_exam_round ? ` (${f_exam_round} ${f_qnum}번)` : ''}`);
// 응답의 실제 저장값으로 표시 동기화 (서버가 결정한 qnum)
const actualQnum = saved?.exam_question_number ?? null;
if (actualQnum) f_qnum = actualQnum;
addToast('success', `문제 저장됨${f_exam_round && actualQnum ? ` (${f_exam_round} ${actualQnum}번)` : ''}`);
if (continueAfter) {
clearForCont();
f_qnum = Number(f_qnum) + 1;
// 다음 표시값 = 방금 저장된 qnum + 1. 사용자가 다시 수정하기 전까지는
// 자동(서버 max+1) 모드 유지.
if (actualQnum) f_qnum = actualQnum + 1;
f_qnum_user_edited = false;
// 회차 진행률 갱신 (도달 체크)
await refreshExamRounds();
persist();
@@ -406,6 +432,7 @@
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">문항 번호</span>
<input type="number" min="1" bind:value={f_qnum}
oninput={() => (f_qnum_user_edited = true)}
class="w-28 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
{#if currentProgress() && currentProgress().size}