feat: 作业纸

This commit is contained in:
R524809
2026-10-23 16:52:47 +08:00
parent 83a90407b0
commit c090675255
14 changed files with 532 additions and 3 deletions
+2 -1
View File
@@ -15,7 +15,8 @@
"test:db": "pnpm prisma:generate && node scripts/test-auth-db.mjs",
"categories:import": "node scripts/import-categories.mjs",
"worksheets:import": "node scripts/import-worksheets.mjs",
"assets:map": "node scripts/map-assets.mjs"
"assets:map": "node scripts/map-assets.mjs",
"paper-sheets:seed": "node scripts/seed-paper-sheets.mjs"
},
"dependencies": {
"@doodle/contracts": "workspace:*",
+4
View File
@@ -3,3 +3,7 @@
后续加入 CloudBase 数据导出/导入、七牛文件迁移、关联与计数审计、备份恢复等脚本。
当前未执行任何迁移或外部写入。真实导出、文件和凭证不应提交源码仓库。
### 初始化独立作业纸条目
在工作区根目录运行 `pnpm paper-sheets:seed` 预演,追加 `--apply` 创建缺少的十种纸张草稿。工具仅允许本机 doodle_dev,原 paper-sheet 必须存在;不改写已有条目、旧收藏或历史计数。新条目复用 paper-sheet 绘制器并保存固定 paperType 配置。草稿封面暂借原入口,需在开发版生成各自真实封面后正常发布。详见 [纸张条目设计](../../../docs/v3.5/Worksheet数据库设计说明.md)。
+38
View File
@@ -0,0 +1,38 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { parseArgs, parseEnv } from 'node:util';
const require = createRequire(import.meta.url);
const { PrismaService } = require('../dist/database/prisma.service.js');
const { seedPaperSheets } = require('../dist/worksheets/paper-sheet-seed.js');
const { HttpException } = require('@nestjs/common');
let database;
try {
const { values } = parseArgs({
options: { apply: { type: 'boolean', default: false } },
});
const local = parseEnv(
readFileSync(new URL('../.env', import.meta.url), 'utf8'),
);
const url = new URL(process.env.DATABASE_URL ?? local.DATABASE_URL);
// 与现有导入工具一致,仅操作本机开发库;默认预演,不代替内容正式发布。
if (
!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.pathname !== '/doodle_dev'
)
throw new Error('作业纸初始化只允许本机 doodle_dev');
database = new PrismaService({ databaseUrl: url.toString() });
console.log(
JSON.stringify(await seedPaperSheets(database, !values.apply), null, 4),
);
} catch (error) {
console.error(
error instanceof HttpException
? error.message
: '作业纸初始化失败,请检查本地数据库及构建结果',
);
process.exitCode = 1;
} finally {
await database?.onModuleDestroy();
}
@@ -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,
@@ -11,6 +11,11 @@ import { PrismaService } from '../src/database/prisma.service';
import { JwtTokenService } from '../src/auth/jwt-token.service';
import { ASSET_STORAGE } from '../src/assets/qiniu.gateway';
import { AssetService } from '../src/assets/asset.service';
import { seedPaperSheets } from '../src/worksheets/paper-sheet-seed';
import {
PAPER_SHEET_PATH,
PAPER_SHEET_PRESETS,
} from '../src/worksheets/paper-sheet-presets';
import { SiteService } from '../src/site/site.service';
import { testConfig } from './test-config';
@@ -707,6 +712,147 @@ test(
);
},
);
await context.test(
'纸张拆分:预演不写库、草稿幂等、独立发布/收藏/下载及历史兼容',
async () => {
const parent = await db.worksheet.create({
data: {
id: 'paper-sheet',
title: '作业纸',
subtitle: '',
categoryId,
path: `${PAPER_SHEET_PATH}?id=paper-sheet`,
ageMin: 3,
ageMax: 12,
difficulty: 1,
previewAssetId: previewId,
status: 'active',
downloads: 123n,
likes: 9n,
},
});
await db.worksheetGrade.create({
data: { worksheetId: parent.id, grade: 0 },
});
const dry = await seedPaperSheets(app.get(PrismaService), true);
assert.equal(
dry.changes.filter((row) => row.action === 'insert-draft')
.length,
10,
);
assert.equal(
await db.worksheet.count({
where: { rendererKey: 'paper-sheet' },
}),
0,
);
await seedPaperSheets(app.get(PrismaService), false);
assert.ok(
(
await seedPaperSheets(app.get(PrismaService), false)
).changes.every((row) => row.action === 'unchanged'),
);
assert.deepEqual(
await db.worksheet.findUnique({ where: { id: parent.id } }),
parent,
);
for (const preset of PAPER_SHEET_PRESETS) {
const draft = await db.worksheet.findUniqueOrThrow({
where: { id: preset.id },
});
assert.equal(draft.status, 'draft');
assert.equal(draft.firstPublishedAt, null);
assert.equal(draft.downloads, 0n);
assert.equal(draft.likes, 0n);
assert.equal(
(await request(`/worksheets/${preset.id}`)).status,
404,
);
}
const input = {
id: 'paper-mi',
title: '米字格',
subtitle: '',
categoryId,
path: `${PAPER_SHEET_PATH}?id=paper-mi`,
ageMin: 3,
ageMax: 12,
difficulty: 1,
previewAssetId: previewId,
tags: ['米字格'],
status: 'active',
};
assert.equal(
(
await request(
'/admin/worksheets/publish',
tokens.admin,
'POST',
{
...input,
path: `${PAPER_SHEET_PATH}?id=paper-tian`,
},
)
).status,
400,
);
assert.equal(
(
await request(
'/admin/worksheets/publish',
tokens.admin,
'POST',
input,
)
).status,
201,
);
const detail = (await request('/worksheets/paper-mi')).body
.data;
assert.deepEqual(detail.rendering, {
rendererKey: 'paper-sheet',
paperType: 'mi',
});
assert.equal(detail.renderConfig, undefined);
assert.equal(
(await request('/me/favorites/paper-mi', tokens.b, 'PUT'))
.status,
200,
);
assert.equal(
(
await request(
'/me/favorites/paper-sheet',
tokens.b,
'PUT',
)
).status,
200,
);
const download = await request(
'/me/downloads',
tokens.b,
'POST',
{ worksheetId: 'paper-mi', exportId: randomUUID() },
);
assert.equal(download.status, 201);
const saved = await db.worksheet.findUniqueOrThrow({
where: { id: 'paper-mi' },
});
assert.equal(saved.downloads, 1n);
assert.equal(saved.likes, 1n);
const old = await db.worksheet.findUniqueOrThrow({
where: { id: parent.id },
});
assert.equal(old.downloads, 123n);
assert.equal(old.likes, 10n);
assert.ok(
await db.favorite.findFirst({
where: { userId: ids.b, worksheetId: parent.id },
}),
);
},
);
await context.test(
'注销删除本人明细、撤销旧 JWT,其余用户不受影响',
async () => {
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
PAPER_SHEET_PATH,
PAPER_SHEET_PRESETS,
paperSheetPreset,
publicPaperSheetRendering,
} from '../src/worksheets/paper-sheet-presets';
import { parseWorksheetExport } from '../src/imports/worksheet-import';
import { worksheetFixture } from './worksheet-import.fixture';
test('独立纸张:固定注册、路由归属、导入配置和公开参数一致', () => {
assert.equal(
new Set(PAPER_SHEET_PRESETS.map((entry) => entry.id)).size,
10,
);
for (const entry of PAPER_SHEET_PRESETS) {
const path = `${PAPER_SHEET_PATH}?id=${entry.id}`;
const config = paperSheetPreset(entry.id, path)!;
const row = parseWorksheetExport(
JSON.stringify([{ ...worksheetFixture, _id: entry.id, path }]),
).rows[0]!.data;
assert.equal(row.rendererKey, 'paper-sheet');
assert.deepEqual(row.renderConfig, { paperType: entry.paperType });
assert.deepEqual(
publicPaperSheetRendering({
id: entry.id,
path,
...config,
renderConfig: {
...config.renderConfig,
privateKey: 'must-not-leak',
},
}),
{ rendererKey: 'paper-sheet', paperType: entry.paperType },
);
assert.throws(() =>
paperSheetPreset(entry.id, `${PAPER_SHEET_PATH}?id=paper-sheet`),
);
assert.equal(
publicPaperSheetRendering({
id: entry.id,
path,
...config,
renderConfig: { paperType: 'unknown' },
}),
undefined,
);
assert.equal(
publicPaperSheetRendering({
id: entry.id,
path,
...config,
rendererKey: 'unknown',
}),
undefined,
);
}
assert.equal(
paperSheetPreset('paper-sheet', `${PAPER_SHEET_PATH}?id=paper-sheet`),
undefined,
);
assert.equal(
paperSheetPreset('paper-unregistered', '/arbitrary'),
undefined,
);
});