feat: add public question bank sync

This commit is contained in:
Codex
2026-06-29 08:37:40 +08:00
parent 69f8e28d4e
commit 35cff06df7
11 changed files with 690 additions and 128 deletions

View File

@@ -3579,6 +3579,137 @@ async function testPublicQuestionBankAdoption() {
});
assert.equal(session.item?.questionCount, 2, 'adopted public bank collection should be usable for practice sessions');
const studentSyncDenied = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
method: 'POST',
body: { adoptionId: adopted.item.id },
expectStatus: 403,
});
assert.equal(studentSyncDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not sync adopted public banks');
const crossTenantSyncDenied = await request('/api/tenant-content/public-question-banks/sync', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id },
expectStatus: 404,
});
assert.equal(crossTenantSyncDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank sync must be tenant isolated');
const syncSourceHash = `public-sync-source-${Date.now()}`;
const sourceQuestion = await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
questionBankId: ids.questionBank,
subjectId: ids.subject,
categoryId: ids.category,
entryId: ids.contentEntry,
contentNodeId: ids.contentNodeSchoolTarget,
primaryCollectionId: ids.questionCollection,
legacyId: `public-sync-source-${Date.now()}`,
type: 'choice',
typeLabel: '单选题',
difficulty: 1,
content: '平台公共题库新增同步题5 + 5 = ?',
options: [
{ label: 'A', text: '9' },
{ label: 'B', text: '10' },
],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: '10',
explanation: '新增公共题应同步到已采纳租户。',
status: 'published',
sourceHash: syncSourceHash,
},
});
assert.ok(sourceQuestion.item?.id, 'platform content editor should create a new source public question');
const synced = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(synced.sync?.status, 'synced', 'public bank sync should finish without conflicts for platform-only changes');
assert.equal(synced.item?.syncStatus, 'synced', 'adoption sync status should be synced');
assert.ok(
synced.sync?.results?.some(item => item.sourceQuestionId === sourceQuestion.item.id && item.action === 'inserted'),
'public bank sync should insert newly published source questions',
);
const collectionAfterSync = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const syncedLegacyId = `public:${MAIN_TENANT_ID}:${sourceQuestion.item.id}`;
assert.ok(
collectionAfterSync.items?.some(item => item.legacyId === syncedLegacyId && item.content === '平台公共题库新增同步题5 + 5 = ?'),
'tenant collection should expose newly synced public question copy',
);
const conflictTarget = collectionAfterSync.items?.find(item => item.legacyId === `public:${MAIN_TENANT_ID}:${ids.question}`);
assert.ok(conflictTarget?.id, 'adopted tenant collection should include a copied seed question for conflict checks');
const tenantCustomContent = `租户自定义公共题副本 ${Date.now()}`;
await request('/api/tenant-content/questions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: conflictTarget.id,
createVersion: true,
content: tenantCustomContent,
options: conflictTarget.options || [],
correctOptionIndex: conflictTarget.correctOptionIndex,
correctOptionIndices: conflictTarget.correctOptionIndices || [],
answerText: conflictTarget.answerText,
explanation: '租户已经对采纳题目做了本地改写,同步时必须保留。',
sourceHash: `tenant-custom-${Date.now()}`,
},
});
const platformChangedHash = `platform-source-update-${Date.now()}`;
await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: ids.question,
createVersion: true,
content: '平台公共题库更新1 + 1 = ?',
options: [
{ label: 'A', text: '1' },
{ label: 'B', text: '2' },
],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: '2',
explanation: '平台公共题库发布了新版本。',
sourceHash: platformChangedHash,
},
});
const conflictSync = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(conflictSync.sync?.status, 'conflict', 'public bank sync should report conflicts when tenant changed copied content');
assert.equal(conflictSync.item?.syncStatus, 'failed', 'adoption should keep failed sync status when conflicts need operator review');
assert.ok(
conflictSync.sync?.results?.some(item => item.sourceQuestionId === ids.question && item.action === 'conflict'),
'public bank sync should identify the source question that conflicts with tenant edits',
);
const collectionAfterConflict = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const conflictTargetAfter = collectionAfterConflict.items?.find(item => item.id === conflictTarget.id);
assert.equal(conflictTargetAfter?.content, tenantCustomContent, 'public bank sync must not overwrite tenant-customized copied question');
const mainTenantNotAdopted = await request('/api/tenant-content/public-question-banks', {
userId: TENANT_ADMIN_USER_ID,
query: { onlyNotAdopted: 'true' },