整理后端模块清单
This commit is contained in:
372
server-node/scripts/generateBackendCapabilityArtifacts.ts
Normal file
372
server-node/scripts/generateBackendCapabilityArtifacts.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
BACKEND_CAPABILITY_MANIFEST,
|
||||
type BackendCapabilityManifest,
|
||||
type BackendDomainModule,
|
||||
type BackendRouteCapability,
|
||||
type BackendRouteSurface,
|
||||
} from '../src/manifest/backendCapabilityManifest.js';
|
||||
|
||||
type GeneratedSummary = {
|
||||
surfaceCount: number;
|
||||
routeCount: number;
|
||||
moduleCount: number;
|
||||
publicRouteCount: number;
|
||||
jwtRouteCount: number;
|
||||
envSwitchRouteCount: number;
|
||||
streamRouteCount: number;
|
||||
};
|
||||
|
||||
type GeneratedArtifact = {
|
||||
generatedAt: string;
|
||||
manifestVersion: string;
|
||||
generatedCommand: string;
|
||||
outputTargets: BackendCapabilityManifest['outputTargets'];
|
||||
summary: GeneratedSummary;
|
||||
surfaces: Array<
|
||||
BackendRouteSurface & {
|
||||
routeCount: number;
|
||||
routeIds: string[];
|
||||
}
|
||||
>;
|
||||
modules: Array<
|
||||
BackendDomainModule & {
|
||||
routeCount: number;
|
||||
routeIds: string[];
|
||||
}
|
||||
>;
|
||||
routes: BackendRouteCapability[];
|
||||
maintenanceRules: string[];
|
||||
};
|
||||
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
const scriptDirectory = path.dirname(currentFilePath);
|
||||
const repoRoot = path.resolve(scriptDirectory, '..', '..');
|
||||
|
||||
/**
|
||||
* 统一把 repo 相对路径转成绝对路径,避免不同工作目录下解析不一致。
|
||||
*/
|
||||
function resolveRepoPath(relativePath: string) {
|
||||
return path.resolve(repoRoot, relativePath);
|
||||
}
|
||||
|
||||
function sortById<T extends { id: string }>(items: T[]) {
|
||||
return [...items].sort((left, right) => left.id.localeCompare(right.id, 'zh-Hans-CN'));
|
||||
}
|
||||
|
||||
function sortRoutes(routes: BackendRouteCapability[]) {
|
||||
return [...routes].sort((left, right) => {
|
||||
if (left.path === right.path) {
|
||||
return left.method.localeCompare(right.method, 'en-US');
|
||||
}
|
||||
return left.path.localeCompare(right.path, 'en-US');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 用最小约束校验 manifest 的唯一性与引用完整性,确保生成结果可维护。
|
||||
*/
|
||||
function assertUniqueIds(items: Array<{ id: string }>, label: string) {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
if (seen.has(item.id)) {
|
||||
duplicates.push(item.id);
|
||||
return;
|
||||
}
|
||||
seen.add(item.id);
|
||||
});
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`${label} 存在重复 id:${duplicates.join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSourceFileContains(params: {
|
||||
sourceFile: string;
|
||||
sourceHint: string;
|
||||
routeId: string;
|
||||
}) {
|
||||
const absolutePath = resolveRepoPath(params.sourceFile);
|
||||
const content = await readFile(absolutePath, 'utf8');
|
||||
if (!content.includes(params.sourceHint)) {
|
||||
throw new Error(
|
||||
`路由 ${params.routeId} 的 sourceHint 未命中源码:${params.sourceFile} -> ${params.sourceHint}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateModuleCoverage(modules: BackendDomainModule[]) {
|
||||
const modulesRoot = resolveRepoPath('server-node/src/modules');
|
||||
const directoryEntries = await readdir(modulesRoot, { withFileTypes: true });
|
||||
const actualDirectories = directoryEntries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => `server-node/src/modules/${entry.name}`)
|
||||
.sort((left, right) => left.localeCompare(right, 'en-US'));
|
||||
const manifestDirectories = modules
|
||||
.map((moduleItem) => moduleItem.directory)
|
||||
.sort((left, right) => left.localeCompare(right, 'en-US'));
|
||||
|
||||
const missingInManifest = actualDirectories.filter(
|
||||
(directory) => !manifestDirectories.includes(directory),
|
||||
);
|
||||
const staleInManifest = manifestDirectories.filter(
|
||||
(directory) => !actualDirectories.includes(directory),
|
||||
);
|
||||
|
||||
if (missingInManifest.length > 0) {
|
||||
throw new Error(
|
||||
`以下模块目录尚未进入能力 manifest:${missingInManifest.join('、')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (staleInManifest.length > 0) {
|
||||
throw new Error(
|
||||
`manifest 中存在已失效的模块目录:${staleInManifest.join('、')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateManifest(manifest: BackendCapabilityManifest) {
|
||||
assertUniqueIds(manifest.surfaces, '挂载面');
|
||||
assertUniqueIds(manifest.modules, '内部模块');
|
||||
assertUniqueIds(manifest.routes, '路由');
|
||||
|
||||
const surfaceIds = new Set(manifest.surfaces.map((surface) => surface.id));
|
||||
const moduleIds = new Set(manifest.modules.map((moduleItem) => moduleItem.id));
|
||||
|
||||
for (const surface of manifest.surfaces) {
|
||||
for (const relatedModuleId of surface.relatedModuleIds) {
|
||||
if (!moduleIds.has(relatedModuleId)) {
|
||||
throw new Error(
|
||||
`挂载面 ${surface.id} 引用了未定义的模块:${relatedModuleId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const mount of surface.mounts) {
|
||||
const absoluteEntryPath = resolveRepoPath(mount.entryFile);
|
||||
const content = await readFile(absoluteEntryPath, 'utf8');
|
||||
if (!content.includes(mount.routeFactory)) {
|
||||
throw new Error(
|
||||
`挂载面 ${surface.id} 的入口文件缺少工厂引用:${mount.entryFile} -> ${mount.routeFactory}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const moduleItem of manifest.modules) {
|
||||
for (const surfaceId of moduleItem.exposedBySurfaceIds) {
|
||||
if (!surfaceIds.has(surfaceId)) {
|
||||
throw new Error(
|
||||
`模块 ${moduleItem.id} 引用了未定义的挂载面:${surfaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const absoluteDirectory = resolveRepoPath(moduleItem.directory);
|
||||
const statsEntries = await readdir(absoluteDirectory);
|
||||
if (statsEntries.length === 0) {
|
||||
throw new Error(`模块目录为空,无法作为能力边界:${moduleItem.directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const route of manifest.routes) {
|
||||
if (!surfaceIds.has(route.surfaceId)) {
|
||||
throw new Error(`路由 ${route.id} 引用了未定义的挂载面:${route.surfaceId}`);
|
||||
}
|
||||
|
||||
for (const moduleId of route.domainModuleIds) {
|
||||
if (!moduleIds.has(moduleId)) {
|
||||
throw new Error(`路由 ${route.id} 引用了未定义的模块:${moduleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
await assertSourceFileContains({
|
||||
sourceFile: route.sourceFile,
|
||||
sourceHint: route.sourceHint,
|
||||
routeId: route.id,
|
||||
});
|
||||
}
|
||||
|
||||
await validateModuleCoverage(manifest.modules);
|
||||
}
|
||||
|
||||
function buildSummary(routes: BackendRouteCapability[]): GeneratedSummary {
|
||||
return {
|
||||
surfaceCount: BACKEND_CAPABILITY_MANIFEST.surfaces.length,
|
||||
routeCount: routes.length,
|
||||
moduleCount: BACKEND_CAPABILITY_MANIFEST.modules.length,
|
||||
publicRouteCount: routes.filter((route) => route.access === '公开').length,
|
||||
jwtRouteCount: routes.filter((route) => route.access === 'JWT').length,
|
||||
envSwitchRouteCount: routes.filter((route) => route.access.startsWith('开关:')).length,
|
||||
streamRouteCount: routes.filter((route) => route.responseMode === 'stream').length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildArtifact(manifest: BackendCapabilityManifest): GeneratedArtifact {
|
||||
const routes = sortRoutes(manifest.routes);
|
||||
const summary = buildSummary(routes);
|
||||
|
||||
const surfaces = sortById(manifest.surfaces).map((surface) => {
|
||||
const surfaceRoutes = routes.filter((route) => route.surfaceId === surface.id);
|
||||
return {
|
||||
...surface,
|
||||
routeCount: surfaceRoutes.length,
|
||||
routeIds: surfaceRoutes.map((route) => route.id),
|
||||
};
|
||||
});
|
||||
|
||||
const modules = sortById(manifest.modules).map((moduleItem) => {
|
||||
const moduleRoutes = routes.filter((route) =>
|
||||
route.domainModuleIds.includes(moduleItem.id),
|
||||
);
|
||||
return {
|
||||
...moduleItem,
|
||||
routeCount: moduleRoutes.length,
|
||||
routeIds: moduleRoutes.map((route) => route.id),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
manifestVersion: manifest.version,
|
||||
generatedCommand: manifest.generatedCommand,
|
||||
outputTargets: manifest.outputTargets,
|
||||
summary,
|
||||
surfaces,
|
||||
modules,
|
||||
routes,
|
||||
maintenanceRules: manifest.maintenanceRules,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(artifact: GeneratedArtifact) {
|
||||
const lines: string[] = [];
|
||||
lines.push('# Node 后端模块与接口索引');
|
||||
lines.push('');
|
||||
lines.push('> 该文档由 `server-node/src/manifest/backendCapabilityManifest.ts` 自动生成。');
|
||||
lines.push(`> 生成命令:\`${artifact.generatedCommand}\``);
|
||||
lines.push(`> 生成时间:\`${artifact.generatedAt}\``);
|
||||
lines.push('');
|
||||
lines.push('## 总览');
|
||||
lines.push('');
|
||||
lines.push(`- 对外挂载面:${artifact.summary.surfaceCount} 个`);
|
||||
lines.push(`- 已登记路由:${artifact.summary.routeCount} 条`);
|
||||
lines.push(`- 内部模块目录:${artifact.summary.moduleCount} 个`);
|
||||
lines.push(`- 公开接口:${artifact.summary.publicRouteCount} 条`);
|
||||
lines.push(`- JWT 接口:${artifact.summary.jwtRouteCount} 条`);
|
||||
lines.push(`- 受环境开关控制的接口:${artifact.summary.envSwitchRouteCount} 条`);
|
||||
lines.push(`- 流式接口:${artifact.summary.streamRouteCount} 条`);
|
||||
lines.push('');
|
||||
lines.push('## 产物');
|
||||
lines.push('');
|
||||
lines.push(`- JSON 清单:\`${artifact.outputTargets.json}\``);
|
||||
lines.push(`- Markdown 索引:\`${artifact.outputTargets.markdown}\``);
|
||||
lines.push(`- Manifest 源:\`server-node/src/manifest/backendCapabilityManifest.ts\``);
|
||||
lines.push('');
|
||||
lines.push('## 对外挂载面');
|
||||
lines.push('');
|
||||
|
||||
artifact.surfaces.forEach((surface) => {
|
||||
lines.push(`### ${surface.title}`);
|
||||
lines.push('');
|
||||
lines.push(`- 标识:\`${surface.id}\``);
|
||||
lines.push(`- 路由数:${surface.routeCount}`);
|
||||
lines.push(`- 入口:${surface.mounts.map((mount) => `\`${mount.entryFile} -> ${mount.mountPath} -> ${mount.routeFactory}\``).join(';')}`);
|
||||
lines.push(`- 关联模块:${surface.relatedModuleIds.length > 0 ? surface.relatedModuleIds.map((moduleId) => `\`${moduleId}\``).join('、') : '无'}`);
|
||||
lines.push('- 责任:');
|
||||
surface.responsibilities.forEach((item) => {
|
||||
lines.push(` - ${item}`);
|
||||
});
|
||||
lines.push('- 主要服务边界:');
|
||||
surface.primaryServiceBoundaries.forEach((item) => {
|
||||
lines.push(` - ${item}`);
|
||||
});
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
lines.push('## 接口索引');
|
||||
lines.push('');
|
||||
lines.push('| 方法 | 路径 | 访问 | 响应 | 挂载面 | 内部模块 | 说明 |');
|
||||
lines.push('| --- | --- | --- | --- | --- | --- | --- |');
|
||||
artifact.routes.forEach((route) => {
|
||||
const moduleLabel =
|
||||
route.domainModuleIds.length > 0
|
||||
? route.domainModuleIds.map((moduleId) => `\`${moduleId}\``).join('、')
|
||||
: '无';
|
||||
lines.push(
|
||||
`| ${route.method} | \`${route.path}\` | ${route.access} | ${route.responseMode} | \`${route.surfaceId}\` | ${moduleLabel} | ${route.summary} |`,
|
||||
);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
lines.push('## 内部模块边界');
|
||||
lines.push('');
|
||||
artifact.modules.forEach((moduleItem) => {
|
||||
lines.push(`### ${moduleItem.title}`);
|
||||
lines.push('');
|
||||
lines.push(`- 标识:\`${moduleItem.id}\``);
|
||||
lines.push(`- 目录:\`${moduleItem.directory}\``);
|
||||
lines.push(`- 对外可见面:${moduleItem.exposedBySurfaceIds.map((surfaceId) => `\`${surfaceId}\``).join('、')}`);
|
||||
lines.push(`- 关联路由数:${moduleItem.routeCount}`);
|
||||
lines.push('- 职责:');
|
||||
moduleItem.responsibilities.forEach((item) => {
|
||||
lines.push(` - ${item}`);
|
||||
});
|
||||
lines.push('- 主要服务边界:');
|
||||
moduleItem.primaryServiceBoundaries.forEach((item) => {
|
||||
lines.push(` - ${item}`);
|
||||
});
|
||||
lines.push('- 关键文件:');
|
||||
moduleItem.keyFiles.forEach((filePath) => {
|
||||
lines.push(` - \`${filePath}\``);
|
||||
});
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
lines.push('## 维护规则');
|
||||
lines.push('');
|
||||
artifact.maintenanceRules.forEach((rule) => {
|
||||
lines.push(`- ${rule}`);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
return `${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
async function writeArtifactFile(relativePath: string, content: string) {
|
||||
const absolutePath = resolveRepoPath(relativePath);
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, content, 'utf8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await validateManifest(BACKEND_CAPABILITY_MANIFEST);
|
||||
|
||||
const artifact = buildArtifact(BACKEND_CAPABILITY_MANIFEST);
|
||||
const jsonContent = `${JSON.stringify(artifact, null, 2)}\n`;
|
||||
const markdownContent = renderMarkdown(artifact);
|
||||
|
||||
await writeArtifactFile(artifact.outputTargets.json, jsonContent);
|
||||
await writeArtifactFile(artifact.outputTargets.markdown, markdownContent);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`backend capability artifacts generated`,
|
||||
`json=${artifact.outputTargets.json}`,
|
||||
`markdown=${artifact.outputTargets.markdown}`,
|
||||
`routes=${artifact.summary.routeCount}`,
|
||||
`modules=${artifact.summary.moduleCount}`,
|
||||
].join(' | '),
|
||||
);
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user