@wukongcrm/mcp-server 0.2.4 → 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 +68 -25
- package/dist/client.d.ts +35 -1
- package/dist/client.js +156 -29
- package/dist/nocode.js +75 -47
- package/dist/server.js +228 -51
- package/dist/tool-discovery.d.ts +18 -0
- package/dist/tool-discovery.js +186 -0
- package/dist/tools.js +492 -151
- 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
|
|
|
@@ -397,13 +419,34 @@ wukong-mcp
|
|
|
397
419
|
{
|
|
398
420
|
"fieldName": "ownerUserId",
|
|
399
421
|
"formType": "user",
|
|
400
|
-
"type":
|
|
422
|
+
"type": 3,
|
|
401
423
|
"values": ["张三"]
|
|
402
424
|
}
|
|
403
425
|
]
|
|
404
426
|
}
|
|
405
427
|
```
|
|
406
428
|
|
|
429
|
+
旧 CRM 的人员筛选只会处理操作 `3`(包含)、`4`(不包含)、`5`(为空)和 `6`(不为空);其中按人员 ID 匹配必须使用 `type=3`。MCP 的人员便捷参数会固定生成操作 `3`,显式传入未实现的人员操作会在请求后端前报错,避免返回未按人员过滤的数据。
|
|
430
|
+
|
|
431
|
+
日期字段必须明确传 `formType=date` 或 `formType=datetime`。固定范围使用操作 `14`,`values` 按开始、结束顺序提供两个值;`date` 使用 `YYYY-MM-DD`,`datetime` 使用 `YYYY-MM-DD HH:mm:ss`。查询完整自然日时,边界为当天 `00:00:00` 和 `23:59:59`,后端范围两端均包含:
|
|
432
|
+
|
|
433
|
+
```json
|
|
434
|
+
{
|
|
435
|
+
"module": "activity",
|
|
436
|
+
"createUserName": "<人员唯一昵称>",
|
|
437
|
+
"filters": [
|
|
438
|
+
{
|
|
439
|
+
"fieldName": "createTime",
|
|
440
|
+
"formType": "datetime",
|
|
441
|
+
"type": 14,
|
|
442
|
+
"values": ["YYYY-MM-DD 00:00:00", "YYYY-MM-DD 23:59:59"]
|
|
443
|
+
}
|
|
444
|
+
]
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
`filters` 和 `searchList` 是同一个高级筛选参数的两个名称,只能选一个。每项必须提供字段名和 `type`/`searchEnum`;两种操作码同时出现时必须相同。除操作 `5`/`6` 外必须提供非空 `values`。日期范围也可传后端已有的单个预设时间值(例如 `today`、`week`、`month`);不支持的操作、无效日期、倒置边界和缺失值会明确报错。
|
|
449
|
+
|
|
407
450
|
如需单独核对用户 ID,可调用 `crm_find_user_id`:
|
|
408
451
|
|
|
409
452
|
```json
|
|
@@ -634,14 +677,14 @@ wukong-mcp
|
|
|
634
677
|
|
|
635
678
|
```json
|
|
636
679
|
{
|
|
637
|
-
"categoryId": 7,
|
|
680
|
+
"categoryId": "7",
|
|
638
681
|
"batchId": "可选附件批次ID",
|
|
639
682
|
"fields": {
|
|
640
683
|
"审批内容": "客户拜访报销",
|
|
641
684
|
"金额": 128.5,
|
|
642
|
-
"申请人": [{ "userId": 9 }]
|
|
685
|
+
"申请人": [{ "userId": "9" }]
|
|
643
686
|
},
|
|
644
|
-
"customerIds": [1001],
|
|
687
|
+
"customerIds": ["1001"],
|
|
645
688
|
"confirm": true
|
|
646
689
|
}
|
|
647
690
|
```
|
|
@@ -656,8 +699,8 @@ wukong-mcp
|
|
|
656
699
|
{
|
|
657
700
|
"startTime": 1782470400000,
|
|
658
701
|
"endTime": 1782556799999,
|
|
659
|
-
"typeIds": [1, 2],
|
|
660
|
-
"userId": 9
|
|
702
|
+
"typeIds": ["1", "2"],
|
|
703
|
+
"userId": "9"
|
|
661
704
|
}
|
|
662
705
|
```
|
|
663
706
|
|
|
@@ -666,9 +709,9 @@ wukong-mcp
|
|
|
666
709
|
```json
|
|
667
710
|
{
|
|
668
711
|
"event": {
|
|
669
|
-
"eventId": 12,
|
|
712
|
+
"eventId": "12",
|
|
670
713
|
"title": "客户回访",
|
|
671
|
-
"typeId": 3,
|
|
714
|
+
"typeId": "3",
|
|
672
715
|
"startTime": "2026-06-26 10:00:00",
|
|
673
716
|
"endTime": "2026-06-26 11:00:00",
|
|
674
717
|
"ownerUserIds": "9",
|
|
@@ -708,7 +751,7 @@ wukong-mcp
|
|
|
708
751
|
{
|
|
709
752
|
"title": "公告标题",
|
|
710
753
|
"content": "公告内容",
|
|
711
|
-
"ownerUserIds": [9, 10],
|
|
754
|
+
"ownerUserIds": ["9", "10"],
|
|
712
755
|
"confirm": true
|
|
713
756
|
}
|
|
714
757
|
```
|
|
@@ -732,9 +775,9 @@ wukong-mcp
|
|
|
732
775
|
|
|
733
776
|
```json
|
|
734
777
|
{
|
|
735
|
-
"categoryId": 7,
|
|
778
|
+
"categoryId": "7",
|
|
736
779
|
"title": "日报",
|
|
737
|
-
"sendUserIds": [9],
|
|
780
|
+
"sendUserIds": ["9"],
|
|
738
781
|
"fields": {
|
|
739
782
|
"日志内容": "今天完成客户回访",
|
|
740
783
|
"明日工作": "继续跟进报价",
|
|
@@ -758,7 +801,7 @@ OA 任务列表固定调用 `/oaTask/queryTaskList`,支持 72CRM 的 `OaTaskLi
|
|
|
758
801
|
"status": 1,
|
|
759
802
|
"priority": 3,
|
|
760
803
|
"keyword": "客户回访",
|
|
761
|
-
"mainUserIds": [9, 10]
|
|
804
|
+
"mainUserIds": ["9", "10"]
|
|
762
805
|
}
|
|
763
806
|
```
|
|
764
807
|
|
|
@@ -767,7 +810,7 @@ OA 任务列表固定调用 `/oaTask/queryTaskList`,支持 72CRM 的 `OaTaskLi
|
|
|
767
810
|
```json
|
|
768
811
|
{
|
|
769
812
|
"type": 2,
|
|
770
|
-
"typeId": 1001
|
|
813
|
+
"typeId": "1001"
|
|
771
814
|
}
|
|
772
815
|
```
|
|
773
816
|
|
|
@@ -777,13 +820,13 @@ OA 任务列表固定调用 `/oaTask/queryTaskList`,支持 72CRM 的 `OaTaskLi
|
|
|
777
820
|
{
|
|
778
821
|
"name": "客户回访",
|
|
779
822
|
"description": "确认续费计划",
|
|
780
|
-
"mainUserId": 9,
|
|
781
|
-
"ownerUserId": [9, 10],
|
|
823
|
+
"mainUserId": "9",
|
|
824
|
+
"ownerUserId": ["9", "10"],
|
|
782
825
|
"startTime": "2026-06-27",
|
|
783
826
|
"stopTime": "2026-06-28",
|
|
784
827
|
"priority": 3,
|
|
785
|
-
"labelId": [1, 2],
|
|
786
|
-
"customerIds": [1001],
|
|
828
|
+
"labelId": ["1", "2"],
|
|
829
|
+
"customerIds": ["1001"],
|
|
787
830
|
"confirm": true
|
|
788
831
|
}
|
|
789
832
|
```
|
|
@@ -792,15 +835,15 @@ OA 任务列表固定调用 `/oaTask/queryTaskList`,支持 72CRM 的 `OaTaskLi
|
|
|
792
835
|
|
|
793
836
|
```json
|
|
794
837
|
{
|
|
795
|
-
"taskId": 12,
|
|
838
|
+
"taskId": "12",
|
|
796
839
|
"updates": {
|
|
797
840
|
"name": "客户回访-已更新",
|
|
798
841
|
"description": "带上报价单",
|
|
799
|
-
"mainUserId": 9,
|
|
800
|
-
"ownerUserId": [9, 10],
|
|
842
|
+
"mainUserId": "9",
|
|
843
|
+
"ownerUserId": ["9", "10"],
|
|
801
844
|
"startTime": "2026-06-27",
|
|
802
845
|
"stopTime": "2026-06-28",
|
|
803
|
-
"labelId": [1, 2],
|
|
846
|
+
"labelId": ["1", "2"],
|
|
804
847
|
"priority": 3,
|
|
805
848
|
"status": 5
|
|
806
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) {
|