feat: add public question bank sync worker

This commit is contained in:
Codex
2026-06-29 09:38:11 +08:00
parent f63f8491f6
commit 6f0083bd13
20 changed files with 632 additions and 39 deletions

View File

@@ -46,6 +46,7 @@ import {
} from './navigation.js';
import {
adoptPublicQuestionBankRoute,
publicQuestionBankConflictsRoute,
publicQuestionBanksRoute,
syncPublicQuestionBankRoute,
} from './public-banks.js';
@@ -80,6 +81,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
['GET', '/api/tenant-content/public-question-banks', publicQuestionBanksRoute],
['POST', '/api/tenant-content/public-question-banks/adopt', adoptPublicQuestionBankRoute],
['POST', '/api/tenant-content/public-question-banks/sync', syncPublicQuestionBankRoute],
['GET', '/api/tenant-content/public-question-banks/conflicts', publicQuestionBankConflictsRoute],
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],

View File

@@ -72,6 +72,23 @@ interface SyncQuestionResult {
targetHash: string | null;
}
interface PublicQuestionBankSyncAuth {
tenantId: string;
userId: string | null;
role: string;
permissions: Record<string, unknown>;
templatePermissions: Record<string, unknown>;
}
export interface PublicQuestionBankSyncInput {
tenantId: string;
adoptionId: string;
actorUserId?: string | null;
copyLimit?: number;
triggeredBy?: 'manual' | 'worker';
workerId?: string | null;
}
function slugFromName(name: string) {
const ascii = name
.normalize('NFKD')
@@ -311,7 +328,7 @@ async function sourceQuestionSnapshots(client: pg.PoolClient, input: {
}
async function syncQuestionSnapshot(client: pg.PoolClient, input: {
auth: TenantContentAuth;
auth: PublicQuestionBankSyncAuth;
sourceTenantId: string;
sourceQuestionBankId: string;
targetQuestionBankId: string;
@@ -508,6 +525,10 @@ function syncCounts(results: SyncQuestionResult[]) {
};
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function buildSourceSnapshot(input: {
sourceTenantId: string;
sourceQuestionBankId: string;
@@ -539,7 +560,7 @@ function buildSourceSnapshot(input: {
}
async function syncQuestionsSnapshot(client: pg.PoolClient, input: {
auth: TenantContentAuth;
auth: PublicQuestionBankSyncAuth;
sourceTenantId: string;
sourceQuestionBankId: string;
targetQuestionBankId: string;
@@ -873,13 +894,17 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) {
return { item };
}
export async function syncPublicQuestionBankRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const adoptionId = requiredString(body, 'adoptionId');
const copyLimit = Math.max(1, Math.min(intValue(body.copyLimit, 1000), 1000));
export async function executePublicQuestionBankSync(input: PublicQuestionBankSyncInput) {
const copyLimit = Math.max(1, Math.min(intValue(input.copyLimit, 1000), 1000));
const auth: PublicQuestionBankSyncAuth = {
tenantId: input.tenantId,
userId: input.actorUserId || null,
role: input.triggeredBy === 'worker' ? 'system_worker' : 'tenant_content_editor',
permissions: { 'content:*': true },
templatePermissions: {},
};
const result = await transaction(async client => {
return transaction(async client => {
const adoptionResult = await client.query<AdoptionRow>(
`
select id, tenant_id as "tenantId",
@@ -900,7 +925,7 @@ export async function syncPublicQuestionBankRoute(ctx: RequestContext) {
limit 1
for update
`,
[auth.tenantId, adoptionId],
[auth.tenantId, input.adoptionId],
);
const adoption = adoptionResult.rows[0];
if (!adoption) {
@@ -947,6 +972,21 @@ export async function syncPublicQuestionBankRoute(ctx: RequestContext) {
counts: syncResult.counts,
conflictCount: conflicts.length,
conflicts: conflicts.slice(0, 50),
triggeredBy: input.triggeredBy || 'manual',
workerId: input.workerId || null,
finishedAt: new Date().toISOString(),
},
publicBankSyncWorker: {
...(
adoption.metadata?.publicBankSyncWorker
&& typeof adoption.metadata.publicBankSyncWorker === 'object'
&& !Array.isArray(adoption.metadata.publicBankSyncWorker)
? adoption.metadata.publicBankSyncWorker as Record<string, unknown>
: {}
),
lastStatus: syncStatus,
lastWorkerId: input.workerId || null,
lastFinishedAt: new Date().toISOString(),
},
};
@@ -1003,6 +1043,8 @@ export async function syncPublicQuestionBankRoute(ctx: RequestContext) {
syncStatus,
syncSummary: syncResult.counts,
conflicts,
triggeredBy: input.triggeredBy || 'manual',
workerId: input.workerId || null,
}),
],
);
@@ -1016,6 +1058,59 @@ export async function syncPublicQuestionBankRoute(ctx: RequestContext) {
},
};
});
return result;
}
export async function syncPublicQuestionBankRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
return executePublicQuestionBankSync({
tenantId: auth.tenantId,
actorUserId: auth.userId,
adoptionId: requiredString(body, 'adoptionId'),
copyLimit: intValue(body.copyLimit, 1000),
triggeredBy: 'manual',
});
}
export async function publicQuestionBankConflictsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const adoptionId = stringParam(ctx, 'adoptionId');
if (!adoptionId) {
throw new HttpError(400, 'adoptionId is required', 'REQUIRED_FIELD');
}
const rows = await query<{
id: string;
syncStatus: string;
metadata: Record<string, unknown>;
lastSyncedAt: string | null;
updatedAt: string;
}>(
`
select id, sync_status as "syncStatus", metadata,
last_synced_at as "lastSyncedAt", updated_at as "updatedAt"
from public.tenant_question_bank_adoptions
where tenant_id = $1 and id = $2 and status <> 'archived'
limit 1
`,
[auth.tenantId, adoptionId],
);
const adoption = rows[0];
if (!adoption) {
throw new HttpError(404, 'Question bank adoption not found', 'QUESTION_BANK_ADOPTION_NOT_FOUND');
}
const lastSync = objectValue(adoption.metadata?.lastSync);
const conflicts = Array.isArray(lastSync.conflicts) ? lastSync.conflicts : [];
return {
item: {
adoptionId: adoption.id,
syncStatus: adoption.syncStatus,
lastSyncedAt: adoption.lastSyncedAt,
updatedAt: adoption.updatedAt,
conflictCount: Number(lastSync.conflictCount || conflicts.length || 0),
counts: objectValue(lastSync.counts),
conflicts,
},
};
}