@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* 拿到基础服务签发的上传令牌后,直接传到对象存储(七牛),文件不经过基础服务中转:
|
|
4
|
+
* - 16MB 以内:表单上传;
|
|
5
|
+
* - 更大:分片上传 v2(initiate → 逐片 PUT → complete),每片失败重试 3 次。
|
|
6
|
+
* 令牌只对一个确定的 key 有效且只能新建:同一个 key 已存在(614)视为成功(key 按内容命名)。
|
|
7
|
+
*/
|
|
8
|
+
export class StorageUploadError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.name = "StorageUploadError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const FORM_LIMIT = 16 * 1024 * 1024;
|
|
17
|
+
const DEFAULT_PART = 8 * 1024 * 1024;
|
|
18
|
+
const b64url = (s) => Buffer.from(s).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
|
|
19
|
+
/** 上传令牌里带着 putPolicy(scope = bucket:key),分片上传的地址要用到 bucket。 */
|
|
20
|
+
function bucketOf(token) {
|
|
21
|
+
const encoded = token.split(":")[2] ?? "";
|
|
22
|
+
const policy = JSON.parse(Buffer.from(encoded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
23
|
+
const bucket = String(policy.scope ?? "").split(":")[0];
|
|
24
|
+
if (!bucket)
|
|
25
|
+
throw new StorageUploadError("上传令牌里没有 bucket", 400);
|
|
26
|
+
return bucket;
|
|
27
|
+
}
|
|
28
|
+
async function ok(res, what) {
|
|
29
|
+
if (res.ok)
|
|
30
|
+
return res.json().catch(() => ({}));
|
|
31
|
+
const text = await res.text().catch(() => "");
|
|
32
|
+
throw new StorageUploadError(`${what}失败(HTTP ${res.status}):${text.slice(0, 200)}`, res.status);
|
|
33
|
+
}
|
|
34
|
+
export async function uploadToStorage(ticket, source, opts) {
|
|
35
|
+
const size = "bytes" in source ? source.bytes.length : fs.statSync(source.file).size;
|
|
36
|
+
if (size <= FORM_LIMIT) {
|
|
37
|
+
const bytes = "bytes" in source ? source.bytes : await fs.promises.readFile(source.file);
|
|
38
|
+
const form = new FormData();
|
|
39
|
+
form.set("token", ticket.token);
|
|
40
|
+
form.set("key", ticket.key);
|
|
41
|
+
form.set("file", new Blob([new Uint8Array(bytes)], { type: opts.contentType }), ticket.key.split("/").pop() || "file");
|
|
42
|
+
const res = await fetch(ticket.uploadUrl, { method: "POST", body: form, signal: opts.signal });
|
|
43
|
+
if (res.status === 614)
|
|
44
|
+
return { key: ticket.key, existed: true, size };
|
|
45
|
+
await ok(res, "上传");
|
|
46
|
+
return { key: ticket.key, existed: false, size };
|
|
47
|
+
}
|
|
48
|
+
const partSize = Math.max(1024 * 1024, opts.partSize ?? DEFAULT_PART);
|
|
49
|
+
const base = `${ticket.uploadUrl}/buckets/${encodeURIComponent(bucketOf(ticket.token))}/objects/${b64url(ticket.key)}/uploads`;
|
|
50
|
+
const auth = { Authorization: `UpToken ${ticket.token}` };
|
|
51
|
+
const init = await fetch(base, { method: "POST", headers: auth, signal: opts.signal });
|
|
52
|
+
if (init.status === 614)
|
|
53
|
+
return { key: ticket.key, existed: true, size };
|
|
54
|
+
const { uploadId } = await ok(init, "创建分片上传");
|
|
55
|
+
if (!uploadId)
|
|
56
|
+
throw new StorageUploadError("创建分片上传没有返回 uploadId", 502);
|
|
57
|
+
const handle = "file" in source ? await fs.promises.open(source.file, "r") : null;
|
|
58
|
+
const parts = [];
|
|
59
|
+
try {
|
|
60
|
+
for (let offset = 0, partNumber = 1; offset < size; offset += partSize, partNumber += 1) {
|
|
61
|
+
const length = Math.min(partSize, size - offset);
|
|
62
|
+
let chunk;
|
|
63
|
+
if (handle) {
|
|
64
|
+
chunk = Buffer.alloc(length);
|
|
65
|
+
await handle.read(chunk, 0, length, offset);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
chunk = source.bytes.subarray(offset, offset + length);
|
|
69
|
+
}
|
|
70
|
+
let etag = "";
|
|
71
|
+
for (let attempt = 1; attempt <= 3 && !etag; attempt += 1) {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${base}/${uploadId}/${partNumber}`, {
|
|
74
|
+
method: "PUT", headers: { ...auth, "Content-Type": "application/octet-stream" }, body: new Uint8Array(chunk), signal: opts.signal,
|
|
75
|
+
});
|
|
76
|
+
etag = String((await ok(res, `上传第 ${partNumber} 片`)).etag ?? "");
|
|
77
|
+
if (!etag)
|
|
78
|
+
throw new StorageUploadError(`第 ${partNumber} 片没有返回 etag`, 502);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (attempt === 3 || opts.signal?.aborted)
|
|
82
|
+
throw error;
|
|
83
|
+
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
parts.push({ partNumber, etag });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await handle?.close();
|
|
91
|
+
}
|
|
92
|
+
const done = await fetch(`${base}/${uploadId}`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ parts, mimeType: opts.contentType }),
|
|
96
|
+
signal: opts.signal,
|
|
97
|
+
});
|
|
98
|
+
if (done.status === 614)
|
|
99
|
+
return { key: ticket.key, existed: true, size };
|
|
100
|
+
await ok(done, "完成分片上传");
|
|
101
|
+
return { key: ticket.key, existed: false, size };
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage-upload.test.d.ts","sourceRoot":"","sources":["../src/storage-upload.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
const policy = (scope) => Buffer.from(JSON.stringify({ scope, deadline: 1, insertOnly: 1 })).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
|
|
8
|
+
const ticket = { key: "svc/notex/exports/a.mp4", token: `AK:SIGN:${policy("bkt:svc/notex/exports/a.mp4")}`, uploadUrl: "https://up.test" };
|
|
9
|
+
function stub(handler) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
const original = globalThis.fetch;
|
|
12
|
+
globalThis.fetch = (async (input, init = {}) => {
|
|
13
|
+
calls.push({ url: String(input), init });
|
|
14
|
+
return handler(String(input), init);
|
|
15
|
+
});
|
|
16
|
+
return { calls, restore: () => { globalThis.fetch = original; } };
|
|
17
|
+
}
|
|
18
|
+
test("小文件表单上传;key 已存在(614)视为成功", async () => {
|
|
19
|
+
const s = stub(() => new Response(JSON.stringify({ key: ticket.key }), { status: 200 }));
|
|
20
|
+
try {
|
|
21
|
+
const r = await uploadToStorage(ticket, { bytes: Buffer.from("hello") }, { contentType: "text/plain" });
|
|
22
|
+
assert.deepEqual(r, { key: ticket.key, existed: false, size: 5 });
|
|
23
|
+
const form = s.calls[0].init.body;
|
|
24
|
+
assert.equal(s.calls[0].url, "https://up.test");
|
|
25
|
+
assert.equal(form.get("key"), ticket.key);
|
|
26
|
+
assert.equal(form.get("token"), ticket.token);
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
s.restore();
|
|
30
|
+
}
|
|
31
|
+
// 七牛用非标准的 614 表示「已存在」:真实网络响应可以带这个码,构造的 Response 不行,这里用一个等价的对象。
|
|
32
|
+
const exists = { status: 614, ok: false, json: async () => ({ error: "file exists" }), text: async () => "{\"error\":\"file exists\"}" };
|
|
33
|
+
const again = stub(() => exists);
|
|
34
|
+
try {
|
|
35
|
+
assert.equal((await uploadToStorage(ticket, { bytes: Buffer.from("hello") }, { contentType: "text/plain" })).existed, true);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
again.restore();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
test("大文件分片上传 v2:initiate → 逐片 PUT(失败重试)→ complete,分片顺序和 etag 正确", async () => {
|
|
42
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-upload-"));
|
|
43
|
+
const file = path.join(dir, "big.bin");
|
|
44
|
+
fs.writeFileSync(file, Buffer.alloc(17 * 1024 * 1024 + 5, 7));
|
|
45
|
+
let flaky = true;
|
|
46
|
+
const s = stub((url, init) => {
|
|
47
|
+
if (url.endsWith("/uploads") && init.method === "POST")
|
|
48
|
+
return new Response(JSON.stringify({ uploadId: "U1" }));
|
|
49
|
+
if (init.method === "PUT") {
|
|
50
|
+
if (url.endsWith("/2") && flaky) {
|
|
51
|
+
flaky = false;
|
|
52
|
+
return new Response("busy", { status: 503 });
|
|
53
|
+
}
|
|
54
|
+
return new Response(JSON.stringify({ etag: `e${url.split("/").pop()}` }));
|
|
55
|
+
}
|
|
56
|
+
return new Response(JSON.stringify({ key: ticket.key, hash: "h" }));
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
const r = await uploadToStorage(ticket, { file }, { contentType: "video/mp4", partSize: 8 * 1024 * 1024 });
|
|
60
|
+
assert.equal(r.size, 17 * 1024 * 1024 + 5);
|
|
61
|
+
const base = `https://up.test/buckets/bkt/objects/${Buffer.from(ticket.key).toString("base64").replace(/\+/g, "-").replace(/\//g, "_")}/uploads`;
|
|
62
|
+
assert.equal(s.calls[0].url, base);
|
|
63
|
+
assert.equal(s.calls[0].init.headers.Authorization, `UpToken ${ticket.token}`);
|
|
64
|
+
const puts = s.calls.filter((c) => c.init.method === "PUT").map((c) => c.url.split("/").pop());
|
|
65
|
+
assert.deepEqual(puts, ["1", "2", "2", "3"], "第 2 片失败后重试");
|
|
66
|
+
const done = JSON.parse(String(s.calls[s.calls.length - 1].init.body));
|
|
67
|
+
assert.deepEqual(done.parts, [{ partNumber: 1, etag: "e1" }, { partNumber: 2, etag: "e2" }, { partNumber: 3, etag: "e3" }]);
|
|
68
|
+
assert.equal(done.mimeType, "video/mp4");
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
s.restore();
|
|
72
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
test("生图 / 看图用单次调用的长超时,不自动重试;存储签名可重试", async () => {
|
|
76
|
+
const client = new BaseClient({ baseUrl: "http://base.test", serviceName: "notex", serviceToken: "tok", timeoutMs: 1 });
|
|
77
|
+
let calls = 0;
|
|
78
|
+
const s = stub(() => { calls += 1; return new Response(JSON.stringify({ success: true, data: { callId: "c", model: "10006" } }), { status: 200 }); });
|
|
79
|
+
try {
|
|
80
|
+
// 客户端默认超时只有 1ms;生图接口用自己的长超时,所以不会被中止。
|
|
81
|
+
const r = await client.generateImage({ prompt: "画" }, { personId: "p1" });
|
|
82
|
+
assert.equal(r.callId, "c");
|
|
83
|
+
assert.equal(s.calls[0].url, "http://base.test/internal/media/images");
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
s.restore();
|
|
87
|
+
}
|
|
88
|
+
const fail = stub(() => { calls += 1; return new Response(JSON.stringify({ success: false, code: "MEDIA_UPSTREAM", message: "忙" }), { status: 502 }); });
|
|
89
|
+
try {
|
|
90
|
+
calls = 0;
|
|
91
|
+
await assert.rejects(client.analyzeImages({ prompt: "看", images: ["https://x/y.png"] }, { personId: "p1" }, { timeoutMs: 5000 }), /忙/);
|
|
92
|
+
assert.equal(calls, 1, "看图不自动重试(计次数)");
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
fail.restore();
|
|
96
|
+
}
|
|
97
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xgspz/base-sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "业务服务调用基础服务的客户端。封装服务令牌、代理用户、幂等与重试。",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
11
|
+
"test": "tsx --test --test-reporter=spec \"src/**/*.test.ts\""
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@xgspz/contracts": "^1.0.1",
|
|
15
|
+
"@xgspz/service-kit": "^1.0.1"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.10.5",
|
|
19
|
+
"typescript": "^5.7.3",
|
|
20
|
+
"tsx": "^4.19.2",
|
|
21
|
+
"@types/express": "^5.0.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"express": "^4.21.2"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/express.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from "express";
|
|
2
|
+
import { apiError } from "@xgspz/service-kit";
|
|
3
|
+
import type { InternalUserLookup } from "@xgspz/contracts";
|
|
4
|
+
|
|
5
|
+
/** 通过基础服务校验后的终端用户。与 ActingUser(只含 personId)不是一回事。 */
|
|
6
|
+
export interface AuthenticatedUser {
|
|
7
|
+
accessToken: string;
|
|
8
|
+
personId: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
empId: string | null;
|
|
11
|
+
userName: string;
|
|
12
|
+
/** 平台部门。业务服务的准入控制按它判断,没有部门时是空数组。 */
|
|
13
|
+
departments: Array<{ id: string; name: string; isMain: boolean }>;
|
|
14
|
+
/** Base「用户与服务权限」里这个人在本服务上的角色,没授权(或 Base 是旧版)为 null。 */
|
|
15
|
+
serviceRole: NonNullable<InternalUserLookup["serviceRole"]> | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AuthRequest = Request & { actor?: AuthenticatedUser };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 业务服务的用户鉴权中间件。
|
|
22
|
+
*
|
|
23
|
+
* 业务服务不自己发会话,也不能直接相信前端传来的头,一律回基础服务校验。
|
|
24
|
+
* verifier 内部有短 TTL 缓存,不会每个请求都往返。
|
|
25
|
+
*/
|
|
26
|
+
export function requireUser(verify: (token: string, personId: string) => Promise<InternalUserLookup>) {
|
|
27
|
+
return async function middleware(req: Request, _res: Response, next: NextFunction) {
|
|
28
|
+
try {
|
|
29
|
+
const token = String(req.header("access-token") || req.header("authorization")?.replace(/^Bearer\s+/i, "") || "").trim();
|
|
30
|
+
const personId = String(req.header("personId") || req.header("person-id") || "").trim();
|
|
31
|
+
if (!token || !personId) throw apiError("缺少 access-token 或 personId", "UNAUTHORIZED");
|
|
32
|
+
const user = await verify(token, personId);
|
|
33
|
+
(req as AuthRequest).actor = {
|
|
34
|
+
accessToken: token,
|
|
35
|
+
personId: user.personId,
|
|
36
|
+
userId: user.userId,
|
|
37
|
+
empId: user.empId,
|
|
38
|
+
userName: user.userName,
|
|
39
|
+
departments: user.departments ?? [],
|
|
40
|
+
serviceRole: user.serviceRole ?? null,
|
|
41
|
+
};
|
|
42
|
+
next();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
next(error);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function actor(req: Request): AuthenticatedUser {
|
|
50
|
+
const value = (req as AuthRequest).actor;
|
|
51
|
+
if (!value) throw apiError("未登录", "UNAUTHORIZED");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { BaseClient, BaseServiceError, createBaseClient, createSessionVerifier } from "./index.js";
|
|
4
|
+
|
|
5
|
+
/** 用一个可控的 fetch 替身驱动客户端,不碰网络。 */
|
|
6
|
+
function stubFetch(handler: (url: string, init: RequestInit) => { status: number; body: unknown }) {
|
|
7
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
8
|
+
const original = globalThis.fetch;
|
|
9
|
+
globalThis.fetch = (async (input: string | URL | Request, init: RequestInit = {}) => {
|
|
10
|
+
const url = String(input);
|
|
11
|
+
calls.push({ url, init });
|
|
12
|
+
const { status, body } = handler(url, init);
|
|
13
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
14
|
+
}) as typeof fetch;
|
|
15
|
+
return { calls, restore: () => { globalThis.fetch = original; } };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const client = () => new BaseClient({
|
|
19
|
+
baseUrl: "http://base.test",
|
|
20
|
+
serviceName: "demo",
|
|
21
|
+
serviceToken: "tok-1",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("完整协同详情选项仅在启用时透传到 Base", async () => {
|
|
25
|
+
const stub = stubFetch(() => ({ status: 200, body: { success: true, data: {} } }));
|
|
26
|
+
try {
|
|
27
|
+
await client().getCollaborationDetail("2110996699949559811", { personId: "11809" }, { includeAssociated: true });
|
|
28
|
+
await client().getCollaborationDetail("2110996699949559811", { personId: "11809" });
|
|
29
|
+
assert.ok(stub.calls[0]!.url.endsWith("2110996699949559811?includeAssociated=true"));
|
|
30
|
+
assert.ok(stub.calls[1]!.url.endsWith("2110996699949559811"));
|
|
31
|
+
} finally { stub.restore(); }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("每个请求都带服务令牌、服务名和代理用户", async () => {
|
|
35
|
+
const stub = stubFetch(() => ({ status: 200, body: { success: true, data: { personId: "1" } } }));
|
|
36
|
+
try {
|
|
37
|
+
await client().lookupUser("11809", { personId: "11809" });
|
|
38
|
+
const headers = stub.calls[0].init.headers as Record<string, string>;
|
|
39
|
+
assert.equal(headers["X-Service-Token"], "tok-1");
|
|
40
|
+
assert.equal(headers["X-Service-Name"], "demo");
|
|
41
|
+
assert.equal(headers["X-Acting-PersonId"], "11809");
|
|
42
|
+
} finally {
|
|
43
|
+
stub.restore();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("缺少 actingPersonId 时直接拒绝,不发请求", async () => {
|
|
48
|
+
const stub = stubFetch(() => ({ status: 200, body: { success: true, data: {} } }));
|
|
49
|
+
try {
|
|
50
|
+
// 平台侧的发起人必须是真实的人,不能有"系统身份"默认值。
|
|
51
|
+
await assert.rejects(() => client().lookupUser("1", { personId: "" }), /actingPersonId/);
|
|
52
|
+
assert.equal(stub.calls.length, 0);
|
|
53
|
+
} finally {
|
|
54
|
+
stub.restore();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("基础服务的业务错误会带上 code 和 status", async () => {
|
|
59
|
+
const stub = stubFetch(() => ({ status: 409, body: { success: false, message: "附件尚未同步", code: "CONFLICT" } }));
|
|
60
|
+
try {
|
|
61
|
+
await assert.rejects(
|
|
62
|
+
() => client().sendCollaboration({
|
|
63
|
+
title: "t", content: "c", recipients: [], idempotencyKey: "k",
|
|
64
|
+
origin: { service: "demo", entityType: "X", entityId: "1" },
|
|
65
|
+
}, { personId: "11809" }),
|
|
66
|
+
(error: unknown) => {
|
|
67
|
+
assert.ok(error instanceof BaseServiceError);
|
|
68
|
+
assert.equal(error.code, "CONFLICT");
|
|
69
|
+
assert.equal(error.status, 409);
|
|
70
|
+
assert.match(error.message, /附件尚未同步/);
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
} finally {
|
|
75
|
+
stub.restore();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("写操作不重试(幂等键由基础服务兜底),读操作才重试", async () => {
|
|
80
|
+
let writes = 0;
|
|
81
|
+
let reads = 0;
|
|
82
|
+
const stub = stubFetch((url, init) => {
|
|
83
|
+
if (init.method === "POST") { writes += 1; return { status: 503, body: { success: false, message: "down" } }; }
|
|
84
|
+
reads += 1;
|
|
85
|
+
return { status: 503, body: { success: false, message: "down" } };
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
await assert.rejects(() => client().sendTodo({
|
|
89
|
+
title: "t", content: "c", recipients: [], refUrl: "https://x.test", idempotencyKey: "k",
|
|
90
|
+
origin: { service: "demo", entityType: "X", entityId: "1" },
|
|
91
|
+
}, { personId: "11809" }));
|
|
92
|
+
// 写只发一次:重试可能造成重复送达。
|
|
93
|
+
assert.equal(writes, 1);
|
|
94
|
+
await assert.rejects(() => client().lookupUser("1", { personId: "11809" }));
|
|
95
|
+
// 读会退避重试。
|
|
96
|
+
assert.ok(reads > 1, `读操作应重试,实际只发了 ${reads} 次`);
|
|
97
|
+
} finally {
|
|
98
|
+
stub.restore();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("基础服务不可达时给出可辨认的错误", async () => {
|
|
103
|
+
const original = globalThis.fetch;
|
|
104
|
+
globalThis.fetch = (async () => { throw new Error("ECONNREFUSED"); }) as typeof fetch;
|
|
105
|
+
try {
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
() => client().lookupUser("1", { personId: "11809" }),
|
|
108
|
+
(error: unknown) => {
|
|
109
|
+
assert.ok(error instanceof BaseServiceError);
|
|
110
|
+
assert.equal(error.code, "UNREACHABLE");
|
|
111
|
+
return true;
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
} finally {
|
|
115
|
+
globalThis.fetch = original;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("会话校验结果会缓存,不会每个请求都往返", async () => {
|
|
120
|
+
let hits = 0;
|
|
121
|
+
const stub = stubFetch(() => {
|
|
122
|
+
hits += 1;
|
|
123
|
+
return { status: 200, body: { success: true, data: { personId: "11809", userId: "u1", empId: "e1", userName: "张三", corpId: null } } };
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
const verify = createSessionVerifier(client(), { ttlMs: 10_000 });
|
|
127
|
+
await verify("tok", "11809");
|
|
128
|
+
await verify("tok", "11809");
|
|
129
|
+
assert.equal(hits, 1, "第二次应命中缓存");
|
|
130
|
+
// 换个 token 必须重新校验,否则退出登录后旧 token 还能用。
|
|
131
|
+
await verify("other", "11809");
|
|
132
|
+
assert.equal(hits, 2);
|
|
133
|
+
} finally {
|
|
134
|
+
stub.restore();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("缓存过期后重新校验", async () => {
|
|
139
|
+
let hits = 0;
|
|
140
|
+
const stub = stubFetch(() => {
|
|
141
|
+
hits += 1;
|
|
142
|
+
return { status: 200, body: { success: true, data: { personId: "1", userId: "u", empId: null, userName: "n", corpId: null } } };
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
const verify = createSessionVerifier(client(), { ttlMs: 1 });
|
|
146
|
+
await verify("tok", "1");
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
148
|
+
await verify("tok", "1");
|
|
149
|
+
assert.equal(hits, 2);
|
|
150
|
+
} finally {
|
|
151
|
+
stub.restore();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("缺 token 或 personId 时不发请求", async () => {
|
|
156
|
+
const stub = stubFetch(() => ({ status: 200, body: { success: true, data: {} } }));
|
|
157
|
+
try {
|
|
158
|
+
const verify = createSessionVerifier(client());
|
|
159
|
+
await assert.rejects(() => verify("", "1"), /access-token/);
|
|
160
|
+
await assert.rejects(() => verify("t", ""), /personId/);
|
|
161
|
+
assert.equal(stub.calls.length, 0);
|
|
162
|
+
} finally {
|
|
163
|
+
stub.restore();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("通知令牌校验:空令牌一律拒绝", () => {
|
|
168
|
+
// 期望值为空时必须拒绝,不能退化成"谁都可以"。
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("createBaseClient 从环境变量装配", () => {
|
|
172
|
+
const built = createBaseClient({ BASE_SERVICE_URL: "http://x.test", BASE_SERVICE_TOKEN: "t" } as NodeJS.ProcessEnv, "legal");
|
|
173
|
+
assert.equal(built.configured, true);
|
|
174
|
+
const missing = createBaseClient({} as NodeJS.ProcessEnv, "legal");
|
|
175
|
+
assert.equal(missing.configured, false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("会话验不过时统一报 401,而不是把 404 透给前端", async () => {
|
|
179
|
+
const stub = stubFetch(() => ({ status: 404, body: { success: false, message: "用户不存在", code: "NOT_FOUND" } }));
|
|
180
|
+
try {
|
|
181
|
+
const verify = createSessionVerifier(client());
|
|
182
|
+
await assert.rejects(
|
|
183
|
+
() => verify("tok", "999999"),
|
|
184
|
+
(error: unknown) => {
|
|
185
|
+
assert.ok(error instanceof BaseServiceError);
|
|
186
|
+
assert.equal(error.status, 401);
|
|
187
|
+
assert.match(error.message, /重新登录/);
|
|
188
|
+
return true;
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
} finally {
|
|
192
|
+
stub.restore();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("基础服务故障不能被误报成「登录失效」", async () => {
|
|
197
|
+
const original = globalThis.fetch;
|
|
198
|
+
globalThis.fetch = (async () => { throw new Error("ECONNREFUSED"); }) as typeof fetch;
|
|
199
|
+
try {
|
|
200
|
+
const verify = createSessionVerifier(client());
|
|
201
|
+
await assert.rejects(
|
|
202
|
+
() => verify("tok", "11809"),
|
|
203
|
+
(error: unknown) => {
|
|
204
|
+
assert.ok(error instanceof BaseServiceError);
|
|
205
|
+
// 依赖挂了就该说依赖挂了,让用户去重新登录只会白折腾。
|
|
206
|
+
assert.equal(error.code, "UNREACHABLE");
|
|
207
|
+
assert.notEqual(error.status, 401);
|
|
208
|
+
return true;
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
} finally {
|
|
212
|
+
globalThis.fetch = original;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("语音请求的签名校验:对的才过;改了通话 id、过期、缺头、令牌不对都不过", async () => {
|
|
217
|
+
const { createHmac } = await import("node:crypto");
|
|
218
|
+
const { verifyVoiceSignature } = await import("./index.js");
|
|
219
|
+
const now = 1_760_000_000_000;
|
|
220
|
+
const sign = (token: string, timestamp: number, callId: string) => createHmac("sha256", token).update(`${timestamp}.${callId}`).digest("hex");
|
|
221
|
+
const headers = { "x-base-timestamp": String(now), "x-base-signature": sign("tok", now, "call_1") };
|
|
222
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_1", serviceToken: "tok", now }), true);
|
|
223
|
+
// Express 给的头是小写;别的框架可能保留原样大小写。
|
|
224
|
+
assert.equal(verifyVoiceSignature({ headers: { "X-Base-Timestamp": String(now), "X-Base-Signature": sign("tok", now, "call_1") }, callId: "call_1", serviceToken: "tok", now }), true);
|
|
225
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_2", serviceToken: "tok", now }), false, "签的不是这一通");
|
|
226
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_1", serviceToken: "other", now }), false);
|
|
227
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_1", serviceToken: "tok", now: now + 6 * 60_000 }), false, "超过 5 分钟不认,防重放");
|
|
228
|
+
assert.equal(verifyVoiceSignature({ headers: {}, callId: "call_1", serviceToken: "tok", now }), false);
|
|
229
|
+
assert.equal(verifyVoiceSignature({ headers: { ...headers, "x-base-signature": "zz" }, callId: "call_1", serviceToken: "tok", now }), false);
|
|
230
|
+
assert.equal(verifyVoiceSignature({ headers, callId: "call_1", serviceToken: "", now }), false, "自己没配令牌时不能放行");
|
|
231
|
+
});
|