dsh-lh-data 0.1.0
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/README.md +332 -0
- package/cordis.patch.yml +7 -0
- package/lib/admin-contract.d.ts +274 -0
- package/lib/admin-http.d.ts +31 -0
- package/lib/admin-validate.d.ts +87 -0
- package/lib/admin.d.ts +60 -0
- package/lib/client.js +2969 -0
- package/lib/client.js.map +1 -0
- package/lib/datasource/columns.d.ts +13 -0
- package/lib/datasource/connection.d.ts +33 -0
- package/lib/datasource/connector/base.d.ts +21 -0
- package/lib/datasource/connector/mysql.d.ts +23 -0
- package/lib/datasource/connector/postgresql.d.ts +23 -0
- package/lib/datasource/crypto.d.ts +13 -0
- package/lib/datasource/driver.d.ts +39 -0
- package/lib/datasource/errors.d.ts +25 -0
- package/lib/datasource/importer.d.ts +38 -0
- package/lib/datasource/source-sql.d.ts +11 -0
- package/lib/datasource/source-store.d.ts +28 -0
- package/lib/datasource/types.d.ts +119 -0
- package/lib/db.d.ts +91 -0
- package/lib/http-common.d.ts +31 -0
- package/lib/http.d.ts +13 -0
- package/lib/index.d.ts +50 -0
- package/lib/index.js +5735 -0
- package/lib/parse.d.ts +41 -0
- package/lib/preview.d.ts +75 -0
- package/lib/render.d.ts +100 -0
- package/lib/scope-registry.d.ts +47 -0
- package/lib/scope.d.ts +56 -0
- package/lib/sql.d.ts +133 -0
- package/lib/store.d.ts +174 -0
- package/lib/table.d.ts +38 -0
- package/lib/tooling.d.ts +267 -0
- package/lib/tools/datasource.d.ts +16 -0
- package/lib/tools/import.d.ts +21 -0
- package/lib/tools/read.d.ts +77 -0
- package/lib/tools/registry.d.ts +12 -0
- package/lib/tools/write.d.ts +37 -0
- package/lib/view.d.ts +138 -0
- package/package.json +61 -0
- package/src/admin-contract.ts +351 -0
- package/src/admin-http.ts +254 -0
- package/src/admin-validate.ts +393 -0
- package/src/admin.ts +558 -0
- package/src/client/index.ts +403 -0
- package/src/client/settings/CreateForm.tsx +186 -0
- package/src/client/settings/DataSourceForm.tsx +278 -0
- package/src/client/settings/DataSourcesPanel.tsx +301 -0
- package/src/client/settings/DatasetEditor.tsx +245 -0
- package/src/client/settings/DatasetTable.tsx +110 -0
- package/src/client/settings/DatasetsPanel.tsx +207 -0
- package/src/client/settings/RowsPanel.tsx +196 -0
- package/src/client/settings/Section.tsx +41 -0
- package/src/client/settings/SourceTablesPanel.tsx +226 -0
- package/src/client/settings/api.ts +154 -0
- package/src/client/settings/styles.ts +125 -0
- package/src/datasource/columns.ts +56 -0
- package/src/datasource/connection.ts +124 -0
- package/src/datasource/connector/base.ts +54 -0
- package/src/datasource/connector/mysql.ts +187 -0
- package/src/datasource/connector/postgresql.ts +212 -0
- package/src/datasource/crypto.ts +61 -0
- package/src/datasource/driver.ts +108 -0
- package/src/datasource/errors.ts +69 -0
- package/src/datasource/importer.ts +262 -0
- package/src/datasource/index.ts +62 -0
- package/src/datasource/source-sql.ts +64 -0
- package/src/datasource/source-store.ts +152 -0
- package/src/datasource/types.ts +133 -0
- package/src/db.ts +277 -0
- package/src/http-common.ts +91 -0
- package/src/http.ts +130 -0
- package/src/index.ts +486 -0
- package/src/parse.ts +213 -0
- package/src/preview.ts +198 -0
- package/src/render.ts +294 -0
- package/src/scope-registry.ts +111 -0
- package/src/scope.ts +159 -0
- package/src/sql.ts +491 -0
- package/src/store.ts +412 -0
- package/src/table.ts +160 -0
- package/src/tooling.ts +551 -0
- package/src/tools/datasource.ts +378 -0
- package/src/tools/import.ts +282 -0
- package/src/tools/read.ts +536 -0
- package/src/tools/registry.ts +56 -0
- package/src/tools/write.ts +241 -0
- package/src/view.ts +371 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页管理接口的**双半身契约**。
|
|
3
|
+
*
|
|
4
|
+
* 主机半身(`admin.ts` / `admin-http.ts`)与浏览器半身(`client/settings/*`)
|
|
5
|
+
* 共用本文件,因此有两条硬约束:
|
|
6
|
+
*
|
|
7
|
+
* 1. **禁止 import 任何 node 内置模块** —— 本文件会被打进浏览器 bundle
|
|
8
|
+
* (`tsdown.config.ts` 的 client 产物);
|
|
9
|
+
* 2. **只有常量与类型**,不含任何运行时逻辑 —— 浏览器 bundle 是 CJS 工厂包,
|
|
10
|
+
* 多引一个模块就多一份内联体积。
|
|
11
|
+
*
|
|
12
|
+
* 存在理由:浏览器拿不到主机的 `Config`。`viewRoutePrefix` 之类是用户在 dsh
|
|
13
|
+
* 配置里改的,如果路由前缀两边各写一份字面量,改配置就会让设置页悄悄失联。
|
|
14
|
+
* 下面的常量是唯一真源,主机注册路由和浏览器拼 URL 都从这里取。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── 路由 ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** 管理接口的路由前缀。与视图接口(可配置)解耦,固定不变。 */
|
|
20
|
+
export const ADMIN_ROUTE_PREFIX = '/api/lh-data'
|
|
21
|
+
|
|
22
|
+
/** 管理接口的固定挂载点:`${ADMIN_ROUTE_PREFIX}/admin`。 */
|
|
23
|
+
export const ADMIN_API_BASE: string = `${ADMIN_ROUTE_PREFIX}/admin`
|
|
24
|
+
|
|
25
|
+
// ── 设置菜单 ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `settings.section` 的注册 id。既有分区用的是 0(general)/ 10(models)/
|
|
29
|
+
* 15(plugins)/ 20(agent-presets),这里取 100 排在最后。
|
|
30
|
+
*/
|
|
31
|
+
export const ADMIN_SECTION_ID = 'lh-data'
|
|
32
|
+
export const ADMIN_SECTION_ORDER = 100
|
|
33
|
+
export const ADMIN_SECTION_LABEL = '数据集'
|
|
34
|
+
|
|
35
|
+
// ── 列表分页 ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export const ADMIN_PAGE_SIZE = 20
|
|
38
|
+
export const ADMIN_MAX_PAGE_SIZE = 100
|
|
39
|
+
|
|
40
|
+
// ── 表数据分页(查看某个数据集的行) ───────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** 表数据默认每页行数。 */
|
|
43
|
+
export const ADMIN_ROW_PAGE_SIZE = 20
|
|
44
|
+
/** 表数据每页行数的上限。 */
|
|
45
|
+
export const ADMIN_ROW_MAX_PAGE_SIZE = 100
|
|
46
|
+
/** 行号列:物理表的自增主键,只用于稳定排序与前端行键,不算业务列。 */
|
|
47
|
+
export const ADMIN_ROW_ID_COLUMN = '_row_id'
|
|
48
|
+
|
|
49
|
+
// ── 录入上限(前端 maxLength 与后端校验共用) ─────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const ADMIN_MAX_NAME_LENGTH = 80
|
|
52
|
+
export const ADMIN_MAX_DESCRIPTION_LENGTH = 500
|
|
53
|
+
export const ADMIN_MAX_SOURCE_LENGTH = 1024
|
|
54
|
+
export const ADMIN_MAX_COLUMNS = 64
|
|
55
|
+
export const ADMIN_MAX_QUERY_LENGTH = 80
|
|
56
|
+
/** 单列样例值的个数上限(前端拼接与后端夹紧共用)。 */
|
|
57
|
+
export const ADMIN_MAX_SAMPLE_VALUES = 5
|
|
58
|
+
/** 单个样例值的字符上限。 */
|
|
59
|
+
export const ADMIN_MAX_SAMPLE_LENGTH = 40
|
|
60
|
+
|
|
61
|
+
// ── 数据源录入上限 ───────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export const ADMIN_MAX_SOURCE_NAME_LENGTH = 80
|
|
64
|
+
export const ADMIN_MAX_HOST_LENGTH = 255
|
|
65
|
+
export const ADMIN_MAX_DATABASE_LENGTH = 128
|
|
66
|
+
export const ADMIN_MAX_USERNAME_LENGTH = 128
|
|
67
|
+
export const ADMIN_MAX_PASSWORD_LENGTH = 256
|
|
68
|
+
export const ADMIN_MIN_PORT = 1
|
|
69
|
+
export const ADMIN_MAX_PORT = 65535
|
|
70
|
+
export const ADMIN_MAX_POOL_MAX = 100
|
|
71
|
+
|
|
72
|
+
/** 数据源类型下拉的可选项(顺序即展示顺序)。 */
|
|
73
|
+
export const ADMIN_SOURCE_TYPES: readonly string[] = ['mysql', 'postgresql']
|
|
74
|
+
|
|
75
|
+
export const ADMIN_SOURCE_TYPE_LABELS: Readonly<Record<string, string>> = {
|
|
76
|
+
mysql: 'MySQL',
|
|
77
|
+
postgresql: 'PostgreSQL',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 各类型的默认端口(前端带出与后端兜底共用)。 */
|
|
81
|
+
export const ADMIN_SOURCE_DEFAULT_PORTS: Readonly<Record<string, number>> = {
|
|
82
|
+
mysql: 3306,
|
|
83
|
+
postgresql: 5432,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── 类型 ─────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** 与 `parse.ts` 的 `ColumnType` 同构(本文件不能 import 主机侧的 parse)。 */
|
|
89
|
+
export type AdminColumnType = 'text' | 'numeric' | 'boolean' | 'date'
|
|
90
|
+
|
|
91
|
+
/** 人类可读的类型名:前端下拉与列结构表共用,避免两处各写一份中文。 */
|
|
92
|
+
export const ADMIN_COLUMN_TYPE_LABELS: Readonly<Record<AdminColumnType, string>> = {
|
|
93
|
+
text: '文本',
|
|
94
|
+
numeric: '数值',
|
|
95
|
+
boolean: '布尔',
|
|
96
|
+
date: '日期',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 列类型下拉的可选项(顺序即展示顺序)。 */
|
|
100
|
+
export const ADMIN_COLUMN_TYPES: readonly AdminColumnType[] = ['text', 'numeric', 'boolean', 'date']
|
|
101
|
+
|
|
102
|
+
export type DatasetStatus = 'importing' | 'ready' | 'failed'
|
|
103
|
+
|
|
104
|
+
/** 设置页可见的一列。列名是业务表头(可含中文),不含任何物理表信息。 */
|
|
105
|
+
export interface DatasetColumnView {
|
|
106
|
+
/** 原始表头。 */
|
|
107
|
+
name: string
|
|
108
|
+
/** SQL 中使用的列名(重名时追加 `_2`)。 */
|
|
109
|
+
sanitizedName: string
|
|
110
|
+
type: AdminColumnType
|
|
111
|
+
nullable: boolean
|
|
112
|
+
description: string
|
|
113
|
+
sample: unknown[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 新建数据集时提交的一列。 */
|
|
117
|
+
export interface DatasetColumnSpec {
|
|
118
|
+
name: string
|
|
119
|
+
type: AdminColumnType
|
|
120
|
+
nullable?: boolean
|
|
121
|
+
description?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 列表里的一条数据集(跨工作区聚合;不含列结构,避免列表响应膨胀)。 */
|
|
125
|
+
export interface DatasetAdminView {
|
|
126
|
+
id: string
|
|
127
|
+
scopeKey: string
|
|
128
|
+
name: string
|
|
129
|
+
description: string | null
|
|
130
|
+
sourcePath: string | null
|
|
131
|
+
rowCount: number
|
|
132
|
+
columnCount: number
|
|
133
|
+
status: DatasetStatus
|
|
134
|
+
error: string | null
|
|
135
|
+
createdAt: number
|
|
136
|
+
updatedAt: number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 详情 = 列表项 + 只读列结构。 */
|
|
140
|
+
export interface DatasetDetailView extends DatasetAdminView {
|
|
141
|
+
columns: DatasetColumnView[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 一页表数据(只读)。`columns` 只含业务列,`rows` 每行额外带 `_row_id`
|
|
146
|
+
* (`ADMIN_ROW_ID_COLUMN`)作为稳定行键;`page` 由服务端夹到分页边界内。
|
|
147
|
+
*/
|
|
148
|
+
export interface DatasetRowsResult {
|
|
149
|
+
columns: string[]
|
|
150
|
+
rows: Record<string, unknown>[]
|
|
151
|
+
page: number
|
|
152
|
+
pageSize: number
|
|
153
|
+
/** `COUNT(*)` 得到的真实行数(元数据里的 rowCount 可能与之不符)。 */
|
|
154
|
+
total: number
|
|
155
|
+
totalPages: number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 设置页可见的一个工作区。 */
|
|
159
|
+
export interface ScopeView {
|
|
160
|
+
scopeKey: string
|
|
161
|
+
lastSeen: number
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 单个工作区读取失败时的降级提示(不阻断整页)。 */
|
|
165
|
+
export interface AdminWarning {
|
|
166
|
+
scopeKey: string
|
|
167
|
+
message: string
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ListDatasetsResult {
|
|
171
|
+
items: DatasetAdminView[]
|
|
172
|
+
total: number
|
|
173
|
+
page: number
|
|
174
|
+
pageSize: number
|
|
175
|
+
/** 命中 `adminMaxDatasets` 上限被截断。 */
|
|
176
|
+
truncated: boolean
|
|
177
|
+
warnings: AdminWarning[]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 新建空数据集:列名 / 类型 / 可空性提交后冻结,说明与样例仍可改。 */
|
|
181
|
+
export interface CreateDatasetRequest {
|
|
182
|
+
scopeKey: string
|
|
183
|
+
name: string
|
|
184
|
+
description?: string
|
|
185
|
+
sourcePath?: string | null
|
|
186
|
+
columns: DatasetColumnSpec[]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 列定义的补丁:只改「说明」与「样例」这两项纯元数据。
|
|
191
|
+
* 列名 / 类型 / 可空性对应物理表 DDL,设置页不可改。
|
|
192
|
+
* 用 `sanitizedName` 定位列——原始表头 `name` 在重名列上会重复,不能当键。
|
|
193
|
+
*/
|
|
194
|
+
export interface DatasetColumnPatch {
|
|
195
|
+
/** 列的 SQL 名(去重后唯一)。 */
|
|
196
|
+
sanitizedName: string
|
|
197
|
+
description?: string
|
|
198
|
+
sample?: unknown[]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── 数据源 ───────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 设置页可见的一条数据源。
|
|
205
|
+
* **密码永不回显**:只给 `hasPassword`,前端据此显示「已设置 · 不可查看」。
|
|
206
|
+
*/
|
|
207
|
+
export interface DataSourceView {
|
|
208
|
+
id: string
|
|
209
|
+
name: string
|
|
210
|
+
type: string
|
|
211
|
+
host: string
|
|
212
|
+
port: number
|
|
213
|
+
database: string
|
|
214
|
+
username: string
|
|
215
|
+
hasPassword: boolean
|
|
216
|
+
sslMode: string | null
|
|
217
|
+
poolMax: number | null
|
|
218
|
+
description: string | null
|
|
219
|
+
status: string
|
|
220
|
+
lastError: string | null
|
|
221
|
+
lastCheckedAt: number | null
|
|
222
|
+
createdAt: number
|
|
223
|
+
updatedAt: number
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface ConnectionTestView {
|
|
227
|
+
success: boolean
|
|
228
|
+
latency: number
|
|
229
|
+
version: string | null
|
|
230
|
+
error: string | null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 新建数据源。`test` 为 true 时先测连,不通就不落库。 */
|
|
234
|
+
export interface CreateDataSourceRequest {
|
|
235
|
+
name: string
|
|
236
|
+
type: string
|
|
237
|
+
host: string
|
|
238
|
+
port?: number | null
|
|
239
|
+
database: string
|
|
240
|
+
username: string
|
|
241
|
+
password?: string
|
|
242
|
+
sslMode?: string | null
|
|
243
|
+
poolMax?: number | null
|
|
244
|
+
description?: string | null
|
|
245
|
+
test?: boolean
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 改数据源:字段缺省表示不改;`password` 给了字符串就替换(空串表示置空)。 */
|
|
249
|
+
export interface PatchDataSourceRequest {
|
|
250
|
+
name?: string
|
|
251
|
+
type?: string
|
|
252
|
+
host?: string
|
|
253
|
+
port?: number | null
|
|
254
|
+
database?: string
|
|
255
|
+
username?: string
|
|
256
|
+
password?: string
|
|
257
|
+
sslMode?: string | null
|
|
258
|
+
poolMax?: number | null
|
|
259
|
+
description?: string | null
|
|
260
|
+
test?: boolean
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `POST /sources/test` 的请求体:给 `source` 就测已登记的,给全参数就测未保存的草稿。 */
|
|
264
|
+
export interface TestDataSourceRequest {
|
|
265
|
+
source?: string
|
|
266
|
+
type?: string
|
|
267
|
+
host?: string
|
|
268
|
+
port?: number | null
|
|
269
|
+
database?: string
|
|
270
|
+
username?: string
|
|
271
|
+
password?: string
|
|
272
|
+
sslMode?: string | null
|
|
273
|
+
poolMax?: number | null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface SourceTableView {
|
|
277
|
+
tableName: string
|
|
278
|
+
schemaName: string | null
|
|
279
|
+
/** 远端估计行数;-1 表示未知。 */
|
|
280
|
+
rowCount: number
|
|
281
|
+
primaryKey: string | null
|
|
282
|
+
columns: {
|
|
283
|
+
name: string
|
|
284
|
+
type: AdminColumnType
|
|
285
|
+
nullable: boolean
|
|
286
|
+
description: string | null
|
|
287
|
+
}[]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface ListSourceTablesResult {
|
|
291
|
+
/** 实际使用的 schema(MySQL 为库名,PostgreSQL 默认 public)。 */
|
|
292
|
+
schema: string | null
|
|
293
|
+
/** 可选 schema 列表(PostgreSQL 多个,MySQL 一个)。 */
|
|
294
|
+
schemas: string[]
|
|
295
|
+
tables: SourceTableView[]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `POST /sources/:id/import`。 */
|
|
299
|
+
export interface ImportSourceTableRequest {
|
|
300
|
+
scopeKey: string
|
|
301
|
+
tableName: string
|
|
302
|
+
schemaName?: string | null
|
|
303
|
+
name?: string | null
|
|
304
|
+
limit?: number | null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface ImportSourceTableResult {
|
|
308
|
+
datasetId: string
|
|
309
|
+
name: string
|
|
310
|
+
rowCount: number
|
|
311
|
+
columnCount: number
|
|
312
|
+
status: 'ready' | 'running'
|
|
313
|
+
jobId?: string
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 字段缺省表示不改动。 */
|
|
317
|
+
export interface PatchDatasetRequest {
|
|
318
|
+
name?: string
|
|
319
|
+
description?: string | null
|
|
320
|
+
sourcePath?: string | null
|
|
321
|
+
columns?: DatasetColumnPatch[]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export type AdminErrorCode =
|
|
325
|
+
| 'UNAUTHORIZED'
|
|
326
|
+
| 'FORBIDDEN'
|
|
327
|
+
| 'NOT_FOUND'
|
|
328
|
+
| 'METHOD_NOT_ALLOWED'
|
|
329
|
+
| 'BAD_REQUEST'
|
|
330
|
+
| 'PAYLOAD_TOO_LARGE'
|
|
331
|
+
| 'READ_ONLY'
|
|
332
|
+
| 'ADMIN_DISABLED'
|
|
333
|
+
| 'SCOPE_UNKNOWN'
|
|
334
|
+
| 'DUPLICATE_NAME'
|
|
335
|
+
| 'INVALID_COLUMNS'
|
|
336
|
+
| 'QUERY_FAILED'
|
|
337
|
+
| 'SOURCE_DISABLED'
|
|
338
|
+
| 'DRIVER_MISSING'
|
|
339
|
+
| 'SOURCE_UNREACHABLE'
|
|
340
|
+
| 'IMPORT_FAILED'
|
|
341
|
+
|
|
342
|
+
/** 错误响应体。主机侧统一脱敏:不回显 SQL 与物理表名。 */
|
|
343
|
+
export interface AdminErrorBody {
|
|
344
|
+
error: { code: AdminErrorCode; message: string }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 管理接口是否可写(前端据此置灰按钮)。 */
|
|
348
|
+
export interface AdminCapabilities {
|
|
349
|
+
writable: boolean
|
|
350
|
+
reason: string
|
|
351
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页管理接口的 HTTP 路由 —— 与视图路由同源(自建前缀 + `connection` 鉴权),
|
|
3
|
+
* 但不接受任何 SQL:所有读写都走 `admin.ts` 的服务函数,物理表名与 SQL 不出现在接口里。
|
|
4
|
+
*
|
|
5
|
+
* 路由表(固定挂在 `ADMIN_API_BASE = /api/lh-data/admin`):
|
|
6
|
+
* GET /scopes 列出已知工作区
|
|
7
|
+
* GET /datasets 聚合列表(?q=&page=&pageSize=)
|
|
8
|
+
* POST /datasets 新建空数据集(body 含 scopeKey 与列定义)
|
|
9
|
+
* GET /datasets/:id?scope= 单条详情
|
|
10
|
+
* GET /datasets/:id/rows?scope=&page=&pageSize= 分页查看表数据(只读)
|
|
11
|
+
* PATCH /datasets/:id?scope= 改名 / 改描述 / 改来源
|
|
12
|
+
* DELETE /datasets/:id?scope= 连同物理表与元数据删除
|
|
13
|
+
*
|
|
14
|
+
* 数据源(datasourceEnabled=false 时整组不注册):
|
|
15
|
+
* GET /sources 数据源列表(脱敏,不含密码)
|
|
16
|
+
* POST /sources 新建数据源(body.test=true 时先测连)
|
|
17
|
+
* POST /sources/test 测试连接(已登记的 source,或未保存的完整参数)
|
|
18
|
+
* GET /sources/:id 单条详情
|
|
19
|
+
* PATCH /sources/:id 改连接参数 / 密码 / 名称 / 描述
|
|
20
|
+
* DELETE /sources/:id 删除数据源
|
|
21
|
+
* GET /sources/:id/tables?schema=&q= 浏览远端表
|
|
22
|
+
* POST /sources/:id/import 把远端表导入指定工作区
|
|
23
|
+
*
|
|
24
|
+
* scope 一律由写操作的调用方从「已知工作区列表」里回传,不接收任意路径输入。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
28
|
+
import { ADMIN_API_BASE, type CreateDatasetRequest, type PatchDatasetRequest } from './admin-contract'
|
|
29
|
+
import {
|
|
30
|
+
createDataset,
|
|
31
|
+
createDataSource,
|
|
32
|
+
deleteDataset,
|
|
33
|
+
deleteDataSource,
|
|
34
|
+
getDataset,
|
|
35
|
+
getDataSource,
|
|
36
|
+
importSourceTable,
|
|
37
|
+
listDatasetRows,
|
|
38
|
+
listDatasets,
|
|
39
|
+
listDataSources,
|
|
40
|
+
listScopes,
|
|
41
|
+
listSourceTables,
|
|
42
|
+
patchDataset,
|
|
43
|
+
patchDataSource,
|
|
44
|
+
testDataSource,
|
|
45
|
+
AdminServiceError,
|
|
46
|
+
} from './admin'
|
|
47
|
+
import { AdminValidationError } from './admin-validate'
|
|
48
|
+
import type { DataServices } from './store'
|
|
49
|
+
import {
|
|
50
|
+
authorize,
|
|
51
|
+
readJsonBody,
|
|
52
|
+
sendError,
|
|
53
|
+
sendJson,
|
|
54
|
+
type RouteRequest,
|
|
55
|
+
type RouteResponse,
|
|
56
|
+
} from './http-common'
|
|
57
|
+
|
|
58
|
+
type Resource =
|
|
59
|
+
| { kind: 'scopes' }
|
|
60
|
+
| { kind: 'datasets'; id?: string; rows?: boolean }
|
|
61
|
+
| { kind: 'sources'; id?: string; action?: 'test' | 'tables' | 'import' }
|
|
62
|
+
|
|
63
|
+
/** `/api/lh-data/admin` 之后的路径解析。 */
|
|
64
|
+
function parseAdminPath(pathname: string): Resource | undefined {
|
|
65
|
+
const base = ADMIN_API_BASE
|
|
66
|
+
const rest = pathname.slice(base.length).replace(/^\/+/, '').replace(/\/+$/, '')
|
|
67
|
+
if (rest === '' || rest === 'scopes') return { kind: 'scopes' }
|
|
68
|
+
if (rest === 'datasets') return { kind: 'datasets' }
|
|
69
|
+
if (rest === 'sources') return { kind: 'sources' }
|
|
70
|
+
// `sources/test` 必须优先于 `:id` 匹配,否则会被当成 id=test 的数据源。
|
|
71
|
+
if (rest === 'sources/test') return { kind: 'sources', action: 'test' }
|
|
72
|
+
const parts = rest.split('/')
|
|
73
|
+
if (parts[0] === 'sources' && parts[1] !== undefined && parts[1].length > 0) {
|
|
74
|
+
const id = decodeURIComponent(parts[1])
|
|
75
|
+
if (parts.length === 2) return { kind: 'sources', id }
|
|
76
|
+
if (parts.length === 3 && parts[2] === 'tables') return { kind: 'sources', id, action: 'tables' }
|
|
77
|
+
if (parts.length === 3 && parts[2] === 'import') return { kind: 'sources', id, action: 'import' }
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
if (parts[0] === 'datasets' && parts[1] !== undefined && parts[1].length > 0) {
|
|
81
|
+
const id = decodeURIComponent(parts[1])
|
|
82
|
+
// `/datasets/:id/rows` —— 表数据分页;更深或别的子路径一律不认(404)。
|
|
83
|
+
if (parts.length === 2) return { kind: 'datasets', id }
|
|
84
|
+
if (parts.length === 3 && parts[2] === 'rows') return { kind: 'datasets', id, rows: true }
|
|
85
|
+
}
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function methodNotAllowed(response: RouteResponse): void {
|
|
90
|
+
sendError(response, 405, 'METHOD_NOT_ALLOWED', 'only GET / POST / PATCH / DELETE are allowed')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireScope(params: URLSearchParams): string {
|
|
94
|
+
const scope = params.get('scope')
|
|
95
|
+
if (scope === null || scope.trim().length === 0) {
|
|
96
|
+
throw new AdminValidationError('BAD_REQUEST', '缺少 scope 参数(请从工作区列表里选)')
|
|
97
|
+
}
|
|
98
|
+
return scope.trim()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 注册管理路由。宿主缺失(`webServer` / `connection`)时返回 undefined,由调用方降级。
|
|
103
|
+
* 路由路径重复会抛错(webserver 契约),与视图路由路径不同前缀,互不影响。
|
|
104
|
+
*/
|
|
105
|
+
export function registerAdminRoutes(services: DataServices): (() => void) | undefined {
|
|
106
|
+
const { webServer, connection } = services
|
|
107
|
+
if (webServer === undefined || connection === undefined) return undefined
|
|
108
|
+
|
|
109
|
+
const handler = async (rawRequest: unknown, rawResponse: unknown): Promise<void> => {
|
|
110
|
+
const request = rawRequest as RouteRequest & IncomingMessage
|
|
111
|
+
const response = rawResponse as RouteResponse & ServerResponse
|
|
112
|
+
|
|
113
|
+
const rejection = authorize(connection, request)
|
|
114
|
+
if (rejection !== undefined) {
|
|
115
|
+
sendError(response, rejection, rejection === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN', 'unauthorized')
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const method = (request.method ?? 'GET').toUpperCase()
|
|
120
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
121
|
+
const resource = parseAdminPath(url.pathname)
|
|
122
|
+
if (resource === undefined) {
|
|
123
|
+
sendError(response, 404, 'NOT_FOUND', 'not found')
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
const params = url.searchParams
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
if (resource.kind === 'scopes') {
|
|
130
|
+
if (method !== 'GET') {
|
|
131
|
+
methodNotAllowed(response)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const scopes = await listScopes(services)
|
|
135
|
+
sendJson(response, 200, { scopes })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (resource.kind === 'sources') {
|
|
140
|
+
if (resource.action === 'test') {
|
|
141
|
+
if (method !== 'POST') {
|
|
142
|
+
methodNotAllowed(response)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const body = await readJsonBody(request, services.cfg.adminMaxBodyBytes)
|
|
146
|
+
sendJson(response, 200, await testDataSource(services, body))
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (resource.id === undefined) {
|
|
150
|
+
if (method === 'GET') {
|
|
151
|
+
sendJson(response, 200, { sources: await listDataSources(services) })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if (method === 'POST') {
|
|
155
|
+
const body = await readJsonBody(request, services.cfg.adminMaxBodyBytes)
|
|
156
|
+
sendJson(response, 201, await createDataSource(services, body))
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
methodNotAllowed(response)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (resource.action === 'tables') {
|
|
163
|
+
if (method !== 'GET') {
|
|
164
|
+
methodNotAllowed(response)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
sendJson(response, 200, await listSourceTables(services, resource.id, Object.fromEntries(params)))
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (resource.action === 'import') {
|
|
171
|
+
if (method !== 'POST') {
|
|
172
|
+
methodNotAllowed(response)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
const body = await readJsonBody(request, services.cfg.adminMaxBodyBytes)
|
|
176
|
+
sendJson(response, 201, await importSourceTable(services, resource.id, body))
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
if (method === 'GET') {
|
|
180
|
+
sendJson(response, 200, await getDataSource(services, resource.id))
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (method === 'PATCH') {
|
|
184
|
+
const body = await readJsonBody(request, services.cfg.adminMaxBodyBytes)
|
|
185
|
+
sendJson(response, 200, await patchDataSource(services, resource.id, body))
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (method === 'DELETE') {
|
|
189
|
+
sendJson(response, 200, await deleteDataSource(services, resource.id))
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
methodNotAllowed(response)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (resource.id === undefined) {
|
|
197
|
+
if (method === 'GET') {
|
|
198
|
+
const result = await listDatasets(services, Object.fromEntries(params))
|
|
199
|
+
sendJson(response, 200, result)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (method === 'POST') {
|
|
203
|
+
const body = await readJsonBody(request, services.cfg.adminMaxBodyBytes)
|
|
204
|
+
const created = await createDataset(services, body as CreateDatasetRequest)
|
|
205
|
+
sendJson(response, 201, created)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
methodNotAllowed(response)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const scope = requireScope(params)
|
|
213
|
+
if (resource.rows === true) {
|
|
214
|
+
if (method !== 'GET') {
|
|
215
|
+
methodNotAllowed(response)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
const rows = await listDatasetRows(services, scope, resource.id, Object.fromEntries(params))
|
|
219
|
+
sendJson(response, 200, rows)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (method === 'GET') {
|
|
223
|
+
const detail = await getDataset(services, scope, resource.id)
|
|
224
|
+
sendJson(response, 200, detail)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (method === 'PATCH') {
|
|
228
|
+
const body = await readJsonBody(request, services.cfg.adminMaxBodyBytes)
|
|
229
|
+
const patched = await patchDataset(services, scope, resource.id, body as PatchDatasetRequest)
|
|
230
|
+
sendJson(response, 200, patched)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (method === 'DELETE') {
|
|
234
|
+
const result = await deleteDataset(services, scope, resource.id)
|
|
235
|
+
sendJson(response, 200, result)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
methodNotAllowed(response)
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error instanceof AdminValidationError) {
|
|
241
|
+
sendError(response, 400, error.code, error.message)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (error instanceof AdminServiceError) {
|
|
245
|
+
sendError(response, error.status, error.code, error.message)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
// 脱敏:不回显 SQL 与物理表名。
|
|
249
|
+
sendError(response, 500, 'QUERY_FAILED', '管理接口内部错误')
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return webServer.register({ kind: 'prefix', path: ADMIN_API_BASE, handler })
|
|
254
|
+
}
|