forked from wangziqi/gongxue-base
feat: export daily practice image packages
This commit is contained in:
31
README.md
31
README.md
@@ -17,9 +17,9 @@
|
||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF 和 Word,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word 由 exports worker 生成水印文件并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata 和基础 PDF/Word 运营版式。
|
||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
||||
- 销售/代理/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 导出文件和每日一练 ZIP 图片素材包。
|
||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
||||
@@ -31,7 +31,7 @@
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;完整资金流水对账和真实生产账号联调还没接完。
|
||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。
|
||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker 和每日一练基础导出;后续还要补更精细试卷模板、每日一练图片拼图/ZIP 卡片包和导出操作台体验。
|
||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||
@@ -181,7 +181,7 @@ npm --workspace @tiku-saas/worker run imports:once
|
||||
npm --workspace @tiku-saas/worker run public-banks:once
|
||||
```
|
||||
|
||||
单次运行题库 PDF/Word 导出 worker:
|
||||
单次运行题库 PDF/Word/每日一练 ZIP 导出 worker:
|
||||
|
||||
```bash
|
||||
npm --workspace @tiku-saas/worker run exports:once
|
||||
@@ -307,7 +307,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。
|
||||
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
||||
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
||||
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。
|
||||
|
||||
## 最近一次验证
|
||||
@@ -316,23 +316,16 @@ API 身份上下文:
|
||||
|
||||
```text
|
||||
npx supabase db reset
|
||||
npm run check:refactor
|
||||
npm run test:readiness
|
||||
npm run audit:runtime
|
||||
npm run test:worker:imports
|
||||
npm run test:worker:crm
|
||||
npm run test:worker:commerce
|
||||
npm run test:worker:assets
|
||||
npm run check:api
|
||||
npm run check:worker
|
||||
npm run test:api
|
||||
npm run test:worker:exports
|
||||
npm run test:worker:public-banks
|
||||
npm run check:taro
|
||||
npm run build:taro:h5:student
|
||||
npm run build:taro:h5:tenant
|
||||
npm run build:taro:h5:platform
|
||||
npm run check:refactor
|
||||
npm run audit:runtime
|
||||
git diff --check
|
||||
```
|
||||
|
||||
结果:通过。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||
结果:通过。`npm run test:worker:exports` 已覆盖 PDF、Word、每日一练 PDF 和 `daily_practice_zip` 图片素材包生成、资源台账写入、ZIP 解包和答案/解析脱敏。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||
|
||||
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
||||
|
||||
@@ -343,5 +336,5 @@ git diff --check
|
||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
||||
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
|
||||
4. 题库导出模板精排、每日一练图片拼图/ZIP 卡片包、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -7,7 +7,7 @@ import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
import { boolValue, jsonObjectValue, nullableString } from './utils.js';
|
||||
|
||||
const INLINE_EXPORT_FORMATS = ['json', 'paper_json', 'print_payload'];
|
||||
const BINARY_EXPORT_FORMATS = ['pdf', 'docx'];
|
||||
const BINARY_EXPORT_FORMATS = ['pdf', 'docx', 'daily_practice_zip'];
|
||||
const EXPORT_FORMATS = [...INLINE_EXPORT_FORMATS, ...BINARY_EXPORT_FORMATS];
|
||||
const EXPORT_TYPES = ['questions', 'paper', 'daily_practice'];
|
||||
const SCOPE_TYPES = ['collection', 'entry', 'content_node'];
|
||||
@@ -184,6 +184,16 @@ function isBinaryExportFormat(format: string) {
|
||||
return BINARY_EXPORT_FORMATS.includes(format);
|
||||
}
|
||||
|
||||
function assertExportFormatMatchesType(format: string, exportType: string) {
|
||||
if (format === 'daily_practice_zip' && exportType !== 'daily_practice') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'daily_practice_zip is only supported for daily_practice exports',
|
||||
'DAILY_PRACTICE_ZIP_REQUIRES_DAILY_PRACTICE',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
@@ -732,9 +742,10 @@ export async function createQuestionExportRoute(ctx: RequestContext) {
|
||||
const exportType = choose(
|
||||
body.exportType,
|
||||
EXPORT_TYPES,
|
||||
format === 'paper_json' || isBinaryExportFormat(format) ? 'paper' : 'questions',
|
||||
format === 'daily_practice_zip' ? 'daily_practice' : format === 'paper_json' || isBinaryExportFormat(format) ? 'paper' : 'questions',
|
||||
'INVALID_EXPORT_TYPE',
|
||||
);
|
||||
assertExportFormatMatchesType(format, exportType);
|
||||
const includeAnswers = boolValue(body.includeAnswers, true);
|
||||
const includeExplanations = boolValue(body.includeExplanations, includeAnswers);
|
||||
const includeVideoRefs = boolValue(body.includeVideoRefs, false);
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
"exports:once": "tsx src/index.ts --once --job exports"
|
||||
},
|
||||
"dependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@supabase/storage-js": "^2.108.2",
|
||||
"ali-oss": "^6.23.0",
|
||||
"docx": "^9.7.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
|
||||
373
apps/worker/src/jobs/daily-practice-package.ts
Normal file
373
apps/worker/src/jobs/daily-practice-package.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import fs from 'node:fs';
|
||||
import { Resvg } from '@resvg/resvg-js';
|
||||
import JSZip from 'jszip';
|
||||
import type { BuiltExportPayload } from '../../../api/src/features/tenant-content/exports.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
interface RenderDailyPracticePackageResult {
|
||||
body: Buffer;
|
||||
mimeType: string;
|
||||
extension: 'zip';
|
||||
}
|
||||
|
||||
type ExportQuestion = BuiltExportPayload['questions'][number];
|
||||
|
||||
const CARD_SIZE = 1080;
|
||||
const COLLAGE_GAP = 28;
|
||||
const COLLAGE_PADDING = 42;
|
||||
const CARD_SLOTS = [0, 1, 2, 3, 5, 6, 7, 8];
|
||||
const FONT_FAMILY = '"Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", sans-serif';
|
||||
|
||||
const THEMES: Record<string, {
|
||||
name: string;
|
||||
background: string;
|
||||
card: string;
|
||||
border: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
accent: string;
|
||||
accentSoft: string;
|
||||
center: string;
|
||||
centerText: string;
|
||||
}> = {
|
||||
default: {
|
||||
name: 'default',
|
||||
background: '#f6f8fb',
|
||||
card: '#ffffff',
|
||||
border: '#d7dee8',
|
||||
text: '#0f172a',
|
||||
muted: '#64748b',
|
||||
accent: '#0f766e',
|
||||
accentSoft: '#dff7f1',
|
||||
center: '#0f172a',
|
||||
centerText: '#ffffff',
|
||||
},
|
||||
cream: {
|
||||
name: 'cream',
|
||||
background: '#fbf7ef',
|
||||
card: '#fffdf8',
|
||||
border: '#e4d6c4',
|
||||
text: '#1f2933',
|
||||
muted: '#786a5c',
|
||||
accent: '#b45309',
|
||||
accentSoft: '#f7ead8',
|
||||
center: '#3f2f24',
|
||||
centerText: '#fff8ed',
|
||||
},
|
||||
ink: {
|
||||
name: 'ink',
|
||||
background: '#111827',
|
||||
card: '#f8fafc',
|
||||
border: '#334155',
|
||||
text: '#0f172a',
|
||||
muted: '#475569',
|
||||
accent: '#2563eb',
|
||||
accentSoft: '#dbeafe',
|
||||
center: '#020617',
|
||||
centerText: '#e2e8f0',
|
||||
},
|
||||
};
|
||||
|
||||
function boolOption(payload: BuiltExportPayload, key: string) {
|
||||
return payload.options && typeof payload.options[key] === 'boolean' ? payload.options[key] === true : false;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function plainText(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, ' [图片] ')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function xmlEscape(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function estimateWidth(char: string) {
|
||||
if (/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/.test(char)) return 1;
|
||||
if (/[A-Z0-9]/.test(char)) return 0.68;
|
||||
if (/[a-z]/.test(char)) return 0.56;
|
||||
if (/\s/.test(char)) return 0.35;
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
function wrapText(value: unknown, maxUnits: number, maxLines: number) {
|
||||
const normalized = plainText(value).replace(/\s+/g, ' ');
|
||||
const lines: string[] = [];
|
||||
let line = '';
|
||||
let units = 0;
|
||||
for (const char of normalized) {
|
||||
const width = estimateWidth(char);
|
||||
if (units + width > maxUnits && line) {
|
||||
lines.push(line.trim());
|
||||
line = '';
|
||||
units = 0;
|
||||
if (lines.length >= maxLines) break;
|
||||
}
|
||||
line += char;
|
||||
units += width;
|
||||
}
|
||||
if (line && lines.length < maxLines) lines.push(line.trim());
|
||||
if (lines.length > 0 && normalized.length > lines.join('').length) {
|
||||
lines[lines.length - 1] = `${lines[lines.length - 1].replace(/[。;,、,.!?!?;::]*$/, '')}...`;
|
||||
}
|
||||
return lines.filter(Boolean);
|
||||
}
|
||||
|
||||
function textLines(input: {
|
||||
lines: string[];
|
||||
x: number;
|
||||
y: number;
|
||||
fontSize: number;
|
||||
fill: string;
|
||||
lineHeight?: number;
|
||||
weight?: number;
|
||||
anchor?: 'start' | 'middle';
|
||||
}) {
|
||||
const lineHeight = input.lineHeight || Math.round(input.fontSize * 1.42);
|
||||
return input.lines.map((line, index) => (
|
||||
`<text x="${input.x}" y="${input.y + index * lineHeight}" font-family="${xmlEscape(FONT_FAMILY)}" font-size="${input.fontSize}" font-weight="${input.weight || 400}" fill="${input.fill}" text-anchor="${input.anchor || 'start'}">${xmlEscape(line)}</text>`
|
||||
)).join('\n');
|
||||
}
|
||||
|
||||
function optionText(option: unknown, index: number) {
|
||||
const prefix = `${String.fromCharCode(65 + index)}. `;
|
||||
if (option && typeof option === 'object' && !Array.isArray(option)) {
|
||||
const object = option as Record<string, unknown>;
|
||||
return prefix + plainText(object.text ?? object.label ?? object.content ?? JSON.stringify(object));
|
||||
}
|
||||
return prefix + plainText(option);
|
||||
}
|
||||
|
||||
function answerText(question: ExportQuestion) {
|
||||
const answers: string[] = [];
|
||||
if (typeof question.correctOptionIndex === 'number') {
|
||||
answers.push(String.fromCharCode(65 + question.correctOptionIndex));
|
||||
}
|
||||
if (Array.isArray(question.correctOptionIndices) && question.correctOptionIndices.length) {
|
||||
answers.push(question.correctOptionIndices.map(item => String.fromCharCode(65 + Number(item))).join(', '));
|
||||
}
|
||||
if (typeof question.answerText === 'string' && question.answerText.trim()) {
|
||||
answers.push(plainText(question.answerText));
|
||||
}
|
||||
return Array.from(new Set(answers.filter(Boolean))).join(';');
|
||||
}
|
||||
|
||||
function subQuestionPreview(question: ExportQuestion) {
|
||||
const subQuestions = Array.isArray(question.subQuestions) ? question.subQuestions : [];
|
||||
return subQuestions
|
||||
.slice(0, 2)
|
||||
.map((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return '';
|
||||
const object = item as Record<string, unknown>;
|
||||
return `(${index + 1}) ${plainText(object.content ?? object.stem ?? object.title ?? '')}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function questionTitle(question: ExportQuestion, fallback: string) {
|
||||
return stringValue(question.subjectName, '') || stringValue(question.categoryName, '') || fallback;
|
||||
}
|
||||
|
||||
function themeFor(payload: BuiltExportPayload) {
|
||||
const key = payload.dailyPractice?.theme || stringValue(payload.options?.theme, 'default');
|
||||
return THEMES[key] || THEMES.default;
|
||||
}
|
||||
|
||||
function questionCardSvg(payload: BuiltExportPayload, question: ExportQuestion, index: number) {
|
||||
const theme = themeFor(payload);
|
||||
const includeAnswers = boolOption(payload, 'includeAnswers');
|
||||
const includeExplanations = boolOption(payload, 'includeExplanations');
|
||||
const title = questionTitle(question, payload.dailyPractice?.issue || payload.title || '每日一练');
|
||||
const type = stringValue(question.typeLabel, stringValue(question.type, '题目'));
|
||||
const stem = plainText(question.content);
|
||||
const subPreview = subQuestionPreview(question);
|
||||
const options = Array.isArray(question.options) ? question.options.slice(0, 4).map(optionText) : [];
|
||||
const answer = includeAnswers ? answerText(question) : '';
|
||||
const explanation = includeExplanations ? plainText(question.explanation).slice(0, 90) : '';
|
||||
const contentLines = wrapText(subPreview ? `${stem} ${subPreview}` : stem, 21, options.length ? 8 : 11);
|
||||
const optionLines = options.flatMap(option => wrapText(option, 27, 1)).slice(0, 4);
|
||||
const footerLines = [
|
||||
answer ? `答案:${answer}` : '',
|
||||
explanation ? `解析:${explanation}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_SIZE}" height="${CARD_SIZE}" viewBox="0 0 ${CARD_SIZE} ${CARD_SIZE}">
|
||||
<rect width="${CARD_SIZE}" height="${CARD_SIZE}" rx="46" fill="${theme.card}"/>
|
||||
<rect x="28" y="28" width="${CARD_SIZE - 56}" height="${CARD_SIZE - 56}" rx="38" fill="none" stroke="${theme.border}" stroke-width="4"/>
|
||||
<rect x="64" y="64" width="148" height="52" rx="26" fill="${theme.accentSoft}"/>
|
||||
<text x="138" y="99" font-family="${xmlEscape(FONT_FAMILY)}" font-size="26" font-weight="700" fill="${theme.accent}" text-anchor="middle">${String(index + 1).padStart(2, '0')}</text>
|
||||
<text x="236" y="101" font-family="${xmlEscape(FONT_FAMILY)}" font-size="30" font-weight="700" fill="${theme.text}">${xmlEscape(type)}</text>
|
||||
<text x="64" y="172" font-family="${xmlEscape(FONT_FAMILY)}" font-size="24" font-weight="500" fill="${theme.muted}">${xmlEscape(title)}</text>
|
||||
<line x1="64" y1="210" x2="${CARD_SIZE - 64}" y2="210" stroke="${theme.border}" stroke-width="3"/>
|
||||
${textLines({ lines: contentLines, x: 72, y: 282, fontSize: 42, fill: theme.text, lineHeight: 64, weight: 650 })}
|
||||
${optionLines.length ? textLines({ lines: optionLines, x: 80, y: 765, fontSize: 32, fill: theme.muted, lineHeight: 48, weight: 500 }) : ''}
|
||||
${footerLines.length ? `<rect x="64" y="908" width="${CARD_SIZE - 128}" height="102" rx="24" fill="${theme.accentSoft}"/>` : ''}
|
||||
${footerLines.length ? textLines({ lines: footerLines.flatMap(line => wrapText(line, 30, 1)), x: 92, y: 950, fontSize: 26, fill: theme.accent, lineHeight: 38, weight: 650 }) : ''}
|
||||
<text x="64" y="1034" font-family="${xmlEscape(FONT_FAMILY)}" font-size="22" fill="${theme.muted}">${xmlEscape(payload.dailyPractice?.brand?.english || 'GONGXUE EDU')}</text>
|
||||
<text x="${CARD_SIZE - 64}" y="1034" font-family="${xmlEscape(FONT_FAMILY)}" font-size="22" fill="${theme.muted}" text-anchor="end">${xmlEscape(payload.dailyPractice?.date || payload.exportedAt.slice(0, 10))}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function centerCardSvg(payload: BuiltExportPayload) {
|
||||
const theme = themeFor(payload);
|
||||
const brand = payload.dailyPractice?.brand;
|
||||
const center = payload.dailyPractice?.centerSlot;
|
||||
const titleLines = wrapText(center?.title || brand?.name || '恭学教育', 9, 2);
|
||||
const subtitleLines = wrapText(center?.subtitle || brand?.ctaLine || '每日一练 · 精选八题 · 稳步上岸', 15, 3);
|
||||
const sloganLines = wrapText(brand?.slogan || '专注高职升本', 16, 1);
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_SIZE}" height="${CARD_SIZE}" viewBox="0 0 ${CARD_SIZE} ${CARD_SIZE}">
|
||||
<rect width="${CARD_SIZE}" height="${CARD_SIZE}" rx="46" fill="${theme.center}"/>
|
||||
<circle cx="540" cy="270" r="118" fill="${theme.accent}" opacity="0.16"/>
|
||||
<circle cx="540" cy="270" r="78" fill="${theme.accentSoft}" opacity="0.92"/>
|
||||
<text x="540" y="288" font-family="${xmlEscape(FONT_FAMILY)}" font-size="40" font-weight="800" fill="${theme.accent}" text-anchor="middle">每日</text>
|
||||
${textLines({ lines: titleLines, x: 540, y: 482, fontSize: 58, fill: theme.centerText, lineHeight: 76, weight: 800, anchor: 'middle' })}
|
||||
${textLines({ lines: sloganLines, x: 540, y: 646, fontSize: 30, fill: '#cbd5e1', lineHeight: 42, weight: 500, anchor: 'middle' })}
|
||||
${textLines({ lines: subtitleLines, x: 540, y: 746, fontSize: 34, fill: theme.centerText, lineHeight: 50, weight: 700, anchor: 'middle' })}
|
||||
<line x1="230" y1="906" x2="850" y2="906" stroke="#94a3b8" stroke-width="2" opacity="0.42"/>
|
||||
<text x="540" y="972" font-family="${xmlEscape(FONT_FAMILY)}" font-size="28" fill="#cbd5e1" text-anchor="middle">${xmlEscape(brand?.english || 'GONGXUE EDU')}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function emptyCardSvg(payload: BuiltExportPayload, slot: number) {
|
||||
const theme = themeFor(payload);
|
||||
const brand = payload.dailyPractice?.brand;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_SIZE}" height="${CARD_SIZE}" viewBox="0 0 ${CARD_SIZE} ${CARD_SIZE}">
|
||||
<rect width="${CARD_SIZE}" height="${CARD_SIZE}" rx="46" fill="${theme.card}"/>
|
||||
<rect x="28" y="28" width="${CARD_SIZE - 56}" height="${CARD_SIZE - 56}" rx="38" fill="none" stroke="${theme.border}" stroke-width="4" stroke-dasharray="18 18"/>
|
||||
<text x="540" y="478" font-family="${xmlEscape(FONT_FAMILY)}" font-size="44" font-weight="800" fill="${theme.muted}" text-anchor="middle">题位待补</text>
|
||||
<text x="540" y="548" font-family="${xmlEscape(FONT_FAMILY)}" font-size="30" fill="${theme.muted}" text-anchor="middle">Slot ${slot + 1}</text>
|
||||
<text x="540" y="646" font-family="${xmlEscape(FONT_FAMILY)}" font-size="30" fill="${theme.accent}" text-anchor="middle">${xmlEscape(brand?.ctaLine || '每日一练 · 精选八题 · 稳步上岸')}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function renderSvgToPng(svg: string) {
|
||||
const fontPath = [config.exportPdfFontPath, 'C:\\Windows\\Fonts\\msyh.ttc', 'C:\\Windows\\Fonts\\simhei.ttf']
|
||||
.filter((candidate): candidate is string => Boolean(candidate) && fs.existsSync(candidate));
|
||||
const renderer = new Resvg(svg, {
|
||||
fitTo: { mode: 'original' },
|
||||
font: {
|
||||
loadSystemFonts: true,
|
||||
fontFiles: fontPath,
|
||||
defaultFontFamily: 'Microsoft YaHei',
|
||||
},
|
||||
});
|
||||
return renderer.render().asPng();
|
||||
}
|
||||
|
||||
function dataUri(buffer: Buffer) {
|
||||
return `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
function collageSvg(payload: BuiltExportPayload, pngCards: Buffer[]) {
|
||||
const theme = themeFor(payload);
|
||||
const width = CARD_SIZE * 3 + COLLAGE_GAP * 2 + COLLAGE_PADDING * 2;
|
||||
const height = width;
|
||||
const title = payload.dailyPractice?.issue || payload.title || '每日一练';
|
||||
const images = pngCards.map((buffer, slot) => {
|
||||
const row = Math.floor(slot / 3);
|
||||
const col = slot % 3;
|
||||
const x = COLLAGE_PADDING + col * (CARD_SIZE + COLLAGE_GAP);
|
||||
const y = COLLAGE_PADDING + row * (CARD_SIZE + COLLAGE_GAP);
|
||||
return `<image href="${dataUri(buffer)}" x="${x}" y="${y}" width="${CARD_SIZE}" height="${CARD_SIZE}"/>`;
|
||||
}).join('\n');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<rect width="${width}" height="${height}" fill="${theme.background}"/>
|
||||
${images}
|
||||
<text x="${width - 64}" y="${height - 28}" font-family="${xmlEscape(FONT_FAMILY)}" font-size="24" fill="${theme.muted}" text-anchor="end">${xmlEscape(title)} · ${xmlEscape(payload.dailyPractice?.brand?.name || '恭学教育')}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function manifest(payload: BuiltExportPayload) {
|
||||
return {
|
||||
version: '1.0',
|
||||
generatedBy: 'tiku-saas-export-worker',
|
||||
generatedAt: new Date().toISOString(),
|
||||
exportJobId: payload.jobId,
|
||||
tenantId: payload.tenantId,
|
||||
exportType: payload.exportType,
|
||||
format: payload.format,
|
||||
title: payload.title,
|
||||
summary: payload.summary,
|
||||
dailyPractice: payload.dailyPractice,
|
||||
files: {
|
||||
collage: {
|
||||
png: 'collage.png',
|
||||
svg: 'collage.svg',
|
||||
},
|
||||
cards: Array.from({ length: 9 }).map((_, index) => ({
|
||||
slot: index,
|
||||
png: `cards/card-${String(index + 1).padStart(2, '0')}.png`,
|
||||
svg: `cards/card-${String(index + 1).padStart(2, '0')}.svg`,
|
||||
})),
|
||||
},
|
||||
safety: {
|
||||
includeAnswers: boolOption(payload, 'includeAnswers'),
|
||||
includeExplanations: boolOption(payload, 'includeExplanations'),
|
||||
note: 'Card files are rendered server-side from the audited export payload. Private media URLs are not embedded.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderDailyPracticePackage(payload: BuiltExportPayload): Promise<RenderDailyPracticePackageResult> {
|
||||
if (payload.exportType !== 'daily_practice') {
|
||||
throw new Error('daily_practice_zip renderer only supports daily_practice exports');
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const cardPngs: Buffer[] = [];
|
||||
const bySlot = new Map<number, ExportQuestion>();
|
||||
payload.questions.slice(0, 8).forEach((question, index) => {
|
||||
bySlot.set(CARD_SLOTS[index], question);
|
||||
});
|
||||
|
||||
for (let slot = 0; slot < 9; slot += 1) {
|
||||
const question = bySlot.get(slot);
|
||||
const svg = question ? questionCardSvg(payload, question, CARD_SLOTS.indexOf(slot)) : slot === 4 ? centerCardSvg(payload) : emptyCardSvg(payload, slot);
|
||||
const png = renderSvgToPng(svg);
|
||||
cardPngs[slot] = png;
|
||||
const name = String(slot + 1).padStart(2, '0');
|
||||
zip.file(`cards/card-${name}.svg`, svg);
|
||||
zip.file(`cards/card-${name}.png`, png);
|
||||
}
|
||||
|
||||
const collageSource = collageSvg(payload, cardPngs);
|
||||
zip.file('collage.svg', collageSource);
|
||||
zip.file('collage.png', renderSvgToPng(collageSource));
|
||||
zip.file('manifest.json', JSON.stringify(manifest(payload), null, 2));
|
||||
zip.file('payload.json', JSON.stringify(payload, null, 2));
|
||||
|
||||
return {
|
||||
body: await zip.generateAsync({
|
||||
type: 'nodebuffer',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 7 },
|
||||
}),
|
||||
mimeType: 'application/zip',
|
||||
extension: 'zip',
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { config } from '../config.js';
|
||||
import { closePool as closeApiExportPool } from '../../../api/src/core/db.js';
|
||||
import { buildExportPayloadForJob } from '../../../api/src/features/tenant-content/exports.js';
|
||||
import { normalizeStorageProvider, putStorageObject, storageBucket, validateObjectKey } from '../storage.js';
|
||||
import { renderDailyPracticePackage } from './daily-practice-package.js';
|
||||
import { renderExportDocx, renderExportPdf } from './export-renderer.js';
|
||||
|
||||
interface ExportJobRow {
|
||||
@@ -13,7 +14,7 @@ interface ExportJobRow {
|
||||
tenantId: string;
|
||||
createdBy: string | null;
|
||||
exportType: string;
|
||||
format: 'pdf' | 'docx';
|
||||
format: 'pdf' | 'docx' | 'daily_practice_zip';
|
||||
scopeType: string;
|
||||
scopeId: string;
|
||||
status: string;
|
||||
@@ -35,7 +36,7 @@ interface ExportWorkerResult {
|
||||
interface RenderedExport {
|
||||
body: Buffer;
|
||||
mimeType: string;
|
||||
extension: 'pdf' | 'docx';
|
||||
extension: 'pdf' | 'docx' | 'zip';
|
||||
}
|
||||
|
||||
const ASSET_VISIBILITIES = new Set(['public', 'tenant', 'members', 'svip', 'private']);
|
||||
@@ -121,7 +122,7 @@ async function claimExportJobs() {
|
||||
output_metadata as "outputMetadata", created_at as "createdAt"
|
||||
from public.content_export_jobs
|
||||
where status = 'pending'
|
||||
and format in ('pdf', 'docx')
|
||||
and format in ('pdf', 'docx', 'daily_practice_zip')
|
||||
and attempt_count < max_attempts
|
||||
and (next_attempt_at is null or next_attempt_at <= now())
|
||||
order by created_at asc
|
||||
@@ -169,7 +170,9 @@ async function renderJob(job: ExportJobRow): Promise<{ rendered: RenderedExport;
|
||||
});
|
||||
const rendered = job.format === 'pdf'
|
||||
? await renderExportPdf(payload)
|
||||
: await renderExportDocx(payload);
|
||||
: job.format === 'docx'
|
||||
? await renderExportDocx(payload)
|
||||
: await renderDailyPracticePackage(payload);
|
||||
return {
|
||||
rendered,
|
||||
title: payload.title || '题库导出',
|
||||
@@ -186,7 +189,7 @@ async function insertExportAsset(
|
||||
input: {
|
||||
title: string;
|
||||
fileName: string;
|
||||
extension: 'pdf' | 'docx';
|
||||
extension: 'pdf' | 'docx' | 'zip';
|
||||
mimeType: string;
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
@@ -197,7 +200,7 @@ async function insertExportAsset(
|
||||
) {
|
||||
const visibility = assetVisibility(job.options || {});
|
||||
const status = assetStatus(job.options || {});
|
||||
const assetType = input.extension === 'pdf' ? 'pdf' : 'document';
|
||||
const assetType = input.extension === 'pdf' ? 'pdf' : input.extension === 'docx' ? 'document' : 'package';
|
||||
const asset = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.content_assets (
|
||||
@@ -475,7 +478,7 @@ export async function processExportBatch(): Promise<ExportWorkerResult> {
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.format !== 'pdf' && job.format !== 'docx') {
|
||||
if (job.format !== 'pdf' && job.format !== 'docx' && job.format !== 'daily_practice_zip') {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,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`、`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` 异步导出;`exportType=daily_practice` 可生成每日一练九宫格运营素材 metadata 和 PDF/Word 基础版式;后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 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/daily_practice_zip` 异步导出;`exportType=daily_practice` 可生成每日一练九宫格运营素材 metadata、PDF/Word 基础版式和 ZIP 图片素材包;后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;`apps/worker --job exports` 负责 PDF/Word/ZIP 渲染、生成 `content_assets`、记录 hash/size/assetId,前端通过资源签名接口下载/预览 |
|
||||
|
||||
## 销售、代理、CRM
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
| 视频 JSON preview/import | 可联调 | 支持 `videos/items`,后端校验题目、科目、资源引用,导入后写入 `question_videos` |
|
||||
| Excel/CSV 导入 | 可联调 | 题目、单词、知识手册、分数线、视频已支持 CSV 和 `.xlsx` 解析,解析后复用 `content_import_jobs/items/issues` 管线并保留 `parser_metadata`;模板下载、字段映射 API、字段映射覆盖白名单、导入任务详情、异步 worker 状态、导入后复检已接入;Taro 租户内容页已接上传/粘贴 preview/import、字段别名编辑、异步任务轮询和复检详情第一版 |
|
||||
| 大批量异步导入 | 可联调 | `executionMode=async` 会将 preview job 置为 `pending`;`apps/worker --job imports` 抢占 queued job,复用 API 导入 executor,支持重试、清锁和审计 |
|
||||
| 题库导出任务 | 可联调 | `content_export_jobs` 记录导出范围、格式、题量、输出 hash、选项、执行人、异步状态、重试次数和 `asset_id`;JSON 类导出返回 inline base64,`pdf/docx` 由 exports worker 渲染为资源台账文件 |
|
||||
| 题库导出任务 | 可联调 | `content_export_jobs` 记录导出范围、格式、题量、输出 hash、选项、执行人、异步状态、重试次数和 `asset_id`;JSON 类导出返回 inline base64,`pdf/docx/daily_practice_zip` 由 exports worker 渲染为资源台账文件 |
|
||||
| 公共题库自动同步增强 | 部分覆盖 | `apps/worker --job public-banks` 已可抢占待同步采纳记录、自动同步平台新增/更新题目、记录失败和审计;同步新增/更新和冲突会写入 `tenant_content_notifications`,租户后台已有通知列表、已读/忽略、单条和批量冲突处理第一版;后续需接入生产定时调度、失败告警和更完整运营消息 |
|
||||
|
||||
## 当前验证
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata 与基础 PDF/Word 运营版式 | 长题干/公式图片混排体验、导出模板精排、每日一练图片包、排行榜防刷/预聚合 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
|
||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架、题库导出 PDF/Word 可生成 `content_assets` 并走签名下载/预览 | CDN 防盗链、杀毒扫描、视频水印、资料前端操作体验 |
|
||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架、题库导出 PDF/Word/每日一练 ZIP 可生成 `content_assets` 并走签名下载/预览 | CDN 防盗链、杀毒扫描、视频水印、资料前端操作体验 |
|
||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 |
|
||||
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
||||
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 |
|
||||
@@ -76,7 +76,7 @@
|
||||
### P0:上云测试和前端主链路前必须处理
|
||||
|
||||
- 生产鉴权:API 已支持 Supabase Auth JWT;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、assets worker 复检和题库导出 PDF/Word worker 已完成,继续补视频播放防盗链、杀毒扫描和动态水印。
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、assets worker 复检和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、杀毒扫描和动态水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前结论
|
||||
|
||||
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库导出基础、PDF/Word 导出 worker、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。
|
||||
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库导出基础、PDF/Word/每日一练 ZIP 导出 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 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `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/每日一练基础导出可联调;公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 |
|
||||
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、每日一练九宫格 metadata、PDF/Word 运营版式、ZIP 图片素材包、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、每日一练导出 metadata、异步 PDF/Word/每日一练 ZIP job 创建、exports worker、公共题库授权、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题第一版可联调,公共题库采纳/手动/自动同步、同步通知、冲突查询/处理、导入后复检、模板下载、字段映射 API、JSON/PDF/Word/每日一练 ZIP 基础导出可联调;公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 |
|
||||
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
|
||||
@@ -32,7 +32,7 @@
|
||||
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放和导入断言 | 播放、权益、后台绑定和批量 JSON 导入链路已实现,深度防盗链、动态水印和播放统计待补 |
|
||||
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、题库导出 PDF/Word 自动发布资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载,assets worker 和 exports worker 测试 | 资料资源基础闭环可跑,CDN 防盗链、杀毒扫描和资料下载前端待补 |
|
||||
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、题库导出 PDF/Word/每日一练 ZIP 自动发布资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载,assets worker 和 exports worker 测试 | 资料资源基础闭环可跑,CDN 防盗链、杀毒扫描和资料下载前端待补 |
|
||||
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,学习报告可视化和更细任务系统待补 |
|
||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 |
|
||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
||||
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
||||
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 |
|
||||
| 题库导出 PDF/Word/JSON | 部分覆盖 | 服务端 JSON、`paper_json`、打印 payload、PDF 和 Word 异步导出已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job、审计、水印、`content_assets` 发布/下载路径和 exports worker;每日一练九宫格 metadata 与 PDF/Word 基础版式已补;后续补更精细试卷模板、图片拼图/ZIP 卡片包和后台操作台体验 |
|
||||
| 题库导出 PDF/Word/JSON/每日一练 ZIP | 部分覆盖 | 服务端 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练 ZIP 图片素材包异步导出已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job、审计、水印、`content_assets` 发布/下载路径和 exports worker;每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图包已补;后续补更精细试卷模板和后台操作台体验 |
|
||||
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
||||
| 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 |
|
||||
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
||||
@@ -98,7 +98,7 @@
|
||||
1. 阅读理解/案例分析多小题:后端 `subAnswers` 判分、断点恢复、报告 `subResults` 和 Taro 作答 UI 第一版已完成;仍需优化长题干排版、公式图片混排和复盘体验。
|
||||
2. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
||||
3. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
||||
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏和每日一练基础导出已补;仍缺更精细试卷模板、图片拼图/ZIP 卡片包和后台导出操作台体验。
|
||||
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。
|
||||
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
||||
7. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||
@@ -127,4 +127,4 @@
|
||||
1. 主题模板、素材库、主题预览/发布。
|
||||
2. 班级、教师、学生分组和学习督导。
|
||||
3. AI 择校推荐和 PDF 报告。
|
||||
4. 题库导出模板精排、每日一练图片拼图/ZIP 卡片包和运营操作台。
|
||||
4. 题库导出模板精排、多模板排版和运营操作台。
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
||||
- 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。
|
||||
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx` 导出任务,渲染 PDF/Word、水印、写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata 和 PDF/Word 基础版式。
|
||||
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。
|
||||
- 本地验证:`npm run check:refactor` 已通过。
|
||||
|
||||
当前更适合进入前端联调前阅读的总览文档:
|
||||
@@ -84,8 +84,8 @@
|
||||
|
||||
4. 题库导出增强
|
||||
- 已完成服务端 JSON、`paper_json`、`print_payload` 基础导出和导出 job 台账。
|
||||
- 已完成 PDF/Word 二进制生成 worker、页面水印、导出发布到 `content_assets`、后台资源签名下载/预览路径,以及每日一练九宫格 metadata 和 PDF/Word 基础版式。
|
||||
- 继续补更精细的试卷模板、每日一练图片拼图/ZIP 卡片包、导出操作台和多模板排版。
|
||||
- 已完成 PDF/Word 二进制生成 worker、页面水印、导出发布到 `content_assets`、后台资源签名下载/预览路径,以及每日一练九宫格 metadata、PDF/Word 基础版式和 ZIP 图片素材包。
|
||||
- 继续补更精细的试卷模板、导出操作台和多模板排版。
|
||||
|
||||
5. 公共题库和租户授权
|
||||
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
||||
@@ -220,5 +220,5 @@
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出模板精排/每日一练图片包、公共题库生产定时调度和失败告警。
|
||||
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -70,7 +70,7 @@ POST /api/tenant-content/assets/sign-preview
|
||||
npm --workspace @tiku-saas/worker run assets:once
|
||||
```
|
||||
|
||||
题库 PDF/Word 导出 worker:
|
||||
题库 PDF/Word/每日一练 ZIP 导出 worker:
|
||||
|
||||
```bash
|
||||
npm --workspace @tiku-saas/worker run exports:once
|
||||
@@ -91,16 +91,16 @@ npm --workspace @tiku-saas/worker run exports:once
|
||||
|
||||
## 题库导出生成资源
|
||||
|
||||
题库导出 PDF/Word 不走前端上传,而是由后端和 worker 自动进入资源台账:
|
||||
题库导出 PDF/Word/每日一练 ZIP 不走前端上传,而是由后端和 worker 自动进入资源台账:
|
||||
|
||||
1. 租户后台调用 `POST /api/tenant-content/exports/questions`,`format=pdf` 或 `format=docx`。
|
||||
1. 租户后台调用 `POST /api/tenant-content/exports/questions`,`format=pdf`、`format=docx` 或 `format=daily_practice_zip`。
|
||||
2. API 校验租户内容权限、导出范围和答案/解析开关,创建 `content_export_jobs.status=pending`。
|
||||
3. `apps/worker --job exports` 抢占 pending job,复用后端导出 payload 规则渲染 PDF/Word,应用 `options.watermarkText` 水印。
|
||||
3. `apps/worker --job exports` 抢占 pending job,复用后端导出 payload 规则渲染 PDF/Word,或为每日一练生成 ZIP 素材包;PDF/Word 会应用 `options.watermarkText` 水印,ZIP 包会包含 PNG/SVG 卡片、拼图、manifest 和脱敏 payload。
|
||||
4. worker 将二进制文件写入配置的对象存储。`local_dev` 会写入 `EXPORT_LOCAL_STORAGE_ROOT`,生产建议使用 `supabase_storage`、`aliyun_oss` 或 `tencent_cos`。
|
||||
5. worker 创建 `content_assets`,设置 `uploadStatus=verified`,并把 `assetId`、文件名、大小和 SHA-256 回填到 `content_export_jobs.output_metadata`。
|
||||
6. 前端轮询 `GET /api/tenant-content/exports/jobs`,拿到 `assetId` 后通过 `POST /api/tenant-content/assets/sign-download` 或 `sign-preview` 下载/预览。
|
||||
|
||||
注意:PDF/Word 文件本身不直接存入数据库,数据库只保存 job、资源台账和校验 metadata。
|
||||
注意:PDF/Word/ZIP 文件本身不直接存入数据库,数据库只保存 job、资源台账和校验 metadata。`daily_practice_zip` 资源类型为 `package`,通常只做签名下载,不做 inline 预览。
|
||||
|
||||
确认上传示例:
|
||||
|
||||
|
||||
@@ -1015,10 +1015,11 @@ failed:导入结果和目标表不一致,必须提示管理员排查,不
|
||||
|
||||
## 题库导出对接
|
||||
|
||||
租户后台题库导出统一走后端生成结构化 payload,前端不要直接查 Supabase 表拼导出文件。当前后端支持两种交付形态:
|
||||
租户后台题库导出统一走后端生成结构化 payload,前端不要直接查 Supabase 表拼导出文件。当前后端支持三种交付形态:
|
||||
|
||||
- 同步结构化导出:`json`、`paper_json`、`print_payload`,接口直接返回 base64 JSON/payload。
|
||||
- 异步二进制导出:`pdf`、`docx`,接口先返回 `pending` job,由 `apps/worker --job exports` 渲染 PDF/Word、水印并写入 `content_assets`,前端轮询 job 后再走资源签名下载或预览。
|
||||
- 异步运营素材包:`daily_practice_zip`,仅支持 `exportType=daily_practice`,worker 会生成九宫格 PNG/SVG 卡片、拼图 PNG/SVG、`manifest.json` 和脱敏后的 `payload.json`,并作为 `asset_type=package` 写入 `content_assets`。
|
||||
|
||||
可用接口:
|
||||
|
||||
@@ -1088,9 +1089,9 @@ 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`,不要轻易公开带题目的文件。
|
||||
|
||||
每日一练运营素材导出:
|
||||
每日一练运营素材导出 PDF/Word:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1125,7 +1126,52 @@ PDF/Word 异步导出:
|
||||
}
|
||||
```
|
||||
|
||||
`daily_practice` 会自动限制最多 8 道题,并生成第 5 格中央品牌/CTA 位。同步 `json` 导出会返回 `export.dailyPractice.slots`,适合前端做九宫格预览;异步 `pdf/docx` 会由 worker 生成运营素材文件并进入 `content_assets`。图片拼图、ZIP 卡片包和更精细的旧项目同款视觉仍由后续 Taro 操作台继续增强。
|
||||
每日一练图片 ZIP 素材包:
|
||||
|
||||
```json
|
||||
{
|
||||
"scopeType": "collection",
|
||||
"scopeId": "<questionCollectionId>",
|
||||
"format": "daily_practice_zip",
|
||||
"exportType": "daily_practice",
|
||||
"includeAnswers": false,
|
||||
"includeExplanations": false,
|
||||
"limit": 8,
|
||||
"options": {
|
||||
"title": "每日一练 第 2 期",
|
||||
"issue": "每日一练 第 2 期",
|
||||
"date": "2026-06-29",
|
||||
"theme": "default",
|
||||
"cardFormat": "1:1",
|
||||
"publishToAssets": true,
|
||||
"assetVisibility": "tenant",
|
||||
"brand": {
|
||||
"name": "恭学教育",
|
||||
"english": "GONGXUE EDU",
|
||||
"slogan": "专注高职升本",
|
||||
"ctaLine": "每日一练 · 精选八题 · 稳步上岸"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`daily_practice` 会自动限制最多 8 道题,并生成第 5 格中央品牌/CTA 位。同步 `json` 导出会返回 `export.dailyPractice.slots`,适合前端做九宫格预览;异步 `pdf/docx` 会由 worker 生成运营素材文件并进入 `content_assets`;异步 `daily_practice_zip` 会生成运营图片包,适合租户后台“一键下载每日一练素材”。
|
||||
|
||||
`daily_practice_zip` 产物结构:
|
||||
|
||||
```text
|
||||
manifest.json 导出任务、品牌、主题、文件清单和脱敏策略
|
||||
payload.json 后端导出 payload,遵守 includeAnswers/includeExplanations
|
||||
collage.png 3x3 九宫格拼图
|
||||
collage.svg 拼图 SVG 源文件
|
||||
cards/card-01.png 单张卡片 PNG
|
||||
cards/card-01.svg 单张卡片 SVG 源文件
|
||||
...
|
||||
cards/card-09.png
|
||||
cards/card-09.svg
|
||||
```
|
||||
|
||||
前端只需要轮询导出 job 并下载 ZIP,不需要在 Taro 端重写图片渲染。后台可读取 `manifest.json.files.cards` 做下载后预览;若导出时 `includeAnswers=false`,`payload.json` 和卡片都不会包含答案/解析。
|
||||
|
||||
响应关键结构:
|
||||
|
||||
@@ -1184,14 +1230,14 @@ worker 完成后,`GET /api/tenant-content/exports/jobs` 返回:
|
||||
"items": [
|
||||
{
|
||||
"id": "...",
|
||||
"format": "pdf",
|
||||
"format": "daily_practice_zip",
|
||||
"status": "completed",
|
||||
"assetId": "...",
|
||||
"attemptCount": 1,
|
||||
"outputMetadata": {
|
||||
"delivery": "content_asset",
|
||||
"fileName": "天津专升本模拟试卷.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"fileName": "每日一练-第-2-期.zip",
|
||||
"mimeType": "application/zip",
|
||||
"sizeBytes": 123456,
|
||||
"checksumSha256": "...",
|
||||
"assetId": "..."
|
||||
@@ -1207,9 +1253,9 @@ worker 完成后,`GET /api/tenant-content/exports/jobs` 返回:
|
||||
- 下载 JSON 时使用 `files[0].contentBase64` 生成 Blob,文件名使用后端返回的 `filename`。
|
||||
- `includeAnswers=false` 时,顶层答案字段和阅读理解/案例分析的子题答案都会被后端脱敏;前端不要在本地重新合并答案。
|
||||
- `includeExplanations=false` 时,不展示解析,也不要从题目详情接口额外补解析。
|
||||
- `paper_json` 可用于后台试卷预览和打印;`pdf/docx` 用于正式文件下载、资料发布和带水印留档。
|
||||
- `paper_json` 可用于后台试卷预览和打印;`pdf/docx` 用于正式文件下载、资料发布和带水印留档;`daily_practice_zip` 用于每日一练运营图片包。
|
||||
- `GET /api/tenant-content/exports/jobs?scopeType=collection&scopeId=...` 用于后台导出历史;结构化导出只记录 metadata 和输出 hash,二进制导出记录 `assetId`、文件名、大小和 checksum。
|
||||
- `status=pending/rendering` 时展示生成中;`completed` 且存在 `assetId` 后,后台可调用 `POST /api/tenant-content/assets/sign-download` 下载,PDF 也可调用 `POST /api/tenant-content/assets/sign-preview` 预览。
|
||||
- `status=pending/rendering` 时展示生成中;`completed` 且存在 `assetId` 后,后台可调用 `POST /api/tenant-content/assets/sign-download` 下载,PDF/图片资源也可调用 `POST /api/tenant-content/assets/sign-preview` 预览。ZIP 包通常直接下载,不做 inline 预览。
|
||||
- 学生资料页只展示 `content_assets.status=active` 且非 `private` 的资源;后台私有导出不会出现在学生资料列表。
|
||||
- 跨租户导出会返回 404 或 403,前端不要重试其它租户 ID。
|
||||
|
||||
|
||||
217
package-lock.json
generated
217
package-lock.json
generated
@@ -84,9 +84,11 @@
|
||||
"name": "@tiku-saas/worker",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@supabase/storage-js": "^2.108.2",
|
||||
"ali-oss": "^6.23.0",
|
||||
"docx": "^9.7.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
@@ -3281,6 +3283,221 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
"integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@resvg/resvg-js-android-arm-eabi": "2.6.2",
|
||||
"@resvg/resvg-js-android-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-x64": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm-eabi": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
|
||||
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-x64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
|
||||
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
|
||||
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rnx-kit/babel-preset-metro-react-native": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@rnx-kit/babel-preset-metro-react-native/-/babel-preset-metro-react-native-1.1.8.tgz",
|
||||
|
||||
@@ -2849,6 +2849,47 @@ async function testTenantContentAdmin() {
|
||||
'daily practice export should cap question count for nine-grid material',
|
||||
);
|
||||
|
||||
const invalidDailyPracticeZip = await request('/api/tenant-content/exports/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
scopeType: 'collection',
|
||||
scopeId: collection.item.id,
|
||||
format: 'daily_practice_zip',
|
||||
exportType: 'paper',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(
|
||||
invalidDailyPracticeZip.code,
|
||||
'DAILY_PRACTICE_ZIP_REQUIRES_DAILY_PRACTICE',
|
||||
'daily practice ZIP export should reject non-daily-practice export types',
|
||||
);
|
||||
|
||||
const dailyPracticeZipJob = await request('/api/tenant-content/exports/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
scopeType: 'collection',
|
||||
scopeId: collection.item.id,
|
||||
format: 'daily_practice_zip',
|
||||
exportType: 'daily_practice',
|
||||
includeAnswers: false,
|
||||
includeExplanations: false,
|
||||
limit: 99,
|
||||
options: {
|
||||
title: '集成测试每日一练图片包',
|
||||
issue: '每日一练 图片包',
|
||||
date: '2026-06-29',
|
||||
theme: 'default',
|
||||
publishToAssets: true,
|
||||
assetVisibility: 'tenant',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(dailyPracticeZipJob.job?.status, 'pending', 'daily practice ZIP export should create a pending async job');
|
||||
assert.equal(dailyPracticeZipJob.export, null, 'daily practice ZIP export should not return inline ZIP payload');
|
||||
|
||||
const pdfExportJob = await request('/api/tenant-content/exports/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
@@ -2909,6 +2950,9 @@ async function testTenantContentAdmin() {
|
||||
assert.equal(listedPdfJob?.status, 'pending', 'question export jobs should expose pending binary export jobs');
|
||||
assert.equal(listedPdfJob?.assetId, null, 'pending binary export job should not have an asset yet');
|
||||
assert.equal(listedPdfJob?.attemptCount, 0, 'pending binary export job should expose worker attempt count');
|
||||
const listedDailyPracticeZipJob = exportJobs.items?.find(item => item.id === dailyPracticeZipJob.job.id);
|
||||
assert.equal(listedDailyPracticeZipJob?.format, 'daily_practice_zip', 'export jobs should list pending daily practice ZIP jobs');
|
||||
assert.equal(listedDailyPracticeZipJob?.status, 'pending', 'pending daily practice ZIP job should be visible to tenant content admins');
|
||||
const invalidExportJobsScope = await request('/api/tenant-content/exports/jobs', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { scopeType: 'not_supported' },
|
||||
|
||||
@@ -16,9 +16,10 @@ const ids = {
|
||||
docxJob: '21000000-0000-0000-0000-000000000902',
|
||||
noAnswerDocxJob: '21000000-0000-0000-0000-000000000903',
|
||||
dailyPdfJob: '21000000-0000-0000-0000-000000000904',
|
||||
dailyZipJob: '21000000-0000-0000-0000-000000000905',
|
||||
};
|
||||
|
||||
const jobIds = [ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, ids.dailyPdfJob];
|
||||
const jobIds = [ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, ids.dailyPdfJob, ids.dailyZipJob];
|
||||
|
||||
async function runWorkerOnce() {
|
||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'exports'], {
|
||||
@@ -119,9 +120,17 @@ async function seed(pool) {
|
||||
'{"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'
|
||||
),
|
||||
(
|
||||
$8, $4, $5, 'daily_practice', 'daily_practice_zip', 'collection', $6,
|
||||
'pending', false, false, false,
|
||||
0, '{"source":"export-worker-test"}'::jsonb,
|
||||
'{"title":"每日一练图片包测试","issue":"每日一练 第 2 期","date":"2026-06-29","theme":"ink","cardFormat":"1:1","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.dailyPdfJob],
|
||||
[ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, tenantId, tenantAdminUserId, collectionId, ids.dailyPdfJob, ids.dailyZipJob],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,6 +150,8 @@ async function assertGeneratedFile(asset) {
|
||||
assert.ok(body.length > 500, 'generated binary export should not be empty');
|
||||
if (asset.mime_type === 'application/pdf') {
|
||||
assert.equal(body.subarray(0, 4).toString('utf8'), '%PDF', 'PDF export should have PDF header');
|
||||
} else if (asset.mime_type === 'application/zip') {
|
||||
assert.equal(body.subarray(0, 2).toString('hex'), '504b', 'ZIP export should have ZIP header');
|
||||
} else {
|
||||
assert.equal(body.subarray(0, 2).toString('hex'), '504b', 'DOCX export should be a zipped Office file');
|
||||
}
|
||||
@@ -157,6 +168,19 @@ async function docxText(asset) {
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
async function zipEntries(asset) {
|
||||
const zip = await JSZip.loadAsync(await generatedFileBody(asset));
|
||||
const manifestText = await zip.file('manifest.json')?.async('string');
|
||||
const payloadText = await zip.file('payload.json')?.async('string');
|
||||
assert.ok(manifestText, 'daily practice ZIP should contain manifest.json');
|
||||
assert.ok(payloadText, 'daily practice ZIP should contain payload.json');
|
||||
return {
|
||||
manifest: JSON.parse(manifestText),
|
||||
payload: JSON.parse(payloadText),
|
||||
names: Object.keys(zip.files).filter(name => !zip.files[name].dir),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
let seeded = false;
|
||||
@@ -168,7 +192,7 @@ async function main() {
|
||||
seeded = true;
|
||||
|
||||
const output = await runWorkerOnce();
|
||||
assert.ok(countFromWorkerOutput(output, 'completed') >= 4, 'worker should complete all binary export jobs');
|
||||
assert.ok(countFromWorkerOutput(output, 'completed') >= 5, 'worker should complete all binary export jobs');
|
||||
|
||||
const jobs = await pool.query(
|
||||
`
|
||||
@@ -179,7 +203,7 @@ async function main() {
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
assert.equal(jobs.rowCount, 4, 'all export jobs should still exist');
|
||||
assert.equal(jobs.rowCount, 5, 'all export jobs should still exist');
|
||||
for (const job of jobs.rows) {
|
||||
assert.equal(job.status, 'completed', 'export job should be completed');
|
||||
assert.ok(job.asset_id, 'completed export job should reference a content asset');
|
||||
@@ -199,30 +223,38 @@ async function main() {
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
assert.equal(assets.rowCount, 4, 'worker should create four export assets');
|
||||
assert.equal(assets.rowCount, 5, 'worker should create five export assets');
|
||||
const pdfAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.pdfJob);
|
||||
const docxAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.docxJob);
|
||||
const noAnswerDocxAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.noAnswerDocxJob);
|
||||
const dailyPdfAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.dailyPdfJob);
|
||||
const dailyZipAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.dailyZipJob);
|
||||
assert.ok(pdfAsset, 'PDF export should create a PDF 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(dailyPdfAsset, 'daily practice PDF export should create a PDF content asset');
|
||||
assert.ok(dailyZipAsset, 'daily practice ZIP export should create a package content asset');
|
||||
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.equal(dailyZipAsset.mime_type, 'application/zip', 'daily practice package should use application/zip');
|
||||
assert.equal(dailyZipAsset.asset_type, 'package', 'daily practice ZIP should use package asset type');
|
||||
assert.equal(pdfAsset.upload_status, 'verified', 'PDF 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(dailyPdfAsset.upload_status, 'verified', 'daily practice PDF asset should be verified');
|
||||
assert.equal(dailyZipAsset.upload_status, 'verified', 'daily practice ZIP asset should be verified');
|
||||
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(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(dailyZipAsset.visibility, 'tenant', 'daily practice ZIP export should use requested asset visibility');
|
||||
assert.equal(dailyPdfAsset.metadata?.exportType, 'daily_practice', 'daily practice asset should keep export type metadata');
|
||||
assert.equal(dailyZipAsset.metadata?.format, 'daily_practice_zip', 'daily practice package should keep export format metadata');
|
||||
await assertGeneratedFile(pdfAsset);
|
||||
await assertGeneratedFile(docxAsset);
|
||||
await assertGeneratedFile(noAnswerDocxAsset);
|
||||
await assertGeneratedFile(dailyPdfAsset);
|
||||
await assertGeneratedFile(dailyZipAsset);
|
||||
|
||||
const answeredDocxText = await docxText(docxAsset);
|
||||
assert.match(answeredDocxText, /答案/, 'DOCX with answers should include answer labels');
|
||||
@@ -231,6 +263,26 @@ async function main() {
|
||||
assert.doesNotMatch(noAnswerDocxText, /后端需要统一校验租户/, 'DOCX without answers should not leak subjective answer text');
|
||||
assert.doesNotMatch(noAnswerDocxText, /基础加法。/, 'DOCX without explanations should not leak explanations');
|
||||
|
||||
const dailyZip = await zipEntries(dailyZipAsset);
|
||||
assert.equal(dailyZip.manifest.exportType, 'daily_practice', 'daily practice package manifest should keep export type');
|
||||
assert.equal(dailyZip.manifest.format, 'daily_practice_zip', 'daily practice package manifest should keep format');
|
||||
assert.equal(dailyZip.manifest.safety?.includeAnswers, false, 'daily practice package should record answer redaction');
|
||||
assert.ok(dailyZip.names.includes('collage.png'), 'daily practice package should include collage PNG');
|
||||
assert.ok(dailyZip.names.includes('collage.svg'), 'daily practice package should include collage SVG source');
|
||||
assert.equal(dailyZip.names.filter(name => /^cards\/card-\d{2}\.png$/.test(name)).length, 9, 'daily practice package should include nine PNG cards');
|
||||
assert.equal(dailyZip.names.filter(name => /^cards\/card-\d{2}\.svg$/.test(name)).length, 9, 'daily practice package should include nine SVG card sources');
|
||||
assert.equal(dailyZip.payload.questions?.[0]?.answerText, undefined, 'daily practice package payload should omit answer text when disabled');
|
||||
assert.equal(dailyZip.payload.questions?.[0]?.explanation, undefined, 'daily practice package payload should omit explanations when disabled');
|
||||
assert.ok(
|
||||
dailyZip.payload.summary?.cardCount >= 1 && dailyZip.payload.summary?.cardCount <= 8,
|
||||
'daily practice package payload should cap card count to the nine-grid question slots',
|
||||
);
|
||||
assert.equal(
|
||||
dailyZip.payload.summary?.cardCount,
|
||||
dailyZip.manifest.summary?.cardCount,
|
||||
'daily practice package manifest and payload should agree on card count',
|
||||
);
|
||||
|
||||
const audits = await pool.query(
|
||||
`
|
||||
select action, details
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
alter table public.content_export_jobs
|
||||
drop constraint if exists content_export_jobs_format_check;
|
||||
|
||||
alter table public.content_export_jobs
|
||||
add constraint content_export_jobs_format_check
|
||||
check (format in ('json', 'paper_json', 'print_payload', 'pdf', 'docx', 'daily_practice_zip'));
|
||||
Reference in New Issue
Block a user