feat: 完成分类、worksheet 接口开发,资源迁移到七牛
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user