-- ============================================= -- Education 模块 — 错题本 DDL -- Ticket #9: 错题自动收集、复习练习创建 -- Migration: 005 -- Prerequisites: 004-education-submit-report.sql (report + detail tables) -- ============================================= -- ============================================= -- Preconditions -- ============================================= -- Operator is expected to verify: -- SELECT COUNT(*) FROM information_schema.tables -- WHERE table_schema = current_database() -- AND table_name IN ('education_wrong_question', -- 'education_wrong_question_idempotency'); -- Result MUST be 0 before executing this migration. -- -- Verify prerequisite tables exist: -- SELECT COUNT(*) FROM information_schema.tables -- WHERE table_schema = current_database() -- AND table_name IN ('education_practice_report', -- 'education_practice_report_detail'); -- Result MUST be 2. -- ============================================= -- PracticeReportDetailDO: add content_version + options snapshot columns -- ============================================= -- Purpose: At submit time, snapshot question content version and options -- (without isCorrect) so wrong-question book and review sessions have -- stable display data. The options are already stripped of isCorrect -- by the submit flow. ALTER TABLE education_practice_report_detail ADD COLUMN content_version VARCHAR(64) NOT NULL DEFAULT '' /* 题目内容版本快照 */, ADD COLUMN options TEXT DEFAULT NULL /* 选项快照 JSON(不含 isCorrect) */; -- ============================================= -- PracticeSessionDO: add review fingerprint column -- ============================================= -- Purpose: Persist the canonical fingerprint of wrong-question IDs used -- to create a review session. On idempotent replay, the fingerprint -- is compared: same tenant+clientSessionId+sameUser+sortedIDs match -- returns the existing session; different ID set returns -- SESSION_IDEMPOTENCY_MISMATCH. ALTER TABLE education_practice_session ADD COLUMN review_fingerprint VARCHAR(64) DEFAULT NULL /* 复习会话题目指纹(SHA-256 of sorted unique wrongQuestionIds) */; -- 错题表 -- ============================================= -- Purpose: Persistent wrong-question book per student. -- Each (tenant, user, question) is a unique entry. -- Repeated wrong answers on the SAME question increment wrong_count -- and update last_wrong_time. The idempotency guard table ensures -- each (tenant, user, question, report) can upsert at most once. -- -- master_status values: 'PENDING' (default) | 'MASTERED' -- Marking mastered retains the full history and count; it does NOT -- delete or archive the record. Students can optionally un-master. -- -- Snapshot fields (stem, type, difficulty, options, content_version): -- populated from the latest report detail that touched this question. -- These are for listing/detail display without joining report details. -- -- latest_correct_answer, latest_explanation: -- also from the latest report detail; available for detail display -- post-submit (not exposed in review session creation pre-submit). -- -- Indexes: -- uk_tenant_user_question — per-tenant, per-user, per-question uniqueness. -- INSERT ... ON CONFLICT ... DO UPDATE is the primary write path. -- idx_tenant_user_status — covers filtered list queries (page with status filter). -- idx_tenant_user_last_wrong — covers time-sorted listing. CREATE TABLE education_wrong_question ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY /* 主键 */, tenant_id BIGINT NOT NULL /* 租户编号 */, user_id BIGINT NOT NULL /* 学生用户编号 */, question_id VARCHAR(64) NOT NULL /* 原始题目 ID */, -- snapshot fields for listing / detail (from latest report detail) stem TEXT NOT NULL /* 题干快照(最新) */, type VARCHAR(32) NOT NULL /* 题型快照 */, difficulty VARCHAR(32) DEFAULT NULL /* 难度快照 */, options JSONB NOT NULL /* 选项快照 JSON(不含 isCorrect) */, content_version VARCHAR(64) NOT NULL DEFAULT '' /* 题目内容版本 */, latest_correct_answer TEXT DEFAULT NULL /* 正确答案快照(最新,供详情展示) */, latest_explanation TEXT DEFAULT NULL /* 解析快照(最新,供详情展示) */, -- timing & count first_wrong_time TIMESTAMP NOT NULL /* 首次错误时间 */, last_wrong_time TIMESTAMP NOT NULL /* 最近错误时间 */, wrong_count INT NOT NULL DEFAULT 1 /* 累计错误次数 */, -- mastery master_status VARCHAR(20) NOT NULL DEFAULT 'PENDING' /* 掌握状态:PENDING-待掌握, MASTERED-已掌握 */, mastered_time TIMESTAMP DEFAULT NULL /* 标记掌握时间 */, -- provenance last_report_id BIGINT DEFAULT NULL /* 最近关联的报告 ID */, last_session_id BIGINT DEFAULT NULL /* 最近关联的会话 ID */, -- audit creator VARCHAR(64) DEFAULT '' /* 创建者 */, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP /* 创建时间 */, updater VARCHAR(64) DEFAULT '' /* 更新者 */, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP /* 更新时间 */, deleted BOOLEAN NOT NULL DEFAULT false /* 是否删除 */ ); COMMENT ON TABLE education_wrong_question IS '教育-错题本'; CREATE UNIQUE INDEX uk_tenant_user_question ON education_wrong_question (tenant_id, user_id, question_id); CREATE INDEX idx_tenant_user_status ON education_wrong_question (tenant_id, user_id, master_status); CREATE INDEX idx_tenant_user_last_wrong ON education_wrong_question (tenant_id, user_id, last_wrong_time); -- ============================================= -- 错题流水幂等表 -- ============================================= -- Purpose: Ensure each (tenant, user, question, report) upserts the -- wrong-question book exactly once. The submitSession transaction -- INSERT ... ON CONFLICT DO NOTHING into this table BEFORE the -- wrong question upsert; a duplicate means this report already -- contributed to the count. This guards against: -- - Replayed submit (idempotent resubmit) double-counting -- - Concurrent submit races where both threads evaluate the -- same report details -- -- Indexes: -- uk_tenant_user_question_report — per (tenant, user, question, report) uniqueness. -- INSERT ... ON CONFLICT DO NOTHING provides the idempotency guard -- BEFORE upserting. wrong_question_id is filled after upsert for -- audit purposes. -- idx_report — fast lookup by report for audit/debug. CREATE TABLE education_wrong_question_idempotency ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY /* 主键 */, tenant_id BIGINT NOT NULL /* 租户编号 */, user_id BIGINT NOT NULL /* 学生用户编号 */, wrong_question_id BIGINT DEFAULT NULL /* 错题记录 ID(upsert 后填充) */, report_id BIGINT NOT NULL /* 报告 ID */, question_id VARCHAR(64) NOT NULL /* 题目 ID */, creator VARCHAR(64) DEFAULT '' /* 创建者 */, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP /* 创建时间 */, updater VARCHAR(64) DEFAULT '' /* 更新者 */, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP /* 更新时间 */, deleted BOOLEAN NOT NULL DEFAULT false /* 是否删除 */ ); COMMENT ON TABLE education_wrong_question_idempotency IS '教育-错题流水幂等'; CREATE UNIQUE INDEX uk_tenant_user_question_report ON education_wrong_question_idempotency (tenant_id, user_id, question_id, report_id); CREATE INDEX idx_report ON education_wrong_question_idempotency (report_id); -- ============================================= -- Post-migration verification queries -- ============================================= -- Verify new tables exist: -- \d education_wrong_question -- \d education_wrong_question_idempotency -- Verify unique keys are enforced: -- SELECT * FROM pg_indexes WHERE tablename = 'education_wrong_question' AND indexname = 'uk_tenant_user_question'; -- SELECT * FROM pg_indexes WHERE tablename = 'education_wrong_question_idempotency' AND indexname = 'uk_tenant_user_question_report'; -- Verify no orphan data (should be 0 after fresh migration): -- SELECT COUNT(*) FROM education_wrong_question; -- SELECT COUNT(*) FROM education_wrong_question_idempotency;