forked from wangziqi/gongxue-base
feat: batch resolve public bank conflicts
This commit is contained in:
@@ -49,6 +49,7 @@ import {
|
||||
adoptPublicQuestionBankRoute,
|
||||
publicQuestionBankConflictsRoute,
|
||||
publicQuestionBanksRoute,
|
||||
resolvePublicQuestionBankConflictsRoute,
|
||||
resolvePublicQuestionBankConflictRoute,
|
||||
syncPublicQuestionBankRoute,
|
||||
} from './public-banks.js';
|
||||
@@ -85,6 +86,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/public-question-banks/sync', syncPublicQuestionBankRoute],
|
||||
['GET', '/api/tenant-content/public-question-banks/conflicts', publicQuestionBankConflictsRoute],
|
||||
['POST', '/api/tenant-content/public-question-banks/conflicts/resolve', resolvePublicQuestionBankConflictRoute],
|
||||
['POST', '/api/tenant-content/public-question-banks/conflicts/resolve-batch', resolvePublicQuestionBankConflictsRoute],
|
||||
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
|
||||
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
|
||||
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],
|
||||
|
||||
@@ -1424,3 +1424,402 @@ export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
type PublicQuestionBankConflictResolution = 'accept_platform' | 'keep_local';
|
||||
|
||||
function parsePublicQuestionBankConflictResolution(value: string): PublicQuestionBankConflictResolution {
|
||||
if (value === 'accept_platform' || value === 'keep_local') return value;
|
||||
throw new HttpError(400, 'resolution must be accept_platform or keep_local', 'PUBLIC_BANK_CONFLICT_RESOLUTION_INVALID');
|
||||
}
|
||||
|
||||
function sourceQuestionIdsFromBody(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const item of value) {
|
||||
const id = typeof item === 'string' ? item.trim() : '';
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function resolvePublicQuestionBankConflictsBatch(auth: TenantContentAuth, input: {
|
||||
adoptionId: string;
|
||||
sourceQuestionIds: string[];
|
||||
resolution: PublicQuestionBankConflictResolution;
|
||||
resolveAll: boolean;
|
||||
limit: number;
|
||||
}) {
|
||||
const limit = Math.max(1, Math.min(intValue(input.limit, 50), 100));
|
||||
const sourceQuestionIds = input.sourceQuestionIds.slice(0, 100);
|
||||
if (!input.resolveAll && sourceQuestionIds.length === 0) {
|
||||
throw new HttpError(400, 'sourceQuestionIds is required unless resolveAll is true', 'PUBLIC_BANK_CONFLICT_SOURCE_IDS_REQUIRED');
|
||||
}
|
||||
if (!input.resolveAll && input.sourceQuestionIds.length > limit) {
|
||||
throw new HttpError(400, `Batch can resolve at most ${limit} conflicts`, 'PUBLIC_BANK_CONFLICT_BATCH_LIMIT_EXCEEDED');
|
||||
}
|
||||
|
||||
return transaction(async client => {
|
||||
const adoptionResult = await client.query<AdoptionRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
grant_id as "grantId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
target_entry_id as "targetEntryId",
|
||||
target_collection_id as "targetCollectionId",
|
||||
adoption_mode as "adoptionMode", status,
|
||||
sync_status as "syncStatus",
|
||||
source_snapshot as "sourceSnapshot",
|
||||
copied_question_count as "copiedQuestionCount",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.tenant_question_bank_adoptions
|
||||
where tenant_id = $1 and id = $2 and status <> 'archived'
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, input.adoptionId],
|
||||
);
|
||||
const adoption = adoptionResult.rows[0];
|
||||
if (!adoption) throw new HttpError(404, 'Question bank adoption not found', 'QUESTION_BANK_ADOPTION_NOT_FOUND');
|
||||
if (!adoption.targetQuestionBankId || !adoption.targetEntryId || !adoption.targetCollectionId) {
|
||||
throw new HttpError(409, 'Question bank adoption target is incomplete', 'QUESTION_BANK_ADOPTION_TARGET_MISSING');
|
||||
}
|
||||
|
||||
const lastConflicts = lastSyncConflicts(adoption.metadata || {});
|
||||
const conflictsBySourceId = new Map<string, Record<string, unknown>>();
|
||||
for (const conflict of lastConflicts) {
|
||||
const sourceQuestionId = nullableString(conflict.sourceQuestionId);
|
||||
if (sourceQuestionId && !conflictsBySourceId.has(sourceQuestionId)) {
|
||||
conflictsBySourceId.set(sourceQuestionId, conflict);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedConflicts = input.resolveAll
|
||||
? lastConflicts.filter(conflict => nullableString(conflict.sourceQuestionId)).slice(0, limit)
|
||||
: sourceQuestionIds.map(sourceQuestionId => conflictsBySourceId.get(sourceQuestionId));
|
||||
const missingIds = input.resolveAll ? [] : sourceQuestionIds.filter(sourceQuestionId => !conflictsBySourceId.has(sourceQuestionId));
|
||||
if (missingIds.length) {
|
||||
throw new HttpError(404, 'Public question bank conflict not found', 'PUBLIC_BANK_CONFLICT_NOT_FOUND');
|
||||
}
|
||||
|
||||
const grant = adoption.grantId ? await loadEligibleGrant(client, auth.tenantId, adoption.grantId) : null;
|
||||
if (!grant || grant.sourceQuestionBankId !== adoption.sourceQuestionBankId) {
|
||||
throw new HttpError(403, 'Question bank grant is not available for this tenant', 'QUESTION_BANK_GRANT_NOT_AVAILABLE');
|
||||
}
|
||||
|
||||
const sourceQuestions = await sourceQuestionSnapshots(client, {
|
||||
sourceTenantId: grant.sourceTenantId,
|
||||
sourceQuestionBankId: grant.sourceQuestionBankId,
|
||||
copyLimit: 1000,
|
||||
});
|
||||
const sourceById = new Map(sourceQuestions.map(question => [question.id, question]));
|
||||
|
||||
const snapshot = sourceSnapshotObject(adoption.sourceSnapshot);
|
||||
const metadata: Record<string, unknown> = {
|
||||
...(adoption.metadata || {}),
|
||||
resolvedPublicBankConflicts: {
|
||||
...resolvedPublicBankConflicts(adoption.metadata || {}),
|
||||
},
|
||||
};
|
||||
const resolved = objectValue(metadata.resolvedPublicBankConflicts);
|
||||
const processedSourceIds = new Set<string>();
|
||||
const results: Array<{
|
||||
sourceQuestionId: string;
|
||||
targetQuestionId: string;
|
||||
resolution: PublicQuestionBankConflictResolution;
|
||||
sourceHash: string;
|
||||
previousTargetHash: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const conflict of selectedConflicts) {
|
||||
if (!conflict) continue;
|
||||
const sourceQuestionId = nullableString(conflict.sourceQuestionId);
|
||||
if (!sourceQuestionId || processedSourceIds.has(sourceQuestionId)) continue;
|
||||
|
||||
const targetQuestionId = nullableString(conflict.targetQuestionId);
|
||||
if (!targetQuestionId) {
|
||||
throw new HttpError(409, 'Conflict has no target question', 'PUBLIC_BANK_CONFLICT_TARGET_MISSING');
|
||||
}
|
||||
|
||||
const source = sourceById.get(sourceQuestionId);
|
||||
if (!source) throw new HttpError(404, 'Source question not found', 'PUBLIC_BANK_SOURCE_QUESTION_NOT_FOUND');
|
||||
|
||||
const target = await client.query<{ id: string; source_hash: string | null }>(
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
`,
|
||||
[auth.tenantId, targetQuestionId],
|
||||
);
|
||||
const targetRow = target.rows[0];
|
||||
if (!targetRow) throw new HttpError(404, 'Target question not found', 'PUBLIC_BANK_TARGET_QUESTION_NOT_FOUND');
|
||||
|
||||
if (input.resolution === 'accept_platform') {
|
||||
const latest = await client.query<{ version_no: number }>(
|
||||
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
|
||||
[targetQuestionId],
|
||||
);
|
||||
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
|
||||
const version = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template,
|
||||
source_hash, created_by
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetQuestionId,
|
||||
nextVersionNo,
|
||||
source.content,
|
||||
JSON.stringify(source.options || []),
|
||||
source.correctOptionIndex,
|
||||
JSON.stringify(source.correctOptionIndices || []),
|
||||
source.answerText,
|
||||
source.explanation,
|
||||
JSON.stringify(source.subQuestions || []),
|
||||
source.codeLang,
|
||||
source.codeTemplate,
|
||||
source.sourceHash,
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set question_bank_id = $3,
|
||||
entry_id = $4,
|
||||
primary_collection_id = $5,
|
||||
type = $6,
|
||||
type_label = $7,
|
||||
difficulty = $8,
|
||||
tags = $9::jsonb,
|
||||
media_url = $10,
|
||||
has_video_explanation = $11,
|
||||
status = 'published',
|
||||
current_version_id = $12,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetQuestionId,
|
||||
adoption.targetQuestionBankId,
|
||||
adoption.targetEntryId,
|
||||
adoption.targetCollectionId,
|
||||
source.type,
|
||||
source.typeLabel,
|
||||
source.difficulty,
|
||||
JSON.stringify(source.tags || []),
|
||||
source.mediaUrl,
|
||||
source.hasVideoExplanation,
|
||||
version.rows[0].id,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
insert into public.question_collection_items (
|
||||
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, null, true, $6::jsonb)
|
||||
on conflict (tenant_id, collection_id, question_id)
|
||||
do update set section_key = excluded.section_key,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
adoption.targetCollectionId,
|
||||
targetQuestionId,
|
||||
source.type,
|
||||
0,
|
||||
JSON.stringify({
|
||||
source: 'public_question_bank_adoption',
|
||||
sourceQuestionId,
|
||||
lastConflictResolution: 'accept_platform',
|
||||
}),
|
||||
],
|
||||
);
|
||||
setSourceSnapshotQuestion(snapshot, sourceQuestionId, source.sourceHash, { resolution: 'accept_platform' });
|
||||
delete resolved[sourceQuestionId];
|
||||
} else {
|
||||
setSourceSnapshotQuestion(snapshot, sourceQuestionId, source.sourceHash, { resolution: 'keep_local' });
|
||||
resolved[sourceQuestionId] = {
|
||||
decision: 'keep_local',
|
||||
sourceHash: source.sourceHash,
|
||||
targetHash: targetRow.source_hash,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
resolvedBy: auth.userId,
|
||||
};
|
||||
}
|
||||
|
||||
processedSourceIds.add(sourceQuestionId);
|
||||
results.push({
|
||||
sourceQuestionId,
|
||||
targetQuestionId,
|
||||
resolution: input.resolution,
|
||||
sourceHash: source.sourceHash,
|
||||
previousTargetHash: targetRow.source_hash,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.resolution === 'accept_platform' && results.length) {
|
||||
await client.query(
|
||||
`
|
||||
update public.question_collections
|
||||
set question_count = (
|
||||
select count(*)
|
||||
from public.question_collection_items
|
||||
where tenant_id = $1 and collection_id = $2
|
||||
),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, adoption.targetCollectionId],
|
||||
);
|
||||
}
|
||||
|
||||
const remainingConflicts = lastConflicts.filter(conflict => {
|
||||
const sourceQuestionId = nullableString(conflict.sourceQuestionId);
|
||||
return !sourceQuestionId || !processedSourceIds.has(sourceQuestionId);
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
const lastSync = {
|
||||
...objectValue((adoption.metadata || {}).lastSync),
|
||||
status: remainingConflicts.length ? 'failed' : 'resolved',
|
||||
conflictCount: remainingConflicts.length,
|
||||
conflicts: remainingConflicts,
|
||||
lastBatchResolution: {
|
||||
sourceQuestionIds: results.map(item => item.sourceQuestionId),
|
||||
resolution: input.resolution,
|
||||
resolvedAt: now,
|
||||
processedCount: results.length,
|
||||
},
|
||||
lastResolution: results.length === 1 ? {
|
||||
sourceQuestionId: results[0]?.sourceQuestionId,
|
||||
resolution: input.resolution,
|
||||
resolvedAt: now,
|
||||
} : objectValue((adoption.metadata || {}).lastSync).lastResolution,
|
||||
};
|
||||
metadata.resolvedPublicBankConflicts = resolved;
|
||||
metadata.lastSync = lastSync;
|
||||
|
||||
const updated = await client.query<AdoptionRow>(
|
||||
`
|
||||
update public.tenant_question_bank_adoptions
|
||||
set sync_status = $3,
|
||||
source_snapshot = $4::jsonb,
|
||||
metadata = $5::jsonb,
|
||||
updated_by = $6,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id, tenant_id as "tenantId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
grant_id as "grantId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
target_entry_id as "targetEntryId",
|
||||
target_collection_id as "targetCollectionId",
|
||||
adoption_mode as "adoptionMode", status,
|
||||
sync_status as "syncStatus",
|
||||
source_snapshot as "sourceSnapshot",
|
||||
copied_question_count as "copiedQuestionCount",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
adoption.id,
|
||||
remainingConflicts.length ? 'failed' : 'synced',
|
||||
JSON.stringify(snapshot),
|
||||
JSON.stringify(metadata),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.public_question_bank.conflict_resolved', 'tenant_question_bank_adoption', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
adoption.id,
|
||||
JSON.stringify({
|
||||
sourceQuestionBankId: adoption.sourceQuestionBankId,
|
||||
sourceQuestionId: result.sourceQuestionId,
|
||||
targetQuestionId: result.targetQuestionId,
|
||||
resolution: input.resolution,
|
||||
sourceHash: result.sourceHash,
|
||||
previousTargetHash: result.previousTargetHash,
|
||||
remainingConflictCount: remainingConflicts.length,
|
||||
batch: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.public_question_bank.conflicts_batch_resolved', 'tenant_question_bank_adoption', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
adoption.id,
|
||||
JSON.stringify({
|
||||
sourceQuestionBankId: adoption.sourceQuestionBankId,
|
||||
resolution: input.resolution,
|
||||
requestedCount: input.resolveAll ? null : sourceQuestionIds.length,
|
||||
processedCount: results.length,
|
||||
remainingConflictCount: remainingConflicts.length,
|
||||
resolveAll: input.resolveAll,
|
||||
limit,
|
||||
results,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
adoption: updated.rows[0],
|
||||
resolution: input.resolution,
|
||||
processedCount: results.length,
|
||||
remainingConflictCount: remainingConflicts.length,
|
||||
syncStatus: updated.rows[0].syncStatus,
|
||||
results,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolvePublicQuestionBankConflictsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const adoptionId = requiredString(body, 'adoptionId');
|
||||
const resolution = parsePublicQuestionBankConflictResolution(requiredString(body, 'resolution'));
|
||||
const sourceQuestionIds = sourceQuestionIdsFromBody(body.sourceQuestionIds);
|
||||
const resolveAll = boolValue(body.resolveAll, false);
|
||||
const limit = Math.max(1, Math.min(intValue(body.limit, sourceQuestionIds.length || 50), 100));
|
||||
|
||||
const item = await resolvePublicQuestionBankConflictsBatch(auth, {
|
||||
adoptionId,
|
||||
sourceQuestionIds,
|
||||
resolution,
|
||||
resolveAll,
|
||||
limit,
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user