feat(education): add content import persistence

This commit is contained in:
2026-07-31 12:46:52 +08:00
parent 26fa2dfaa2
commit 2a53e8e2c0
9 changed files with 352 additions and 24 deletions

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.education.dal.dataobject.importjob;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@TableName("education_content_import_asset")
@KeySequence("education_content_import_asset_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class ContentImportAssetDO extends TenantBaseDO {
@TableId
private Long id;
private String objectKey;
private String fileName;
private String mimeType;
private Long fileSizeBytes;
private String checksumSha256;
}

View File

@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.education.dal.dataobject.importjob;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@TableName(value = "education_content_import_job", autoResultMap = true)
@KeySequence("education_content_import_job_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class ContentImportJobDO extends TenantBaseDO {
@TableId
private Long id;
private Long assetId;
private String previewKey;
private String importType;
private String status;
private Integer attemptCount;
private Integer maxAttempts;
private LocalDateTime nextAttemptAt;
private String lockedBy;
private String leaseToken;
private LocalDateTime leaseExpiresAt;
private LocalDateTime lastHeartbeatAt;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private String errorMessage;
@TableField("preview_payload")
private String previewPayload;
@TableField("result_summary")
private String resultSummary;
}

View File

@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.education.dal.mysql.importjob;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportAssetDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContentImportAssetMapper extends BaseMapperX<ContentImportAssetDO> {
}

View File

