整理后端模块清单

This commit is contained in:
2026-04-20 23:07:43 +08:00
parent 39c7f0735f
commit adc57ba49b
11 changed files with 4777 additions and 21 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,8 @@
"build": "node build.mjs",
"start": "node dist/server.cjs",
"test": "node test.mjs",
"db:migrate": "tsx src/migrate.ts"
"db:migrate": "tsx src/migrate.ts",
"manifest:backend": "tsx scripts/generateBackendCapabilityArtifacts.ts"
},
"dependencies": {
"@alicloud/dypnsapi20170525": "^2.0.0",

View 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);
});

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ import { PNG } from 'pngjs';
import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js';
import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js';
import type { AppConfig } from '../../config.js';
import { routeMeta } from '../../middleware/routeMeta.js';
import {
buildArkCharacterAnimationPrompt,
buildFallbackModerationSafeAnimationPrompt,
@@ -30,14 +31,18 @@ import {
import type { UpstreamLlmClient } from '../../services/llmClient.js';
const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache';
const CHARACTER_WORKFLOW_CACHE_DETAIL_PATH =
'/api/assets/character-workflow-cache/:characterId';
const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate';
const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish';
const CHARACTER_VISUAL_JOBS_PATH = '/api/assets/character-visual/jobs/';
const CHARACTER_VISUAL_JOB_DETAIL_PATH =
'/api/assets/character-visual/jobs/:taskId';
const CHARACTER_ANIMATION_GENERATE_PATH =
'/api/assets/character-animation/generate';
const CHARACTER_ANIMATION_PUBLISH_PATH =
'/api/assets/character-animation/publish';
const CHARACTER_ANIMATION_JOBS_PATH = '/api/assets/character-animation/jobs/';
const CHARACTER_ANIMATION_JOB_DETAIL_PATH =
'/api/assets/character-animation/jobs/:taskId';
const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH =
'/api/assets/character-animation/import-video';
const CHARACTER_ANIMATION_TEMPLATES_PATH =
@@ -2990,29 +2995,37 @@ export function createCharacterAssetRoutes(
next();
});
router.use(
router.post(
CHARACTER_WORKFLOW_CACHE_PATH,
routeMeta({ operation: 'assets.character.workflowCache.save' }),
toExpressHandler((request, response) => {
if (request.method === 'GET') {
return handleGetCharacterWorkflowCache(config, request, response);
}
return handleSaveCharacterWorkflowCache(config, request, response);
}),
);
router.use(
router.get(
CHARACTER_WORKFLOW_CACHE_DETAIL_PATH,
routeMeta({ operation: 'assets.character.workflowCache.get' }),
toExpressHandler((request, response) =>
handleGetCharacterWorkflowCache(config, request, response),
),
);
router.post(
CHARACTER_VISUAL_GENERATE_PATH,
routeMeta({ operation: 'assets.character.visual.generate' }),
toExpressHandler((request, response) =>
handleGenerateCharacterVisuals(config, request, response),
),
);
router.use(
router.post(
CHARACTER_VISUAL_PUBLISH_PATH,
routeMeta({ operation: 'assets.character.visual.publish' }),
toExpressHandler((request, response) =>
handlePublishCharacterVisual(config, request, response),
),
);
router.use(
CHARACTER_VISUAL_JOBS_PATH,
router.get(
CHARACTER_VISUAL_JOB_DETAIL_PATH,
routeMeta({ operation: 'assets.character.visual.job.get' }),
toExpressHandler((request, response) =>
handleReadCharacterJobStatus(
config.projectRoot,
@@ -3022,20 +3035,23 @@ export function createCharacterAssetRoutes(
),
),
);
router.use(
router.post(
CHARACTER_ANIMATION_GENERATE_PATH,
routeMeta({ operation: 'assets.character.animation.generate' }),
toExpressHandler((request, response) =>
handleGenerateCharacterAnimation(config, request, response),
),
);
router.use(
router.post(
CHARACTER_ANIMATION_PUBLISH_PATH,
routeMeta({ operation: 'assets.character.animation.publish' }),
toExpressHandler((request, response) =>
handlePublishCharacterAnimation(config, request, response),
),
);
router.use(
CHARACTER_ANIMATION_JOBS_PATH,
router.get(
CHARACTER_ANIMATION_JOB_DETAIL_PATH,
routeMeta({ operation: 'assets.character.animation.job.get' }),
toExpressHandler((request, response) =>
handleReadCharacterJobStatus(
config.projectRoot,
@@ -3045,8 +3061,9 @@ export function createCharacterAssetRoutes(
),
),
);
router.use(
router.post(
CHARACTER_ANIMATION_IMPORT_VIDEO_PATH,
routeMeta({ operation: 'assets.character.animation.importVideo' }),
toExpressHandler((request, response) =>
handleImportCharacterAnimationVideo(
config.projectRoot,
@@ -3055,8 +3072,9 @@ export function createCharacterAssetRoutes(
),
),
);
router.use(
router.get(
CHARACTER_ANIMATION_TEMPLATES_PATH,
routeMeta({ operation: 'assets.character.animation.templates.list' }),
toExpressHandler((request, response) =>
handleListAnimationTemplates(config, request, response),
),

View File

@@ -9,6 +9,7 @@ import path from 'node:path';
import { Router, type NextFunction, type Request, type Response } from 'express';
import type { AppConfig } from '../../config.js';
import { routeMeta } from '../../middleware/routeMeta.js';
const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/assets/qwen-sprite/master';
const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/assets/qwen-sprite/sheet';
@@ -878,26 +879,30 @@ export function createQwenSpriteRoutes(config: AppConfig) {
next();
});
router.use(
router.post(
QWEN_SPRITE_MASTER_GENERATE_PATH,
routeMeta({ operation: 'assets.qwenSprite.master.generate' }),
toExpressHandler((request, response) =>
handleGenerateMaster(config, request, response),
),
);
router.use(
router.post(
QWEN_SPRITE_SHEET_GENERATE_PATH,
routeMeta({ operation: 'assets.qwenSprite.sheet.generate' }),
toExpressHandler((request, response) =>
handleGenerateSheet(config, request, response),
),
);
router.use(
router.post(
QWEN_SPRITE_FRAME_REPAIR_PATH,
routeMeta({ operation: 'assets.qwenSprite.frameRepair.generate' }),
toExpressHandler((request, response) =>
handleRepairFrame(config, request, response),
),
);
router.use(
router.post(
QWEN_SPRITE_SAVE_PATH,
routeMeta({ operation: 'assets.qwenSprite.asset.save' }),
toExpressHandler((request, response) =>
handleSaveAsset(config.projectRoot, request, response),
),

View File

@@ -6,6 +6,7 @@ import { Router } from 'express';
import type { AppConfig } from '../../config.js';
import { badRequest, notFound } from '../../errors.js';
import { asyncHandler } from '../../http.js';
import { routeMeta } from '../../middleware/routeMeta.js';
const EDITOR_JSON_RESOURCE_FILES = {
'item-overrides': 'src/data/itemOverrides.json',
@@ -102,6 +103,7 @@ export function createEditorRoutes(config: AppConfig) {
router.get(
'/api/editor/catalog/items',
routeMeta({ operation: 'editor.catalog.items.list' }),
asyncHandler(async (_request, response) => {
response.json({
assetPaths: await collectPngAssetPaths(
@@ -113,6 +115,7 @@ export function createEditorRoutes(config: AppConfig) {
router.get(
'/api/editor/json/:resourceId',
routeMeta({ operation: 'editor.resource.read' }),
asyncHandler(async (request, response) => {
const filePath = resolveEditorJsonFile(config, request.params.resourceId);
response.json(await readEditorJsonFile(filePath));
@@ -121,6 +124,7 @@ export function createEditorRoutes(config: AppConfig) {
router.post(
'/api/editor/json/:resourceId',
routeMeta({ operation: 'editor.resource.write' }),
asyncHandler(async (request, response) => {
if (!isEditorJsonPayload(request.body)) {
throw badRequest('编辑器保存请求必须是 JSON 对象。');