From 2a53e8e2c00ab855f923ecd9d3aa5dc325cf79ca Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 31 Jul 2026 12:46:52 +0800 Subject: [PATCH] feat(education): add content import persistence --- .../importjob/ContentImportAssetDO.java | 22 ++++ .../importjob/ContentImportJobDO.java | 38 +++++++ .../importjob/ContentImportAssetMapper.java | 9 ++ .../importjob/ContentImportJobMapper.java | 93 +++++++++++++++ .../education/enums/ErrorCodeConstants.java | 27 +---- .../V4130__add_content_import_jobs.sql | 74 ++++++++++++ ...ortJobMapperPostgreSqlIntegrationTest.java | 107 ++++++++++++++++++ ...ucationFlywayMigrationIntegrationTest.java | 4 +- .../test/resources/sql/postgresql/clean.sql | 2 + 9 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/ContentImportAssetDO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/ContentImportJobDO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportAssetMapper.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapper.java create mode 100644 yudao-module-education/src/main/resources/db/migration/education/V4130__add_content_import_jobs.sql create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapperPostgreSqlIntegrationTest.java diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/ContentImportAssetDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/ContentImportAssetDO.java new file mode 100644 index 00000000..81cb55d6 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/ContentImportAssetDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/ContentImportJobDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/ContentImportJobDO.java new file mode 100644 index 00000000..4671b66e --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/ContentImportJobDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportAssetMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportAssetMapper.java new file mode 100644 index 00000000..a3b99db3 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportAssetMapper.java @@ -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 { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapper.java new file mode 100644 index 00000000..fc8c7164 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapper.java @@ -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 { + + @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 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); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 3e5830bb..07097384 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -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, "内容导入任务租约已失效"); } diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4130__add_content_import_jobs.sql b/yudao-module-education/src/main/resources/db/migration/education/V4130__add_content_import_jobs.sql new file mode 100644 index 00000000..69fe9f14 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4130__add_content_import_jobs.sql @@ -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; diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapperPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapperPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..c54e9ff5 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/ContentImportJobMapperPostgreSqlIntegrationTest.java @@ -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 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; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java index 44c7a33d..4ced6e25 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java @@ -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 (" + diff --git a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql index 160a5890..f94c85a4 100644 --- a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql @@ -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,