forked from wangziqi/gongxue-base
feat: support daily practice exports
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
||||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
||||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
||||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF 和 Word,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word 由 exports worker 生成水印文件并发布到 `content_assets`。
|
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF 和 Word,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word 由 exports worker 生成水印文件并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata 和基础 PDF/Word 运营版式。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
||||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件。
|
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件。
|
||||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;完整资金流水对账和真实生产账号联调还没接完。
|
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;完整资金流水对账和真实生产账号联调还没接完。
|
||||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。
|
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。
|
||||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||||
- 题库导出已完成服务端结构化 payload 和 PDF/Word 二进制 worker;后续还要补更精细试卷模板、每日一练素材导出和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker 和每日一练基础导出;后续还要补更精细试卷模板、每日一练图片拼图/ZIP 卡片包和导出操作台体验。
|
||||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
||||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||||
@@ -343,5 +343,5 @@ git diff --check
|
|||||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||||
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
||||||
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
|
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
|
||||||
4. 题库导出模板精排、每日一练运营工具、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
4. 题库导出模板精排、每日一练图片拼图/ZIP 卡片包、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||||
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ export interface BuiltExportPayload {
|
|||||||
sectionCount: number;
|
sectionCount: number;
|
||||||
totalScore: number;
|
totalScore: number;
|
||||||
durationMinutes: number | null;
|
durationMinutes: number | null;
|
||||||
|
cardCount?: number;
|
||||||
|
issue?: string;
|
||||||
|
date?: string;
|
||||||
};
|
};
|
||||||
|
dailyPractice?: DailyPracticeMetadata;
|
||||||
sections: Array<{
|
sections: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -117,6 +121,38 @@ export interface BuiltExportPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DailyPracticeBrand {
|
||||||
|
name: string;
|
||||||
|
english: string;
|
||||||
|
slogan: string;
|
||||||
|
ctaLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyPracticeMetadata {
|
||||||
|
issue: string;
|
||||||
|
date: string;
|
||||||
|
theme: string;
|
||||||
|
cardFormat: string;
|
||||||
|
brand: DailyPracticeBrand;
|
||||||
|
showAnswer: boolean;
|
||||||
|
centerSlot: {
|
||||||
|
type: 'cta' | 'image';
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
slots: Array<{
|
||||||
|
slot: number;
|
||||||
|
kind: 'question' | 'center';
|
||||||
|
questionId?: unknown;
|
||||||
|
subjectName?: unknown;
|
||||||
|
typeLabel?: unknown;
|
||||||
|
order?: unknown;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
function choose(value: unknown, allowed: string[], fallback: string, code: string) {
|
function choose(value: unknown, allowed: string[], fallback: string, code: string) {
|
||||||
const candidate = nullableString(value) || fallback;
|
const candidate = nullableString(value) || fallback;
|
||||||
if (!allowed.includes(candidate)) {
|
if (!allowed.includes(candidate)) {
|
||||||
@@ -139,6 +175,11 @@ function exportLimit(value: unknown) {
|
|||||||
return Math.max(1, Math.min(Math.trunc(parsed), 5000));
|
return Math.max(1, Math.min(Math.trunc(parsed), 5000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportLimitForType(exportType: string, value: unknown) {
|
||||||
|
const limit = exportLimit(value);
|
||||||
|
return exportType === 'daily_practice' ? Math.min(limit, 8) : limit;
|
||||||
|
}
|
||||||
|
|
||||||
function isBinaryExportFormat(format: string) {
|
function isBinaryExportFormat(format: string) {
|
||||||
return BINARY_EXPORT_FORMATS.includes(format);
|
return BINARY_EXPORT_FORMATS.includes(format);
|
||||||
}
|
}
|
||||||
@@ -231,6 +272,72 @@ function groupBySection(questions: ReturnType<typeof formatQuestion>[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function todayDateString() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown, fallback: string) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim().slice(0, 120) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyPracticeBrand(options: Record<string, unknown>): DailyPracticeBrand {
|
||||||
|
const brand = objectValue(options.brand);
|
||||||
|
return {
|
||||||
|
name: stringValue(brand.name, '恭学教育'),
|
||||||
|
english: stringValue(brand.english, 'GONGXUE EDU'),
|
||||||
|
slogan: stringValue(brand.slogan, '专注高职升本'),
|
||||||
|
ctaLine: stringValue(brand.ctaLine, '每日一练 · 精选八题 · 稳步上岸'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyPracticeMetadata(input: {
|
||||||
|
questions: ReturnType<typeof formatQuestion>[];
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
includeAnswers: boolean;
|
||||||
|
}): DailyPracticeMetadata {
|
||||||
|
const issue = stringValue(input.options.issue, stringValue(input.options.title, '每日一练'));
|
||||||
|
const date = stringValue(input.options.date, todayDateString());
|
||||||
|
const center = objectValue(input.options.centerSlot);
|
||||||
|
const brand = dailyPracticeBrand(input.options);
|
||||||
|
const questionSlots = [0, 1, 2, 3, 5, 6, 7, 8];
|
||||||
|
const slots: DailyPracticeMetadata['slots'] = [];
|
||||||
|
input.questions.slice(0, 8).forEach((question, index) => {
|
||||||
|
slots.push({
|
||||||
|
slot: questionSlots[index],
|
||||||
|
kind: 'question',
|
||||||
|
questionId: question.id,
|
||||||
|
subjectName: question.subjectName,
|
||||||
|
typeLabel: question.typeLabel,
|
||||||
|
order: index + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
slots.splice(Math.min(4, slots.length), 0, {
|
||||||
|
slot: 4,
|
||||||
|
kind: 'center',
|
||||||
|
title: stringValue(center.title, brand.name),
|
||||||
|
subtitle: stringValue(center.subtitle, brand.ctaLine),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
issue,
|
||||||
|
date,
|
||||||
|
theme: stringValue(input.options.theme, 'default'),
|
||||||
|
cardFormat: stringValue(input.options.cardFormat, '1:1'),
|
||||||
|
brand,
|
||||||
|
showAnswer: input.includeAnswers,
|
||||||
|
centerSlot: {
|
||||||
|
type: stringValue(center.type, 'cta') === 'image' ? 'image' : 'cta',
|
||||||
|
title: stringValue(center.title, brand.name),
|
||||||
|
subtitle: stringValue(center.subtitle, brand.ctaLine),
|
||||||
|
imageUrl: typeof center.imageUrl === 'string' && center.imageUrl.trim() ? center.imageUrl.trim() : null,
|
||||||
|
},
|
||||||
|
slots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildExportPayload(input: {
|
function buildExportPayload(input: {
|
||||||
auth: TenantContentAuth;
|
auth: TenantContentAuth;
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@@ -268,7 +375,23 @@ function buildExportPayload(input: {
|
|||||||
sectionCount: sections.length,
|
sectionCount: sections.length,
|
||||||
totalScore: sections.reduce((sum, section) => sum + section.totalScore, 0),
|
totalScore: sections.reduce((sum, section) => sum + section.totalScore, 0),
|
||||||
durationMinutes: input.scope.durationMinutes,
|
durationMinutes: input.scope.durationMinutes,
|
||||||
|
...(input.exportType === 'daily_practice'
|
||||||
|
? {
|
||||||
|
cardCount: Math.min(input.questions.length, 8),
|
||||||
|
issue: stringValue(input.options.issue, stringValue(input.options.title, '每日一练')),
|
||||||
|
date: stringValue(input.options.date, todayDateString()),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
|
...(input.exportType === 'daily_practice'
|
||||||
|
? {
|
||||||
|
dailyPractice: dailyPracticeMetadata({
|
||||||
|
questions: input.questions,
|
||||||
|
options: input.options,
|
||||||
|
includeAnswers: input.includeAnswers,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
sections,
|
sections,
|
||||||
questions: input.questions,
|
questions: input.questions,
|
||||||
};
|
};
|
||||||
@@ -284,7 +407,7 @@ function buildExportPayload(input: {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
renderHints: {
|
renderHints: {
|
||||||
pdfLayout: input.exportType === 'paper' ? 'paper' : 'practice',
|
pdfLayout: input.exportType === 'daily_practice' ? 'daily_practice' : input.exportType === 'paper' ? 'paper' : 'practice',
|
||||||
pageSize: 'A4',
|
pageSize: 'A4',
|
||||||
answerPlacement: input.includeAnswers ? 'inline_or_appendix' : 'hidden',
|
answerPlacement: input.includeAnswers ? 'inline_or_appendix' : 'hidden',
|
||||||
frontendRenderer: 'apps/taro admin export renderer',
|
frontendRenderer: 'apps/taro admin export renderer',
|
||||||
@@ -606,11 +729,16 @@ export async function createQuestionExportRoute(ctx: RequestContext) {
|
|||||||
const scopeType = choose(body.scopeType, SCOPE_TYPES, 'collection', 'INVALID_EXPORT_SCOPE') as ExportScope['scopeType'];
|
const scopeType = choose(body.scopeType, SCOPE_TYPES, 'collection', 'INVALID_EXPORT_SCOPE') as ExportScope['scopeType'];
|
||||||
const scopeId = requiredString(body, 'scopeId');
|
const scopeId = requiredString(body, 'scopeId');
|
||||||
const format = choose(body.format, EXPORT_FORMATS, 'json', 'INVALID_EXPORT_FORMAT');
|
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 exportType = choose(
|
||||||
|
body.exportType,
|
||||||
|
EXPORT_TYPES,
|
||||||
|
format === 'paper_json' || isBinaryExportFormat(format) ? 'paper' : 'questions',
|
||||||
|
'INVALID_EXPORT_TYPE',
|
||||||
|
);
|
||||||
const includeAnswers = boolValue(body.includeAnswers, true);
|
const includeAnswers = boolValue(body.includeAnswers, true);
|
||||||
const includeExplanations = boolValue(body.includeExplanations, includeAnswers);
|
const includeExplanations = boolValue(body.includeExplanations, includeAnswers);
|
||||||
const includeVideoRefs = boolValue(body.includeVideoRefs, false);
|
const includeVideoRefs = boolValue(body.includeVideoRefs, false);
|
||||||
const limit = exportLimit(body.limit);
|
const limit = exportLimitForType(exportType, body.limit);
|
||||||
const options = body.options && typeof body.options === 'object' && !Array.isArray(body.options) ? body.options as Record<string, unknown> : {};
|
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 result = await transaction(async client => {
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ function watermarkText(payload: BuiltExportPayload) {
|
|||||||
return typeof raw === 'string' && raw.trim() ? raw.trim().slice(0, 80) : '';
|
return typeof raw === 'string' && raw.trim() ? raw.trim().slice(0, 80) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDailyPractice(payload: BuiltExportPayload) {
|
||||||
|
return payload.exportType === 'daily_practice';
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandName(payload: BuiltExportPayload) {
|
||||||
|
return payload.dailyPractice?.brand?.name || '恭学教育';
|
||||||
|
}
|
||||||
|
|
||||||
function findPdfFont() {
|
function findPdfFont() {
|
||||||
return PDF_FONT_CANDIDATES.find(candidate => fs.existsSync(candidate));
|
return PDF_FONT_CANDIDATES.find(candidate => fs.existsSync(candidate));
|
||||||
}
|
}
|
||||||
@@ -161,7 +169,140 @@ function writePdfQuestion(doc: PDFKit.PDFDocument, question: ExportQuestion, ind
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fitText(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
text: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
fontSize = 10,
|
||||||
|
options: PDFKit.Mixins.TextOptions = {},
|
||||||
|
) {
|
||||||
|
const content = plainText(text) || ' ';
|
||||||
|
let nextFontSize = fontSize;
|
||||||
|
while (nextFontSize > 6) {
|
||||||
|
doc.fontSize(nextFontSize);
|
||||||
|
const measured = doc.heightOfString(content, { ...options, width });
|
||||||
|
if (measured <= height) break;
|
||||||
|
nextFontSize -= 0.6;
|
||||||
|
}
|
||||||
|
doc.fontSize(nextFontSize).text(content, x, y, { ...options, width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDailyPracticePdfCard(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
question: ExportQuestion,
|
||||||
|
index: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
payload: BuiltExportPayload,
|
||||||
|
) {
|
||||||
|
const includeAnswers = boolOption(payload, 'includeAnswers');
|
||||||
|
const includeExplanations = boolOption(payload, 'includeExplanations');
|
||||||
|
doc.save();
|
||||||
|
doc.roundedRect(x, y, width, height, 8).fillAndStroke('#ffffff', '#d7dee8');
|
||||||
|
doc.fillColor('#0f172a').fontSize(8).text(`${String(index + 1).padStart(2, '0')} ${question.typeLabel || question.type || '题目'}`, x + 10, y + 10, {
|
||||||
|
width: width - 20,
|
||||||
|
});
|
||||||
|
doc.moveTo(x + 10, y + 25).lineTo(x + width - 10, y + 25).strokeColor('#e2e8f0').stroke();
|
||||||
|
fitText(doc, String(question.content || ''), x + 10, y + 33, width - 20, height * 0.44, 10, { lineGap: 2 });
|
||||||
|
const options = Array.isArray(question.options) ? question.options.slice(0, 4) : [];
|
||||||
|
let optionY = y + height * 0.56;
|
||||||
|
options.forEach((option, optionIndex) => {
|
||||||
|
fitText(doc, optionText(option, optionIndex), x + 12, optionY, width - 24, 16, 9, { lineGap: 1 });
|
||||||
|
optionY += 18;
|
||||||
|
});
|
||||||
|
const answer = includeAnswers ? answerText(question) : '';
|
||||||
|
if (answer) {
|
||||||
|
doc.fillColor('#0f766e').fontSize(7.5).text(`答案:${answer}`, x + 10, y + height - 31, { width: width - 20 });
|
||||||
|
}
|
||||||
|
if (includeExplanations && typeof question.explanation === 'string' && question.explanation.trim()) {
|
||||||
|
doc.fillColor('#64748b').fontSize(7).text(`解析:${plainText(question.explanation).slice(0, 70)}`, x + 10, y + height - 18, { width: width - 20 });
|
||||||
|
}
|
||||||
|
doc.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDailyPracticePdf(payload: BuiltExportPayload): Promise<RenderResult> {
|
||||||
|
const doc = new PDFDocument({ size: 'A4', margin: 36, bufferPages: true, autoFirstPage: true });
|
||||||
|
const done = collectPdf(doc);
|
||||||
|
const fontPath = findPdfFont();
|
||||||
|
if (fontPath) {
|
||||||
|
doc.registerFont('TikuCjk', fontPath);
|
||||||
|
doc.font('TikuCjk');
|
||||||
|
}
|
||||||
|
const watermark = watermarkText(payload) || brandName(payload);
|
||||||
|
renderPdfWatermark(doc, watermark);
|
||||||
|
|
||||||
|
const daily = payload.dailyPractice;
|
||||||
|
const title = daily?.issue || payload.title || '每日一练';
|
||||||
|
doc.rect(0, 0, doc.page.width, doc.page.height).fill('#f6f8fb');
|
||||||
|
renderPdfWatermark(doc, watermark);
|
||||||
|
doc.fillColor('#0f172a').fontSize(22).text(title, 36, 34, { width: doc.page.width - 72, align: 'center' });
|
||||||
|
doc.fillColor('#64748b').fontSize(9).text(
|
||||||
|
`${daily?.date || payload.exportedAt.slice(0, 10)} · ${daily?.brand?.slogan || '专注高职升本'} · ${daily?.brand?.english || 'GONGXUE EDU'}`,
|
||||||
|
36,
|
||||||
|
64,
|
||||||
|
{ width: doc.page.width - 72, align: 'center' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const gap = 10;
|
||||||
|
const gridX = 44;
|
||||||
|
const gridY = 96;
|
||||||
|
const cardW = (doc.page.width - gridX * 2 - gap * 2) / 3;
|
||||||
|
const cardH = (doc.page.height - gridY - 82 - gap * 2) / 3;
|
||||||
|
const questionSlots = [0, 1, 2, 3, 5, 6, 7, 8];
|
||||||
|
payload.questions.slice(0, 8).forEach((question, index) => {
|
||||||
|
const slot = questionSlots[index];
|
||||||
|
const row = Math.floor(slot / 3);
|
||||||
|
const col = slot % 3;
|
||||||
|
writeDailyPracticePdfCard(
|
||||||
|
doc,
|
||||||
|
question,
|
||||||
|
index,
|
||||||
|
gridX + col * (cardW + gap),
|
||||||
|
gridY + row * (cardH + gap),
|
||||||
|
cardW,
|
||||||
|
cardH,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const centerX = gridX + (cardW + gap);
|
||||||
|
const centerY = gridY + (cardH + gap);
|
||||||
|
doc.save();
|
||||||
|
doc.roundedRect(centerX, centerY, cardW, cardH, 8).fillAndStroke('#0f172a', '#0f172a');
|
||||||
|
doc.fillColor('#ffffff').fontSize(17).text(daily?.centerSlot.title || brandName(payload), centerX + 10, centerY + 35, {
|
||||||
|
width: cardW - 20,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
doc.fillColor('#cbd5e1').fontSize(9).text(daily?.centerSlot.subtitle || daily?.brand.ctaLine || '每日一练', centerX + 10, centerY + 72, {
|
||||||
|
width: cardW - 20,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
doc.restore();
|
||||||
|
|
||||||
|
doc.fillColor('#64748b').fontSize(8).text(
|
||||||
|
`${daily?.brand?.name || '恭学教育'} · ${daily?.brand?.ctaLine || '每日一练 · 精选八题 · 稳步上岸'}`,
|
||||||
|
36,
|
||||||
|
doc.page.height - 44,
|
||||||
|
{ width: doc.page.width - 72, align: 'center' },
|
||||||
|
);
|
||||||
|
doc.end();
|
||||||
|
return {
|
||||||
|
body: await done,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
extension: 'pdf',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function renderExportPdf(payload: BuiltExportPayload): Promise<RenderResult> {
|
export async function renderExportPdf(payload: BuiltExportPayload): Promise<RenderResult> {
|
||||||
|
if (isDailyPractice(payload)) {
|
||||||
|
return renderDailyPracticePdf(payload);
|
||||||
|
}
|
||||||
|
|
||||||
const doc = new PDFDocument({ size: 'A4', margin: 48, bufferPages: true, autoFirstPage: true });
|
const doc = new PDFDocument({ size: 'A4', margin: 48, bufferPages: true, autoFirstPage: true });
|
||||||
const done = collectPdf(doc);
|
const done = collectPdf(doc);
|
||||||
const fontPath = findPdfFont();
|
const fontPath = findPdfFont();
|
||||||
@@ -255,6 +396,10 @@ function docxQuestionParagraphs(question: ExportQuestion, index: number, payload
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function renderExportDocx(payload: BuiltExportPayload): Promise<RenderResult> {
|
export async function renderExportDocx(payload: BuiltExportPayload): Promise<RenderResult> {
|
||||||
|
if (isDailyPractice(payload)) {
|
||||||
|
return renderDailyPracticeDocx(payload);
|
||||||
|
}
|
||||||
|
|
||||||
const watermark = watermarkText(payload);
|
const watermark = watermarkText(payload);
|
||||||
const children: Paragraph[] = [
|
const children: Paragraph[] = [
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
@@ -338,3 +483,77 @@ export async function renderExportDocx(payload: BuiltExportPayload): Promise<Ren
|
|||||||
extension: 'docx',
|
extension: 'docx',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderDailyPracticeDocx(payload: BuiltExportPayload): Promise<RenderResult> {
|
||||||
|
const daily = payload.dailyPractice;
|
||||||
|
const title = daily?.issue || payload.title || '每日一练';
|
||||||
|
const children: Paragraph[] = [
|
||||||
|
new Paragraph({
|
||||||
|
heading: HeadingLevel.TITLE,
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: { after: 160 },
|
||||||
|
children: [new TextRun({ text: title, bold: true, font: 'Microsoft YaHei', size: 36 })],
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: { after: 220 },
|
||||||
|
children: [new TextRun({
|
||||||
|
text: `${daily?.date || payload.exportedAt.slice(0, 10)} · ${daily?.brand.slogan || '专注高职升本'} · ${daily?.brand.ctaLine || '每日一练'}`,
|
||||||
|
color: '64748B',
|
||||||
|
font: 'Microsoft YaHei',
|
||||||
|
size: 19,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
payload.questions.slice(0, 8).forEach((question, index) => {
|
||||||
|
children.push(...docxQuestionParagraphs(question, index, payload));
|
||||||
|
});
|
||||||
|
children.push(new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: { before: 220 },
|
||||||
|
children: [new TextRun({
|
||||||
|
text: daily?.brand.name || '恭学教育',
|
||||||
|
bold: true,
|
||||||
|
color: '0F172A',
|
||||||
|
font: 'Microsoft YaHei',
|
||||||
|
size: 24,
|
||||||
|
})],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
creator: 'Tiku SaaS Export Worker',
|
||||||
|
title,
|
||||||
|
description: 'Generated by Tiku SaaS daily practice export worker',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
default: new Header({
|
||||||
|
children: [
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
children: [new TextRun({ text: watermarkText(payload) || brandName(payload), color: 'CBD5E1', font: 'Microsoft YaHei', size: 18 })],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
footers: {
|
||||||
|
default: new Footer({
|
||||||
|
children: [
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
children: [new TextRun({ children: ['第 ', PageNumber.CURRENT, ' / ', PageNumber.TOTAL_PAGES, ' 页'], font: 'Microsoft YaHei', size: 18 })],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: await Packer.toBuffer(doc),
|
||||||
|
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
extension: 'docx',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
||||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |
|
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |
|
||||||
| 题库导出 | 可联调 | `/api/tenant-content/exports/questions`、`/api/tenant-content/exports/jobs`;支持按题目集合、内容入口或分类节点导出 JSON/试卷 payload,也支持 `pdf/docx` 异步导出;后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;`apps/worker --job exports` 负责 PDF/Word 渲染、水印、生成 `content_assets`、记录 hash/size/assetId,前端通过资源签名接口下载/预览 |
|
| 题库导出 | 可联调 | `/api/tenant-content/exports/questions`、`/api/tenant-content/exports/jobs`;支持按题目集合、内容入口或分类节点导出 JSON/试卷 payload,也支持 `pdf/docx` 异步导出;`exportType=daily_practice` 可生成每日一练九宫格运营素材 metadata 和 PDF/Word 基础版式;后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;`apps/worker --job exports` 负责 PDF/Word 渲染、水印、生成 `content_assets`、记录 hash/size/assetId,前端通过资源签名接口下载/预览 |
|
||||||
|
|
||||||
## 销售、代理、CRM
|
## 销售、代理、CRM
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
||||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径 | 长题干/公式图片混排体验、导出模板精排、排行榜防刷/预聚合 |
|
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata 与基础 PDF/Word 运营版式 | 长题干/公式图片混排体验、导出模板精排、每日一练图片包、排行榜防刷/预聚合 |
|
||||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
|
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 |
|
| 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 |
|
||||||
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、异步 PDF/Word job 创建、exports worker、公共题库授权、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题第一版可联调,公共题库采纳/手动/自动同步、同步通知、冲突查询/处理、导入后复检、模板下载、字段映射 API、JSON/PDF/Word 导出可联调;公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 |
|
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、每日一练九宫格 metadata 与基础 PDF/Word 运营版式、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、每日一练导出 metadata、异步 PDF/Word job 创建、exports worker、公共题库授权、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题第一版可联调,公共题库采纳/手动/自动同步、同步通知、冲突查询/处理、导入后复检、模板下载、字段映射 API、JSON/PDF/Word/每日一练基础导出可联调;公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 |
|
||||||
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||||
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||||
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
|
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
||||||
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
||||||
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 |
|
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 |
|
||||||
| 题库导出 PDF/Word/JSON | 部分覆盖 | 服务端 JSON、`paper_json`、打印 payload、PDF 和 Word 异步导出已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job、审计、水印、`content_assets` 发布/下载路径和 exports worker;后续补更精细试卷模板、每日一练素材导出和后台操作台体验 |
|
| 题库导出 PDF/Word/JSON | 部分覆盖 | 服务端 JSON、`paper_json`、打印 payload、PDF 和 Word 异步导出已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job、审计、水印、`content_assets` 发布/下载路径和 exports worker;每日一练九宫格 metadata 与 PDF/Word 基础版式已补;后续补更精细试卷模板、图片拼图/ZIP 卡片包和后台操作台体验 |
|
||||||
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
||||||
| 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 |
|
| 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 |
|
||||||
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
1. 阅读理解/案例分析多小题:后端 `subAnswers` 判分、断点恢复、报告 `subResults` 和 Taro 作答 UI 第一版已完成;仍需优化长题干排版、公式图片混排和复盘体验。
|
1. 阅读理解/案例分析多小题:后端 `subAnswers` 判分、断点恢复、报告 `subResults` 和 Taro 作答 UI 第一版已完成;仍需优化长题干排版、公式图片混排和复盘体验。
|
||||||
2. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
2. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
||||||
3. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
3. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
||||||
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计和答案脱敏已补;仍缺更精细试卷模板、每日一练素材导出和后台导出操作台体验。
|
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏和每日一练基础导出已补;仍缺更精细试卷模板、图片拼图/ZIP 卡片包和后台导出操作台体验。
|
||||||
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
||||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
||||||
7. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
7. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||||
@@ -127,4 +127,4 @@
|
|||||||
1. 主题模板、素材库、主题预览/发布。
|
1. 主题模板、素材库、主题预览/发布。
|
||||||
2. 班级、教师、学生分组和学习督导。
|
2. 班级、教师、学生分组和学习督导。
|
||||||
3. AI 择校推荐和 PDF 报告。
|
3. AI 择校推荐和 PDF 报告。
|
||||||
4. 题库导出模板精排、每日一练运营工具。
|
4. 题库导出模板精排、每日一练图片拼图/ZIP 卡片包和运营操作台。
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||||
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
||||||
- 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。
|
- 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。
|
||||||
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx` 导出任务,渲染 PDF/Word、水印、写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`。
|
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx` 导出任务,渲染 PDF/Word、水印、写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata 和 PDF/Word 基础版式。
|
||||||
- 本地验证:`npm run check:refactor` 已通过。
|
- 本地验证:`npm run check:refactor` 已通过。
|
||||||
|
|
||||||
当前更适合进入前端联调前阅读的总览文档:
|
当前更适合进入前端联调前阅读的总览文档:
|
||||||
@@ -84,8 +84,8 @@
|
|||||||
|
|
||||||
4. 题库导出增强
|
4. 题库导出增强
|
||||||
- 已完成服务端 JSON、`paper_json`、`print_payload` 基础导出和导出 job 台账。
|
- 已完成服务端 JSON、`paper_json`、`print_payload` 基础导出和导出 job 台账。
|
||||||
- 已完成 PDF/Word 二进制生成 worker、页面水印、导出发布到 `content_assets`、后台资源签名下载/预览路径。
|
- 已完成 PDF/Word 二进制生成 worker、页面水印、导出发布到 `content_assets`、后台资源签名下载/预览路径,以及每日一练九宫格 metadata 和 PDF/Word 基础版式。
|
||||||
- 继续补更精细的试卷模板、每日一练素材导出、导出操作台和多模板排版。
|
- 继续补更精细的试卷模板、每日一练图片拼图/ZIP 卡片包、导出操作台和多模板排版。
|
||||||
|
|
||||||
5. 公共题库和租户授权
|
5. 公共题库和租户授权
|
||||||
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
||||||
@@ -220,5 +220,5 @@
|
|||||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||||
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出模板精排/每日一练、公共题库生产定时调度和失败告警。
|
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出模板精排/每日一练图片包、公共题库生产定时调度和失败告警。
|
||||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||||
|
|||||||
@@ -1090,6 +1090,43 @@ PDF/Word 异步导出:
|
|||||||
|
|
||||||
`format` 可为 `pdf` 或 `docx`。`publishToAssets=true` 表示导出文件可作为资料资源展示给对应可见范围用户;不传时默认生成后台私有资源,仅后台可下载。`assetVisibility` 支持 `public`、`tenant`、`members`、`svip`、`private`,生产默认建议用 `tenant/private/svip`,不要轻易公开带题目的文件。
|
`format` 可为 `pdf` 或 `docx`。`publishToAssets=true` 表示导出文件可作为资料资源展示给对应可见范围用户;不传时默认生成后台私有资源,仅后台可下载。`assetVisibility` 支持 `public`、`tenant`、`members`、`svip`、`private`,生产默认建议用 `tenant/private/svip`,不要轻易公开带题目的文件。
|
||||||
|
|
||||||
|
每日一练运营素材导出:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scopeType": "collection",
|
||||||
|
"scopeId": "<questionCollectionId>",
|
||||||
|
"format": "pdf",
|
||||||
|
"exportType": "daily_practice",
|
||||||
|
"includeAnswers": true,
|
||||||
|
"includeExplanations": false,
|
||||||
|
"limit": 8,
|
||||||
|
"options": {
|
||||||
|
"title": "每日一练 第 1 期",
|
||||||
|
"issue": "每日一练 第 1 期",
|
||||||
|
"date": "2026-06-29",
|
||||||
|
"theme": "ink",
|
||||||
|
"cardFormat": "1:1",
|
||||||
|
"watermarkText": "恭学教育",
|
||||||
|
"publishToAssets": true,
|
||||||
|
"assetVisibility": "tenant",
|
||||||
|
"brand": {
|
||||||
|
"name": "恭学教育",
|
||||||
|
"english": "GONGXUE EDU",
|
||||||
|
"slogan": "专注高职升本",
|
||||||
|
"ctaLine": "每日一练 · 精选八题 · 稳步上岸"
|
||||||
|
},
|
||||||
|
"centerSlot": {
|
||||||
|
"type": "cta",
|
||||||
|
"title": "恭学教育",
|
||||||
|
"subtitle": "每日一练 · 稳步上岸"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`daily_practice` 会自动限制最多 8 道题,并生成第 5 格中央品牌/CTA 位。同步 `json` 导出会返回 `export.dailyPractice.slots`,适合前端做九宫格预览;异步 `pdf/docx` 会由 worker 生成运营素材文件并进入 `content_assets`。图片拼图、ZIP 卡片包和更精细的旧项目同款视觉仍由后续 Taro 操作台继续增强。
|
||||||
|
|
||||||
响应关键结构:
|
响应关键结构:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -2814,6 +2814,41 @@ async function testTenantContentAdmin() {
|
|||||||
const exportedQuestionWithAnswer = paperExport.export?.questions?.find(item => item.id === question.item.id);
|
const exportedQuestionWithAnswer = paperExport.export?.questions?.find(item => item.id === question.item.id);
|
||||||
assert.equal(exportedQuestionWithAnswer?.answerText, '4', 'paper export should include answers when requested');
|
assert.equal(exportedQuestionWithAnswer?.answerText, '4', 'paper export should include answers when requested');
|
||||||
|
|
||||||
|
const dailyPracticeExport = await request('/api/tenant-content/exports/questions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
scopeType: 'collection',
|
||||||
|
scopeId: collection.item.id,
|
||||||
|
format: 'json',
|
||||||
|
exportType: 'daily_practice',
|
||||||
|
includeAnswers: true,
|
||||||
|
includeExplanations: false,
|
||||||
|
limit: 99,
|
||||||
|
options: {
|
||||||
|
title: '第 1 期每日一练',
|
||||||
|
issue: '每日一练 第 1 期',
|
||||||
|
date: '2026-06-29',
|
||||||
|
theme: 'ink',
|
||||||
|
cardFormat: '1:1',
|
||||||
|
brand: {
|
||||||
|
name: '恭学教育',
|
||||||
|
english: 'GONGXUE EDU',
|
||||||
|
slogan: '专注高职升本',
|
||||||
|
ctaLine: '每日一练 · 稳步上岸',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(dailyPracticeExport.export?.exportType, 'daily_practice', 'daily practice export should use daily_practice type');
|
||||||
|
assert.equal(dailyPracticeExport.export?.summary?.issue, '每日一练 第 1 期', 'daily practice export should expose issue metadata');
|
||||||
|
assert.equal(dailyPracticeExport.export?.summary?.cardCount, 1, 'daily practice export should expose card count');
|
||||||
|
assert.equal(dailyPracticeExport.export?.dailyPractice?.slots?.some(item => item.slot === 4 && item.kind === 'center'), true, 'daily practice export should include center slot metadata');
|
||||||
|
assert.ok(
|
||||||
|
dailyPracticeExport.export?.questions?.length <= 8,
|
||||||
|
'daily practice export should cap question count for nine-grid material',
|
||||||
|
);
|
||||||
|
|
||||||
const pdfExportJob = await request('/api/tenant-content/exports/questions', {
|
const pdfExportJob = await request('/api/tenant-content/exports/questions', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ const ids = {
|
|||||||
pdfJob: '21000000-0000-0000-0000-000000000901',
|
pdfJob: '21000000-0000-0000-0000-000000000901',
|
||||||
docxJob: '21000000-0000-0000-0000-000000000902',
|
docxJob: '21000000-0000-0000-0000-000000000902',
|
||||||
noAnswerDocxJob: '21000000-0000-0000-0000-000000000903',
|
noAnswerDocxJob: '21000000-0000-0000-0000-000000000903',
|
||||||
|
dailyPdfJob: '21000000-0000-0000-0000-000000000904',
|
||||||
};
|
};
|
||||||
|
|
||||||
const jobIds = [ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob];
|
const jobIds = [ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, ids.dailyPdfJob];
|
||||||
|
|
||||||
async function runWorkerOnce() {
|
async function runWorkerOnce() {
|
||||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'exports'], {
|
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'exports'], {
|
||||||
@@ -110,9 +111,17 @@ async function seed(pool) {
|
|||||||
'{"title":"导出测试无答案文档","watermarkText":"内部水印","publishToAssets":false,"assetVisibility":"private","limit":50}'::jsonb,
|
'{"title":"导出测试无答案文档","watermarkText":"内部水印","publishToAssets":false,"assetVisibility":"private","limit":50}'::jsonb,
|
||||||
'{"delivery":"content_asset_pending","maxQuestionLimit":50}'::jsonb,
|
'{"delivery":"content_asset_pending","maxQuestionLimit":50}'::jsonb,
|
||||||
now() - interval '1 minute', now() - interval '1 minute'
|
now() - interval '1 minute', now() - interval '1 minute'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$7, $4, $5, 'daily_practice', 'pdf', 'collection', $6,
|
||||||
|
'pending', true, false, false,
|
||||||
|
0, '{"source":"export-worker-test"}'::jsonb,
|
||||||
|
'{"title":"每日一练测试","issue":"每日一练 第 1 期","date":"2026-06-29","watermarkText":"每日一练水印","publishToAssets":true,"assetVisibility":"tenant","limit":99,"brand":{"name":"恭学教育","english":"GONGXUE EDU","slogan":"专注高职升本","ctaLine":"每日一练 · 稳步上岸"}}'::jsonb,
|
||||||
|
'{"delivery":"content_asset_pending","maxQuestionLimit":8}'::jsonb,
|
||||||
|
now() - interval '1 minute', now() - interval '1 minute'
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
[ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, tenantId, tenantAdminUserId, collectionId],
|
[ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, tenantId, tenantAdminUserId, collectionId, ids.dailyPdfJob],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +168,7 @@ async function main() {
|
|||||||
seeded = true;
|
seeded = true;
|
||||||
|
|
||||||
const output = await runWorkerOnce();
|
const output = await runWorkerOnce();
|
||||||
assert.ok(countFromWorkerOutput(output, 'completed') >= 3, 'worker should complete all binary export jobs');
|
assert.ok(countFromWorkerOutput(output, 'completed') >= 4, 'worker should complete all binary export jobs');
|
||||||
|
|
||||||
const jobs = await pool.query(
|
const jobs = await pool.query(
|
||||||
`
|
`
|
||||||
@@ -170,7 +179,7 @@ async function main() {
|
|||||||
`,
|
`,
|
||||||
[tenantId, jobIds],
|
[tenantId, jobIds],
|
||||||
);
|
);
|
||||||
assert.equal(jobs.rowCount, 3, 'all export jobs should still exist');
|
assert.equal(jobs.rowCount, 4, 'all export jobs should still exist');
|
||||||
for (const job of jobs.rows) {
|
for (const job of jobs.rows) {
|
||||||
assert.equal(job.status, 'completed', 'export job should be completed');
|
assert.equal(job.status, 'completed', 'export job should be completed');
|
||||||
assert.ok(job.asset_id, 'completed export job should reference a content asset');
|
assert.ok(job.asset_id, 'completed export job should reference a content asset');
|
||||||
@@ -190,24 +199,30 @@ async function main() {
|
|||||||
`,
|
`,
|
||||||
[tenantId, jobIds],
|
[tenantId, jobIds],
|
||||||
);
|
);
|
||||||
assert.equal(assets.rowCount, 3, 'worker should create three export assets');
|
assert.equal(assets.rowCount, 4, 'worker should create four export assets');
|
||||||
const pdfAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.pdfJob);
|
const pdfAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.pdfJob);
|
||||||
const docxAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.docxJob);
|
const docxAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.docxJob);
|
||||||
const noAnswerDocxAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.noAnswerDocxJob);
|
const noAnswerDocxAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.noAnswerDocxJob);
|
||||||
|
const dailyPdfAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.dailyPdfJob);
|
||||||
assert.ok(pdfAsset, 'PDF export should create a PDF content asset');
|
assert.ok(pdfAsset, 'PDF export should create a PDF content asset');
|
||||||
assert.ok(docxAsset, 'DOCX export should create a Word content asset');
|
assert.ok(docxAsset, 'DOCX export should create a Word content asset');
|
||||||
assert.ok(noAnswerDocxAsset, 'no-answer DOCX export should create a Word content asset');
|
assert.ok(noAnswerDocxAsset, 'no-answer DOCX export should create a Word content asset');
|
||||||
|
assert.ok(dailyPdfAsset, 'daily practice PDF export should create a PDF content asset');
|
||||||
assert.equal(pdfAsset.mime_type, 'application/pdf', 'PDF asset should use application/pdf');
|
assert.equal(pdfAsset.mime_type, 'application/pdf', 'PDF asset should use application/pdf');
|
||||||
assert.ok(docxAsset.mime_type?.includes('wordprocessingml'), 'DOCX asset should use Word MIME type');
|
assert.ok(docxAsset.mime_type?.includes('wordprocessingml'), 'DOCX asset should use Word MIME type');
|
||||||
assert.equal(pdfAsset.upload_status, 'verified', 'PDF asset should be verified');
|
assert.equal(pdfAsset.upload_status, 'verified', 'PDF asset should be verified');
|
||||||
assert.equal(docxAsset.upload_status, 'verified', 'DOCX asset should be verified');
|
assert.equal(docxAsset.upload_status, 'verified', 'DOCX asset should be verified');
|
||||||
assert.equal(noAnswerDocxAsset.upload_status, 'verified', 'no-answer DOCX asset should be verified');
|
assert.equal(noAnswerDocxAsset.upload_status, 'verified', 'no-answer DOCX asset should be verified');
|
||||||
|
assert.equal(dailyPdfAsset.upload_status, 'verified', 'daily practice PDF asset should be verified');
|
||||||
assert.equal(pdfAsset.visibility, 'tenant', 'published PDF export should use requested asset visibility');
|
assert.equal(pdfAsset.visibility, 'tenant', 'published PDF export should use requested asset visibility');
|
||||||
assert.equal(docxAsset.visibility, 'private', 'non-published DOCX export should remain private');
|
assert.equal(docxAsset.visibility, 'private', 'non-published DOCX export should remain private');
|
||||||
assert.equal(noAnswerDocxAsset.visibility, 'private', 'no-answer DOCX export should remain private');
|
assert.equal(noAnswerDocxAsset.visibility, 'private', 'no-answer DOCX export should remain private');
|
||||||
|
assert.equal(dailyPdfAsset.visibility, 'tenant', 'daily practice PDF export should use requested asset visibility');
|
||||||
|
assert.equal(dailyPdfAsset.metadata?.exportType, 'daily_practice', 'daily practice asset should keep export type metadata');
|
||||||
await assertGeneratedFile(pdfAsset);
|
await assertGeneratedFile(pdfAsset);
|
||||||
await assertGeneratedFile(docxAsset);
|
await assertGeneratedFile(docxAsset);
|
||||||
await assertGeneratedFile(noAnswerDocxAsset);
|
await assertGeneratedFile(noAnswerDocxAsset);
|
||||||
|
await assertGeneratedFile(dailyPdfAsset);
|
||||||
|
|
||||||
const answeredDocxText = await docxText(docxAsset);
|
const answeredDocxText = await docxText(docxAsset);
|
||||||
assert.match(answeredDocxText, /答案/, 'DOCX with answers should include answer labels');
|
assert.match(answeredDocxText, /答案/, 'DOCX with answers should include answer labels');
|
||||||
|
|||||||
Reference in New Issue
Block a user