@ynight/framework 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +511 -2
- package/assets/.gitkeep +0 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/assets/index.d.ts +41 -0
- package/dist/assets/index.d.ts.map +1 -0
- package/dist/assets/index.js +68 -0
- package/dist/assets/index.js.map +1 -0
- package/dist/client/common.d.ts +77 -0
- package/dist/client/common.d.ts.map +1 -0
- package/dist/client/index.d.ts +56 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.mjs +8 -0
- package/dist/client/index.mjs.LICENSE.txt +8 -0
- package/dist/client/layout/Layout.vue.d.ts +21 -0
- package/dist/client/layout/Layout.vue.d.ts.map +1 -0
- package/dist/client/layout/SiderLayout.vue.d.ts +32 -0
- package/dist/client/layout/SiderLayout.vue.d.ts.map +1 -0
- package/dist/client/layout/SubMenu.vue.d.ts +8 -0
- package/dist/client/layout/SubMenu.vue.d.ts.map +1 -0
- package/dist/client/package.json +3 -0
- package/dist/client/pages/DashboardPage.vue.d.ts +4 -0
- package/dist/client/pages/DashboardPage.vue.d.ts.map +1 -0
- package/dist/client/pages/ErrorPage.vue.d.ts +9 -0
- package/dist/client/pages/ErrorPage.vue.d.ts.map +1 -0
- package/dist/client/pages/PageSkeleton.vue.d.ts +4 -0
- package/dist/client/pages/PageSkeleton.vue.d.ts.map +1 -0
- package/dist/client/pages/ProjectListPage.vue.d.ts +4 -0
- package/dist/client/pages/ProjectListPage.vue.d.ts.map +1 -0
- package/dist/client/pages/project-navigation.d.ts +21 -0
- package/dist/client/pages/project-navigation.d.ts.map +1 -0
- package/dist/client/public-components.d.ts +56 -0
- package/dist/client/public-components.d.ts.map +1 -0
- package/dist/client/runtime.d.ts +55 -0
- package/dist/client/runtime.d.ts.map +1 -0
- package/dist/client/schema-types.d.ts +129 -0
- package/dist/client/schema-types.d.ts.map +1 -0
- package/dist/client/schema.d.ts +105 -0
- package/dist/client/schema.d.ts.map +1 -0
- package/dist/client/style.css +147 -0
- package/dist/client/types.d.ts +195 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/views/ModuleHost.vue.d.ts +8 -0
- package/dist/client/views/ModuleHost.vue.d.ts.map +1 -0
- package/dist/client/views/SchemaView.vue.d.ts +8 -0
- package/dist/client/views/SchemaView.vue.d.ts.map +1 -0
- package/dist/client/views/schema/create-form.vue.d.ts +13 -0
- package/dist/client/views/schema/create-form.vue.d.ts.map +1 -0
- package/dist/client/views/schema/detail-view.vue.d.ts +10 -0
- package/dist/client/views/schema/detail-view.vue.d.ts.map +1 -0
- package/dist/client/views/schema/search-panel.vue.d.ts +11 -0
- package/dist/client/views/schema/search-panel.vue.d.ts.map +1 -0
- package/dist/client/views/schema/table-panel.vue.d.ts +17 -0
- package/dist/client/views/schema/table-panel.vue.d.ts.map +1 -0
- package/dist/client/views/schema/update-form.vue.d.ts +14 -0
- package/dist/client/views/schema/update-form.vue.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/SchemaField.vue.d.ts +18 -0
- package/dist/client/widgets/schema-form/SchemaField.vue.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/complex-view/index.d.ts +11 -0
- package/dist/client/widgets/schema-form/complex-view/index.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/complex-view/schema-async-search-select.vue.d.ts +39 -0
- package/dist/client/widgets/schema-form/complex-view/schema-async-search-select.vue.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/complex-view/schema-dynamic-select.vue.d.ts +32 -0
- package/dist/client/widgets/schema-form/complex-view/schema-dynamic-select.vue.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/complex-view/schema-tag-input.vue.d.ts +25 -0
- package/dist/client/widgets/schema-form/complex-view/schema-tag-input.vue.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/complex-view/schema-upload.vue.d.ts +16 -0
- package/dist/client/widgets/schema-form/complex-view/schema-upload.vue.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/hooks/use-schema-rules.d.ts +45 -0
- package/dist/client/widgets/schema-form/hooks/use-schema-rules.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/index.vue.d.ts +26 -0
- package/dist/client/widgets/schema-form/index.vue.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/type.d.ts +295 -0
- package/dist/client/widgets/schema-form/type.d.ts.map +1 -0
- package/dist/client/widgets/schema-form/validator.d.ts +127 -0
- package/dist/client/widgets/schema-form/validator.d.ts.map +1 -0
- package/dist/client/widgets/schema-table/index.vue.d.ts +9 -0
- package/dist/client/widgets/schema-table/index.vue.d.ts.map +1 -0
- package/dist/client/widgets/schema-table/type.d.ts +91 -0
- package/dist/client/widgets/schema-table/type.d.ts.map +1 -0
- package/dist/model/index.d.ts +75 -0
- package/dist/model/index.d.ts.map +1 -0
- package/dist/model/index.js +108 -0
- package/dist/model/index.js.map +1 -0
- package/dist/model.d.ts +13 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +16 -0
- package/dist/model.js.map +1 -0
- package/dist/server/application.d.ts +33 -0
- package/dist/server/application.d.ts.map +1 -0
- package/dist/server/application.js +1082 -0
- package/dist/server/application.js.map +1 -0
- package/dist/server/base-class.d.ts +50 -0
- package/dist/server/base-class.d.ts.map +1 -0
- package/dist/server/base-class.js +66 -0
- package/dist/server/base-class.js.map +1 -0
- package/dist/server/config.d.ts +44 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +231 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/context.d.ts +30 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +3 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/define-routes.d.ts +29 -0
- package/dist/server/define-routes.d.ts.map +1 -0
- package/dist/server/define-routes.js +39 -0
- package/dist/server/define-routes.js.map +1 -0
- package/dist/server/define-server.d.ts +19 -0
- package/dist/server/define-server.d.ts.map +1 -0
- package/dist/server/define-server.js +23 -0
- package/dist/server/define-server.js.map +1 -0
- package/dist/server/index.d.ts +26 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +35 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/legacy.d.ts +13 -0
- package/dist/server/legacy.d.ts.map +1 -0
- package/dist/server/legacy.js +15 -0
- package/dist/server/legacy.js.map +1 -0
- package/dist/server/types.d.ts +272 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +3 -0
- package/dist/server/types.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +15 -0
- package/dist/server.js.map +1 -0
- package/package.json +106 -3
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createApplication = createApplication;
|
|
4
|
+
exports.startApplication = startApplication;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const node_fs_1 = tslib_1.__importDefault(require("node:fs"));
|
|
7
|
+
const node_path_1 = tslib_1.__importDefault(require("node:path"));
|
|
8
|
+
const router_1 = tslib_1.__importDefault(require("@koa/router"));
|
|
9
|
+
const koa_bodyparser_1 = tslib_1.__importDefault(require("koa-bodyparser"));
|
|
10
|
+
const koa_static_1 = tslib_1.__importDefault(require("koa-static"));
|
|
11
|
+
const ajv_1 = tslib_1.__importDefault(require("ajv"));
|
|
12
|
+
const ajv_formats_1 = tslib_1.__importDefault(require("ajv-formats"));
|
|
13
|
+
const core_1 = require("@ynight/core");
|
|
14
|
+
const model_runtime_1 = require("@ynight/model-runtime");
|
|
15
|
+
const assets_1 = require("../assets");
|
|
16
|
+
const define_routes_1 = require("./define-routes");
|
|
17
|
+
const config_1 = require("./config");
|
|
18
|
+
/**
|
|
19
|
+
* 加载业务 middleware 目录下所有模块。
|
|
20
|
+
*
|
|
21
|
+
* 文件必须默认导出一个工厂函数 `export default (app) => Middleware`:
|
|
22
|
+
* 1. 工厂在装配阶段被调用,接收 FrameworkApplication 实例;
|
|
23
|
+
* 2. 返回的中间件按文件遍历顺序注册到 app.use(),因此"先注册先执行";
|
|
24
|
+
* 3. 工厂返回值不是函数时抛出明确错误,避免被静默忽略。
|
|
25
|
+
*/
|
|
26
|
+
function loadMiddlewares(root, extension, app) {
|
|
27
|
+
(0, core_1.listModuleFiles)(root, extension).forEach(filePath => {
|
|
28
|
+
const factory = (0, core_1.readModule)(filePath);
|
|
29
|
+
if (typeof factory !== 'function') {
|
|
30
|
+
throw new Error(`业务中间件必须导出函数: ${filePath}`);
|
|
31
|
+
}
|
|
32
|
+
const middleware = factory(app);
|
|
33
|
+
if (typeof middleware !== 'function') {
|
|
34
|
+
throw new Error(`业务中间件工厂必须返回 Koa 中间件: ${filePath}`);
|
|
35
|
+
}
|
|
36
|
+
app.use(middleware);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 从 server/router-schema 目录加载集中维护的请求参数校验规则。
|
|
41
|
+
*
|
|
42
|
+
* 文件结构示例:
|
|
43
|
+
* ```ts
|
|
44
|
+
* export default {
|
|
45
|
+
* '/api/product': {
|
|
46
|
+
* get: { query: { type: 'object', properties: { id: { type: 'string' } } } },
|
|
47
|
+
* post: { body: { ... } },
|
|
48
|
+
* },
|
|
49
|
+
* '/api/order/:id': { get: { params: { ... } } },
|
|
50
|
+
* };
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* 校验规则:
|
|
54
|
+
* - 路径必须以 / 开头
|
|
55
|
+
* - 方法必须属于 HTTP 动词集合(大小写不敏感)
|
|
56
|
+
* - 值必须是对象或布尔(兼容 Ajv 的 boolean schema)
|
|
57
|
+
* - 同一路由同一方法重复声明直接报错(避免后写覆盖前写造成调试困惑)
|
|
58
|
+
*/
|
|
59
|
+
function loadRouterSchemas(root, extension) {
|
|
60
|
+
const result = {};
|
|
61
|
+
(0, core_1.listModuleFiles)(root, extension).forEach(filePath => {
|
|
62
|
+
const value = (0, core_1.readModule)(filePath);
|
|
63
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
64
|
+
throw new Error(`路由校验规则必须导出对象: ${filePath}`);
|
|
65
|
+
}
|
|
66
|
+
Object.entries(value).forEach(([routePath, methods]) => {
|
|
67
|
+
if (!routePath.startsWith('/') || !methods || typeof methods !== 'object') {
|
|
68
|
+
throw new Error(`路由校验规则路径无效: ${filePath} -> ${routePath}`);
|
|
69
|
+
}
|
|
70
|
+
const current = result[routePath] ?? {};
|
|
71
|
+
Object.entries(methods).forEach(([method, schema]) => {
|
|
72
|
+
if (!/^(get|post|put|patch|delete|head)$/i.test(method)) {
|
|
73
|
+
throw new Error(`路由校验规则方法无效: ${filePath} -> ${method}`);
|
|
74
|
+
}
|
|
75
|
+
if (schema === null ||
|
|
76
|
+
(typeof schema !== 'object' && typeof schema !== 'boolean') ||
|
|
77
|
+
(typeof schema === 'object' && Array.isArray(schema))) {
|
|
78
|
+
throw new Error(`路由校验规则必须是对象: ${filePath} -> ${routePath}.${method}`);
|
|
79
|
+
}
|
|
80
|
+
const normalizedMethod = method.toLowerCase();
|
|
81
|
+
if (normalizedMethod in current) {
|
|
82
|
+
throw new Error(`路由校验规则重复: ${filePath} -> ${routePath}.${method}`);
|
|
83
|
+
}
|
|
84
|
+
current[normalizedMethod] = schema;
|
|
85
|
+
});
|
|
86
|
+
result[routePath] = current;
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 将 Ajv 的 ErrorObject 转换为人类可读的位置描述。
|
|
93
|
+
*
|
|
94
|
+
* - `required` 错误:追加缺失字段名
|
|
95
|
+
* - `additionalProperties` 错误:追加多余字段名
|
|
96
|
+
* - `instancePath`(JSON Pointer)转回点分路径,并对数字段使用 `[idx]` 表示
|
|
97
|
+
* - 输出格式:`{configPath}.{instancePath}.{property} [keyword] message`
|
|
98
|
+
*
|
|
99
|
+
* 主要供开发期日志与 400 响应体 message 字段使用。
|
|
100
|
+
*/
|
|
101
|
+
function formatAjvError(error, configPath) {
|
|
102
|
+
const property = error.keyword === 'required'
|
|
103
|
+
? error.params.missingProperty
|
|
104
|
+
: error.keyword === 'additionalProperties'
|
|
105
|
+
? error.params.additionalProperty
|
|
106
|
+
: undefined;
|
|
107
|
+
const suffix = typeof property === 'string' ? `.${property}` : '';
|
|
108
|
+
const instancePath = error.instancePath
|
|
109
|
+
.split('/')
|
|
110
|
+
.slice(1)
|
|
111
|
+
// Ajv 使用 JSON Pointer(RFC 6901):~1 → /、~0 → ~,需还原字面字符
|
|
112
|
+
.map(segment => segment.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
113
|
+
// 数组下标用 [i] 形式,对象键用 .key 形式
|
|
114
|
+
.map(segment => (/^\d+$/.test(segment) ? `[${segment}]` : `.${segment}`))
|
|
115
|
+
.join('');
|
|
116
|
+
return `${configPath}${instancePath}${suffix} [${error.keyword}] ${error.message ?? '校验失败'}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 创建专用于请求参数校验的 Ajv 实例。
|
|
120
|
+
*
|
|
121
|
+
* - allErrors:收集所有错误而非遇到第一个就返回,方便前端一次性提示
|
|
122
|
+
* - coerceTypes:自动将 query/path 中的字符串转成 number/boolean,减少业务侧样板代码
|
|
123
|
+
* - strict:严格模式,避免"未知关键字"被静默忽略
|
|
124
|
+
*
|
|
125
|
+
* addFormats 注入 format(date / uri / email 等)的标准校验能力。
|
|
126
|
+
*/
|
|
127
|
+
function createRequestSchemaCompiler() {
|
|
128
|
+
const ajv = new ajv_1.default({ allErrors: true, coerceTypes: true, strict: true });
|
|
129
|
+
(0, ajv_formats_1.default)(ajv);
|
|
130
|
+
return ajv;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 将单条路由的请求校验规则编译为 Ajv 校验函数。
|
|
134
|
+
*
|
|
135
|
+
* - 仅支持 body/query/params/headers 四类位置;其它键直接抛错(早期失败优于静默忽略)
|
|
136
|
+
* - 任意 schema 编译失败抛错,附带 configPath 与 Ajv 原消息(cause 链保留原始异常)
|
|
137
|
+
* - 全部未声明时返回 undefined,调用方按"无校验"处理
|
|
138
|
+
*/
|
|
139
|
+
function compileRequestSchemas(schemas, configPath, ajv) {
|
|
140
|
+
if (!schemas) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
if (!schemas || typeof schemas !== 'object' || Array.isArray(schemas)) {
|
|
144
|
+
throw new Error(`路由校验规则 ${configPath} 必须是对象`);
|
|
145
|
+
}
|
|
146
|
+
const supportedParts = new Set(['body', 'query', 'params', 'headers']);
|
|
147
|
+
for (const part of Object.keys(schemas)) {
|
|
148
|
+
if (!supportedParts.has(part)) {
|
|
149
|
+
throw new Error(`路由校验规则 ${configPath} 包含未知参数位置: ${part}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const result = {};
|
|
153
|
+
for (const part of ['body', 'query', 'params', 'headers']) {
|
|
154
|
+
const schema = schemas[part];
|
|
155
|
+
if (schema === undefined) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
result[part] = ajv.compile(schema);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
163
|
+
throw new Error(`路由校验规则 ${configPath}.${part} 无效: ${message}`, { cause: error });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 批量编译 router-schema 目录下所有规则。
|
|
170
|
+
* 返回值结构与 RouterSchemas 一致,但每个值已被替换为 CompiledRequestSchemas。
|
|
171
|
+
*/
|
|
172
|
+
function compileRouterSchemas(schemas, ajv) {
|
|
173
|
+
const result = {};
|
|
174
|
+
for (const [routePath, methods] of Object.entries(schemas)) {
|
|
175
|
+
result[routePath] = {};
|
|
176
|
+
for (const [method, requestSchemas] of Object.entries(methods)) {
|
|
177
|
+
result[routePath][method] =
|
|
178
|
+
compileRequestSchemas(requestSchemas, `${method.toUpperCase()} ${routePath}`, ajv) ?? {};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* 从请求中提取 projectKey 并写入 ctx.state.projectKey。
|
|
185
|
+
* 优先顺序:自定义头 `projectKey` > query `projectKey`。
|
|
186
|
+
*
|
|
187
|
+
* 返回值:最终使用的 projectKey(可能为 undefined),便于调用方判断"是否需要中断请求"。
|
|
188
|
+
*/
|
|
189
|
+
function attachProject(ctx) {
|
|
190
|
+
const fromHeader = ctx.get('projectKey');
|
|
191
|
+
const fromQuery = ctx.query.projectKey;
|
|
192
|
+
const value = fromHeader || (typeof fromQuery === 'string' ? fromQuery : undefined);
|
|
193
|
+
if (value) {
|
|
194
|
+
ctx.state.projectKey = value;
|
|
195
|
+
}
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 把已加载模型展开为"扁平的项目列表",便于统一做授权与返回数据组装。
|
|
200
|
+
* 每个条目包含:项目 key、项目内容(IModel)、所属 model 的 key。
|
|
201
|
+
*/
|
|
202
|
+
function allProjects(models) {
|
|
203
|
+
return models.flatMap(item => Object.entries(item.project).map(([key, project]) => ({ key, project, modelKey: item.key })));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 构造对外公开的项目 DTO:
|
|
207
|
+
* - 去掉内部私有字段(如 model 内部标记)
|
|
208
|
+
* - 补充 modelKey(前端 dashboard 据此选择模型运行时)
|
|
209
|
+
* - homePage 缺省时补空串,避免 undefined 进入 JSON 序列化
|
|
210
|
+
*/
|
|
211
|
+
function publicProject(modelKey, key, project) {
|
|
212
|
+
return {
|
|
213
|
+
key,
|
|
214
|
+
name: project.name,
|
|
215
|
+
desc: project.desc,
|
|
216
|
+
homePage: project.homePage ?? '',
|
|
217
|
+
modelKey,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 将 AuthIdentity 投影为对外可见的安全字段。
|
|
222
|
+
* 仅暴露 id 与可选 name,其它业务字段(如角色、token)不会进入 API 响应。
|
|
223
|
+
*/
|
|
224
|
+
function publicIdentity(identity) {
|
|
225
|
+
if (!identity) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return { id: identity.id, ...(identity.name === undefined ? {} : { name: identity.name }) };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* 判定当前用户是否对指定 projectKey 具备访问权限。
|
|
232
|
+
*
|
|
233
|
+
* 关键细节:
|
|
234
|
+
* 1. 未配置 AuthAdapter 时直接放行(保留最小可用状态,便于早期原型)
|
|
235
|
+
* 2. 无身份时拒绝访问(即便 devAnonymous 模式也不应绕过项目级授权)
|
|
236
|
+
* 3. 调用 authorize 前临时写入 projectKey,使适配器可以基于项目决策;
|
|
237
|
+
* 调用结束(finally)后恢复原值,避免污染后续请求
|
|
238
|
+
* 4. 默认 access 为 `module='project', action='read'`,与路由注册处的语义对齐
|
|
239
|
+
*/
|
|
240
|
+
async function isProjectAllowed(ctx, app, projectKey, access = { module: 'project', action: 'read' }) {
|
|
241
|
+
if (!app.serverExtension.auth) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
const identity = ctx.state.user;
|
|
245
|
+
if (!identity) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
const previous = ctx.state.projectKey;
|
|
249
|
+
ctx.state.projectKey = projectKey;
|
|
250
|
+
try {
|
|
251
|
+
return await app.serverExtension.auth.authorize(ctx, access, identity);
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
if (previous === undefined) {
|
|
255
|
+
delete ctx.state.projectKey;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
ctx.state.projectKey = previous;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 过滤当前用户实际可见的项目列表。
|
|
264
|
+
* - 可按 modelKey 缩小范围(用于 /api/project/list?modelKey=xxx)
|
|
265
|
+
* - 项目级权限失败的项目直接丢弃
|
|
266
|
+
*/
|
|
267
|
+
async function visibleProjects(ctx, app, models, modelKey) {
|
|
268
|
+
const result = [];
|
|
269
|
+
for (const item of allProjects(models)) {
|
|
270
|
+
if (modelKey && item.modelKey !== modelKey) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (await isProjectAllowed(ctx, app, item.key)) {
|
|
274
|
+
result.push(item);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* 构造 `/api/project/model_list` 的响应:每个模型 + 其下当前用户可见的项目列表。
|
|
281
|
+
* - 没有任何可见项目的模型会被过滤掉,避免暴露空模型
|
|
282
|
+
*/
|
|
283
|
+
async function publicModels(ctx, app, models) {
|
|
284
|
+
const projects = await visibleProjects(ctx, app, models);
|
|
285
|
+
return models
|
|
286
|
+
.map(item => ({
|
|
287
|
+
key: item.key,
|
|
288
|
+
name: item.model.name,
|
|
289
|
+
desc: item.model.desc,
|
|
290
|
+
project: projects
|
|
291
|
+
.filter(project => project.modelKey === item.key)
|
|
292
|
+
.map(project => publicProject(item.key, project.key, project.project)),
|
|
293
|
+
}))
|
|
294
|
+
.filter(item => item.project.length > 0);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* 在全部项目中按字符串精确匹配查找。
|
|
298
|
+
* 命中失败返回 undefined,由调用方决定 404 行为。
|
|
299
|
+
*/
|
|
300
|
+
function findProject(models, projectKey) {
|
|
301
|
+
return allProjects(models).find(item => item.key === projectKey);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* 模块可见性判定。
|
|
305
|
+
* - 未配置 AuthAdapter 时一律放行
|
|
306
|
+
* - 传入模块 key(通常为路由路径),动作固定为 read
|
|
307
|
+
*/
|
|
308
|
+
async function isModuleAllowed(ctx, app, moduleKey) {
|
|
309
|
+
if (!app.serverExtension.auth) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
const identity = ctx.state.user;
|
|
313
|
+
return Boolean(identity &&
|
|
314
|
+
(await app.serverExtension.auth.authorize(ctx, { module: moduleKey, action: 'read' }, identity)));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* 对菜单进行授权过滤,仅保留当前用户可见的项。
|
|
318
|
+
*
|
|
319
|
+
* 递归规则:
|
|
320
|
+
* - group:递归子菜单,过滤后若空则丢弃整个分组
|
|
321
|
+
* - sider(侧边栏容器):递归内部 menu,过滤后若空则保留空容器(避免结构破坏)
|
|
322
|
+
* - 其它 module:按 key 做 read 权限判定
|
|
323
|
+
*/
|
|
324
|
+
async function visibleMenu(ctx, app, menu) {
|
|
325
|
+
const result = [];
|
|
326
|
+
for (const item of menu) {
|
|
327
|
+
if (!(await isModuleAllowed(ctx, app, item.key))) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (item.menuType === 'group') {
|
|
331
|
+
const subMenu = await visibleMenu(ctx, app, item.subMenu);
|
|
332
|
+
if (subMenu.length > 0) {
|
|
333
|
+
result.push({ ...item, subMenu });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else if (item.moduleType === 'sider') {
|
|
337
|
+
const subMenu = await visibleMenu(ctx, app, item.moduleConfig.menu);
|
|
338
|
+
if (subMenu.length > 0) {
|
|
339
|
+
result.push({ ...item, moduleConfig: { ...item.moduleConfig, menu: subMenu } });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
result.push(item);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* 返回对外可见的项目详情(含菜单授权过滤结果)。
|
|
350
|
+
*
|
|
351
|
+
* 同样采用"临时覆盖 → 调用 → finally 还原"的模式,与 isProjectAllowed 保持一致,
|
|
352
|
+
* 避免授权适配器读到的 ctx.state.projectKey 与调用方预期不符。
|
|
353
|
+
*/
|
|
354
|
+
async function publicProjectDetail(ctx, app, projectKey, project) {
|
|
355
|
+
const previous = ctx.state.projectKey;
|
|
356
|
+
ctx.state.projectKey = projectKey;
|
|
357
|
+
try {
|
|
358
|
+
return { ...project, menu: await visibleMenu(ctx, app, project.menu) };
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
if (previous === undefined) {
|
|
362
|
+
delete ctx.state.projectKey;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
ctx.state.projectKey = previous;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 构造请求参数校验中间件。
|
|
371
|
+
*
|
|
372
|
+
* - 按 body → query → params → headers 顺序校验,任一失败立即返回 400
|
|
373
|
+
* - 错误信息包含 Ajv 的所有错误(每条按 formatAjvError 格式化)
|
|
374
|
+
* - 校验成功或未声明时透传到下游(即 next())
|
|
375
|
+
*/
|
|
376
|
+
function requestSchemaMiddleware(schema) {
|
|
377
|
+
return async (ctx, next) => {
|
|
378
|
+
if (schema) {
|
|
379
|
+
for (const part of ['body', 'query', 'params', 'headers']) {
|
|
380
|
+
const validate = schema[part];
|
|
381
|
+
if (!validate) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const value = part === 'body'
|
|
385
|
+
? ctx.request.body
|
|
386
|
+
: part === 'query'
|
|
387
|
+
? ctx.query
|
|
388
|
+
: part === 'params'
|
|
389
|
+
? ctx.params
|
|
390
|
+
: ctx.headers;
|
|
391
|
+
if (!validate(value)) {
|
|
392
|
+
const details = (validate.errors ?? [])
|
|
393
|
+
.map(error => formatAjvError(error, part))
|
|
394
|
+
.join('; ');
|
|
395
|
+
ctx.status = 400;
|
|
396
|
+
ctx.body = {
|
|
397
|
+
success: false,
|
|
398
|
+
code: 400,
|
|
399
|
+
message: `参数校验失败: ${details || part}`,
|
|
400
|
+
errors: validate.errors,
|
|
401
|
+
};
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
await next();
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* 把单个 RouteDefinition 注册到 @koa/router 实例上。
|
|
411
|
+
*
|
|
412
|
+
* 装配顺序(从外到内):
|
|
413
|
+
* 1. accessMiddleware:
|
|
414
|
+
* - projectRequired 时强制要求 projectKey,缺失返回 400
|
|
415
|
+
* - 路由级 access 鉴权,失败返回 403
|
|
416
|
+
* 2. requestSchemaMiddleware:参数校验(内联 > 集中)
|
|
417
|
+
* 3. handler:业务处理函数
|
|
418
|
+
*
|
|
419
|
+
* 路由 ID(method + path)必须唯一,重复注册立即抛错。
|
|
420
|
+
*/
|
|
421
|
+
function registerRoute(router, route, app, schemas, registered, ajv) {
|
|
422
|
+
const method = route.method.toLowerCase();
|
|
423
|
+
if (!route.path.startsWith('/')) {
|
|
424
|
+
throw new Error(`路由路径必须以 / 开头: ${route.path}`);
|
|
425
|
+
}
|
|
426
|
+
const routeId = `${method} ${route.path}`;
|
|
427
|
+
if (registered.has(routeId)) {
|
|
428
|
+
throw new Error(`路由重复注册: ${routeId}`);
|
|
429
|
+
}
|
|
430
|
+
registered.add(routeId);
|
|
431
|
+
const accessMiddleware = async (ctx, next) => {
|
|
432
|
+
if (route.projectRequired && !attachProject(ctx)) {
|
|
433
|
+
ctx.status = 400;
|
|
434
|
+
ctx.body = { success: false, code: 400, message: '缺少 projectKey' };
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (route.access && app.serverExtension.auth) {
|
|
438
|
+
const identity = ctx.state.user;
|
|
439
|
+
if (!identity || !(await app.serverExtension.auth.authorize(ctx, route.access, identity))) {
|
|
440
|
+
ctx.status = 403;
|
|
441
|
+
ctx.body = { success: false, code: 403, message: '无权访问当前资源' };
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
await next();
|
|
446
|
+
};
|
|
447
|
+
const inlineSchemas = compileRequestSchemas(route.schema, `${route.method} ${route.path}`, ajv);
|
|
448
|
+
const compiledSchemas = inlineSchemas ?? schemas[route.path]?.[method];
|
|
449
|
+
router[method](route.path, accessMiddleware, requestSchemaMiddleware(compiledSchemas), async (ctx) => route.handler(ctx));
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 注册业务 router 目录下的所有路由。
|
|
453
|
+
*
|
|
454
|
+
* 该函数通过 Proxy 拦截 router.get/post/... 方法,在原有 Router 之上包裹一层:
|
|
455
|
+
* - attachProject:把 projectKey 写入 ctx.state,便于后续授权/业务使用
|
|
456
|
+
* - 模块级鉴权:未配置 AuthAdapter 时放行,否则按"路由路径 + 方法"判定
|
|
457
|
+
* - 参数校验:自动应用 router-schema 目录下的集中规则
|
|
458
|
+
*
|
|
459
|
+
* 同时提供 reserve 机制:
|
|
460
|
+
* - reserved 用于记录框架内置路由;业务路由与之冲突直接报错
|
|
461
|
+
* - registered 跟踪已注册路由(含内置 + 业务),重复声明抛错
|
|
462
|
+
*
|
|
463
|
+
* 业务侧有两种声明方式:
|
|
464
|
+
* 1. `export default (app, router) => { router.get('/x', handler) }`(推荐,Proxy 模式)
|
|
465
|
+
* 2. `export default (app) => [{ method, path, handler, ... }]`(声明式,经 registerRoute)
|
|
466
|
+
*/
|
|
467
|
+
function registerBusinessRoutes(router, root, extension, app, schemas, registered, reserved, ajv) {
|
|
468
|
+
const methods = new Set(['get', 'post', 'put', 'patch', 'delete', 'head']);
|
|
469
|
+
const businessRouter = new Proxy(router, {
|
|
470
|
+
get(target, property, receiver) {
|
|
471
|
+
if (typeof property === 'string' && methods.has(property)) {
|
|
472
|
+
return (routePath, ...handlers) => {
|
|
473
|
+
const routeId = `${property} ${routePath}`;
|
|
474
|
+
if (reserved.has(routeId) || registered.has(routeId)) {
|
|
475
|
+
throw new Error(`业务路由与框架路由冲突: ${routeId}`);
|
|
476
|
+
}
|
|
477
|
+
if (!routePath.startsWith('/')) {
|
|
478
|
+
throw new Error(`路由路径必须以 / 开头: ${routePath}`);
|
|
479
|
+
}
|
|
480
|
+
registered.add(routeId);
|
|
481
|
+
const register = target[property];
|
|
482
|
+
register.call(target, routePath, async (ctx, next) => {
|
|
483
|
+
attachProject(ctx);
|
|
484
|
+
if (app.serverExtension.auth) {
|
|
485
|
+
const identity = ctx.state.user;
|
|
486
|
+
if (!identity ||
|
|
487
|
+
!(await app.serverExtension.auth.authorize(ctx, { module: routePath, action: property }, identity))) {
|
|
488
|
+
ctx.status = 403;
|
|
489
|
+
ctx.body = { success: false, code: 403, message: '无权访问当前资源' };
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
await next();
|
|
494
|
+
}, requestSchemaMiddleware(schemas[routePath]?.[property]), ...handlers);
|
|
495
|
+
return target;
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return Reflect.get(target, property, receiver);
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
(0, core_1.listModuleFiles)(root, extension).forEach(filePath => {
|
|
502
|
+
const exported = (0, core_1.readModule)(filePath);
|
|
503
|
+
if (typeof exported !== 'function') {
|
|
504
|
+
throw new Error(`业务路由必须导出函数: ${filePath}`);
|
|
505
|
+
}
|
|
506
|
+
const result = exported(app, businessRouter);
|
|
507
|
+
if ((0, define_routes_1.isRouteList)(result)) {
|
|
508
|
+
result.forEach(route => registerRoute(router, route, app, schemas, registered, ajv));
|
|
509
|
+
}
|
|
510
|
+
else if (result !== undefined && result !== router) {
|
|
511
|
+
throw new Error(`业务路由工厂返回了不支持的值: ${filePath}`);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* 校验 router-schema 目录下的每条规则都对应了一个实际路由。
|
|
517
|
+
*
|
|
518
|
+
* 目的:避免"定义却未使用"的死代码——一旦路由被删除,集中校验规则极易被遗忘。
|
|
519
|
+
* 不通过则在启动期抛错,强制开发者清理或补齐路由。
|
|
520
|
+
*/
|
|
521
|
+
function assertNoOrphanSchemas(schemas, registered) {
|
|
522
|
+
Object.entries(schemas).forEach(([routePath, methods]) => Object.keys(methods).forEach(method => {
|
|
523
|
+
if (!registered.has(`${method.toLowerCase()} ${routePath}`)) {
|
|
524
|
+
throw new Error(`路由校验规则没有匹配的实际路由: ${method.toUpperCase()} ${routePath}`);
|
|
525
|
+
}
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* 加载控件契约(controlContracts)索引文件。
|
|
530
|
+
* - 文件不存在时返回空对象(视为"无契约")
|
|
531
|
+
* - 加载内容必须为对象,否则抛错
|
|
532
|
+
* - 抛错信息使用统一前缀 `ELPIS_CONTROL_CONTRACT_MISSING:` 便于上层聚合错误识别
|
|
533
|
+
*/
|
|
534
|
+
function loadControlContracts(filePath) {
|
|
535
|
+
if (!filePath) {
|
|
536
|
+
return {};
|
|
537
|
+
}
|
|
538
|
+
if (!node_fs_1.default.existsSync(filePath)) {
|
|
539
|
+
throw new Error(`ELPIS_CONTROL_CONTRACT_MISSING: 控件契约文件不存在: ${filePath}`);
|
|
540
|
+
}
|
|
541
|
+
const value = (0, core_1.readModule)(filePath);
|
|
542
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
543
|
+
throw new Error(`控件契约必须导出对象: ${filePath}`);
|
|
544
|
+
}
|
|
545
|
+
return value;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* 根据请求路径返回对应的框架内置页面名称。
|
|
549
|
+
*
|
|
550
|
+
* 映射规则:
|
|
551
|
+
* - `/error` → error
|
|
552
|
+
* - `/project-list` 或配置的 homePage(剥离 query) → project-list
|
|
553
|
+
* - dashboardBase 自身或以其开头的子路径(且不包含点号视为 HTML) → dashboard
|
|
554
|
+
*
|
|
555
|
+
* 返回 undefined 表示"不属于框架内置页面",由调用方走 404 或自定义菜单重定向兜底。
|
|
556
|
+
*/
|
|
557
|
+
function pageForPath(config, requestPath) {
|
|
558
|
+
const dashboardBase = config.client.dashboardBase === '/' ? '' : config.client.dashboardBase;
|
|
559
|
+
if (requestPath === '/error') {
|
|
560
|
+
return 'error';
|
|
561
|
+
}
|
|
562
|
+
if (requestPath === '/project-list' || requestPath === config.public.homePage.split('?')[0]) {
|
|
563
|
+
return 'project-list';
|
|
564
|
+
}
|
|
565
|
+
if (requestPath === dashboardBase ||
|
|
566
|
+
(dashboardBase && requestPath.startsWith(`${dashboardBase}/`))) {
|
|
567
|
+
const rest = requestPath.slice(dashboardBase.length).replace(/^\/+/, '');
|
|
568
|
+
// 路径中存在点号视为静态资源,不返回框架页面
|
|
569
|
+
if (!rest || !rest.includes('.')) {
|
|
570
|
+
return 'dashboard';
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
/** 仅将显式接受 HTML 的浏览器导航视为页面请求,避免把通配资源请求当作页面兜底。 */
|
|
576
|
+
function acceptsHtmlDocument(ctx) {
|
|
577
|
+
return ctx
|
|
578
|
+
.get('accept')
|
|
579
|
+
.split(',')
|
|
580
|
+
.some(mediaRange => {
|
|
581
|
+
const [mediaType, ...parameters] = mediaRange.trim().split(';');
|
|
582
|
+
if (!['text/html', 'application/xhtml+xml'].includes(mediaType?.toLowerCase() ?? '')) {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
const quality = parameters.find(parameter => /^q\s*=/i.test(parameter.trim()));
|
|
586
|
+
return !quality || Number(quality.split('=')[1]) > 0;
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
/** API 前缀只包含 `/api` 本身和其子路径,避免误伤 `/apiary` 等普通页面。 */
|
|
590
|
+
function isApiPath(requestPath) {
|
|
591
|
+
return requestPath === '/api' || requestPath.startsWith('/api/');
|
|
592
|
+
}
|
|
593
|
+
/** 检查请求路径是否对应 publicRoot 下的文件或静态目录首页。 */
|
|
594
|
+
async function hasStaticFile(publicRoot, requestPath) {
|
|
595
|
+
let decodedPath;
|
|
596
|
+
try {
|
|
597
|
+
decodedPath = decodeURIComponent(requestPath);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
const root = node_path_1.default.resolve(publicRoot);
|
|
603
|
+
const target = node_path_1.default.resolve(root, `.${decodedPath}`);
|
|
604
|
+
const relativePath = node_path_1.default.relative(root, target);
|
|
605
|
+
if (relativePath === '..' ||
|
|
606
|
+
relativePath.startsWith(`..${node_path_1.default.sep}`) ||
|
|
607
|
+
node_path_1.default.isAbsolute(relativePath)) {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
const targetStat = await node_fs_1.default.promises.stat(target);
|
|
612
|
+
if (targetStat.isFile()) {
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
if (targetStat.isDirectory()) {
|
|
616
|
+
return (await node_fs_1.default.promises.stat(node_path_1.default.join(target, 'index.html'))).isFile();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* 在项目菜单树中递归查找匹配当前请求路径的自定义菜单项。
|
|
626
|
+
* - group:递归子菜单
|
|
627
|
+
* - sider:递归内部 menu,并把当前 sider.key 透传,便于后续路由参数化
|
|
628
|
+
* - custom:按 moduleConfig.path 精确匹配
|
|
629
|
+
* 匹配成功返回 CustomMenuMatch;未匹配返回 undefined。
|
|
630
|
+
*/
|
|
631
|
+
function findCustomMenu(menu, requestPath, siderKey) {
|
|
632
|
+
for (const item of menu) {
|
|
633
|
+
if (item.menuType === 'group') {
|
|
634
|
+
const nested = findCustomMenu(item.subMenu, requestPath, siderKey);
|
|
635
|
+
if (nested) {
|
|
636
|
+
return nested;
|
|
637
|
+
}
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (item.moduleType === 'sider') {
|
|
641
|
+
const nested = findCustomMenu(item.moduleConfig.menu, requestPath, siderKey ?? item.key);
|
|
642
|
+
if (nested) {
|
|
643
|
+
return nested;
|
|
644
|
+
}
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (item.moduleType === 'custom' && item.moduleConfig.path === requestPath) {
|
|
648
|
+
return siderKey ? { menu: item, siderKey } : { menu: item };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* 处理 History 路由 + 自定义菜单的短链重定向:
|
|
655
|
+
* - 当用户直接访问一个 custom 菜单的原始 path 时,将其重定向到 dashboard 形式
|
|
656
|
+
* (携带 projectKey/key/siderKey 等 query)
|
|
657
|
+
*
|
|
658
|
+
* 行为:
|
|
659
|
+
* 1. 按 requestedProjectKey 过滤候选项目;未提供时使用全部
|
|
660
|
+
* 2. 对每个候选项目,临时写入 ctx.state.projectKey 后再进行菜单授权/匹配
|
|
661
|
+
* 3. 唯一命中:构造带 query 的目标 URL 并 ctx.redirect
|
|
662
|
+
* 4. 多命中且未指定 projectKey:跳转回 /project-list 由用户选择
|
|
663
|
+
* 5. 其余情况返回 false,交给上游走 404 兜底
|
|
664
|
+
*/
|
|
665
|
+
async function redirectCustomPageShortcut(ctx, app, models) {
|
|
666
|
+
const requestedProjectKey = typeof ctx.query.projectKey === 'string' ? ctx.query.projectKey : undefined;
|
|
667
|
+
const projects = await visibleProjects(ctx, app, models);
|
|
668
|
+
const candidates = requestedProjectKey
|
|
669
|
+
? projects.filter(item => item.key === requestedProjectKey)
|
|
670
|
+
: projects;
|
|
671
|
+
const matches = [];
|
|
672
|
+
for (const project of candidates) {
|
|
673
|
+
const previous = ctx.state.projectKey;
|
|
674
|
+
ctx.state.projectKey = project.key;
|
|
675
|
+
try {
|
|
676
|
+
const match = findCustomMenu(await visibleMenu(ctx, app, project.project.menu), ctx.path);
|
|
677
|
+
if (match) {
|
|
678
|
+
matches.push({ projectKey: project.key, match });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
finally {
|
|
682
|
+
if (previous === undefined) {
|
|
683
|
+
delete ctx.state.projectKey;
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
ctx.state.projectKey = previous;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (matches.length !== 1) {
|
|
691
|
+
if (!requestedProjectKey && matches.length > 1) {
|
|
692
|
+
// 多项目都匹配同一自定义路径且未指定项目:回到项目列表让用户自行选择
|
|
693
|
+
ctx.redirect('/project-list');
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
const match = matches[0];
|
|
699
|
+
if (!match) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
const { projectKey, match: customMatch } = match;
|
|
703
|
+
const { menu, siderKey } = customMatch;
|
|
704
|
+
const dashboardBase = app.config.client.dashboardBase === '/' ? '' : app.config.client.dashboardBase;
|
|
705
|
+
const query = new URLSearchParams(ctx.querystring);
|
|
706
|
+
query.set('projectKey', projectKey);
|
|
707
|
+
query.set('key', siderKey ?? menu.key);
|
|
708
|
+
if (siderKey) {
|
|
709
|
+
query.set('siderKey', menu.key);
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
query.delete('siderKey');
|
|
713
|
+
}
|
|
714
|
+
const suffix = query.toString() ? `?${query.toString()}` : '';
|
|
715
|
+
ctx.redirect(`${dashboardBase}${siderKey ? `/sider${ctx.path}` : ctx.path}${suffix}`);
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* 判断 host 是否为回环地址(含 IPv4 与 IPv6 形式)。
|
|
720
|
+
* - 仅在 devAnonymous + 非生产场景下使用,避免匿名认证在公网监听
|
|
721
|
+
*/
|
|
722
|
+
function isLoopbackHost(host) {
|
|
723
|
+
return (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1');
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* 根据实际存在的业务模块推断加载扩展名。
|
|
727
|
+
* 优先级:.js > .cjs > .ts;空项目按编译产物约定返回 .js。
|
|
728
|
+
*
|
|
729
|
+
* CLI dev/build 流程会把业务模块输出为 .js,因此推断逻辑主要面向:
|
|
730
|
+
* - 用户未显式传入 moduleExtension
|
|
731
|
+
* - 框架内部 listFiles 需要按确定扩展名扫描
|
|
732
|
+
*/
|
|
733
|
+
function inferModuleExtension(paths) {
|
|
734
|
+
if ((0, core_1.listModuleFiles)(paths.modelRoot, '.js').length || (0, core_1.listModuleFiles)(paths.serverRoot, '.js').length) {
|
|
735
|
+
return '.js';
|
|
736
|
+
}
|
|
737
|
+
if ((0, core_1.listModuleFiles)(paths.modelRoot, '.cjs').length || (0, core_1.listModuleFiles)(paths.serverRoot, '.cjs').length) {
|
|
738
|
+
return '.cjs';
|
|
739
|
+
}
|
|
740
|
+
if ((0, core_1.listModuleFiles)(paths.modelRoot, '.ts').length || (0, core_1.listModuleFiles)(paths.serverRoot, '.ts').length) {
|
|
741
|
+
return '.ts';
|
|
742
|
+
}
|
|
743
|
+
// 空项目也按编译产物约定处理;CLI dev/build 会把业务模块输出为 JavaScript。
|
|
744
|
+
return '.js';
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* 装配整个 Elpis 框架应用。
|
|
748
|
+
*
|
|
749
|
+
* 装配顺序(严格按以下阶段执行;任一阶段抛错都会在 finally 中清理已启动的容器):
|
|
750
|
+
* 1. 解析配置与路径(resolveConfig / resolveApplicationPaths)
|
|
751
|
+
* 2. 预热框架内置页面(readFrameworkPage)以暴露同步访问
|
|
752
|
+
* 3. 加载并校验业务模型(loadModels + validateModels,拒止 site 模板)
|
|
753
|
+
* 4. 创建 Koa 容器(createApplicationContainer)并挂载框架扩展字段
|
|
754
|
+
* 5. 注册全局错误处理 + bodyParser + 认证/匿名中间件
|
|
755
|
+
* 6. 加载业务 service / controller / middleware
|
|
756
|
+
* 7. 注册框架内置路由(/api/session、/api/project/list 等)
|
|
757
|
+
* 8. 注册业务路由(含 router-schema 集中校验)并断言无孤儿 schema
|
|
758
|
+
* 9. 注册静态资源 + History 兜底(pageForPath / 自定义菜单短链 / 未命中路径 302 到 homePage)
|
|
759
|
+
* 10. 调用 serverExtension.onReady 钩子
|
|
760
|
+
*
|
|
761
|
+
* 重要安全约束:
|
|
762
|
+
* - 生产模式必须显式注入 AuthAdapter;未提供直接抛错
|
|
763
|
+
* - 非生产模式若无 AuthAdapter,必须显式开启 devAnonymous(防遗忘)
|
|
764
|
+
* - devAnonymous 不允许在生产中生效;且 listen 必须为回环地址
|
|
765
|
+
*
|
|
766
|
+
* @param context 装配上下文(见 {@link ApplicationContext})
|
|
767
|
+
* @returns 托管应用(含 app/listen/close)
|
|
768
|
+
*/
|
|
769
|
+
async function createApplication(context) {
|
|
770
|
+
const sourceRoot = node_path_1.default.resolve(context.sourceRoot ?? context.rootDir);
|
|
771
|
+
const mode = context.mode ?? (process.env.NODE_ENV === 'production' ? 'production' : 'development');
|
|
772
|
+
const serverExtension = context.server ?? {};
|
|
773
|
+
// 生产环境必须有真实认证;防止误把开发配置部署到线上
|
|
774
|
+
if (mode === 'production' && !serverExtension.auth) {
|
|
775
|
+
throw new Error('ELPIS_AUTH_REQUIRED: 生产环境必须提供真实认证适配器');
|
|
776
|
+
}
|
|
777
|
+
const resolved = (0, config_1.resolveConfig)(sourceRoot, mode, context.config);
|
|
778
|
+
const configuredPaths = context.publicRoot
|
|
779
|
+
? (0, config_1.resolveApplicationPaths)(sourceRoot, resolved.config, mode, context.publicRoot)
|
|
780
|
+
: resolved.paths;
|
|
781
|
+
const moduleRoot = context.moduleRoot ? node_path_1.default.resolve(context.moduleRoot) : undefined;
|
|
782
|
+
const paths = moduleRoot
|
|
783
|
+
? {
|
|
784
|
+
...configuredPaths,
|
|
785
|
+
serverRoot: node_path_1.default.resolve(moduleRoot, resolved.config.server.businessDir ?? 'server'),
|
|
786
|
+
modelRoot: node_path_1.default.resolve(moduleRoot, resolved.config.model.dir ?? 'model'),
|
|
787
|
+
sharedRoot: node_path_1.default.resolve(moduleRoot, 'shared'),
|
|
788
|
+
}
|
|
789
|
+
: configuredPaths;
|
|
790
|
+
const moduleExtension = context.moduleExtension ?? inferModuleExtension(paths);
|
|
791
|
+
// devAnonymous 仅在非生产下生效,且必须显式开启,避免"忘记关"导致开发配置漏到测试
|
|
792
|
+
if (mode !== 'production' && !serverExtension.auth && !serverExtension.devAnonymous) {
|
|
793
|
+
throw new Error('ELPIS_AUTH_REQUIRED: 非生产环境也必须显式启用 devAnonymous 或提供认证适配器');
|
|
794
|
+
}
|
|
795
|
+
if (moduleExtension !== '.ts' && moduleExtension !== '.js' && moduleExtension !== '.cjs') {
|
|
796
|
+
throw new Error(`不支持的业务模块扩展名: ${moduleExtension}`);
|
|
797
|
+
}
|
|
798
|
+
// 预热:把框架页面读入内存,确保后续路由兜底可以同步响应 HTML
|
|
799
|
+
assets_1.frameworkPageNames.forEach(page => (0, assets_1.readFrameworkPage)(paths.publicRoot, page));
|
|
800
|
+
const controlContractPath = resolved.config.model.controlContracts
|
|
801
|
+
? node_path_1.default.resolve(moduleRoot ?? paths.sourceRoot, resolved.config.model.controlContracts)
|
|
802
|
+
: undefined;
|
|
803
|
+
const controlContracts = loadControlContracts(controlContractPath);
|
|
804
|
+
const models = (0, model_runtime_1.loadModels)({ modelDir: paths.modelRoot, extension: moduleExtension });
|
|
805
|
+
for (const loaded of models) {
|
|
806
|
+
const entries = [
|
|
807
|
+
[`model.${loaded.key}`, loaded.model],
|
|
808
|
+
...Object.entries(loaded.project).map(([key, model]) => [`model.${loaded.key}.project.${key}`, model]),
|
|
809
|
+
];
|
|
810
|
+
for (const [modelPath, model] of entries) {
|
|
811
|
+
// site 模板尚未实现,启动期直接拒止,避免运行时出现半成品页面
|
|
812
|
+
if (model && typeof model === 'object' && 'mode' in model && model.mode === 'site') {
|
|
813
|
+
throw new Error(`ELPIS_UNSUPPORTED_TEMPLATE: ${modelPath}.mode 的 site 模板尚未实现`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
(0, model_runtime_1.validateModels)(models, { controlContracts });
|
|
818
|
+
const application = (0, core_1.createApplicationContainer)({
|
|
819
|
+
host: resolved.config.server.host,
|
|
820
|
+
port: resolved.config.server.port,
|
|
821
|
+
closeTimeoutMs: resolved.config.server.closeTimeoutMs ?? 10_000,
|
|
822
|
+
// 监听前的安全护栏:devAnonymous 仅允许绑定回环地址
|
|
823
|
+
validateListen: listenHost => {
|
|
824
|
+
if (mode !== 'production' && serverExtension.devAnonymous && !isLoopbackHost(listenHost)) {
|
|
825
|
+
throw new Error('ELPIS_AUTH_REQUIRED: devAnonymous 仅允许监听回环地址');
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
onClose: app => serverExtension.onClose?.(app),
|
|
829
|
+
});
|
|
830
|
+
const app = application.app;
|
|
831
|
+
try {
|
|
832
|
+
const routerSchemas = loadRouterSchemas(node_path_1.default.resolve(paths.serverRoot, 'router-schema'), moduleExtension);
|
|
833
|
+
const schemaCompiler = createRequestSchemaCompiler();
|
|
834
|
+
const compiledRouterSchemas = compileRouterSchemas(routerSchemas, schemaCompiler);
|
|
835
|
+
// 通过 Object.defineProperties 注入只读扩展字段,避免业务意外改写
|
|
836
|
+
Object.defineProperties(app, {
|
|
837
|
+
config: { value: resolved.config },
|
|
838
|
+
paths: { value: paths },
|
|
839
|
+
mode: { value: mode },
|
|
840
|
+
framework: { value: { models, routerSchemas, controlContracts } },
|
|
841
|
+
routerSchema: { value: routerSchemas },
|
|
842
|
+
// service/controller/middlewares 需在装载阶段写入,因此声明为 writable
|
|
843
|
+
service: { value: {}, writable: true },
|
|
844
|
+
controller: { value: {}, writable: true },
|
|
845
|
+
middlewares: { value: {}, writable: true },
|
|
846
|
+
serverExtension: { value: serverExtension },
|
|
847
|
+
});
|
|
848
|
+
// 全局错误兜底中间件:
|
|
849
|
+
// - 任何下游中间件抛错都会被这里捕获
|
|
850
|
+
// - /api 路径返回结构化错误响应,否则按 text/plain 兜底
|
|
851
|
+
// - 总是触发 app.emit('error', ...) 以便业务 onReady 注册的探针记录
|
|
852
|
+
app.use(async (ctx, next) => {
|
|
853
|
+
try {
|
|
854
|
+
await next();
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
const status = error && typeof error === 'object' && 'status' in error ? Number(error.status) : 500;
|
|
858
|
+
ctx.status = status >= 400 && status < 600 ? status : 500;
|
|
859
|
+
if (isApiPath(ctx.path)) {
|
|
860
|
+
ctx.body = {
|
|
861
|
+
success: false,
|
|
862
|
+
code: ctx.status,
|
|
863
|
+
message: ctx.status >= 500
|
|
864
|
+
? '服务器内部错误'
|
|
865
|
+
: error instanceof Error
|
|
866
|
+
? error.message
|
|
867
|
+
: '请求失败',
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
ctx.type = 'text/plain';
|
|
872
|
+
ctx.body = ctx.status >= 500 ? 'Internal Server Error' : '请求失败';
|
|
873
|
+
}
|
|
874
|
+
app.emit('error', error, ctx);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
app.use((0, koa_bodyparser_1.default)({ enableTypes: ['json', 'form', 'text'] }));
|
|
878
|
+
if (serverExtension.auth) {
|
|
879
|
+
// 真实认证中间件:解析 token / session 后写入 ctx.state.user
|
|
880
|
+
app.use(async (ctx, next) => {
|
|
881
|
+
const identity = await serverExtension.auth.authenticate(ctx);
|
|
882
|
+
if (!identity) {
|
|
883
|
+
if (mode !== 'production' && serverExtension.devAnonymous) {
|
|
884
|
+
ctx.state.user = {
|
|
885
|
+
id: 'development-anonymous',
|
|
886
|
+
name: '开发匿名用户',
|
|
887
|
+
};
|
|
888
|
+
await next();
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (isApiPath(ctx.path)) {
|
|
892
|
+
ctx.status = 401;
|
|
893
|
+
ctx.set('WWW-Authenticate', serverExtension.auth.challenge ?? 'Bearer realm="elpis"');
|
|
894
|
+
ctx.body = { success: false, code: 401, message: '未登录' };
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
ctx.status = 401;
|
|
898
|
+
ctx.set('WWW-Authenticate', serverExtension.auth.challenge ?? 'Bearer realm="elpis"');
|
|
899
|
+
ctx.type = 'text/plain';
|
|
900
|
+
ctx.body = '未登录';
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
ctx.state.user = identity;
|
|
904
|
+
await next();
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
// 无认证适配器时,devAnonymous 已显式开启;写入固定匿名身份
|
|
909
|
+
app.use(async (ctx, next) => {
|
|
910
|
+
ctx.state.user = {
|
|
911
|
+
id: 'development-anonymous',
|
|
912
|
+
name: '开发匿名用户',
|
|
913
|
+
};
|
|
914
|
+
await next();
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
const businessRoot = paths.serverRoot;
|
|
918
|
+
// 业务 service / controller 通过类装饰方式装载;loadClassModules 自动实例化并注入 app
|
|
919
|
+
app.service = (0, core_1.loadClassModules)(node_path_1.default.resolve(businessRoot, 'service'), moduleExtension, app);
|
|
920
|
+
app.controller = (0, core_1.loadClassModules)(node_path_1.default.resolve(businessRoot, 'controller'), moduleExtension, app);
|
|
921
|
+
loadMiddlewares(node_path_1.default.resolve(businessRoot, 'middleware'), moduleExtension, app);
|
|
922
|
+
const router = new router_1.default();
|
|
923
|
+
const registered = new Set();
|
|
924
|
+
const reserved = new Set();
|
|
925
|
+
// 框架内置路由:业务路由若与之同名/同方法直接报错,避免静默覆盖关键接口
|
|
926
|
+
const addBuiltin = (method, routePath, handler) => {
|
|
927
|
+
const routeId = `${method} ${routePath}`;
|
|
928
|
+
reserved.add(routeId);
|
|
929
|
+
registered.add(routeId);
|
|
930
|
+
const register = router[method];
|
|
931
|
+
register.call(router, routePath, handler);
|
|
932
|
+
};
|
|
933
|
+
// /api/session:当前登录身份 + 公共配置(前端启动时获取)
|
|
934
|
+
addBuiltin('get', '/api/session', ctx => {
|
|
935
|
+
ctx.body = {
|
|
936
|
+
success: true,
|
|
937
|
+
data: {
|
|
938
|
+
user: publicIdentity(ctx.state.user),
|
|
939
|
+
public: resolved.config.public,
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
});
|
|
943
|
+
// /api/project/model_list:模型 + 可见项目(用于项目列表页)
|
|
944
|
+
addBuiltin('get', '/api/project/model_list', async (ctx) => {
|
|
945
|
+
ctx.body = { success: true, data: await publicModels(ctx, app, models) };
|
|
946
|
+
});
|
|
947
|
+
// /api/project/list:项目列表(支持 ?modelKey 过滤)
|
|
948
|
+
addBuiltin('get', '/api/project/list', async (ctx) => {
|
|
949
|
+
const modelKey = typeof ctx.query.modelKey === 'string' ? ctx.query.modelKey : undefined;
|
|
950
|
+
const projects = await visibleProjects(ctx, app, models, modelKey);
|
|
951
|
+
ctx.body = {
|
|
952
|
+
success: true,
|
|
953
|
+
data: projects.map(item => publicProject(item.modelKey, item.key, item.project)),
|
|
954
|
+
};
|
|
955
|
+
});
|
|
956
|
+
// /api/project:单个项目详情(含菜单授权过滤)
|
|
957
|
+
addBuiltin('get', '/api/project', async (ctx) => {
|
|
958
|
+
const projectKey = typeof ctx.query.projectKey === 'string' ? ctx.query.projectKey : undefined;
|
|
959
|
+
if (!projectKey) {
|
|
960
|
+
ctx.status = 400;
|
|
961
|
+
ctx.body = { success: false, code: 400, message: '缺少 projectKey' };
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const item = findProject(models, projectKey);
|
|
965
|
+
if (!item) {
|
|
966
|
+
ctx.status = 404;
|
|
967
|
+
ctx.body = { success: false, code: 404, message: '项目不存在' };
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (!(await isProjectAllowed(ctx, app, projectKey))) {
|
|
971
|
+
ctx.status = 403;
|
|
972
|
+
ctx.body = { success: false, code: 403, message: '无权访问当前项目' };
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
ctx.body = {
|
|
976
|
+
success: true,
|
|
977
|
+
data: await publicProjectDetail(ctx, app, item.key, item.project),
|
|
978
|
+
};
|
|
979
|
+
});
|
|
980
|
+
registerBusinessRoutes(router, node_path_1.default.resolve(businessRoot, 'router'), moduleExtension, app, compiledRouterSchemas, registered, reserved, schemaCompiler);
|
|
981
|
+
// 启动期校验:避免"定义了校验却忘记挂路由"的死代码
|
|
982
|
+
assertNoOrphanSchemas(routerSchemas, registered);
|
|
983
|
+
app.use(router.routes());
|
|
984
|
+
app.use(router.allowedMethods());
|
|
985
|
+
if (node_fs_1.default.existsSync(paths.publicRoot)) {
|
|
986
|
+
// 静态资源托管:
|
|
987
|
+
// 1. /api 和 /api/* 永远走接口,不被同名静态文件劫持;
|
|
988
|
+
// 2. 含扩展名的静态业务资源也走授权(用 module=public, action=read);
|
|
989
|
+
// 3. 无扩展名的路径(History 路由)交由下方的页面兜底中间件处理。
|
|
990
|
+
const staticMiddleware = (0, koa_static_1.default)(paths.publicRoot);
|
|
991
|
+
app.use(async (ctx, next) => {
|
|
992
|
+
// API 路径永远不能被同名静态文件劫持成 HTML 或其他资源。
|
|
993
|
+
if (isApiPath(ctx.path)) {
|
|
994
|
+
await next();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
// 真实静态资源必须授权;缺失的带点号页面路径仍可按 HTML 导航进入 History 兜底。
|
|
998
|
+
if (ctx.path.includes('.') &&
|
|
999
|
+
app.serverExtension.auth &&
|
|
1000
|
+
(!acceptsHtmlDocument(ctx) || (await hasStaticFile(paths.publicRoot, ctx.path)))) {
|
|
1001
|
+
attachProject(ctx);
|
|
1002
|
+
const identity = ctx.state.user;
|
|
1003
|
+
if (!identity ||
|
|
1004
|
+
!(await app.serverExtension.auth.authorize(ctx, { module: 'public', action: 'read' }, identity))) {
|
|
1005
|
+
ctx.status = 403;
|
|
1006
|
+
ctx.body = { success: false, code: 403, message: '无权访问当前资源' };
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
await staticMiddleware(ctx, next);
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
// History 路由兜底:
|
|
1014
|
+
// - /api 或 /api/* 未匹配路由 → 404 结构化响应
|
|
1015
|
+
// - 非 API + 404 + GET/HEAD 依次尝试:框架页面 → HTML 页面请求的自定义菜单短链 → homePage;
|
|
1016
|
+
// 非 HTML 请求保留 404,静态文件已由上方中间件处理
|
|
1017
|
+
app.use(async (ctx) => {
|
|
1018
|
+
if (isApiPath(ctx.path)) {
|
|
1019
|
+
// 利用 koa-router 注入的内部字段判断是否有路由匹配
|
|
1020
|
+
const matchedRoute = ctx._matchedRoute;
|
|
1021
|
+
if (!matchedRoute) {
|
|
1022
|
+
ctx.status = 404;
|
|
1023
|
+
ctx.body = { success: false, code: 404, message: '接口不存在' };
|
|
1024
|
+
}
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
// 已显式写过状态码或非 GET/HEAD 的请求不再做 History 兜底
|
|
1028
|
+
if (ctx.status !== 404 || !['GET', 'HEAD'].includes(ctx.method)) {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const page = pageForPath(resolved.config, ctx.path);
|
|
1032
|
+
if (page) {
|
|
1033
|
+
ctx.type = 'html';
|
|
1034
|
+
ctx.body = (0, assets_1.readFrameworkPage)(paths.publicRoot, page);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
// 未命中的非 HTML 请求通常是缺失静态资源或程序化请求,保留 404。
|
|
1038
|
+
if (!acceptsHtmlDocument(ctx)) {
|
|
1039
|
+
ctx.status = 404;
|
|
1040
|
+
ctx.type = 'text/plain';
|
|
1041
|
+
ctx.body = '404 Not Found';
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
// 框架页面未命中:尝试 custom 菜单短链重定向
|
|
1045
|
+
if (await redirectCustomPageShortcut(ctx, app, models)) {
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
// 兜底收敛:302 到配置的 homePage;homePage 剥 query 后为根路径或
|
|
1049
|
+
// 空串时,为避免循环改为原路径渲染项目列表页
|
|
1050
|
+
const homePagePath = resolved.config.public.homePage.split('?')[0];
|
|
1051
|
+
if (homePagePath && homePagePath !== '/') {
|
|
1052
|
+
ctx.redirect(resolved.config.public.homePage);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
ctx.type = 'html';
|
|
1056
|
+
ctx.body = (0, assets_1.readFrameworkPage)(paths.publicRoot, 'project-list');
|
|
1057
|
+
});
|
|
1058
|
+
await serverExtension.onReady?.(app);
|
|
1059
|
+
}
|
|
1060
|
+
catch (error) {
|
|
1061
|
+
// 任一阶段失败:先尝试关闭已启动容器(释放端口/句柄),再抛出原错误
|
|
1062
|
+
try {
|
|
1063
|
+
await application.close();
|
|
1064
|
+
}
|
|
1065
|
+
catch (cleanupError) {
|
|
1066
|
+
// 聚合两个错误,方便上层一次性看到"初始化失败 + 清理失败"
|
|
1067
|
+
throw new AggregateError([error, cleanupError], '应用初始化失败且清理失败');
|
|
1068
|
+
}
|
|
1069
|
+
throw error;
|
|
1070
|
+
}
|
|
1071
|
+
return application;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* 一键装配 + 启动:默认按 production 模式装配(即使环境变量为 development),
|
|
1075
|
+
* 因为 startApplication 面向"真正开始对外监听"的场景。
|
|
1076
|
+
*
|
|
1077
|
+
* 适合 CLI / 容器入口调用;测试或本地热重载请直接使用 {@link createApplication}。
|
|
1078
|
+
*/
|
|
1079
|
+
async function startApplication(context) {
|
|
1080
|
+
return (0, core_1.startApplicationContainer)(await createApplication({ ...context, mode: context.mode ?? 'production' }));
|
|
1081
|
+
}
|
|
1082
|
+
//# sourceMappingURL=application.js.map
|