@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/src/index.ts
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { currentRequestId, REQUEST_ID_HEADER } from "@xgspz/service-kit";
|
|
3
|
+
import type {
|
|
4
|
+
ApiResponse,
|
|
5
|
+
HuiJiDetailResult,
|
|
6
|
+
InternalCollaborationRequest,
|
|
7
|
+
InternalCollaborationDetail,
|
|
8
|
+
InternalEmployee,
|
|
9
|
+
InternalCredential,
|
|
10
|
+
InternalCredentialRequest,
|
|
11
|
+
InternalCollaborationResult,
|
|
12
|
+
InternalJobRequest,
|
|
13
|
+
InternalConversationRequest,
|
|
14
|
+
InternalConversationResult,
|
|
15
|
+
InternalConversationMessageRequest,
|
|
16
|
+
InternalConversationTurn,
|
|
17
|
+
InternalJobResult,
|
|
18
|
+
InternalOrigin,
|
|
19
|
+
InternalTodoRequest,
|
|
20
|
+
InternalTodoResult,
|
|
21
|
+
InternalUploadResult,
|
|
22
|
+
InternalUploadTextRequest,
|
|
23
|
+
InternalAttachmentTextRequest,
|
|
24
|
+
InternalAttachmentResult,
|
|
25
|
+
InternalUserLookup,
|
|
26
|
+
InternalJobLineToken,
|
|
27
|
+
InternalDepartment,
|
|
28
|
+
} from "@xgspz/contracts";
|
|
29
|
+
import type {
|
|
30
|
+
MediaImageRequest,
|
|
31
|
+
MediaImageResult,
|
|
32
|
+
MediaQuota,
|
|
33
|
+
MediaVisionRequest,
|
|
34
|
+
MediaVisionResult,
|
|
35
|
+
StorageFetchResult,
|
|
36
|
+
StorageStat,
|
|
37
|
+
StorageUploadTicket,
|
|
38
|
+
} from "@xgspz/contracts";
|
|
39
|
+
import type {
|
|
40
|
+
InternalVoiceCall,
|
|
41
|
+
InternalVoiceCallBatchRequest,
|
|
42
|
+
InternalVoiceCallBatchResult,
|
|
43
|
+
InternalVoiceCallRequest,
|
|
44
|
+
InternalVoiceInboundRoute,
|
|
45
|
+
InternalVoiceInboundRouteRequest,
|
|
46
|
+
VoiceConfigView,
|
|
47
|
+
VoiceQueueStatus,
|
|
48
|
+
} from "@xgspz/contracts";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 基础服务客户端。业务服务只依赖这个包,不直接认识平台接口。
|
|
52
|
+
*
|
|
53
|
+
* 设计要点:
|
|
54
|
+
* - 每次调用都要显式声明 `actingPersonId`(代表谁操作)。不提供"系统身份"
|
|
55
|
+
* 的默认值,因为平台侧的发起人必须是真实的人,审计也要能追到责任人。
|
|
56
|
+
* - 写操作强制 `idempotencyKey`:网络重试、业务重投都不会造成重复送达。
|
|
57
|
+
* - 只对**读**和网络层错误重试;写操作交给基础服务的幂等键兜底。
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
export class BaseServiceError extends Error {
|
|
61
|
+
constructor(
|
|
62
|
+
message: string,
|
|
63
|
+
readonly code: string,
|
|
64
|
+
readonly status: number,
|
|
65
|
+
) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "BaseServiceError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BaseClientOptions {
|
|
72
|
+
/** 基础服务地址,如 http://xg-base:4000。 */
|
|
73
|
+
baseUrl: string;
|
|
74
|
+
/** 本服务名,必须与基础服务 SERVICE_TOKENS 里登记的一致。 */
|
|
75
|
+
serviceName: string;
|
|
76
|
+
/** 本服务的服务令牌。 */
|
|
77
|
+
serviceToken: string;
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
/** 只作用于读操作。 */
|
|
80
|
+
retries?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ActingUser {
|
|
84
|
+
/** 平台 personId。该用户必须在基础服务中存在(至少登录过一次)。 */
|
|
85
|
+
personId: string;
|
|
86
|
+
/** 当前登录用户调用第三方平台时使用的 access-token。 */
|
|
87
|
+
accessToken?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 校验基础服务发来的语音请求(turnUrl 的每一轮、notifyUrl 的通话结果)确实出自基础服务、说的确实是这一通电话。
|
|
94
|
+
* 基础服务用**本服务的服务令牌**对「时间戳.通话 id」做 HMAC-SHA256,放在 X-Base-Timestamp / X-Base-Signature 两个头里。
|
|
95
|
+
* callId 取请求体里的:每一轮是 `body.callId`,通话结果是 `body.call.id`。时间戳超过容差(默认 5 分钟)的一律不认,防重放。
|
|
96
|
+
*/
|
|
97
|
+
export function verifyVoiceSignature(input: {
|
|
98
|
+
headers: Record<string, string | string[] | undefined>;
|
|
99
|
+
callId: string;
|
|
100
|
+
serviceToken: string;
|
|
101
|
+
toleranceMs?: number;
|
|
102
|
+
now?: number;
|
|
103
|
+
}) {
|
|
104
|
+
const header = (name: string) => {
|
|
105
|
+
const value = input.headers[name] ?? input.headers[name.toLowerCase()];
|
|
106
|
+
return String(Array.isArray(value) ? value[0] ?? "" : value ?? "").trim();
|
|
107
|
+
};
|
|
108
|
+
const timestamp = header("X-Base-Timestamp");
|
|
109
|
+
const signature = header("X-Base-Signature");
|
|
110
|
+
if (!input.serviceToken || !input.callId || !/^\d{10,16}$/.test(timestamp) || !/^[0-9a-f]{64}$/i.test(signature)) return false;
|
|
111
|
+
if (Math.abs((input.now ?? Date.now()) - Number(timestamp)) > (input.toleranceMs ?? 5 * 60_000)) return false;
|
|
112
|
+
const expected = createHmac("sha256", input.serviceToken).update(`${timestamp}.${input.callId}`).digest();
|
|
113
|
+
return timingSafeEqual(expected, Buffer.from(signature, "hex"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class BaseClient {
|
|
117
|
+
private readonly baseUrl: string;
|
|
118
|
+
private readonly timeoutMs: number;
|
|
119
|
+
private readonly retries: number;
|
|
120
|
+
|
|
121
|
+
constructor(private readonly options: BaseClientOptions) {
|
|
122
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
123
|
+
this.timeoutMs = options.timeoutMs ?? 20000;
|
|
124
|
+
this.retries = options.retries ?? 2;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get configured() {
|
|
128
|
+
return Boolean(this.baseUrl && this.options.serviceToken);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 音频生成不自动重试;TTS 调用可能计费。调用方应设置足够的 timeoutMs。 */
|
|
132
|
+
generateAudio(input: { text: string; provider?: "doubao" | "minimax"; voiceId?: string; speed?: number }, acting: ActingUser) {
|
|
133
|
+
return this.call<{ provider: string; voiceId: string; audioUrl: string }>("/internal/audio/generate", { method: "POST", body: input, acting });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 生图:基础服务先扣额度,主模型失败换备用(审核拦截不换),结果立即转存对象存储。
|
|
138
|
+
* 可能要几分钟;不自动重试(每次调用都计次数),重试请带同一个 idempotencyKey。
|
|
139
|
+
*/
|
|
140
|
+
generateImage(input: MediaImageRequest, acting: ActingUser, opts: { timeoutMs?: number } = {}) {
|
|
141
|
+
return this.call<MediaImageResult>("/internal/media/images", { method: "POST", body: input, acting, timeoutMs: opts.timeoutMs ?? 400_000 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 看图:多张图 + 提示词,可要求按 JSON 回答。不自动重试(计次数)。 */
|
|
145
|
+
analyzeImages(input: MediaVisionRequest, acting: ActingUser, opts: { timeoutMs?: number } = {}) {
|
|
146
|
+
return this.call<MediaVisionResult>("/internal/media/vision", { method: "POST", body: input, acting, timeoutMs: opts.timeoutMs ?? 420_000 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 当天的生图 / 看图额度。 */
|
|
150
|
+
mediaQuota(acting: ActingUser) {
|
|
151
|
+
return this.call<MediaQuota>("/internal/media/quota", { acting, retryable: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 对象存储上传令牌:只对 svc/<本服务>/<keySuffix> 这一个 key 有效、只能新建。配合 uploadToStorage 直传。 */
|
|
155
|
+
storageUploadToken(input: { keySuffix: string; maxBytes?: number }, acting: ActingUser) {
|
|
156
|
+
return this.call<StorageUploadTicket>("/internal/storage/upload-token", { method: "POST", body: input, acting, retryable: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 让基础服务代为下载一个地址并存进对象存储(例如会失效的外部链接)。 */
|
|
160
|
+
fetchIntoStorage(input: { url: string; keySuffix?: string }, acting: ActingUser) {
|
|
161
|
+
return this.call<StorageFetchResult>("/internal/storage/fetch", { method: "POST", body: input, acting, timeoutMs: 120_000 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 本服务对象的短期签名下载链接(不要入库)。 */
|
|
165
|
+
signStorageUrls(input: { keys: string[]; expiresSec?: number; downloadName?: string }, acting: ActingUser) {
|
|
166
|
+
return this.call<{ urls: Record<string, string> }>("/internal/storage/sign", { method: "POST", body: input, acting, retryable: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
statStorage(key: string, acting: ActingUser) {
|
|
170
|
+
return this.call<StorageStat>("/internal/storage/stat", { method: "POST", body: { key }, acting, retryable: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 在自己的 turnUrl / notifyUrl 处理函数里调:确认请求出自基础服务。
|
|
175
|
+
* 每一轮传 `body.callId`,通话结果传 `body.call.id`。返回 false 就回 401,不要处理。
|
|
176
|
+
*/
|
|
177
|
+
verifyVoiceRequest(headers: Record<string, string | string[] | undefined>, callId: string) {
|
|
178
|
+
return verifyVoiceSignature({ headers, callId, serviceToken: this.options.serviceToken });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 语音外呼是否已配置好(不含任何密钥和回调地址)。 */
|
|
182
|
+
voiceConfig(acting: ActingUser) {
|
|
183
|
+
return this.call<VoiceConfigView>("/internal/voice/config", { acting, retryable: true });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 提交一通外呼。**只是排队**:基础服务按并发上限和拨号间隔一通一通拨出,所以返回的状态通常是 QUEUED,
|
|
188
|
+
* 结果通过 notifyUrl 送回(或用 getVoiceCall 查)。接通后先说 greeting,之后对方每说一句,
|
|
189
|
+
* 基础服务把文字 POST 到 turnUrl,由业务回下一句;不传 turnUrl 用内置示例对话。
|
|
190
|
+
* 重试请带同一个 idempotencyKey:已经排上或拨出的不会多打一通。
|
|
191
|
+
*/
|
|
192
|
+
startVoiceCall(input: InternalVoiceCallRequest, acting: ActingUser) {
|
|
193
|
+
return this.call<InternalVoiceCall>("/internal/voice/calls", { method: "POST", body: input, acting });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 批量提交(一次最多 500 通,再多就分几次调)。每一通各自校验、各自幂等,结果逐条返回;
|
|
198
|
+
* 一通号码写错不影响其它的。上千人的调研也是这样全部交进来排队,拨出的节奏由基础服务控制。
|
|
199
|
+
*/
|
|
200
|
+
startVoiceCalls(input: InternalVoiceCallBatchRequest, acting: ActingUser) {
|
|
201
|
+
return this.call<InternalVoiceCallBatchResult>("/internal/voice/calls/batch", { method: "POST", body: input, acting, timeoutMs: 120_000 });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 取消某个业务对象名下所有还在排队的外呼(比如调研被叫停);不传 entityId 就是这一类全部。已拨出的不受影响。 */
|
|
205
|
+
cancelQueuedVoiceCalls(input: { entityType: string; entityId?: string }, acting: ActingUser) {
|
|
206
|
+
return this.call<{ cancelled: number }>("/internal/voice/calls/cancel", { method: "POST", body: input, acting });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 外呼队列现状:排队多少、正在打多少、并发上限、是否在外呼时段、是否被暂停。 */
|
|
210
|
+
voiceQueue(acting: ActingUser) {
|
|
211
|
+
return this.call<VoiceQueueStatus>("/internal/voice/queue", { acting, retryable: true });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 通话状态和全程文字记录。只能查本服务发起(或归本服务接听)的通话。 */
|
|
215
|
+
getVoiceCall(callId: string, acting: ActingUser) {
|
|
216
|
+
return this.call<InternalVoiceCall>(`/internal/voice/calls/${encodeURIComponent(callId)}`, { acting, retryable: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 不想打了:还在排队的直接取消;已拨出的在对方下一次说话或沉默时挂断(平台没有即时挂断接口)。 */
|
|
220
|
+
hangupVoiceCall(callId: string, acting: ActingUser) {
|
|
221
|
+
return this.call<InternalVoiceCall>(`/internal/voice/calls/${encodeURIComponent(callId)}/hangup`, { method: "POST", body: {}, acting });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 本服务登记的呼入路由(哪些号码的来电归本服务接)。 */
|
|
225
|
+
listVoiceInboundRoutes(acting: ActingUser) {
|
|
226
|
+
return this.call<InternalVoiceInboundRoute[]>("/internal/voice/inbound-routes", { acting, retryable: true });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 登记或更新一条呼入路由。号码已登记给别的服务时返回 409。 */
|
|
230
|
+
saveVoiceInboundRoute(input: InternalVoiceInboundRouteRequest, acting: ActingUser) {
|
|
231
|
+
return this.call<InternalVoiceInboundRoute>("/internal/voice/inbound-routes", { method: "PUT", body: input, acting });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
deleteVoiceInboundRoute(number: string, acting: ActingUser) {
|
|
235
|
+
return this.call<{ deleted: boolean }>(`/internal/voice/inbound-routes/${encodeURIComponent(number)}`, { method: "DELETE", body: {}, acting });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private headers(acting: ActingUser) {
|
|
239
|
+
if (!this.options.serviceToken) throw new BaseServiceError("未配置 BASE_SERVICE_TOKEN", "CONFIG_REQUIRED", 503);
|
|
240
|
+
if (!acting?.personId) throw new BaseServiceError("缺少 actingPersonId:必须声明代表谁操作", "BAD_REQUEST", 400);
|
|
241
|
+
const requestId = currentRequestId();
|
|
242
|
+
return {
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
"X-Service-Token": this.options.serviceToken,
|
|
245
|
+
"X-Service-Name": this.options.serviceName,
|
|
246
|
+
"X-Acting-PersonId": acting.personId,
|
|
247
|
+
...(acting.accessToken ? { "X-Acting-Access-Token": acting.accessToken } : {}),
|
|
248
|
+
// 把当前请求的追踪 ID 带给基础服务。没有它,一次用户操作在
|
|
249
|
+
// 业务服务和基础服务的日志里就是两条对不上的记录。
|
|
250
|
+
...(requestId ? { [REQUEST_ID_HEADER]: requestId } : {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async call<T>(
|
|
255
|
+
path: string,
|
|
256
|
+
init: { method?: string; body?: unknown; acting: ActingUser; retryable?: boolean; timeoutMs?: number },
|
|
257
|
+
): Promise<T> {
|
|
258
|
+
const retries = init.retryable ? this.retries : 0;
|
|
259
|
+
let lastError: BaseServiceError | undefined;
|
|
260
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? this.timeoutMs);
|
|
263
|
+
try {
|
|
264
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
265
|
+
method: init.method || "GET",
|
|
266
|
+
headers: this.headers(init.acting),
|
|
267
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
268
|
+
signal: controller.signal,
|
|
269
|
+
});
|
|
270
|
+
const payload = (await response.json().catch(() => null)) as ApiResponse<T> | null;
|
|
271
|
+
if (response.ok && payload?.success === true) return payload.data;
|
|
272
|
+
const message = payload && "message" in payload ? payload.message : `基础服务返回 HTTP ${response.status}`;
|
|
273
|
+
const code = payload && "code" in payload && payload.code ? payload.code : `HTTP_${response.status}`;
|
|
274
|
+
const error = new BaseServiceError(message, code, response.status);
|
|
275
|
+
if (!RETRYABLE.has(response.status) || attempt === retries) throw error;
|
|
276
|
+
lastError = error;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error instanceof BaseServiceError) {
|
|
279
|
+
if (attempt === retries) throw error;
|
|
280
|
+
lastError = error;
|
|
281
|
+
} else {
|
|
282
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
283
|
+
const normalized = new BaseServiceError(
|
|
284
|
+
aborted ? "基础服务请求超时" : `基础服务不可达:${error instanceof Error ? error.message : String(error)}`,
|
|
285
|
+
aborted ? "TIMEOUT" : "UNREACHABLE",
|
|
286
|
+
503,
|
|
287
|
+
);
|
|
288
|
+
if (attempt === retries) throw normalized;
|
|
289
|
+
lastError = normalized;
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
clearTimeout(timeout);
|
|
293
|
+
}
|
|
294
|
+
await new Promise((resolve) => setTimeout(resolve, 300 * 2 ** attempt));
|
|
295
|
+
}
|
|
296
|
+
throw lastError || new BaseServiceError("基础服务请求失败", "UNKNOWN", 500);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 探活。业务服务的 /api/health 用它报告依赖状态。 */
|
|
300
|
+
async ping(): Promise<{ ok: boolean; message?: string }> {
|
|
301
|
+
try {
|
|
302
|
+
const response = await fetch(`${this.baseUrl}/api/health`, { signal: AbortSignal.timeout(5000) });
|
|
303
|
+
return { ok: response.ok };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 校验终端用户会话。业务服务的鉴权中间件用它。
|
|
311
|
+
* 结果建议缓存几十秒——不缓存的话每个业务请求都多一次内网往返。
|
|
312
|
+
*/
|
|
313
|
+
verifySession(token: string, personId: string, acting: ActingUser) {
|
|
314
|
+
return this.call<InternalUserLookup>("/internal/sessions/verify", {
|
|
315
|
+
method: "POST",
|
|
316
|
+
body: { token, personId },
|
|
317
|
+
acting,
|
|
318
|
+
retryable: true,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 查人。发工作协同前用它补齐 empId。 */
|
|
323
|
+
lookupUser(personId: string, acting: ActingUser) {
|
|
324
|
+
return this.call<InternalUserLookup>(`/internal/users/${encodeURIComponent(personId)}`, { acting, retryable: true });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 按姓名查工作协同员工(姓名完全一致;重名全部返回)。 */
|
|
328
|
+
searchEmployees(name: string, acting: ActingUser) {
|
|
329
|
+
return this.call<InternalEmployee[]>(`/internal/employees?name=${encodeURIComponent(name)}`, { acting, retryable: true });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** 平台上出现过的部门(来自登录过的人的 deptList),用于按部门授权。 */
|
|
333
|
+
listDepartments(acting: ActingUser) {
|
|
334
|
+
return this.call<InternalDepartment[]>("/internal/departments", { acting, retryable: true });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
sendCollaboration(input: Omit<InternalCollaborationRequest, "origin"> & { origin: InternalOrigin }, acting: ActingUser) {
|
|
338
|
+
return this.call<InternalCollaborationResult>("/internal/collaborations", { method: "POST", body: input, acting });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
sendTodo(input: InternalTodoRequest, acting: ActingUser) {
|
|
342
|
+
return this.call<InternalTodoResult>("/internal/todos", { method: "POST", body: input, acting });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
revokeTodo(todoId: string, acting: ActingUser) {
|
|
346
|
+
return this.call<InternalTodoResult>(`/internal/todos/${encodeURIComponent(todoId)}/revoke`, { method: "POST", body: {}, acting });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 把自己发的待办标为已办。已办过再调返回当前状态,不算错误。
|
|
351
|
+
* force=true:本地已记为已办的也再回写一次(代收件人回写时平台可能静默不生效,需要补办)。
|
|
352
|
+
*/
|
|
353
|
+
completeTodo(todoId: string, acting: ActingUser, options: { force?: boolean } = {}) {
|
|
354
|
+
return this.call<InternalTodoResult>(`/internal/todos/${encodeURIComponent(todoId)}/complete`, { method: "POST", body: options.force ? { force: true } : {}, acting });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 登记一把平台凭据(AppKey)。同 scope+label 再登记一次就是换 key。
|
|
359
|
+
* 明文只进不出:返回里没有 appKey,只有末四位。
|
|
360
|
+
*/
|
|
361
|
+
saveCredential(input: InternalCredentialRequest, acting: ActingUser) {
|
|
362
|
+
return this.call<InternalCredential>("/internal/credentials", { method: "POST", body: input, acting });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
listCredentials(scope: string, acting: ActingUser) {
|
|
366
|
+
return this.call<InternalCredential[]>(`/internal/credentials?scope=${encodeURIComponent(scope)}`, { acting, retryable: true });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
updateCredential(id: string, input: { label?: string; role?: string; usage?: string; enabled?: boolean }, acting: ActingUser) {
|
|
370
|
+
return this.call<InternalCredential>(`/internal/credentials/${encodeURIComponent(id)}`, { method: "PATCH", body: input, acting });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
deleteCredential(id: string, acting: ActingUser) {
|
|
374
|
+
return this.call<{ deleted: boolean }>(`/internal/credentials/${encodeURIComponent(id)}`, { method: "DELETE", body: {}, acting });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 本服务的产品线 Token 状态。明文永远不回传,只有末四位。 */
|
|
378
|
+
jobLineToken(acting: ActingUser) {
|
|
379
|
+
return this.call<InternalJobLineToken>("/internal/job-line-token", { acting, retryable: true });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 配置本服务的产品线 Token。每个业务一把,互不共用。 */
|
|
383
|
+
saveJobLineToken(input: { token: string; label?: string }, acting: ActingUser) {
|
|
384
|
+
return this.call<InternalJobLineToken>("/internal/job-line-token", { method: "PUT", body: input, acting });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
deleteJobLineToken(acting: ActingUser) {
|
|
388
|
+
return this.call<InternalJobLineToken>("/internal/job-line-token", { method: "DELETE", body: {}, acting });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 按工作协同汇报记录 ID 拉回详情(正文、回复、各节点处理意见)。只能拉本服务自己发出的。 */
|
|
392
|
+
getCollaborationDetail(recordId: string, acting: ActingUser, options: { includeAssociated?: boolean } = {}) {
|
|
393
|
+
return this.call<InternalCollaborationDetail>(`/internal/collaborations/remote/${encodeURIComponent(recordId)}${options.includeAssociated ? "?includeAssociated=true" : ""}`, { acting, retryable: true });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
createJob(input: InternalJobRequest, acting: ActingUser) {
|
|
397
|
+
return this.call<InternalJobResult>("/internal/agent-jobs", { method: "POST", body: input, acting });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 取消自己发的 Job。重复取消不算错误,会直接返回当前状态。 */
|
|
401
|
+
cancelJob(jobId: string, acting: ActingUser) {
|
|
402
|
+
return this.call<InternalJobResult>(`/internal/agent-jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST", body: {}, acting });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** 重试自己发出的 Job 失败子任务。 */
|
|
406
|
+
retryJob(jobId: string, acting: ActingUser, taskKeys?: string[]) {
|
|
407
|
+
return this.call<InternalJobResult>(`/internal/agent-jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST", body: taskKeys ? { taskKeys } : {}, acting });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
getJob(jobId: string, acting: ActingUser) {
|
|
411
|
+
return this.call<InternalJobResult & { tasks: Array<{ taskKey: string; status: string; errorMessage: string | null }> }>(
|
|
412
|
+
`/internal/agent-jobs/${encodeURIComponent(jobId)}`,
|
|
413
|
+
{ acting, retryable: true },
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** 建对话任务(任务中心 mode=conversation):firstMessage 是第 1 轮,之后每条消息一轮,每轮结果转发到 notifyUrl。 */
|
|
418
|
+
createConversation(input: InternalConversationRequest, acting: ActingUser) {
|
|
419
|
+
return this.call<InternalConversationResult>("/internal/agent-conversations", { method: "POST", body: input, acting });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 发一条消息 = 一轮。带 businessId 时重复提交只算一轮。 */
|
|
423
|
+
sendConversationMessage(conversationId: string, input: InternalConversationMessageRequest, acting: ActingUser) {
|
|
424
|
+
return this.call<InternalConversationResult>(`/internal/agent-conversations/${encodeURIComponent(conversationId)}/messages`, { method: "POST", body: input, acting });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
getConversation(conversationId: string, acting: ActingUser) {
|
|
428
|
+
return this.call<InternalConversationResult>(`/internal/agent-conversations/${encodeURIComponent(conversationId)}`, { acting, retryable: true });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 回调之外的兜底:直接问任务中心各轮状态。 */
|
|
432
|
+
getConversationTurns(conversationId: string, acting: ActingUser, afterSeq?: number) {
|
|
433
|
+
return this.call<{ externalJobId?: string; turns: InternalConversationTurn[] }>(
|
|
434
|
+
`/internal/agent-conversations/${encodeURIComponent(conversationId)}/turns${afterSeq ? `?afterSeq=${afterSeq}` : ""}`,
|
|
435
|
+
{ acting, retryable: true },
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** 结束对话。重复结束不算错误。 */
|
|
440
|
+
closeConversation(conversationId: string, acting: ActingUser) {
|
|
441
|
+
return this.call<InternalConversationResult>(`/internal/agent-conversations/${encodeURIComponent(conversationId)}/close`, { method: "POST", body: {}, acting });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** 把文本变成工作协同附件(只生成物理资源,不进知识库,不需要知识库配置)。附件最大 50MB,上传对象存储要时间,默认等 3 分钟。 */
|
|
445
|
+
uploadAttachmentText(input: InternalAttachmentTextRequest, acting: ActingUser, opts: { timeoutMs?: number } = {}) {
|
|
446
|
+
return this.call<InternalAttachmentResult>("/internal/attachments/text", { method: "POST", body: input, acting, timeoutMs: opts.timeoutMs ?? 180_000 });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 以当前用户本人身份读取慧记会议的完整正文(逐段发言 + 整理纪要)。只读,不生成附件。
|
|
451
|
+
* 必须带 acting.accessToken:慧记是「我的数据」。一次最多 10 场。
|
|
452
|
+
*/
|
|
453
|
+
fetchHuiJiDetails(items: Array<string | { id: string; title?: string }>, acting: ActingUser) {
|
|
454
|
+
const body = { items: items.map((item) => (typeof item === "string" ? { id: item } : item)) };
|
|
455
|
+
return this.call<HuiJiDetailResult>("/internal/sources/huiji/details", { method: "POST", body, acting, retryable: true });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
uploadText(input: InternalUploadTextRequest, acting: ActingUser) {
|
|
459
|
+
return this.call<InternalUploadResult>("/internal/knowledge/text", { method: "POST", body: input, acting });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 从环境变量装配客户端,避免每个业务服务重复写一遍。 */
|
|
464
|
+
export function createBaseClient(env: NodeJS.ProcessEnv, serviceName: string) {
|
|
465
|
+
return new BaseClient({
|
|
466
|
+
baseUrl: (env.BASE_SERVICE_URL || "http://127.0.0.1:4000").trim(),
|
|
467
|
+
serviceName,
|
|
468
|
+
serviceToken: (env.BASE_SERVICE_TOKEN || "").trim(),
|
|
469
|
+
timeoutMs: Number(env.BASE_SERVICE_TIMEOUT_MS) || 20000,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export type { InternalJobNotification, InternalOrigin, InternalConversationNotification, InternalConversationResult, InternalConversationTurn } from "@xgspz/contracts";
|
|
474
|
+
export type {
|
|
475
|
+
InternalVoiceCall,
|
|
476
|
+
InternalVoiceCallBatchRequest,
|
|
477
|
+
InternalVoiceCallBatchResult,
|
|
478
|
+
InternalVoiceCallNotification,
|
|
479
|
+
InternalVoiceCallRequest,
|
|
480
|
+
VoiceCallOutcome,
|
|
481
|
+
VoiceQueueStatus,
|
|
482
|
+
InternalVoiceInboundRoute,
|
|
483
|
+
InternalVoiceInboundRouteRequest,
|
|
484
|
+
InternalVoiceTurnReply,
|
|
485
|
+
InternalVoiceTurnRequest,
|
|
486
|
+
} from "@xgspz/contracts";
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 业务服务的用户鉴权中间件工厂。
|
|
491
|
+
*
|
|
492
|
+
* 前端把 access-token + personId 传给业务服务,业务服务**不能**直接相信它们,
|
|
493
|
+
* 必须回基础服务验一次。这里带一个短 TTL 的内存缓存,避免每个请求都往返。
|
|
494
|
+
*/
|
|
495
|
+
export function createSessionVerifier(client: BaseClient, options: { ttlMs?: number } = {}) {
|
|
496
|
+
const ttl = options.ttlMs ?? 30_000;
|
|
497
|
+
const cache = new Map<string, { user: InternalUserLookup; expiresAt: number }>();
|
|
498
|
+
|
|
499
|
+
return async function verify(token: string, personId: string): Promise<InternalUserLookup> {
|
|
500
|
+
if (!token || !personId) {
|
|
501
|
+
throw new BaseServiceError("缺少 access-token 或 personId", "UNAUTHORIZED", 401);
|
|
502
|
+
}
|
|
503
|
+
const key = `${personId}:${token}`;
|
|
504
|
+
const hit = cache.get(key);
|
|
505
|
+
if (hit && hit.expiresAt > Date.now()) return hit.user;
|
|
506
|
+
// 校验时代表的就是这个人自己。
|
|
507
|
+
const user = await client.verifySession(token, personId, { personId }).catch((error: unknown) => {
|
|
508
|
+
// 把"验不过"和"验不了"分开:
|
|
509
|
+
// 基础服务明确拒绝(401/403/404)→ 会话无效,前端该重新登录;
|
|
510
|
+
// 基础服务不可达或 5xx → 依赖故障,不能误导用户去重新登录。
|
|
511
|
+
if (error instanceof BaseServiceError && [401, 403, 404].includes(error.status)) {
|
|
512
|
+
throw new BaseServiceError("登录已失效,请重新登录", "UNAUTHORIZED", 401);
|
|
513
|
+
}
|
|
514
|
+
throw error;
|
|
515
|
+
});
|
|
516
|
+
cache.set(key, { user, expiresAt: Date.now() + ttl });
|
|
517
|
+
// 顺手清理过期项,避免长时间运行后缓存无限增长。
|
|
518
|
+
if (cache.size > 1000) {
|
|
519
|
+
const now = Date.now();
|
|
520
|
+
for (const [cacheKey, value] of cache) if (value.expiresAt <= now) cache.delete(cacheKey);
|
|
521
|
+
}
|
|
522
|
+
return user;
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
export { requireUser, actor, type AuthenticatedUser, type AuthRequest } from "./express.js";
|
|
528
|
+
|
|
529
|
+
export { uploadToStorage, StorageUploadError } from "./storage-upload.js";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { BaseClient, uploadToStorage } from "./index.js";
|
|
7
|
+
|
|
8
|
+
const policy = (scope: string) => Buffer.from(JSON.stringify({ scope, deadline: 1, insertOnly: 1 })).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
|
|
9
|
+
const ticket = { key: "svc/notex/exports/a.mp4", token: `AK:SIGN:${policy("bkt:svc/notex/exports/a.mp4")}`, uploadUrl: "https://up.test" };
|
|
10
|
+
|
|
11
|
+
function stub(handler: (url: string, init: RequestInit) => Response) {
|
|
12
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
13
|
+
const original = globalThis.fetch;
|
|
14
|
+
globalThis.fetch = (async (input: string | URL | Request, init: RequestInit = {}) => {
|
|
15
|
+
calls.push({ url: String(input), init });
|
|
16
|
+
return handler(String(input), init);
|
|
17
|
+
}) as typeof fetch;
|
|
18
|
+
return { calls, restore: () => { globalThis.fetch = original; } };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("小文件表单上传;key 已存在(614)视为成功", async () => {
|
|
22
|
+
const s = stub(() => new Response(JSON.stringify({ key: ticket.key }), { status: 200 }));
|
|
23
|
+
try {
|
|
24
|
+
const r = await uploadToStorage(ticket, { bytes: Buffer.from("hello") }, { contentType: "text/plain" });
|
|
25
|
+
assert.deepEqual(r, { key: ticket.key, existed: false, size: 5 });
|
|
26
|
+
const form = s.calls[0]!.init.body as FormData;
|
|
27
|
+
assert.equal(s.calls[0]!.url, "https://up.test");
|
|
28
|
+
assert.equal(form.get("key"), ticket.key);
|
|
29
|
+
assert.equal(form.get("token"), ticket.token);
|
|
30
|
+
} finally { s.restore(); }
|
|
31
|
+
// 七牛用非标准的 614 表示「已存在」:真实网络响应可以带这个码,构造的 Response 不行,这里用一个等价的对象。
|
|
32
|
+
const exists = { status: 614, ok: false, json: async () => ({ error: "file exists" }), text: async () => "{\"error\":\"file exists\"}" } as unknown as Response;
|
|
33
|
+
const again = stub(() => exists);
|
|
34
|
+
try {
|
|
35
|
+
assert.equal((await uploadToStorage(ticket, { bytes: Buffer.from("hello") }, { contentType: "text/plain" })).existed, true);
|
|
36
|
+
} finally { again.restore(); }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("大文件分片上传 v2:initiate → 逐片 PUT(失败重试)→ complete,分片顺序和 etag 正确", async () => {
|
|
40
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-upload-"));
|
|
41
|
+
const file = path.join(dir, "big.bin");
|
|
42
|
+
fs.writeFileSync(file, Buffer.alloc(17 * 1024 * 1024 + 5, 7));
|
|
43
|
+
let flaky = true;
|
|
44
|
+
const s = stub((url, init) => {
|
|
45
|
+
if (url.endsWith("/uploads") && init.method === "POST") return new Response(JSON.stringify({ uploadId: "U1" }));
|
|
46
|
+
if (init.method === "PUT") {
|
|
47
|
+
if (url.endsWith("/2") && flaky) { flaky = false; return new Response("busy", { status: 503 }); }
|
|
48
|
+
return new Response(JSON.stringify({ etag: `e${url.split("/").pop()}` }));
|
|
49
|
+
}
|
|
50
|
+
return new Response(JSON.stringify({ key: ticket.key, hash: "h" }));
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
const r = await uploadToStorage(ticket, { file }, { contentType: "video/mp4", partSize: 8 * 1024 * 1024 });
|
|
54
|
+
assert.equal(r.size, 17 * 1024 * 1024 + 5);
|
|
55
|
+
const base = `https://up.test/buckets/bkt/objects/${Buffer.from(ticket.key).toString("base64").replace(/\+/g, "-").replace(/\//g, "_")}/uploads`;
|
|
56
|
+
assert.equal(s.calls[0]!.url, base);
|
|
57
|
+
assert.equal((s.calls[0]!.init.headers as Record<string, string>).Authorization, `UpToken ${ticket.token}`);
|
|
58
|
+
const puts = s.calls.filter((c) => c.init.method === "PUT").map((c) => c.url.split("/").pop());
|
|
59
|
+
assert.deepEqual(puts, ["1", "2", "2", "3"], "第 2 片失败后重试");
|
|
60
|
+
const done = JSON.parse(String(s.calls[s.calls.length - 1]!.init.body));
|
|
61
|
+
assert.deepEqual(done.parts, [{ partNumber: 1, etag: "e1" }, { partNumber: 2, etag: "e2" }, { partNumber: 3, etag: "e3" }]);
|
|
62
|
+
assert.equal(done.mimeType, "video/mp4");
|
|
63
|
+
} finally { s.restore(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("生图 / 看图用单次调用的长超时,不自动重试;存储签名可重试", async () => {
|
|
67
|
+
const client = new BaseClient({ baseUrl: "http://base.test", serviceName: "notex", serviceToken: "tok", timeoutMs: 1 });
|
|
68
|
+
let calls = 0;
|
|
69
|
+
const s = stub(() => { calls += 1; return new Response(JSON.stringify({ success: true, data: { callId: "c", model: "10006" } }), { status: 200 }); });
|
|
70
|
+
try {
|
|
71
|
+
// 客户端默认超时只有 1ms;生图接口用自己的长超时,所以不会被中止。
|
|
72
|
+
const r = await client.generateImage({ prompt: "画" }, { personId: "p1" });
|
|
73
|
+
assert.equal(r.callId, "c");
|
|
74
|
+
assert.equal(s.calls[0]!.url, "http://base.test/internal/media/images");
|
|
75
|
+
} finally { s.restore(); }
|
|
76
|
+
const fail = stub(() => { calls += 1; return new Response(JSON.stringify({ success: false, code: "MEDIA_UPSTREAM", message: "忙" }), { status: 502 }); });
|
|
77
|
+
try {
|
|
78
|
+
calls = 0;
|
|
79
|
+
await assert.rejects(client.analyzeImages({ prompt: "看", images: ["https://x/y.png"] }, { personId: "p1" }, { timeoutMs: 5000 }), /忙/);
|
|
80
|
+
assert.equal(calls, 1, "看图不自动重试(计次数)");
|
|
81
|
+
} finally { fail.restore(); }
|
|
82
|
+
});
|