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,7 @@
-- Education 模块 Flyway 起始迁移PostgreSQL
--
-- 既有数据库在首次启动时会建立 V4009 baseline本文件作为其后的首个受管版本。
-- 当前版本仅验证 Flyway 接管链路;后续数据库变更请新增更高版本的 migration
-- 不要修改已经在共享环境执行过的脚本。
SELECT 1;

View File

@@ -0,0 +1,401 @@
-- Education 原生题库目录PostgreSQL
--
-- 目录数据采用显式 scope
-- TENANT_OWNED - 仅归属租户可见;
-- PUBLIC - 所有已认证租户可读tenant_id 固定为 0。
-- 应用层只能通过受控目录查询接缝读取 PUBLIC 数据,不得全局关闭租户隔离。
CREATE TABLE education_region (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
legacy_id VARCHAR(64),
name VARCHAR(100) NOT NULL,
code VARCHAR(32),
short_name VARCHAR(50),
full_name VARCHAR(200),
icon VARCHAR(500),
pinyin VARCHAR(200),
is_hot BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER 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,
CONSTRAINT ck_education_region_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
)
);
COMMENT ON TABLE education_region IS '教育-地区';
COMMENT ON COLUMN education_region.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_region_tenant_active_order
ON education_region (tenant_id, is_active, sort_order) WHERE deleted = false;
CREATE INDEX idx_education_region_public_active_order
ON education_region (is_active, sort_order) WHERE deleted = false AND scope = 'PUBLIC';
CREATE TABLE education_school (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
legacy_id VARCHAR(64),
region_id BIGINT,
module_id BIGINT,
name VARCHAR(200) NOT NULL,
professional_exam_date VARCHAR(200),
metadata JSONB,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER 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,
CONSTRAINT ck_education_school_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_school_region FOREIGN KEY (region_id) REFERENCES education_region (id)
);
COMMENT ON TABLE education_school IS '教育-院校';
COMMENT ON COLUMN education_school.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_school_tenant_region_order
ON education_school (tenant_id, region_id, sort_order) WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_school_public_region_order
ON education_school (region_id, sort_order) WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
CREATE TABLE education_major (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
legacy_id VARCHAR(64),
region_id BIGINT,
school_id BIGINT,
name VARCHAR(200) NOT NULL,
description VARCHAR(500),
study_tips VARCHAR(500),
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER 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,
CONSTRAINT ck_education_major_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_major_region FOREIGN KEY (region_id) REFERENCES education_region (id),
CONSTRAINT fk_education_major_school FOREIGN KEY (school_id) REFERENCES education_school (id)
);
COMMENT ON TABLE education_major IS '教育-专业';
COMMENT ON COLUMN education_major.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_major_tenant_school_order
ON education_major (tenant_id, school_id, sort_order) WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_major_public_school_order
ON education_major (school_id, sort_order) WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
CREATE TABLE education_subject (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
region_id BIGINT,
school_id BIGINT,
major_id BIGINT,
module_id BIGINT,
name VARCHAR(200) NOT NULL,
type VARCHAR(50),
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER 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,
CONSTRAINT ck_education_subject_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_subject_region FOREIGN KEY (region_id) REFERENCES education_region (id),
CONSTRAINT fk_education_subject_school FOREIGN KEY (school_id) REFERENCES education_school (id),
CONSTRAINT fk_education_subject_major FOREIGN KEY (major_id) REFERENCES education_major (id)
);
COMMENT ON TABLE education_subject IS '教育-科目';
COMMENT ON COLUMN education_subject.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_subject_tenant_filters
ON education_subject (tenant_id, region_id, school_id, major_id, type, sort_order)
WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_subject_public_filters
ON education_subject (region_id, school_id, major_id, type, sort_order)
WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
CREATE TABLE education_category (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
subject_id BIGINT,
legacy_node_id VARCHAR(64),
name VARCHAR(200) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER 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,
CONSTRAINT ck_education_category_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_category_subject FOREIGN KEY (subject_id) REFERENCES education_subject (id)
);
COMMENT ON TABLE education_category IS '教育-题目分类';
COMMENT ON COLUMN education_category.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_category_tenant_subject_order
ON education_category (tenant_id, subject_id, sort_order) WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_category_public_subject_order
ON education_category (subject_id, sort_order) WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
CREATE TABLE education_content_entry (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
legacy_id VARCHAR(64),
region_id BIGINT,
entry_key VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
entry_type VARCHAR(50) NOT NULL,
icon VARCHAR(500),
route VARCHAR(200),
description VARCHAR(500),
access_rules JSONB,
layout_config JSONB,
is_hidden BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER 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,
CONSTRAINT ck_education_content_entry_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_content_entry_region FOREIGN KEY (region_id) REFERENCES education_region (id),
CONSTRAINT uk_education_content_entry_scope_key UNIQUE (tenant_id, entry_key)
);
COMMENT ON TABLE education_content_entry IS '教育-内容入口';
COMMENT ON COLUMN education_content_entry.scope IS '内容范围PUBLIC、TENANT_OWNED';
COMMENT ON COLUMN education_content_entry.is_hidden IS '是否对学生端隐藏';
CREATE INDEX idx_education_content_entry_tenant_filters
ON education_content_entry (tenant_id, region_id, entry_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false;
CREATE INDEX idx_education_content_entry_public_filters
ON education_content_entry (region_id, entry_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false AND scope = 'PUBLIC';
CREATE TABLE education_content_node (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
entry_id BIGINT NOT NULL,
parent_id BIGINT,
name VARCHAR(200) NOT NULL,
title VARCHAR(200),
node_type VARCHAR(50) NOT NULL,
marker_type VARCHAR(50),
depth INTEGER NOT NULL DEFAULT 0,
is_leaf BOOLEAN NOT NULL DEFAULT false,
is_selectable BOOLEAN NOT NULL DEFAULT true,
is_hidden BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB,
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,
CONSTRAINT ck_education_content_node_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_content_node_entry FOREIGN KEY (entry_id) REFERENCES education_content_entry (id),
CONSTRAINT fk_education_content_node_parent FOREIGN KEY (parent_id) REFERENCES education_content_node (id)
);
COMMENT ON TABLE education_content_node IS '教育-内容节点';
COMMENT ON COLUMN education_content_node.scope IS '内容范围PUBLIC、TENANT_OWNED';
COMMENT ON COLUMN education_content_node.is_hidden IS '是否对学生端隐藏';
CREATE INDEX idx_education_content_node_tenant_tree
ON education_content_node (tenant_id, entry_id, parent_id, marker_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false;
CREATE INDEX idx_education_content_node_public_tree
ON education_content_node (entry_id, parent_id, marker_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false AND scope = 'PUBLIC';
CREATE TABLE education_question_collection (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
entry_id BIGINT,
node_id BIGINT,
name VARCHAR(200) NOT NULL,
title VARCHAR(200),
collection_type VARCHAR(50) NOT NULL,
question_count INTEGER NOT NULL DEFAULT 0,
duration_minutes INTEGER,
access_rules JSONB,
is_hidden BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB,
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,
CONSTRAINT ck_education_question_collection_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_question_collection_entry FOREIGN KEY (entry_id) REFERENCES education_content_entry (id),
CONSTRAINT fk_education_question_collection_node FOREIGN KEY (node_id) REFERENCES education_content_node (id)
);
COMMENT ON TABLE education_question_collection IS '教育-题集';
COMMENT ON COLUMN education_question_collection.scope IS '内容范围PUBLIC、TENANT_OWNED';
COMMENT ON COLUMN education_question_collection.is_hidden IS '是否对学生端隐藏';
CREATE INDEX idx_education_question_collection_tenant_filters
ON education_question_collection (tenant_id, entry_id, node_id, collection_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false;
CREATE INDEX idx_education_question_collection_public_filters
ON education_question_collection (entry_id, node_id, collection_type, sort_order)
WHERE deleted = false AND is_active = true AND is_hidden = false AND scope = 'PUBLIC';
CREATE TABLE education_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
content_version INTEGER NOT NULL DEFAULT 1,
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
type_label VARCHAR(50),
difficulty VARCHAR(32),
question_content JSONB,
options JSONB NOT NULL,
correct_answer VARCHAR(500),
explanation TEXT,
analysis TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED',
is_published BOOLEAN NOT NULL DEFAULT true,
subject_id BIGINT,
node_id BIGINT,
tags JSONB,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB,
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,
CONSTRAINT ck_education_question_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT ck_education_question_version CHECK (content_version > 0),
CONSTRAINT ck_education_question_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'HIDDEN', 'INACTIVE')),
CONSTRAINT fk_education_question_subject FOREIGN KEY (subject_id) REFERENCES education_subject (id),
CONSTRAINT fk_education_question_node FOREIGN KEY (node_id) REFERENCES education_content_node (id)
);
COMMENT ON TABLE education_question IS '教育-题目';
COMMENT ON COLUMN education_question.scope IS '内容范围PUBLIC、TENANT_OWNED';
COMMENT ON COLUMN education_question.options IS '选项 JSONB学生端响应必须剥离 isCorrect';
COMMENT ON COLUMN education_question.correct_answer IS '正确答案;提交前不得返回';
COMMENT ON COLUMN education_question.explanation IS '答案解析;提交前不得返回';
COMMENT ON COLUMN education_question.analysis IS '深度解析;提交前不得返回';
CREATE INDEX idx_education_question_tenant_filters
ON education_question (tenant_id, node_id, subject_id, type, difficulty, sort_order)
WHERE deleted = false AND is_published = true AND status = 'PUBLISHED';
CREATE INDEX idx_education_question_public_filters
ON education_question (node_id, subject_id, type, difficulty, sort_order)
WHERE deleted = false AND is_published = true AND status = 'PUBLISHED' AND scope = 'PUBLIC';
CREATE TABLE education_practice_blueprint (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
mode VARCHAR(50) NOT NULL,
entry_id BIGINT,
node_id BIGINT,
collection_id BIGINT,
question_limit INTEGER,
duration_minutes INTEGER,
eligible_count INTEGER NOT NULL DEFAULT 0,
total_count INTEGER NOT NULL DEFAULT 0,
available_types JSONB,
available_difficulties JSONB,
min_questions INTEGER NOT NULL DEFAULT 1,
max_questions INTEGER NOT NULL DEFAULT 200,
suggested_count INTEGER NOT NULL DEFAULT 20,
is_active BOOLEAN NOT NULL DEFAULT true,
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,
CONSTRAINT ck_education_practice_blueprint_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT ck_education_practice_blueprint_counts CHECK (
min_questions > 0 AND max_questions >= min_questions AND suggested_count BETWEEN min_questions AND max_questions
),
CONSTRAINT fk_education_practice_blueprint_entry FOREIGN KEY (entry_id) REFERENCES education_content_entry (id),
CONSTRAINT fk_education_practice_blueprint_node FOREIGN KEY (node_id) REFERENCES education_content_node (id),
CONSTRAINT fk_education_practice_blueprint_collection FOREIGN KEY (collection_id) REFERENCES education_question_collection (id)
);
COMMENT ON TABLE education_practice_blueprint IS '教育-练习蓝图';
COMMENT ON COLUMN education_practice_blueprint.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_practice_blueprint_tenant_filters
ON education_practice_blueprint (tenant_id, entry_id, node_id, collection_id, mode)
WHERE deleted = false AND is_active = true;
CREATE INDEX idx_education_practice_blueprint_public_filters
ON education_practice_blueprint (entry_id, node_id, collection_id, mode)
WHERE deleted = false AND is_active = true AND scope = 'PUBLIC';
-- 题集-题目关联是题集成员关系的唯一事实源;题目表不保存 collection_id。
CREATE TABLE education_question_collection_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'TENANT_OWNED',
collection_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
sort_order INTEGER 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,
CONSTRAINT ck_education_collection_question_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT fk_education_collection_question_collection FOREIGN KEY (collection_id) REFERENCES education_question_collection (id),
CONSTRAINT fk_education_collection_question_question FOREIGN KEY (question_id) REFERENCES education_question (id),
CONSTRAINT uk_education_collection_question UNIQUE (tenant_id, collection_id, question_id)
);
COMMENT ON TABLE education_question_collection_question IS '教育-题集题目关联,题集成员关系唯一事实源';
COMMENT ON COLUMN education_question_collection_question.scope IS '内容范围PUBLIC、TENANT_OWNED';
CREATE INDEX idx_education_collection_question_order
ON education_question_collection_question (tenant_id, collection_id, sort_order) WHERE deleted = false;
CREATE INDEX idx_education_collection_question_public_order
ON education_question_collection_question (collection_id, sort_order) WHERE deleted = false AND scope = 'PUBLIC';
-- Verification examples:
-- SELECT table_name FROM information_schema.tables
-- WHERE table_schema = 'public' AND table_name LIKE 'education_%' ORDER BY table_name;
-- SELECT conname FROM pg_constraint
-- WHERE conrelid IN ('education_question'::regclass, 'education_question_collection_question'::regclass)
-- ORDER BY conname;

View File

@@ -0,0 +1,398 @@
-- Education Practice core-loop schema and forward adoption (PostgreSQL)
--
-- Fresh databases receive the final unified schema. Existing compatible manual
-- installations are adopted in place. Legacy answer/submit idempotency tables
-- are read for backfill when present, but are never created or dropped here.
CREATE TABLE IF NOT EXISTS education_practice_session (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
client_session_id VARCHAR(36) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
question_count INTEGER NOT NULL DEFAULT 0,
collection_id VARCHAR(64),
node_id VARCHAR(64),
type VARCHAR(32),
difficulty VARCHAR(32),
version INTEGER NOT NULL DEFAULT 0,
last_client_sequence INTEGER,
review_fingerprint VARCHAR(64),
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 '教育-练习会话';
ALTER TABLE education_practice_session
ADD COLUMN IF NOT EXISTS last_client_sequence INTEGER,
ADD COLUMN IF NOT EXISTS review_fingerprint VARCHAR(64);
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_practice_session_tenant_client
ON education_practice_session (tenant_id, client_session_id);
CREATE INDEX IF NOT EXISTS idx_education_practice_session_tenant_user_status
ON education_practice_session (tenant_id, user_id, status);
CREATE TABLE IF NOT EXISTS education_practice_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
session_id BIGINT NOT NULL,
sequence INTEGER NOT NULL,
question_id VARCHAR(64) NOT NULL,
content_version VARCHAR(64) NOT NULL DEFAULT '',
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
difficulty VARCHAR(32),
options JSONB NOT NULL,
correct_answer TEXT,
explanation TEXT,
selected_answer TEXT,
is_answered BOOLEAN NOT NULL DEFAULT false,
client_sequence INTEGER,
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 '教育-练习会话题目快照';
ALTER TABLE education_practice_question
ADD COLUMN IF NOT EXISTS correct_answer TEXT,
ADD COLUMN IF NOT EXISTS explanation TEXT,
ADD COLUMN IF NOT EXISTS client_sequence INTEGER;
DO $$
DECLARE
options_type TEXT;
BEGIN
SELECT data_type INTO options_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_practice_question'
AND column_name = 'options';
IF options_type = 'text' THEN
BEGIN
ALTER TABLE education_practice_question
ALTER COLUMN options TYPE JSONB USING options::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_practice_question.options contains invalid JSON and cannot be adopted';
END;
ELSIF options_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_practice_question.options has incompatible type %', options_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_practice_question_session_sequence
ON education_practice_question (session_id, sequence);
CREATE INDEX IF NOT EXISTS idx_education_practice_question_session
ON education_practice_question (session_id);
CREATE TABLE IF NOT EXISTS education_practice_report (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
session_id BIGINT NOT NULL,
question_count INTEGER NOT NULL,
answered_count INTEGER NOT NULL DEFAULT 0,
unanswered_count INTEGER NOT NULL DEFAULT 0,
correct_count INTEGER NOT NULL DEFAULT 0,
incorrect_count INTEGER NOT NULL DEFAULT 0,
score INTEGER 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 '教育-练习报告';
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_practice_report_tenant_session
ON education_practice_report (tenant_id, session_id);
CREATE INDEX IF NOT EXISTS idx_education_practice_report_tenant_user
ON education_practice_report (tenant_id, user_id);
CREATE INDEX IF NOT EXISTS idx_education_practice_report_create_time
ON education_practice_report (create_time);
CREATE TABLE IF NOT EXISTS education_practice_report_detail (
id BIGINT 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 INTEGER NOT NULL,
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
difficulty VARCHAR(32),
selected_answer TEXT,
correct_answer TEXT,
is_correct BOOLEAN NOT NULL DEFAULT false,
explanation TEXT,
content_version VARCHAR(64) NOT NULL DEFAULT '',
options JSONB,
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 '教育-练习报告明细';
ALTER TABLE education_practice_report_detail
ADD COLUMN IF NOT EXISTS content_version VARCHAR(64) NOT NULL DEFAULT '';
DO $$
DECLARE
options_type TEXT;
BEGIN
SELECT data_type INTO options_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_practice_report_detail'
AND column_name = 'options';
IF options_type IS NULL THEN
ALTER TABLE education_practice_report_detail ADD COLUMN options JSONB;
ELSIF options_type = 'text' THEN
BEGIN
ALTER TABLE education_practice_report_detail
ALTER COLUMN options TYPE JSONB USING options::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_practice_report_detail.options contains invalid JSON and cannot be adopted';
END;
ELSIF options_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_practice_report_detail.options has incompatible type %', options_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_practice_report_detail_report_sequence
ON education_practice_report_detail (report_id, sequence);
CREATE INDEX IF NOT EXISTS idx_education_practice_report_detail_session
ON education_practice_report_detail (session_id);
CREATE TABLE IF NOT EXISTS 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,
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
difficulty VARCHAR(32),
options JSONB NOT NULL,
content_version VARCHAR(64) NOT NULL DEFAULT '',
latest_correct_answer TEXT,
latest_explanation TEXT,
first_wrong_time TIMESTAMP NOT NULL,
last_wrong_time TIMESTAMP NOT NULL,
wrong_count INTEGER NOT NULL DEFAULT 1,
master_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
mastered_time TIMESTAMP,
last_report_id BIGINT,
last_session_id BIGINT,
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 '教育-错题本';
DO $$
DECLARE
options_type TEXT;
BEGIN
SELECT data_type INTO options_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_wrong_question'
AND column_name = 'options';
IF options_type = 'text' THEN
BEGIN
ALTER TABLE education_wrong_question
ALTER COLUMN options TYPE JSONB USING options::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_wrong_question.options contains invalid JSON and cannot be adopted';
END;
ELSIF options_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_wrong_question.options has incompatible type %', options_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_wrong_question_tenant_user_question
ON education_wrong_question (tenant_id, user_id, question_id);
CREATE INDEX IF NOT EXISTS idx_education_wrong_question_tenant_user_status
ON education_wrong_question (tenant_id, user_id, master_status);
CREATE INDEX IF NOT EXISTS idx_education_wrong_question_tenant_user_last_wrong
ON education_wrong_question (tenant_id, user_id, last_wrong_time);
CREATE TABLE IF NOT EXISTS 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,
report_id BIGINT NOT NULL,
question_id VARCHAR(64) 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_wrong_question_idempotency IS '教育-错题流水幂等';
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_wrong_idempotency_tenant_user_question_report
ON education_wrong_question_idempotency (tenant_id, user_id, question_id, report_id);
CREATE INDEX IF NOT EXISTS idx_education_wrong_question_idempotency_report
ON education_wrong_question_idempotency (report_id);
CREATE TABLE IF NOT EXISTS education_favorite (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id VARCHAR(64) NOT NULL,
stem TEXT,
type VARCHAR(32),
difficulty VARCHAR(32),
options JSONB,
content_version VARCHAR(64) NOT NULL DEFAULT '',
available BOOLEAN NOT NULL DEFAULT true,
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_favorite IS '教育-收藏夹';
DO $$
DECLARE
options_type TEXT;
BEGIN
SELECT data_type INTO options_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_favorite'
AND column_name = 'options';
IF options_type = 'text' THEN
BEGIN
ALTER TABLE education_favorite
ALTER COLUMN options TYPE JSONB USING options::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_favorite.options contains invalid JSON and cannot be adopted';
END;
ELSIF options_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_favorite.options has incompatible type %', options_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_favorite_tenant_user_target
ON education_favorite (tenant_id, user_id, target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_education_favorite_tenant_user
ON education_favorite (tenant_id, user_id);
CREATE INDEX IF NOT EXISTS idx_education_favorite_tenant_user_type
ON education_favorite (tenant_id, user_id, target_type);
CREATE TABLE IF NOT EXISTS education_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,
idempotency_key VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
session_id BIGINT NOT NULL,
question_id VARCHAR(64),
selected_answer TEXT,
report_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED',
response_json TEXT,
business_payload JSONB,
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_idempotency IS '教育-统一幂等记录';
DO $$
DECLARE
payload_type TEXT;
BEGIN
SELECT data_type INTO payload_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_idempotency'
AND column_name = 'business_payload';
IF payload_type IS NULL THEN
ALTER TABLE education_idempotency ADD COLUMN business_payload JSONB;
ELSIF payload_type = 'text' THEN
BEGIN
ALTER TABLE education_idempotency
ALTER COLUMN business_payload TYPE JSONB USING business_payload::JSONB;
EXCEPTION WHEN invalid_text_representation THEN
RAISE EXCEPTION 'education_idempotency.business_payload contains invalid JSON and cannot be adopted';
END;
ELSIF payload_type <> 'jsonb' THEN
RAISE EXCEPTION 'education_idempotency.business_payload has incompatible type %', payload_type;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_education_idempotency_tenant_user_operation_key
ON education_idempotency (tenant_id, user_id, operation, idempotency_key);
CREATE INDEX IF NOT EXISTS idx_education_idempotency_tenant_session
ON education_idempotency (tenant_id, session_id);
DO $$
BEGIN
IF to_regclass('education_answer_idempotency') IS NOT NULL THEN
IF EXISTS (
SELECT 1
FROM education_answer_idempotency
GROUP BY tenant_id, user_id, idempotency_key
HAVING COUNT(DISTINCT request_hash) > 1
) THEN
RAISE EXCEPTION 'education_answer_idempotency contains conflicting duplicate request_hash values';
END IF;
IF EXISTS (
SELECT 1
FROM education_answer_idempotency legacy
JOIN education_idempotency unified
ON unified.tenant_id = legacy.tenant_id
AND unified.user_id = legacy.user_id
AND unified.operation = 'SUBMIT_ANSWER'
AND unified.idempotency_key = legacy.idempotency_key
WHERE unified.request_hash <> legacy.request_hash
) THEN
RAISE EXCEPTION 'education_answer_idempotency conflicts with education_idempotency request_hash';
END IF;
INSERT INTO education_idempotency
(tenant_id, user_id, operation, idempotency_key, request_hash, session_id,
question_id, selected_answer, status, response_json,
creator, create_time, updater, update_time, deleted)
SELECT tenant_id, user_id, 'SUBMIT_ANSWER', idempotency_key, request_hash, session_id,
question_id, selected_answer, status, response_json,
creator, create_time, updater, update_time, deleted
FROM education_answer_idempotency
ON CONFLICT (tenant_id, user_id, operation, idempotency_key) DO NOTHING;
END IF;
IF to_regclass('education_submit_idempotency') IS NOT NULL THEN
IF EXISTS (
SELECT 1
FROM education_submit_idempotency
GROUP BY tenant_id, user_id, idempotency_key
HAVING COUNT(DISTINCT request_hash) > 1
) THEN
RAISE EXCEPTION 'education_submit_idempotency contains conflicting duplicate request_hash values';
END IF;
IF EXISTS (
SELECT 1
FROM education_submit_idempotency legacy
JOIN education_idempotency unified
ON unified.tenant_id = legacy.tenant_id
AND unified.user_id = legacy.user_id
AND unified.operation = 'SUBMIT_SESSION'
AND unified.idempotency_key = legacy.idempotency_key
WHERE unified.request_hash <> legacy.request_hash
) THEN
RAISE EXCEPTION 'education_submit_idempotency conflicts with education_idempotency request_hash';
END IF;
INSERT INTO education_idempotency
(tenant_id, user_id, operation, idempotency_key, request_hash, session_id,
report_id, status, response_json,
creator, create_time, updater, update_time, deleted)
SELECT tenant_id, user_id, 'SUBMIT_SESSION', idempotency_key, request_hash, session_id,
report_id, status, response_json,
creator, create_time, updater, update_time, deleted
FROM education_submit_idempotency
ON CONFLICT (tenant_id, user_id, operation, idempotency_key) DO NOTHING;
END IF;
END $$;

View File

@@ -0,0 +1,51 @@
ALTER TABLE education_idempotency
ADD COLUMN IF NOT EXISTS claim_token VARCHAR(64),
ADD COLUMN IF NOT EXISTS claim_started_at TIMESTAMP;
DO $$
DECLARE
claim_token_type TEXT;
claim_token_length INTEGER;
claim_started_at_type TEXT;
BEGIN
SELECT data_type, character_maximum_length INTO claim_token_type, claim_token_length
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_idempotency'
AND column_name = 'claim_token';
IF claim_token_type <> 'character varying' OR claim_token_length < 36 THEN
RAISE EXCEPTION 'education_idempotency.claim_token must be VARCHAR(36+), found %(%)',
claim_token_type, claim_token_length;
END IF;
SELECT data_type INTO claim_started_at_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_idempotency'
AND column_name = 'claim_started_at';
IF claim_started_at_type <> 'timestamp without time zone' THEN
RAISE EXCEPTION 'education_idempotency.claim_started_at must be timestamp without time zone, found %',
claim_started_at_type;
END IF;
END $$;
COMMENT ON COLUMN education_idempotency.claim_token IS '当前幂等处理租约令牌;完成或非交卷记录为空';
COMMENT ON COLUMN education_idempotency.claim_started_at IS '当前幂等处理租约开始时间;用于进程崩溃后的超时接管';
UPDATE education_idempotency
SET status = 'PROCESSING',
claim_token = NULL,
claim_started_at = create_time
WHERE operation = 'SUBMIT_SESSION'
AND status = 'ACCEPTED'
AND (report_id IS NULL OR response_json IS NULL OR BTRIM(response_json) = '');
UPDATE education_idempotency
SET status = 'COMPLETED',
claim_token = NULL,
claim_started_at = NULL
WHERE operation = 'SUBMIT_SESSION'
AND status = 'ACCEPTED'
AND report_id IS NOT NULL
AND response_json IS NOT NULL
AND BTRIM(response_json) <> '';