forked from wangziqi/ruoyi-vue-pro
feat(education): add content import persistence
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, "内容导入任务租约已失效");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user