diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index 54301874..72d2374d 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -48,6 +48,7 @@ import { adoptPublicQuestionBankRoute, publicQuestionBankConflictsRoute, publicQuestionBanksRoute, + resolvePublicQuestionBankConflictRoute, syncPublicQuestionBankRoute, } from './public-banks.js'; import { @@ -82,6 +83,7 @@ export const tenantContentRoutes: RouteDefinition[] = [ ['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], + ['POST', '/api/tenant-content/public-question-banks/conflicts/resolve', resolvePublicQuestionBankConflictRoute], ['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 382c8688..92726c50 100644 --- a/apps/api/src/features/tenant-content/public-banks.ts +++ b/apps/api/src/features/tenant-content/public-banks.ts @@ -70,6 +70,7 @@ interface SyncQuestionResult { sourceHash: string; previousSourceHash: string | null; targetHash: string | null; + resolution?: 'keep_local'; } interface PublicQuestionBankSyncAuth { @@ -263,6 +264,32 @@ function snapshotMap(value: unknown): Record { return result; } +function sourceSnapshotObject(value: unknown) { + const snapshot = objectValue(value); + return { + ...snapshot, + questions: objectValue(snapshot.questions), + }; +} + +function setSourceSnapshotQuestion(snapshot: Record & { questions: Record }, sourceQuestionId: string, sourceHash: string, extra: Record = {}) { + snapshot.questions[sourceQuestionId] = { + ...objectValue(snapshot.questions[sourceQuestionId]), + ...extra, + sourceHash, + syncedAt: new Date().toISOString(), + }; +} + +function lastSyncConflicts(metadata: Record) { + const lastSync = objectValue(metadata.lastSync); + return Array.isArray(lastSync.conflicts) ? lastSync.conflicts.map(item => objectValue(item)) : []; +} + +function resolvedPublicBankConflicts(metadata: Record) { + return objectValue(metadata.resolvedPublicBankConflicts); +} + async function sourceQuestionSnapshots(client: pg.PoolClient, input: { sourceTenantId: string; sourceQuestionBankId: string; @@ -336,6 +363,7 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: { targetCollectionId: string; source: SourceQuestionSnapshot; previousSourceHash: string | null; + conflictResolutions?: Record; order: number; }) { const legacyId = `public:${input.sourceTenantId}:${input.source.id}`; @@ -360,6 +388,22 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: { const targetMatchesCurrent = !!targetHash && targetHash === input.source.sourceHash; const targetMatchesPrevious = !!targetHash && !!input.previousSourceHash && targetHash === input.previousSourceHash; if (!targetMatchesCurrent && !targetMatchesPrevious) { + const resolved = objectValue(input.conflictResolutions?.[input.source.id]); + if ( + resolved.decision === 'keep_local' + && resolved.sourceHash === input.source.sourceHash + && resolved.targetHash === targetHash + ) { + return { + sourceQuestionId: input.source.id, + targetQuestionId: existingQuestion.id, + action: 'skipped', + sourceHash: input.source.sourceHash, + previousSourceHash: input.previousSourceHash, + targetHash, + resolution: 'keep_local', + } satisfies SyncQuestionResult; + } return { sourceQuestionId: input.source.id, targetQuestionId: existingQuestion.id, @@ -568,6 +612,7 @@ async function syncQuestionsSnapshot(client: pg.PoolClient, input: { targetCollectionId: string; copyLimit: number; previousSnapshot?: unknown; + conflictResolutions?: Record; }) { const sources = await sourceQuestionSnapshots(client, input); const previous = snapshotMap(input.previousSnapshot); @@ -583,6 +628,7 @@ async function syncQuestionsSnapshot(client: pg.PoolClient, input: { targetCollectionId: input.targetCollectionId, source, previousSourceHash: previous[source.id] || null, + conflictResolutions: input.conflictResolutions, order, }); results.push(result); @@ -952,6 +998,7 @@ export async function executePublicQuestionBankSync(input: PublicQuestionBankSyn targetCollectionId: adoption.targetCollectionId, copyLimit, previousSnapshot: adoption.sourceSnapshot, + conflictResolutions: resolvedPublicBankConflicts(adoption.metadata || {}), }); const conflicts = syncResult.results.filter(item => item.action === 'conflict'); const syncStatus = conflicts.length ? 'failed' : 'synced'; @@ -1114,3 +1161,266 @@ export async function publicQuestionBankConflictsRoute(ctx: RequestContext) { }, }; } + +export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx); + const adoptionId = requiredString(body, 'adoptionId'); + const sourceQuestionId = requiredString(body, 'sourceQuestionId'); + const resolution = requiredString(body, 'resolution'); + if (!['accept_platform', 'keep_local'].includes(resolution)) { + throw new HttpError(400, 'resolution must be accept_platform or keep_local', 'PUBLIC_BANK_CONFLICT_RESOLUTION_INVALID'); + } + + const item = 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 <> 'archived' + 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.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 conflict = lastConflicts.find(item => item.sourceQuestionId === sourceQuestionId); + if (!conflict) { + throw new HttpError(404, 'Public question bank conflict not found', 'PUBLIC_BANK_CONFLICT_NOT_FOUND'); + } + const targetQuestionId = nullableString(conflict.targetQuestionId); + if (!targetQuestionId) { + throw new HttpError(409, 'Conflict has no target question', 'PUBLIC_BANK_CONFLICT_TARGET_MISSING'); + } + + 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 source = (await sourceQuestionSnapshots(client, { + sourceTenantId: grant.sourceTenantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + copyLimit: 1000, + })).find(question => question.id === 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], + ); + if (!target.rows[0]) throw new HttpError(404, 'Target question not found', 'PUBLIC_BANK_TARGET_QUESTION_NOT_FOUND'); + + const snapshot = sourceSnapshotObject(adoption.sourceSnapshot); + const metadata: Record = { + ...(adoption.metadata || {}), + resolvedPublicBankConflicts: { + ...resolvedPublicBankConflicts(adoption.metadata || {}), + }, + }; + const resolved = objectValue(metadata.resolvedPublicBankConflicts); + + if (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: target.rows[0].source_hash, + resolvedAt: new Date().toISOString(), + resolvedBy: auth.userId, + }; + } + + const remainingConflicts = lastConflicts.filter(item => item.sourceQuestionId !== sourceQuestionId); + const lastSync = { + ...objectValue((adoption.metadata || {}).lastSync), + status: remainingConflicts.length ? 'failed' : 'resolved', + conflictCount: remainingConflicts.length, + conflicts: remainingConflicts, + lastResolution: { + sourceQuestionId, + resolution, + resolvedAt: new Date().toISOString(), + }, + }; + 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, + ], + ); + + 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, + targetQuestionId, + resolution, + sourceHash: source.sourceHash, + previousTargetHash: target.rows[0].source_hash, + remainingConflictCount: remainingConflicts.length, + }), + ], + ); + + return { + adoption: updated.rows[0], + sourceQuestionId, + targetQuestionId, + resolution, + remainingConflictCount: remainingConflicts.length, + syncStatus: updated.rows[0].syncStatus, + }; + }); + + return { item }; +} diff --git a/apps/taro/README.md b/apps/taro/README.md index 6f53a042..1a1ed95c 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 27e1c081..f8609938 100644 --- a/apps/taro/src/pages/tenant-admin/content/index.tsx +++ b/apps/taro/src/pages/tenant-admin/content/index.tsx @@ -13,6 +13,7 @@ import { loadPublicQuestionBankConflicts, loadPublicQuestionBanks, previewContentImport, + resolvePublicQuestionBankConflict, runImportPostCheck, syncPublicQuestionBank, type ContentEntryAdminItem, @@ -23,6 +24,8 @@ import { type ImportTemplateItem, type ImportType, type ImportSourceFormat, + type PublicQuestionBankConflictItem, + type PublicQuestionBankConflictsResult, type PublicQuestionBankItem, } from '@/services/tenantAdmin'; import '../admin.css'; @@ -100,6 +103,10 @@ function safeJsonPreview(value: unknown, maxLength = 360) { } } +function conflictItems(value: PublicQuestionBankConflictsResult | null) { + return Array.isArray(value?.conflicts) ? value.conflicts : []; +} + function objectRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } @@ -131,7 +138,7 @@ export default function TenantContentPage() { const [template, setTemplate] = useState(null); const [previewResult, setPreviewResult] = useState(null); const [postCheck, setPostCheck] = useState | null>(null); - const [conflicts, setConflicts] = useState | null>(null); + const [conflicts, setConflicts] = useState(null); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); @@ -393,6 +400,38 @@ export default function TenantContentPage() { } } + async function resolveConflict(item: PublicQuestionBankConflictItem, resolution: 'accept_platform' | 'keep_local') { + const adoptionId = conflicts?.adoptionId; + if (!adoptionId || !item.sourceQuestionId) { + setError('缺少冲突上下文,无法处理。'); + return; + } + const ok = await confirm( + resolution === 'accept_platform' ? '采纳平台版本' : '保留租户版本', + resolution === 'accept_platform' + ? '确认用平台公共题库的新版本覆盖该租户副本?系统会写入新题目版本并记录审计。' + : '确认保留租户本地改写版本?系统会记录该冲突已处理,后续同步不再反复提示同一版本。', + ); + if (!ok) return; + setBusy(`resolve:${item.sourceQuestionId}:${resolution}`); + setError(''); + try { + await resolvePublicQuestionBankConflict({ + adoptionId, + sourceQuestionId: item.sourceQuestionId, + resolution, + }); + 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; @@ -564,10 +603,24 @@ export default function TenantContentPage() { 同步冲突 {conflicts ? ( - - 状态 {String(conflicts.status || conflicts.syncStatus || '-')} - 冲突数 {String(conflicts.conflictCount || 0)} · 最近同步 {String(conflicts.lastSyncedAt || '')} - {JSON.stringify(conflicts).slice(0, 300)} + + + 状态 {String(conflicts.status || conflicts.syncStatus || '-')} + 冲突数 {String(conflicts.conflictCount || 0)} · 最近同步 {String(conflicts.lastSyncedAt || '')} + 采纳平台会覆盖为平台新版;保留本地会记录本次冲突已处理。 + + {conflictItems(conflicts).slice(0, 20).map((item, index) => ( + + 题目冲突 {String(item.sourceQuestionId || '').slice(0, 8)} + 目标 {String(item.targetQuestionId || '-').slice(0, 8)} · 平台 {String(item.sourceHash || '').slice(0, 10)} · 本地 {String(item.targetHash || '').slice(0, 10)} + 上次平台 {String(item.previousSourceHash || '-').slice(0, 10)} + + + + + + ))} + {!conflictItems(conflicts).length ? 当前没有待处理冲突。 : null} ) : 同步后或点击“冲突”可查看最近冲突摘要。} diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index ae1d8d6d..4aa99f43 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -85,6 +85,26 @@ export interface PublicQuestionBankItem { adoption?: Record | null; } +export interface PublicQuestionBankConflictItem { + sourceQuestionId?: string; + targetQuestionId?: string | null; + action?: string; + sourceHash?: string; + previousSourceHash?: string | null; + targetHash?: string | null; +} + +export interface PublicQuestionBankConflictsResult { + adoptionId?: string; + syncStatus?: string; + status?: string; + lastSyncedAt?: string | null; + updatedAt?: string; + conflictCount?: number; + counts?: Record; + conflicts?: PublicQuestionBankConflictItem[]; +} + export interface ImportIssueItem { id: string; rowNo?: number | null; @@ -250,11 +270,18 @@ export async function syncPublicQuestionBank(input: { adoptionId: string; copyLi } export async function loadPublicQuestionBankConflicts(adoptionId: string) { - return apiRequest<{ item?: Record }>('/api/tenant-content/public-question-banks/conflicts', { + return apiRequest<{ item?: PublicQuestionBankConflictsResult }>('/api/tenant-content/public-question-banks/conflicts', { query: { adoptionId }, }); } +export async function resolvePublicQuestionBankConflict(input: { adoptionId: string; sourceQuestionId: string; resolution: 'accept_platform' | 'keep_local' }) { + return apiRequest<{ item?: Record }>('/api/tenant-content/public-question-banks/conflicts/resolve', { + 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 bb746b53..3b06a144 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`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,租户自改题目会标记冲突并跳过,后台可查询最近一次冲突明细 | +| 租户采纳/同步公共题库 | 可联调 | `/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/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 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录和租户自改冲突保护;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、冲突不覆盖和 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、字段映射覆盖白名单、导入后复检已接入;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 94e65d1b..1d3ebd64 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、班级/教师/学生范围权限 | 前端权限 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、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、字段别名编辑和同步/异步执行导入第一版 | 异步 job 轮询、模板文件下载按钮、复检结果详情、真实数据 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 ead3762c..d9ce8ba3 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/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` | +| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`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/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` | -当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、导入问题查看、字段映射/模板预览、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检。下一批需要继续补完整后台写入表单、异步导入轮询、模板文件下载按钮、复检结果详情、学生批量导入、角色模板配置 UI 和权限驱动菜单。 +当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、单条采纳平台版本/保留本地版本、导入问题查看、字段映射/模板预览、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检。下一批需要继续补完整后台写入表单、异步导入轮询、模板文件下载按钮、复检结果详情、冲突批量处理、学生批量导入、角色模板配置 UI 和权限驱动菜单。 ## 已落地的 Taro 平台后台页面 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 8c3884e8..a2f370ba 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -131,6 +131,7 @@ tenant-content: POST /api/tenant-content/public-question-banks/adopt POST /api/tenant-content/public-question-banks/sync GET /api/tenant-content/public-question-banks/conflicts + POST /api/tenant-content/public-question-banks/conflicts/resolve GET /api/tenant-content/content-nodes PUT /api/tenant-content/content-nodes GET /api/tenant-content/question-collections diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 6f5fc8c5..0600f91b 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、冲突查询和租户自改冲突保护已完成基础闭环;还需版本通知、冲突处理操作台和运营后台 UI。 +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 eac1f068..80d49518 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。 + - 已完成 `public-banks` worker 自动同步、失败记录、审计、冲突查询 API 和单条冲突处理 API。 - 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。 - - 继续补生产定时调度、版本通知、冲突处理操作台、批量接受/保留策略和运营后台 UI。 + - 继续补生产定时调度、版本通知、批量接受/保留策略和运营后台消息。 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 8a91dc89..d420317e 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=...` | +| 公共题库采纳/同步 | `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/exports/questions`、`GET /api/tenant-content/exports/jobs` | ## 练习访问控制契约 @@ -975,6 +975,7 @@ 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 ``` 采纳请求: @@ -1043,11 +1044,12 @@ GET /api/tenant-content/public-question-banks/conflicts?adoptionId=... - `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 后续同步不再反复提示。两种操作都会写审计日志。 - `QUESTION_BANK_GRANT_NOT_AVAILABLE`:说明 SaaS 套餐/授权已失效,提示联系平台或升级套餐。 - `QUESTION_BANK_ADOPTION_NOT_FOUND`:说明不是当前租户的采纳记录或记录已归档,前端不要跨租户重试。 -- 后续会补版本通知、冲突操作台和批量确认策略;当前租户后台可以先提供手动“同步平台更新”按钮,并展示 worker 自动同步后的冲突查询结果。 +- 后续会补版本通知和批量确认策略;当前租户后台可以先提供手动“同步平台更新”按钮,并展示 worker 自动同步后的冲突查询结果和单条处理按钮。 ## 登录对接 @@ -1404,7 +1406,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 平台后台租户、套餐账单、用量、公共题库授权 ``` @@ -1422,7 +1424,7 @@ npm run build:taro:h5:platform 下一批前端开发重点: - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。 -- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、导入问题、模板预览和导入后复检第一版;下一批继续补完整写入表单、导入上传 preview/import 操作台、字段映射编辑、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。 +- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、单条采纳平台/保留本地、导入问题、模板预览和导入后复检第一版;下一批继续补完整写入表单、导入上传 preview/import 操作台、字段映射编辑、冲突批量处理、学生批量导入、角色模板配置 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 89c39ba8..893a9565 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -4088,6 +4088,126 @@ async function testPublicQuestionBankAdoption() { 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 crossTenantResolveDenied = await request('/api/tenant-content/public-question-banks/conflicts/resolve', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + adoptionId: adopted.item.id, + sourceQuestionId: ids.question, + resolution: 'accept_platform', + }, + expectStatus: 404, + }); + assert.equal(crossTenantResolveDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank conflict resolution must be tenant isolated'); + + const acceptedConflict = await request('/api/tenant-content/public-question-banks/conflicts/resolve', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + adoptionId: adopted.item.id, + sourceQuestionId: ids.question, + resolution: 'accept_platform', + }, + }); + assert.equal(acceptedConflict.item?.resolution, 'accept_platform', 'tenant admin should accept platform version for a conflict'); + assert.equal(acceptedConflict.item?.remainingConflictCount, 0, 'accepting the only conflict should clear conflict count'); + assert.equal(acceptedConflict.item?.syncStatus, 'synced', 'accepted conflict should mark adoption synced when no conflicts remain'); + + const collectionAfterAccept = await request('/api/catalog/question-collections/questions', { + tenantId: PARTNER_TENANT_ID, + userId: false, + query: { collectionId: adopted.item.targetCollectionId, limit: 50 }, + }); + const acceptedTarget = collectionAfterAccept.items?.find(item => item.id === conflictTarget.id); + assert.equal(acceptedTarget?.content, '平台公共题库更新:1 + 1 = ?', 'accepting platform conflict should update tenant copy to platform content'); + + const conflictListAfterAccept = 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(conflictListAfterAccept.item?.conflictCount, 0, 'resolved conflict list should have no remaining conflicts'); + + const keepLocalContent = `租户再次保留本地公共题副本 ${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: keepLocalContent, + options: acceptedTarget.options || [], + correctOptionIndex: acceptedTarget.correctOptionIndex, + correctOptionIndices: acceptedTarget.correctOptionIndices || [], + answerText: acceptedTarget.answerText, + explanation: '租户这一次选择保留本地版本。', + sourceHash: `tenant-keep-local-${Date.now()}`, + }, + }); + + const platformSecondChangedHash = `platform-source-update-keep-local-${Date.now()}`; + await request('/api/tenant-content/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'PATCH', + body: { + questionId: ids.question, + createVersion: true, + content: '平台公共题库二次更新:1 + 2 = ?', + options: [ + { label: 'A', text: '2' }, + { label: 'B', text: '3' }, + ], + correctOptionIndex: 1, + correctOptionIndices: [1], + answerText: '3', + explanation: '平台公共题库发布了第二个新版本。', + sourceHash: platformSecondChangedHash, + }, + }); + + const secondConflictSync = 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(secondConflictSync.sync?.status, 'conflict', 'second platform update should conflict with tenant-local edit'); + + const keptLocalConflict = await request('/api/tenant-content/public-question-banks/conflicts/resolve', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + adoptionId: adopted.item.id, + sourceQuestionId: ids.question, + resolution: 'keep_local', + }, + }); + assert.equal(keptLocalConflict.item?.resolution, 'keep_local', 'tenant admin should keep local version for a conflict'); + assert.equal(keptLocalConflict.item?.syncStatus, 'synced', 'keeping the only conflict should mark adoption synced'); + + const afterKeepLocalSync = 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(afterKeepLocalSync.sync?.status, 'synced', 'future sync should not repeat a keep-local conflict for the same platform hash'); + assert.ok( + afterKeepLocalSync.sync?.results?.some(item => item.sourceQuestionId === ids.question && item.action === 'skipped' && item.resolution === 'keep_local'), + 'future sync should expose keep-local skip decision', + ); + + const collectionAfterKeepLocal = await request('/api/catalog/question-collections/questions', { + tenantId: PARTNER_TENANT_ID, + userId: false, + query: { collectionId: adopted.item.targetCollectionId, limit: 50 }, + }); + const keptLocalTarget = collectionAfterKeepLocal.items?.find(item => item.id === conflictTarget.id); + assert.equal(keptLocalTarget?.content, keepLocalContent, 'keep-local resolution should preserve tenant customized content'); + const mainTenantNotAdopted = await request('/api/tenant-content/public-question-banks', { userId: TENANT_ADMIN_USER_ID, query: { onlyNotAdopted: 'true' },