Compare commits
3 Commits
69ec885803
...
feat-3.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c090675255 | |||
| 83a90407b0 | |||
| a3c70bf1a7 |
@@ -0,0 +1,27 @@
|
||||
# 构建产物必须在目标 Linux 平台重新生成。
|
||||
**/node_modules
|
||||
**/.pnpm-store
|
||||
**/dist
|
||||
**/dist-test
|
||||
**/coverage
|
||||
**/*.tsbuildinfo
|
||||
apps/server/src/generated
|
||||
**/.git
|
||||
**/.DS_Store
|
||||
**/*.log
|
||||
# 真实密钥、备份与资源报告禁止进入镜像;仅保留示例配置。
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
**/server.env
|
||||
**/server.env.*
|
||||
!**/server.env.example
|
||||
deploy/data
|
||||
deploy/backups
|
||||
**/*.dump
|
||||
**/*.tar
|
||||
**/*.tar.gz
|
||||
packages/asset-sync/reports
|
||||
.agents
|
||||
.codex
|
||||
docs
|
||||
@@ -12,3 +12,6 @@ coverage/
|
||||
!.env.example
|
||||
/deploy/data/
|
||||
/deploy/backups/
|
||||
/deploy/server.env
|
||||
/deploy/server.env.*
|
||||
!/deploy/server.env.example
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
后续加入 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 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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
# 在 Linux 内安装依赖和生成 Prisma 客户端,不能复用开发机的 node_modules。
|
||||
FROM node:24.20.0-bookworm-slim AS base
|
||||
# 按构建网络选择 npm 源;不修改开发机配置,也不改变锁定依赖版本。
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& npm install --global pnpm@11.24.0 --registry="${NPM_REGISTRY}"
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS build
|
||||
COPY . .
|
||||
RUN pnpm install --frozen-lockfile --filter @doodle/server... --registry="${NPM_REGISTRY}"
|
||||
RUN pnpm --filter @doodle/server... run build
|
||||
|
||||
FROM base AS runtime
|
||||
ENV NODE_ENV=production HOST=0.0.0.0 PORT=8800
|
||||
# 首版保留构建依赖及 Prisma CLI,允许用同一版本镜像执行独立迁移任务。
|
||||
# 后续优化镜像体积时应拆分 migrator,不能直接删掉迁移命令所需的依赖。
|
||||
COPY --from=build --chown=node:node /app /app
|
||||
USER node
|
||||
EXPOSE 8800
|
||||
CMD ["node", "apps/server/dist/main.js"]
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Doodle 云端部署准备
|
||||
|
||||
更新时间:2026-10-20。本文件记录部署方案与待确认条件。已在 1Panel 申请根域及泛域名证书,并安装 PostgreSQL;尚未创建 Doodle 云端应用、应用域名 DNS 记录或镜像仓库,也未改动旧线上云开发链路。
|
||||
更新时间:2026-10-22。用户已确认 `doodle_prod` 数据库准备完成;本轮未现场重新核验云端数据,未部署 API 或修改旧线上云开发链路。
|
||||
|
||||
下一步按 [服务端首次部署操作手册](../docs/v3.5/服务端首次部署操作手册.md) 操作。已补齐 `Dockerfile.server`、`compose.api.yaml` 和两份环境配置模板,采用本机构建 `linux/amd64` 镜像、1Panel 上传导入、复用现有 PostgreSQL 与 OpenResty 的流程。下文的早期计划与状态记录如有差异,以该手册及本段最新状态为准。
|
||||
|
||||
## 已确认条件
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: doodle-prod
|
||||
|
||||
services:
|
||||
api:
|
||||
image: ${DOODLE_API_IMAGE:?请在 .env 中填写已导入的镜像标签}
|
||||
platform: linux/amd64
|
||||
container_name: doodle-api-prod
|
||||
# raw 保留密码中的 $ 等字符,需要 Docker Compose >= 2.30.0。
|
||||
env_file:
|
||||
- path: ./server.env
|
||||
format: raw
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
ports:
|
||||
- '127.0.0.1:8800:8800'
|
||||
networks:
|
||||
- panel
|
||||
healthcheck:
|
||||
# 同时验证 HTTP 和数据库连通性;不代表业务表、微信或七牛已验收。
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://127.0.0.1:8800/api/v1/health/ready', { signal: AbortSignal.timeout(2500) }).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
mem_limit: 768m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
|
||||
networks:
|
||||
panel:
|
||||
# 复用 1Panel 已有数据库网络,不创建或接管 PostgreSQL 容器。
|
||||
external: true
|
||||
name: ${PANEL_DOCKER_NETWORK:-1panel-network}
|
||||
@@ -0,0 +1,3 @@
|
||||
# 仅供 Compose 选择镜像和网络;复制为服务器部署目录下的 .env。
|
||||
DOODLE_API_IMAGE=doodle-server:20261022-01
|
||||
PANEL_DOCKER_NETWORK=1panel-network
|
||||
@@ -0,0 +1,29 @@
|
||||
# 复制为 server.env,仅在服务器填写真实值,禁止提交仓库或打入镜像。
|
||||
# Compose 使用 raw 读取:每行 KEY=value,值不加包裹引号,不写行尾注释。
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=8800
|
||||
LOG_LEVEL=info
|
||||
NODE_OPTIONS=--max-old-space-size=384
|
||||
API_BODY_LIMIT_BYTES=1048576
|
||||
SWAGGER_ENABLED=false
|
||||
CORS_ORIGINS=
|
||||
# 初次部署留空;只在确认实际反向代理来源后填写明确 IP/CIDR。
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
JWT_SECRET=
|
||||
JWT_ISSUER=doodle-store
|
||||
JWT_AUDIENCE=doodle-mini
|
||||
JWT_EXPIRES_IN_SECONDS=86400
|
||||
WECHAT_MINI_APP_ID=
|
||||
WECHAT_MINI_APP_SECRET=
|
||||
# 密码先 URL 编码;主机是数据库容器名,端口是容器内部的 5432。
|
||||
DATABASE_URL=postgresql://joey_postgresql:REPLACE_WITH_URL_ENCODED_PASSWORD@1Panel-postgresql-RqP6:5432/doodle_prod?schema=public
|
||||
WORKSHEET_NEW_WINDOW_DAYS=30
|
||||
QINIU_PUBLIC_DOMAIN=https://cdn.joeyone.cn
|
||||
QINIU_KEY_PREFIX=doodle-assets/
|
||||
QINIU_BUCKET=doodle-cn
|
||||
# 只读联通阶段可一起留空;头像和管理员图片上传验收前必须配置。
|
||||
QINIU_AK=
|
||||
QINIU_SK=
|
||||
QINIU_REGION=
|
||||
SITE_REFRESH_HOUR_UTC=22
|
||||
@@ -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 仍管理“小猫涂色”等功能下面的具体内容,不将这十种独立纸张重复建为资源。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Doodle v3.5 开发交接文档
|
||||
|
||||
2026-10-22 更新:用户确认云端 `doodle_prod` 已准备完成。已提供 [服务端首次部署操作手册](./服务端首次部署操作手册.md) 和配套 Docker/Compose/环境模板;本轮仅准备配置和说明,未实际部署云端 API、执行生产迁移或切换小程序地址。当前流程为 Mac 构建 `linux/amd64` 镜像后通过 1Panel 上传导入,复用面板已有 PostgreSQL 与 OpenResty。后文“尚未准备部署配置”“数据库未部署”等属于历史记录,不覆盖本条更新。
|
||||
|
||||
更新时间:2026-10-19。本次整理当前进度和后续任务,未执行云端部署。验证结果来自此前开发记录,本次文档更新未重新运行全套测试。
|
||||
|
||||
2026-10-20 最新决策:云端 PostgreSQL 由 1Panel 安装管理。本地 `doodle_dev` 提供已整理的内容及七牛映射,CloudBase 提供最新用户、收藏、下载记录及题型真实计数;建议在本机独立迁移库整合后导入 `doodle_prod`。此前“仅导内容”和“直接全量复制本地库”的约定均已被替代;逐表来源和补导边界见 [生产数据整合与导入方案](./生产数据整合与导入方案.md)。其余尚未更新的历史部署建议不覆盖本条决策。
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
# 服务端首次部署操作手册
|
||||
|
||||
更新时间:2026-10-22。适用腾讯云 Ubuntu 24.04 / x86_64、1Panel 2.3.2、已有 PostgreSQL 与 `doodle_prod` 的环境。
|
||||
|
||||
用户已确认数据库准备完成;本文不重复建库、初始化表或导入数据,也不表示已现场核验云端数据。本文是操作说明与配套配置,不代表已经部署到云端。
|
||||
|
||||
## 1. 本次采用的部署方式
|
||||
|
||||
在 Mac 上构建 Linux 镜像,导出压缩包,通过 1Panel 上传到服务器并导入。服务器只运行构建好的 API,不需要额外安装 Node.js、pnpm、PM2 或源码。暂时无需镜像仓库,后续可改为仓库推送/拉取。
|
||||
|
||||
```text
|
||||
小程序 → https://doodle-api.joeyone.cn
|
||||
→ 1Panel 已有 OpenResty:443 / 证书
|
||||
→ doodle-api-prod:8800
|
||||
→ 1panel-network → 既有 PostgreSQL:5432 / doodle_prod
|
||||
|
||||
资源 → https://cdn.joeyone.cn
|
||||
```
|
||||
|
||||
本阶段只运行一套云端业务环境。小程序 develop 可选择 production 调试;trial/release 固定 production。旧正式版继续访问旧 CloudBase,保留全部旧云函数、预拉取和定时任务;新服务部署成功后先用开发版和体验版验收。
|
||||
|
||||
现有数据库由 1Panel 管理;API 由本文 Compose 文件管理,在 1Panel 中可查看其状态和日志。不要再用 1Panel 新建一个同名 API 编排,也不要运行旧 `compose.database.yaml`。
|
||||
|
||||
配套文件都在 `doodle-store` 仓库:
|
||||
|
||||
| 文件 | 作用 |
|
||||
| ---------------------------- | ------------------------------------------ |
|
||||
| `deploy/Dockerfile.server` | 构建 Linux 服务端镜像,启动 `dist/main.js` |
|
||||
| `.dockerignore` | 排除本机依赖、真实环境配置和备份 |
|
||||
| `deploy/compose.api.yaml` | 只启动 API,复用 1Panel 数据库网络 |
|
||||
| `deploy/compose.env.example` | 镜像版本和网络名模板 |
|
||||
| `deploy/server.env.example` | 服务运行配置模板,真实值仅在服务器填写 |
|
||||
|
||||
首版镜像保留 Prisma CLI 和构建依赖,便于同一个版本镜像执行独立数据库迁移;因此体积尚未优化。迁移不随 API 每次重启自动执行。
|
||||
|
||||
## 2. 服务器部署前检查
|
||||
|
||||
以下命令均在 **1Panel → 终端 → 本地服务器**执行,建议使用当前已有管理终端。不要在 Mac 终端执行服务器命令。
|
||||
|
||||
```bash
|
||||
docker version
|
||||
docker compose version
|
||||
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
|
||||
docker network inspect 1panel-network --format '{{range .Containers}}{{println .Name .IPv4Address}}{{end}}'
|
||||
ss -lntp | rg ':80 |:443 |:8800 '
|
||||
```
|
||||
|
||||
若服务器没有 `rg`,最后一条改用 `ss -lntp` 直接查看。
|
||||
|
||||
确认以下条件后继续:
|
||||
|
||||
- Docker 正常,Compose 至少 **2.30.0**;配置使用 `env_file.format: raw`,低版本会报不支持。版本不足时通过 1Panel 支持的升级方式更新 Compose,再继续。
|
||||
- `1Panel-postgresql-RqP6` 正常运行,属于 `1panel-network`。这是此前核查的名称,若已重装,应以本次实际名称为准,并同步替换 `server.env`。
|
||||
- `8800` 未被其他程序占用;`80/443` 继续交给已有 OpenResty。
|
||||
- 在 1Panel → 容器中查出 OpenResty 容器名和网络模式。也可把实际名称代入:
|
||||
|
||||
```bash
|
||||
docker inspect <OpenResty容器名> --format '{{.HostConfig.NetworkMode}} {{json .NetworkSettings.Networks}}'
|
||||
```
|
||||
|
||||
若是 `host` 模式,反向代理上游使用 `http://127.0.0.1:8800`;若是 bridge 且与 API 同处 `1panel-network`,使用 `http://doodle-api-prod:8800`。若 bridge 未接入该网络,先通过 1Panel 核对并持久配置共享网络,再创建代理;不要把 OpenResty 容器内的 `127.0.0.1` 当成宿主机。
|
||||
|
||||
若不存在 `1panel-network`,先找到数据库实际所在网络,将该名字写入后面的 `.env`,而不是盲目新建一个与数据库隔离的同名网络。
|
||||
|
||||
### 检查并备份已有数据库
|
||||
|
||||
先在 1Panel 的 PostgreSQL 管理中为 `doodle_prod` 做一次完整备份,确认任务成功并下载一份到本机。也可通过 DBX 执行以下只读查询核对当前目标和迁移历史:
|
||||
|
||||
```sql
|
||||
SELECT current_database(), current_user;
|
||||
|
||||
SELECT migration_name, finished_at, rolled_back_at
|
||||
FROM public._prisma_migrations
|
||||
ORDER BY migration_name;
|
||||
|
||||
SELECT 'categories' AS table_name, count(*) FROM public.categories
|
||||
UNION ALL SELECT 'worksheets', count(*) FROM public.worksheets
|
||||
UNION ALL SELECT 'assets', count(*) FROM public.assets;
|
||||
```
|
||||
|
||||
当前代码包含三份迁移:
|
||||
|
||||
```text
|
||||
0_v35_baseline
|
||||
20261016000000_category_publication
|
||||
20261019000000_member_assets
|
||||
```
|
||||
|
||||
初始化 SQL 已写入这些迁移记录。不要重新运行初始化 SQL、导入本地 `_prisma_migrations`,也不要删除现有表来绕过迁移错误。
|
||||
|
||||
## 3. 在 Mac 构建和导出镜像
|
||||
|
||||
先启动 Docker Desktop。在 **Mac 终端**执行:
|
||||
|
||||
```bash
|
||||
cd /Users/joey-xd/sites/doodle/doodle-store
|
||||
git status --short
|
||||
docker version
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
-f deploy/Dockerfile.server \
|
||||
-t doodle-server:20261022-01 \
|
||||
--load .
|
||||
```
|
||||
|
||||
`20261022-01` 是本文示例版本号。实际每次发布使用新编号,不覆盖上一次可运行的标签;构建包含当前工作区未提交的改动,应先确认这些改动属于本次发布。
|
||||
|
||||
必须在 `doodle-store` 根目录构建,最后的 `.` 不能省略。即使 Mac 是 Apple Silicon,也显式构建 `linux/amd64`,与云服务器一致。
|
||||
|
||||
Docker 内部安装项目锁定的 Node.js 24.20.0 / pnpm 11.24.0,构建 contracts、生成 Linux Prisma 客户端,再编译 NestJS。不会使用 Mac 的 `node_modules`,也不需要提供真实数据库密码、微信或七牛密钥来构建。
|
||||
|
||||
如果 `pnpm install` 从 npm 官方源下载依赖时持续超时,可通过构建参数切换到 [npmmirror 国内镜像源](https://npmmirror.com/):
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--progress=plain \
|
||||
--build-arg NPM_REGISTRY=https://registry.npmmirror.com \
|
||||
-f deploy/Dockerfile.server \
|
||||
-t doodle-server:20261022-01 \
|
||||
--load .
|
||||
```
|
||||
|
||||
该参数同时用于安装 pnpm 和安装项目依赖;默认仍使用 npm 官方源,不修改 Mac 的全局 npm 配置或锁文件。保留 `--frozen-lockfile` 及依赖完整性校验。仅在 Mac 上执行 `npm config set registry` 不会自动修改 Docker 构建环境中的源。
|
||||
|
||||
此参数只控制 npm 包下载,不控制 Docker Hub 基础镜像、Debian apt 软件源和 Prisma 独立引擎下载。如果镜像源尚未同步指定版本,或错误发生在其他下载域名,需要按完整日志另行处理。
|
||||
|
||||
确认镜像架构并导出:
|
||||
|
||||
```bash
|
||||
docker image inspect doodle-server:20261022-01 --format '{{.Os}}/{{.Architecture}}'
|
||||
mkdir -p /tmp/doodle-release-20261022-01
|
||||
docker image save doodle-server:20261022-01 | gzip > /tmp/doodle-release-20261022-01/doodle-server-20261022-01.tar.gz
|
||||
cd /tmp/doodle-release-20261022-01
|
||||
shasum -a 256 doodle-server-20261022-01.tar.gz > doodle-server-20261022-01.tar.gz.sha256
|
||||
```
|
||||
|
||||
架构输出应为 `linux/amd64`。若构建失败,先处理镜像源、依赖下载或编译报错,不上传不完整的压缩包。初次拉取基础镜像和跨架构编译可能较慢。
|
||||
|
||||
## 4. 用 1Panel 上传并导入镜像
|
||||
|
||||
服务器建立本次发布目录和长期配置目录。在 **1Panel 终端**执行:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/doodle/releases/20261022-01
|
||||
mkdir -p /opt/doodle/prod
|
||||
chmod 700 /opt/doodle/prod
|
||||
```
|
||||
|
||||
在 **1Panel → 文件**中:
|
||||
|
||||
1. 将 Mac `/tmp/doodle-release-20261022-01/` 中的压缩包和 `.sha256` 文件上传到服务器 `/opt/doodle/releases/20261022-01/`。
|
||||
2. 将仓库 `deploy/` 下的 `compose.api.yaml`、`compose.env.example`、`server.env.example` 上传到 `/opt/doodle/prod/`。
|
||||
3. 首次在服务器复制模板:`compose.env.example` → `.env`;`server.env.example` → `server.env`。后续发布不要再次复制模板覆盖真实配置。
|
||||
|
||||
如果文件页隐藏点文件,打开显示隐藏文件后编辑 `.env`。若通过网页上传大镜像超时,可改用 1Panel 支持的分片上传,或用已有 SSH/SFTP 上传;无需改用浏览器将镜像作为 SQL 文件导入。
|
||||
|
||||
建议在导入前通过 **1Panel 终端**校验上传文件:
|
||||
|
||||
```bash
|
||||
cd /opt/doodle/releases/20261022-01
|
||||
sha256sum -c doodle-server-20261022-01.tar.gz.sha256
|
||||
```
|
||||
|
||||
校验输出应为 `OK`;否则重新上传。SHA-256 校验不是部署必需步骤,但建议保留,以便提前发现上传不完整的问题。
|
||||
|
||||
### 4.1 通过图形界面导入(推荐)
|
||||
|
||||
1. 进入 **1Panel → 容器 → 镜像 → 导入镜像**。
|
||||
2. 选择已上传到服务器的 `/opt/doodle/releases/20261022-01/doodle-server-20261022-01.tar.gz`,确认导入。
|
||||
3. 如果导入界面不接受 `.tar.gz`,先通过 **1Panel → 文件**将其解压成 `.tar`,再选择该 `.tar` 文件导入。
|
||||
4. 导入完成后,在镜像列表确认存在标签 **`doodle-server:20261022-01`**。
|
||||
|
||||
使用图形界面导入后,不需要再执行 `docker load`。镜像已经保存在服务器本机 Docker 中,后续 Compose 可直接使用,无需再从镜像仓库下载。
|
||||
|
||||
**导入镜像不等于启动服务。** 接下来仍需按第 5 节填写 `server.env`,再按第 6 节通过 Compose 创建、启动 API 容器。
|
||||
|
||||
### 4.2 命令行导入(备选)
|
||||
|
||||
如果不使用图形界面,也可在 **1Panel 终端**执行以下命令,二选一即可:
|
||||
|
||||
```bash
|
||||
cd /opt/doodle/releases/20261022-01
|
||||
docker image load -i doodle-server-20261022-01.tar.gz
|
||||
```
|
||||
|
||||
无论采用哪种导入方式,都可以用以下命令补充检查镜像架构和 ID:
|
||||
|
||||
```bash
|
||||
docker image inspect doodle-server:20261022-01 --format '{{.Os}}/{{.Architecture}} {{.Id}}'
|
||||
```
|
||||
|
||||
## 5. 填写服务器配置
|
||||
|
||||
### 5.1 `.env`:编排配置
|
||||
|
||||
编辑服务器 `/opt/doodle/prod/.env`:
|
||||
|
||||
```dotenv
|
||||
DOODLE_API_IMAGE=doodle-server:20261022-01
|
||||
PANEL_DOCKER_NETWORK=1panel-network
|
||||
```
|
||||
|
||||
镜像标签必须与刚导入的一致;网络名必须是数据库实际所在网络。
|
||||
|
||||
### 5.2 `server.env`:应用配置
|
||||
|
||||
编辑 `/opt/doodle/prod/server.env`。文件采用 raw 格式:每行 `KEY=value`,不加包裹引号,不写行尾注释,密钥中的 `$` 不需要改成 `$$`。
|
||||
|
||||
| 配置项 | 填写要求 |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `NODE_ENV` / `HOST` / `PORT` | 保留 `production` / `0.0.0.0` / `8800`。容器内不能只监听 `127.0.0.1` |
|
||||
| `JWT_SECRET` | 单独生成至少 32 字节的随机密钥;可在自己的终端执行 `openssl rand -hex 32`,只把结果保存到此文件 |
|
||||
| `WECHAT_MINI_APP_ID` | 当前小程序 AppID |
|
||||
| `WECHAT_MINI_APP_SECRET` | 与 AppID 对应的服务端 AppSecret,不能填 JWT 密钥 |
|
||||
| `DATABASE_URL` | 使用下文格式,指向 `doodle_prod` |
|
||||
| `QINIU_PUBLIC_DOMAIN` | `https://cdn.joeyone.cn` |
|
||||
| `QINIU_BUCKET` / `QINIU_KEY_PREFIX` | `doodle-cn` / `doodle-assets/` |
|
||||
| `QINIU_AK` / `QINIU_SK` | 上传验收前填写真实服务端密钥;只读连通阶段可同时留空 |
|
||||
| `QINIU_REGION` | 按七牛空间真实区域填写;未知时留空由 SDK 发现,不猜区域 |
|
||||
| `SWAGGER_ENABLED` | 保留 `false`,生产默认不开放接口文档 |
|
||||
| `CORS_ORIGINS` | 仅小程序联调可留空;未来 Web 上线时增加准确 origin,例如 `https://doodle.joeyone.cn`,不带路径和末尾 `/` |
|
||||
| `TRUSTED_PROXY_CIDRS` | 初次留空;确认实际代理来源后再填明确 IP/CIDR,不填 `true` 或任意来源网段 |
|
||||
| `SITE_REFRESH_HOUR_UTC` | `22`,对应北京时间每日 06:00;新后端任务不会关闭旧云任务 |
|
||||
|
||||
数据库连接格式:
|
||||
|
||||
```text
|
||||
postgresql://joey_postgresql:URL编码后的密码@1Panel-postgresql-RqP6:5432/doodle_prod?schema=public
|
||||
```
|
||||
|
||||
这里的 `5432` 是 PostgreSQL **容器内部端口**;数据库主机不是公网 IP,也不是 API 容器的 `127.0.0.1`。不需要开放 PostgreSQL 公网端口。
|
||||
|
||||
密码中含 `@`、`#`、`/`、`%` 等字符时必须 URL 编码。可在自己的 **Mac 终端**交互转换,输入不会回显,结果只用于配置,不发送到聊天或截图:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
from getpass import getpass
|
||||
from urllib.parse import quote
|
||||
print(quote(getpass('数据库原始密码:'), safe=''))
|
||||
PY
|
||||
```
|
||||
|
||||
完成后在 **服务器终端**限制权限并检查编排语法:
|
||||
|
||||
```bash
|
||||
cd /opt/doodle/prod
|
||||
chmod 600 .env server.env
|
||||
docker compose -f compose.api.yaml config --quiet
|
||||
```
|
||||
|
||||
检查成功无输出。不要把不带 `--quiet` 的完整配置输出发到聊天,其中可能包含已展开的密钥。此语法检查不能验证密钥、数据库密码或域名是否正确。
|
||||
|
||||
## 6. 核对迁移状态,再启动 API
|
||||
|
||||
以下在 **服务器 `/opt/doodle/prod`** 执行:
|
||||
|
||||
```bash
|
||||
cd /opt/doodle/prod
|
||||
docker compose -f compose.api.yaml run --rm --no-deps api pnpm --filter @doodle/server db:status
|
||||
```
|
||||
|
||||
这个一次性容器使用同样的镜像、环境变量和内部网络,不发布 API 端口,也不会启动 HTTP 服务或业务定时任务。
|
||||
|
||||
首次部署且数据库已用当前初始化 SQL 建好时,预期为 **Database schema is up to date**。若报错,按下表处理:
|
||||
|
||||
| 输出 | 处理 |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| 无法连接 / P1001 | 检查网络、数据库容器名和内部端口 |
|
||||
| 认证失败 / P1000 | 检查账号、密码 URL 编码及库名 |
|
||||
| 表已存在但没有迁移历史 / P3005 | 停止,不删除表;核对是否执行了完整初始化 SQL,单独制定基线修复方案 |
|
||||
| 失败迁移 / 校验和不一致 | 停止,核对初始化脚本与本次镜像版本,不手工把失败记录标为成功 |
|
||||
| 出现待执行迁移 | 首次预期不应出现;确认这些是计划发布的新增迁移并已备份后,再执行下方迁移命令 |
|
||||
|
||||
需要应用已审查的新迁移时执行:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.api.yaml run --rm --no-deps api pnpm --filter @doodle/server db:deploy
|
||||
```
|
||||
|
||||
`db:deploy` 会修改数据库;没有待执行迁移时不会重复初始化。云端不使用 `migrate dev`、`db push` 或 `migrate reset`。
|
||||
|
||||
状态确认后启动:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.api.yaml up -d --pull never --wait --wait-timeout 120
|
||||
docker compose -f compose.api.yaml ps
|
||||
docker compose -f compose.api.yaml logs --tail=100 api
|
||||
```
|
||||
|
||||
容器名是 `doodle-api-prod`。API 容器自动重启,启动失败时先看日志,不需要叠加 PM2。健康检查通过只说明 HTTP 和数据库能连接,还需要下一节的业务检查。
|
||||
|
||||
## 7. 先验证服务器内部访问
|
||||
|
||||
在服务器终端执行:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://127.0.0.1:8800/api/v1/health
|
||||
curl --fail-with-body http://127.0.0.1:8800/api/v1/health/ready
|
||||
curl --fail-with-body http://127.0.0.1:8800/api/v1/categories
|
||||
curl --fail-with-body 'http://127.0.0.1:8800/api/v1/worksheets?page=1&pageSize=1'
|
||||
curl --fail-with-body http://127.0.0.1:8800/api/v1/page-config
|
||||
```
|
||||
|
||||
前两个预期 HTTP 200 且 `code: 0`;分类和题型应能查询到已导入并发布的数据。没有数据时检查发布状态、分类可见性、资产 ready 状态和迁移内容,不能仅凭健康检查认定导入成功。
|
||||
|
||||
如果 `page_configs.current` 仍为空或快照引用不正确,需要在管理员功能中执行新后端页面重建,再验收首页;不要重新导入默认占位记录。管理员权限以数据库当前角色为准,不会自动把第一个登录用户设为管理员。
|
||||
|
||||
服务启动后会运行新后端的任务逻辑:每日首页刷新受 `settings.homeAutoRefresh` 控制;配置七牛密钥后还会处理待清理头像。因此启动前应确认导入的任务、资产状态符合 [生产数据整合与导入方案](./生产数据整合与导入方案.md),不要把开发库的清理队列直接复制到生产。
|
||||
|
||||
## 8. 配置 API 域名和 HTTPS
|
||||
|
||||
### 8.1 DNS
|
||||
|
||||
在 `joeyone.cn` 的 DNS 管理后台添加:
|
||||
|
||||
| 类型 | 主机记录 | 记录值 |
|
||||
| ---- | ------------ | ---------------------------------------------- |
|
||||
| A | `doodle-api` | `175.24.138.234`(以云控制台当前公网 IP 为准) |
|
||||
|
||||
DNS 记录只填 `doodle-api`;1Panel 网站的域名要填完整的 `doodle-api.joeyone.cn`。证书不代替域名解析。
|
||||
|
||||
### 8.2 1Panel 创建反向代理网站
|
||||
|
||||
进入 **网站 → 创建网站 → 反向代理**:
|
||||
|
||||
| 项目 | 值 |
|
||||
| --------- | ----------------------------------------------------------------------- |
|
||||
| 域名 | `doodle-api.joeyone.cn` |
|
||||
| HTTP 端口 | `80` |
|
||||
| 代号 | `doodle-api` |
|
||||
| HTTPS | 启用,选择包含 `joeyone.cn` + `*.joeyone.cn` 的已有证书(此前 ID 11) |
|
||||
| 代理协议 | `http`,TLS 在 OpenResty 终止 |
|
||||
| 代理地址 | host 网络用 `127.0.0.1:8800`;共享 bridge 网络用 `doodle-api-prod:8800` |
|
||||
| 应用选择 | 自定义 Compose 服务可能不在商店应用列表,手工填写代理地址即可 |
|
||||
|
||||
代理路径为 `/`,保留原始路径和查询参数,**不要把上游填写成 `/api/v1`**,避免重复前缀。创建后检查 HTTPS 监听 443、HTTP 跳转 HTTPS,代理缓存关闭。
|
||||
|
||||
网站上传大小建议设为 `4 MB` 或更高,使 2 MiB 图片及 multipart 包装能通过;API 自身仍执行 2 MiB 图片限制。若编辑 OpenResty 配置,在网站对应 `server` 中设置 `client_max_body_size 4m;`,不要重复添加同名指令。使用 1Panel 的配置检测和重载功能,不替换其他网站配置。
|
||||
|
||||
腾讯云安全组应允许业务访问 `80/443`。API 的 `8800` 仅发布到宿主机回环地址,无需放行公网 `8800`,数据库也不依赖公网 `5432`。
|
||||
|
||||
### 8.3 公网验收
|
||||
|
||||
在 **Mac 终端**执行,不加 `-k` 忽略证书验证:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body https://doodle-api.joeyone.cn/api/v1/health
|
||||
curl --fail-with-body https://doodle-api.joeyone.cn/api/v1/health/ready
|
||||
curl --fail-with-body https://doodle-api.joeyone.cn/api/v1/categories
|
||||
```
|
||||
|
||||
出现 502 时先回到第 7 节检查 API,再核对 OpenResty 网络和上游。域名根路径 `/` 返回 404 可以是正常现象,应使用实际健康检查路径。
|
||||
|
||||
网站绑定已有证书,并确认其自动续签处于开启状态;未来需要核验续签任务与 OpenResty 实际生效证书,不能只看最初签发成功。
|
||||
|
||||
## 9. 微信与小程序联调
|
||||
|
||||
1. 微信后台配置新后端调用微信接口所需的**实际公网出口 IP 白名单**。`10.0.16.14` 是内网地址,不能填它;若出现微信 `40164`,核对实际出口与白名单。
|
||||
2. 服务器应能出站访问微信接口及七牛服务。检查 AppID/AppSecret 配对,不能用浏览器随便编造一个 code 判断登录是否成功。
|
||||
3. 配置 request、uploadFile 合法域名为 `https://doodle-api.joeyone.cn`;资源下载使用的 downloadFile 合法域名包含 `https://cdn.joeyone.cn`。依据实际接口用途添加,不在合法域名中填写 `/api/v1` 路径。
|
||||
4. API 公网验收成功后,再修改 `doodle-mini/miniprogram/config/api.ts` 中的 `productionBaseUrl` 为 `https://doodle-api.joeyone.cn/api/v1`。`localBaseUrl` 保留本地;未部署 staging 时不将其描述为独立可用的测试环境。本手册不会自动修改小程序地址。
|
||||
5. 先在 develop 的 Debug 页切到 production,再用体验版验证真实登录、分类、题型、首页、收藏、下载记录、资料保存、头像上传与管理员发布。trial/release 固定 production,不能通过 Debug 切到本地。
|
||||
6. 使用七牛真实密钥做上传验收前,确认 CloudBase 用户头像绑定、七牛对象 key 和 `assets` 权限映射符合预期,避免把本地测试用户当成线上用户。
|
||||
|
||||
只有 HTTP、数据库、真实微信登录和必要业务都验收后,才安排新的正式版发布。旧线上 CloudBase 在并行期仍产生新数据;部署本身不会实现双向同步,正式切换前还需按迁移方案安排最新补导和对账。
|
||||
|
||||
## 10. 日常维护、更新和回退
|
||||
|
||||
### 查看状态与重新加载配置
|
||||
|
||||
在服务器 `/opt/doodle/prod`:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.api.yaml ps
|
||||
docker compose -f compose.api.yaml logs --tail=100 api
|
||||
docker stats --no-stream doodle-api-prod
|
||||
```
|
||||
|
||||
修改 `server.env` 后必须重建容器来注入新配置,仅 `restart` 不会读取新环境变量:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.api.yaml up -d --pull never --force-recreate --wait --wait-timeout 120
|
||||
```
|
||||
|
||||
Compose 的内存限制为 768 MiB,Node 堆上限为 384 MiB。上线后根据实际占用调整;不要为小项目预先启动第二套 API/数据库占满服务器。unhealthy 不会自动触发 Docker 重启,需查看告警/日志和实际进程;`restart: unless-stopped` 处理的是进程退出。
|
||||
|
||||
### 发布新版本
|
||||
|
||||
1. 在本机使用新标签重新构建,保存 Git 提交/工作区说明、构建输出和镜像 ID。
|
||||
2. 导出、校验、上传、`docker load`;保留旧镜像与旧配置副本。配置副本含密钥,仅存权限受限目录,不能打入发布包。
|
||||
3. 完整备份数据库并确认可下载恢复;查看本次迁移 SQL,判断旧程序能否兼容新结构。
|
||||
4. 编辑 `.env` 的 `DOODLE_API_IMAGE` 为新标签,先运行 `db:status`;需要时执行 `db:deploy`,再 `up -d --pull never --wait --wait-timeout 120`。
|
||||
5. 重做内部和公网健康、业务检查,以及涉及本次改动的小程序验收。
|
||||
|
||||
当前是单 API 容器更新,重建期间会有短暂中断;若以后需要零中断,再设计双实例切换。本手册不宣称零停机。
|
||||
|
||||
### 应用回退
|
||||
|
||||
若数据库结构仍兼容旧镜像,把 `.env` 标签改回上一版本,恢复匹配的运行配置,再执行:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.api.yaml up -d --pull never --wait --wait-timeout 120
|
||||
```
|
||||
|
||||
应用回退不会自动撤销数据库迁移。若新增迁移不兼容旧程序,先停止业务写入并制定恢复方案,不能直接执行 `migrate reset`;恢复旧备份还会丢失备份之后的新写入,必须先保留当前数据。
|
||||
|
||||
### 定期备份
|
||||
|
||||
通过 1Panel 给 `doodle_prod` 配置每日完整备份、保留周期和服务器外副本,并在独立恢复库演练恢复。网站备份不能自动等同于 PostgreSQL 备份;仅删除/更新 API 容器不会删除数据库,但服务器损坏仍会同时影响本机文件和容器卷。
|
||||
|
||||
## 11. 常见故障
|
||||
|
||||
| 现象 | 排查方式 |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `env_file ... format` 不支持 | Compose 升级到 2.30.0 及以上 |
|
||||
| 镜像不存在 / pull access denied | 检查是否执行 `docker load`,以及 `.env` 标签是否一致;离线发布使用 `--pull never` |
|
||||
| `exec format error` | 镜像架构错误,重建 `linux/amd64` |
|
||||
| 启动即退出 | 检查 JWT 长度、必填微信凭据、连接串格式及日志;不要贴出真实环境文件 |
|
||||
| health 成功、ready 失败 | 检查 PostgreSQL 网络、密码、数据库名;health 不检查数据库 |
|
||||
| ready 成功、业务查询失败 | 检查表结构、迁移历史、导入数据与资产状态 |
|
||||
| 502 Bad Gateway | 检查 API 是否 healthy、OpenResty 网络与上游地址 |
|
||||
| 413 Request Entity Too Large | 检查 OpenResty 上传上限及后端图片 2 MiB 限制 |
|
||||
| 微信 `40164` | 服务端实际出口 IP 不在微信白名单 |
|
||||
| 登录正常、上传失败 | 检查七牛 AK/SK、空间区域、上传域名及资产权限 |
|
||||
| 首页为空 | 检查内容发布状态和页面快照;通过有权限的管理员重建 |
|
||||
| 修改配置后仍用旧值 | `up --force-recreate`,而不是仅 `restart` |
|
||||
|
||||
## 12. 本轮验证范围
|
||||
|
||||
2026-10-22 已在本机验证 Compose 解析、单 API 服务、回环端口绑定、已有外部网络引用及 raw 环境变量特殊字符处理;文档和 YAML 格式检查通过。
|
||||
|
||||
实际尝试构建 `linux/amd64` 镜像时,Docker Hub 的 `node:24.20.0-bookworm-slim` 元数据下载超时,尚未进入依赖安装和编译。因此镜像完整构建、容器启动和迁移 CLI 仍需在网络恢复后验证,不能把本手册当作已部署成功的记录。云端没有执行迁移、启动 API 或修改小程序接口地址。
|
||||
|
||||
## 13. 官方参考
|
||||
|
||||
- [Docker 跨平台镜像构建参数](https://docs.docker.com/reference/cli/docker/buildx/build/)
|
||||
- [Docker 镜像导出与压缩](https://docs.docker.com/reference/cli/docker/image/save/)
|
||||
- [Docker Compose 服务配置:env_file、healthcheck、ports 等](https://docs.docker.com/reference/compose-file/services/)
|
||||
- [1Panel 创建反向代理网站](https://docs.fit2cloud.com/1panel/user_manual/websites/website-create/)
|
||||
- [1Panel 网站 HTTPS 与反向代理配置](https://docs.fit2cloud.com/1panel/user_manual/websites/website-config-basic/)
|
||||
@@ -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` 的响应保持兼容。
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user