-- ============================================= -- Education 模块 — 答案保存幂等性 DDL (PostgreSQL) -- Ticket #7: 答案命令幂等、乐观锁并发控制、答案恢复 -- Migration: 003 -- Prerequisites: 002-education-practice-session.sql (session + question snapshots) -- ============================================= -- ============================================= -- Preconditions -- ============================================= -- Operator is expected to verify: -- SELECT COUNT(*) FROM information_schema.tables -- WHERE table_catalog = current_database() -- AND table_name = 'education_answer_idempotency'; -- 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'); -- Result MUST be 2. -- ============================================= -- 答案命令幂等表 -- ============================================= -- Purpose: Provide durable idempotency for answer save commands. -- Same (tenant, user, operation, idempotency_key) + same request_hash → replay original response. -- Same key + different request_hash → conflict. -- Concurrent same-key inserts are resolved by unique constraint race handling. -- -- Indexes: -- uk_answer_idempotency — per-tenant, per-actor, per-operation uniqueness for idempotency key. -- INSERT during answer save. DuplicateKeyException catch for concurrent-create race resolution. -- idx_tenant_session — covers lookup by session for audit/debug. -- -- response_json: Stores the serialized answer response for replay after network timeout/retry. -- request_hash: SHA-256 of canonical payload (sorted JSON fields) for content-based dedup. CREATE TABLE education_answer_idempotency ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, operation VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_ANSWER', idempotency_key VARCHAR(64) NOT NULL, request_hash VARCHAR(64) NOT NULL, session_id BIGINT NOT NULL, question_id VARCHAR(64) NOT NULL, selected_answer TEXT 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_answer_idempotency IS '教育-答案命令幂等记录'; COMMENT ON COLUMN education_answer_idempotency.id IS '主键'; COMMENT ON COLUMN education_answer_idempotency.tenant_id IS '租户编号'; COMMENT ON COLUMN education_answer_idempotency.user_id IS '答题用户编号'; COMMENT ON COLUMN education_answer_idempotency.operation IS '操作类型:SUBMIT_ANSWER'; COMMENT ON COLUMN education_answer_idempotency.idempotency_key IS '客户端幂等键(UUID)'; COMMENT ON COLUMN education_answer_idempotency.request_hash IS '请求载荷 SHA-256 哈希'; COMMENT ON COLUMN education_answer_idempotency.session_id IS '会话 ID'; COMMENT ON COLUMN education_answer_idempotency.question_id IS '题目 ID'; COMMENT ON COLUMN education_answer_idempotency.selected_answer IS '学生已选答案'; COMMENT ON COLUMN education_answer_idempotency.status IS '状态:ACCEPTED-已接受, CONFLICT-冲突'; COMMENT ON COLUMN education_answer_idempotency.response_json IS '首次成功响应 JSON(用于重试重放)'; COMMENT ON COLUMN education_answer_idempotency.creator IS '创建者'; COMMENT ON COLUMN education_answer_idempotency.create_time IS '创建时间'; COMMENT ON COLUMN education_answer_idempotency.updater IS '更新者'; COMMENT ON COLUMN education_answer_idempotency.update_time IS '更新时间'; COMMENT ON COLUMN education_answer_idempotency.deleted IS '是否删除'; CREATE UNIQUE INDEX uk_answer_idempotency ON education_answer_idempotency (tenant_id, user_id, operation, idempotency_key); CREATE INDEX idx_tenant_session ON education_answer_idempotency (tenant_id, session_id); -- ============================================= -- PracticeQuestionDO: add client_sequence column -- ============================================= -- Purpose: Track the last accepted client command sequence per question. -- Rejects stale clientSequence: only sequences strictly greater than the -- stored value are accepted (monotonic forward progression). -- NULL means no answer has been accepted yet. ALTER TABLE education_practice_question ADD COLUMN client_sequence INT DEFAULT NULL; CREATE INDEX idx_client_sequence ON education_practice_question (client_sequence); -- ============================================= -- PracticeSessionDO: add last_client_sequence column -- ============================================= -- Purpose: Session-wide monotonic counter for client commands. -- Rejects stale clientSequence across questions (not just per-question). -- CAS incrementVersion now updates this column alongside version. -- NULL means no answer has been accepted yet for this session. ALTER TABLE education_practice_session ADD COLUMN last_client_sequence INT DEFAULT NULL; -- ============================================= -- Post-migration verification queries -- ============================================= -- Verify new table exists: -- \d education_answer_idempotency -- Verify unique key is enforced: -- SELECT * FROM pg_indexes WHERE tablename = 'education_answer_idempotency' AND indexname = 'uk_answer_idempotency'; -- Verify column 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 = 'client_sequence'; -- Verify no orphan data (should be 0 after fresh migration): -- SELECT COUNT(*) FROM education_answer_idempotency;