From 35cff06df7e20eb05f76e1a1241e84b738e9f7e1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 08:37:40 +0800 Subject: [PATCH] feat: add public question bank sync --- README.md | 7 +- apps/api/src/features/tenant-content/index.ts | 2 + .../features/tenant-content/public-banks.ts | 587 ++++++++++++++---- docs/refactor/backend-capability-status.md | 6 +- docs/refactor/backend-handoff-roadmap.md | 6 +- docs/refactor/blueprint-coverage.md | 4 +- docs/refactor/implementation-status.md | 4 +- docs/refactor/legacy-feature-gap-matrix.md | 6 +- docs/refactor/next-development-todo.md | 8 +- docs/refactor/taro-frontend-integration.md | 57 +- scripts/api-integration-test.js | 131 ++++ 11 files changed, 690 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 46838fcc..58a0e14c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ - 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 -- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 +- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 +- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 - `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 @@ -29,7 +30,7 @@ - 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 -- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。 +- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库自动同步 worker 和冲突操作台还没完成。 - Taro 跨端前端还没开始 scaffold。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -214,4 +215,4 @@ npm run check:refactor 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。 4. 题库导出 PDF/Word/JSON、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 -5. 微信网页/QQ 登录、完整资金流水对账、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。 +5. 微信网页/QQ 登录、完整资金流水对账、公共题库自动同步 worker/冲突操作台、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index 46ff84c3..d809bb7b 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -43,6 +43,7 @@ import { import { adoptPublicQuestionBankRoute, publicQuestionBanksRoute, + syncPublicQuestionBankRoute, } from './public-banks.js'; import { bindQuestionVideoRoute, @@ -74,6 +75,7 @@ export const tenantContentRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-content/content-entries', contentEntriesAdminRoute], ['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], ['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute], ['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute], ['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute], diff --git a/apps/api/src/features/tenant-content/public-banks.ts b/apps/api/src/features/tenant-content/public-banks.ts index aaabb2a7..be013ed4 100644 --- a/apps/api/src/features/tenant-content/public-banks.ts +++ b/apps/api/src/features/tenant-content/public-banks.ts @@ -17,9 +17,59 @@ interface EligibleBankRow { questionCount: number; adoptedId: string | null; adoptionStatus: string | null; + syncStatus: string | null; targetQuestionBankId: string | null; targetEntryId: string | null; targetCollectionId: string | null; + copiedQuestionCount: number | null; + lastSyncedAt: string | null; +} + +interface AdoptionRow { + id: string; + tenantId: string; + sourceQuestionBankId: string; + grantId: string | null; + targetQuestionBankId: string | null; + targetEntryId: string | null; + targetCollectionId: string | null; + adoptionMode: string; + status: string; + syncStatus: string; + sourceSnapshot: unknown; + copiedQuestionCount: number; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +interface SourceQuestionSnapshot { + id: string; + type: string; + typeLabel: string | null; + difficulty: number | null; + tags: unknown[]; + mediaUrl: string | null; + hasVideoExplanation: boolean; + content: string | null; + options: unknown[]; + correctOptionIndex: number | null; + correctOptionIndices: unknown[]; + answerText: string | null; + explanation: string | null; + subQuestions: unknown[]; + codeLang: string | null; + codeTemplate: string | null; + sourceHash: string; +} + +interface SyncQuestionResult { + sourceQuestionId: string; + targetQuestionId: string | null; + action: 'inserted' | 'updated' | 'skipped' | 'conflict'; + sourceHash: string; + previousSourceHash: string | null; + targetHash: string | null; } function slugFromName(name: string) { @@ -132,9 +182,12 @@ async function loadEligibleGrant( coalesce(qs.question_count, 0)::integer as "questionCount", a.id as "adoptedId", a.status as "adoptionStatus", + a.sync_status as "syncStatus", a.target_question_bank_id as "targetQuestionBankId", a.target_entry_id as "targetEntryId", - a.target_collection_id as "targetCollectionId" + a.target_collection_id as "targetCollectionId", + a.copied_question_count as "copiedQuestionCount", + a.last_synced_at as "lastSyncedAt" from public.question_bank_grants g join public.question_banks qb on qb.id = g.source_question_bank_id left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id @@ -179,19 +232,27 @@ async function loadEligibleGrant( return result.rows[0] || null; } -async function copyQuestionsSnapshot(client: pg.PoolClient, input: { - auth: TenantContentAuth; +function snapshotMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const questions = (value as { questions?: unknown }).questions; + if (!questions || typeof questions !== 'object' || Array.isArray(questions)) return {}; + const result: Record = {}; + for (const [key, raw] of Object.entries(questions as Record)) { + if (typeof raw === 'string' && raw) result[key] = raw; + else if (raw && typeof raw === 'object' && typeof (raw as { sourceHash?: unknown }).sourceHash === 'string') { + result[key] = (raw as { sourceHash: string }).sourceHash; + } + } + return result; +} + +async function sourceQuestionSnapshots(client: pg.PoolClient, input: { sourceTenantId: string; sourceQuestionBankId: string; - targetQuestionBankId: string; - targetEntryId: string; - targetCollectionId: string; copyLimit: number; }) { const sourceQuestions = await client.query<{ id: string; - subject_id: string | null; - category_id: string | null; type: string; type_label: string | null; difficulty: number | null; @@ -228,56 +289,132 @@ async function copyQuestionsSnapshot(client: pg.PoolClient, input: { [input.sourceTenantId, input.sourceQuestionBankId, input.copyLimit], ); - let order = 0; - for (const source of sourceQuestions.rows) { - const legacyId = `public:${input.sourceTenantId}:${source.id}`; - const questionResult = await client.query<{ id: string }>( - ` - insert into public.questions ( - tenant_id, question_bank_id, entry_id, primary_collection_id, - legacy_id, type, type_label, difficulty, tags, media_url, - has_video_explanation, status, exam_markers - ) - values ( - $1, $2, $3, $4, - $5, $6, $7, $8, $9::jsonb, $10, - $11, 'published', $12::jsonb - ) - on conflict (tenant_id, legacy_id) - do update set question_bank_id = excluded.question_bank_id, - entry_id = excluded.entry_id, - primary_collection_id = excluded.primary_collection_id, - type = excluded.type, - type_label = excluded.type_label, - difficulty = excluded.difficulty, - tags = excluded.tags, - media_url = excluded.media_url, - has_video_explanation = excluded.has_video_explanation, - status = 'published', - exam_markers = excluded.exam_markers, - updated_at = now() - returning id - `, - [ - input.auth.tenantId, - input.targetQuestionBankId, - input.targetEntryId, - input.targetCollectionId, - legacyId, - source.type, - source.type_label, - source.difficulty, - JSON.stringify(source.tags || []), - source.media_url, - source.has_video_explanation, - JSON.stringify({ - sourceTenantId: input.sourceTenantId, - sourceQuestionBankId: input.sourceQuestionBankId, - sourceQuestionId: source.id, - }), - ], + return sourceQuestions.rows.map((source): SourceQuestionSnapshot => ({ + id: source.id, + type: source.type, + typeLabel: source.type_label, + difficulty: source.difficulty, + tags: source.tags || [], + mediaUrl: source.media_url, + hasVideoExplanation: source.has_video_explanation, + content: source.content, + options: source.options || [], + correctOptionIndex: source.correct_option_index, + correctOptionIndices: source.correct_option_indices || [], + answerText: source.answer_text, + explanation: source.explanation, + subQuestions: source.sub_questions || [], + codeLang: source.code_lang, + codeTemplate: source.code_template, + sourceHash: source.source_hash || `public:${source.id}`, + })); +} + +async function syncQuestionSnapshot(client: pg.PoolClient, input: { + auth: TenantContentAuth; + sourceTenantId: string; + sourceQuestionBankId: string; + targetQuestionBankId: string; + targetEntryId: string; + targetCollectionId: string; + source: SourceQuestionSnapshot; + previousSourceHash: string | null; + order: number; +}) { + const legacyId = `public:${input.sourceTenantId}:${input.source.id}`; + const existing = 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.legacy_id = $2 + limit 1 + for update of q + `, + [input.auth.tenantId, legacyId], + ); + const existingQuestion = existing.rows[0] || null; + const targetHash = existingQuestion?.source_hash || null; + + if (existingQuestion) { + const targetMatchesCurrent = !!targetHash && targetHash === input.source.sourceHash; + const targetMatchesPrevious = !!targetHash && !!input.previousSourceHash && targetHash === input.previousSourceHash; + if (!targetMatchesCurrent && !targetMatchesPrevious) { + return { + sourceQuestionId: input.source.id, + targetQuestionId: existingQuestion.id, + action: 'conflict', + sourceHash: input.source.sourceHash, + previousSourceHash: input.previousSourceHash, + targetHash, + } satisfies SyncQuestionResult; + } + } + + const questionResult = await client.query<{ id: string }>( + ` + insert into public.questions ( + tenant_id, question_bank_id, entry_id, primary_collection_id, + legacy_id, type, type_label, difficulty, tags, media_url, + has_video_explanation, status, exam_markers + ) + values ( + $1, $2, $3, $4, + $5, $6, $7, $8, $9::jsonb, $10, + $11, 'published', $12::jsonb + ) + on conflict (tenant_id, legacy_id) + do update set question_bank_id = excluded.question_bank_id, + entry_id = excluded.entry_id, + primary_collection_id = excluded.primary_collection_id, + type = excluded.type, + type_label = excluded.type_label, + difficulty = excluded.difficulty, + tags = excluded.tags, + media_url = excluded.media_url, + has_video_explanation = excluded.has_video_explanation, + status = 'published', + exam_markers = excluded.exam_markers, + updated_at = now() + returning id + `, + [ + input.auth.tenantId, + input.targetQuestionBankId, + input.targetEntryId, + input.targetCollectionId, + legacyId, + input.source.type, + input.source.typeLabel, + input.source.difficulty, + JSON.stringify(input.source.tags || []), + input.source.mediaUrl, + input.source.hasVideoExplanation, + JSON.stringify({ + sourceTenantId: input.sourceTenantId, + sourceQuestionBankId: input.sourceQuestionBankId, + sourceQuestionId: input.source.id, + }), + ], + ); + const questionId = questionResult.rows[0].id; + + const action: SyncQuestionResult['action'] = existingQuestion + ? targetHash === input.source.sourceHash + ? 'skipped' + : 'updated' + : 'inserted'; + + if (action !== 'skipped') { + 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', + [questionId], ); - const questionId = questionResult.rows[0].id; + const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1; + const versionNo = existingQuestion ? nextVersionNo : 1; const versionResult = await client.query<{ id: string }>( ` @@ -288,10 +425,10 @@ async function copyQuestionsSnapshot(client: pg.PoolClient, input: { source_hash, created_by ) values ( - $1, $2, 1, $3, $4::jsonb, - $5, $6::jsonb, $7, - $8, $9::jsonb, $10, $11, - $12, $13 + $1, $2, $3, $4, $5::jsonb, + $6, $7::jsonb, $8, + $9, $10::jsonb, $11, $12, + $13, $14 ) on conflict (question_id, version_no) do update set content = excluded.content, @@ -309,16 +446,17 @@ async function copyQuestionsSnapshot(client: pg.PoolClient, input: { [ input.auth.tenantId, questionId, - source.content, - JSON.stringify(source.options || []), - source.correct_option_index, - JSON.stringify(source.correct_option_indices || []), - source.answer_text, - source.explanation, - JSON.stringify(source.sub_questions || []), - source.code_lang, - source.code_template, - source.source_hash || `public:${source.id}`, + versionNo, + input.source.content, + JSON.stringify(input.source.options || []), + input.source.correctOptionIndex, + JSON.stringify(input.source.correctOptionIndices || []), + input.source.answerText, + input.source.explanation, + JSON.stringify(input.source.subQuestions || []), + input.source.codeLang, + input.source.codeTemplate, + input.source.sourceHash, input.auth.userId, ], ); @@ -327,42 +465,124 @@ async function copyQuestionsSnapshot(client: pg.PoolClient, input: { 'update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2', [input.auth.tenantId, questionId, versionResult.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, - sort_order = excluded.sort_order, - metadata = excluded.metadata, - updated_at = now() - `, - [ - input.auth.tenantId, - input.targetCollectionId, - questionId, - source.type, - order, - JSON.stringify({ source: 'public_question_bank_adoption', sourceQuestionId: source.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, + sort_order = excluded.sort_order, + metadata = excluded.metadata, + updated_at = now() + `, + [ + input.auth.tenantId, + input.targetCollectionId, + questionId, + input.source.type, + input.order, + JSON.stringify({ source: 'public_question_bank_adoption', sourceQuestionId: input.source.id }), + ], + ); + + return { + sourceQuestionId: input.source.id, + targetQuestionId: questionId, + action, + sourceHash: input.source.sourceHash, + previousSourceHash: input.previousSourceHash, + targetHash, + } satisfies SyncQuestionResult; +} + +function syncCounts(results: SyncQuestionResult[]) { + return { + inserted: results.filter(item => item.action === 'inserted').length, + updated: results.filter(item => item.action === 'updated').length, + skipped: results.filter(item => item.action === 'skipped').length, + conflicts: results.filter(item => item.action === 'conflict').length, + }; +} + +function buildSourceSnapshot(input: { + sourceTenantId: string; + sourceQuestionBankId: string; + sourceQuestionBankName: string; + sourceRegionId: string | null; + sourceRegionName: string | null; + sourceQuestionCount: number; + sourceQuestions: SourceQuestionSnapshot[]; + syncSummary?: Record; +}) { + return { + sourceTenantId: input.sourceTenantId, + sourceQuestionBankId: input.sourceQuestionBankId, + sourceQuestionBankName: input.sourceQuestionBankName, + sourceRegionId: input.sourceRegionId, + sourceRegionName: input.sourceRegionName, + sourceQuestionCount: input.sourceQuestionCount, + questions: Object.fromEntries( + input.sourceQuestions.map(question => [ + question.id, + { + sourceHash: question.sourceHash, + syncedAt: new Date().toISOString(), + }, + ]), + ), + syncSummary: input.syncSummary || undefined, + }; +} + +async function syncQuestionsSnapshot(client: pg.PoolClient, input: { + auth: TenantContentAuth; + sourceTenantId: string; + sourceQuestionBankId: string; + targetQuestionBankId: string; + targetEntryId: string; + targetCollectionId: string; + copyLimit: number; + previousSnapshot?: unknown; +}) { + const sources = await sourceQuestionSnapshots(client, input); + const previous = snapshotMap(input.previousSnapshot); + const results: SyncQuestionResult[] = []; + let order = 0; + for (const source of sources) { + const result = await syncQuestionSnapshot(client, { + auth: input.auth, + sourceTenantId: input.sourceTenantId, + sourceQuestionBankId: input.sourceQuestionBankId, + targetQuestionBankId: input.targetQuestionBankId, + targetEntryId: input.targetEntryId, + targetCollectionId: input.targetCollectionId, + source, + previousSourceHash: previous[source.id] || null, + order, + }); + results.push(result); order += 1; } await client.query( ` update public.question_collections - set question_count = $3, + 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 `, - [input.auth.tenantId, input.targetCollectionId, sourceQuestions.rows.length], + [input.auth.tenantId, input.targetCollectionId], ); - return sourceQuestions.rows.length; + return { sourceQuestions: sources, results, counts: syncCounts(results) }; } export async function publicQuestionBanksRoute(ctx: RequestContext) { @@ -417,9 +637,12 @@ export async function publicQuestionBanksRoute(ctx: RequestContext) { coalesce(qs.question_count, 0)::integer as "questionCount", a.id as "adoptedId", a.status as "adoptionStatus", + a.sync_status as "syncStatus", a.target_question_bank_id as "targetQuestionBankId", a.target_entry_id as "targetEntryId", - a.target_collection_id as "targetCollectionId" + a.target_collection_id as "targetCollectionId", + a.copied_question_count as "copiedQuestionCount", + a.last_synced_at as "lastSyncedAt" from eligible_grants g join public.question_banks qb on qb.id = g.source_question_bank_id left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id @@ -550,7 +773,7 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) { ); const targetCollectionId = collectionResult.rows[0].id; - const copiedQuestionCount = await copyQuestionsSnapshot(client, { + const syncResult = await syncQuestionsSnapshot(client, { auth, sourceTenantId: grant.sourceTenantId, sourceQuestionBankId: grant.sourceQuestionBankId, @@ -559,8 +782,19 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) { targetCollectionId, copyLimit, }); + const copiedQuestionCount = syncResult.sourceQuestions.length; + const sourceSnapshot = buildSourceSnapshot({ + sourceTenantId: grant.sourceTenantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + sourceQuestionBankName: grant.sourceQuestionBankName, + sourceRegionId: grant.sourceRegionId, + sourceRegionName: grant.sourceRegionName, + sourceQuestionCount: grant.questionCount, + sourceQuestions: syncResult.sourceQuestions, + syncSummary: syncResult.counts, + }); - const adoptionResult = await client.query( + const adoptionResult = await client.query( ` insert into public.tenant_question_bank_adoptions ( tenant_id, source_question_bank_id, grant_id, target_question_bank_id, @@ -605,14 +839,7 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) { targetQuestionBankId, targetEntryId, targetCollectionId, - JSON.stringify({ - sourceTenantId: grant.sourceTenantId, - sourceQuestionBankId: grant.sourceQuestionBankId, - sourceQuestionBankName: grant.sourceQuestionBankName, - sourceRegionId: grant.sourceRegionId, - sourceRegionName: grant.sourceRegionName, - sourceQuestionCount: grant.questionCount, - }), + JSON.stringify(sourceSnapshot), copiedQuestionCount, jsonObjectValue(body.metadata), auth.userId, @@ -635,6 +862,7 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) { targetEntryId, targetCollectionId, copiedQuestionCount, + syncSummary: syncResult.counts, }), ], ); @@ -644,3 +872,150 @@ 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)); + + const result = await transaction(async client => { + const adoptionResult = await client.query( + ` + 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 in ('active', 'sync_pending') + limit 1 + for update + `, + [auth.tenantId, adoptionId], + ); + const adoption = adoptionResult.rows[0]; + if (!adoption) { + throw new HttpError(404, 'Question bank adoption not found', 'QUESTION_BANK_ADOPTION_NOT_FOUND'); + } + if (!adoption.grantId) { + throw new HttpError(409, 'Question bank adoption has no active grant', 'QUESTION_BANK_ADOPTION_GRANT_MISSING'); + } + if (!adoption.targetQuestionBankId || !adoption.targetEntryId || !adoption.targetCollectionId) { + throw new HttpError(409, 'Question bank adoption target is incomplete', 'QUESTION_BANK_ADOPTION_TARGET_MISSING'); + } + + const grant = await loadEligibleGrant(client, auth.tenantId, adoption.grantId); + 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 syncResult = await syncQuestionsSnapshot(client, { + auth, + sourceTenantId: grant.sourceTenantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + targetQuestionBankId: adoption.targetQuestionBankId, + targetEntryId: adoption.targetEntryId, + targetCollectionId: adoption.targetCollectionId, + copyLimit, + previousSnapshot: adoption.sourceSnapshot, + }); + const conflicts = syncResult.results.filter(item => item.action === 'conflict'); + const syncStatus = conflicts.length ? 'failed' : 'synced'; + const sourceSnapshot = buildSourceSnapshot({ + sourceTenantId: grant.sourceTenantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + sourceQuestionBankName: grant.sourceQuestionBankName, + sourceRegionId: grant.sourceRegionId, + sourceRegionName: grant.sourceRegionName, + sourceQuestionCount: grant.questionCount, + sourceQuestions: syncResult.sourceQuestions, + syncSummary: syncResult.counts, + }); + const metadata = { + ...(adoption.metadata || {}), + lastSync: { + status: syncStatus, + counts: syncResult.counts, + conflictCount: conflicts.length, + conflicts: conflicts.slice(0, 50), + }, + }; + + const updated = await client.query( + ` + update public.tenant_question_bank_adoptions + set sync_status = $3, + status = 'active', + source_snapshot = $4::jsonb, + copied_question_count = $5, + metadata = $6::jsonb, + updated_by = $7, + last_synced_at = now(), + 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, + syncStatus, + JSON.stringify(sourceSnapshot), + syncResult.sourceQuestions.length, + JSON.stringify(metadata), + auth.userId, + ], + ); + + 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.synced', 'tenant_question_bank_adoption', $3, $4::jsonb) + `, + [ + auth.tenantId, + auth.userId, + adoption.id, + JSON.stringify({ + grantId: adoption.grantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + targetQuestionBankId: adoption.targetQuestionBankId, + targetEntryId: adoption.targetEntryId, + targetCollectionId: adoption.targetCollectionId, + syncStatus, + syncSummary: syncResult.counts, + conflicts, + }), + ], + ); + + return { + item: updated.rows[0], + sync: { + status: conflicts.length ? 'conflict' : 'synced', + counts: syncResult.counts, + results: syncResult.results, + }, + }; + }); + + return result; +} diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 8a751360..a4ac5f5d 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -135,7 +135,7 @@ | 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` | | 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 | -| 租户采纳公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习 | +| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动同步,租户自改题目会标记冲突并跳过 | ## 销售、代理、CRM @@ -158,14 +158,14 @@ | --- | --- | --- | | PocketBase schema 分析 | 可联调 | `scripts/import-pocketbase` | | 题目 JSON preview/import | 可联调 | 后端负责规范化、issue、幂等、审计 | -| 公共题库采纳快照 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;已覆盖跨租户、重复采纳和采纳后组卷测试 | +| 公共题库采纳与手动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录和租户自改冲突保护;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题和冲突不覆盖测试 | | 单词 JSON preview/import | 可联调 | 兼容旧模板 | | 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 | | 分数线 JSON preview/import | 可联调 | 支持 `fields/schools/majors/records` 分桶或 `items` 列表,后端校验租户地区和院校/专业引用 | | 视频 JSON preview/import | 可联调 | 支持 `videos/items`,后端校验题目、科目、资源引用,导入后写入 `question_videos` | | Excel/CSV 导入 | 可联调 | 题目、单词、知识手册、分数线、视频已支持 CSV 和 `.xlsx` 解析,解析后复用 `content_import_jobs/items/issues` 管线并保留 `parser_metadata`;模板下载、字段映射 API 和导入后复检已接入 | | 大批量异步导入 | 可联调 | `executionMode=async` 会将 preview job 置为 `pending`;`apps/worker --job imports` 抢占 queued job,复用 API 导入 executor,支持重试、清锁和审计 | -| 公共题库版本同步 | 待补齐 | 当前采纳为快照复制;后续需 worker 做增量同步、冲突处理、版本升级通知和租户自改保护 | +| 公共题库自动同步增强 | 待补齐 | 手动同步 API 已完成;后续需 worker 做定时同步、失败重试、版本升级通知、冲突操作台和批量确认/跳过 | ## 当前验证 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 191fc662..3d06a294 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -21,9 +21,9 @@ | 模块 | 当前状态 | 已经具备 | 上线前还要补 | | --- | --- | --- | --- | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 | -| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权 | 自动计费、平台审计、公共题库版本同步 | +| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权 | 自动计费、平台审计、公共题库自动同步运营台 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限 | 前端权限 UI、更细的数据范围组合 | -| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照 | 专项策略、公共题库版本同步、排行榜防刷/预聚合 | +| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照和手动同步 | 专项策略、公共题库自动同步 worker/冲突操作台、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 | | 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 | @@ -86,7 +86,7 @@ - 完整资金流水对账、账单下载比对和异常订单运营台。 - XPay 或其它实际支付网关 adapter。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。 -- 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 +- 公共题库/地区题库自动同步 worker、版本通知、冲突操作台,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 - 导入模板、字段映射和复检 API 已可用;前端继续补模板下载按钮、字段映射 UI、job 状态轮询和复检结果面板。 - 视频深度防盗链、动态水印和播放统计。 - 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 3a79b43d..b3c175b9 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -18,7 +18,7 @@ | 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | -| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权和采纳快照 | 字段映射 UI、公共题库版本同步、可视化拖拽排序前端 | +| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照和手动同步 | 字段映射 UI、公共题库自动同步 worker/冲突操作台、可视化拖拽排序前端 | | 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜 | 专项练习策略、题型统计深度分析、排行榜防刷/预聚合 | | 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜 | 更细复习参数 | | 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 | @@ -37,7 +37,7 @@ ## 接下来优先级 1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、杀毒扫描和视频水印。 -2. 公共题库/地区题库授权:已完成披露和采纳快照;继续补版本同步、租户自改冲突处理和按 SaaS 套餐限制地区。 +2. 公共题库/地区题库授权:已完成披露、采纳快照和手动同步冲突保护;继续补自动同步 worker、版本通知、冲突操作台和按 SaaS 套餐限制地区。 3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 视频会员控制:深度防盗链、水印和播放统计。 5. 数据看板预聚合:把实时聚合升级为大租户可承载的日/周/月预聚合。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index a7b3146e..8f777237 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -24,7 +24,7 @@ | 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 | | --- | --- | --- | --- | --- | --- | | 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 | -| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、异步导入 worker、平台公共题库授权、租户采纳快照已实现 | 核心 API 集成测试含导航、组卷、导入、公共题库授权和采纳后组卷断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳快照、导入后复检、模板下载和字段映射 API 可联调;公共题库全量/增量版本同步仍需补齐 | +| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、异步导入 worker、平台公共题库授权、租户采纳快照和手动同步已实现 | 核心 API 集成测试含导航、组卷、导入、公共题库授权、采纳后组卷、同步新增题和租户自改冲突保护断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳/手动同步、导入后复检、模板下载和字段映射 API 可联调;公共题库自动同步 worker、版本通知和冲突操作台仍需补齐 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | | 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 | @@ -278,7 +278,7 @@ platform-admin: 为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续: 1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、CDN 防盗链、杀毒扫描。 -2. 补公共题库版本同步 worker、租户套餐地区/科目/题库范围限制、主题模板系统。 +2. 补公共题库自动同步 worker、版本通知/冲突操作台、租户套餐地区/科目/题库范围限制、主题模板系统。 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 补视频商用控制:深度防盗链、动态水印和播放统计。 5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 7dae532e..d1e0afbc 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -76,7 +76,7 @@ | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | -| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权、租户可采纳列表、采纳快照复制、采纳后练习组卷;缺全量/增量版本同步、冲突处理和运营 UI | +| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权、租户可采纳列表、采纳快照复制、采纳后练习组卷,以及手动同步 API;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺自动同步 worker、版本通知、冲突操作台和更完整运营 UI | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | | 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 | @@ -99,7 +99,7 @@ 2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、模板下载和字段映射 API 已补,仍缺前端字段映射 UI 和真实数据 dry-run。 -5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。 +5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步和租户自改冲突保护已完成基础闭环;还需自动同步 worker、版本通知、冲突操作台和运营后台 UI。 6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 @@ -119,7 +119,7 @@ 2. 对象存储 PDF 预览、视频深度防盗链、动态水印。 3. 字段映射 UI、真实数据 dry-run 和导入复检结果操作台。 4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。 -5. 公共题库版本同步、租户采纳后的更新策略和同步 worker。 +5. 公共题库自动同步 worker、版本通知、冲突操作台和租户确认/跳过策略。 ### P2:增强体验 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index e6026c3f..f8aa0ee6 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -22,7 +22,7 @@ - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 - 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 -- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session。 +- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 进入租户副本,租户自改题目会返回冲突并保留原内容。 - 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。 - 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。 - 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。 @@ -80,9 +80,9 @@ - 模板下载、字段映射 API 和导入后复检已补;继续补字段映射 UI、真实数据 dry-run、导入前抽样校验和复检结果操作台。 4. 公共题库和租户授权 - - 已完成平台公共题库/地区题库的基础授权、租户采纳和题目快照复制。 + - 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。 - 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。 - - 继续补公共题库版本同步 worker、租户自改冲突处理、同步失败重试和运营后台 UI。 + - 继续补公共题库自动同步 worker、同步失败重试、版本通知、冲突操作台和运营后台 UI。 5. 视频会员控制 - 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。 @@ -206,5 +206,5 @@ 2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。 -5. 并行补对象存储、真实登录、完整资金流水对账、题库导出和公共题库版本同步 worker。 +5. 并行补对象存储、真实登录、完整资金流水对账、题库导出和公共题库自动同步 worker/冲突操作台。 6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 617dd532..43c1cb48 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -184,7 +184,7 @@ tenant::theme | 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` | | 租户反馈处理 | `GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` | | 租户勋章 | `GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` | -| 公共题库采纳 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt` | +| 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync` | ## 练习访问控制契约 @@ -850,6 +850,7 @@ PUT /api/platform-admin/question-bank-grants ```text GET /api/tenant-content/public-question-banks POST /api/tenant-content/public-question-banks/adopt +POST /api/tenant-content/public-question-banks/sync ``` 采纳请求: @@ -868,7 +869,59 @@ POST /api/tenant-content/public-question-banks/adopt - 租户只能看到后端判定为已授权的公共题库,不要在前端用套餐码自行过滤。 - 采纳成功后后端会生成本租户自己的 `questionBankId`、`entryId`、`collectionId` 和题目快照,学生端直接按普通 `/api/catalog/content-entries`、`question-collections`、`practice-sessions` 接入。 - 重复采纳返回 `QUESTION_BANK_ALREADY_ADOPTED`,前端展示“已采纳”即可。 -- 当前版本是快照复制;平台公共题库后续更新不会自动进入租户题库,后续会由 worker 做版本同步、冲突处理和租户确认。 +- 已采纳公共题库可以手动同步平台后续新增/更新题目;同步会重新校验当前租户仍有授权,且只写入租户自己的题目副本。 + +同步请求: + +```json +{ + "adoptionId": "", + "copyLimit": 1000 +} +``` + +同步响应关键字段: + +```json +{ + "item": { + "id": "...", + "syncStatus": "synced | failed", + "copiedQuestionCount": 120, + "targetQuestionBankId": "...", + "targetEntryId": "...", + "targetCollectionId": "..." + }, + "sync": { + "status": "synced | conflict", + "counts": { + "inserted": 1, + "updated": 3, + "skipped": 116, + "conflicts": 0 + }, + "results": [ + { + "sourceQuestionId": "...", + "targetQuestionId": "...", + "action": "inserted | updated | skipped | conflict", + "sourceHash": "...", + "previousSourceHash": "...", + "targetHash": "..." + } + ] + } +} +``` + +前端处理规则: + +- `sync.status=synced`:刷新公共题库列表、题目集合和题目列表。 +- `sync.status=conflict` 或 `item.syncStatus=failed`:展示冲突数量和冲突题目,不要把它当系统异常。冲突表示租户已经改过这道采纳题,后端已跳过并保留租户内容。 +- `action=conflict` 的记录可以进入后续“冲突处理”页面:展示平台源题 ID、租户目标题 ID、上次平台 hash、当前平台 hash、租户当前 hash。当前后端只负责保护不覆盖,批量接受平台版本/保留租户版本的操作台后续补。 +- `QUESTION_BANK_GRANT_NOT_AVAILABLE`:说明 SaaS 套餐/授权已失效,提示联系平台或升级套餐。 +- `QUESTION_BANK_ADOPTION_NOT_FOUND`:说明不是当前租户的采纳记录或记录已归档,前端不要跨租户重试。 +- 后续会补自动同步 worker、版本通知、冲突操作台和批量确认策略;当前租户后台可以先提供手动“同步平台更新”按钮。 ## 登录对接 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 85ad3b3a..cbfa7f7b 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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' },