feat: 完成分类、worksheet 接口开发,资源迁移到七牛

This commit is contained in:
R524809
2026-10-16 17:32:23 +08:00
parent e6ded00a4c
commit 8827110f2f
107 changed files with 12775 additions and 125 deletions
+69
View File
@@ -0,0 +1,69 @@
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { createInterface } from 'node:readline';
const require = createRequire(import.meta.url);
const child = spawn(
process.execPath,
[require.resolve('@nestjs/cli/bin/nest.js'), 'start', '--watch'],
{
env: { ...process.env, DOODLE_DEV_LAUNCHER: '1' },
stdio: ['inherit', 'pipe', 'inherit'],
},
);
let opened = false;
/** 使用系统默认浏览器,不经 shell 拼接 URL;打开失败不影响开发服务继续运行。 */
function openBrowser(url) {
const command =
process.platform === 'darwin'
? 'open'
: process.platform === 'win32'
? 'explorer.exe'
: 'xdg-open';
const browser = spawn(command, [url], { stdio: 'ignore', detached: true });
const warn = () =>
process.stderr.write(`无法自动打开浏览器,请手动访问:${url}\n`);
browser.once('error', warn);
browser.once('exit', (code) => {
if (code !== 0 && code !== null) warn();
});
browser.unref();
}
// 只在本次启动器收到子服务的成功通知后打开;热更新重启不会反复弹出页面。
const lines = createInterface({ input: child.stdout });
lines.on('line', (line) => {
let event;
try {
event = JSON.parse(line);
} catch {
/* Nest 编译提示不是 JSON,原样转发。 */
}
if (event?.event !== 'doodle-dev-ready') {
process.stdout.write(`${line}\n`);
return;
}
if (!event.swaggerUrl) {
process.stdout.write('\nSwagger 已关闭(SWAGGER_ENABLED=false)。\n\n');
return;
}
process.stdout.write(`\nSwagger: ${event.swaggerUrl}\n\n`);
if (!opened) {
opened = true;
openBrowser(event.swaggerUrl);
}
});
// 退出启动器时将信号传给 Nest,保留其对子服务的正常关闭和端口释放流程。
for (const signal of ['SIGINT', 'SIGTERM'])
process.on(signal, () => child.kill(signal));
child.once('error', () => {
process.stderr.write('开发服务启动失败,请检查本地 Node.js 与项目依赖。\n');
process.exitCode = 1;
});
child.once('exit', (code, signal) => {
lines.close();
process.exitCode =
code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1);
});
+102
View File
@@ -0,0 +1,102 @@
import 'reflect-metadata';
import { createHash, randomUUID } from 'node:crypto';
import {
closeSync,
openSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { basename, resolve } from 'node:path';
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 { CategoryService } = require('../dist/categories/category.service.js');
const { HttpException } = require('@nestjs/common');
let database;
let reportFd;
let reportPath;
let reportWritten = false;
let committed = false;
try {
const { values } = parseArgs({
options: {
file: { type: 'string' },
apply: { type: 'boolean', default: false },
report: { type: 'string' },
},
});
if (!values.file)
throw new Error(
'请用 --file 指定分类 JSON / JSON Lines;默认只校验,--apply 才写入',
);
const file = resolve(values.file);
if (statSync(file).size > 10 * 1024 * 1024)
throw new Error('分类文件不能超过 10 MiB');
const source = readFileSync(file, 'utf8');
const sourceSha256 = createHash('sha256').update(source).digest('hex');
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');
// 在任何数据库修改前独占创建报告文件,提前发现路径、权限或重名问题。
if (values.report) {
reportPath = resolve(values.report);
reportFd = openSync(reportPath, 'wx');
}
database = new PrismaService({ databaseUrl: url.toString() });
const service = new CategoryService(database);
const requestId = `categories-import-${randomUUID()}`;
const result = await service.importExport(
source,
!values.apply,
requestId,
`CloudBase category export sha256=${sourceSha256}; missing timestamps use batch time`,
);
committed = !result.dryRun;
const report = {
source: basename(file),
sourceSha256,
requestId,
...result,
};
const output = `${JSON.stringify(report, null, 4)}\n`;
process.stdout.write(output);
if (reportFd !== undefined) {
writeFileSync(reportFd, output);
reportWritten = true;
}
} catch (error) {
// 业务校验可说明原因;数据库异常、URL、凭据和堆栈均不输出。
const message =
error instanceof HttpException
? error.message
: error instanceof Error &&
!/(postgres|password|connection|prisma|\/\/)/i.test(
error.message,
)
? error.message
: '分类导入失败,请检查本地数据库配置和文件';
process.stderr.write(`${message}\n`);
if (committed)
process.stderr.write(
'数据库事务已提交;报告保存失败,具体批次见上方输出及数据库审计。\n',
);
process.exitCode = 1;
} finally {
if (reportFd !== undefined) {
closeSync(reportFd);
if (!reportWritten) unlinkSync(reportPath);
}
await database?.onModuleDestroy();
}
+98
View File
@@ -0,0 +1,98 @@
import 'reflect-metadata';
import { createHash, randomUUID } from 'node:crypto';
import {
closeSync,
openSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { basename, resolve } from 'node:path';
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 { WorksheetImporter } = require('../dist/imports/worksheet-import.js');
const { HttpException } = require('@nestjs/common');
let database;
let reportFd;
let reportPath;
let reportWritten = false;
let committed = false;
try {
const { values } = parseArgs({
options: {
file: { type: 'string' },
apply: { type: 'boolean', default: false },
'metadata-only': { type: 'boolean', default: false },
report: { type: 'string' },
},
});
if (!values.file)
throw new Error('请用 --file 指定题型导出;默认预演,--apply 才写库');
const file = resolve(values.file);
if (statSync(file).size > 10 * 1024 * 1024)
throw new Error('题型文件不能超过 10 MiB');
const source = readFileSync(file, 'utf8');
const sourceSha256 = createHash('sha256').update(source).digest('hex');
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');
if (values.report) {
reportPath = resolve(values.report);
reportFd = openSync(reportPath, 'wx');
}
database = new PrismaService({ databaseUrl: url.toString() });
const requestId = `worksheets-import-${randomUUID()}`;
const result = await new WorksheetImporter(database).importExport(source, {
dryRun: !values.apply,
metadataOnly: values['metadata-only'],
requestId,
sourceSha256,
});
committed = !result.dryRun;
const report = {
source: basename(file),
sourceSha256,
requestId,
...result,
};
const output = `${JSON.stringify(report, null, 4)}\n`;
process.stdout.write(output);
if (reportFd !== undefined) {
writeFileSync(reportFd, output);
reportWritten = true;
}
} catch (error) {
const message =
error instanceof HttpException
? error.message
: error instanceof Error &&
!/(postgres|password|connection|prisma|\/\/)/i.test(
error.message,
)
? error.message
: '题型导入失败,请检查本地数据库配置和文件';
process.stderr.write(`${message}\n`);
if (committed)
process.stderr.write(
'数据库事务已提交;报告保存失败,批次见上方输出及审计。\n',
);
process.exitCode = 1;
} finally {
if (reportFd !== undefined) {
closeSync(reportFd);
if (!reportWritten) unlinkSync(reportPath);
}
await database?.onModuleDestroy();
}
+84
View File
@@ -0,0 +1,84 @@
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { parseEnv } from 'node:util';
import { spawnSync } from 'node:child_process';
import pg from 'pg';
// 只使用本机开发库凭据创建独立短期库;绝不在 doodle_dev 中清空或写入测试用户。
const serverRoot = fileURLToPath(new URL('../', import.meta.url));
const local = parseEnv(
readFileSync(new URL('../.env', import.meta.url), 'utf8'),
);
const url = new URL(local.DATABASE_URL);
if (
!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.pathname !== '/doodle_dev'
)
throw new Error('数据库集成测试只允许本机 doodle_dev 实例创建隔离库');
const database = `doodle_auth_test_${randomUUID().replaceAll('-', '')}`;
const admin = new pg.Pool({
connectionString: url.toString(),
max: 1,
connectionTimeoutMillis: 3000,
});
const testUrl = new URL(url);
testUrl.pathname = `/${database}`;
const env = {
...process.env,
DATABASE_URL: testUrl.toString(),
TEST_DATABASE_URL: testUrl.toString(),
};
let created = false;
function run(args) {
const result = spawnSync('pnpm', args, {
cwd: serverRoot,
env,
encoding: 'utf8',
timeout: 120_000,
});
for (const output of [result.stdout, result.stderr])
if (output)
process.stdout.write(
output
.replaceAll(testUrl.toString(), '[test-database-url]')
.replaceAll(decodeURIComponent(url.password), '[redacted]'),
);
if (result.status !== 0) throw new Error('数据库测试子任务失败');
}
try {
await admin.query(
`CREATE DATABASE "${database}" WITH TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C'`,
);
created = true;
run(['exec', 'prisma', 'migrate', 'deploy']);
run(['exec', 'tsc', '-p', 'tsconfig.test.json']);
run(['exec', 'node', '--test', 'dist-test/test/auth-database.test.js']);
run(['exec', 'node', '--test', 'dist-test/test/category-database.test.js']);
run([
'exec',
'node',
'--test',
'dist-test/test/worksheet-import-database.test.js',
]);
run([
'exec',
'node',
'--test',
'dist-test/test/worksheet-database.test.js',
]);
run([
'exec',
'node',
'--test',
'dist-test/test/content-publication-database.test.js',
]);
} finally {
if (created) {
await admin.query(`DROP DATABASE "${database}"`);
console.log('临时数据库已删除,开发库业务数据未改动。');
}
await admin.end();
}