@wukongcrm/mcp-server 0.2.5 → 0.2.6
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/README.md +46 -24
- package/dist/client.d.ts +35 -1
- package/dist/client.js +156 -29
- package/dist/nocode.js +75 -47
- package/dist/server.js +208 -34
- package/dist/tool-discovery.d.ts +18 -0
- package/dist/tool-discovery.js +186 -0
- package/dist/tools.js +327 -146
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,27 @@
|
|
|
16
16
|
- 进销存 JXC 保留产品、供应商、仓库、库存和出入库明细查询,并开放 JXC 域内固定路径写入。
|
|
17
17
|
- 无代码能力覆盖应用、模块、字段、数据、角色、流程、阶段、跟进、打印、消息、开放配置和应用市场的 JSON 业务接口。
|
|
18
18
|
- 所有写入仍必须传 `confirm=true`;未确认时只返回预览,不调用 72CRM 写接口。
|
|
19
|
+
- 所有业务、人员、部门等 ID 在 MCP Schema 中都使用数字字符串,禁止用 JSON number 传 Snowflake ID;动态 payload 中的不安全数字 ID 会在请求旧 CRM 前被拒绝。
|
|
20
|
+
- 所有工具通过标准 `description`、Schema、annotations 和 `_meta["com.wukongcrm/tool-discovery"]` 暴露可检索的业务域、实体、动作、能力、别名与排除语义;不识别自定义 `_meta` 的 MCP 客户端可安全忽略该字段。
|
|
21
|
+
|
|
22
|
+
## 商机与跟进写入约定
|
|
23
|
+
|
|
24
|
+
新增商机不能只传名称和客户 ID。先调用 `crm_get_business_status_groups`,从同一状态组中取 `flowId` 作为
|
|
25
|
+
`entity.typeId`、取其 `settingList[].settingId` 作为 `entity.statusId`,再调用 `crm_create_record`。商机名称、客户、
|
|
26
|
+
状态组和阶段分别放在 `entity.businessName/customerId/typeId/statusId`;关联产品、主联系人和审批数据分别使用
|
|
27
|
+
顶层 `product/contactsId/examineFlowData`,MCP 不会再静默丢弃这些字段。只有一个可用状态组时可采用其首个
|
|
28
|
+
进行中阶段;存在多个状态组且用户没有指定时必须让用户选择,不能猜测。
|
|
29
|
+
|
|
30
|
+
新增跟进使用 `crm_add_followup`,`entity` 必须包含 `content`、`category`、`activityType` 和
|
|
31
|
+
`activityTypeId`。MCP 会先按 `activityType` 回读目标记录,确认类型、ID、权限和存在性,再调用写接口。
|
|
32
|
+
|
|
33
|
+
旧 CRM 写接口返回 `code=0` 就表示执行成功,`data=null` 只表示没有返回业务数据。MCP 会统一补充
|
|
34
|
+
`success=true`、`requestAccepted=true`、`crmCode=0` 和 `writeOutcome=confirmed`;回读只用于补充新记录 ID、
|
|
35
|
+
检查重复或核验目标,不用于推翻 `code=0` 的成功语义。
|
|
36
|
+
|
|
37
|
+
旧 CRM 返回 `code=500, msg=网络繁忙` 时,含义是后端未处理异常,不是已经确认的网络故障。MCP 错误会保留
|
|
38
|
+
`path/httpStatus/code/retryable/writeOutcome`;若 `writeOutcome=unknown`,先回读原业务目标,禁止直接重放写请求,
|
|
39
|
+
也不能擅自改用 OA 任务等其他模块并向用户声称原操作成功。
|
|
19
40
|
|
|
20
41
|
## ChatGPT 网页端(远程 MCP)
|
|
21
42
|
|
|
@@ -147,7 +168,7 @@ npm pack
|
|
|
147
168
|
生成文件示例:
|
|
148
169
|
|
|
149
170
|
```text
|
|
150
|
-
wukongcrm-mcp-server-0.2.
|
|
171
|
+
wukongcrm-mcp-server-0.2.6.tgz
|
|
151
172
|
```
|
|
152
173
|
|
|
153
174
|
这个包只包含运行所需的 `dist`、`README.md` 和 `package.json`,不会携带源码、测试文件或用户 API Key。
|
|
@@ -155,7 +176,7 @@ wukongcrm-mcp-server-0.2.4.tgz
|
|
|
155
176
|
使用者拿到 `.tgz` 后安装:
|
|
156
177
|
|
|
157
178
|
```powershell
|
|
158
|
-
npm install -g .\wukongcrm-mcp-server-0.2.
|
|
179
|
+
npm install -g .\wukongcrm-mcp-server-0.2.6.tgz
|
|
159
180
|
```
|
|
160
181
|
|
|
161
182
|
安装后可以直接用命令启动:
|
|
@@ -214,6 +235,7 @@ wukong-mcp
|
|
|
214
235
|
- `crm_list_modules`
|
|
215
236
|
- `crm_list_customer_pools`
|
|
216
237
|
- `crm_get_module_schema`
|
|
238
|
+
- `crm_get_business_status_groups`
|
|
217
239
|
- `crm_validate_field`
|
|
218
240
|
- `crm_search_records`
|
|
219
241
|
- `crm_get_record`
|
|
@@ -374,8 +396,8 @@ wukong-mcp
|
|
|
374
396
|
```json
|
|
375
397
|
{
|
|
376
398
|
"module": "customer",
|
|
377
|
-
"ownerUserIds": [9, 10],
|
|
378
|
-
"createUserId": 11
|
|
399
|
+
"ownerUserIds": ["9", "10"],
|
|
400
|
+
"createUserId": "11"
|
|
379
401
|
}
|
|
380
402
|
```
|
|
381
403
|
|
|
@@ -655,14 +677,14 @@ wukong-mcp
|
|
|
655
677
|
|
|
656
678
|
```json
|
|
657
679
|
{
|
|
658
|
-
"categoryId": 7,
|
|
680
|
+
"categoryId": "7",
|
|
659
681
|
"batchId": "可选附件批次ID",
|
|
660
682
|
"fields": {
|
|
661
683
|
"审批内容": "客户拜访报销",
|
|
662
684
|
"金额": 128.5,
|
|
663
|
-
"申请人": [{ "userId": 9 }]
|
|
685
|
+
"申请人": [{ "userId": "9" }]
|
|
664
686
|
},
|
|
665
|
-
"customerIds": [1001],
|
|
687
|
+
"customerIds": ["1001"],
|
|
666
688
|
"confirm": true
|
|
667
689
|
}
|
|
668
690
|
```
|
|
@@ -677,8 +699,8 @@ wukong-mcp
|
|
|
677
699
|
{
|
|
678
700
|
"startTime": 1782470400000,
|
|
679
701
|
"endTime": 1782556799999,
|
|
680
|
-
"typeIds": [1, 2],
|
|
681
|
-
"userId": 9
|
|
702
|
+
"typeIds": ["1", "2"],
|
|
703
|
+
"userId": "9"
|
|
682
704
|
}
|
|
683
705
|
```
|
|
684
706
|
|
|
@@ -687,9 +709,9 @@ wukong-mcp
|
|
|
687
709
|
```json
|
|
688
710
|
{
|
|
689
711
|
"event": {
|
|
690
|
-
"eventId": 12,
|
|
712
|
+
"eventId": "12",
|
|
691
713
|
"title": "客户回访",
|
|
692
|
-
"typeId": 3,
|
|
714
|
+
"typeId": "3",
|
|
693
715
|
"startTime": "2026-06-26 10:00:00",
|
|
694
716
|
"endTime": "2026-06-26 11:00:00",
|
|
695
717
|
"ownerUserIds": "9",
|
|
@@ -729,7 +751,7 @@ wukong-mcp
|
|
|
729
751
|
{
|
|
730
752
|
"title": "公告标题",
|
|
731
753
|
"content": "公告内容",
|
|
732
|
-
"ownerUserIds": [9, 10],
|
|
754
|
+
"ownerUserIds": ["9", "10"],
|
|
733
755
|
"confirm": true
|
|
734
756
|
}
|
|
735
757
|
```
|
|
@@ -753,9 +775,9 @@ wukong-mcp
|
|
|
753
775
|
|
|
754
776
|
```json
|
|
755
777
|
{
|
|
756
|
-
"categoryId": 7,
|
|
778
|
+
"categoryId": "7",
|
|
757
779
|
"title": "日报",
|
|
758
|
-
"sendUserIds": [9],
|
|
780
|
+
"sendUserIds": ["9"],
|
|
759
781
|
"fields": {
|
|
760
782
|
"日志内容": "今天完成客户回访",
|
|
761
783
|
"明日工作": "继续跟进报价",
|
|
@@ -779,7 +801,7 @@ OA 任务列表固定调用 `/oaTask/queryTaskList`,支持 72CRM 的 `OaTaskLi
|
|
|
779
801
|
"status": 1,
|
|
780
802
|
"priority": 3,
|
|
781
803
|
"keyword": "客户回访",
|
|
782
|
-
"mainUserIds": [9, 10]
|
|
804
|
+
"mainUserIds": ["9", "10"]
|
|
783
805
|
}
|
|
784
806
|
```
|
|
785
807
|
|
|
@@ -788,7 +810,7 @@ OA 任务列表固定调用 `/oaTask/queryTaskList`,支持 72CRM 的 `OaTaskLi
|
|
|
788
810
|
```json
|
|
789
811
|
{
|
|
790
812
|
"type": 2,
|
|
791
|
-
"typeId": 1001
|
|
813
|
+
"typeId": "1001"
|
|
792
814
|
}
|
|
793
815
|
```
|
|
794
816
|
|
|
@@ -798,13 +820,13 @@ OA 任务列表固定调用 `/oaTask/queryTaskList`,支持 72CRM 的 `OaTaskLi
|
|
|
798
820
|
{
|
|
799
821
|
"name": "客户回访",
|
|
800
822
|
"description": "确认续费计划",
|
|
801
|
-
"mainUserId": 9,
|
|
802
|
-
"ownerUserId": [9, 10],
|
|
823
|
+
"mainUserId": "9",
|
|
824
|
+
"ownerUserId": ["9", "10"],
|
|
803
825
|
"startTime": "2026-06-27",
|
|
804
826
|
"stopTime": "2026-06-28",
|
|
805
827
|
"priority": 3,
|
|
806
|
-
"labelId": [1, 2],
|
|
807
|
-
"customerIds": [1001],
|
|
828
|
+
"labelId": ["1", "2"],
|
|
829
|
+
"customerIds": ["1001"],
|
|
808
830
|
"confirm": true
|
|
809
831
|
}
|
|
810
832
|
```
|
|
@@ -813,15 +835,15 @@ OA 任务列表固定调用 `/oaTask/queryTaskList`,支持 72CRM 的 `OaTaskLi
|
|
|
813
835
|
|
|
814
836
|
```json
|
|
815
837
|
{
|
|
816
|
-
"taskId": 12,
|
|
838
|
+
"taskId": "12",
|
|
817
839
|
"updates": {
|
|
818
840
|
"name": "客户回访-已更新",
|
|
819
841
|
"description": "带上报价单",
|
|
820
|
-
"mainUserId": 9,
|
|
821
|
-
"ownerUserId": [9, 10],
|
|
842
|
+
"mainUserId": "9",
|
|
843
|
+
"ownerUserId": ["9", "10"],
|
|
822
844
|
"startTime": "2026-06-27",
|
|
823
845
|
"stopTime": "2026-06-28",
|
|
824
|
-
"labelId": [1, 2],
|
|
846
|
+
"labelId": ["1", "2"],
|
|
825
847
|
"priority": 3,
|
|
826
848
|
"status": 5
|
|
827
849
|
},
|
package/dist/client.d.ts
CHANGED
|
@@ -8,11 +8,43 @@ export interface CrmResult<T = unknown> {
|
|
|
8
8
|
msg?: string;
|
|
9
9
|
data?: T;
|
|
10
10
|
}
|
|
11
|
+
export type AuthFailureReason = "authentication_failed" | "transport_failed" | "api_error" | "protocol_error" | "unknown";
|
|
11
12
|
export interface AuthStatusResult {
|
|
12
13
|
ok: boolean;
|
|
13
14
|
authMode?: "apiKey";
|
|
14
15
|
data?: unknown;
|
|
15
16
|
message?: string;
|
|
17
|
+
reason?: AuthFailureReason;
|
|
18
|
+
retryable?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export type CrmWriteOutcome = "not_applied" | "unknown";
|
|
21
|
+
export type CrmRequestEffect = "read" | "write";
|
|
22
|
+
export declare class CrmApiError extends Error {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly httpStatus: number;
|
|
25
|
+
readonly crmCode: number;
|
|
26
|
+
readonly retryable: boolean;
|
|
27
|
+
readonly writeOutcome: CrmWriteOutcome;
|
|
28
|
+
constructor(options: {
|
|
29
|
+
path: string;
|
|
30
|
+
httpStatus: number;
|
|
31
|
+
crmCode: number;
|
|
32
|
+
message: string;
|
|
33
|
+
retryable: boolean;
|
|
34
|
+
writeOutcome: CrmWriteOutcome;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export declare class CrmTransportError extends Error {
|
|
38
|
+
readonly path: string;
|
|
39
|
+
readonly retryable = true;
|
|
40
|
+
readonly writeOutcome: CrmWriteOutcome;
|
|
41
|
+
constructor(path: string, effect: CrmRequestEffect, cause: unknown);
|
|
42
|
+
}
|
|
43
|
+
export declare class CrmProtocolError extends Error {
|
|
44
|
+
readonly path: string;
|
|
45
|
+
readonly retryable = false;
|
|
46
|
+
readonly writeOutcome: CrmWriteOutcome;
|
|
47
|
+
constructor(path: string, effect?: CrmRequestEffect);
|
|
16
48
|
}
|
|
17
49
|
export declare class CrmClient {
|
|
18
50
|
private readonly baseUrl;
|
|
@@ -22,8 +54,10 @@ export declare class CrmClient {
|
|
|
22
54
|
private loginPromise;
|
|
23
55
|
constructor(config?: CrmClientConfig);
|
|
24
56
|
authStatus(): Promise<AuthStatusResult>;
|
|
25
|
-
call<T = unknown>(method: "GET" | "POST", path: string, body?: unknown, query?: Record<string, unknown
|
|
57
|
+
call<T = unknown>(method: "GET" | "POST", path: string, body?: unknown, query?: Record<string, unknown>, effect?: CrmRequestEffect): Promise<T>;
|
|
26
58
|
post<T = unknown>(path: string, body?: unknown, query?: Record<string, unknown>): Promise<T>;
|
|
59
|
+
read<T = unknown>(path: string, body?: unknown, query?: Record<string, unknown>): Promise<T>;
|
|
60
|
+
write<T = unknown>(path: string, body?: unknown, query?: Record<string, unknown>): Promise<T>;
|
|
27
61
|
private ensureToken;
|
|
28
62
|
private loginWithApiKey;
|
|
29
63
|
private requestOnce;
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
|
+
export class CrmApiError extends Error {
|
|
2
|
+
path;
|
|
3
|
+
httpStatus;
|
|
4
|
+
crmCode;
|
|
5
|
+
retryable;
|
|
6
|
+
writeOutcome;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
const genericBackendNote = options.crmCode === 500 && options.message.includes("网络繁忙")
|
|
9
|
+
? "这是旧 CRM 对未处理后端异常的通用提示,不代表已确认的网络故障。"
|
|
10
|
+
: "";
|
|
11
|
+
const guidance = options.writeOutcome === "unknown"
|
|
12
|
+
? "写入结果未知;不要自动改写为 OA 任务等其他业务,也不要直接重放写请求,请先回读原目标确认是否已落库。"
|
|
13
|
+
: "请求未成功;请根据业务提示修正参数,不要用其他模块冒充本次操作成功。";
|
|
14
|
+
super(`72CRM API 请求失败:path=${options.path},httpStatus=${options.httpStatus},`
|
|
15
|
+
+ `code=${options.crmCode},retryable=${options.retryable},writeOutcome=${options.writeOutcome},`
|
|
16
|
+
+ `message=${options.message}。${genericBackendNote}${guidance}`);
|
|
17
|
+
this.name = "CrmApiError";
|
|
18
|
+
this.path = options.path;
|
|
19
|
+
this.httpStatus = options.httpStatus;
|
|
20
|
+
this.crmCode = options.crmCode;
|
|
21
|
+
this.retryable = options.retryable;
|
|
22
|
+
this.writeOutcome = options.writeOutcome;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class CrmTransportError extends Error {
|
|
26
|
+
path;
|
|
27
|
+
retryable = true;
|
|
28
|
+
writeOutcome;
|
|
29
|
+
constructor(path, effect, cause) {
|
|
30
|
+
const writeOutcome = writeOutcomeFor(effect);
|
|
31
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
32
|
+
super(`72CRM 传输失败:path=${path},retryable=true,writeOutcome=${writeOutcome},message=${reason}。`
|
|
33
|
+
+ (writeOutcome === "unknown"
|
|
34
|
+
? "请求可能已到达服务端;不要直接重放或改写为其他业务,请先回读原目标。"
|
|
35
|
+
: "可以在确认连接状态后重试只读请求。"));
|
|
36
|
+
this.name = "CrmTransportError";
|
|
37
|
+
this.path = path;
|
|
38
|
+
this.writeOutcome = writeOutcome;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class CrmProtocolError extends Error {
|
|
42
|
+
path;
|
|
43
|
+
retryable = false;
|
|
44
|
+
writeOutcome;
|
|
45
|
+
constructor(path, effect = "read") {
|
|
46
|
+
const writeOutcome = writeOutcomeFor(effect);
|
|
47
|
+
super(`72CRM 接口响应格式异常:path=${path},writeOutcome=${writeOutcome}。`
|
|
48
|
+
+ (writeOutcome === "unknown"
|
|
49
|
+
? "写请求可能已到达服务端,请先回读原目标,禁止直接重放。"
|
|
50
|
+
: "请检查 CRM_BASE_URL 是否指向旧 CRM API 根路径。"));
|
|
51
|
+
this.name = "CrmProtocolError";
|
|
52
|
+
this.path = path;
|
|
53
|
+
this.writeOutcome = writeOutcome;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
1
56
|
const API_KEY_REQUIRED_MESSAGE = "请先配置 CRM_API_KEY,然后重新启动本地 MCP。";
|
|
2
57
|
const AUTH_FAILURE_MESSAGE = "MCP 鉴权失败,请检查 CRM_API_KEY 是否正确或是否已被重置。";
|
|
58
|
+
const AUTH_STATUS_MESSAGES = {
|
|
59
|
+
authentication_failed: AUTH_FAILURE_MESSAGE,
|
|
60
|
+
transport_failed: "无法连接旧 CRM 登录接口,请检查 CRM_BASE_URL、网络和证书。",
|
|
61
|
+
api_error: "旧 CRM 登录接口返回服务端错误,请稍后重试或检查旧 CRM 日志。",
|
|
62
|
+
protocol_error: "旧 CRM 登录接口返回了无法识别的响应,请检查 CRM_BASE_URL 是否指向 API 根路径。",
|
|
63
|
+
unknown: "旧 CRM 连接验证失败。"
|
|
64
|
+
};
|
|
3
65
|
export class CrmClient {
|
|
4
66
|
baseUrl;
|
|
5
67
|
apiKey;
|
|
@@ -17,14 +79,21 @@ export class CrmClient {
|
|
|
17
79
|
return { ok: true, authMode: "apiKey", data };
|
|
18
80
|
}
|
|
19
81
|
catch (error) {
|
|
20
|
-
|
|
82
|
+
const failure = classifyAuthFailure(error);
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
authMode: "apiKey",
|
|
86
|
+
reason: failure.reason,
|
|
87
|
+
retryable: failure.retryable,
|
|
88
|
+
message: AUTH_STATUS_MESSAGES[failure.reason]
|
|
89
|
+
};
|
|
21
90
|
}
|
|
22
91
|
}
|
|
23
|
-
async call(method, path, body, query) {
|
|
92
|
+
async call(method, path, body, query, effect = "read") {
|
|
24
93
|
assertFixedPath(path);
|
|
25
94
|
await this.ensureToken();
|
|
26
95
|
try {
|
|
27
|
-
return await this.requestOnce(method, path, body, query);
|
|
96
|
+
return await this.requestOnce(method, path, body, query, effect);
|
|
28
97
|
}
|
|
29
98
|
catch (error) {
|
|
30
99
|
if (!(error instanceof CrmUnauthenticatedError)) {
|
|
@@ -33,7 +102,7 @@ export class CrmClient {
|
|
|
33
102
|
}
|
|
34
103
|
await this.ensureToken(true);
|
|
35
104
|
try {
|
|
36
|
-
return await this.requestOnce(method, path, body, query);
|
|
105
|
+
return await this.requestOnce(method, path, body, query, effect);
|
|
37
106
|
}
|
|
38
107
|
catch (error) {
|
|
39
108
|
if (error instanceof CrmUnauthenticatedError) {
|
|
@@ -43,7 +112,16 @@ export class CrmClient {
|
|
|
43
112
|
}
|
|
44
113
|
}
|
|
45
114
|
async post(path, body, query) {
|
|
46
|
-
|
|
115
|
+
// 兼容旧调用方;内部工具应显式选择 read()/write(),避免仅凭 URL 猜测写入结果。
|
|
116
|
+
return this.read(path, body, query);
|
|
117
|
+
}
|
|
118
|
+
// 旧 CRM 的查询和写入都大量使用 HTTP POST。这里的 read/write 表示业务副作用,
|
|
119
|
+
// 专门用于决定传输或协议失败时能否安全重试,而不是 HTTP method 的别名。
|
|
120
|
+
async read(path, body, query) {
|
|
121
|
+
return this.call("POST", path, body, query, "read");
|
|
122
|
+
}
|
|
123
|
+
async write(path, body, query) {
|
|
124
|
+
return this.call("POST", path, body, query, "write");
|
|
47
125
|
}
|
|
48
126
|
async ensureToken(force = false) {
|
|
49
127
|
if (!this.apiKey.trim()) {
|
|
@@ -62,8 +140,9 @@ export class CrmClient {
|
|
|
62
140
|
this.adminToken = await this.loginPromise;
|
|
63
141
|
}
|
|
64
142
|
async loginWithApiKey() {
|
|
143
|
+
let response;
|
|
65
144
|
try {
|
|
66
|
-
|
|
145
|
+
response = await this.fetchImpl(this.buildUrl("/login"), {
|
|
67
146
|
method: "POST",
|
|
68
147
|
headers: this.buildHeaders(true),
|
|
69
148
|
body: JSON.stringify({
|
|
@@ -72,38 +151,65 @@ export class CrmClient {
|
|
|
72
151
|
type: 4
|
|
73
152
|
})
|
|
74
153
|
});
|
|
75
|
-
if (response.status === 401 || response.status === 403) {
|
|
76
|
-
throw new CrmAuthenticationError();
|
|
77
|
-
}
|
|
78
|
-
const result = await parseJsonResult(response);
|
|
79
|
-
const adminToken = typeof result.data?.adminToken === "string" ? result.data.adminToken.trim() : "";
|
|
80
|
-
if (result.code !== 0 || !adminToken) {
|
|
81
|
-
throw new CrmAuthenticationError();
|
|
82
|
-
}
|
|
83
|
-
return adminToken;
|
|
84
154
|
}
|
|
85
155
|
catch (error) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
156
|
+
throw new CrmTransportError("/login", "read", error);
|
|
157
|
+
}
|
|
158
|
+
if (response.status === 401 || response.status === 403) {
|
|
159
|
+
throw new CrmAuthenticationError();
|
|
160
|
+
}
|
|
161
|
+
const result = await parseJsonResult(response, "/login", "read");
|
|
162
|
+
if (isAuthenticationCode(result.code)) {
|
|
89
163
|
throw new CrmAuthenticationError();
|
|
90
164
|
}
|
|
165
|
+
if (!response.ok || result.code !== 0) {
|
|
166
|
+
const crmCode = result.code || response.status;
|
|
167
|
+
throw new CrmApiError({
|
|
168
|
+
path: "/login",
|
|
169
|
+
httpStatus: response.status,
|
|
170
|
+
crmCode,
|
|
171
|
+
message: result.msg || response.statusText || "未返回错误说明",
|
|
172
|
+
retryable: response.status === 429 || response.status === 502
|
|
173
|
+
|| response.status === 503 || response.status === 504,
|
|
174
|
+
writeOutcome: "not_applied"
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const adminToken = typeof result.data?.adminToken === "string" ? result.data.adminToken.trim() : "";
|
|
178
|
+
if (!adminToken) {
|
|
179
|
+
throw new CrmProtocolError("/login");
|
|
180
|
+
}
|
|
181
|
+
return adminToken;
|
|
91
182
|
}
|
|
92
|
-
async requestOnce(method, path, body, query) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
183
|
+
async requestOnce(method, path, body, query, effect = "read") {
|
|
184
|
+
let response;
|
|
185
|
+
try {
|
|
186
|
+
response = await this.fetchImpl(this.buildUrl(path, query), {
|
|
187
|
+
method,
|
|
188
|
+
headers: this.buildHeaders(body !== undefined, this.adminToken),
|
|
189
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
throw new CrmTransportError(path, effect, error);
|
|
194
|
+
}
|
|
98
195
|
if (response.status === 401 || response.status === 403) {
|
|
99
196
|
throw new CrmUnauthenticatedError();
|
|
100
197
|
}
|
|
101
|
-
const result = await parseJsonResult(response);
|
|
198
|
+
const result = await parseJsonResult(response, path, effect);
|
|
102
199
|
if (result.code === 302) {
|
|
103
200
|
throw new CrmUnauthenticatedError();
|
|
104
201
|
}
|
|
105
|
-
if (result.code !== 0) {
|
|
106
|
-
|
|
202
|
+
if (!response.ok || result.code !== 0) {
|
|
203
|
+
const crmCode = result.code || response.status;
|
|
204
|
+
throw new CrmApiError({
|
|
205
|
+
path,
|
|
206
|
+
httpStatus: response.status,
|
|
207
|
+
crmCode,
|
|
208
|
+
message: result.msg || response.statusText || "未返回错误说明",
|
|
209
|
+
// 旧 CRM 的 code=500/networkError 是统一的后端异常兜底,不是网络诊断。
|
|
210
|
+
retryable: response.status === 429 || response.status === 502 || response.status === 503 || response.status === 504,
|
|
211
|
+
writeOutcome: writeOutcomeFor(effect)
|
|
212
|
+
});
|
|
107
213
|
}
|
|
108
214
|
return result.data;
|
|
109
215
|
}
|
|
@@ -128,6 +234,27 @@ export class CrmClient {
|
|
|
128
234
|
return headers;
|
|
129
235
|
}
|
|
130
236
|
}
|
|
237
|
+
function writeOutcomeFor(effect) {
|
|
238
|
+
return effect === "write" ? "unknown" : "not_applied";
|
|
239
|
+
}
|
|
240
|
+
function isAuthenticationCode(code) {
|
|
241
|
+
return code === 302 || code === 401 || code === 403 || code === 1022 || code === 1023;
|
|
242
|
+
}
|
|
243
|
+
function classifyAuthFailure(error) {
|
|
244
|
+
if (error instanceof CrmAuthenticationError || errorMessage(error) === API_KEY_REQUIRED_MESSAGE) {
|
|
245
|
+
return { reason: "authentication_failed", retryable: false };
|
|
246
|
+
}
|
|
247
|
+
if (error instanceof CrmTransportError) {
|
|
248
|
+
return { reason: "transport_failed", retryable: true };
|
|
249
|
+
}
|
|
250
|
+
if (error instanceof CrmApiError) {
|
|
251
|
+
return { reason: "api_error", retryable: error.retryable };
|
|
252
|
+
}
|
|
253
|
+
if (error instanceof CrmProtocolError) {
|
|
254
|
+
return { reason: "protocol_error", retryable: false };
|
|
255
|
+
}
|
|
256
|
+
return { reason: "unknown", retryable: false };
|
|
257
|
+
}
|
|
131
258
|
class CrmUnauthenticatedError extends Error {
|
|
132
259
|
constructor() {
|
|
133
260
|
super("72CRM 当前登录态已失效。");
|
|
@@ -138,7 +265,7 @@ class CrmAuthenticationError extends Error {
|
|
|
138
265
|
super(AUTH_FAILURE_MESSAGE);
|
|
139
266
|
}
|
|
140
267
|
}
|
|
141
|
-
async function parseJsonResult(response) {
|
|
268
|
+
async function parseJsonResult(response, path, effect) {
|
|
142
269
|
const text = await response.text();
|
|
143
270
|
if (!text) {
|
|
144
271
|
return { code: response.ok ? 0 : response.status, msg: response.statusText, data: undefined };
|
|
@@ -147,7 +274,7 @@ async function parseJsonResult(response) {
|
|
|
147
274
|
return JSON.parse(text);
|
|
148
275
|
}
|
|
149
276
|
catch {
|
|
150
|
-
throw new
|
|
277
|
+
throw new CrmProtocolError(path, effect);
|
|
151
278
|
}
|
|
152
279
|
}
|
|
153
280
|
function normalizeBaseUrl(baseUrl) {
|