feat: batch resolve public bank conflicts

This commit is contained in:
Codex
2026-06-29 14:18:25 +08:00
parent 215cbd05f7
commit c00992aab5
13 changed files with 621 additions and 28 deletions

View File

@@ -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],

View File

@@ -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 };
}

View File

@@ -59,12 +59,12 @@ pages/student/profile/index 个人中心、会员、订单、签到、激
pages/tenant-admin/workbench/index 租户后台工作台
pages/tenant-admin/dashboard/index 数据看板
pages/tenant-admin/students/index 学生与班级
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/冲突处理
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/单条和批量冲突处理
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板
```
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条采纳平台版本/保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
## 当前平台后台页面

View File

@@ -15,6 +15,7 @@ import {
loadPublicQuestionBanks,
previewContentImport,
resolvePublicQuestionBankConflict,
resolvePublicQuestionBankConflicts,
runImportPostCheck,
syncPublicQuestionBank,
type ContentEntryAdminItem,
@@ -516,6 +517,43 @@ export default function TenantContentPage() {
}
}
async function resolveVisibleConflicts(resolution: 'accept_platform' | 'keep_local') {
const adoptionId = conflicts?.adoptionId;
const sourceQuestionIds = conflictItems(conflicts)
.map(item => item.sourceQuestionId || '')
.filter(Boolean)
.slice(0, 50);
if (!adoptionId || !sourceQuestionIds.length) {
setError('当前没有可批量处理的冲突。');
return;
}
const ok = await confirm(
resolution === 'accept_platform' ? '批量采纳平台版本' : '批量保留租户版本',
resolution === 'accept_platform'
? `确认批量用平台公共题库的新版本覆盖 ${sourceQuestionIds.length} 道租户副本?系统会逐条写入审计。`
: `确认批量保留 ${sourceQuestionIds.length} 道租户本地版本?后续同步不再反复提示同一版本。`,
);
if (!ok) return;
setBusy(`resolve-batch:${resolution}`);
setError('');
try {
await resolvePublicQuestionBankConflicts({
adoptionId,
sourceQuestionIds,
resolution,
limit: sourceQuestionIds.length,
});
const payload = await loadPublicQuestionBankConflicts(adoptionId);
setConflicts(payload.item || null);
Taro.showToast({ title: '批量处理完成', icon: 'success' });
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '批量冲突处理失败');
} finally {
setBusy('');
}
}
const postCheckSummary = postCheck?.importPostCheck && typeof postCheck.importPostCheck === 'object'
? postCheck.importPostCheck as Record<string, unknown>
: postCheck;
@@ -711,6 +749,12 @@ export default function TenantContentPage() {
<Text className='admin-row-main'> {String(conflicts.status || conflicts.syncStatus || '-')}</Text>
<Text className='admin-row-meta'> {String(conflicts.conflictCount || 0)} · {String(conflicts.lastSyncedAt || '')}</Text>
<Text className='admin-row-meta'></Text>
{conflictItems(conflicts).length ? (
<View className='admin-row-actions'>
<Button className='admin-mini-button primary' loading={busy === 'resolve-batch:accept_platform'} onClick={() => resolveVisibleConflicts('accept_platform')}></Button>
<Button className='admin-mini-button' loading={busy === 'resolve-batch:keep_local'} onClick={() => resolveVisibleConflicts('keep_local')}></Button>
</View>
) : null}
</View>
{conflictItems(conflicts).slice(0, 20).map((item, index) => (
<View className='admin-row' key={`${item.sourceQuestionId || index}-${item.targetQuestionId || ''}`}>

View File

@@ -318,6 +318,18 @@ export async function resolvePublicQuestionBankConflict(input: { adoptionId: str
});
}
export async function resolvePublicQuestionBankConflicts(input: {
adoptionId: string;
sourceQuestionIds: string[];
resolution: 'accept_platform' | 'keep_local';
limit?: number;
}) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/conflicts/resolve-batch', {
method: 'POST',
body: input,
});
}
export async function runImportPostCheck(jobId: string) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/imports/post-check', {
method: 'POST',