-- ============================================= -- Education 模块 — 交卷提交与成绩报告 DDL (PostgreSQL) -- Ticket #8: 交卷 CAS、保护性答案快照、评分与报告 -- Migration: 004 -- Prerequisites: 003-education-answer-idempotency.sql -- ============================================= -- ============================================= -- Preconditions -- ============================================= -- Operator is expected to verify: -- SELECT COUNT(*) FROM information_schema.tables -- WHERE table_catalog = current_database() -- AND table_name IN ('education_submit_idempotency', -- 'education_practice_report', -- 'education_practice_report_detail'); -- Result MUST be 0 before executing this migration. -- -- Verify prerequisite tables exist: -- SELECT COUNT(*) FROM information_schema.tables -- WHERE table_catalog = current_database() -- AND table_name IN ('education_practice_session', -- 'education_practice_question', -- 'education_answer_idempotency'); -- Result MUST be 3. -- ============================================= -- PracticeQuestionDO: add protected answer snapshot columns -- ============================================= -- Purpose: At session creation, snapshot correct_answer and explanation -- from the full CatalogQuestionDTO. These fields are NEVER exposed -- before submission (enforced by SafeQuestionRespVO allow-list and -- PracticeQuestionRespVO which does not include them). ALTER TABLE education_practice_question ADD COLUMN correct_answer TEXT DEFAULT NULL, ADD COLUMN explanation TEXT DEFAULT NULL; COMMENT ON COLUMN education_practice_question.correct_answer IS '正确答案快照(不可在交卷前暴露)'; COMMENT ON COLUMN education_practice_question.explanation IS '解析快照(不可在交卷前暴露)'; -- ============================================= -- 交卷幂等表 -- ============================================= -- Purpose: Provide durable idempotency for submit-session commands. -- Same (tenant, user, operation, idempotency_key) + same request_hash → replay original report. -- Same key + different request_hash → conflict. -- Concurrent same-key inserts resolved by unique constraint race handling. -- -- Indexes: -- uk_submit_idempotency — per-tenant, per-actor, per-operation uniqueness for idempotency key. -- INSERT during submit. DuplicateKeyException catch for concurrent-create race resolution. -- idx_submit_session — covers lookup by session for audit/debug. CREATE TABLE education_submit_idempotency ( id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, operation VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_SESSION', idempotency_key VARCHAR(64) NOT NULL, request_hash VARCHAR(64) NOT NULL, session_id BIGINT NOT NULL, report_id BIGINT DEFAULT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED', response_json TEXT NOT NULL, 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_submit_idempotency IS '教育-交卷幂等记录'; COMMENT ON COLUMN education_submit_idempotency.id IS '主键'; COMMENT ON COLUMN education_submit_idempotency.tenant_id IS '租户编号'; COMMENT ON COLUMN education_submit_idempotency.user_id IS '交卷用户编号'; COMMENT ON COLUMN education_submit_idempotency.operation IS '操作类型:SUBMIT_SESSION'; COMMENT ON COLUMN education_submit_idempotency.idempotency_key IS '客户端幂等键(UUID)'; COMMENT ON COLUMN education_submit_idempotency.request_hash IS '请求载荷 SHA-256 哈希'; COMMENT ON COLUMN education_submit_idempotency.session_id IS '会话 ID'; COMMENT ON COLUMN education_submit_idempotency.report_id IS '关联的报告 ID(成功时有值)'; COMMENT ON COLUMN education_submit_idempotency.status IS '状态:ACCEPTED-已接受, CONFLICT-冲突'; COMMENT ON COLUMN education_submit_idempotency.response_json IS '首次成功响应 JSON(用于重试重放)'; COMMENT ON COLUMN education_submit_idempotency.creator IS '创建者'; COMMENT ON COLUMN education_submit_idempotency.create_time IS '创建时间'; COMMENT ON COLUMN education_submit_idempotency.updater IS '更新者'; COMMENT ON COLUMN education_submit_idempotency.update_time IS '更新时间'; COMMENT ON COLUMN education_submit_idempotency.deleted IS '是否删除'; CREATE UNIQUE INDEX uk_submit_idempotency ON education_submit_idempotency (tenant_id, user_id, operation, idempotency_key); CREATE INDEX idx_submit_session ON education_submit_idempotency (tenant_id, session_id); -- ============================================= -- 练习报告表(会话级) -- ============================================= -- Purpose: Store the computed scoring result for a submitted session. -- One report per session. Immutable after creation. -- Question snapshots (stem, selectedAnswer, correctAnswer, explanation) -- are stored in report_details so source question edits don't affect history. -- -- Indexes: -- uk_report_session — one report per session (unique). -- idx_report_tenant_user — covers paginated history queries for current tenant+user. -- idx_report_create_time — covers time-sorted listing. CREATE TABLE education_practice_report ( id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, session_id BIGINT NOT NULL, question_count INT NOT NULL, answered_count INT NOT NULL DEFAULT 0, unanswered_count INT NOT NULL DEFAULT 0, correct_count INT NOT NULL DEFAULT 0, incorrect_count INT NOT NULL DEFAULT 0, score INT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED', 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_practice_report IS '教育-练习报告'; COMMENT ON COLUMN education_practice_report.id IS '主键'; COMMENT ON COLUMN education_practice_report.tenant_id IS '租户编号'; COMMENT ON COLUMN education_practice_report.user_id IS '用户编号'; COMMENT ON COLUMN education_practice_report.session_id IS '会话 ID'; COMMENT ON COLUMN education_practice_report.question_count IS '题目总数'; COMMENT ON COLUMN education_practice_report.answered_count IS '已答题数'; COMMENT ON COLUMN education_practice_report.unanswered_count IS '未答题数'; COMMENT ON COLUMN education_practice_report.correct_count IS '正确题数'; COMMENT ON COLUMN education_practice_report.incorrect_count IS '错误题数'; COMMENT ON COLUMN education_practice_report.score IS '得分(整数,满分 100 为基准)'; COMMENT ON COLUMN education_practice_report.status IS '报告状态:SUBMITTED'; COMMENT ON COLUMN education_practice_report.creator IS '创建者'; COMMENT ON COLUMN education_practice_report.create_time IS '创建时间'; COMMENT ON COLUMN education_practice_report.updater IS '更新者'; COMMENT ON COLUMN education_practice_report.update_time IS '更新时间'; COMMENT ON COLUMN education_practice_report.deleted IS '是否删除'; CREATE UNIQUE INDEX uk_report_session ON education_practice_report (session_id); CREATE INDEX idx_report_tenant_user ON education_practice_report (tenant_id, user_id); CREATE INDEX idx_report_create_time ON education_practice_report (create_time); -- ============================================= -- 练习报告明细表(逐题结果) -- ============================================= -- Purpose: Store per-question scoring results at submission time. -- Includes snapshot of stem, selectedAnswer, correctAnswer, and explanation -- so that history is stable even if source questions are later edited. -- -- Indexes: -- uk_report_sequence — per-report uniqueness for question sequence. -- idx_detail_session — covers lookup by session for report assembly. CREATE TABLE education_practice_report_detail ( id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, report_id BIGINT NOT NULL, session_id BIGINT NOT NULL, question_id VARCHAR(64) NOT NULL, sequence INT NOT NULL, stem TEXT NOT NULL, type VARCHAR(32) NOT NULL, difficulty VARCHAR(32) DEFAULT NULL, selected_answer TEXT DEFAULT NULL, correct_answer TEXT DEFAULT NULL, is_correct BOOLEAN NOT NULL DEFAULT false, explanation TEXT DEFAULT NULL, 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_practice_report_detail IS '教育-练习报告明细'; COMMENT ON COLUMN education_practice_report_detail.id IS '主键'; COMMENT ON COLUMN education_practice_report_detail.tenant_id IS '租户编号'; COMMENT ON COLUMN education_practice_report_detail.user_id IS '用户编号'; COMMENT ON COLUMN education_practice_report_detail.report_id IS '报告 ID'; COMMENT ON COLUMN education_practice_report_detail.session_id IS '会话 ID'; COMMENT ON COLUMN education_practice_report_detail.question_id IS '原始题目 ID'; COMMENT ON COLUMN education_practice_report_detail.sequence IS '题目序号(1-based)'; COMMENT ON COLUMN education_practice_report_detail.stem IS '题干快照'; COMMENT ON COLUMN education_practice_report_detail.type IS '题型快照'; COMMENT ON COLUMN education_practice_report_detail.difficulty IS '难度快照'; COMMENT ON COLUMN education_practice_report_detail.selected_answer IS '学生已选答案'; COMMENT ON COLUMN education_practice_report_detail.correct_answer IS '正确答案快照'; COMMENT ON COLUMN education_practice_report_detail.is_correct IS '是否正确'; COMMENT ON COLUMN education_practice_report_detail.explanation IS '解析快照'; COMMENT ON COLUMN education_practice_report_detail.creator IS '创建者'; COMMENT ON COLUMN education_practice_report_detail.create_time IS '创建时间'; COMMENT ON COLUMN education_practice_report_detail.updater IS '更新者'; COMMENT ON COLUMN education_practice_report_detail.update_time IS '更新时间'; COMMENT ON COLUMN education_practice_report_detail.deleted IS '是否删除'; CREATE UNIQUE INDEX uk_report_sequence ON education_practice_report_detail (report_id, sequence); CREATE INDEX idx_detail_session ON education_practice_report_detail (session_id); -- ============================================= -- Post-migration verification queries -- ============================================= -- Verify new tables exist: -- \d education_submit_idempotency -- \d education_practice_report -- \d education_practice_report_detail -- Verify columns added to question table: -- SELECT column_name, data_type, column_default -- FROM information_schema.columns -- WHERE table_catalog = current_database() -- AND table_name = 'education_practice_question' -- AND column_name IN ('correct_answer', 'explanation'); -- Verify unique keys are enforced: -- SELECT * FROM pg_indexes WHERE tablename = 'education_submit_idempotency' AND indexname = 'uk_submit_idempotency'; -- SELECT * FROM pg_indexes WHERE tablename = 'education_practice_report' AND indexname = 'uk_report_session'; -- SELECT * FROM pg_indexes WHERE tablename = 'education_practice_report_detail' AND indexname = 'uk_report_sequence'; -- Verify no orphan data (should be 0 after fresh migration): -- SELECT COUNT(*) FROM education_submit_idempotency; -- SELECT COUNT(*) FROM education_practice_report; -- SELECT COUNT(*) FROM education_practice_report_detail;