feat: add import job detail polling

This commit is contained in:
Codex
2026-06-29 13:59:11 +08:00
parent 7fc92986a5
commit c07c9866f2
14 changed files with 246 additions and 41 deletions

View File

@@ -3662,6 +3662,27 @@ export async function importVideosRoute(ctx: RequestContext) {
);
}
const importJobSelect = `
id, import_type as "importType", source_format as "sourceFormat",
status, source_name as "sourceName", source_hash as "sourceHash",
target_region_id as "targetRegionId", target_subject_id as "targetSubjectId",
target_category_id as "targetCategoryId", target_node_id as "targetNodeId",
target_question_bank_id as "targetQuestionBankId",
target_entry_id as "targetEntryId", target_content_node_id as "targetContentNodeId",
target_collection_id as "targetCollectionId",
dry_run as "dryRun", total_count as "totalCount",
valid_count as "validCount", error_count as "errorCount",
warning_count as "warningCount", inserted_count as "insertedCount",
updated_count as "updatedCount", skipped_count as "skippedCount",
execution_mode as "executionMode", queued_at as "queuedAt",
locked_at as "lockedAt", locked_by as "lockedBy",
attempt_count as "attemptCount", max_attempts as "maxAttempts",
next_attempt_at as "nextAttemptAt", parser_metadata as "parserMetadata",
summary, error_message as "errorMessage",
started_at as "startedAt", finished_at as "finishedAt",
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
`;
export async function importJobsRoute(ctx: RequestContext) {
const { intParam, requireTenantContentEditor, stringParam } = await routeDeps();
const auth = await requireTenantContentEditor(ctx);
@@ -3683,19 +3704,7 @@ export async function importJobsRoute(ctx: RequestContext) {
const items = await query(
`
select id, import_type as "importType", source_format as "sourceFormat",
status, source_name as "sourceName", source_hash as "sourceHash",
target_region_id as "targetRegionId", target_subject_id as "targetSubjectId",
target_category_id as "targetCategoryId", target_node_id as "targetNodeId",
target_question_bank_id as "targetQuestionBankId",
dry_run as "dryRun", total_count as "totalCount",
valid_count as "validCount", error_count as "errorCount",
warning_count as "warningCount", inserted_count as "insertedCount",
updated_count as "updatedCount", skipped_count as "skippedCount",
execution_mode as "executionMode", parser_metadata as "parserMetadata",
summary, error_message as "errorMessage",
started_at as "startedAt", finished_at as "finishedAt",
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
select ${importJobSelect}
from public.content_import_jobs
where ${filters.join(' and ')}
order by created_at desc
@@ -3707,6 +3716,80 @@ export async function importJobsRoute(ctx: RequestContext) {
return { items };
}
export async function importJobDetailRoute(ctx: RequestContext) {
const { intParam, requireTenantContentEditor, requiredString, stringParam } = await routeDeps();
const auth = await requireTenantContentEditor(ctx);
const jobId = requiredString({ jobId: stringParam(ctx, 'jobId') }, 'jobId');
const issueLimit = intParam(ctx, 'issueLimit', 20, 200);
const item = await queryOne<Record<string, unknown>>(
`
select ${importJobSelect}
from public.content_import_jobs
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, jobId],
);
if (!item) {
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
}
const issueSummary = await query<{ severity: string; count: string }>(
`
select severity, count(*)::text as count
from public.content_import_issues
where tenant_id = $1 and job_id = $2
group by severity
order by case severity when 'error' then 1 when 'warning' then 2 else 9 end
`,
[auth.tenantId, jobId],
);
const itemStatusSummary = await query<{ status: string; count: string }>(
`
select status, count(*)::text as count
from public.content_import_items
where tenant_id = $1 and job_id = $2
group by status
order by status asc
`,
[auth.tenantId, jobId],
);
const recentIssues = await query(
`
select i.id, i.row_no as "rowNo", i.severity, i.code,
i.field_path as "fieldPath", i.message, i.details,
item.external_id as "externalId", item.status as "itemStatus",
i.created_at as "createdAt"
from public.content_import_issues i
left join public.content_import_items item on item.id = i.item_id
where i.tenant_id = $1 and i.job_id = $2
order by i.row_no asc nulls last, case i.severity when 'error' then 1 else 2 end, i.created_at asc
limit $3
`,
[auth.tenantId, jobId, issueLimit],
);
const summary = objectValue(item.summary);
return {
item,
issueSummary: Object.fromEntries(issueSummary.map(row => [row.severity, Number(row.count || 0)])),
itemStatusSummary: Object.fromEntries(itemStatusSummary.map(row => [row.status, Number(row.count || 0)])),
recentIssues,
importPostCheck: summary.importPostCheck || null,
worker: {
executionMode: item.executionMode,
queuedAt: item.queuedAt,
lockedAt: item.lockedAt,
lockedBy: item.lockedBy,
attemptCount: item.attemptCount,
maxAttempts: item.maxAttempts,
nextAttemptAt: item.nextAttemptAt,
lastWorkerError: objectValue(summary.lastWorkerError),
},
};
}
export async function importIssuesRoute(ctx: RequestContext) {
const { intParam, requireTenantContentEditor, requiredString, stringParam } = await routeDeps();
const auth = await requireTenantContentEditor(ctx);

View File

@@ -9,6 +9,7 @@ import {
} from './assets.js';
import {
importHandbookRoute,
importJobDetailRoute,
importIssuesRoute,
importJobsRoute,
importQuestionsRoute,
@@ -111,6 +112,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/imports/preview/videos', previewVideosImportRoute],
['POST', '/api/tenant-content/imports/videos', importVideosRoute],
['GET', '/api/tenant-content/imports', importJobsRoute],
['GET', '/api/tenant-content/imports/detail', importJobDetailRoute],
['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
['GET', '/api/tenant-content/imports/field-mapping', importFieldMappingRoute],
['GET', '/api/tenant-content/imports/templates', importTemplateRoute],