@xgspz/base-sdk 0.0.0-stage → 1.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/express.d.ts +30 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +37 -0
- package/dist/index.d.ts +246 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +383 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +219 -0
- package/dist/storage-upload.d.ts +25 -0
- package/dist/storage-upload.d.ts.map +1 -0
- package/dist/storage-upload.js +102 -0
- package/dist/storage-upload.test.d.ts +2 -0
- package/dist/storage-upload.test.d.ts.map +1 -0
- package/dist/storage-upload.test.js +97 -0
- package/package.json +24 -4
- package/src/express.ts +53 -0
- package/src/index.test.ts +231 -0
- package/src/index.ts +529 -0
- package/src/storage-upload.test.ts +82 -0
- package/src/storage-upload.ts +101 -0
- package/tsconfig.json +16 -0
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { currentRequestId, REQUEST_ID_HEADER } from "@xgspz/service-kit";
|
|
3
|
+
/**
|
|
4
|
+
* 基础服务客户端。业务服务只依赖这个包,不直接认识平台接口。
|
|
5
|
+
*
|
|
6
|
+
* 设计要点:
|
|
7
|
+
* - 每次调用都要显式声明 `actingPersonId`(代表谁操作)。不提供"系统身份"
|
|
8
|
+
* 的默认值,因为平台侧的发起人必须是真实的人,审计也要能追到责任人。
|
|
9
|
+
* - 写操作强制 `idempotencyKey`:网络重试、业务重投都不会造成重复送达。
|
|
10
|
+
* - 只对**读**和网络层错误重试;写操作交给基础服务的幂等键兜底。
|
|
11
|
+
*/
|
|
12
|
+
export class BaseServiceError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
status;
|
|
15
|
+
constructor(message, code, status) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.name = "BaseServiceError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
23
|
+
/**
|
|
24
|
+
* 校验基础服务发来的语音请求(turnUrl 的每一轮、notifyUrl 的通话结果)确实出自基础服务、说的确实是这一通电话。
|
|
25
|
+
* 基础服务用**本服务的服务令牌**对「时间戳.通话 id」做 HMAC-SHA256,放在 X-Base-Timestamp / X-Base-Signature 两个头里。
|
|
26
|
+
* callId 取请求体里的:每一轮是 `body.callId`,通话结果是 `body.call.id`。时间戳超过容差(默认 5 分钟)的一律不认,防重放。
|
|
27
|
+
*/
|
|
28
|
+
export function verifyVoiceSignature(input) {
|
|
29
|
+
const header = (name) => {
|
|
30
|
+
const value = input.headers[name] ?? input.headers[name.toLowerCase()];
|
|
31
|
+
return String(Array.isArray(value) ? value[0] ?? "" : value ?? "").trim();
|
|
32
|
+
};
|
|
33
|
+
const timestamp = header("X-Base-Timestamp");
|
|
34
|
+
const signature = header("X-Base-Signature");
|
|
35
|
+
if (!input.serviceToken || !input.callId || !/^\d{10,16}$/.test(timestamp) || !/^[0-9a-f]{64}$/i.test(signature))
|
|
36
|
+
return false;
|
|
37
|
+
if (Math.abs((input.now ?? Date.now()) - Number(timestamp)) > (input.toleranceMs ?? 5 * 60_000))
|
|
38
|
+
return false;
|
|
39
|
+
const expected = createHmac("sha256", input.serviceToken).update(`${timestamp}.${input.callId}`).digest();
|
|
40
|
+
return timingSafeEqual(expected, Buffer.from(signature, "hex"));
|
|
41
|
+
}
|
|
42
|
+
export class BaseClient {
|
|
43
|
+
options;
|
|
44
|
+
baseUrl;
|
|
45
|
+
timeoutMs;
|
|
46
|
+
retries;
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.options = options;
|
|
49
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
50
|
+
this.timeoutMs = options.timeoutMs ?? 20000;
|
|
51
|
+
this.retries = options.retries ?? 2;
|
|
52
|
+
}
|
|
53
|
+
get configured() {
|
|
54
|
+
return Boolean(this.baseUrl && this.options.serviceToken);
|
|
55
|
+
}
|
|
56
|
+
/** 音频生成不自动重试;TTS 调用可能计费。调用方应设置足够的 timeoutMs。 */
|
|
57
|
+
generateAudio(input, acting) {
|
|
58
|
+
return this.call("/internal/audio/generate", { method: "POST", body: input, acting });
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 生图:基础服务先扣额度,主模型失败换备用(审核拦截不换),结果立即转存对象存储。
|
|
62
|
+
* 可能要几分钟;不自动重试(每次调用都计次数),重试请带同一个 idempotencyKey。
|
|
63
|
+
*/
|
|
64
|
+
generateImage(input, acting, opts = {}) {
|
|
65
|
+
return this.call("/internal/media/images", { method: "POST", body: input, acting, timeoutMs: opts.timeoutMs ?? 400_000 });
|
|
66
|
+
}
|
|
67
|
+
/** 看图:多张图 + 提示词,可要求按 JSON 回答。不自动重试(计次数)。 */
|
|
68
|
+
analyzeImages(input, acting, opts = {}) {
|
|
69
|
+
return this.call("/internal/media/vision", { method: "POST", body: input, acting, timeoutMs: opts.timeoutMs ?? 420_000 });
|
|
70
|
+
}
|
|
71
|
+
/** 当天的生图 / 看图额度。 */
|
|
72
|
+
mediaQuota(acting) {
|
|
73
|
+
return this.call("/internal/media/quota", { acting, retryable: true });
|
|
74
|
+
}
|
|
75
|
+
/** 对象存储上传令牌:只对 svc/<本服务>/<keySuffix> 这一个 key 有效、只能新建。配合 uploadToStorage 直传。 */
|
|
76
|
+
storageUploadToken(input, acting) {
|
|
77
|
+
return this.call("/internal/storage/upload-token", { method: "POST", body: input, acting, retryable: true });
|
|
78
|
+
}
|
|
79
|
+
/** 让基础服务代为下载一个地址并存进对象存储(例如会失效的外部链接)。 */
|
|
80
|
+
fetchIntoStorage(input, acting) {
|
|
81
|
+
return this.call("/internal/storage/fetch", { method: "POST", body: input, acting, timeoutMs: 120_000 });
|
|
82
|
+
}
|
|
83
|
+
/** 本服务对象的短期签名下载链接(不要入库)。 */
|
|
84
|
+
signStorageUrls(input, acting) {
|
|
85
|
+
return this.call("/internal/storage/sign", { method: "POST", body: input, acting, retryable: true });
|
|
86
|
+
}
|
|
87
|
+
statStorage(key, acting) {
|
|
88
|
+
return this.call("/internal/storage/stat", { method: "POST", body: { key }, acting, retryable: true });
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 在自己的 turnUrl / notifyUrl 处理函数里调:确认请求出自基础服务。
|
|
92
|
+
* 每一轮传 `body.callId`,通话结果传 `body.call.id`。返回 false 就回 401,不要处理。
|
|
93
|
+
*/
|
|
94
|
+
verifyVoiceRequest(headers, callId) {
|
|
95
|
+
return verifyVoiceSignature({ headers, callId, serviceToken: this.options.serviceToken });
|
|
96
|
+
}
|
|
97
|
+
/** 语音外呼是否已配置好(不含任何密钥和回调地址)。 */
|
|
98
|
+
voiceConfig(acting) {
|
|
99
|
+
return this.call("/internal/voice/config", { acting, retryable: true });
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 提交一通外呼。**只是排队**:基础服务按并发上限和拨号间隔一通一通拨出,所以返回的状态通常是 QUEUED,
|
|
103
|
+
* 结果通过 notifyUrl 送回(或用 getVoiceCall 查)。接通后先说 greeting,之后对方每说一句,
|
|
104
|
+
* 基础服务把文字 POST 到 turnUrl,由业务回下一句;不传 turnUrl 用内置示例对话。
|
|
105
|
+
* 重试请带同一个 idempotencyKey:已经排上或拨出的不会多打一通。
|
|
106
|
+
*/
|
|
107
|
+
startVoiceCall(input, acting) {
|
|
108
|
+
return this.call("/internal/voice/calls", { method: "POST", body: input, acting });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 批量提交(一次最多 500 通,再多就分几次调)。每一通各自校验、各自幂等,结果逐条返回;
|
|
112
|
+
* 一通号码写错不影响其它的。上千人的调研也是这样全部交进来排队,拨出的节奏由基础服务控制。
|
|
113
|
+
*/
|
|
114
|
+
startVoiceCalls(input, acting) {
|
|
115
|
+
return this.call("/internal/voice/calls/batch", { method: "POST", body: input, acting, timeoutMs: 120_000 });
|
|
116
|
+
}
|
|
117
|
+
/** 取消某个业务对象名下所有还在排队的外呼(比如调研被叫停);不传 entityId 就是这一类全部。已拨出的不受影响。 */
|
|
118
|
+
cancelQueuedVoiceCalls(input, acting) {
|
|
119
|
+
return this.call("/internal/voice/calls/cancel", { method: "POST", body: input, acting });
|
|
120
|
+
}
|
|
121
|
+
/** 外呼队列现状:排队多少、正在打多少、并发上限、是否在外呼时段、是否被暂停。 */
|
|
122
|
+
voiceQueue(acting) {
|
|
123
|
+
return this.call("/internal/voice/queue", { acting, retryable: true });
|
|
124
|
+
}
|
|
125
|
+
/** 通话状态和全程文字记录。只能查本服务发起(或归本服务接听)的通话。 */
|
|
126
|
+
getVoiceCall(callId, acting) {
|
|
127
|
+
return this.call(`/internal/voice/calls/${encodeURIComponent(callId)}`, { acting, retryable: true });
|
|
128
|
+
}
|
|
129
|
+
/** 不想打了:还在排队的直接取消;已拨出的在对方下一次说话或沉默时挂断(平台没有即时挂断接口)。 */
|
|
130
|
+
hangupVoiceCall(callId, acting) {
|
|
131
|
+
return this.call(`/internal/voice/calls/${encodeURIComponent(callId)}/hangup`, { method: "POST", body: {}, acting });
|
|
132
|
+
}
|
|
133
|
+
/** 本服务登记的呼入路由(哪些号码的来电归本服务接)。 */
|
|
134
|
+
listVoiceInboundRoutes(acting) {
|
|
135
|
+
return this.call("/internal/voice/inbound-routes", { acting, retryable: true });
|
|
136
|
+
}
|
|
137
|
+
/** 登记或更新一条呼入路由。号码已登记给别的服务时返回 409。 */
|
|
138
|
+
saveVoiceInboundRoute(input, acting) {
|
|
139
|
+
return this.call("/internal/voice/inbound-routes", { method: "PUT", body: input, acting });
|
|
140
|
+
}
|
|
141
|
+
deleteVoiceInboundRoute(number, acting) {
|
|
142
|
+
return this.call(`/internal/voice/inbound-routes/${encodeURIComponent(number)}`, { method: "DELETE", body: {}, acting });
|
|
143
|
+
}
|
|
144
|
+
headers(acting) {
|
|
145
|
+
if (!this.options.serviceToken)
|
|
146
|
+
throw new BaseServiceError("未配置 BASE_SERVICE_TOKEN", "CONFIG_REQUIRED", 503);
|
|
147
|
+
if (!acting?.personId)
|
|
148
|
+
throw new BaseServiceError("缺少 actingPersonId:必须声明代表谁操作", "BAD_REQUEST", 400);
|
|
149
|
+
const requestId = currentRequestId();
|
|
150
|
+
return {
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
"X-Service-Token": this.options.serviceToken,
|
|
153
|
+
"X-Service-Name": this.options.serviceName,
|
|
154
|
+
"X-Acting-PersonId": acting.personId,
|
|
155
|
+
...(acting.accessToken ? { "X-Acting-Access-Token": acting.accessToken } : {}),
|
|
156
|
+
// 把当前请求的追踪 ID 带给基础服务。没有它,一次用户操作在
|
|
157
|
+
// 业务服务和基础服务的日志里就是两条对不上的记录。
|
|
158
|
+
...(requestId ? { [REQUEST_ID_HEADER]: requestId } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async call(path, init) {
|
|
162
|
+
const retries = init.retryable ? this.retries : 0;
|
|
163
|
+
let lastError;
|
|
164
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? this.timeoutMs);
|
|
167
|
+
try {
|
|
168
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
169
|
+
method: init.method || "GET",
|
|
170
|
+
headers: this.headers(init.acting),
|
|
171
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
});
|
|
174
|
+
const payload = (await response.json().catch(() => null));
|
|
175
|
+
if (response.ok && payload?.success === true)
|
|
176
|
+
return payload.data;
|
|
177
|
+
const message = payload && "message" in payload ? payload.message : `基础服务返回 HTTP ${response.status}`;
|
|
178
|
+
const code = payload && "code" in payload && payload.code ? payload.code : `HTTP_${response.status}`;
|
|
179
|
+
const error = new BaseServiceError(message, code, response.status);
|
|
180
|
+
if (!RETRYABLE.has(response.status) || attempt === retries)
|
|
181
|
+
throw error;
|
|
182
|
+
lastError = error;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (error instanceof BaseServiceError) {
|
|
186
|
+
if (attempt === retries)
|
|
187
|
+
throw error;
|
|
188
|
+
lastError = error;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
192
|
+
const normalized = new BaseServiceError(aborted ? "基础服务请求超时" : `基础服务不可达:${error instanceof Error ? error.message : String(error)}`, aborted ? "TIMEOUT" : "UNREACHABLE", 503);
|
|
193
|
+
if (attempt === retries)
|
|
194
|
+
throw normalized;
|
|
195
|
+
lastError = normalized;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
clearTimeout(timeout);
|
|
200
|
+
}
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 300 * 2 ** attempt));
|
|
202
|
+
}
|
|
203
|
+
throw lastError || new BaseServiceError("基础服务请求失败", "UNKNOWN", 500);
|
|
204
|
+
}
|
|
205
|
+
/** 探活。业务服务的 /api/health 用它报告依赖状态。 */
|
|
206
|
+
async ping() {
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch(`${this.baseUrl}/api/health`, { signal: AbortSignal.timeout(5000) });
|
|
209
|
+
return { ok: response.ok };
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 校验终端用户会话。业务服务的鉴权中间件用它。
|
|
217
|
+
* 结果建议缓存几十秒——不缓存的话每个业务请求都多一次内网往返。
|
|
218
|
+
*/
|
|
219
|
+
verifySession(token, personId, acting) {
|
|
220
|
+
return this.call("/internal/sessions/verify", {
|
|
221
|
+
method: "POST",
|
|
222
|
+
body: { token, personId },
|
|
223
|
+
acting,
|
|
224
|
+
retryable: true,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/** 查人。发工作协同前用它补齐 empId。 */
|
|
228
|
+
lookupUser(personId, acting) {
|
|
229
|
+
return this.call(`/internal/users/${encodeURIComponent(personId)}`, { acting, retryable: true });
|
|
230
|
+
}
|
|
231
|
+
/** 按姓名查工作协同员工(姓名完全一致;重名全部返回)。 */
|
|
232
|
+
searchEmployees(name, acting) {
|
|
233
|
+
return this.call(`/internal/employees?name=${encodeURIComponent(name)}`, { acting, retryable: true });
|
|
234
|
+
}
|
|
235
|
+
/** 平台上出现过的部门(来自登录过的人的 deptList),用于按部门授权。 */
|
|
236
|
+
listDepartments(acting) {
|
|
237
|
+
return this.call("/internal/departments", { acting, retryable: true });
|
|
238
|
+
}
|
|
239
|
+
sendCollaboration(input, acting) {
|
|
240
|
+
return this.call("/internal/collaborations", { method: "POST", body: input, acting });
|
|
241
|
+
}
|
|
242
|
+
sendTodo(input, acting) {
|
|
243
|
+
return this.call("/internal/todos", { method: "POST", body: input, acting });
|
|
244
|
+
}
|
|
245
|
+
revokeTodo(todoId, acting) {
|
|
246
|
+
return this.call(`/internal/todos/${encodeURIComponent(todoId)}/revoke`, { method: "POST", body: {}, acting });
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 把自己发的待办标为已办。已办过再调返回当前状态,不算错误。
|
|
250
|
+
* force=true:本地已记为已办的也再回写一次(代收件人回写时平台可能静默不生效,需要补办)。
|
|
251
|
+
*/
|
|
252
|
+
completeTodo(todoId, acting, options = {}) {
|
|
253
|
+
return this.call(`/internal/todos/${encodeURIComponent(todoId)}/complete`, { method: "POST", body: options.force ? { force: true } : {}, acting });
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 登记一把平台凭据(AppKey)。同 scope+label 再登记一次就是换 key。
|
|
257
|
+
* 明文只进不出:返回里没有 appKey,只有末四位。
|
|
258
|
+
*/
|
|
259
|
+
saveCredential(input, acting) {
|
|
260
|
+
return this.call("/internal/credentials", { method: "POST", body: input, acting });
|
|
261
|
+
}
|
|
262
|
+
listCredentials(scope, acting) {
|
|
263
|
+
return this.call(`/internal/credentials?scope=${encodeURIComponent(scope)}`, { acting, retryable: true });
|
|
264
|
+
}
|
|
265
|
+
updateCredential(id, input, acting) {
|
|
266
|
+
return this.call(`/internal/credentials/${encodeURIComponent(id)}`, { method: "PATCH", body: input, acting });
|
|
267
|
+
}
|
|
268
|
+
deleteCredential(id, acting) {
|
|
269
|
+
return this.call(`/internal/credentials/${encodeURIComponent(id)}`, { method: "DELETE", body: {}, acting });
|
|
270
|
+
}
|
|
271
|
+
/** 本服务的产品线 Token 状态。明文永远不回传,只有末四位。 */
|
|
272
|
+
jobLineToken(acting) {
|
|
273
|
+
return this.call("/internal/job-line-token", { acting, retryable: true });
|
|
274
|
+
}
|
|
275
|
+
/** 配置本服务的产品线 Token。每个业务一把,互不共用。 */
|
|
276
|
+
saveJobLineToken(input, acting) {
|
|
277
|
+
return this.call("/internal/job-line-token", { method: "PUT", body: input, acting });
|
|
278
|
+
}
|
|
279
|
+
deleteJobLineToken(acting) {
|
|
280
|
+
return this.call("/internal/job-line-token", { method: "DELETE", body: {}, acting });
|
|
281
|
+
}
|
|
282
|
+
/** 按工作协同汇报记录 ID 拉回详情(正文、回复、各节点处理意见)。只能拉本服务自己发出的。 */
|
|
283
|
+
getCollaborationDetail(recordId, acting, options = {}) {
|
|
284
|
+
return this.call(`/internal/collaborations/remote/${encodeURIComponent(recordId)}${options.includeAssociated ? "?includeAssociated=true" : ""}`, { acting, retryable: true });
|
|
285
|
+
}
|
|
286
|
+
createJob(input, acting) {
|
|
287
|
+
return this.call("/internal/agent-jobs", { method: "POST", body: input, acting });
|
|
288
|
+
}
|
|
289
|
+
/** 取消自己发的 Job。重复取消不算错误,会直接返回当前状态。 */
|
|
290
|
+
cancelJob(jobId, acting) {
|
|
291
|
+
return this.call(`/internal/agent-jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST", body: {}, acting });
|
|
292
|
+
}
|
|
293
|
+
/** 重试自己发出的 Job 失败子任务。 */
|
|
294
|
+
retryJob(jobId, acting, taskKeys) {
|
|
295
|
+
return this.call(`/internal/agent-jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST", body: taskKeys ? { taskKeys } : {}, acting });
|
|
296
|
+
}
|
|
297
|
+
getJob(jobId, acting) {
|
|
298
|
+
return this.call(`/internal/agent-jobs/${encodeURIComponent(jobId)}`, { acting, retryable: true });
|
|
299
|
+
}
|
|
300
|
+
/** 建对话任务(任务中心 mode=conversation):firstMessage 是第 1 轮,之后每条消息一轮,每轮结果转发到 notifyUrl。 */
|
|
301
|
+
createConversation(input, acting) {
|
|
302
|
+
return this.call("/internal/agent-conversations", { method: "POST", body: input, acting });
|
|
303
|
+
}
|
|
304
|
+
/** 发一条消息 = 一轮。带 businessId 时重复提交只算一轮。 */
|
|
305
|
+
sendConversationMessage(conversationId, input, acting) {
|
|
306
|
+
return this.call(`/internal/agent-conversations/${encodeURIComponent(conversationId)}/messages`, { method: "POST", body: input, acting });
|
|
307
|
+
}
|
|
308
|
+
getConversation(conversationId, acting) {
|
|
309
|
+
return this.call(`/internal/agent-conversations/${encodeURIComponent(conversationId)}`, { acting, retryable: true });
|
|
310
|
+
}
|
|
311
|
+
/** 回调之外的兜底:直接问任务中心各轮状态。 */
|
|
312
|
+
getConversationTurns(conversationId, acting, afterSeq) {
|
|
313
|
+
return this.call(`/internal/agent-conversations/${encodeURIComponent(conversationId)}/turns${afterSeq ? `?afterSeq=${afterSeq}` : ""}`, { acting, retryable: true });
|
|
314
|
+
}
|
|
315
|
+
/** 结束对话。重复结束不算错误。 */
|
|
316
|
+
closeConversation(conversationId, acting) {
|
|
317
|
+
return this.call(`/internal/agent-conversations/${encodeURIComponent(conversationId)}/close`, { method: "POST", body: {}, acting });
|
|
318
|
+
}
|
|
319
|
+
/** 把文本变成工作协同附件(只生成物理资源,不进知识库,不需要知识库配置)。附件最大 50MB,上传对象存储要时间,默认等 3 分钟。 */
|
|
320
|
+
uploadAttachmentText(input, acting, opts = {}) {
|
|
321
|
+
return this.call("/internal/attachments/text", { method: "POST", body: input, acting, timeoutMs: opts.timeoutMs ?? 180_000 });
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 以当前用户本人身份读取慧记会议的完整正文(逐段发言 + 整理纪要)。只读,不生成附件。
|
|
325
|
+
* 必须带 acting.accessToken:慧记是「我的数据」。一次最多 10 场。
|
|
326
|
+
*/
|
|
327
|
+
fetchHuiJiDetails(items, acting) {
|
|
328
|
+
const body = { items: items.map((item) => (typeof item === "string" ? { id: item } : item)) };
|
|
329
|
+
return this.call("/internal/sources/huiji/details", { method: "POST", body, acting, retryable: true });
|
|
330
|
+
}
|
|
331
|
+
uploadText(input, acting) {
|
|
332
|
+
return this.call("/internal/knowledge/text", { method: "POST", body: input, acting });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** 从环境变量装配客户端,避免每个业务服务重复写一遍。 */
|
|
336
|
+
export function createBaseClient(env, serviceName) {
|
|
337
|
+
return new BaseClient({
|
|
338
|
+
baseUrl: (env.BASE_SERVICE_URL || "http://127.0.0.1:4000").trim(),
|
|
339
|
+
serviceName,
|
|
340
|
+
serviceToken: (env.BASE_SERVICE_TOKEN || "").trim(),
|
|
341
|
+
timeoutMs: Number(env.BASE_SERVICE_TIMEOUT_MS) || 20000,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 业务服务的用户鉴权中间件工厂。
|
|
346
|
+
*
|
|
347
|
+
* 前端把 access-token + personId 传给业务服务,业务服务**不能**直接相信它们,
|
|
348
|
+
* 必须回基础服务验一次。这里带一个短 TTL 的内存缓存,避免每个请求都往返。
|
|
349
|
+
*/
|
|
350
|
+
export function createSessionVerifier(client, options = {}) {
|
|
351
|
+
const ttl = options.ttlMs ?? 30_000;
|
|
352
|
+
const cache = new Map();
|
|
353
|
+
return async function verify(token, personId) {
|
|
354
|
+
if (!token || !personId) {
|
|
355
|
+
throw new BaseServiceError("缺少 access-token 或 personId", "UNAUTHORIZED", 401);
|
|
356
|
+
}
|
|
357
|
+
const key = `${personId}:${token}`;
|
|
358
|
+
const hit = cache.get(key);
|
|
359
|
+
if (hit && hit.expiresAt > Date.now())
|
|
360
|
+
return hit.user;
|
|
361
|
+
// 校验时代表的就是这个人自己。
|
|
362
|
+
const user = await client.verifySession(token, personId, { personId }).catch((error) => {
|
|
363
|
+
// 把"验不过"和"验不了"分开:
|
|
364
|
+
// 基础服务明确拒绝(401/403/404)→ 会话无效,前端该重新登录;
|
|
365
|
+
// 基础服务不可达或 5xx → 依赖故障,不能误导用户去重新登录。
|
|
366
|
+
if (error instanceof BaseServiceError && [401, 403, 404].includes(error.status)) {
|
|
367
|
+
throw new BaseServiceError("登录已失效,请重新登录", "UNAUTHORIZED", 401);
|
|
368
|
+
}
|
|
369
|
+
throw error;
|
|
370
|
+
});
|
|
371
|
+
cache.set(key, { user, expiresAt: Date.now() + ttl });
|
|
372
|
+
// 顺手清理过期项,避免长时间运行后缓存无限增长。
|
|
373
|
+
if (cache.size > 1000) {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
for (const [cacheKey, value] of cache)
|
|
376
|
+
if (value.expiresAt <= now)
|
|
377
|
+
cache.delete(cacheKey);
|
|
378
|
+
}
|
|
379
|
+
return user;
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
export { requireUser, actor } from "./express.js";
|
|
383
|
+
export { uploadToStorage, StorageUploadError } from "./storage-upload.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { BaseClient, BaseServiceError, createBaseClient, createSessionVerifier } from "./index.js";
|
|
4
|
+
/** 用一个可控的 fetch 替身驱动客户端,不碰网络。 */
|
|
5
|
+
function stubFetch(handler) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const original = globalThis.fetch;
|
|
8
|
+
globalThis.fetch = (async (input, init = {}) => {
|
|
9
|
+
const url = String(input);
|
|
10
|
+
calls.push({ url, init });
|
|
11
|
+
const { status, body } = handler(url, init);
|
|
12
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
13
|
+
});
|
|
14
|
+
return { calls, restore: () => { globalThis.fetch = original; } };
|
|
15
|
+
}
|
|
16
|
+
const client = () => new BaseClient({
|
|
17
|
+
baseUrl: "http://base.test",
|
|
18
|
+
serviceName: "demo",
|
|
19
|
+
serviceToken: "tok-1",
|
|
20
|
+
});
|
|
21
|
+
test("完整协同详情选项仅在启用时透传到 Base", async () => {
|
|
22
|
+
const stub = stubFetch(() => ({ status: 200, body: { success: true, data: {} } }));
|
|
23
|
+
try {
|
|
24
|
+
await client().getCollaborationDetail("2110996699949559811", { personId: "11809" }, { includeAssociated: true });
|
|
25
|
+
await client().getCollaborationDetail("2110996699949559811", { personId: "11809" });
|
|
26
|
+
assert.ok(stub.calls[0].url.endsWith("2110996699949559811?includeAssociated=true"));
|
|
27
|
+
assert.ok(stub.calls[1].url.endsWith("2110996699949559811"));
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
stub.restore();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
test("每个请求都带服务令牌、服务名和代理用户", async () => {
|
|
34
|
+
const stub = stubFetch(() => ({ status: 200, body: { success: true, data: { personId: "1" } } }));
|
|
35
|
+
try {
|
|
36
|
+
await client().lookupUser("11809", { personId: "11809" });
|
|
37
|
+
const headers = stub.calls[0].init.headers;
|
|
38
|
+
assert.equal(headers["X-Service-Token"], "tok-1");
|
|
39
|
+
assert.equal(headers["X-Service-Name"], "demo");
|
|
40
|
+
assert.equal(headers["X-Acting-PersonId"], "11809");
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
stub.restore();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
test("缺少 actingPersonId 时直接拒绝,不发请求", async () => {
|
|
47
|
+
const stub = stubFetch(() => ({ status: 200, body: { success: true, data: {} } }));
|
|
48
|
+
try {
|
|
49
|
+
// 平台侧的发起人必须是真实的人,不能有"系统身份"默认值。
|
|
50
|
+
await assert.rejects(() => client().lookupUser("1", { personId: "" }), /actingPersonId/);
|
|
51
|
+
assert.equal(stub.calls.length, 0);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
stub.restore();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
test("基础服务的业务错误会带上 code 和 status", async () => {
|
|
58
|
+
const stub = stubFetch(() => ({ status: 409, body: { success: false, message: "附件尚未同步", code: "CONFLICT" } }));
|
|
59
|
+
try {
|
|
60
|
+
await assert.rejects(() => client().sendCollaboration({
|
|
61
|
+
title: "t", content: "c", recipients: [], idempotencyKey: "k",
|
|
62
|
+
origin: { service: "demo", entityType: "X", entityId: "1" },
|
|
63
|
+
}, { personId: "11809" }), (error) => {
|
|
64
|
+
assert.ok(error instanceof BaseServiceError);
|
|
65
|
+
assert.equal(error.code, "CONFLICT");
|
|
66
|
+
assert.equal(error.status, 409);
|
|
67
|
+
assert.match(error.message, /附件尚未同步/);
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
stub.restore();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
test("写操作不重试(幂等键由基础服务兜底),读操作才重试", async () => {
|
|
76
|
+
let writes = 0;
|
|
77
|
+
let reads = 0;
|
|
78
|
+
const stub = stubFetch((url, init) => {
|
|
79
|
+
if (init.method === "POST") {
|
|
80
|
+
writes += 1;
|
|
81
|
+
return { status: 503, body: { success: false, message: "down" } };
|
|
82
|
+
}
|
|
83
|
+
reads += 1;
|
|
84
|
+
return { status: 503, body: { success: false, message: "down" } };
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await assert.rejects(() => client().sendTodo({
|
|
88
|
+
title: "t", content: "c", recipients: [], refUrl: "https://x.test", idempotencyKey: "k",
|
|
89
|
+
origin: { service: "demo", entityType: "X", entityId: "1" },
|
|
90
|
+
}, { personId: "11809" }));
|
|
91
|
+
// 写只发一次:重试可能造成重复送达。
|
|
92
|
+
assert.equal(writes, 1);
|
|
93
|
+
await assert.rejects(() => client().lookupUser("1", { personId: "11809" }));
|
|
94
|
+
// 读会退避重试。
|
|
95
|
+
assert.ok(reads > 1, `读操作应重试,实际只发了 ${reads} 次`);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
stub.restore();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
test("基础服务不可达时给出可辨认的错误", async () => {
|
|
102
|
+
const original = globalThis.fetch;
|
|
103
|
+
globalThis.fetch = (async () => { throw new Error("ECONNREFUSED"); });
|
|
104
|
+
try {
|
|
105
|
+
await assert.rejects(() => client().lookupUser("1", { personId: "11809" }), (error) => {
|
|
106
|
+
assert.ok(error instanceof BaseServiceError);
|
|
107
|
+
assert.equal(error.code, "UNREACHABLE");
|
|
108
|
+
return true;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
globalThis.fetch = original;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
test("会话校验结果会缓存,不会每个请求都往返", async () => {
|
|
116
|
+
let hits = 0;
|
|
117
|
+
const stub = stubFetch(() => {
|
|
118
|
+
hits += 1;
|
|
119
|
+
return { status: 200, body: { success: true, data: { personId: "11809", userId: "u1", empId: "e1", userName: "张三", corpId: null } } };
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
const verify = createSessionVerifier(client(), { ttlMs: 10_000 });
|
|
123
|
+
await verify("tok", "11809");
|
|
124
|
+
await verify("tok", "11809");
|
|
125
|
+
assert.equal(hits, 1, "第二次应命中缓存");
|
|
126
|
+
// 换个 token 必须重新校验,否则退出登录后旧 token 还能用。
|
|
127
|
+
await verify("other", "11809");
|
|
128
|
+
assert.equal(hits, 2);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
stub.restore();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test("缓存过期后重新校验", async () => {
|
|
135
|
+
let hits = 0;
|
|
136
|
+
const stub = stubFetch(() => {
|
|
137
|
+
hits += 1;
|
|
138
|
+
return { status: 200, body: { success: true, data: { personId: "1", userId: "u", empId: null, userName: "n", corpId: null } } };
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const verify = createSessionVerifier(client(), { ttlMs: 1 });
|
|
142
|
+
await verify("tok", "1");
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
144
|
+
await verify("tok", "1");
|
|
145
|
+
assert.equal(hits, 2);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
stub.restore();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
test("缺 token 或 personId 时不发请求", async () => {
|
|
152
|
+
const stub = stubFetch(() => ({ status: 200, body: { success: true, data: {} } }));
|
|
153
|
+
try {
|
|
154
|
+
const verify = createSessionVerifier(client());
|
|
155
|
+
await assert.rejects(() => verify("", "1"), /access-token/);
|
|
156
|
+
await assert.rejects(() => verify("t", ""), /personId/);
|
|
157
|
+
assert.equal(stub.calls.length, 0);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
stub.restore();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
test("通知令牌校验:空令牌一律拒绝", () => {
|
|
164
|
+
// 期望值为空时必须拒绝,不能退化成"谁都可以"。
|
|
165
|
+
});
|
|
166
|
+
test("createBaseClient 从环境变量装配", () => {
|
|
167
|
+
const built = createBaseClient({ BASE_SERVICE_URL: "http://x.test", BASE_SERVICE_TOKEN: "t" }, "legal");
|
|
168
|
+
assert.equal(built.configured, true);
|
|
169
|
+
const missing = createBaseClient({}, "legal");
|
|
170
|
+
assert.equal(missing.configured, false);
|
|
171
|
+
});
|
|
172
|
+
test("会话验不过时统一报 401,而不是把 404 透给前端", async () => {
|
|
173
|
+
const stub = stubFetch(() => ({ status: 404, body: { success: false, message: "用户不存在", code: "NOT_FOUND" } }));
|
|
174
|
+
try {
|
|
175
|
+
const verify = createSessionVerifier(client());
|
|
176
|
+
await assert.rejects(() => verify("tok", "999999"), (error) => {
|
|
177
|
+
assert.ok(error instanceof BaseServiceError);
|
|
178
|
+
assert.equal(error.status, 401);
|
|
179
|
+
assert.match(error.message, /重新登录/);
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
stub.restore();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
test("基础服务故障不能被误报成「登录失效」", async () => {
|
|
188
|
+
const original = globalThis.fetch;
|
|
189
|
+
globalThis.fetch = (async () => { throw new Error("ECONNREFUSED"); });
|
|
190
|
+
try {
|
|
191
|
+
const verify = createSessionVerifier(client());
|
|
192
|
+
await assert.rejects(() => verify("tok", "11809"), (error) => {
|
|
193
|
+
assert.ok(error instanceof BaseServiceError);
|
|
194
|
+
// 依赖挂了就该说依赖挂了,让用户去重新登录只会白折腾。
|
|
195
|
+
assert.equal(error.code, "UNREACHABLE");
|
|
196
|
+
assert.notEqual(error.status, 401);
|
|
197
|
+
return true;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
globalThis.fetch = original;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
test("语音请求的签名校验:对的才过;改了通话 id、过期、缺头、令牌不对都不过", async () => {
|
|
205
|
+
const { createHmac } = await import("node:crypto");
|
|
206
|
+
const { verifyVoiceSignature } = await import("./index.js");
|
|
207
|
+
const now = 1_760_000_000_000;
|
|
208
|
+
const sign = (token, timestamp, callId) => createHmac("sha256", token).update(`${timestamp}.${callId}`).digest("hex");
|
|
209
|
+
const headers = { "x-base-timestamp": String(now), "x-base-signature": sign("tok", now, "call_1") };
|
|
210
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_1", serviceToken: "tok", now }), true);
|
|
211
|
+
// Express 给的头是小写;别的框架可能保留原样大小写。
|
|
212
|
+
assert.equal(verifyVoiceSignature({ headers: { "X-Base-Timestamp": String(now), "X-Base-Signature": sign("tok", now, "call_1") }, callId: "call_1", serviceToken: "tok", now }), true);
|
|
213
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_2", serviceToken: "tok", now }), false, "签的不是这一通");
|
|
214
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_1", serviceToken: "other", now }), false);
|
|
215
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_1", serviceToken: "tok", now: now + 6 * 60_000 }), false, "超过 5 分钟不认,防重放");
|
|
216
|
+
assert.equal(verifyVoiceSignature({ headers: {}, callId: "call_1", serviceToken: "tok", now }), false);
|
|
217
|
+
assert.equal(verifyVoiceSignature({ headers: { ...headers, "x-base-signature": "zz" }, callId: "call_1", serviceToken: "tok", now }), false);
|
|
218
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_1", serviceToken: "", now }), false, "自己没配令牌时不能放行");
|
|
219
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { StorageUploadTicket } from "@xgspz/contracts";
|
|
2
|
+
/**
|
|
3
|
+
* 拿到基础服务签发的上传令牌后,直接传到对象存储(七牛),文件不经过基础服务中转:
|
|
4
|
+
* - 16MB 以内:表单上传;
|
|
5
|
+
* - 更大:分片上传 v2(initiate → 逐片 PUT → complete),每片失败重试 3 次。
|
|
6
|
+
* 令牌只对一个确定的 key 有效且只能新建:同一个 key 已存在(614)视为成功(key 按内容命名)。
|
|
7
|
+
*/
|
|
8
|
+
export declare class StorageUploadError extends Error {
|
|
9
|
+
readonly status: number;
|
|
10
|
+
constructor(message: string, status: number);
|
|
11
|
+
}
|
|
12
|
+
export declare function uploadToStorage(ticket: Pick<StorageUploadTicket, "key" | "token" | "uploadUrl">, source: {
|
|
13
|
+
file: string;
|
|
14
|
+
} | {
|
|
15
|
+
bytes: Buffer;
|
|
16
|
+
}, opts: {
|
|
17
|
+
contentType: string;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
partSize?: number;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
key: string;
|
|
22
|
+
existed: boolean;
|
|
23
|
+
size: number;
|
|
24
|
+
}>;
|
|
25
|
+
//# sourceMappingURL=storage-upload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage-upload.d.ts","sourceRoot":"","sources":["../src/storage-upload.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE5D;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IACd,QAAQ,CAAC,MAAM,EAAE,MAAM;gBAAxC,OAAO,EAAE,MAAM,EAAW,MAAM,EAAE,MAAM;CAIrD;AAqBD,wBAAsB,eAAe,CACnC,MAAM,EAAE,IAAI,CAAC,mBAAmB,EAAE,KAAK,GAAG,OAAO,GAAG,WAAW,CAAC,EAChE,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,EAC5C,IAAI,EAAE;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GACrE,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CA6D1D"}
|