diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index edeca272..b5e98c5b 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -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], diff --git a/apps/api/src/features/tenant-content/public-banks.ts b/apps/api/src/features/tenant-content/public-banks.ts index 92726c50..0ac1afb9 100644 --- a/apps/api/src/features/tenant-content/public-banks.ts +++ b/apps/api/src/features/tenant-content/public-banks.ts @@ -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(); + 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( + ` + 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>(); + 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 = { + ...(adoption.metadata || {}), + resolvedPublicBankConflicts: { + ...resolvedPublicBankConflicts(adoption.metadata || {}), + }, + }; + const resolved = objectValue(metadata.resolvedPublicBankConflicts); + const processedSourceIds = new Set(); + 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( + ` + 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 }; +} diff --git a/apps/taro/README.md b/apps/taro/README.md index ad7b0ab9..1efed0c1 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -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 为准,前端菜单隐藏只做体验优化。 ## 当前平台后台页面 diff --git a/apps/taro/src/pages/tenant-admin/content/index.tsx b/apps/taro/src/pages/tenant-admin/content/index.tsx index b6049ade..21b2c1a6 100644 --- a/apps/taro/src/pages/tenant-admin/content/index.tsx +++ b/apps/taro/src/pages/tenant-admin/content/index.tsx @@ -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 : postCheck; @@ -711,6 +749,12 @@ export default function TenantContentPage() { 状态 {String(conflicts.status || conflicts.syncStatus || '-')} 冲突数 {String(conflicts.conflictCount || 0)} · 最近同步 {String(conflicts.lastSyncedAt || '')} 采纳平台会覆盖为平台新版;保留本地会记录本次冲突已处理。 + {conflictItems(conflicts).length ? ( + + + + + ) : null} {conflictItems(conflicts).slice(0, 20).map((item, index) => ( diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index 2fd14280..94907619 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -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 }>('/api/tenant-content/public-question-banks/conflicts/resolve-batch', { + method: 'POST', + body: input, + }); +} + export async function runImportPostCheck(jobId: string) { return apiRequest<{ item?: Record }>('/api/tenant-content/imports/post-check', { method: 'POST', diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 3bce49e7..ed65d305 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -136,7 +136,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`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条选择“采纳平台版本”或“保留本地版本”,操作会写入审计 | +| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计 | | 题库导出基础 | 可联调 | `/api/tenant-content/exports/questions`、`/api/tenant-content/exports/jobs`;支持按题目集合、内容入口或分类节点导出 JSON/试卷 payload,后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word 二进制与水印 worker 后续补 | ## 销售、代理、CRM @@ -161,7 +161,7 @@ | PocketBase schema/导出分析 | 可联调 | `scripts/import-pocketbase` 支持 schema summary/risk、`npm run pb:import:dry-run` 导出目录静态迁移报告 | | PocketBase JSON dry-run | 可联调 | 不写数据库,检查导出目录、JSON 形态、核心集合、旧 ID、敏感字段、schema relation、未映射集合和关键业务计数 | | 题目 JSON preview/import | 可联调 | 后端负责规范化、issue、幂等、审计 | -| 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录和租户自改冲突保护;冲突处理 API 已支持单条采纳平台版本和保留租户本地版本;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、冲突不覆盖、冲突处理和 worker 自动同步测试 | +| 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录和租户自改冲突保护;冲突处理 API 已支持单条/批量采纳平台版本和保留租户本地版本;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、冲突不覆盖、单条/批量冲突处理和 worker 自动同步测试 | | 单词 JSON preview/import | 可联调 | 兼容旧模板 | | 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 | | 分数线 JSON preview/import | 可联调 | 支持 `fields/schools/majors/records` 分桶或 `items` 列表,后端校验租户地区和院校/专业引用 | @@ -169,7 +169,7 @@ | Excel/CSV 导入 | 可联调 | 题目、单词、知识手册、分数线、视频已支持 CSV 和 `.xlsx` 解析,解析后复用 `content_import_jobs/items/issues` 管线并保留 `parser_metadata`;模板下载、字段映射 API、字段映射覆盖白名单、导入任务详情、异步 worker 状态、导入后复检已接入;Taro 租户内容页已接上传/粘贴 preview/import、字段别名编辑、异步任务轮询和复检详情第一版 | | 大批量异步导入 | 可联调 | `executionMode=async` 会将 preview job 置为 `pending`;`apps/worker --job imports` 抢占 queued job,复用 API 导入 executor,支持重试、清锁和审计 | | 题库导出任务 | 可联调 | `content_export_jobs` 记录导出范围、格式、题量、输出 hash、选项和执行人;当前返回 inline base64 JSON 文件,前端可先下载 `.json` 或交给后续 PDF/Word worker 渲染 | -| 公共题库自动同步增强 | 部分覆盖 | `apps/worker --job public-banks` 已可抢占待同步采纳记录、自动同步平台新增/更新题目、记录失败和审计;租户后台已有单条冲突处理第一版;后续需接入生产定时调度、版本升级通知、批量确认/跳过和更完整运营消息 | +| 公共题库自动同步增强 | 部分覆盖 | `apps/worker --job public-banks` 已可抢占待同步采纳记录、自动同步平台新增/更新题目、记录失败和审计;租户后台已有单条和批量冲突处理第一版;后续需接入生产定时调度、版本升级通知和更完整运营消息 | ## 当前验证 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 27cf044c..91a01eb8 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 深测 | -| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker | 自动计费、平台审计、公共题库版本通知和批量处理运营台 | +| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限 | 前端权限 UI、更细的数据范围组合 | -| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知和批量处理、排行榜防刷/预聚合 | +| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 | | 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 | @@ -35,7 +35,7 @@ | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 | -| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条采纳平台/保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版 | 刷题细节 UI、租户后台冲突批量处理、平台后台审计/详情增强、小程序兼容验证和端到端测试 | +| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版 | 刷题细节 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 | ## 前端接入建议 @@ -79,14 +79,14 @@ - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。 - 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。 - 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 -- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、冲突查看、单条采纳平台/保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;下一步补刷题细节 UI、租户后台冲突批量处理、平台后台审计增强和小程序兼容验证。 +- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;下一步补刷题细节 UI、平台后台审计增强和小程序兼容验证。 ### P1:商用收费和运营能力 - 完整资金流水对账、账单下载比对和异常订单运营台。 - XPay 或其它实际支付网关 adapter。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。 -- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有单条冲突采纳平台/保留本地操作;继续补版本通知、批量处理,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 +- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有单条/批量冲突采纳平台或保留本地操作;继续补版本通知,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 - 导入模板、字段映射、导入任务详情和复检 API 已可用;Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。 - 视频深度防盗链、动态水印和播放统计。 - 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 7cc82755..d5ab87db 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -26,7 +26,7 @@ ## 当前可进入的前端工作 - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 -- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、单条采纳平台版本/保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力。 +- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力。 - 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。 - 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以按新后端主模型接入内容导航: @@ -87,11 +87,11 @@ | 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview`、`tenant-admin/dashboard` | | 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` | | 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/students` | -| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve` | +| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch` | | 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/queue`、`commission/summary` | | 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`role-templates` | -当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、单条采纳平台版本/保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情。下一批需要继续补完整后台写入表单、冲突批量处理、学生批量导入、角色模板配置 UI 和权限驱动菜单。 +当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情。下一批需要继续补完整后台写入表单、学生批量导入、角色模板配置 UI 和权限驱动菜单。 ## 已落地的 Taro 平台后台页面 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 0e2ee937..d96673d7 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 预览/导入、JSON/试卷 payload 导出、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker 和冲突查询已实现 | 核心 API 集成测试含导航、组卷、导入、导出权限/脱敏、公共题库授权、采纳后组卷、同步新增题、租户自改冲突保护和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳/手动/自动同步、冲突查询、导入后复检、模板下载、字段映射 API 和导出基础可联调;PDF/Word 导出 worker、公共题库版本通知和冲突操作台仍需补齐 | +| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、导入、导出权限/脱敏、公共题库授权、采纳后组卷、同步新增题、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳/手动/自动同步、冲突查询/处理、导入后复检、模板下载、字段映射 API 和导出基础可联调;PDF/Word 导出 worker、公共题库版本通知和运营消息仍需补齐 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | | 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 | @@ -287,7 +287,7 @@ platform-admin: 为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续: 1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run 执行验收、CDN 防盗链、杀毒扫描。 -2. 补公共题库版本通知/冲突操作台、租户套餐地区/科目/题库范围限制、主题模板系统。 +2. 补公共题库版本通知/运营消息、租户套餐地区/科目/题库范围限制、主题模板系统。 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 补视频商用控制:深度防盗链、动态水印和播放统计。 5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 32c02cb1..6b8457a0 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -76,7 +76,7 @@ | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | -| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API、单条冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、批量接受/保留策略和更完整运营消息 | +| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知和更完整运营消息 | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | | 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 | @@ -99,7 +99,7 @@ 2. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。 3. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。 4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。 -5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询、租户自改冲突保护和单条冲突处理已完成基础闭环;还需版本通知、批量处理和运营后台消息。 +5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需版本通知和运营后台消息。 6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 @@ -119,7 +119,7 @@ 2. 对象存储 PDF 预览、视频深度防盗链、动态水印。 3. 真实数据 dry-run 执行验收、导入抽样校验和性能压测。 4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。 -5. 公共题库版本通知、冲突批量处理、租户确认/跳过策略和运营消息。 +5. 公共题库版本通知、租户确认/跳过策略和运营消息。 ### P2:增强体验 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index ce7c8f67..5930afb2 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -23,7 +23,7 @@ - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 - 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 -- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条选择采纳平台版本或保留本地版本。 +- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本。 - 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。 - 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。 - 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。 @@ -88,9 +88,9 @@ 5. 公共题库和租户授权 - 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。 - - 已完成 `public-banks` worker 自动同步、失败记录、审计、冲突查询 API 和单条冲突处理 API。 + - 已完成 `public-banks` worker 自动同步、失败记录、审计、冲突查询 API 和单条/批量冲突处理 API。 - 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。 - - 继续补生产定时调度、版本通知、批量接受/保留策略和运营后台消息。 + - 继续补生产定时调度、版本通知和运营后台消息。 6. 视频会员控制 - 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。 @@ -212,10 +212,10 @@ ## 推荐下一步顺序 -1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条冲突采纳平台/保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询和导入后复检详情已接第一版;继续补冲突批量处理、角色模板、CRM/分佣。 +1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询和导入后复检详情已接第一版;继续补角色模板、CRM/分佣。 2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 -6. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库版本通知/批量处理。 +6. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库版本通知。 7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index e29d77cf..30c052e8 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`、`POST /api/tenant-content/public-question-banks/sync`、`GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...`、`POST /api/tenant-content/public-question-banks/conflicts/resolve` | +| 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync`、`GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...`、`POST /api/tenant-content/public-question-banks/conflicts/resolve`、`POST /api/tenant-content/public-question-banks/conflicts/resolve-batch` | | 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` | ## 练习访问控制契约 @@ -976,6 +976,7 @@ POST /api/tenant-content/public-question-banks/adopt POST /api/tenant-content/public-question-banks/sync GET /api/tenant-content/public-question-banks/conflicts?adoptionId=... POST /api/tenant-content/public-question-banks/conflicts/resolve +POST /api/tenant-content/public-question-banks/conflicts/resolve-batch ``` 采纳请求: @@ -1044,12 +1045,13 @@ POST /api/tenant-content/public-question-banks/conflicts/resolve - `sync.status=synced`:刷新公共题库列表、题目集合和题目列表。 - `sync.status=conflict` 或 `item.syncStatus=failed`:展示冲突数量和冲突题目,不要把它当系统异常。冲突表示租户已经改过这道采纳题,后端已跳过并保留租户内容。 -- `action=conflict` 的记录可以进入冲突处理区域:展示平台源题 ID、租户目标题 ID、上次平台 hash、当前平台 hash、租户当前 hash。当前后端已支持单条处理,批量接受平台版本/保留租户版本后续补。 +- `action=conflict` 的记录可以进入冲突处理区域:展示平台源题 ID、租户目标题 ID、上次平台 hash、当前平台 hash、租户当前 hash。当前后端已支持单条和批量处理。 - 页面初始化或 worker 后台同步完成后,可以调用 `GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...` 查询最近一次同步状态、`counts` 和 `conflicts`。这个接口只返回当前租户自己的采纳记录,跨租户会返回 `QUESTION_BANK_ADOPTION_NOT_FOUND`。 - 单条冲突处理调用 `POST /api/tenant-content/public-question-banks/conflicts/resolve`,body 为 `{ "adoptionId": "...", "sourceQuestionId": "...", "resolution": "accept_platform | keep_local" }`。`accept_platform` 会把租户副本写成平台当前版本并生成新题目版本;`keep_local` 会记录本地保留决策,同一平台 hash 和本地 hash 后续同步不再反复提示。两种操作都会写审计日志。 +- 批量冲突处理调用 `POST /api/tenant-content/public-question-banks/conflicts/resolve-batch`,body 为 `{ "adoptionId": "...", "sourceQuestionIds": ["..."], "resolution": "accept_platform | keep_local", "limit": 50 }`。后端最多处理 100 条,仍会重新校验租户授权、锁定采纳记录和目标题,逐条写审计;前端只提交当前冲突列表中明确展示给操作者的 source id。 - `QUESTION_BANK_GRANT_NOT_AVAILABLE`:说明 SaaS 套餐/授权已失效,提示联系平台或升级套餐。 - `QUESTION_BANK_ADOPTION_NOT_FOUND`:说明不是当前租户的采纳记录或记录已归档,前端不要跨租户重试。 -- 后续会补版本通知和批量确认策略;当前租户后台可以先提供手动“同步平台更新”按钮,并展示 worker 自动同步后的冲突查询结果和单条处理按钮。 +- 后续会补版本通知和更完整运营消息;当前租户后台可以先提供手动“同步平台更新”按钮,并展示 worker 自动同步后的冲突查询结果、单条处理和批量处理按钮。 ## 登录对接 @@ -1406,7 +1408,7 @@ src/services/learning.ts 练习 session、答题、收藏、错题/收藏复 src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单详情、权益、激活码 src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时 src/services/video.ts 题目视频列表、播放签名 -src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置、公共题库采纳/同步/冲突处理、导入详情/复检 +src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检 src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权 ``` @@ -1424,7 +1426,7 @@ npm run build:taro:h5:platform 下一批前端开发重点: - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。 -- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、单条采纳平台/保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;下一批继续补完整写入表单、冲突批量处理、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。 +- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;下一批继续补完整写入表单、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。 - 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 48e989d7..faddeaa2 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -4233,6 +4233,140 @@ async function testPublicQuestionBankAdoption() { const keptLocalTarget = collectionAfterKeepLocal.items?.find(item => item.id === conflictTarget.id); assert.equal(keptLocalTarget?.content, keepLocalContent, 'keep-local resolution should preserve tenant customized content'); + const batchTargetOne = keptLocalTarget; + const batchTargetTwo = collectionAfterKeepLocal.items?.find(item => item.legacyId === syncedLegacyId); + assert.ok(batchTargetOne?.id && batchTargetTwo?.id, 'batch conflict test needs two adopted tenant question copies'); + + const batchLocalOneContent = `租户批量冲突本地题一 ${Date.now()}`; + const batchLocalTwoContent = `租户批量冲突本地题二 ${Date.now()}`; + await request('/api/tenant-content/questions', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'PATCH', + body: { + questionId: batchTargetOne.id, + createVersion: true, + content: batchLocalOneContent, + options: batchTargetOne.options || [], + correctOptionIndex: batchTargetOne.correctOptionIndex, + correctOptionIndices: batchTargetOne.correctOptionIndices || [], + answerText: batchTargetOne.answerText, + explanation: '租户批量冲突测试本地版本一。', + sourceHash: `tenant-batch-local-one-${Date.now()}`, + }, + }); + await request('/api/tenant-content/questions', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'PATCH', + body: { + questionId: batchTargetTwo.id, + createVersion: true, + content: batchLocalTwoContent, + options: batchTargetTwo.options || [], + correctOptionIndex: batchTargetTwo.correctOptionIndex, + correctOptionIndices: batchTargetTwo.correctOptionIndices || [], + answerText: batchTargetTwo.answerText, + explanation: '租户批量冲突测试本地版本二。', + sourceHash: `tenant-batch-local-two-${Date.now()}`, + }, + }); + + const batchPlatformOneContent = '平台公共题库批量更新:1 + 3 = ?'; + const batchPlatformTwoContent = '平台公共题库批量更新:5 + 6 = ?'; + await request('/api/tenant-content/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'PATCH', + body: { + questionId: ids.question, + createVersion: true, + content: batchPlatformOneContent, + options: [ + { label: 'A', text: '3' }, + { label: 'B', text: '4' }, + ], + correctOptionIndex: 1, + correctOptionIndices: [1], + answerText: '4', + explanation: '平台公共题库批量冲突测试题一。', + sourceHash: `platform-batch-conflict-one-${Date.now()}`, + }, + }); + await request('/api/tenant-content/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'PATCH', + body: { + questionId: sourceQuestion.item.id, + createVersion: true, + content: batchPlatformTwoContent, + options: [ + { label: 'A', text: '11' }, + { label: 'B', text: '12' }, + ], + correctOptionIndex: 0, + correctOptionIndices: [0], + answerText: '11', + explanation: '平台公共题库批量冲突测试题二。', + sourceHash: `platform-batch-conflict-two-${Date.now()}`, + }, + }); + + const batchConflictSync = 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(batchConflictSync.sync?.status, 'conflict', 'batch setup should produce public bank conflicts'); + const batchConflictIds = (batchConflictSync.sync?.results || []) + .filter(item => item.action === 'conflict' && [ids.question, sourceQuestion.item.id].includes(item.sourceQuestionId)) + .map(item => item.sourceQuestionId); + assert.deepEqual(new Set(batchConflictIds), new Set([ids.question, sourceQuestion.item.id]), 'batch setup should produce two expected conflicts'); + + const crossTenantBatchDenied = await request('/api/tenant-content/public-question-banks/conflicts/resolve-batch', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + adoptionId: adopted.item.id, + sourceQuestionIds: [ids.question, sourceQuestion.item.id], + resolution: 'accept_platform', + }, + expectStatus: 404, + }); + assert.equal(crossTenantBatchDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank batch conflict resolution must be tenant isolated'); + + const batchAccepted = await request('/api/tenant-content/public-question-banks/conflicts/resolve-batch', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + adoptionId: adopted.item.id, + sourceQuestionIds: [ids.question, sourceQuestion.item.id], + resolution: 'accept_platform', + }, + }); + assert.equal(batchAccepted.item?.resolution, 'accept_platform', 'batch conflict resolution should expose resolution'); + assert.equal(batchAccepted.item?.processedCount, 2, 'batch conflict resolution should process both conflicts'); + assert.equal(batchAccepted.item?.remainingConflictCount, 0, 'batch conflict resolution should clear all conflicts'); + assert.equal(batchAccepted.item?.syncStatus, 'synced', 'batch conflict resolution should mark adoption synced'); + + const collectionAfterBatchAccept = await request('/api/catalog/question-collections/questions', { + tenantId: PARTNER_TENANT_ID, + userId: false, + query: { collectionId: adopted.item.targetCollectionId, limit: 50 }, + }); + const batchAcceptedOne = collectionAfterBatchAccept.items?.find(item => item.id === batchTargetOne.id); + const batchAcceptedTwo = collectionAfterBatchAccept.items?.find(item => item.id === batchTargetTwo.id); + assert.equal(batchAcceptedOne?.content, batchPlatformOneContent, 'batch accept should update first tenant copy to platform content'); + assert.equal(batchAcceptedTwo?.content, batchPlatformTwoContent, 'batch accept should update second tenant copy to platform content'); + + const conflictListAfterBatchAccept = await request('/api/tenant-content/public-question-banks/conflicts', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + query: { adoptionId: adopted.item.id }, + }); + assert.equal(conflictListAfterBatchAccept.item?.conflictCount, 0, 'batch-resolved conflict list should have no remaining conflicts'); + const mainTenantNotAdopted = await request('/api/tenant-content/public-question-banks', { userId: TENANT_ADMIN_USER_ID, query: { onlyNotAdopted: 'true' },