diff --git a/apps/server/package.json b/apps/server/package.json index 4ce8640..ce3591d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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:*", diff --git a/apps/server/scripts/README.md b/apps/server/scripts/README.md index ee0d195..2323c7f 100644 --- a/apps/server/scripts/README.md +++ b/apps/server/scripts/README.md @@ -3,3 +3,7 @@ 后续加入 CloudBase 数据导出/导入、七牛文件迁移、关联与计数审计、备份恢复等脚本。 当前未执行任何迁移或外部写入。真实导出、文件和凭证不应提交源码仓库。 + +### 初始化独立作业纸条目 + +在工作区根目录运行 `pnpm paper-sheets:seed` 预演,追加 `--apply` 创建缺少的十种纸张草稿。工具仅允许本机 doodle_dev,原 paper-sheet 必须存在;不改写已有条目、旧收藏或历史计数。新条目复用 paper-sheet 绘制器并保存固定 paperType 配置。草稿封面暂借原入口,需在开发版生成各自真实封面后正常发布。详见 [纸张条目设计](../../../docs/v3.5/Worksheet数据库设计说明.md)。 diff --git a/apps/server/scripts/seed-paper-sheets.mjs b/apps/server/scripts/seed-paper-sheets.mjs new file mode 100644 index 0000000..37fc8aa --- /dev/null +++ b/apps/server/scripts/seed-paper-sheets.mjs @@ -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(); +} diff --git a/apps/server/src/imports/worksheet-import.ts b/apps/server/src/imports/worksheet-import.ts index e4ecc5d..024c085 100644 --- a/apps/server/src/imports/worksheet-import.ts +++ b/apps/server/src/imports/worksheet-import.ts @@ -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), }, }; }); diff --git a/apps/server/src/site/admin-worksheet.service.ts b/apps/server/src/site/admin-worksheet.service.ts index 8417237..16663d1 100644 --- a/apps/server/src/site/admin-worksheet.service.ts +++ b/apps/server/src/site/admin-worksheet.service.ts @@ -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, diff --git a/apps/server/src/worksheets/paper-sheet-presets.ts b/apps/server/src/worksheets/paper-sheet-presets.ts new file mode 100644 index 0000000..8d5d7c0 --- /dev/null +++ b/apps/server/src/worksheets/paper-sheet-presets.ts @@ -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 }; +} diff --git a/apps/server/src/worksheets/paper-sheet-seed.ts b/apps/server/src/worksheets/paper-sheet-seed.ts new file mode 100644 index 0000000..d040b37 --- /dev/null +++ b/apps/server/src/worksheets/paper-sheet-seed.ts @@ -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 }; + }); +} diff --git a/apps/server/src/worksheets/worksheet.service.ts b/apps/server/src/worksheets/worksheet.service.ts index 2db516b..79525ff 100644 --- a/apps/server/src/worksheets/worksheet.service.ts +++ b/apps/server/src/worksheets/worksheet.service.ts @@ -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, diff --git a/apps/server/test/member-site-database.test.ts b/apps/server/test/member-site-database.test.ts index 34edbb4..69b6174 100644 --- a/apps/server/test/member-site-database.test.ts +++ b/apps/server/test/member-site-database.test.ts @@ -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 () => { diff --git a/apps/server/test/paper-sheet.test.ts b/apps/server/test/paper-sheet.test.ts new file mode 100644 index 0000000..6d86f2c --- /dev/null +++ b/apps/server/test/paper-sheet.test.ts @@ -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, + ); +}); diff --git a/docs/v3.5/Worksheet数据库设计说明.md b/docs/v3.5/Worksheet数据库设计说明.md index 66b0c1f..bdb8a08 100644 --- a/docs/v3.5/Worksheet数据库设计说明.md +++ b/docs/v3.5/Worksheet数据库设计说明.md @@ -137,3 +137,37 @@ worksheet.is_new 暂保留兼容:新接口以时间派生为准,旧接口仅 ## 发布可见性补充(2026-10-16) 沿用 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 仍管理“小猫涂色”等功能下面的具体内容,不将这十种独立纸张重复建为资源。 diff --git a/docs/v3.5/题型查询接口说明.md b/docs/v3.5/题型查询接口说明.md index 363d88c..1b8a996 100644 --- a/docs/v3.5/题型查询接口说明.md +++ b/docs/v3.5/题型查询接口说明.md @@ -125,3 +125,7 @@ page 默认 1,范围 1~1000000;pageSize 默认 20,范围 1~100。超 ## 年龄段匹配调整(2026-10-16) 用户确认将普通题型查询从“最小年龄等于年龄段下限”改为“适用范围完整覆盖所选年龄段”。本次仅修改 ageBand 匹配语义,不修改 grade/grades、age/ageBand 参数格式,不改数据库或小程序云函数。旧分龄推荐接口尚未迁移,其匹配和榜单规则不属于本次普通列表变更。 + +### 独立作业纸绘制摘要(2026-10-23) + +已登记的 `paper-tian`、`paper-mi` 等十个独立条目在配置匹配时额外返回可选 `rendering`,例如 `{"rendererKey":"paper-sheet","paperType":"mi"}`。这是服务端校验后的公开参数摘要,不包含原始 `renderConfig` 或其他生成器内部字段。未知 renderer、未登记条目或错配配置不返回摘要;新客户端遇到独立纸张摘要缺失或不匹配时提示不可用,不回退生成其他纸张。旧综合入口 `paper-sheet` 的响应保持兼容。 diff --git a/package.json b/package.json index 99a5a09..e8a880e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "worksheets:import": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server worksheets:import", "assets:sync": "pnpm --filter @doodle/asset-sync sync", "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": { "@eslint/js": "10.0.1", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 136ca44..04426df 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -107,8 +107,25 @@ export interface WorksheetQuery { 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 精度丢失。 */ export interface PublicWorksheet { + rendering?: PaperSheetRendering; id: string; title: string; subtitle: string;