@cloudbase/app 3.0.4 → 3.0.5
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/dist/cjs/index.js +12 -8
- package/dist/cjs/libs/adapter-node/constants.d.ts +33 -0
- package/dist/cjs/libs/adapter-node/constants.js +38 -0
- package/dist/cjs/libs/adapter-node/context.d.ts +13 -0
- package/dist/cjs/libs/adapter-node/context.js +162 -0
- package/dist/cjs/libs/adapter-node/index.d.ts +23 -0
- package/dist/cjs/libs/adapter-node/index.js +124 -0
- package/dist/cjs/libs/adapter-node/metadata.d.ts +14 -0
- package/dist/cjs/libs/adapter-node/metadata.js +130 -0
- package/dist/cjs/libs/adapter-node/request.d.ts +38 -0
- package/dist/cjs/libs/adapter-node/request.js +432 -0
- package/dist/cjs/libs/adapter-node/tool.d.ts +2 -0
- package/dist/cjs/libs/adapter-node/tool.js +227 -0
- package/dist/cjs/libs/adapter-node/types.d.ts +78 -0
- package/dist/cjs/libs/adapter-node/types.js +3 -0
- package/dist/cjs/libs/adapter-node/utils.d.ts +17 -0
- package/dist/cjs/libs/adapter-node/utils.js +221 -0
- package/dist/esm/index.js +12 -8
- package/dist/esm/libs/adapter-node/constants.d.ts +33 -0
- package/dist/esm/libs/adapter-node/constants.js +35 -0
- package/dist/esm/libs/adapter-node/context.d.ts +13 -0
- package/dist/esm/libs/adapter-node/context.js +156 -0
- package/dist/esm/libs/adapter-node/index.d.ts +23 -0
- package/dist/esm/libs/adapter-node/index.js +118 -0
- package/dist/esm/libs/adapter-node/metadata.d.ts +14 -0
- package/dist/esm/libs/adapter-node/metadata.js +123 -0
- package/dist/esm/libs/adapter-node/request.d.ts +38 -0
- package/dist/esm/libs/adapter-node/request.js +429 -0
- package/dist/esm/libs/adapter-node/tool.d.ts +2 -0
- package/dist/esm/libs/adapter-node/tool.js +223 -0
- package/dist/esm/libs/adapter-node/types.d.ts +78 -0
- package/dist/esm/libs/adapter-node/types.js +2 -0
- package/dist/esm/libs/adapter-node/utils.d.ts +17 -0
- package/dist/esm/libs/adapter-node/utils.js +205 -0
- package/dist/miniprogram/index.js +1 -1
- package/package.json +20 -4
- package/src/index.ts +13 -1
- package/src/libs/adapter-node/constants.ts +42 -0
- package/src/libs/adapter-node/context.ts +238 -0
- package/src/libs/adapter-node/index.ts +166 -0
- package/src/libs/adapter-node/metadata.ts +69 -0
- package/src/libs/adapter-node/request.ts +480 -0
- package/src/libs/adapter-node/tool.ts +223 -0
- package/src/libs/adapter-node/types.ts +116 -0
- package/src/libs/adapter-node/utils.ts +182 -0
- package/webpack/web.prod.js +13 -13
- package/webpack/webpack.miniprogram.js +9 -3
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/* eslint-disable no-nested-ternary */
|
|
2
|
+
import {
|
|
3
|
+
IFetchOptions,
|
|
4
|
+
IRequestConfig,
|
|
5
|
+
IRequestMethod,
|
|
6
|
+
IRequestOptions,
|
|
7
|
+
ResponseObject,
|
|
8
|
+
SDKRequestInterface,
|
|
9
|
+
formatUrl,
|
|
10
|
+
} from '@cloudbase/adapter-interface'
|
|
11
|
+
import { ADMIN_PATH, CLIENT_AUTH_PATH } from './constants'
|
|
12
|
+
import { getCloudbaseContext, getSecretInfo, INIT_CONFIG } from './context'
|
|
13
|
+
import type { ICustomReqOpts } from './types'
|
|
14
|
+
import {
|
|
15
|
+
getEnv,
|
|
16
|
+
isFormData,
|
|
17
|
+
toQueryString,
|
|
18
|
+
safeParseJson,
|
|
19
|
+
obj2StrRecord,
|
|
20
|
+
headersInit2Indexable,
|
|
21
|
+
getCurrRunEnvTag,
|
|
22
|
+
} from './utils'
|
|
23
|
+
import { ICloudbaseConfig } from '@cloudbase/types'
|
|
24
|
+
// 延迟加载,避免非 Node 环境打包时引入此包
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
let signFn: ((...args: any[]) => any) | null = null
|
|
27
|
+
async function getSign() {
|
|
28
|
+
if (!signFn) {
|
|
29
|
+
try {
|
|
30
|
+
// @ts-ignore — 该包仅在 Node 运行时存在,开发环境可能未安装
|
|
31
|
+
const mod = await import('@cloudbase/signature-nodejs')
|
|
32
|
+
signFn = mod.sign
|
|
33
|
+
} catch (e) {
|
|
34
|
+
throw new Error('缺少依赖 @cloudbase/signature-nodejs,请执行以下命令安装:\n\n'
|
|
35
|
+
+ ' npm install @cloudbase/signature-nodejs\n\n'
|
|
36
|
+
+ '该依赖用于 Node 环境下的请求签名。')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return signFn!
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Node.js 环境下的 HTTP 请求实现
|
|
44
|
+
* 基于原生 fetch,支持 GET/POST/PUT/上传/下载/流式请求
|
|
45
|
+
* 自动处理 V3 签名和 Bearer Token 认证
|
|
46
|
+
*/
|
|
47
|
+
export class NodeRequest implements SDKRequestInterface {
|
|
48
|
+
/** 请求配置,包含认证信息 */
|
|
49
|
+
config: IRequestConfig & ICloudbaseConfig
|
|
50
|
+
|
|
51
|
+
/** 请求超时时间(毫秒),默认 15000 */
|
|
52
|
+
private readonly timeout: number
|
|
53
|
+
|
|
54
|
+
/** 受超时控制的请求类型列表 */
|
|
55
|
+
private readonly restrictedMethods: Array<IRequestMethod>
|
|
56
|
+
|
|
57
|
+
constructor(config: IRequestConfig & ICloudbaseConfig) {
|
|
58
|
+
const { timeout, restrictedMethods } = config
|
|
59
|
+
this.timeout = timeout || 15000
|
|
60
|
+
this.restrictedMethods = restrictedMethods || ['get', 'post', 'upload', 'download']
|
|
61
|
+
this.config = config
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 获取客户端凭证 access_token,为管理员权限
|
|
66
|
+
* 用于不支持 V3 签名的接口(如数据模型、关系型数据库、AI)
|
|
67
|
+
*/
|
|
68
|
+
async getClientCredential(opts: { origin: string }): Promise<any> {
|
|
69
|
+
const res = await this.fetch({
|
|
70
|
+
url: `${opts.origin}${CLIENT_AUTH_PATH}`,
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: {
|
|
73
|
+
'content-type': 'application/json',
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
grant_type: 'client_credentials',
|
|
77
|
+
}),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return res.data?.access_token
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 获取实际请求 URL
|
|
85
|
+
* 当请求未携带 Authorization 且未自带 access_token 时,
|
|
86
|
+
* 将 /web 路径替换为 /admin(管理端签名路径)
|
|
87
|
+
*/
|
|
88
|
+
getRealUrl(url: string, headers: Record<string, any>, body: any) {
|
|
89
|
+
if (headers.Authorization?.replace('Bearer', '')?.trim()) {
|
|
90
|
+
return url
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const params = typeof body === 'object' ? body : safeParseJson(body) || ''
|
|
94
|
+
|
|
95
|
+
if (params.access_token) {
|
|
96
|
+
return url
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const urlObj = new URL(url)
|
|
100
|
+
|
|
101
|
+
if (urlObj.pathname === '/web' && this.config.auth?.secretId && this.config.auth?.secretKey) {
|
|
102
|
+
urlObj.pathname = ADMIN_PATH
|
|
103
|
+
}
|
|
104
|
+
return urlObj.toString()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 为请求添加签名或认证信息
|
|
109
|
+
*
|
|
110
|
+
* 处理逻辑优先级:
|
|
111
|
+
* 1. 已有 Authorization header → 直接返回
|
|
112
|
+
* 2. /auth/ 路径 → SDK 认证接口,无需签名
|
|
113
|
+
* 3. 不支持 V3 签名的路径(/v1/model/ 等)→ 用 clientCredential Bearer Token
|
|
114
|
+
* 4. 参数含 access_token → 无需签名
|
|
115
|
+
* 5. 其他 → 使用 V3 签名(TC3-HMAC-SHA256)
|
|
116
|
+
*/
|
|
117
|
+
public getReqOptions = async (
|
|
118
|
+
url: string,
|
|
119
|
+
options: {
|
|
120
|
+
method?: any
|
|
121
|
+
headers: any
|
|
122
|
+
body?: any
|
|
123
|
+
credentials?: string
|
|
124
|
+
signal?: AbortSignal
|
|
125
|
+
url?: any
|
|
126
|
+
},
|
|
127
|
+
) => {
|
|
128
|
+
const urlObj = new URL(url)
|
|
129
|
+
|
|
130
|
+
const { TCB_SOURCE } = getCloudbaseContext()
|
|
131
|
+
// Note: 云函数被调用时可能调用端未传递 SOURCE,TCB_SOURCE 可能为空
|
|
132
|
+
const SOURCE = `${INIT_CONFIG.context?.extendedContext?.source || TCB_SOURCE || ''},${await getCurrRunEnvTag()}`
|
|
133
|
+
|
|
134
|
+
options.headers = {
|
|
135
|
+
...options.headers,
|
|
136
|
+
'User-Agent': `adapter-node/${options.headers?.['X-SDK-Version'] || ''}`,
|
|
137
|
+
'X-TCB-Source': SOURCE,
|
|
138
|
+
'X-Client-Timestamp': new Date().valueOf(),
|
|
139
|
+
'X-TCB-Region': INIT_CONFIG.region || getEnv('TENCENTCLOUD_REGION') || '',
|
|
140
|
+
Host: urlObj.host,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 已携带有效 Authorization,直接返回
|
|
144
|
+
if (options.headers.Authorization?.replace('Bearer', '')?.trim()) {
|
|
145
|
+
return options
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// auth 相关接口不需要签名
|
|
149
|
+
if (urlObj.pathname.startsWith('/auth/') && urlObj.pathname !== CLIENT_AUTH_PATH) {
|
|
150
|
+
return options
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isAdminPath = urlObj.pathname === ADMIN_PATH
|
|
154
|
+
|
|
155
|
+
// 这些路径不支持 V3 签名,改用 clientCredential 获取 Bearer Token
|
|
156
|
+
const UNSUPPORTED_V3_SIGN_PATH = ['/v1/model/', '/v1/rdb/', '/v1/ai/']
|
|
157
|
+
|
|
158
|
+
if (UNSUPPORTED_V3_SIGN_PATH.some(v => urlObj.pathname.startsWith(v))) {
|
|
159
|
+
const token = await this.getClientCredential({ origin: urlObj.origin })
|
|
160
|
+
options.headers.Authorization = `Bearer ${token}`
|
|
161
|
+
return options
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let { secretId, secretKey, sessionToken } = this.config?.auth || {}
|
|
165
|
+
const { secretType } = this.config?.auth || {}
|
|
166
|
+
const { method, body } = options
|
|
167
|
+
const headers = JSON.parse(JSON.stringify(options.headers))
|
|
168
|
+
let params = typeof body === 'object' ? body : safeParseJson(body) || ''
|
|
169
|
+
|
|
170
|
+
// 参数自带 access_token,无需签名
|
|
171
|
+
if (params.access_token) {
|
|
172
|
+
return options
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// GET 请求参数已在 URL 中,签名时 params 置空
|
|
176
|
+
if (method.toLowerCase() === 'get') {
|
|
177
|
+
params = undefined
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// SESSION_SECRET 类型或无密钥时,从环境变量获取临时密钥
|
|
181
|
+
if (!secretId || !secretKey || secretType === 'SESSION_SECRET') {
|
|
182
|
+
const secretInfo = getSecretInfo()
|
|
183
|
+
secretId = secretInfo.secretId
|
|
184
|
+
secretKey = secretInfo.secretKey
|
|
185
|
+
sessionToken = secretInfo.sessionToken
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 仍无密钥则跳过签名
|
|
189
|
+
if (!secretId || !secretKey) {
|
|
190
|
+
return options
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
delete headers.Authorization
|
|
194
|
+
|
|
195
|
+
// content-type 需与服务端签名验证保持大小写一致
|
|
196
|
+
if (headers['content-type']) {
|
|
197
|
+
headers['content-type'] = headers['content-type'].toLowerCase()
|
|
198
|
+
} else if (headers['Content-Type']) {
|
|
199
|
+
headers['Content-Type'] = headers['Content-Type'].toLowerCase()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const signedParams = {
|
|
203
|
+
secretId,
|
|
204
|
+
secretKey,
|
|
205
|
+
method,
|
|
206
|
+
url,
|
|
207
|
+
params,
|
|
208
|
+
headers,
|
|
209
|
+
withSignedParams: isAdminPath,
|
|
210
|
+
timestamp: Math.floor(new Date().getTime() / 1000) - 1,
|
|
211
|
+
isCloudApi: !isAdminPath,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log('======signedParams', signedParams)
|
|
215
|
+
|
|
216
|
+
const sign = await getSign()
|
|
217
|
+
if (!sign) return options
|
|
218
|
+
|
|
219
|
+
const { authorization, timestamp } = sign(signedParams)
|
|
220
|
+
|
|
221
|
+
if (typeof sessionToken === 'string' && sessionToken !== '') {
|
|
222
|
+
// 临时密钥需要额外附带 Token
|
|
223
|
+
headers.Authorization = `${authorization}, Timestamp=${timestamp}, Token=${sessionToken}`
|
|
224
|
+
} else {
|
|
225
|
+
headers.Authorization = `${authorization}, Timestamp=${timestamp}`
|
|
226
|
+
|
|
227
|
+
// admin 路径需要额外的时间戳和过期头
|
|
228
|
+
if (isAdminPath) {
|
|
229
|
+
headers['X-Timestamp'] = timestamp
|
|
230
|
+
headers['X-Signature-Expires'] = 600
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
...options,
|
|
236
|
+
headers,
|
|
237
|
+
body: typeof params === 'object' ? JSON.stringify(params) : params,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
public get = (options: IRequestOptions): Promise<ResponseObject> => this.request(
|
|
242
|
+
{
|
|
243
|
+
...options,
|
|
244
|
+
method: 'get',
|
|
245
|
+
},
|
|
246
|
+
this.restrictedMethods.includes('get'),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
public post = (options: IRequestOptions): Promise<ResponseObject> => this.request(
|
|
250
|
+
{
|
|
251
|
+
...options,
|
|
252
|
+
method: 'post',
|
|
253
|
+
},
|
|
254
|
+
this.restrictedMethods.includes('post'),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
public put = (options: IRequestOptions): Promise<ResponseObject> => this.request({
|
|
258
|
+
...options,
|
|
259
|
+
method: 'put',
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 文件上传
|
|
264
|
+
* POST 方式:构建 FormData 上传
|
|
265
|
+
* PUT 方式:直接将文件作为 body 上传
|
|
266
|
+
*/
|
|
267
|
+
public upload = (options: IRequestOptions): Promise<ResponseObject> => {
|
|
268
|
+
const { data: _data, file, name, method, headers = {} } = options
|
|
269
|
+
if (file === undefined || name == undefined) {
|
|
270
|
+
throw new Error('file and name is required')
|
|
271
|
+
}
|
|
272
|
+
const data = obj2StrRecord(_data ?? {})
|
|
273
|
+
const loweredMethod = method?.toLowerCase()
|
|
274
|
+
const reqMethod = ['post', 'put'].find(m => m === loweredMethod) ?? 'put'
|
|
275
|
+
const formData = new FormData()
|
|
276
|
+
if (reqMethod === 'post') {
|
|
277
|
+
Object.keys(data).forEach((key) => {
|
|
278
|
+
formData.append(key, data[key])
|
|
279
|
+
})
|
|
280
|
+
formData.append('key', name)
|
|
281
|
+
formData.append('file', file)
|
|
282
|
+
return this.request(
|
|
283
|
+
{
|
|
284
|
+
...options,
|
|
285
|
+
data: formData,
|
|
286
|
+
method: reqMethod,
|
|
287
|
+
},
|
|
288
|
+
this.restrictedMethods.includes('upload'),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return this.request(
|
|
293
|
+
{
|
|
294
|
+
...options,
|
|
295
|
+
method: 'put',
|
|
296
|
+
headers,
|
|
297
|
+
body: file,
|
|
298
|
+
},
|
|
299
|
+
this.restrictedMethods.includes('upload'),
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 文件下载(浏览器环境,通过创建 <a> 标签触发下载) */
|
|
304
|
+
public download = async (options: IRequestOptions): Promise<unknown> => {
|
|
305
|
+
const { data } = await this.get({
|
|
306
|
+
...options,
|
|
307
|
+
headers: {},
|
|
308
|
+
responseType: 'blob',
|
|
309
|
+
})
|
|
310
|
+
const url = window.URL.createObjectURL(new Blob([data]))
|
|
311
|
+
const fileName = decodeURIComponent(new URL(options?.url ?? '').pathname.split('/').pop() || '')
|
|
312
|
+
const link = document.createElement('a')
|
|
313
|
+
|
|
314
|
+
link.href = url
|
|
315
|
+
link.setAttribute('download', fileName)
|
|
316
|
+
link.style.display = 'none'
|
|
317
|
+
|
|
318
|
+
document.body.appendChild(link)
|
|
319
|
+
link.click()
|
|
320
|
+
window.URL.revokeObjectURL(url)
|
|
321
|
+
document.body.removeChild(link)
|
|
322
|
+
return new Promise((resolve) => {
|
|
323
|
+
resolve({
|
|
324
|
+
statusCode: 200,
|
|
325
|
+
tempFilePath: options.url,
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 底层 fetch 请求,支持流式响应
|
|
332
|
+
* 超时通过 AbortController + setTimeout 实现
|
|
333
|
+
*/
|
|
334
|
+
public fetch = async (options: Omit<IFetchOptions, 'signal'> & { signal?: AbortSignal; customReqOpts?: ICustomReqOpts }): Promise<ResponseObject> => {
|
|
335
|
+
const { enableAbort = false, stream = false, signal, customReqOpts } = options
|
|
336
|
+
const url = this.getRealUrl(options.url, options.headers || {}, options.body)
|
|
337
|
+
const abortController = new AbortController()
|
|
338
|
+
const timeout = customReqOpts?.timeout || this.timeout
|
|
339
|
+
|
|
340
|
+
// 桥接外部 signal 到内部 AbortController
|
|
341
|
+
if (signal) {
|
|
342
|
+
if (signal.aborted) {
|
|
343
|
+
abortController.abort()
|
|
344
|
+
} else {
|
|
345
|
+
signal.addEventListener('abort', () => abortController.abort())
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 超时自动中断
|
|
350
|
+
let timer = undefined
|
|
351
|
+
if (enableAbort || timeout) {
|
|
352
|
+
timer = setTimeout(() => {
|
|
353
|
+
const timeoutMsg = `请求在${timeout / 1000}s内未完成,已中断`
|
|
354
|
+
console.warn(timeoutMsg)
|
|
355
|
+
abortController.abort(new Error(timeoutMsg))
|
|
356
|
+
}, timeout)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const headers = options.headers ? headersInit2Indexable(options.headers) : undefined
|
|
360
|
+
|
|
361
|
+
const fetchOptions = await this.getReqOptions(url, {
|
|
362
|
+
...options,
|
|
363
|
+
headers,
|
|
364
|
+
body: options.body as any as NodeJS.ReadableStream,
|
|
365
|
+
signal: abortController.signal,
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
console.log('======fetchOptions', url, fetchOptions)
|
|
369
|
+
|
|
370
|
+
const res = await fetch(url, fetchOptions as RequestInit)
|
|
371
|
+
.then((x) => {
|
|
372
|
+
clearTimeout(timer)
|
|
373
|
+
return x
|
|
374
|
+
})
|
|
375
|
+
.catch((x) => {
|
|
376
|
+
clearTimeout(timer)
|
|
377
|
+
return Promise.reject(x)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
const ret = {
|
|
381
|
+
data:
|
|
382
|
+
+res.status === 204
|
|
383
|
+
? '' // 204 No Content
|
|
384
|
+
: stream
|
|
385
|
+
? res.body
|
|
386
|
+
: await res.json(),
|
|
387
|
+
statusCode: res.status,
|
|
388
|
+
header: res.headers,
|
|
389
|
+
}
|
|
390
|
+
return ret
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 通用请求方法,被 get/post/put/upload 等调用
|
|
395
|
+
* 负责构建 payload、设置超时、解析响应
|
|
396
|
+
*
|
|
397
|
+
* @param options - 请求选项
|
|
398
|
+
* @param enableAbort - 是否启用超时中断(由 restrictedMethods 决定)
|
|
399
|
+
*/
|
|
400
|
+
public request = async (options: IRequestOptions, enableAbort = false): Promise<ResponseObject> => {
|
|
401
|
+
const {
|
|
402
|
+
url,
|
|
403
|
+
headers: _headers = {},
|
|
404
|
+
data,
|
|
405
|
+
responseType,
|
|
406
|
+
withCredentials,
|
|
407
|
+
body,
|
|
408
|
+
method: _method,
|
|
409
|
+
customReqOpts,
|
|
410
|
+
} = options
|
|
411
|
+
const headers = obj2StrRecord(_headers)
|
|
412
|
+
const method = String(_method).toLowerCase() || 'get'
|
|
413
|
+
const abortController = new AbortController()
|
|
414
|
+
const { signal } = abortController
|
|
415
|
+
const timeout = customReqOpts?.timeout || this.timeout
|
|
416
|
+
|
|
417
|
+
// 构建请求 payload:FormData > urlencoded > raw body > JSON
|
|
418
|
+
let payload
|
|
419
|
+
if (isFormData(data)) {
|
|
420
|
+
payload = data
|
|
421
|
+
} else if (headers['content-type'] === 'application/x-www-form-urlencoded') {
|
|
422
|
+
payload = toQueryString(data ?? {})
|
|
423
|
+
} else if (body) {
|
|
424
|
+
payload = body
|
|
425
|
+
} else {
|
|
426
|
+
payload = data ? JSON.stringify(data) : undefined
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const realUrl = this.getRealUrl(
|
|
430
|
+
formatUrl('https', url ?? '', method === 'get' ? data : {}),
|
|
431
|
+
options.headers || {},
|
|
432
|
+
payload,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
// 超时通过 AbortController + setTimeout 实现
|
|
436
|
+
let timer
|
|
437
|
+
|
|
438
|
+
if (enableAbort || timeout) {
|
|
439
|
+
timer = setTimeout(() => {
|
|
440
|
+
const timeoutMsg = `请求在${timeout / 1000}s内未完成,已中断`
|
|
441
|
+
console.warn(timeoutMsg)
|
|
442
|
+
abortController.abort(new Error(timeoutMsg))
|
|
443
|
+
}, timeout)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const requestOptions = await this.getReqOptions(realUrl, {
|
|
447
|
+
method,
|
|
448
|
+
headers,
|
|
449
|
+
body: payload,
|
|
450
|
+
credentials: withCredentials ? 'include' : 'same-origin',
|
|
451
|
+
signal,
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
console.log('======requestOptions', realUrl, requestOptions)
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const response = await fetch(realUrl, requestOptions as RequestInit)
|
|
458
|
+
const result: ResponseObject = {
|
|
459
|
+
header: {},
|
|
460
|
+
statusCode: response.status,
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
result.data = responseType === 'blob' ? await response.blob() : safeParseJson(await response.text())
|
|
465
|
+
} catch (e) {
|
|
466
|
+
// 上传 POST 请求可能返回 XML 等非 JSON 格式,容错处理
|
|
467
|
+
console.log('catch an error', e)
|
|
468
|
+
result.data = responseType === 'blob' ? await response.blob() : await response.text()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 将响应头统一转为小写 key
|
|
472
|
+
const { headers } = response
|
|
473
|
+
headers.forEach((val, key) => (result.header[key.toLowerCase()] = val))
|
|
474
|
+
|
|
475
|
+
return result
|
|
476
|
+
} finally {
|
|
477
|
+
clearTimeout(timer)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { ERROR } from './constants'
|
|
2
|
+
import { getCloudbaseContext, parseContext } from './context'
|
|
3
|
+
import { validateUid } from './utils'
|
|
4
|
+
import type {
|
|
5
|
+
ICreateTicketOpts,
|
|
6
|
+
IGetUserInfoResult,
|
|
7
|
+
IGetEndUserInfoResult,
|
|
8
|
+
IUserInfoQuery,
|
|
9
|
+
ITemplateNotifyReq,
|
|
10
|
+
} from './types'
|
|
11
|
+
import { ICloudbaseConfig } from '@cloudbase/types'
|
|
12
|
+
|
|
13
|
+
// 延迟加载,避免非 Node 环境打包时引入此包
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
|
+
let jwtSign: ((...args: any[]) => any) | null = null
|
|
16
|
+
async function getJwtSign() {
|
|
17
|
+
if (!jwtSign) {
|
|
18
|
+
try {
|
|
19
|
+
// @ts-ignore — 该包仅在 Node 运行时存在,开发环境可能未安装
|
|
20
|
+
const mod = await import('jsonwebtoken')
|
|
21
|
+
jwtSign = mod.default?.sign || mod.sign
|
|
22
|
+
} catch (e) {
|
|
23
|
+
throw new Error('缺少依赖 jsonwebtoken,请执行以下命令安装:\n\n'
|
|
24
|
+
+ ' npm install jsonwebtoken\n\n'
|
|
25
|
+
+ '该依赖用于 Node 环境下的自定义登录票据生成。')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return jwtSign!
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 从云函数运行时上下文中获取当前请求的用户信息
|
|
33
|
+
* 数据来源为环境变量(微信 openId、TCB uuid 等)
|
|
34
|
+
*/
|
|
35
|
+
function getDefaultUserInfo(): IGetUserInfoResult {
|
|
36
|
+
const { WX_OPENID, WX_APPID, TCB_UUID, TCB_CUSTOM_USER_ID, TCB_ISANONYMOUS_USER } = getCloudbaseContext()
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
openId: WX_OPENID || '',
|
|
40
|
+
appId: WX_APPID || '',
|
|
41
|
+
uid: TCB_UUID || '',
|
|
42
|
+
customUserId: TCB_CUSTOM_USER_ID || '',
|
|
43
|
+
isAnonymous: TCB_ISANONYMOUS_USER === 'true',
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 调用后端管理接口查询用户信息
|
|
49
|
+
* 统一封装 auth.getUserInfoForAdmin 的请求逻辑
|
|
50
|
+
*/
|
|
51
|
+
function sendUserInfoRequest(app: any, params: Record<string, any>): Promise<any> {
|
|
52
|
+
return app?.request?.send?.('auth.getUserInfoForAdmin', params, {
|
|
53
|
+
pathname: 'web',
|
|
54
|
+
endPointMode: 'CLOUD_API',
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 初始化 Node 端工具方法,挂载到 js-sdk app 实例上
|
|
60
|
+
* 包含:auth 相关方法、模板消息推送、context 解析
|
|
61
|
+
*
|
|
62
|
+
* @param app - js-sdk cloudbase 实例
|
|
63
|
+
* @param config - 配置信息,包含认证凭证和环境 ID
|
|
64
|
+
*/
|
|
65
|
+
export const nodeTool = (app: any, config: ICloudbaseConfig) => {
|
|
66
|
+
// 仅当 app 已初始化 auth 模块时,才注入 auth 相关方法
|
|
67
|
+
if (app.auth) {
|
|
68
|
+
const auth = {
|
|
69
|
+
/** 获取当前请求的用户信息(从环境变量读取,同步) */
|
|
70
|
+
getUserInfo(): IGetUserInfoResult {
|
|
71
|
+
return getDefaultUserInfo()
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 获取终端用户信息
|
|
76
|
+
* 不传 uid 时返回当前请求用户信息,传 uid 时查询指定用户
|
|
77
|
+
*/
|
|
78
|
+
async getEndUserInfo(uid?: string): Promise<IGetEndUserInfoResult> {
|
|
79
|
+
const defaultUserInfo = getDefaultUserInfo()
|
|
80
|
+
|
|
81
|
+
if (uid === undefined) {
|
|
82
|
+
return { userInfo: defaultUserInfo }
|
|
83
|
+
}
|
|
84
|
+
validateUid(uid)
|
|
85
|
+
|
|
86
|
+
return sendUserInfoRequest(app, {
|
|
87
|
+
uuid: uid,
|
|
88
|
+
envName: config.env,
|
|
89
|
+
}).then((result: any) => {
|
|
90
|
+
if (result.code) {
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
userInfo: { ...defaultUserInfo, ...result.data },
|
|
95
|
+
requestId: result.requestId,
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 创建自定义登录 Ticket
|
|
102
|
+
* 使用 RSA 私钥签发 JWT,客户端凭此 Ticket 换取登录态
|
|
103
|
+
*
|
|
104
|
+
* @param uid - 自定义用户 ID(4~32 位)
|
|
105
|
+
* @param options - 刷新间隔和过期时间配置
|
|
106
|
+
* @returns 格式为 "{private_key_id}/@@/{jwt_token}" 的 Ticket 字符串
|
|
107
|
+
*/
|
|
108
|
+
async createTicket(uid: string, options: ICreateTicketOpts = {}): Promise<string> {
|
|
109
|
+
validateUid(uid)
|
|
110
|
+
|
|
111
|
+
const timestamp = new Date().getTime()
|
|
112
|
+
const { credentials } = config.auth || {}
|
|
113
|
+
const { env } = config
|
|
114
|
+
|
|
115
|
+
if (!env) {
|
|
116
|
+
throw { ...ERROR.INVALID_PARAM, message: 'no env in config' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!credentials?.env_id) {
|
|
120
|
+
throw {
|
|
121
|
+
...ERROR.INVALID_PARAM,
|
|
122
|
+
message: '当前私钥未包含env_id 信息, 请前往腾讯云云开发控制台,获取自定义登录最新私钥',
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (credentials.env_id !== env) {
|
|
127
|
+
throw {
|
|
128
|
+
...ERROR.INVALID_PARAM,
|
|
129
|
+
message: '当前私钥所属环境与 init 指定环境不一致!',
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const {
|
|
134
|
+
refresh = 3600 * 1000, // 默认 1 小时刷新
|
|
135
|
+
expire = timestamp + 7 * 24 * 60 * 60 * 1000, // 默认 7 天过期
|
|
136
|
+
} = options
|
|
137
|
+
const sign = await getJwtSign()
|
|
138
|
+
const token = sign(
|
|
139
|
+
{
|
|
140
|
+
alg: 'RS256',
|
|
141
|
+
env,
|
|
142
|
+
iat: timestamp,
|
|
143
|
+
exp: timestamp + 10 * 60 * 1000, // Ticket 本身 10 分钟有效
|
|
144
|
+
uid,
|
|
145
|
+
refresh,
|
|
146
|
+
expire,
|
|
147
|
+
},
|
|
148
|
+
credentials.private_key,
|
|
149
|
+
{
|
|
150
|
+
allowInsecureKeySizes: true,
|
|
151
|
+
algorithm: 'RS256',
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return `${credentials.private_key_id}/@@/${token}`
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 按条件查询用户信息(管理端接口)
|
|
160
|
+
* 支持按 uid、platform、platformId 查询
|
|
161
|
+
*/
|
|
162
|
+
async queryUserInfo(query: IUserInfoQuery): Promise<any> {
|
|
163
|
+
const { uid, platform, platformId } = query
|
|
164
|
+
return sendUserInfoRequest(app, {
|
|
165
|
+
uuid: uid,
|
|
166
|
+
platform,
|
|
167
|
+
platformId,
|
|
168
|
+
envName: config.env,
|
|
169
|
+
}).then((result: any) => {
|
|
170
|
+
if (result.code) {
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
userInfo: { ...result.data },
|
|
175
|
+
requestId: result.requestId,
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
/** 获取客户端 IP 地址 */
|
|
181
|
+
getClientIP(): string {
|
|
182
|
+
const { TCB_SOURCE_IP } = getCloudbaseContext()
|
|
183
|
+
return TCB_SOURCE_IP || ''
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 将 auth 方法逐一挂载到 app.auth 上
|
|
188
|
+
Object.keys(auth).forEach((key) => {
|
|
189
|
+
app.auth[key] = (auth as Record<string, any>)[key]
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 发送模板消息通知
|
|
195
|
+
* 通过调用 lowcode-datasource 云函数间接调用微搭 API
|
|
196
|
+
*
|
|
197
|
+
* @param params - 通知参数(策略 ID、模板变量、跳转链接)
|
|
198
|
+
* @param opts - 可选配置,如超时时间
|
|
199
|
+
*/
|
|
200
|
+
app.sendTemplateNotification = async (params: ITemplateNotifyReq, opts?: { timeout?: number }) => await app?.callFunction?.(
|
|
201
|
+
{
|
|
202
|
+
name: 'lowcode-datasource',
|
|
203
|
+
data: {
|
|
204
|
+
methodName: 'callWedaApi',
|
|
205
|
+
params: {
|
|
206
|
+
action: 'PushNotifyMsg',
|
|
207
|
+
data: {
|
|
208
|
+
NotifyId: params.notifyId,
|
|
209
|
+
Data: JSON.stringify(params.data),
|
|
210
|
+
NotifyUsers: undefined,
|
|
211
|
+
Url: params.url,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
mode: 'c',
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
undefined,
|
|
218
|
+
opts,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
/** 挂载 context 解析工具,方便用户在云函数中使用 */
|
|
222
|
+
app.parseContext = parseContext
|
|
223
|
+
}
|