feat: 作业纸
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
import { isISO8601 } from 'class-validator';
|
||||
import type { Prisma, Worksheet } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { paperSheetPreset } from '../worksheets/paper-sheet-presets';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
|
||||
type WorksheetData = Omit<
|
||||
@@ -242,6 +243,8 @@ export function parseWorksheetExport(source: string): ParsedWorksheets {
|
||||
rendererKey: null,
|
||||
renderConfig: {},
|
||||
firstPublishedAt: null,
|
||||
// 仅显式登记的新纸张条目使用固定配置;旧综合入口仍保留原映射。
|
||||
...paperSheetPreset(id, path),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { paperSheetPreset } from '../worksheets/paper-sheet-presets';
|
||||
import { WorksheetService } from '../worksheets/worksheet.service';
|
||||
import type { AdminWorksheetQueryDto } from '../content/content.dto';
|
||||
import {
|
||||
@@ -273,6 +274,7 @@ export class AdminWorksheetService {
|
||||
throw new BadRequestException('小程序路由不合法');
|
||||
const status = contentStatus(input.status ?? 'draft');
|
||||
const data = {
|
||||
...paperSheetPreset(id, path),
|
||||
title: text(input.title, 100),
|
||||
subtitle: text(input.subtitle, 500, true),
|
||||
categoryId,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { PaperSheetRendering } from '@doodle/contracts';
|
||||
|
||||
/** 显式登记客户端支持的业务条目,不从任意 worksheet ID 猜测绘制实现。 */
|
||||
export const PAPER_SHEET_PRESETS = [
|
||||
{ id: 'paper-tian', paperType: 'tian', title: '田字格', desc: '汉字练习' },
|
||||
{ id: 'paper-mi', paperType: 'mi', title: '米字格', desc: '书法练习' },
|
||||
{
|
||||
id: 'paper-square',
|
||||
paperType: 'square',
|
||||
title: '方格',
|
||||
desc: '书法练字纸',
|
||||
},
|
||||
{
|
||||
id: 'paper-four-line',
|
||||
paperType: 'four-line',
|
||||
title: '四线三格',
|
||||
desc: '拼音 / 字母',
|
||||
},
|
||||
{
|
||||
id: 'paper-letter',
|
||||
paperType: 'letter',
|
||||
title: '信纸',
|
||||
desc: '横线信笺',
|
||||
},
|
||||
{
|
||||
id: 'paper-horizontal',
|
||||
paperType: 'horizontal',
|
||||
title: '横线',
|
||||
desc: '横线本',
|
||||
},
|
||||
{
|
||||
id: 'paper-vertical',
|
||||
paperType: 'vertical',
|
||||
title: '竖线',
|
||||
desc: '竖排书写',
|
||||
},
|
||||
{
|
||||
id: 'paper-dictation-hanzi',
|
||||
paperType: 'dictation-hanzi',
|
||||
title: '汉字听写',
|
||||
desc: '田字格 + 订正栏',
|
||||
},
|
||||
{
|
||||
id: 'paper-dictation-pinyin',
|
||||
paperType: 'dictation-pinyin',
|
||||
title: '拼音/英语听写',
|
||||
desc: '四线三格 + 订正栏',
|
||||
},
|
||||
{
|
||||
id: 'paper-homework-log',
|
||||
paperType: 'homework-log',
|
||||
title: '作业登记表',
|
||||
desc: '分科登记',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: string;
|
||||
paperType: PaperSheetRendering['paperType'];
|
||||
title: string;
|
||||
desc: string;
|
||||
}>;
|
||||
|
||||
export const PAPER_SHEET_PATH = '/papersPages/paperSheet/paperSheet';
|
||||
|
||||
/** 发布与旧格式导入共用固定映射;路由须指向同一业务条目,禁止身份与绘制结果错配。 */
|
||||
export function paperSheetPreset(id: string, path: string) {
|
||||
const preset = PAPER_SHEET_PRESETS.find((entry) => entry.id === id);
|
||||
if (!preset) return undefined;
|
||||
if (path !== `${PAPER_SHEET_PATH}?id=${id}`)
|
||||
throw new BadRequestException('作业纸入口必须匹配已注册条目 ID');
|
||||
return {
|
||||
generationMode: 'procedural' as const,
|
||||
rendererKey: 'paper-sheet',
|
||||
renderConfig: { paperType: preset.paperType },
|
||||
};
|
||||
}
|
||||
|
||||
/** 公开响应只投影已验证字段,内部配置、未知实现和额外参数均不透传。 */
|
||||
export function publicPaperSheetRendering(row: {
|
||||
id: string;
|
||||
path: string;
|
||||
generationMode?: string;
|
||||
rendererKey?: string | null;
|
||||
renderConfig?: unknown;
|
||||
}): PaperSheetRendering | undefined {
|
||||
const preset = PAPER_SHEET_PRESETS.find((entry) => entry.id === row.id);
|
||||
const config = row.renderConfig;
|
||||
if (
|
||||
!preset ||
|
||||
row.path !== `${PAPER_SHEET_PATH}?id=${row.id}` ||
|
||||
row.generationMode !== 'procedural' ||
|
||||
row.rendererKey !== 'paper-sheet' ||
|
||||
!config ||
|
||||
typeof config !== 'object' ||
|
||||
Array.isArray(config) ||
|
||||
!('paperType' in config) ||
|
||||
config.paperType !== preset.paperType
|
||||
)
|
||||
return undefined;
|
||||
return { rendererKey: 'paper-sheet', paperType: preset.paperType };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { invalidateContentSnapshots } from '../content/content-policy';
|
||||
import {
|
||||
PAPER_SHEET_PATH,
|
||||
PAPER_SHEET_PRESETS,
|
||||
paperSheetPreset,
|
||||
} from './paper-sheet-presets';
|
||||
|
||||
/** 从综合入口创建独立草稿;历史身份、收藏、计数、发布时间和已有条目均不改写。 */
|
||||
export async function seedPaperSheets(
|
||||
database: PrismaService,
|
||||
dryRun: boolean,
|
||||
) {
|
||||
return database.client.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
|
||||
await tx.$executeRaw`LOCK TABLE public.assets, public.worksheets IN SHARE ROW EXCLUSIVE MODE`;
|
||||
const parent = await tx.worksheet.findUnique({
|
||||
where: { id: 'paper-sheet' },
|
||||
});
|
||||
if (!parent)
|
||||
throw new NotFoundException(
|
||||
'请先发布或导入原 paper-sheet 综合入口',
|
||||
);
|
||||
const grades = await tx.worksheetGrade.findMany({
|
||||
where: { worksheetId: parent.id },
|
||||
});
|
||||
const changes: Array<{
|
||||
id: string;
|
||||
action: 'insert-draft' | 'unchanged';
|
||||
}> = [];
|
||||
const requestId = `paper-sheets-seed-${randomUUID()}`;
|
||||
for (const [index, preset] of PAPER_SHEET_PRESETS.entries()) {
|
||||
const path = `${PAPER_SHEET_PATH}?id=${preset.id}`;
|
||||
const rendering = paperSheetPreset(preset.id, path)!;
|
||||
const existing = await tx.worksheet.findUnique({
|
||||
where: { id: preset.id },
|
||||
});
|
||||
if (existing) {
|
||||
const config = existing.renderConfig;
|
||||
if (
|
||||
existing.path !== path ||
|
||||
existing.rendererKey !== rendering.rendererKey ||
|
||||
existing.generationMode !== rendering.generationMode ||
|
||||
!config ||
|
||||
typeof config !== 'object' ||
|
||||
Array.isArray(config) ||
|
||||
config.paperType !== preset.paperType
|
||||
)
|
||||
throw new ConflictException(
|
||||
`条目 ${preset.id} 已被其他配置占用,未修改任何数据`,
|
||||
);
|
||||
changes.push({ id: preset.id, action: 'unchanged' });
|
||||
continue;
|
||||
}
|
||||
changes.push({ id: preset.id, action: 'insert-draft' });
|
||||
if (dryRun) continue;
|
||||
await tx.worksheet.create({
|
||||
data: {
|
||||
id: preset.id,
|
||||
title: preset.title,
|
||||
subtitle: `${preset.desc},一键生成打印`,
|
||||
categoryId: parent.categoryId,
|
||||
subcategory: 'paper-sheet',
|
||||
path,
|
||||
ageMin: 3,
|
||||
ageMax: 12,
|
||||
difficulty: 1,
|
||||
// 草稿暂借综合入口封面;开发版逐张生成真实封面,再按正常发布流程上架。
|
||||
previewAssetId: parent.previewAssetId,
|
||||
tags: ['作业纸', preset.title, preset.desc],
|
||||
sortOrder: 51 + index,
|
||||
status: 'draft',
|
||||
...rendering,
|
||||
},
|
||||
});
|
||||
if (grades.length)
|
||||
await tx.worksheetGrade.createMany({
|
||||
data: grades.map(({ grade }) => ({
|
||||
worksheetId: preset.id,
|
||||
grade,
|
||||
})),
|
||||
});
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
actorType: 'system',
|
||||
action: 'worksheet.seed',
|
||||
resourceType: 'worksheets',
|
||||
resourceId: preset.id,
|
||||
requestId,
|
||||
afterData: { status: 'draft', ...rendering },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
!dryRun &&
|
||||
changes.some((change) => change.action === 'insert-draft')
|
||||
)
|
||||
await invalidateContentSnapshots(tx);
|
||||
return { dryRun, requestId, changes };
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
runContentQuery,
|
||||
contentStatus,
|
||||
} from '../content/content-policy';
|
||||
import { publicPaperSheetRendering } from './paper-sheet-presets';
|
||||
import { previewUrl } from '../content/preview-url';
|
||||
import {
|
||||
parseAdminWorksheetQuery,
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
} from './worksheet-query.dto';
|
||||
|
||||
interface WorksheetRow {
|
||||
generationMode: string;
|
||||
rendererKey: string | null;
|
||||
renderConfig: unknown;
|
||||
status: string;
|
||||
visible: boolean;
|
||||
id: string;
|
||||
@@ -228,9 +232,10 @@ export class WorksheetService {
|
||||
}
|
||||
|
||||
private columns(isNew: Prisma.Sql): Prisma.Sql {
|
||||
// 不 SELECT w.*,不把旧 grade、seed、内部资产或生成器配置混入公开响应。
|
||||
// 不 SELECT w.*;配置仅供白名单投影,toPublic 不直接返回内部字段。
|
||||
return Prisma.sql`
|
||||
w.status, (${visibleWorksheet}) AS visible,
|
||||
w.generation_mode AS "generationMode", w.renderer_key AS "rendererKey", w.render_config AS "renderConfig",
|
||||
w.id, w.title, w.subtitle, w.category_id AS category, w.subcategory, w.path,
|
||||
w.age_min AS "ageMin", w.age_max AS "ageMax", w.difficulty, w.tags,
|
||||
array_to_json(ARRAY(SELECT grade FROM public.worksheet_grades g WHERE g.worksheet_id = w.id ORDER BY grade)) AS grades,
|
||||
@@ -244,7 +249,9 @@ export class WorksheetService {
|
||||
}
|
||||
|
||||
private toPublic(row: WorksheetRow): PublicWorksheet {
|
||||
const rendering = publicPaperSheetRendering(row);
|
||||
return {
|
||||
...(rendering ? { rendering } : {}),
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
|
||||
Reference in New Issue
Block a user