feat(web): T-WEB-010 部署脚本(Docker Compose,不含 MySQL)
This commit is contained in:
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 校验代码内的契约版本常量与 contracts/VERSION 一致。
|
||||
* 在构建前执行:不一致则退出码 1,阻止契约漂移。
|
||||
*
|
||||
* 用法:node scripts/check-contracts.mjs
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const versionFile = join(here, '..', '..', 'contracts', 'VERSION');
|
||||
const constantFile = join(here, '..', 'src', 'config', 'contract-versions.ts');
|
||||
|
||||
function parseVersionFile(content) {
|
||||
const out = {};
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const idx = line.indexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
out[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseConstants(content) {
|
||||
const block = /CONTRACT_VERSIONS[^=]*=\s*Object\.freeze\(\{([\s\S]*?)\}\)/.exec(content);
|
||||
if (!block) throw new Error('无法在 contract-versions.ts 中定位 CONTRACT_VERSIONS');
|
||||
const out = {};
|
||||
// 键可能是带引号的 'rest-api',也可能是不带引号的 database
|
||||
for (const match of block[1].matchAll(/['"]?([\w-]+)['"]?\s*:\s*['"]([^'"]+)['"]/g)) {
|
||||
out[match[1]] = match[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let fileVersions;
|
||||
let codeVersions;
|
||||
try {
|
||||
fileVersions = parseVersionFile(readFileSync(versionFile, 'utf8'));
|
||||
} catch (error) {
|
||||
console.error(`✗ 无法读取 ${versionFile}:${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
codeVersions = parseConstants(readFileSync(constantFile, 'utf8'));
|
||||
} catch (error) {
|
||||
console.error(`✗ 无法解析 ${constantFile}:${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const keys = new Set([...Object.keys(fileVersions), ...Object.keys(codeVersions)]);
|
||||
const problems = [];
|
||||
for (const key of [...keys].sort()) {
|
||||
const a = fileVersions[key];
|
||||
const b = codeVersions[key];
|
||||
if (a === undefined) problems.push(` ${key}: 仅存在于代码常量(${b})`);
|
||||
else if (b === undefined) problems.push(` ${key}: 仅存在于 contracts/VERSION(${a})`);
|
||||
else if (a !== b) problems.push(` ${key}: contracts/VERSION=${a} vs 代码常量=${b}`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error('✗ 契约版本不一致:');
|
||||
console.error(problems.join('\n'));
|
||||
console.error('\n请同步 contracts/VERSION 与 web-server/src/config/contract-versions.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ 契约版本一致(${Object.keys(fileVersions).length} 项):${JSON.stringify(fileVersions)}`);
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 校验 contracts/rest-api.yaml 中声明的路由与 NestJS 控制器实际实现的路由一致。
|
||||
*
|
||||
* 双向检查:
|
||||
* - 契约中声明但未实现 → 失败(契约不可落空)
|
||||
* - 已实现但契约未声明 → 失败(实现不可绕过契约)
|
||||
*
|
||||
* 用法:node scripts/check-routes.mjs
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(here, '..');
|
||||
const specPath = join(root, '..', 'contracts', 'rest-api.yaml');
|
||||
|
||||
const METHODS = new Set(['get', 'post', 'patch', 'put', 'delete']);
|
||||
|
||||
/** 从 OpenAPI YAML 中提取 paths 下的路由(不引入 YAML 依赖,按缩进解析) */
|
||||
function parseSpecRoutes(yaml) {
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
const routes = new Set();
|
||||
let inPaths = false;
|
||||
let currentPath = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^paths:\s*$/.test(line)) {
|
||||
inPaths = true;
|
||||
continue;
|
||||
}
|
||||
if (inPaths && /^[A-Za-z]/.test(line)) {
|
||||
inPaths = false; // 离开 paths 段(进入 components 等)
|
||||
currentPath = null;
|
||||
continue;
|
||||
}
|
||||
if (!inPaths) continue;
|
||||
|
||||
const pathMatch = /^ {2}(\/\S*):\s*$/.exec(line);
|
||||
if (pathMatch) {
|
||||
currentPath = pathMatch[1].replace(/\{([^}]+)\}/g, ':$1');
|
||||
continue;
|
||||
}
|
||||
const methodMatch = /^ {4}([a-z]+):\s*/.exec(line);
|
||||
if (methodMatch && currentPath && METHODS.has(methodMatch[1])) {
|
||||
routes.add(`${methodMatch[1].toUpperCase()} ${currentPath}`);
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
/** 递归查找 *.controller.ts(避免依赖 fs.globSync 的 Node 版本差异) */
|
||||
function findControllers(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...findControllers(full));
|
||||
else if (entry.isFile() && entry.name.endsWith('.controller.ts')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 从 NestJS 控制器提取路由 */
|
||||
function parseControllerRoutes(dir) {
|
||||
const routes = new Set();
|
||||
|
||||
for (const file of findControllers(dir)) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
const controllerMatch = /@Controller\(\s*['"]([^'"]*)['"]/.exec(source);
|
||||
const prefix = controllerMatch ? `/${controllerMatch[1].replace(/^\/|\/$/g, '')}` : '';
|
||||
|
||||
const decorator = /@(Get|Post|Patch|Put|Delete)\(\s*(?:['"]([^'"]*)['"])?[^)]*\)/g;
|
||||
let match;
|
||||
while ((match = decorator.exec(source)) !== null) {
|
||||
const method = match[1].toUpperCase();
|
||||
const sub = (match[2] ?? '').replace(/^\/|\/$/g, '');
|
||||
let full = sub ? `${prefix}/${sub}` : prefix;
|
||||
if (!full) full = '/';
|
||||
// Nest 路由参数是 :id,契约是 {id},统一成 :name
|
||||
full = full.replace(/\{([^}]+)\}/g, ':$1').replace(/\/+$/, '') || '/';
|
||||
routes.add(`${method} ${full}`);
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
const specRoutes = parseSpecRoutes(readFileSync(specPath, 'utf8'));
|
||||
const implRoutes = parseControllerRoutes(join(root, 'src'));
|
||||
|
||||
const missing = [...specRoutes].filter((r) => !implRoutes.has(r)).sort();
|
||||
const extra = [...implRoutes].filter((r) => !specRoutes.has(r)).sort();
|
||||
|
||||
const problems = [];
|
||||
if (missing.length) problems.push(`契约中声明但未实现:\n${missing.map((r) => ` - ${r}`).join('\n')}`);
|
||||
if (extra.length) problems.push(`已实现但契约未声明:\n${extra.map((r) => ` + ${r}`).join('\n')}`);
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`✗ 路由与契约不一致(契约 ${specRoutes.size} 条 / 实现 ${implRoutes.size} 条)`);
|
||||
console.error(problems.join('\n\n'));
|
||||
console.error('\n请同步 contracts/rest-api.yaml 与 web-server/src/**/*.controller.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ 路由与契约一致(共 ${specRoutes.size} 条)`);
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 跨环境 TypeORM migration 入口。
|
||||
*
|
||||
* - 生产(存在 dist/db/data-source.js):使用 typeorm CLI + 编译产物,无需 ts-node
|
||||
* - 开发(无 dist):使用 typeorm-ts-node-commonjs + src/*.ts
|
||||
*
|
||||
* 这样 `npm run migration:run` 在本地与 Docker 容器内行为一致。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/migrate.mjs run|revert|show|generate [额外参数]
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const command = process.argv[2];
|
||||
const extra = process.argv.slice(3);
|
||||
|
||||
if (!command) {
|
||||
console.error('用法:node scripts/migrate.mjs <run|revert|show|generate> [args]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const binDir = join(process.cwd(), 'node_modules', '.bin');
|
||||
const suffix = process.platform === 'win32' ? '.cmd' : '';
|
||||
const hasDist = existsSync(join(process.cwd(), 'dist', 'db', 'data-source.js'));
|
||||
|
||||
const bin = hasDist ? `typeorm${suffix}` : `typeorm-ts-node-commonjs${suffix}`;
|
||||
const dataSource = hasDist ? 'dist/db/data-source.js' : 'src/db/data-source.ts';
|
||||
|
||||
if (!hasDist && command !== 'generate' && !existsSync(join(process.cwd(), 'node_modules', 'ts-node'))) {
|
||||
console.error('未找到编译产物 dist/,且未安装 ts-node。请先 npm run build。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = [join(binDir, bin), '-d', dataSource, `migration:${command}`, ...extra];
|
||||
console.log(`[migrate] ${bin} -d ${dataSource} migration:${command} ${extra.join(' ')}`.trim());
|
||||
|
||||
const result = spawnSync(args[0], args.slice(1), { stdio: 'inherit' });
|
||||
process.exit(result.status ?? 1);
|
||||
Reference in New Issue
Block a user