@@ -0,0 +1,93 @@
package cn.iocoder.yudao.module.education.dal.mysql.importjob;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportJobDO;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface ContentImportJobMapper extends BaseMapperX<ContentImportJobDO> {
@Insert("""
INSERT INTO education_content_import_job
(tenant_id, asset_id, preview_key, import_type, status, attempt_count, max_attempts,
preview_payload, creator, create_time, updater, update_time, deleted)
VALUES
(#{tenantId}, #{assetId}, #{previewKey}, #{importType}, #{status}, 0, #{maxAttempts},
CAST(#{previewPayload} AS JSONB), '', CURRENT_TIMESTAMP, '', CURRENT_TIMESTAMP, FALSE)
ON CONFLICT (tenant_id, preview_key) DO NOTHING
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertPreviewIgnore(ContentImportJobDO job);
@Update("""
UPDATE education_content_import_job
SET status = 'PENDING', next_attempt_at = CURRENT_TIMESTAMP, update_time = CURRENT_TIMESTAMP
WHERE tenant_id = #{tenantId} AND id = #{id} AND status = 'PREVIEW' AND deleted = FALSE
""")
int executePreview(@Param("tenantId") Long tenantId, @Param("id") Long id);
@Select("""
WITH candidates AS (
SELECT id FROM education_content_import_job
WHERE deleted = FALSE AND attempt_count < max_attempts
AND ((status = 'PENDING' AND (next_attempt_at IS NULL OR next_attempt_at <= CURRENT_TIMESTAMP))
OR (status = 'PROCESSING' AND lease_expires_at <= CURRENT_TIMESTAMP))
ORDER BY CASE WHEN status = 'PROCESSING' THEN 0 ELSE 1 END,
COALESCE(lease_expires_at, next_attempt_at, create_time), id
LIMIT #{limit} FOR UPDATE SKIP LOCKED
)
UPDATE education_content_import_job job
SET status = 'PROCESSING', locked_by = #{workerId}, lease_token = #{leaseToken},
lease_expires_at = CURRENT_TIMESTAMP + CAST(#{leaseSeconds} || ' seconds' AS INTERVAL),
last_heartbeat_at = CURRENT_TIMESTAMP, attempt_count = job.attempt_count + 1,
next_attempt_at = NULL, started_at = COALESCE(job.started_at, CURRENT_TIMESTAMP),
finished_at = NULL, error_message = NULL, update_time = CURRENT_TIMESTAMP
FROM candidates WHERE job.id = candidates.id
RETURNING job.*
""")
List<ContentImportJobDO> claimReady(@Param("workerId") String workerId,
@Param("leaseToken") String leaseToken,
@Param("leaseSeconds") long leaseSeconds,
@Param("limit") int limit);
@Update("""
UPDATE education_content_import_job
SET lease_expires_at = CURRENT_TIMESTAMP + CAST(#{leaseSeconds} || ' seconds' AS INTERVAL),
last_heartbeat_at = CURRENT_TIMESTAMP, update_time = CURRENT_TIMESTAMP
WHERE tenant_id = #{tenantId} AND id = #{id} AND status = 'PROCESSING'
AND lease_token = #{leaseToken} AND lease_expires_at > CURRENT_TIMESTAMP
""")
int heartbeat(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("leaseToken") String leaseToken, @Param("leaseSeconds") long leaseSeconds);
@Update("""
WITH expired AS (
SELECT id FROM education_content_import_job
WHERE status = 'PROCESSING' AND lease_expires_at <= CURRENT_TIMESTAMP
AND attempt_count < max_attempts
ORDER BY lease_expires_at, id
LIMIT #{limit} FOR UPDATE SKIP LOCKED
)
UPDATE education_content_import_job job
SET status = 'PENDING', locked_by = NULL, lease_token = NULL, lease_expires_at = NULL,
last_heartbeat_at = NULL, next_attempt_at = CURRENT_TIMESTAMP, update_time = CURRENT_TIMESTAMP
FROM expired WHERE job.id = expired.id
""")
int recoverExpired(@Param("limit") int limit);
@Update("""
UPDATE education_content_import_job
SET status = 'COMPLETED', result_summary = CAST(#{resultSummary} AS JSONB),
locked_by = NULL, lease_token = NULL, lease_expires_at = NULL, last_heartbeat_at = NULL,
finished_at = CURRENT_TIMESTAMP, update_time = CURRENT_TIMESTAMP
WHERE tenant_id = #{tenantId} AND id = #{id} AND status = 'PROCESSING'
AND lease_token = #{leaseToken} AND lease_expires_at > CURRENT_TIMESTAMP
""")
int complete(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("leaseToken") String leaseToken, @Param("resultSummary") String resultSummary);
@Update("UPDATE education_content_import_job SET lease_expires_at = CURRENT_TIMESTAMP - INTERVAL '1 second' WHERE id = #{id}")
int expireLeaseForTest(@Param("id") Long id);
}

View File

@@ -126,26 +126,9 @@ public interface ErrorCodeConstants {
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目");
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE = new ErrorCode(1_005_002_036, "题集成员数量不能超过 {}");
// ========== 商业化与权益 1-005-004-000 ~ 1-005-004-019 ==========
ErrorCode EDUCATION_RESOURCE_ACCESS_DENIED = new ErrorCode(1_005_004_000, "教育资源不存在或无权访问");
ErrorCode ENTITLEMENT_IDEMPOTENCY_CONFLICT = new ErrorCode(1_005_004_001, "权益事件幂等键已被不同请求使用");
ErrorCode ENTITLEMENT_EVENT_INVALID = new ErrorCode(1_005_004_002, "权益事件无效");
ErrorCode ENTITLEMENT_RESOURCE_NOT_FOUND = new ErrorCode(1_005_004_003, "权益资源不存在或不可发放");
ErrorCode PRODUCT_BINDING_CONFLICT = new ErrorCode(1_005_004_004, "资源商品绑定冲突");
ErrorCode PRODUCT_NOT_AVAILABLE = new ErrorCode(1_005_004_005, "Mall 商品不存在或不可用");
// ========== 分类创作 1-005-002-040 ~ 1-005-002-049 ==========
ErrorCode CATEGORY_AUTHORING_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_040,
"当前题库数据源模式不支持分类创作:{}");
ErrorCode CATEGORY_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_041, "分类不存在或无权管理");
ErrorCode CATEGORY_AUTHORING_CONFLICT = new ErrorCode(1_005_002_042, "分类已变化,请刷新后重试");
ErrorCode CATEGORY_SUBJECT_UNAVAILABLE = new ErrorCode(1_005_002_043, "分类学科不存在或不可用");
// ========== 练习蓝图创作 1-005-002-050 ~ 1-005-002-059 ==========
ErrorCode PRACTICE_BLUEPRINT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_050,
"当前题库数据源模式不支持练习蓝图创作:{}");
ErrorCode PRACTICE_BLUEPRINT_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_051, "练习蓝图不存在或无权管理");
ErrorCode PRACTICE_BLUEPRINT_AUTHORING_CONFLICT = new ErrorCode(1_005_002_052, "练习蓝图已变化,请刷新后重试");
ErrorCode PRACTICE_BLUEPRINT_TARGET_UNAVAILABLE = new ErrorCode(1_005_002_053, "练习蓝图目标不存在或不可用");
ErrorCode PRACTICE_BLUEPRINT_CONFIG_INVALID = new ErrorCode(1_005_002_054, "练习蓝图配置无效");
ErrorCode PRACTICE_BLUEPRINT_ELIGIBLE_INSUFFICIENT = new ErrorCode(1_005_002_055, "练习蓝图可用题目不足");
// ========== 内容导入持久化 1-005-002-040 ~ 1-005-002-049 ==========
ErrorCode CONTENT_IMPORT_JOB_NOT_FOUND = new ErrorCode(1_005_002_040, "内容导入任务不存在或无权访问");
ErrorCode CONTENT_IMPORT_JOB_NOT_READY = new ErrorCode(1_005_002_041, "内容导入任务当前状态不可执行");
ErrorCode CONTENT_IMPORT_PREVIEW_DUPLICATE = new ErrorCode(1_005_002_042, "内容导入预览键已存在");
ErrorCode CONTENT_IMPORT_LEASE_LOST = new ErrorCode(1_005_002_043, "内容导入任务租约已失效");
}

View File

@@ -0,0 +1,74 @@
-- EDU-011 persistence slice: tenant-owned import assets and durable preview/execute jobs.
CREATE TABLE education_content_import_asset (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
object_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
mime_type VARCHAR(128) NOT NULL,
file_size_bytes BIGINT NOT NULL,
checksum_sha256 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,
CONSTRAINT ck_education_content_import_asset_size CHECK (file_size_bytes >= 0),
CONSTRAINT uk_education_content_import_asset_tenant_id UNIQUE (tenant_id, id),
CONSTRAINT uk_education_content_import_asset_object UNIQUE (tenant_id, object_key)
);
COMMENT ON TABLE education_content_import_asset IS '教育内容导入源文件元数据;文件存储与扫描由 Infra 持有';
COMMENT ON COLUMN education_content_import_asset.object_key IS 'Infra 私有对象键,不存储公开 URL';
CREATE TABLE education_content_import_job (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
asset_id BIGINT NOT NULL,
preview_key VARCHAR(128) NOT NULL,
import_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PREVIEW',
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
next_attempt_at TIMESTAMP,
locked_by VARCHAR(128),
lease_token VARCHAR(64),
lease_expires_at TIMESTAMP,
last_heartbeat_at TIMESTAMP,
started_at TIMESTAMP,
finished_at TIMESTAMP,
error_message VARCHAR(2000),
preview_payload JSONB NOT NULL DEFAULT '{}'::JSONB,
result_summary JSONB NOT NULL DEFAULT '{}'::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 fk_education_content_import_job_asset_tenant
FOREIGN KEY (tenant_id, asset_id)
REFERENCES education_content_import_asset (tenant_id, id),
CONSTRAINT uk_education_content_import_job_preview UNIQUE (tenant_id, preview_key),
CONSTRAINT ck_education_content_import_job_status
CHECK (status IN ('PREVIEW', 'PENDING', 'PROCESSING', 'COMPLETED', 'FAILED')),
CONSTRAINT ck_education_content_import_job_attempts
CHECK (attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts),
CONSTRAINT ck_education_content_import_job_lease CHECK (
(status = 'PROCESSING' AND locked_by IS NOT NULL AND lease_token IS NOT NULL
AND lease_expires_at IS NOT NULL AND last_heartbeat_at IS NOT NULL)
OR
(status <> 'PROCESSING' AND locked_by IS NULL AND lease_token IS NULL
AND lease_expires_at IS NULL AND last_heartbeat_at IS NULL)
)
);
COMMENT ON TABLE education_content_import_job IS '教育内容导入预览与执行作业';
COMMENT ON COLUMN education_content_import_job.preview_key IS '租户内重复预览幂等键';
COMMENT ON COLUMN education_content_import_job.lease_token IS '处理租约 fencing token';
CREATE INDEX idx_education_content_import_job_ready
ON education_content_import_job (next_attempt_at, create_time, id)
WHERE status = 'PENDING' AND deleted = false;
CREATE INDEX idx_education_content_import_job_expired
ON education_content_import_job (lease_expires_at, create_time, id)
WHERE status = 'PROCESSING' AND deleted = false;

View File

@@ -0,0 +1,107 @@
package cn.iocoder.yudao.module.education.dal.mysql.importjob;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportAssetDO;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportJobDO;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ContentImportJobMapperPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource(name = "contentImportAssetMapper") private ContentImportAssetMapper assetMapper;
@Resource(name = "contentImportJobMapper") private ContentImportJobMapper jobMapper;
@BeforeEach
void setUp() {
TenantContextHolder.setTenantId(10L);
}
@AfterEach
void tearDown() {
TenantContextHolder.clear();
}
@Test
void shouldEnforceTenantBoundJobLifecycleAndLeaseFencing() {
ContentImportAssetDO asset = asset(10L, "imports/10/questions.xlsx");
assetMapper.insert(asset);
ContentImportJobDO preview = preview(10L, asset.getId(), "preview-1");
assertEquals(1, jobMapper.insertPreviewIgnore(preview));
assertEquals(0, jobMapper.insertPreviewIgnore(preview(10L, asset.getId(), "preview-1")));
ContentImportAssetDO otherTenantAsset = asset(20L, "imports/20/questions.xlsx");
assetMapper.insert(otherTenantAsset);
assertThrows(DataIntegrityViolationException.class,
() -> jobMapper.insertPreviewIgnore(preview(10L, otherTenantAsset.getId(), "cross-tenant")));
assertEquals(1, jobMapper.executePreview(10L, preview.getId()));
assertEquals(0, jobMapper.executePreview(10L, preview.getId()));
List<ContentImportJobDO> claimed = jobMapper.claimReady("worker-a", "token-a", 60, 1);
assertEquals(1, claimed.size());
ContentImportJobDO job = claimed.getFirst();
assertEquals("PROCESSING", job.getStatus());
assertEquals(1, job.getAttemptCount());
assertEquals("token-a", job.getLeaseToken());
assertTrue(jobMapper.claimReady("worker-b", "token-b", 60, 1).isEmpty());
LocalDateTime firstExpiry = job.getLeaseExpiresAt();
assertEquals(1, jobMapper.heartbeat(10L, job.getId(), "token-a", 120));
assertTrue(jobMapper.selectById(job.getId()).getLeaseExpiresAt().isAfter(firstExpiry));
assertEquals(0, jobMapper.heartbeat(10L, job.getId(), "stale-token", 120));
assertEquals(0, jobMapper.complete(10L, job.getId(), "stale-token", "{}"));
jobMapper.expireLeaseForTest(job.getId());
assertEquals(1, jobMapper.recoverExpired(10));
assertEquals("PENDING", jobMapper.selectById(job.getId()).getStatus());
ContentImportJobDO reclaimed = jobMapper.claimReady("worker-b", "token-b", 60, 1).getFirst();
assertEquals(2, reclaimed.getAttemptCount());
assertEquals(0, jobMapper.complete(10L, job.getId(), "token-a", "{}"));
assertEquals(1, jobMapper.complete(10L, job.getId(), "token-b", "{\"inserted\":1}"));
assertEquals("COMPLETED", jobMapper.selectById(job.getId()).getStatus());
}
@Test
void shouldAllowOnlyFiveJobStates() {
ContentImportAssetDO asset = asset(10L, "imports/10/states.xlsx");
assetMapper.insert(asset);
ContentImportJobDO job = preview(10L, asset.getId(), "states");
job.setStatus("REJECTED");
assertThrows(DataIntegrityViolationException.class, () -> jobMapper.insert(job));
}
private ContentImportAssetDO asset(Long tenantId, String objectKey) {
ContentImportAssetDO asset = new ContentImportAssetDO();
asset.setTenantId(tenantId);
asset.setObjectKey(objectKey);
asset.setFileName("questions.xlsx");
asset.setMimeType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
asset.setFileSizeBytes(128L);
asset.setChecksumSha256("a".repeat(64));
return asset;
}
private ContentImportJobDO preview(Long tenantId, Long assetId, String previewKey) {
ContentImportJobDO job = new ContentImportJobDO();
job.setTenantId(tenantId);
job.setAssetId(assetId);
job.setPreviewKey(previewKey);
job.setImportType("QUESTIONS");
job.setStatus("PREVIEW");
job.setMaxAttempts(3);
job.setPreviewPayload("{}");
return job;
}
}

View File

@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema,
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4140", "4150", "4160", "4170");
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name = 'education_idempotency'"))
@@ -211,7 +211,7 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema,
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4140", "4150", "4160", "4170");
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +

View File

@@ -12,6 +12,8 @@ TRUNCATE TABLE
education_category_lifecycle_audit,
education_practice_blueprint,
education_category,
education_content_import_job,
education_content_import_asset,
education_question_collection_lifecycle_audit,
education_content_node_lifecycle_audit,
education_question_lifecycle_transition_token,