Compare commits
1 Commits
83a90407b0
...
feat-3.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c090675255 |
@@ -15,7 +15,8 @@
|
|||||||
"test:db": "pnpm prisma:generate && node scripts/test-auth-db.mjs",
|
"test:db": "pnpm prisma:generate && node scripts/test-auth-db.mjs",
|
||||||
"categories:import": "node scripts/import-categories.mjs",
|
"categories:import": "node scripts/import-categories.mjs",
|
||||||
"worksheets:import": "node scripts/import-worksheets.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": {
|
"dependencies": {
|
||||||
"@doodle/contracts": "workspace:*",
|
"@doodle/contracts": "workspace:*",
|
||||||
|
|||||||
@@ -3,3 +3,7 @@
|
|||||||
后续加入 CloudBase 数据导出/导入、七牛文件迁移、关联与计数审计、备份恢复等脚本。
|
后续加入 CloudBase 数据导出/导入、七牛文件迁移、关联与计数审计、备份恢复等脚本。
|
||||||
|
|
||||||
当前未执行任何迁移或外部写入。真实导出、文件和凭证不应提交源码仓库。
|
当前未执行任何迁移或外部写入。真实导出、文件和凭证不应提交源码仓库。
|
||||||
|
|
||||||
|
### 初始化独立作业纸条目
|
||||||
|
|
||||||
|
在工作区根目录运行 `pnpm paper-sheets:seed` 预演,追加 `--apply` 创建缺少的十种纸张草稿。工具仅允许本机 doodle_dev,原 paper-sheet 必须存在;不改写已有条目、旧收藏或历史计数。新条目复用 paper-sheet 绘制器并保存固定 paperType 配置。草稿封面暂借原入口,需在开发版生成各自真实封面后正常发布。详见 [纸张条目设计](../../../docs/v3.5/Worksheet数据库设计说明.md)。
|
||||||
|
|||||||
@@ -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 { isISO8601 } from 'class-validator';
|
||||||
import type { Prisma, Worksheet } from '../generated/prisma/client';
|
import type { Prisma, Worksheet } from '../generated/prisma/client';
|
||||||
import { PrismaService } from '../database/prisma.service';
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
import { paperSheetPreset } from '../worksheets/paper-sheet-presets';
|
||||||
import { ApiException } from '../common/api-exception';
|
import { ApiException } from '../common/api-exception';
|
||||||
|
|
||||||
type WorksheetData = Omit<
|
type WorksheetData = Omit<
|
||||||
@@ -242,6 +243,8 @@ export function parseWorksheetExport(source: string): ParsedWorksheets {
|
|||||||
rendererKey: null,
|
rendererKey: null,
|
||||||
renderConfig: {},
|
renderConfig: {},
|
||||||
firstPublishedAt: null,
|
firstPublishedAt: null,
|
||||||
|
// 仅显式登记的新纸张条目使用固定配置;旧综合入口仍保留原映射。
|
||||||
|
...paperSheetPreset(id, path),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { paperSheetPreset } from '../worksheets/paper-sheet-presets';
|
||||||
import { WorksheetService } from '../worksheets/worksheet.service';
|
import { WorksheetService } from '../worksheets/worksheet.service';
|
||||||
import type { AdminWorksheetQueryDto } from '../content/content.dto';
|
import type { AdminWorksheetQueryDto } from '../content/content.dto';
|
||||||
import {
|
import {
|
||||||
@@ -273,6 +274,7 @@ export class AdminWorksheetService {
|
|||||||
throw new BadRequestException('小程序路由不合法');
|
throw new BadRequestException('小程序路由不合法');
|
||||||
const status = contentStatus(input.status ?? 'draft');
|
const status = contentStatus(input.status ?? 'draft');
|
||||||
const data = {
|
const data = {
|
||||||
|
...paperSheetPreset(id, path),
|
||||||
title: text(input.title, 100),
|
title: text(input.title, 100),
|
||||||
subtitle: text(input.subtitle, 500, true),
|
subtitle: text(input.subtitle, 500, true),
|
||||||
categoryId,
|
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,
|
runContentQuery,
|
||||||
contentStatus,
|
contentStatus,
|
||||||
} from '../content/content-policy';
|
} from '../content/content-policy';
|
||||||
|
import { publicPaperSheetRendering } from './paper-sheet-presets';
|
||||||
import { previewUrl } from '../content/preview-url';
|
import { previewUrl } from '../content/preview-url';
|
||||||
import {
|
import {
|
||||||
parseAdminWorksheetQuery,
|
parseAdminWorksheetQuery,
|
||||||
@@ -26,6 +27,9 @@ import {
|
|||||||
} from './worksheet-query.dto';
|
} from './worksheet-query.dto';
|
||||||
|
|
||||||
interface WorksheetRow {
|
interface WorksheetRow {
|
||||||
|
generationMode: string;
|
||||||
|
rendererKey: string | null;
|
||||||
|
renderConfig: unknown;
|
||||||
status: string;
|
status: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -228,9 +232,10 @@ export class WorksheetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private columns(isNew: Prisma.Sql): Prisma.Sql {
|
private columns(isNew: Prisma.Sql): Prisma.Sql {
|
||||||
// 不 SELECT w.*,不把旧 grade、seed、内部资产或生成器配置混入公开响应。
|
// 不 SELECT w.*;配置仅供白名单投影,toPublic 不直接返回内部字段。
|
||||||
return Prisma.sql`
|
return Prisma.sql`
|
||||||
w.status, (${visibleWorksheet}) AS visible,
|
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.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,
|
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,
|
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 {
|
private toPublic(row: WorksheetRow): PublicWorksheet {
|
||||||
|
const rendering = publicPaperSheetRendering(row);
|
||||||
return {
|
return {
|
||||||
|
...(rendering ? { rendering } : {}),
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
subtitle: row.subtitle,
|
subtitle: row.subtitle,
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { PrismaService } from '../src/database/prisma.service';
|
|||||||
import { JwtTokenService } from '../src/auth/jwt-token.service';
|
import { JwtTokenService } from '../src/auth/jwt-token.service';
|
||||||
import { ASSET_STORAGE } from '../src/assets/qiniu.gateway';
|
import { ASSET_STORAGE } from '../src/assets/qiniu.gateway';
|
||||||
import { AssetService } from '../src/assets/asset.service';
|
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 { SiteService } from '../src/site/site.service';
|
||||||
import { testConfig } from './test-config';
|
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(
|
await context.test(
|
||||||
'注销删除本人明细、撤销旧 JWT,其余用户不受影响',
|
'注销删除本人明细、撤销旧 JWT,其余用户不受影响',
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -137,3 +137,37 @@ worksheet.is_new 暂保留兼容:新接口以时间派生为准,旧接口仅
|
|||||||
## 发布可见性补充(2026-10-16)
|
## 发布可见性补充(2026-10-16)
|
||||||
|
|
||||||
沿用 worksheets 与 worksheet_resources 的 draft/active/hidden,不新增布尔上线字段。categories 增加同名 status,新建默认草稿;上级未上线会阻止下级公开读取,不覆盖下级状态。管理预览、上下架、首发时间及快照失效已实现,资源公开读取/生成/下载仍待后续接口接入相同策略。详见 [内容发布与可见性](./内容发布与可见性.md)。
|
沿用 worksheets 与 worksheet_resources 的 draft/active/hidden,不新增布尔上线字段。categories 增加同名 status,新建默认草稿;上级未上线会阻止下级公开读取,不覆盖下级状态。管理预览、上下架、首发时间及快照失效已实现,资源公开读取/生成/下载仍待后续接口接入相同策略。详见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||||
|
|
||||||
|
## 独立纸张条目补充(2026-10-23)
|
||||||
|
|
||||||
|
worksheet 表示可以独立展示、分享、收藏、上下架和统计的业务条目,并不要求与绘制页面或绘制实现一一对应。田字格、米字格等需要独立运营时,各自占一条 worksheet,继续使用字符串业务 ID;不新增自增主键或含义重复的 type 字段。
|
||||||
|
|
||||||
|
| 条目 ID | 标题 | renderer_key | render_config |
|
||||||
|
| ---------------------- | ------------- | ------------ | -------------------------------- |
|
||||||
|
| paper-tian | 田字格 | paper-sheet | {"paperType":"tian"} |
|
||||||
|
| paper-mi | 米字格 | paper-sheet | {"paperType":"mi"} |
|
||||||
|
| paper-square | 方格 | paper-sheet | {"paperType":"square"} |
|
||||||
|
| paper-four-line | 四线三格 | paper-sheet | {"paperType":"four-line"} |
|
||||||
|
| paper-letter | 信纸 | paper-sheet | {"paperType":"letter"} |
|
||||||
|
| paper-horizontal | 横线 | paper-sheet | {"paperType":"horizontal"} |
|
||||||
|
| paper-vertical | 竖线 | paper-sheet | {"paperType":"vertical"} |
|
||||||
|
| paper-dictation-hanzi | 汉字听写 | paper-sheet | {"paperType":"dictation-hanzi"} |
|
||||||
|
| paper-dictation-pinyin | 拼音/英语听写 | paper-sheet | {"paperType":"dictation-pinyin"} |
|
||||||
|
| paper-homework-log | 作业登记表 | paper-sheet | {"paperType":"homework-log"} |
|
||||||
|
|
||||||
|
小程序纸张选项和本地条目元数据统一维护于 `config/worksheets/papers.ts`。服务端在 `paper-sheet-presets.ts` 显式登记支持的 ID、路由和参数;发布与旧格式导入复用此映射,不开放任意渲染配置写入。公开接口仅对已匹配的独立条目增加 `rendering: {rendererKey: "paper-sheet", paperType: "mi"}` 等白名单摘要,不透传原始 render_config。客户端同时校验条目 ID 和纸张类型,不认识的条目或错配配置不能默认生成田字格。
|
||||||
|
|
||||||
|
独立条目初始化使用工作区根目录命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paper-sheets:seed
|
||||||
|
pnpm paper-sheets:seed --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
第一条仅预演;第二条只允许本机 `doodle_dev`,事务创建缺少的十种纸张草稿并记录审计、撤销展示快照。已有相同配置条目不修改,ID 被其他配置占用时整批回滚。初始化需要原 `paper-sheet` 已存在,用它的分类和封面建立草稿,年龄和标题来自独立条目定义;仅复制明确配置过的年级关系,不从年龄推断。
|
||||||
|
|
||||||
|
草稿暂借综合入口预览,不能把该封面当作各纸张真实预览。开发版进入相应独立 ID 后,直接使用客户端已注册配置进行本地预览,不请求公开详情或通过 404 判断草稿;可通过调试发布工具导出当前纸张封面并发布。体验版和正式版必须读取到 active 详情及匹配绘制摘要才允许生成,404 或网络失败均不能降级为本地预览。新增条目首次发布时间为空,计数及 seed 从零开始,发布流程负责首次发布时间。不存在原综合入口时可直接通过开发版逐张创建条目。
|
||||||
|
|
||||||
|
旧 `paper-sheet` 的 ID、记录、收藏、计数和发布时间保留,不向新条目分摊。旧链接的 `type` 参数继续生效,综合入口的新分享通过 `mode` 保留纸张选择;进入综合入口时仍以综合条目为收藏和下载目标,主动切换纸张则转为对应独立条目,之后分享、发布、收藏和下载都指向它。颜色变化继续作为用户参数,不改变业务身份。
|
||||||
|
|
||||||
|
本次复用既有字段,不需要 DDL 迁移。worksheet_resources 仍管理“小猫涂色”等功能下面的具体内容,不将这十种独立纸张重复建为资源。
|
||||||
|
|||||||
@@ -125,3 +125,7 @@ page 默认 1,范围 1~1000000;pageSize 默认 20,范围 1~100。超
|
|||||||
## 年龄段匹配调整(2026-10-16)
|
## 年龄段匹配调整(2026-10-16)
|
||||||
|
|
||||||
用户确认将普通题型查询从“最小年龄等于年龄段下限”改为“适用范围完整覆盖所选年龄段”。本次仅修改 ageBand 匹配语义,不修改 grade/grades、age/ageBand 参数格式,不改数据库或小程序云函数。旧分龄推荐接口尚未迁移,其匹配和榜单规则不属于本次普通列表变更。
|
用户确认将普通题型查询从“最小年龄等于年龄段下限”改为“适用范围完整覆盖所选年龄段”。本次仅修改 ageBand 匹配语义,不修改 grade/grades、age/ageBand 参数格式,不改数据库或小程序云函数。旧分龄推荐接口尚未迁移,其匹配和榜单规则不属于本次普通列表变更。
|
||||||
|
|
||||||
|
### 独立作业纸绘制摘要(2026-10-23)
|
||||||
|
|
||||||
|
已登记的 `paper-tian`、`paper-mi` 等十个独立条目在配置匹配时额外返回可选 `rendering`,例如 `{"rendererKey":"paper-sheet","paperType":"mi"}`。这是服务端校验后的公开参数摘要,不包含原始 `renderConfig` 或其他生成器内部字段。未知 renderer、未登记条目或错配配置不返回摘要;新客户端遇到独立纸张摘要缺失或不匹配时提示不可用,不回退生成其他纸张。旧综合入口 `paper-sheet` 的响应保持兼容。
|
||||||
|
|||||||
+2
-1
@@ -28,7 +28,8 @@
|
|||||||
"worksheets:import": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server worksheets:import",
|
"worksheets:import": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server worksheets:import",
|
||||||
"assets:sync": "pnpm --filter @doodle/asset-sync sync",
|
"assets:sync": "pnpm --filter @doodle/asset-sync sync",
|
||||||
"assets:test": "pnpm --filter @doodle/asset-sync test",
|
"assets:test": "pnpm --filter @doodle/asset-sync test",
|
||||||
"assets:map": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server assets:map"
|
"assets:map": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server assets:map",
|
||||||
|
"paper-sheets:seed": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server paper-sheets:seed"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
|
|||||||
@@ -107,8 +107,25 @@ export interface WorksheetQuery {
|
|||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅下发已注册纸张绘制器的公开参数,不透传数据库配置。 */
|
||||||
|
export type PaperSheetRendering = {
|
||||||
|
rendererKey: 'paper-sheet';
|
||||||
|
paperType:
|
||||||
|
| 'tian'
|
||||||
|
| 'mi'
|
||||||
|
| 'square'
|
||||||
|
| 'four-line'
|
||||||
|
| 'letter'
|
||||||
|
| 'horizontal'
|
||||||
|
| 'vertical'
|
||||||
|
| 'dictation-hanzi'
|
||||||
|
| 'dictation-pinyin'
|
||||||
|
| 'homework-log';
|
||||||
|
};
|
||||||
|
|
||||||
/** 公开题型白名单;64 位展示计数使用十进制字符串,避免 JavaScript 精度丢失。 */
|
/** 公开题型白名单;64 位展示计数使用十进制字符串,避免 JavaScript 精度丢失。 */
|
||||||
export interface PublicWorksheet {
|
export interface PublicWorksheet {
|
||||||
|
rendering?: PaperSheetRendering;
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user