feat: add question export foundation

This commit is contained in:
Codex
2026-06-29 09:05:42 +08:00
parent 35cff06df7
commit f63f8491f6
11 changed files with 901 additions and 22 deletions

View File

@@ -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 待接。
- 题库导出目前完成服务端结构化 payloadPDF/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/冲突操作台、积分活动深化,以及排行榜防刷/预聚合。

View File

@@ -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<string, unknown> = {};
for (const [key, nested] of Object.entries(item as Record<string, unknown>)) {
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<string, unknown> = {
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<typeof formatQuestion>[]) {
const groups = new Map<string, { key: string; title: string; questions: ReturnType<typeof formatQuestion>[]; 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<typeof formatQuestion>[];
options: Record<string, unknown>;
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<ExportScope> {
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<ExportQuestionRow>(
`
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<string, unknown> : {};
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 };
}

View File

@@ -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],

View File

@@ -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 做定时同步、失败重试、版本升级通知、冲突操作台和批量确认/跳过 |
## 当前验证

View File

@@ -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 择校上下文 |

View File

@@ -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

View File

@@ -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、每日一练运营工具。

View File

@@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -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:<tenantId>: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": "<questionCollectionId>",
"format": "json",
"exportType": "questions",
"includeAnswers": false,
"includeExplanations": false,
"options": {
"title": "天津专升本题库导出"
}
}
```
试卷 payload 导出:
```json
{
"scopeType": "content_node",
"scopeId": "<contentNodeId>",
"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。
## 公共题库采纳对接
平台超级管理员后台使用:

View File

@@ -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 },

View File

@@ -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();