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;