-- ============================================= -- Education 模块 — 练习会话与题目快照 DDL -- Ticket #6: 练习会话创建、题目快照、恢复与状态机 -- Migration: 002 -- Prerequisites: 000-education-schema.sql (database creation) -- 001-education-tenant-seed.sql (tenant seed data) -- ============================================= -- ============================================= -- Preconditions -- ============================================= -- This migration MUST fail if either table already exists (no IF NOT EXISTS). -- Operator is expected to verify: -- SELECT COUNT(*) FROM information_schema.tables -- WHERE table_schema = current_database() -- AND table_name IN ('education_practice_session', 'education_practice_question'); -- Result MUST be 0 before executing this migration. -- ============================================= -- 练习会话表 -- ============================================= -- Indexes: -- uk_tenant_client_session — per-tenant uniqueness for clientSessionId idempotency. -- Used by: selectByTenantAndClientSessionId (idempotent create check), -- INSERT ... ON CONFLICT ... for concurrent-create race resolution. -- idx_tenant_user_status — covers getCurrentSession (latest ACTIVE by tenant+user) -- and ownership queries. Column order: (tenant_id, user_id, status) so the -- index supports both filtering by tenant+user and tenant+user+status. -- Lock impact: INSERT acquires a row-level lock on the unique index entry; -- concurrent inserts with same (tenant_id, client_session_id) serialize naturally. -- No additional table-level locks required. CREATE TABLE education_practice_session ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY /* 会话主键 */, tenant_id BIGINT NOT NULL /* 租户编号 */, user_id BIGINT NOT NULL /* Member 用户编号 */, client_session_id VARCHAR(36) NOT NULL /* 客户端生成的会话标识(UUID),用于幂等创建 */, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' /* 会话状态:ACTIVE-进行中, SUBMITTED-已提交, EXPIRED-已过期, CANCELLED-已取消 */, question_count INT NOT NULL DEFAULT 0 /* 题目总数 */, collection_id VARCHAR(64) DEFAULT NULL /* 源题集 ID */, node_id VARCHAR(64) DEFAULT NULL /* 源目录节点 ID */, type VARCHAR(32) DEFAULT NULL /* 筛选题型 */, difficulty VARCHAR(32) DEFAULT NULL /* 筛选难度 */, version INT NOT NULL DEFAULT 0 /* 乐观锁版本号 */, 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_session IS '教育-练习会话'; CREATE UNIQUE INDEX uk_tenant_client_session ON education_practice_session (tenant_id, client_session_id); CREATE INDEX idx_tenant_user_status ON education_practice_session (tenant_id, user_id, status); -- ============================================= -- 练习会话题目快照表 -- ============================================= -- Indexes: -- uk_session_sequence — per-session uniqueness for question sequence numbers. -- Used by: insertBatch to ensure no duplicate sequences within a session. -- idx_session_id — covers selectBySessionIdOrderBySequence (load all questions -- for a session, ordered by sequence). Also used by cascade delete lookups. -- Lock impact: INSERT acquires gap locks within session_id range on uk_session_sequence; -- concurrent inserts into different sessions are independent. -- Options column: JSONB data type stores only label, content, order — never isCorrect. -- Application layer (optionsToSafeJson) strips correctness before storage. CREATE TABLE education_practice_question ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY /* 主键 */, tenant_id BIGINT NOT NULL /* 租户编号 */, session_id BIGINT NOT NULL /* 会话 ID */, sequence INT NOT NULL /* 题目序号(1-based,服务端固定) */, question_id VARCHAR(64) NOT NULL /* 原始题目 ID */, content_version VARCHAR(64) NOT NULL DEFAULT '' /* 快照时的题目内容版本 */, stem TEXT NOT NULL /* 题干快照 */, type VARCHAR(32) NOT NULL /* 题型快照 */, difficulty VARCHAR(32) DEFAULT NULL /* 难度快照 */, options JSONB NOT NULL /* 选项快照 JSON(不含 isCorrect) */, correct_answer TEXT DEFAULT NULL /* 正确答案快照 */, explanation TEXT DEFAULT NULL /* 解析快照 */, selected_answer TEXT DEFAULT NULL /* 学生已选答案 */, is_answered BOOLEAN NOT NULL DEFAULT false /* 是否已作答 */, 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_question IS '教育-练习会话题目快照'; CREATE UNIQUE INDEX uk_session_sequence ON education_practice_question (session_id, sequence); CREATE INDEX idx_session_id ON education_practice_question (session_id); -- ============================================= -- Post-migration verification queries -- ============================================= -- Verify tables exist with correct structure: -- \d education_practice_session -- \d education_practice_question -- Verify unique keys are enforced: -- SELECT * FROM pg_indexes WHERE tablename = 'education_practice_session' AND indexname = 'uk_tenant_client_session'; -- SELECT * FROM pg_indexes WHERE tablename = 'education_practice_question' AND indexname = 'uk_session_sequence'; -- Verify no orphan data (should be 0 after fresh migration): -- SELECT COUNT(*) FROM education_practice_session; -- SELECT COUNT(*) FROM education_practice_question;