Skip to content

Commit 1c4db78

Browse files
authored
Merge pull request #10 from zyh001/fix/quiz-ui-improvements
Fix/quiz UI improvements
2 parents 6dc27a0 + ea319cb commit 1c4db78

7 files changed

Lines changed: 424 additions & 14 deletions

File tree

assets/static/sw.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Service Worker for 医考练习 PWA
2-
const CACHE_NAME = 'med-quiz-v2.11.0';
2+
const CACHE_NAME = 'med-quiz-v2.12.0';
33

44
const STATIC_ASSETS = [
55
'/static/common.css',

internal/progress/progress.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,16 @@ CREATE TABLE IF NOT EXISTS ai_chat_logs (
9090
provider TEXT DEFAULT '',
9191
created_at INTEGER NOT NULL
9292
);
93-
CREATE INDEX IF NOT EXISTS idx_acl_created ON ai_chat_logs(created_at);`
93+
CREATE INDEX IF NOT EXISTS idx_acl_created ON ai_chat_logs(created_at);
94+
95+
CREATE TABLE IF NOT EXISTS exam_sessions (
96+
id TEXT PRIMARY KEY,
97+
answers_json TEXT NOT NULL DEFAULT '{}',
98+
ts INTEGER NOT NULL,
99+
started_at INTEGER NOT NULL DEFAULT 0,
100+
time_limit INTEGER NOT NULL DEFAULT 0,
101+
revealed_at INTEGER NOT NULL DEFAULT 0
102+
);`
94103

95104
if _, err = db.Exec(ddl); err != nil {
96105
return nil, fmt.Errorf("progress: init schema: %w", err)

internal/server/exam_persist.go

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
package server
2+
3+
// exam_persist.go — 考试会话持久化
4+
//
5+
// 不再写 JSON 文件,改为存入已有数据库(SQLite 或 PostgreSQL)。
6+
// 表结构非常简单,与 sessions / attempts 等表共处一个 DB。
7+
//
8+
// SQLite:直接用 b.DB(*sql.DB)
9+
// Postgres:通过 b.PgStore,要求其实现 examSessionStorer 接口
10+
//
11+
// 降级策略:
12+
// - 若所有 bank 均无可用 DB,退回到旧的 JSON 文件(exam-sessions.json),
13+
// 并打印一条警告,保证数据永远不会丢。
14+
15+
import (
16+
"context"
17+
"database/sql"
18+
"encoding/json"
19+
"fmt"
20+
"os"
21+
"path/filepath"
22+
"time"
23+
24+
"github.com/zyh001/med-exam-kit/internal/logger"
25+
"github.com/zyh001/med-exam-kit/internal/store"
26+
)
27+
28+
// ────────────────────────────────────────────────────────────────
29+
// Postgres interface (implemented in store/postgres/postgres.go)
30+
// ────────────────────────────────────────────────────────────────
31+
32+
// examSessionStorer is the subset of PgStore used here.
33+
// postgres.Store implements this; noop.Store returns empty/nil.
34+
type examSessionStorer interface {
35+
SaveExamSession(ctx context.Context, id string, answersJSON []byte, ts, startedAt int64, timeLimit int, revealedAt int64) error
36+
LoadExamSessions(ctx context.Context) ([]store.ExamSessionRow, error)
37+
DeleteExamSession(ctx context.Context, id string) error
38+
}
39+
40+
// ────────────────────────────────────────────────────────────────
41+
// SQLite DDL (one-time migration, called from progress.InitDB)
42+
// ────────────────────────────────────────────────────────────────
43+
44+
const examSessionsDDL = `
45+
CREATE TABLE IF NOT EXISTS exam_sessions (
46+
id TEXT PRIMARY KEY,
47+
answers_json TEXT NOT NULL DEFAULT '{}',
48+
ts INTEGER NOT NULL,
49+
started_at INTEGER NOT NULL DEFAULT 0,
50+
time_limit INTEGER NOT NULL DEFAULT 0,
51+
revealed_at INTEGER NOT NULL DEFAULT 0
52+
);`
53+
54+
// initExamSessionsTable ensures the SQLite table exists.
55+
func initExamSessionsTable(db *sql.DB) error {
56+
_, err := db.Exec(examSessionsDDL)
57+
return err
58+
}
59+
60+
// ────────────────────────────────────────────────────────────────
61+
// dbOrNil returns (sqliteDB, pgStorer) for the first available bank.
62+
// Caller must check which is non-nil.
63+
// ────────────────────────────────────────────────────────────────
64+
func (s *Server) dbOrNil() (*sql.DB, examSessionStorer) {
65+
for i := range s.cfg.Banks {
66+
b := &s.cfg.Banks[i]
67+
if b.PgStore != nil {
68+
if st, ok := b.PgStore.(examSessionStorer); ok {
69+
return nil, st
70+
}
71+
}
72+
if b.DB != nil {
73+
return b.DB, nil
74+
}
75+
}
76+
return nil, nil
77+
}
78+
79+
// ────────────────────────────────────────────────────────────────
80+
// Public helpers (called from server.go)
81+
// ────────────────────────────────────────────────────────────────
82+
83+
// persistExamSessions writes all current sessions to the DB (best-effort).
84+
// Run in a goroutine for non-blocking writes; call synchronously before shutdown.
85+
func (s *Server) persistExamSessions() {
86+
s.examMu.Lock()
87+
snap := make(map[string]*examSession, len(s.examSessions))
88+
for id, sess := range s.examSessions {
89+
snap[id] = sess
90+
}
91+
s.examMu.Unlock()
92+
93+
sqlDB, pgSt := s.dbOrNil()
94+
95+
if sqlDB == nil && pgSt == nil {
96+
// No DB available — fall back to JSON file
97+
s.persistExamSessionsFile(snap)
98+
return
99+
}
100+
101+
ctx := context.Background()
102+
for id, sess := range snap {
103+
raw, err := json.Marshal(sess.answers)
104+
if err != nil {
105+
logger.Errorf("[exam-persist] 序列化 session %s 失败: %v", id, err)
106+
continue
107+
}
108+
if pgSt != nil {
109+
if err := pgSt.SaveExamSession(ctx, id, raw, sess.ts, sess.startedAt, sess.timeLimit, sess.revealedAt); err != nil {
110+
logger.Errorf("[exam-persist] PG 写入 session %s 失败: %v", id, err)
111+
}
112+
} else {
113+
if err := sqliteSaveExamSession(sqlDB, id, raw, sess.ts, sess.startedAt, sess.timeLimit, sess.revealedAt); err != nil {
114+
logger.Errorf("[exam-persist] SQLite 写入 session %s 失败: %v", id, err)
115+
}
116+
}
117+
}
118+
}
119+
120+
// loadExamSessions reads persisted sessions into memory on startup,
121+
// pruning stale sessions.
122+
func (s *Server) loadExamSessions() {
123+
sqlDB, pgSt := s.dbOrNil()
124+
125+
var rows []store.ExamSessionRow
126+
if pgSt != nil {
127+
ctx := context.Background()
128+
// Ensure table exists (Postgres schema migration is idempotent)
129+
var err error
130+
rows, err = pgSt.LoadExamSessions(ctx)
131+
if err != nil {
132+
logger.Warnf("[exam-persist] PG 读取 exam_sessions 失败: %v", err)
133+
return
134+
}
135+
} else if sqlDB != nil {
136+
// Ensure SQLite table exists
137+
if err := initExamSessionsTable(sqlDB); err != nil {
138+
logger.Warnf("[exam-persist] SQLite 建表失败: %v", err)
139+
return
140+
}
141+
var err error
142+
rows, err = sqliteLoadExamSessions(sqlDB)
143+
if err != nil {
144+
logger.Warnf("[exam-persist] SQLite 读取 exam_sessions 失败: %v", err)
145+
return
146+
}
147+
} else {
148+
// Fallback: try the JSON file written by an older version
149+
s.loadExamSessionsFile()
150+
return
151+
}
152+
153+
nowS := time.Now().Unix()
154+
loaded := 0
155+
s.examMu.Lock()
156+
for _, row := range rows {
157+
if nowS-row.Ts > 86400 {
158+
continue
159+
}
160+
if row.RevealedAt != 0 && nowS-row.RevealedAt > int64(revealGraceWindow) {
161+
continue
162+
}
163+
var answers map[string]examAnswer
164+
if err := json.Unmarshal(row.AnswersJSON, &answers); err != nil {
165+
logger.Warnf("[exam-persist] 反序列化 session %s 失败: %v", row.ID, err)
166+
continue
167+
}
168+
s.examSessions[row.ID] = &examSession{
169+
answers: answers,
170+
ts: row.Ts,
171+
startedAt: row.StartedAt,
172+
timeLimit: row.TimeLimit,
173+
revealedAt: row.RevealedAt,
174+
}
175+
loaded++
176+
}
177+
s.examMu.Unlock()
178+
179+
if loaded > 0 {
180+
logger.Infof("[exam-persist] 从数据库恢复 %d 个考试会话", loaded)
181+
}
182+
}
183+
184+
// activeExamCount returns the number of in-progress (not yet revealed) sessions.
185+
func (s *Server) activeExamCount() int {
186+
s.examMu.Lock()
187+
defer s.examMu.Unlock()
188+
n := 0
189+
for _, sess := range s.examSessions {
190+
if sess.revealedAt == 0 {
191+
n++
192+
}
193+
}
194+
return n
195+
}
196+
197+
// examSessionsSummary returns a human-readable summary for the shutdown warning.
198+
func (s *Server) examSessionsSummary() string {
199+
s.examMu.Lock()
200+
defer s.examMu.Unlock()
201+
active := 0
202+
for _, sess := range s.examSessions {
203+
if sess.revealedAt == 0 {
204+
active++
205+
}
206+
}
207+
return fmt.Sprintf("%d 场考试正在进行中(答案已持久化到数据库,重启后可恢复)", active)
208+
}
209+
210+
// ────────────────────────────────────────────────────────────────
211+
// SQLite helpers
212+
// ────────────────────────────────────────────────────────────────
213+
214+
func sqliteSaveExamSession(db *sql.DB, id string, answersJSON []byte, ts, startedAt int64, timeLimit int, revealedAt int64) error {
215+
_, err := db.Exec(`
216+
INSERT INTO exam_sessions(id, answers_json, ts, started_at, time_limit, revealed_at)
217+
VALUES (?, ?, ?, ?, ?, ?)
218+
ON CONFLICT(id) DO UPDATE SET
219+
answers_json = excluded.answers_json,
220+
ts = excluded.ts,
221+
started_at = excluded.started_at,
222+
time_limit = excluded.time_limit,
223+
revealed_at = excluded.revealed_at`,
224+
id, string(answersJSON), ts, startedAt, timeLimit, revealedAt)
225+
return err
226+
}
227+
228+
func sqliteLoadExamSessions(db *sql.DB) ([]store.ExamSessionRow, error) {
229+
rows, err := db.Query(`
230+
SELECT id, answers_json, ts, started_at, time_limit, revealed_at
231+
FROM exam_sessions`)
232+
if err != nil {
233+
return nil, err
234+
}
235+
defer rows.Close()
236+
var out []store.ExamSessionRow
237+
for rows.Next() {
238+
var r store.ExamSessionRow
239+
var answersStr string
240+
if err := rows.Scan(&r.ID, &answersStr, &r.Ts, &r.StartedAt, &r.TimeLimit, &r.RevealedAt); err != nil {
241+
continue
242+
}
243+
r.AnswersJSON = []byte(answersStr)
244+
out = append(out, r)
245+
}
246+
return out, rows.Err()
247+
}
248+
249+
// ────────────────────────────────────────────────────────────────
250+
// JSON-file fallback (no DB available — shouldn't normally happen)
251+
// ────────────────────────────────────────────────────────────────
252+
253+
func (s *Server) examSessionsFilePath() string {
254+
base := "exam-sessions.json"
255+
if s.configPath != "" {
256+
return filepath.Join(filepath.Dir(s.configPath), base)
257+
}
258+
return base
259+
}
260+
261+
func (s *Server) persistExamSessionsFile(snap map[string]*examSession) {
262+
logger.Warnf("[exam-persist] 无可用数据库,降级写 JSON 文件")
263+
type diskEntry struct {
264+
Answers map[string]examAnswer `json:"answers"`
265+
Ts int64 `json:"ts"`
266+
StartedAt int64 `json:"started_at"`
267+
TimeLimit int `json:"time_limit"`
268+
RevealedAt int64 `json:"revealed_at"`
269+
}
270+
m := make(map[string]diskEntry, len(snap))
271+
for id, sess := range snap {
272+
m[id] = diskEntry{sess.answers, sess.ts, sess.startedAt, sess.timeLimit, sess.revealedAt}
273+
}
274+
data, _ := json.MarshalIndent(m, "", " ")
275+
path := s.examSessionsFilePath()
276+
tmp := path + ".tmp"
277+
_ = os.WriteFile(tmp, data, 0600)
278+
_ = os.Rename(tmp, path)
279+
}
280+
281+
func (s *Server) loadExamSessionsFile() {
282+
path := s.examSessionsFilePath()
283+
data, err := os.ReadFile(path)
284+
if err != nil {
285+
return
286+
}
287+
type diskEntry struct {
288+
Answers map[string]examAnswer `json:"answers"`
289+
Ts int64 `json:"ts"`
290+
StartedAt int64 `json:"started_at"`
291+
TimeLimit int `json:"time_limit"`
292+
RevealedAt int64 `json:"revealed_at"`
293+
}
294+
var m map[string]diskEntry
295+
if err := json.Unmarshal(data, &m); err != nil {
296+
return
297+
}
298+
nowS := time.Now().Unix()
299+
loaded := 0
300+
s.examMu.Lock()
301+
for id, d := range m {
302+
if nowS-d.Ts > 86400 || (d.RevealedAt != 0 && nowS-d.RevealedAt > int64(revealGraceWindow)) {
303+
continue
304+
}
305+
s.examSessions[id] = &examSession{answers: d.Answers, ts: d.Ts, startedAt: d.StartedAt, timeLimit: d.TimeLimit, revealedAt: d.RevealedAt}
306+
loaded++
307+
}
308+
s.examMu.Unlock()
309+
if loaded > 0 {
310+
logger.Infof("[exam-persist] 从 JSON 文件恢复 %d 个考试会话(旧格式)", loaded)
311+
}
312+
}

0 commit comments

Comments
 (0)