feat(education): complete Flyway migration and atomic submit

This commit is contained in:
2026-07-30 12:06:55 +08:00
parent ce02f8acb4
commit 79a5799502
228 changed files with 32551 additions and 1376 deletions

View File

@@ -0,0 +1,156 @@
-- =============================================
-- 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 /* 错题记录 IDupsert 后填充) */,
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;