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.
Files changed (89) hide show
  1. package/README.md +332 -0
  2. package/cordis.patch.yml +7 -0
  3. package/lib/admin-contract.d.ts +274 -0
  4. package/lib/admin-http.d.ts +31 -0
  5. package/lib/admin-validate.d.ts +87 -0
  6. package/lib/admin.d.ts +60 -0
  7. package/lib/client.js +2969 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/datasource/columns.d.ts +13 -0
  10. package/lib/datasource/connection.d.ts +33 -0
  11. package/lib/datasource/connector/base.d.ts +21 -0
  12. package/lib/datasource/connector/mysql.d.ts +23 -0
  13. package/lib/datasource/connector/postgresql.d.ts +23 -0
  14. package/lib/datasource/crypto.d.ts +13 -0
  15. package/lib/datasource/driver.d.ts +39 -0
  16. package/lib/datasource/errors.d.ts +25 -0
  17. package/lib/datasource/importer.d.ts +38 -0
  18. package/lib/datasource/source-sql.d.ts +11 -0
  19. package/lib/datasource/source-store.d.ts +28 -0
  20. package/lib/datasource/types.d.ts +119 -0
  21. package/lib/db.d.ts +91 -0
  22. package/lib/http-common.d.ts +31 -0
  23. package/lib/http.d.ts +13 -0
  24. package/lib/index.d.ts +50 -0
  25. package/lib/index.js +5735 -0
  26. package/lib/parse.d.ts +41 -0
  27. package/lib/preview.d.ts +75 -0
  28. package/lib/render.d.ts +100 -0
  29. package/lib/scope-registry.d.ts +47 -0
  30. package/lib/scope.d.ts +56 -0
  31. package/lib/sql.d.ts +133 -0
  32. package/lib/store.d.ts +174 -0
  33. package/lib/table.d.ts +38 -0
  34. package/lib/tooling.d.ts +267 -0
  35. package/lib/tools/datasource.d.ts +16 -0
  36. package/lib/tools/import.d.ts +21 -0
  37. package/lib/tools/read.d.ts +77 -0
  38. package/lib/tools/registry.d.ts +12 -0
  39. package/lib/tools/write.d.ts +37 -0
  40. package/lib/view.d.ts +138 -0
  41. package/package.json +61 -0
  42. package/src/admin-contract.ts +351 -0
  43. package/src/admin-http.ts +254 -0
  44. package/src/admin-validate.ts +393 -0
  45. package/src/admin.ts +558 -0
  46. package/src/client/index.ts +403 -0
  47. package/src/client/settings/CreateForm.tsx +186 -0
  48. package/src/client/settings/DataSourceForm.tsx +278 -0
  49. package/src/client/settings/DataSourcesPanel.tsx +301 -0
  50. package/src/client/settings/DatasetEditor.tsx +245 -0
  51. package/src/client/settings/DatasetTable.tsx +110 -0
  52. package/src/client/settings/DatasetsPanel.tsx +207 -0
  53. package/src/client/settings/RowsPanel.tsx +196 -0
  54. package/src/client/settings/Section.tsx +41 -0
  55. package/src/client/settings/SourceTablesPanel.tsx +226 -0
  56. package/src/client/settings/api.ts +154 -0
  57. package/src/client/settings/styles.ts +125 -0
  58. package/src/datasource/columns.ts +56 -0
  59. package/src/datasource/connection.ts +124 -0
  60. package/src/datasource/connector/base.ts +54 -0
  61. package/src/datasource/connector/mysql.ts +187 -0
  62. package/src/datasource/connector/postgresql.ts +212 -0
  63. package/src/datasource/crypto.ts +61 -0
  64. package/src/datasource/driver.ts +108 -0
  65. package/src/datasource/errors.ts +69 -0
  66. package/src/datasource/importer.ts +262 -0
  67. package/src/datasource/index.ts +62 -0
  68. package/src/datasource/source-sql.ts +64 -0
  69. package/src/datasource/source-store.ts +152 -0
  70. package/src/datasource/types.ts +133 -0
  71. package/src/db.ts +277 -0
  72. package/src/http-common.ts +91 -0
  73. package/src/http.ts +130 -0
  74. package/src/index.ts +486 -0
  75. package/src/parse.ts +213 -0
  76. package/src/preview.ts +198 -0
  77. package/src/render.ts +294 -0
  78. package/src/scope-registry.ts +111 -0
  79. package/src/scope.ts +159 -0
  80. package/src/sql.ts +491 -0
  81. package/src/store.ts +412 -0
  82. package/src/table.ts +160 -0
  83. package/src/tooling.ts +551 -0
  84. package/src/tools/datasource.ts +378 -0
  85. package/src/tools/import.ts +282 -0
  86. package/src/tools/read.ts +536 -0
  87. package/src/tools/registry.ts +56 -0
  88. package/src/tools/write.ts +241 -0
  89. 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
+ }