diff --git a/README.md b/README.md index 58a0e14c..1f5c6d45 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖。 +- 题库导出基础能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json` 和打印 payload,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 - `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 @@ -30,6 +31,7 @@ - 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 +- 题库导出目前完成服务端结构化 payload;PDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库自动同步 worker 和冲突操作台还没完成。 - Taro 跨端前端还没开始 scaffold。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -176,7 +178,7 @@ apps/api/src/features/ scoreline/ 分数线 tenant/ 租户解析 tenant-admin/ 租户后台配置、成员权限、班级学生、活动、勋章和审计 - tenant-content/ 租户内容导航、题库维护、资源管理、批量导入 + tenant-content/ 租户内容导航、题库维护、资源管理、批量导入和题库导出 video/ 题目视频讲解 ``` @@ -195,6 +197,7 @@ API 身份上下文: - 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。 - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。 +- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏。 - 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。 ## 最近一次验证 @@ -214,5 +217,5 @@ npm run check:refactor 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。 -4. 题库导出 PDF/Word/JSON、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 +4. 题库导出 PDF/Word worker、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 5. 微信网页/QQ 登录、完整资金流水对账、公共题库自动同步 worker/冲突操作台、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/tenant-content/exports.ts b/apps/api/src/features/tenant-content/exports.ts new file mode 100644 index 00000000..e1bd8893 --- /dev/null +++ b/apps/api/src/features/tenant-content/exports.ts @@ -0,0 +1,594 @@ +import type pg from 'pg'; +import { createHash } from 'node:crypto'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; +import { query, transaction } from '../../core/db.js'; +import { requireTenantContentEditor, type TenantContentAuth } from './auth.js'; +import { boolValue, jsonObjectValue, nullableString } from './utils.js'; + +const EXPORT_FORMATS = ['json', 'paper_json', 'print_payload']; +const EXPORT_TYPES = ['questions', 'paper', 'daily_practice']; +const SCOPE_TYPES = ['collection', 'entry', 'content_node']; +const SUB_QUESTION_ANSWER_KEYS = new Set([ + 'correctOptionIndex', + 'correct_option_index', + 'correctOptionIndices', + 'correct_option_indices', + 'answerText', + 'answer_text', + 'answer', + 'correctAnswer', + 'correct_answer', + 'referenceAnswer', + 'reference_answer', + 'solution', +]); +const SUB_QUESTION_EXPLANATION_KEYS = new Set(['explanation', 'analysis']); + +interface ExportScope { + scopeType: 'collection' | 'entry' | 'content_node'; + scopeId: string; + regionId: string | null; + entryId: string | null; + entryName: string | null; + contentNodeId: string | null; + contentNodeName: string | null; + collectionId: string | null; + collectionName: string | null; + collectionType: string | null; + durationMinutes: number | null; + totalScore: number | null; +} + +interface ExportQuestionRow { + id: string; + legacyId: string | null; + entryId: string | null; + entryName: string | null; + contentNodeId: string | null; + contentNodeName: string | null; + contentNodePath: string | null; + collectionId: string | null; + collectionName: string | null; + subjectId: string | null; + subjectName: string | null; + categoryId: string | null; + categoryName: string | null; + questionBankId: string | null; + type: string; + typeLabel: string | null; + difficulty: number | null; + tags: unknown[]; + mediaUrl: string | null; + hasVideoExplanation: boolean; + sectionKey: string | null; + score: number | null; + order: number; + versionId: string | null; + 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 | null; +} + +function choose(value: unknown, allowed: string[], fallback: string, code: string) { + const candidate = nullableString(value) || fallback; + if (!allowed.includes(candidate)) { + throw new HttpError(400, `${candidate} is not supported`, code); + } + return candidate; +} + +function base64Json(value: unknown) { + return Buffer.from(JSON.stringify(value, null, 2), 'utf8').toString('base64'); +} + +function contentHash(value: unknown) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function exportLimit(value: unknown) { + const parsed = Number(value ?? 1000); + if (!Number.isFinite(parsed)) return 1000; + return Math.max(1, Math.min(Math.trunc(parsed), 5000)); +} + +function jsonArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function redactSubQuestions(value: unknown, includeAnswers: boolean, includeExplanations: boolean): unknown[] { + if (!Array.isArray(value)) return []; + + return value.map(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const output: Record = {}; + for (const [key, nested] of Object.entries(item as Record)) { + if (!includeAnswers && SUB_QUESTION_ANSWER_KEYS.has(key)) continue; + if (!includeExplanations && SUB_QUESTION_EXPLANATION_KEYS.has(key)) continue; + if (key === 'subQuestions' || key === 'sub_questions') { + output[key] = redactSubQuestions(nested, includeAnswers, includeExplanations); + continue; + } + output[key] = nested; + } + return output; + }); +} + +function formatQuestion(row: ExportQuestionRow, includeAnswers: boolean, includeExplanations: boolean, includeVideoRefs: boolean) { + const question: Record = { + id: row.id, + legacyId: row.legacyId, + type: row.type, + typeLabel: row.typeLabel, + content: row.content, + options: jsonArray(row.options), + difficulty: row.difficulty, + tags: jsonArray(row.tags), + mediaUrl: row.mediaUrl, + sectionKey: row.sectionKey, + score: row.score, + order: row.order, + subjectId: row.subjectId, + subjectName: row.subjectName, + categoryId: row.categoryId, + categoryName: row.categoryName, + entryId: row.entryId, + entryName: row.entryName, + contentNodeId: row.contentNodeId, + contentNodeName: row.contentNodeName, + collectionId: row.collectionId, + collectionName: row.collectionName, + versionId: row.versionId, + sourceHash: row.sourceHash, + }; + if (includeAnswers) { + question.correctOptionIndex = row.correctOptionIndex; + question.correctOptionIndices = jsonArray(row.correctOptionIndices); + question.answerText = row.answerText; + } + if (includeExplanations) { + question.explanation = row.explanation; + } + const subQuestions = redactSubQuestions(row.subQuestions, includeAnswers, includeExplanations); + if (subQuestions.length) { + question.subQuestions = subQuestions; + } + if (row.codeLang || row.codeTemplate) { + question.codeLang = row.codeLang; + question.codeTemplate = row.codeTemplate; + } + if (includeVideoRefs) { + question.hasVideoExplanation = row.hasVideoExplanation; + } + return question; +} + +function groupBySection(questions: ReturnType[]) { + const groups = new Map[]; totalScore: number }>(); + for (const question of questions) { + const key = typeof question.sectionKey === 'string' && question.sectionKey ? question.sectionKey : String(question.type || 'default'); + const title = typeof question.typeLabel === 'string' && question.typeLabel ? question.typeLabel : key; + const group = groups.get(key) || { key, title, questions: [], totalScore: 0 }; + group.questions.push(question); + group.totalScore += typeof question.score === 'number' ? question.score : 0; + groups.set(key, group); + } + return Array.from(groups.values()).map(group => ({ + ...group, + questionCount: group.questions.length, + })); +} + +function buildExportPayload(input: { + auth: TenantContentAuth; + jobId: string; + scope: ExportScope; + exportType: string; + format: string; + includeAnswers: boolean; + includeExplanations: boolean; + includeVideoRefs: boolean; + questions: ReturnType[]; + options: Record; + createdAt: string; +}) { + const sections = groupBySection(input.questions); + const title = typeof input.options.title === 'string' && input.options.title.trim() + ? input.options.title.trim() + : input.scope.collectionName || input.scope.contentNodeName || input.scope.entryName || '题库导出'; + const payload = { + _tikuExport: '3.0', + jobId: input.jobId, + tenantId: input.auth.tenantId, + exportedAt: input.createdAt, + exportType: input.exportType, + format: input.format, + title, + scope: input.scope, + options: { + includeAnswers: input.includeAnswers, + includeExplanations: input.includeExplanations, + includeVideoRefs: input.includeVideoRefs, + ...input.options, + }, + summary: { + questionCount: input.questions.length, + sectionCount: sections.length, + totalScore: sections.reduce((sum, section) => sum + section.totalScore, 0), + durationMinutes: input.scope.durationMinutes, + }, + sections, + questions: input.questions, + }; + + return { + ...payload, + files: [ + { + filename: `${title.replace(/[\\/:*?"<>|]+/g, '_') || 'question-export'}.json`, + mimeType: 'application/json', + encoding: 'base64', + contentBase64: base64Json(payload), + }, + ], + renderHints: { + pdfLayout: input.exportType === 'paper' ? 'paper' : 'practice', + pageSize: 'A4', + answerPlacement: input.includeAnswers ? 'inline_or_appendix' : 'hidden', + frontendRenderer: 'apps/taro admin export renderer', + }, + }; +} + +async function loadScope(client: pg.PoolClient, tenantId: string, scopeType: string, scopeId: string): Promise { + if (scopeType === 'collection') { + const result = await client.query<{ + id: string; + regionId: string | null; + entryId: string | null; + entryName: string | null; + contentNodeId: string | null; + contentNodeName: string | null; + collectionName: string; + collectionType: string; + durationMinutes: number | null; + totalScore: string | null; + }>( + ` + select qc.id, qc.region_id as "regionId", + qc.entry_id as "entryId", ce.name as "entryName", + qc.node_id as "contentNodeId", cn.name as "contentNodeName", + qc.name as "collectionName", qc.collection_type as "collectionType", + qc.duration_minutes as "durationMinutes", qc.total_score::text as "totalScore" + from public.question_collections qc + left join public.content_entries ce on ce.id = qc.entry_id and ce.tenant_id = qc.tenant_id + left join public.content_nodes cn on cn.id = qc.node_id and cn.tenant_id = qc.tenant_id + where qc.tenant_id = $1 and qc.id = $2 and qc.status = 'active' + limit 1 + `, + [tenantId, scopeId], + ); + const row = result.rows[0]; + if (!row) throw new HttpError(404, 'Question collection not found', 'QUESTION_COLLECTION_NOT_FOUND'); + return { + scopeType: 'collection', + scopeId, + regionId: row.regionId, + entryId: row.entryId, + entryName: row.entryName, + contentNodeId: row.contentNodeId, + contentNodeName: row.contentNodeName, + collectionId: row.id, + collectionName: row.collectionName, + collectionType: row.collectionType, + durationMinutes: row.durationMinutes, + totalScore: row.totalScore === null ? null : Number(row.totalScore), + }; + } + + if (scopeType === 'entry') { + const result = await client.query<{ id: string; regionId: string | null; name: string }>( + ` + select id, region_id as "regionId", name + from public.content_entries + where tenant_id = $1 and id = $2 and entry_type = 'question_practice' + limit 1 + `, + [tenantId, scopeId], + ); + const row = result.rows[0]; + if (!row) throw new HttpError(404, 'Content entry not found', 'CONTENT_ENTRY_NOT_FOUND'); + return { + scopeType: 'entry', + scopeId, + regionId: row.regionId, + entryId: row.id, + entryName: row.name, + contentNodeId: null, + contentNodeName: null, + collectionId: null, + collectionName: null, + collectionType: null, + durationMinutes: null, + totalScore: null, + }; + } + + const result = await client.query<{ + id: string; + regionId: string | null; + name: string; + entryId: string; + entryName: string; + }>( + ` + select cn.id, cn.region_id as "regionId", cn.name, + cn.entry_id as "entryId", ce.name as "entryName" + from public.content_nodes cn + join public.content_entries ce on ce.id = cn.entry_id and ce.tenant_id = cn.tenant_id + where cn.tenant_id = $1 and cn.id = $2 + limit 1 + `, + [tenantId, scopeId], + ); + const row = result.rows[0]; + if (!row) throw new HttpError(404, 'Content node not found', 'CONTENT_NODE_NOT_FOUND'); + return { + scopeType: 'content_node', + scopeId, + regionId: row.regionId, + entryId: row.entryId, + entryName: row.entryName, + contentNodeId: row.id, + contentNodeName: row.name, + collectionId: null, + collectionName: null, + collectionType: null, + durationMinutes: null, + totalScore: null, + }; +} + +async function loadQuestions(client: pg.PoolClient, tenantId: string, scopeType: string, scopeId: string, limit: number) { + const params: unknown[] = [tenantId, scopeId, limit]; + let scopeFilter = ''; + let orderBy = 'q.created_at asc'; + if (scopeType === 'collection') { + scopeFilter = 'q.id in (select question_id from public.question_collection_items where tenant_id = $1 and collection_id = $2)'; + orderBy = 'coalesce(ci.sort_order, 0) asc, q.created_at asc'; + } else if (scopeType === 'entry') { + scopeFilter = 'q.entry_id = $2'; + orderBy = 'coalesce(cn.path::text, q.created_at::text) asc, coalesce(ci.sort_order, 0) asc, q.created_at asc'; + } else { + scopeFilter = ` + ( + q.content_node_id = $2 + or q.content_node_id in ( + select child.id + from public.content_nodes root + join public.content_nodes child on child.tenant_id = root.tenant_id and child.path <@ root.path + where root.tenant_id = $1 and root.id = $2 + ) + ) + `; + orderBy = 'coalesce(cn.path::text, q.created_at::text) asc, coalesce(ci.sort_order, 0) asc, q.created_at asc'; + } + + const result = await client.query( + ` + select q.id, q.legacy_id as "legacyId", + q.entry_id as "entryId", ce.name as "entryName", + q.content_node_id as "contentNodeId", cn.name as "contentNodeName", cn.path::text as "contentNodePath", + coalesce(ci.collection_id, q.primary_collection_id) as "collectionId", qc.name as "collectionName", + q.subject_id as "subjectId", s.name as "subjectName", + q.category_id as "categoryId", c.name as "categoryName", + q.question_bank_id as "questionBankId", + q.type, q.type_label as "typeLabel", q.difficulty, q.tags, + q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation", + ci.section_key as "sectionKey", ci.score, coalesce(ci.sort_order, 0) as "order", + v.id as "versionId", v.content, v.options, + v.correct_option_index as "correctOptionIndex", + v.correct_option_indices as "correctOptionIndices", + v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions", + v.code_lang as "codeLang", v.code_template as "codeTemplate", v.source_hash as "sourceHash" + from public.questions q + left join public.question_collection_items ci + on ci.tenant_id = q.tenant_id + and ci.question_id = q.id + and ( + ($4::text = 'collection' and ci.collection_id = $2) + or ($4::text <> 'collection' and ci.collection_id = q.primary_collection_id) + ) + left join public.question_collections qc on qc.id = coalesce(ci.collection_id, q.primary_collection_id) and qc.tenant_id = q.tenant_id + left join public.content_entries ce on ce.id = q.entry_id and ce.tenant_id = q.tenant_id + left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id + left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id + left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id + left join public.question_versions v on v.id = q.current_version_id + where q.tenant_id = $1 + and q.status = 'published' + and ${scopeFilter} + order by ${orderBy} + limit $3 + `, + [...params, scopeType], + ); + return result.rows; +} + +export async function createQuestionExportRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx); + const scopeType = choose(body.scopeType, SCOPE_TYPES, 'collection', 'INVALID_EXPORT_SCOPE') as ExportScope['scopeType']; + const scopeId = requiredString(body, 'scopeId'); + const format = choose(body.format, EXPORT_FORMATS, 'json', 'INVALID_EXPORT_FORMAT'); + const exportType = choose(body.exportType, EXPORT_TYPES, format === 'paper_json' ? 'paper' : 'questions', 'INVALID_EXPORT_TYPE'); + const includeAnswers = boolValue(body.includeAnswers, true); + const includeExplanations = boolValue(body.includeExplanations, includeAnswers); + const includeVideoRefs = boolValue(body.includeVideoRefs, false); + const limit = exportLimit(body.limit); + const options = body.options && typeof body.options === 'object' && !Array.isArray(body.options) ? body.options as Record : {}; + + const result = await transaction(async client => { + const scope = await loadScope(client, auth.tenantId, scopeType, scopeId); + const rows = await loadQuestions(client, auth.tenantId, scopeType, scopeId, limit); + const questions = rows.map(row => formatQuestion(row, includeAnswers, includeExplanations, includeVideoRefs)); + const createdAt = new Date().toISOString(); + + const prePayload = { + tenantId: auth.tenantId, + scope, + exportType, + format, + includeAnswers, + includeExplanations, + includeVideoRefs, + questionCount: questions.length, + questions, + options, + createdAt, + }; + const outputHash = contentHash(prePayload); + + const job = await client.query<{ + id: string; + createdAt: string; + }>( + ` + insert into public.content_export_jobs ( + tenant_id, created_by, export_type, format, scope_type, scope_id, + status, include_answers, include_explanations, include_video_refs, + question_count, summary, options, output_metadata, finished_at + ) + values ( + $1, $2, $3, $4, $5, $6, + 'completed', $7, $8, $9, + $10, $11::jsonb, $12::jsonb, $13::jsonb, now() + ) + returning id, created_at as "createdAt" + `, + [ + auth.tenantId, + auth.userId, + exportType, + format, + scopeType, + scopeId, + includeAnswers, + includeExplanations, + includeVideoRefs, + questions.length, + JSON.stringify({ + questionCount: questions.length, + scopeName: scope.collectionName || scope.contentNodeName || scope.entryName, + format, + exportType, + }), + jsonObjectValue(options), + JSON.stringify({ + outputHash, + delivery: 'inline_payload', + maxQuestionLimit: limit, + }), + ], + ); + + const payload = buildExportPayload({ + auth, + jobId: job.rows[0].id, + scope, + exportType, + format, + includeAnswers, + includeExplanations, + includeVideoRefs, + questions, + options, + createdAt, + }); + + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, 'content.questions.exported', 'content_export_job', $3, $4::jsonb) + `, + [ + auth.tenantId, + auth.userId, + job.rows[0].id, + JSON.stringify({ + scopeType, + scopeId, + format, + exportType, + questionCount: questions.length, + includeAnswers, + includeExplanations, + includeVideoRefs, + outputHash, + }), + ], + ); + + return { + job: { + id: job.rows[0].id, + status: 'completed', + createdAt: job.rows[0].createdAt, + questionCount: questions.length, + outputHash, + }, + export: payload, + }; + }); + + return result; +} + +export async function questionExportJobsRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const scopeType = stringParam(ctx, 'scopeType'); + const scopeId = stringParam(ctx, 'scopeId'); + const limit = intParam(ctx, 'limit', 50, 200); + const params: unknown[] = [auth.tenantId]; + const filters = ['tenant_id = $1']; + if (scopeType) { + if (!SCOPE_TYPES.includes(scopeType)) { + throw new HttpError(400, `${scopeType} is not supported`, 'INVALID_EXPORT_SCOPE'); + } + params.push(scopeType); + filters.push(`scope_type = $${params.length}`); + } + if (scopeId) { + params.push(scopeId); + filters.push(`scope_id = $${params.length}`); + } + params.push(limit); + + const items = await query( + ` + select id, export_type as "exportType", format, scope_type as "scopeType", + scope_id as "scopeId", status, include_answers as "includeAnswers", + include_explanations as "includeExplanations", + include_video_refs as "includeVideoRefs", question_count as "questionCount", + summary, options, output_metadata as "outputMetadata", + error_message as "errorMessage", finished_at as "finishedAt", + created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt" + from public.content_export_jobs + where ${filters.join(' and ')} + order by created_at desc + limit $${params.length} + `, + params, + ); + + return { items }; +} diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index d809bb7b..daa96a5a 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -29,6 +29,10 @@ import { importFieldMappingRoute, importTemplateRoute, } from './import-templates.js'; +import { + createQuestionExportRoute, + questionExportJobsRoute, +} from './exports.js'; import { contentEntriesAdminRoute, contentNodesAdminRoute, @@ -108,6 +112,8 @@ export const tenantContentRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-content/imports/templates', importTemplateRoute], ['POST', '/api/tenant-content/imports/post-check', importPostCheckRoute], ['GET', '/api/tenant-content/imports/post-check', importPostCheckStatusRoute], + ['POST', '/api/tenant-content/exports/questions', createQuestionExportRoute], + ['GET', '/api/tenant-content/exports/jobs', questionExportJobsRoute], ['GET', '/api/tenant-content/videos', videosAdminRoute], ['PUT', '/api/tenant-content/videos', upsertVideoRoute], ['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute], diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index a4ac5f5d..21f8de7c 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -136,6 +136,7 @@ | 数据看板聚合接口 | 可联调 | `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`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动同步,租户自改题目会标记冲突并跳过 | +| 题库导出基础 | 可联调 | `/api/tenant-content/exports/questions`、`/api/tenant-content/exports/jobs`;支持按题目集合、内容入口或分类节点导出 JSON/试卷 payload,后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word 二进制与水印 worker 后续补 | ## 销售、代理、CRM @@ -165,6 +166,7 @@ | 视频 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,支持重试、清锁和审计 | +| 题库导出任务 | 可联调 | `content_export_jobs` 记录导出范围、格式、题量、输出 hash、选项和执行人;当前返回 inline base64 JSON 文件,前端可先下载 `.json` 或交给后续 PDF/Word worker 渲染 | | 公共题库自动同步增强 | 待补齐 | 手动同步 API 已完成;后续需 worker 做定时同步、失败重试、版本升级通知、冲突操作台和批量确认/跳过 | ## 当前验证 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 3d06a294..2d8350b8 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -1,6 +1,6 @@ # 后端进度同步与前端接入路线图 -更新时间:2026-06-22 +更新时间:2026-06-29 这份文档用于在进入 Taro 前端开发前,快速确认新 Supabase/PostgreSQL 后端已经做到哪里、还缺什么、前端应如何接入,以及后续继续开发的优先级。 @@ -9,7 +9,7 @@ 新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界: - 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。 -- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册和资料资源。 +- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。 - 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。 - 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 @@ -23,7 +23,7 @@ | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 | | 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权 | 自动计费、平台审计、公共题库自动同步运营台 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限 | 前端权限 UI、更细的数据范围组合 | -| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照和手动同步 | 专项策略、公共题库自动同步 worker/冲突操作台、排行榜防刷/预聚合 | +| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照和手动同步、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库自动同步 worker/冲突操作台、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 | | 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 | diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 8f777237..77180034 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -4,7 +4,7 @@ ## 当前结论 -当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。 +当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库导出基础、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。 但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API,部分功能只有数据模型和导入映射,部分功能还没有前端/自动化测试闭环。 @@ -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 可联调;公共题库自动同步 worker、版本通知和冲突操作台仍需补齐 | +| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、异步导入 worker、平台公共题库授权、租户采纳快照和手动同步已实现 | 核心 API 集成测试含导航、组卷、导入、导出权限/脱敏、公共题库授权、采纳后组卷、同步新增题和租户自改冲突保护断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳/手动同步、导入后复检、模板下载、字段映射 API 和导出基础可联调;PDF/Word 导出 worker、公共题库自动同步 worker、版本通知和冲突操作台仍需补齐 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | | 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 | @@ -151,6 +151,8 @@ tenant-content: POST /api/tenant-content/imports/videos GET /api/tenant-content/imports GET /api/tenant-content/imports/issues + POST /api/tenant-content/exports/questions + GET /api/tenant-content/exports/jobs GET /api/tenant-content/videos PUT /api/tenant-content/videos POST /api/tenant-content/question-videos diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index d1e0afbc..3e39edf2 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -59,7 +59,7 @@ | 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 | | 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 | | 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载和字段映射 API 已有;字段映射 UI 和复检结果操作台待补 | -| 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 | +| 题库导出 PDF/Word/JSON | 部分覆盖 | 服务端 JSON、`paper_json` 和打印 payload 导出基础已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job 和审计;PDF/Word 二进制、水印、发布到资料下载和导出 worker 待补 | | 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 | | 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 | | 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 | @@ -97,7 +97,7 @@ 1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。 2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 -3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 +3. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。 4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、模板下载和字段映射 API 已补,仍缺前端字段映射 UI 和真实数据 dry-run。 5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步和租户自改冲突保护已完成基础闭环;还需自动同步 worker、版本通知、冲突操作台和运营后台 UI。 6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 @@ -126,4 +126,4 @@ 1. 主题模板、素材库、主题预览/发布。 2. 班级、教师、学生分组和学习督导。 3. AI 择校推荐和 PDF 报告。 -4. 题库导出、试卷生成、每日一练运营工具。 +4. 题库导出二进制生成、试卷排版 worker、每日一练运营工具。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index f8aa0ee6..bc668557 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -17,6 +17,7 @@ - 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。 - 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。 - 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。 +- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。 - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 @@ -76,50 +77,55 @@ 3. 导入体系扩展 - 已完成题目、单词、知识手册、分数线、视频的 CSV/Excel 到规范 JSON 解析适配。 - - 继续补大批量导入异步 worker、重试、导入后校验。 + - 已完成大批量异步导入 worker、重试、导入后复检和审计。 - 模板下载、字段映射 API 和导入后复检已补;继续补字段映射 UI、真实数据 dry-run、导入前抽样校验和复检结果操作台。 -4. 公共题库和租户授权 +4. 题库导出增强 + - 已完成服务端 JSON、`paper_json`、`print_payload` 基础导出和导出 job 台账。 + - 当前前端可先下载 inline base64 JSON,或把 `paper_json` 交给后台打印预览组件渲染。 + - 继续补 PDF/Word 二进制生成 worker、页面水印、导出发布到资料下载、导出模板和操作台。 + +5. 公共题库和租户授权 - 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。 - 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。 - 继续补公共题库自动同步 worker、同步失败重试、版本通知、冲突操作台和运营后台 UI。 -5. 视频会员控制 +6. 视频会员控制 - 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。 - 继续补深度防盗链、动态水印、播放统计。 - 单题视频和通用知识视频混合推荐。 -6. 学习统计 +7. 学习统计 - 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。 - 已完成单词复习算法、每日计划和复习上报。 - 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。 - 继续补断点续练和复盘体验。 -7. 订单和营销体验 +8. 订单和营销体验 - 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。 - 已完成支付/退款补偿 worker;继续补完整资金流水对账、异常订单运营台、优惠券核销报表和复杂活动规则。 -8. 积分和反馈增强 +9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 - 已完成勋章后台维护、手动发放和学生端展示。 - 继续补积分兑换、活动任务、连续签到奖励配置、勋章自动发放、处理通知和反馈聚合统计。 -9. 数据看板 +10. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 - 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。 - 分佣结算基础闭环已完成;继续补销售/代理转化、结算导出、真实打款、凭证和客资跟进效果看板。 -10. 销售/代理分佣 +11. 销售/代理分佣 - 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。 - 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。 - 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、租户隔离和销售/代理本人范围权限。 - 继续补结算导出、真实打款 provider、发票/凭证、财务复核流、销售团队分佣看板和异常调整单。 -11. 学生运营管理 +12. 学生运营管理 - 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。 - 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。 -12. AI 择校推荐 +13. AI 择校推荐 - 地区考试数据上下文。 - 学生输入 schema。 - AI 返回 JSON schema。 @@ -206,5 +212,5 @@ 2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。 -5. 并行补对象存储、真实登录、完整资金流水对账、题库导出和公共题库自动同步 worker/冲突操作台。 +5. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库自动同步 worker/冲突操作台。 6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 43c1cb48..dfc6389a 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -1,6 +1,6 @@ # Taro 前端对接指南 -更新时间:2026-06-28 +更新时间:2026-06-29 目标:用一套 Taro 工程同时服务微信小程序和 H5 Web 题库,并采用“Supabase Auth/JWT + `apps/api` 业务 API 优先”的混合架构,支持多租户、品牌主题、地区题库、会员权益、销售追踪和对象存储资源。 @@ -185,6 +185,7 @@ tenant::theme | 租户反馈处理 | `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` | +| 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` | ## 练习访问控制契约 @@ -817,6 +818,105 @@ failed:导入结果和目标表不一致,必须提示管理员排查,不 - 绑定题目必须提供 `questionId` 或 `legacyQuestionId`。 - 生产建议把私有视频先入 `content_assets`,导入时传 `assetId`,避免长期暴露源站 URL。 +## 题库导出对接 + +租户后台题库导出统一走后端生成结构化 payload,前端不要直接查 Supabase 表拼导出文件。当前后端已支持 JSON、`paper_json` 和 `print_payload` 三类基础导出,适合先做后台“导出 JSON/试卷预览”功能;PDF/Word 二进制、水印和发布到资料下载后续由 worker 增强。 + +可用接口: + +```text +POST /api/tenant-content/exports/questions +GET /api/tenant-content/exports/jobs +``` + +导出范围: + +| scopeType | scopeId | 用途 | +| --- | --- | --- | +| `collection` | `question_collections.id` | 导出某个题目列表或试卷集合 | +| `entry` | `content_entries.id` | 导出某个题库入口下全部已发布题目 | +| `content_node` | `content_nodes.id` | 导出某个分类节点及其子节点下全部已发布题目 | + +普通题库 JSON 导出: + +```json +{ + "scopeType": "collection", + "scopeId": "", + "format": "json", + "exportType": "questions", + "includeAnswers": false, + "includeExplanations": false, + "options": { + "title": "天津专升本题库导出" + } +} +``` + +试卷 payload 导出: + +```json +{ + "scopeType": "content_node", + "scopeId": "", + "format": "paper_json", + "exportType": "paper", + "includeAnswers": true, + "includeExplanations": true, + "options": { + "title": "全真模拟试卷", + "durationMinutes": 120, + "watermarkText": "仅供内部使用" + } +} +``` + +响应关键结构: + +```json +{ + "job": { + "id": "...", + "status": "completed", + "questionCount": 1, + "outputHash": "..." + }, + "export": { + "_tikuExport": "3.0", + "jobId": "...", + "summary": { + "questionCount": 1, + "sectionCount": 1 + }, + "sections": [], + "questions": [], + "files": [ + { + "filename": "天津专升本题库导出.json", + "mimeType": "application/json", + "encoding": "base64", + "contentBase64": "..." + } + ], + "renderHints": { + "pdfLayout": "paper", + "pageSize": "A4", + "answerPlacement": "inline_or_appendix" + } + } +} +``` + +前端处理规则: + +- 导出按钮只给具备租户内容编辑权限的后台成员展示;接口仍以后端 `TENANT_CONTENT_EDITOR_REQUIRED` 为准。 +- 下载 JSON 时使用 `files[0].contentBase64` 生成 Blob,文件名使用后端返回的 `filename`。 +- `includeAnswers=false` 时,顶层答案字段和阅读理解/案例分析的子题答案都会被后端脱敏;前端不要在本地重新合并答案。 +- `includeExplanations=false` 时,不展示解析,也不要从题目详情接口额外补解析。 +- `paper_json` 可先用于后台试卷预览和打印;正式 PDF/Word 导出等后端 worker 完成后再接二进制文件下载。 +- `GET /api/tenant-content/exports/jobs?scopeType=collection&scopeId=...` 用于后台导出历史;当前记录 metadata 和输出 hash,不长期保存完整导出内容。 +- 跨租户导出会返回 404 或 403,前端不要重试其它租户 ID。 + ## 公共题库采纳对接 平台超级管理员后台使用: diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index cbfa7f7b..15d7d570 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -2253,6 +2253,16 @@ async function testTenantContentAdmin() { correctOptionIndices: [1], answerText: '4', explanation: '基础加法。', + subQuestions: [ + { + type: 'choice', + content: '集成测试子题:1 + 1 = ?', + options: ['1', '2'], + correctOptionIndices: [1], + answerText: '2', + explanation: '子题基础加法。', + }, + ], status: 'published', examMarkers: { schoolName: '集成测试学院', salesIntent: true }, }, @@ -2312,6 +2322,118 @@ async function testTenantContentAdmin() { }); assert.ok(publicCreatedCollections.items?.some(item => item.id === collection.item.id), 'catalog should expose created collection'); + const exportDenied = await request('/api/tenant-content/exports/questions', { + method: 'POST', + body: { scopeType: 'collection', scopeId: collection.item.id }, + expectStatus: 403, + }); + assert.equal(exportDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not export tenant question bank content'); + + const exportNoAnswers = await request('/api/tenant-content/exports/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + scopeType: 'collection', + scopeId: collection.item.id, + format: 'json', + exportType: 'questions', + includeAnswers: false, + includeExplanations: false, + options: { title: '集成测试无答案题库导出' }, + }, + }); + assert.equal(exportNoAnswers.job?.status, 'completed', 'question export should create a completed export job'); + assert.equal(exportNoAnswers.export?.summary?.questionCount, 1, 'question export should include collection questions'); + assert.ok(exportNoAnswers.export?.files?.[0]?.contentBase64, 'question export should return a downloadable JSON payload'); + assert.equal( + Object.hasOwn(exportNoAnswers.export?.questions?.[0] || {}, 'answerText'), + false, + 'question export should omit answers when includeAnswers is false', + ); + assert.equal( + Object.hasOwn(exportNoAnswers.export?.questions?.[0] || {}, 'explanation'), + false, + 'question export should omit explanations when includeExplanations is false', + ); + assert.equal( + Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'correctOptionIndices'), + false, + 'question export should omit sub question answers when includeAnswers is false', + ); + assert.equal( + Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'answerText'), + false, + 'question export should omit sub question answer text when includeAnswers is false', + ); + assert.equal( + Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'explanation'), + false, + 'question export should omit sub question explanations when includeExplanations is false', + ); + const decodedExport = JSON.parse(Buffer.from(exportNoAnswers.export.files[0].contentBase64, 'base64').toString('utf8')); + assert.equal(decodedExport._tikuExport, '3.0', 'question export JSON should use the new export contract'); + assert.equal(decodedExport.questions?.[0]?.content, '集成测试题:2 + 2 = ?', 'question export JSON should include question content'); + + const exportBadLimit = await request('/api/tenant-content/exports/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + scopeType: 'collection', + scopeId: collection.item.id, + limit: 'not-a-number', + }, + }); + assert.equal(exportBadLimit.export?.summary?.questionCount, 1, 'question export should tolerate invalid limit input with a safe default'); + + const paperExport = await request('/api/tenant-content/exports/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + scopeType: 'content_node', + scopeId: childNode.item.id, + format: 'paper_json', + exportType: 'paper', + includeAnswers: true, + includeExplanations: true, + options: { + title: '集成测试试卷版导出', + durationMinutes: 90, + watermarkText: '仅供内部测试', + }, + }, + }); + assert.equal(paperExport.export?.renderHints?.pdfLayout, 'paper', 'paper export should expose paper render hints'); + assert.ok( + paperExport.export?.sections?.some(section => section.questions?.some(item => item.id === question.item.id)), + 'paper export should group the created question by section', + ); + const exportedQuestionWithAnswer = paperExport.export?.questions?.find(item => item.id === question.item.id); + assert.equal(exportedQuestionWithAnswer?.answerText, '4', 'paper export should include answers when requested'); + + const partnerExportDenied = await request('/api/tenant-content/exports/questions', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'POST', + body: { scopeType: 'collection', scopeId: collection.item.id }, + expectStatus: 404, + }); + assert.equal(partnerExportDenied.code, 'QUESTION_COLLECTION_NOT_FOUND', 'question export must not read another tenant collection'); + + const exportJobs = await request('/api/tenant-content/exports/jobs', { + userId: TENANT_ADMIN_USER_ID, + query: { scopeType: 'collection', scopeId: collection.item.id, limit: 10 }, + }); + assert.ok( + exportJobs.items?.some(item => item.id === exportNoAnswers.job.id && item.questionCount === 1), + 'question export jobs should list completed export audit records', + ); + const invalidExportJobsScope = await request('/api/tenant-content/exports/jobs', { + userId: TENANT_ADMIN_USER_ID, + query: { scopeType: 'not_supported' }, + expectStatus: 400, + }); + assert.equal(invalidExportJobsScope.code, 'INVALID_EXPORT_SCOPE', 'question export jobs should validate scope type filters'); + const createdBlueprintSession = await request('/api/learning/practice-sessions', { method: 'POST', body: { userId: USER_ID, blueprintId: blueprint.item.id }, diff --git a/supabase/migrations/202606290015_content_export_jobs.sql b/supabase/migrations/202606290015_content_export_jobs.sql new file mode 100644 index 00000000..1817b3d8 --- /dev/null +++ b/supabase/migrations/202606290015_content_export_jobs.sql @@ -0,0 +1,44 @@ +create table if not exists public.content_export_jobs ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + created_by uuid references public.platform_users(id) on delete set null, + export_type text not null default 'questions' + check (export_type in ('questions', 'paper', 'daily_practice')), + format text not null default 'json' + check (format in ('json', 'paper_json', 'print_payload')), + scope_type text not null + check (scope_type in ('collection', 'entry', 'content_node')), + scope_id uuid not null, + status text not null default 'completed' + check (status in ('completed', 'failed')), + include_answers boolean not null default true, + include_explanations boolean not null default true, + include_video_refs boolean not null default false, + question_count integer not null default 0 check (question_count >= 0), + summary jsonb not null default '{}'::jsonb, + options jsonb not null default '{}'::jsonb, + output_metadata jsonb not null default '{}'::jsonb, + error_message text, + finished_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_content_export_jobs_tenant_created + on public.content_export_jobs(tenant_id, created_at desc); + +create index if not exists idx_content_export_jobs_scope + on public.content_export_jobs(tenant_id, scope_type, scope_id, created_at desc); + +alter table public.content_export_jobs enable row level security; + +drop policy if exists tenant_isolation on public.content_export_jobs; +create policy tenant_isolation on public.content_export_jobs + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); + +drop trigger if exists set_updated_at on public.content_export_jobs; +create trigger set_updated_at + before update on public.content_export_jobs + for each row execute function app.touch_updated_at();