yswj-internal-cli 0.1.2__tar.gz → 0.2.0__tar.gz
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.
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/PKG-INFO +28 -3
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/README.md +27 -2
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/pyproject.toml +1 -1
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/AI_ONE_CLICK.md +3 -3
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/AI_SETUP.md +19 -5
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/__init__.py +1 -1
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/cli.py +76 -2
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/client.py +28 -8
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/PKG-INFO +28 -3
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/tests/test_cli.py +64 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/tests/test_client.py +30 -4
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/setup.cfg +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/__main__.py +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/config.py +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/output.py +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/SOURCES.txt +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/dependency_links.txt +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/entry_points.txt +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/requires.txt +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/top_level.txt +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/tests/test_config.py +0 -0
- {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/tests/test_output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yswj-internal-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Command-line client for 元生无界内部管理系统
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,7 +8,7 @@ Requires-Dist: keyring<26,>=25.7
|
|
|
8
8
|
|
|
9
9
|
# 元生无界内部管理系统 CLI
|
|
10
10
|
|
|
11
|
-
命令名:`yswj-internal`。CLI 通过系统开放 API
|
|
11
|
+
命令名:`yswj-internal`。CLI 通过系统开放 API 读取和写入数据,权限由 API Key 和服务端账号权限共同控制。
|
|
12
12
|
|
|
13
13
|
## 安装
|
|
14
14
|
|
|
@@ -35,7 +35,7 @@ pipx install ./cli
|
|
|
35
35
|
macOS 或 Windows 用户在系统页面复制自己的 API Key 后,可让 AI 在本机执行一条命令完成安装和配置:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
uvx --from yswj-internal-cli==0.
|
|
38
|
+
uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
需要验证已授权模块时,可加 `--verify projects` 等参数。验证最多请求一条记录,不打印业务数据。其他系统可运行 `yswj-internal setup`,在本地终端的隐藏提示中输入 Key。
|
|
@@ -72,6 +72,31 @@ yswj-internal activities list --created-since 2026-11-01T00:00:00+08:00 --limit
|
|
|
72
72
|
|
|
73
73
|
`export` 会顺序读取所有页并写入指定文件;CSV 按页流式写盘,避免大批数据占满内存,交互终端会显示读取进度。大数据集建议使用 CSV 和增量时间筛选。联系人私密字段、项目财务字段以及各数据范围仍遵循该 Key 的权限;CLI 不会扩大 Key 权限。
|
|
74
74
|
|
|
75
|
+
## 写入命令
|
|
76
|
+
|
|
77
|
+
API Key 默认只读。在系统页面为具体数据模块分别授予新建、修改或删除权限后,CLI 才可执行对应命令;当前账号的网页端业务规则仍然生效。写入内容使用 UTF-8 JSON 文件,`--input -` 可从标准输入读取,但不要将业务数据放进命令参数。
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# leads.json 内容示例:{"name":"新客户商机","company":"示例公司"}
|
|
81
|
+
yswj-internal leads create --input leads.json
|
|
82
|
+
|
|
83
|
+
# change.json 内容示例:{"name":"更新后的商机名"}
|
|
84
|
+
yswj-internal leads update 101 --input change.json
|
|
85
|
+
|
|
86
|
+
# 项目经理提交修改审批,change.json 可包含 status 和 reason
|
|
87
|
+
yswj-internal projects request-change 501 --input change.json
|
|
88
|
+
|
|
89
|
+
# 管理员或项目经理上级可按网页规则直接修改项目
|
|
90
|
+
yswj-internal projects update 501 --input change.json
|
|
91
|
+
|
|
92
|
+
# 删除需要输入记录 ID 确认;非交互脚本需显式指定 --yes
|
|
93
|
+
yswj-internal leads delete 101
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
所有资源均可 `create`、`update`。只有商机、渠道、联系人和跟进记录可 `delete`;项目和待跟进任务通过 `update` 修改 `status`。项目经理不能直接修改项目,须使用 `request-change` 提交审批。联系人电话、微信、邮箱和项目合同编号的写入还需单独的敏感字段权限;写入权限不自动授予读取这些字段的权限。删除商机或渠道会影响关联跟进记录和联系人关联,执行前请核对目标。
|
|
97
|
+
|
|
98
|
+
写入接口的字段、状态码和副作用见 [开放 API 规范](../docs/api-v1.md)。
|
|
99
|
+
|
|
75
100
|
## 命令帮助
|
|
76
101
|
|
|
77
102
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 元生无界内部管理系统 CLI
|
|
2
2
|
|
|
3
|
-
命令名:`yswj-internal`。CLI 通过系统开放 API
|
|
3
|
+
命令名:`yswj-internal`。CLI 通过系统开放 API 读取和写入数据,权限由 API Key 和服务端账号权限共同控制。
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
@@ -27,7 +27,7 @@ pipx install ./cli
|
|
|
27
27
|
macOS 或 Windows 用户在系统页面复制自己的 API Key 后,可让 AI 在本机执行一条命令完成安装和配置:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
uvx --from yswj-internal-cli==0.
|
|
30
|
+
uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
需要验证已授权模块时,可加 `--verify projects` 等参数。验证最多请求一条记录,不打印业务数据。其他系统可运行 `yswj-internal setup`,在本地终端的隐藏提示中输入 Key。
|
|
@@ -64,6 +64,31 @@ yswj-internal activities list --created-since 2026-11-01T00:00:00+08:00 --limit
|
|
|
64
64
|
|
|
65
65
|
`export` 会顺序读取所有页并写入指定文件;CSV 按页流式写盘,避免大批数据占满内存,交互终端会显示读取进度。大数据集建议使用 CSV 和增量时间筛选。联系人私密字段、项目财务字段以及各数据范围仍遵循该 Key 的权限;CLI 不会扩大 Key 权限。
|
|
66
66
|
|
|
67
|
+
## 写入命令
|
|
68
|
+
|
|
69
|
+
API Key 默认只读。在系统页面为具体数据模块分别授予新建、修改或删除权限后,CLI 才可执行对应命令;当前账号的网页端业务规则仍然生效。写入内容使用 UTF-8 JSON 文件,`--input -` 可从标准输入读取,但不要将业务数据放进命令参数。
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# leads.json 内容示例:{"name":"新客户商机","company":"示例公司"}
|
|
73
|
+
yswj-internal leads create --input leads.json
|
|
74
|
+
|
|
75
|
+
# change.json 内容示例:{"name":"更新后的商机名"}
|
|
76
|
+
yswj-internal leads update 101 --input change.json
|
|
77
|
+
|
|
78
|
+
# 项目经理提交修改审批,change.json 可包含 status 和 reason
|
|
79
|
+
yswj-internal projects request-change 501 --input change.json
|
|
80
|
+
|
|
81
|
+
# 管理员或项目经理上级可按网页规则直接修改项目
|
|
82
|
+
yswj-internal projects update 501 --input change.json
|
|
83
|
+
|
|
84
|
+
# 删除需要输入记录 ID 确认;非交互脚本需显式指定 --yes
|
|
85
|
+
yswj-internal leads delete 101
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
所有资源均可 `create`、`update`。只有商机、渠道、联系人和跟进记录可 `delete`;项目和待跟进任务通过 `update` 修改 `status`。项目经理不能直接修改项目,须使用 `request-change` 提交审批。联系人电话、微信、邮箱和项目合同编号的写入还需单独的敏感字段权限;写入权限不自动授予读取这些字段的权限。删除商机或渠道会影响关联跟进记录和联系人关联,执行前请核对目标。
|
|
89
|
+
|
|
90
|
+
写入接口的字段、状态码和副作用见 [开放 API 规范](../docs/api-v1.md)。
|
|
91
|
+
|
|
67
92
|
## 命令帮助
|
|
68
93
|
|
|
69
94
|
```bash
|
|
@@ -10,20 +10,20 @@
|
|
|
10
10
|
4. 用户说“已复制”后,在同一台 **macOS 或 Windows** 电脑的终端运行下面这一条命令:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
uvx --from yswj-internal-cli==0.
|
|
13
|
+
uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
这条命令会按需从 PyPI 安装 CLI、检查当前系统 API Key 的完整格式、配置生产环境地址、把 Key 保存到系统密钥库并读回校验;密钥库不可用时退回本机用户配置文件。保存成功后,CLI 会清空仍包含该 Key 的剪贴板。命令参数和输出都不包含 Key。Windows 通过系统 PowerShell 剪贴板接口读取 Key,无需额外剪贴板工具。
|
|
17
17
|
|
|
18
18
|
5. 只有当用户明确知道 Key 获准访问的模块时,才可在同一条命令末尾加 `--verify MODULE`,例如 `--verify projects`。它只请求最多一条记录且不打印业务数据。模块可选:`leads`、`channels`、`contacts`、`activities`、`follow-up-tasks`、`projects`。不要逐个模块试探权限。
|
|
19
|
-
6. 配置完成后可运行 `uvx --from yswj-internal-cli==0.
|
|
19
|
+
6. 配置完成后可运行 `uvx --from yswj-internal-cli==0.2.0 yswj-internal config show` 核对地址与 Key 保存状态;此命令不显示 Key。向用户只报告安装版本、配置状态、可选的验证结果和必要的错误信息。配置 CLI 本身不授权修改业务数据;若用户以后明确要求写入,请先阅读同包 `AI_SETUP.md` 的写入规则。
|
|
20
20
|
|
|
21
21
|
如果 `uvx` 不在本机,请按 [uv 官方安装说明](https://docs.astral.sh/uv/getting-started/installation/)安装,或使用已有的 `yswj-internal` 命令运行 `yswj-internal setup --from-clipboard`。不要从同名的非官方包或不明地址下载脚本。
|
|
22
22
|
|
|
23
23
|
Linux 或无法读取剪贴板的环境中,用户可在自己的交互终端运行:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
uvx --from yswj-internal-cli==0.
|
|
26
|
+
uvx --from yswj-internal-cli==0.2.0 yswj-internal setup
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
CLI 会用隐藏输入提示让用户输入并确认 Key。AI 无法安全交接该提示时,让用户在自己的终端完成输入。
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
本文供 AI 助手读取并按步骤配置 `yswj-internal-cli`。CLI 包名是 `yswj-internal-cli`,实际命令名是 `yswj-internal`。
|
|
4
4
|
|
|
5
|
-
若只需快速配置,请先读同包的 `AI_ONE_CLICK.md`。macOS 或 Windows 用户复制系统 API Key 到本机剪贴板后,AI 可运行 `uvx --from yswj-internal-cli==0.
|
|
5
|
+
若只需快速配置,请先读同包的 `AI_ONE_CLICK.md`。macOS 或 Windows 用户复制系统 API Key 到本机剪贴板后,AI 可运行 `uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard`,一次完成安装、地址与密钥配置。
|
|
6
6
|
|
|
7
7
|
## 目标和边界
|
|
8
8
|
|
|
9
9
|
- 配置 CLI 连接用户有权访问的元生无界内部管理系统,并做最小化连通性验证。
|
|
10
|
-
- CLI
|
|
10
|
+
- CLI 通过开放 API 读取或写入数据。API Key 默认只读;服务端同时检查 Key 权限和账号权限。
|
|
11
11
|
- 默认只验证配置和认证,不读取、导出或汇总业务数据。只有用户另行提出数据任务时,才读取所需数据。
|
|
12
12
|
- API Key 是凭据。不得让用户把完整 Key 发在 AI 对话中;不得把 Key 放在命令参数、URL、代码、日志、仓库或普通输出中,也不得读取配置文件来查看 Key。
|
|
13
13
|
|
|
14
14
|
## 需要用户完成的事项
|
|
15
15
|
|
|
16
16
|
1. 线上系统地址为 `http://39.107.72.34:6611`,当前可以直连。安装和本机配置不要求 OpenVPN;若所在网络无法访问,再检查 VPN、网络路由和公司网络策略。
|
|
17
|
-
2. 用户在系统的“API Key 管理”页面创建自己的 Key
|
|
17
|
+
2. 用户在系统的“API Key 管理”页面创建自己的 Key,选择实际需要的数据范围、操作权限和有效期。写入权限应只授予确需操作的模块;完整 Key 创建后只显示一次。
|
|
18
18
|
3. 用户在 AI 能访问的本地终端中直接输入 Key。优先使用 CLI 的隐藏输入提示,不要在 AI 聊天窗口粘贴 Key。
|
|
19
19
|
|
|
20
20
|
若用户使用的是测试或其他环境,使用用户明确给出的站点根地址。站点地址只包含协议、主机和可选端口;不要附加 `/api`、`/api/open/v1`、路径、查询参数或片段。
|
|
@@ -59,7 +59,7 @@ python3 -m pip install --user yswj-internal-cli
|
|
|
59
59
|
yswj-internal --profile prod config set-server http://39.107.72.34:6611 --activate
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
当前线上入口使用 HTTP,因此 CLI 会提示传输未加密。`setup` 默认只配置本机,不发送 Key
|
|
62
|
+
当前线上入口使用 HTTP,因此 CLI 会提示传输未加密。`setup` 默认只配置本机,不发送 Key;验证、读取和写入数据都会通过 HTTP 明文传输 Key。直连可达不代表传输安全;发送含 Key 的请求时应使用受信任的 VPN,或改用用户提供的 HTTPS 站点地址。
|
|
63
63
|
|
|
64
64
|
### 3. 安全保存 API Key
|
|
65
65
|
|
|
@@ -125,13 +125,22 @@ yswj-internal --profile prod projects list --limit 1 --columns id
|
|
|
125
125
|
- JSON 适合后续程序处理;表格输出适合人工查看。只请求任务所需字段时可使用 `--columns`(仅适用于表格列表)。
|
|
126
126
|
- 对 401、403 或网络错误进行诊断时,不要重试密钥猜测或扩大数据范围。
|
|
127
127
|
|
|
128
|
+
## 写入规则
|
|
129
|
+
|
|
130
|
+
- 只有用户明确要求创建、修改、删除某条业务记录时才执行写入。先确认资源、目标 ID、要改的字段及删除影响;不要把“配置 CLI”理解为授权写入业务数据。
|
|
131
|
+
- 在用户授权的 Key 范围内使用 `create --input FILE`、`update ID --input FILE`。可用 `--input -` 从安全的标准输入读取 JSON;不要把 API Key 或敏感业务数据放到命令参数。
|
|
132
|
+
- 项目经理修改项目使用 `projects request-change ID --input FILE` 提交审批;直接 `projects update` 只适用于网页端允许直接修改的账号。项目和待跟进任务不能删除,可用 `update` 修改 `status`,例如 `cancelled`。
|
|
133
|
+
- `delete ID` 仅适用于商机、渠道、联系人和跟进记录。交互终端会要求再次输入 ID;脚本在用户明确指定目标后才可使用 `--yes`。删除商机或渠道会影响关联记录,删除跟进记录可能删除自动生成的待办。
|
|
134
|
+
- 联系人电话、微信、邮箱需要 `write_contact_private`,项目合同编号需要 `write_project_financial`。写入后返回值仍按读取权限遮蔽敏感字段。
|
|
135
|
+
- 写入错误尤其是超时后,不要自动重试 `POST` 或 `DELETE`,应先按 ID 或列表核对服务端结果,避免重复创建或误删。
|
|
136
|
+
|
|
128
137
|
## 常见错误处理
|
|
129
138
|
|
|
130
139
|
| CLI 结果 | AI 应采取的动作 |
|
|
131
140
|
| --- | --- |
|
|
132
141
|
| 无法连接、超时、连接被拒绝 | 先确认服务地址为站点根地址,检查直连、VPN 路由和公司网络策略;必要时做一次重试。不要修改 API Key。 |
|
|
133
142
|
| HTTP 401 | Key 无效、已撤销或过期。让用户在系统页面检查或重新签发,然后通过隐藏提示更新。不要要求用户把 Key 发到聊天中。 |
|
|
134
|
-
| HTTP 403 | Key
|
|
143
|
+
| HTTP 403 | Key 未获准当前模块、字段或操作,或账号不具备网页端相同业务权限。检查对应权限;项目经理直接修改项目时应改用 `request-change`。 |
|
|
135
144
|
| HTTP 404 | 确认地址没有多余路径,ID 和资源名称正确;API 路径由 CLI 自动添加。 |
|
|
136
145
|
| HTTP 500 或非 JSON 响应 | 可能是服务端异常或站点地址不正确。保留状态码和不含密钥的错误内容,提示用户联系系统维护人员。 |
|
|
137
146
|
| 配置未设置 | 先设置 profile 的站点地址,再通过隐藏提示保存 Key。 |
|
|
@@ -156,6 +165,11 @@ yswj-internal --profile prod projects export \
|
|
|
156
165
|
--updated-since 2026-11-01T00:00:00+08:00 \
|
|
157
166
|
--format csv --output projects.csv
|
|
158
167
|
|
|
168
|
+
# 仅在用户明确授权写入时,新建、修改或提交项目审批
|
|
169
|
+
yswj-internal --profile prod leads create --input lead.json
|
|
170
|
+
yswj-internal --profile prod leads update 101 --input change.json
|
|
171
|
+
yswj-internal --profile prod projects request-change 501 --input project-change.json
|
|
172
|
+
|
|
159
173
|
# 更换或删除当前 profile 的密钥
|
|
160
174
|
yswj-internal --profile prod config set-key
|
|
161
175
|
yswj-internal --profile prod config remove-key
|
|
@@ -9,6 +9,7 @@ import subprocess
|
|
|
9
9
|
import sys
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from getpass import getpass
|
|
12
|
+
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
from . import __version__
|
|
14
15
|
from .client import APIClient, APIError
|
|
@@ -25,7 +26,9 @@ RESOURCE_FILTERS = {
|
|
|
25
26
|
"projects": "updated_since",
|
|
26
27
|
}
|
|
27
28
|
RESOURCES = tuple(RESOURCE_FILTERS)
|
|
29
|
+
DELETABLE_RESOURCES = {"leads", "channels", "contacts", "activities"}
|
|
28
30
|
MAX_PAGE_SIZE = 500
|
|
31
|
+
MAX_WRITE_JSON_SIZE = 1_000_000
|
|
29
32
|
DEFAULT_SERVER_URL = "http://39.107.72.34:6611"
|
|
30
33
|
|
|
31
34
|
|
|
@@ -173,7 +176,7 @@ def _add_list_options(parser: argparse.ArgumentParser, resource: str, *, export:
|
|
|
173
176
|
def create_parser() -> argparse.ArgumentParser:
|
|
174
177
|
parser = argparse.ArgumentParser(
|
|
175
178
|
prog="yswj-internal",
|
|
176
|
-
description="
|
|
179
|
+
description="元生无界内部管理系统 CLI;按 API Key 和账号权限读写数据。",
|
|
177
180
|
)
|
|
178
181
|
parser.add_argument("--version", action="version", version=f"yswj-internal {__version__}")
|
|
179
182
|
parser.add_argument(
|
|
@@ -215,7 +218,7 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
215
218
|
config_commands.add_parser("path", help="显示配置文件路径")
|
|
216
219
|
|
|
217
220
|
for resource in RESOURCES:
|
|
218
|
-
resource_parser = root.add_parser(resource, help=f"
|
|
221
|
+
resource_parser = root.add_parser(resource, help=f"读取或修改 {resource} 数据")
|
|
219
222
|
actions = resource_parser.add_subparsers(dest="action", required=True)
|
|
220
223
|
|
|
221
224
|
list_parser = actions.add_parser("list", help="读取列表")
|
|
@@ -229,6 +232,26 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
229
232
|
export_parser = actions.add_parser("export", help="读取所有分页并导出")
|
|
230
233
|
_add_list_options(export_parser, resource, export=True)
|
|
231
234
|
|
|
235
|
+
create_action_parser = actions.add_parser("create", help="新建记录(需 API Key 新建权限)")
|
|
236
|
+
create_action_parser.add_argument("--input", required=True, help="JSON 文件路径;- 表示从标准输入读取")
|
|
237
|
+
create_action_parser.add_argument("--output", help="将返回的 JSON 写入文件")
|
|
238
|
+
|
|
239
|
+
update_parser = actions.add_parser("update", help="修改记录(需 API Key 修改权限)")
|
|
240
|
+
update_parser.add_argument("id", type=_record_id, help="记录 ID")
|
|
241
|
+
update_parser.add_argument("--input", required=True, help="只包含需要修改字段的 JSON 文件;- 表示标准输入")
|
|
242
|
+
update_parser.add_argument("--output", help="将返回的 JSON 写入文件")
|
|
243
|
+
|
|
244
|
+
if resource == "projects":
|
|
245
|
+
request_parser = actions.add_parser("request-change", help="提交项目修改审批(项目经理使用)")
|
|
246
|
+
request_parser.add_argument("id", type=_record_id, help="项目 ID")
|
|
247
|
+
request_parser.add_argument("--input", required=True, help="含拟修改字段及可选 reason 的 JSON 文件;- 表示标准输入")
|
|
248
|
+
request_parser.add_argument("--output", help="将审批单摘要写入文件")
|
|
249
|
+
|
|
250
|
+
if resource in DELETABLE_RESOURCES:
|
|
251
|
+
delete_parser = actions.add_parser("delete", help="删除记录(需 API Key 删除权限)")
|
|
252
|
+
delete_parser.add_argument("id", type=_record_id, help="记录 ID")
|
|
253
|
+
delete_parser.add_argument("--yes", action="store_true", help="确认删除,供已明确授权的非交互操作使用")
|
|
254
|
+
|
|
232
255
|
return parser
|
|
233
256
|
|
|
234
257
|
|
|
@@ -361,10 +384,61 @@ def _query_params(args: argparse.Namespace, *, export: bool = False) -> dict:
|
|
|
361
384
|
return params
|
|
362
385
|
|
|
363
386
|
|
|
387
|
+
def _read_write_payload(source: str) -> dict:
|
|
388
|
+
if source == "-":
|
|
389
|
+
if sys.stdin.isatty():
|
|
390
|
+
raise ConfigError("从标准输入读取 JSON 时,请通过管道或重定向提供内容。")
|
|
391
|
+
stream = getattr(sys.stdin, "buffer", None)
|
|
392
|
+
if stream is not None:
|
|
393
|
+
data = stream.read(MAX_WRITE_JSON_SIZE + 1)
|
|
394
|
+
else:
|
|
395
|
+
data = sys.stdin.read(MAX_WRITE_JSON_SIZE + 1).encode("utf-8")
|
|
396
|
+
else:
|
|
397
|
+
path = Path(source).expanduser()
|
|
398
|
+
with path.open("rb") as stream:
|
|
399
|
+
data = stream.read(MAX_WRITE_JSON_SIZE + 1)
|
|
400
|
+
if len(data) > MAX_WRITE_JSON_SIZE:
|
|
401
|
+
raise ConfigError("写入数据超过 1 MB,请缩小单次请求。")
|
|
402
|
+
try:
|
|
403
|
+
raw = data.decode("utf-8")
|
|
404
|
+
except UnicodeDecodeError as exc:
|
|
405
|
+
raise ConfigError("输入文件必须使用 UTF-8 编码。") from exc
|
|
406
|
+
try:
|
|
407
|
+
payload = json.loads(raw)
|
|
408
|
+
except json.JSONDecodeError as exc:
|
|
409
|
+
raise ConfigError(f"输入不是有效 JSON:第 {exc.lineno} 行第 {exc.colno} 列。") from exc
|
|
410
|
+
if not isinstance(payload, dict) or not payload:
|
|
411
|
+
raise ConfigError("写入数据必须是非空 JSON 对象。")
|
|
412
|
+
return payload
|
|
413
|
+
|
|
414
|
+
|
|
364
415
|
def _data_command(args: argparse.Namespace, store: ConfigStore) -> int:
|
|
365
416
|
client, profile = _api_client(args, store)
|
|
366
417
|
resource = getattr(args, "resource", args.command)
|
|
367
418
|
|
|
419
|
+
if args.action in {"create", "update", "request-change"}:
|
|
420
|
+
payload = _read_write_payload(args.input)
|
|
421
|
+
if args.action == "create":
|
|
422
|
+
result = client.post_json(resource, payload)
|
|
423
|
+
elif args.action == "update":
|
|
424
|
+
result = client.patch_json(f"{resource}/{args.id}", payload)
|
|
425
|
+
else:
|
|
426
|
+
result = client.post_json(f"projects/{args.id}/change-requests", payload)
|
|
427
|
+
write_result(render_result(result, "json", resource, is_list=False), args.output, "json")
|
|
428
|
+
return 0
|
|
429
|
+
|
|
430
|
+
if args.action == "delete":
|
|
431
|
+
if not args.yes:
|
|
432
|
+
if not sys.stdin.isatty():
|
|
433
|
+
raise ConfigError("删除需要确认;在用户明确指定记录后添加 --yes。")
|
|
434
|
+
answer = input(f"即将删除 {resource} #{args.id},可能影响关联记录。请输入记录 ID 确认:")
|
|
435
|
+
if answer.strip() != str(args.id):
|
|
436
|
+
print("已取消删除。")
|
|
437
|
+
return 0
|
|
438
|
+
client.delete(f"{resource}/{args.id}")
|
|
439
|
+
print(f"已删除 {resource} #{args.id}。")
|
|
440
|
+
return 0
|
|
441
|
+
|
|
368
442
|
if args.action == "get":
|
|
369
443
|
payload = client.get_json(f"{resource}/{args.id}")
|
|
370
444
|
content = render_result(payload, args.format, resource, is_list=False)
|
|
@@ -31,15 +31,21 @@ class APIClient:
|
|
|
31
31
|
url = f"{url}?{query}"
|
|
32
32
|
return url
|
|
33
33
|
|
|
34
|
-
def
|
|
34
|
+
def _request_json(self, method: str, path: str, params: dict | None = None, payload: dict | None = None):
|
|
35
|
+
headers = {
|
|
36
|
+
"Accept": "application/json",
|
|
37
|
+
"X-API-Key": self.api_key,
|
|
38
|
+
"User-Agent": f"yswj-internal-cli/{__version__}",
|
|
39
|
+
}
|
|
40
|
+
body_data = None
|
|
41
|
+
if payload is not None:
|
|
42
|
+
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
43
|
+
body_data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
35
44
|
request = Request(
|
|
36
45
|
self._url(path, params),
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"User-Agent": f"yswj-internal-cli/{__version__}",
|
|
41
|
-
},
|
|
42
|
-
method="GET",
|
|
46
|
+
data=body_data,
|
|
47
|
+
headers=headers,
|
|
48
|
+
method=method,
|
|
43
49
|
)
|
|
44
50
|
try:
|
|
45
51
|
with urlopen(request, timeout=self.timeout) as response:
|
|
@@ -49,7 +55,7 @@ class APIClient:
|
|
|
49
55
|
detail = self._error_detail(exc.read())
|
|
50
56
|
messages = {
|
|
51
57
|
401: "API Key 无效、已撤销或已过期。请检查当前环境的 API Key。",
|
|
52
|
-
403: "API Key
|
|
58
|
+
403: "API Key 或账号没有此操作的数据范围、字段或写入权限。",
|
|
53
59
|
404: "记录不存在,或当前账号无权查看该记录。",
|
|
54
60
|
}
|
|
55
61
|
message = messages.get(exc.code, detail or exc.reason or "请求失败")
|
|
@@ -60,6 +66,8 @@ class APIClient:
|
|
|
60
66
|
except TimeoutError as exc:
|
|
61
67
|
raise APIError("连接系统服务超时,请稍后重试。") from exc
|
|
62
68
|
|
|
69
|
+
if not body and method == "DELETE":
|
|
70
|
+
return None
|
|
63
71
|
if "application/json" not in content_type.lower():
|
|
64
72
|
raise APIError("后端未返回 JSON。请检查服务地址是否为系统站点根地址。")
|
|
65
73
|
try:
|
|
@@ -67,6 +75,18 @@ class APIClient:
|
|
|
67
75
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
68
76
|
raise APIError("后端返回的 JSON 格式无效。") from exc
|
|
69
77
|
|
|
78
|
+
def get_json(self, path: str, params: dict | None = None):
|
|
79
|
+
return self._request_json("GET", path, params=params)
|
|
80
|
+
|
|
81
|
+
def post_json(self, path: str, payload: dict):
|
|
82
|
+
return self._request_json("POST", path, payload=payload)
|
|
83
|
+
|
|
84
|
+
def patch_json(self, path: str, payload: dict):
|
|
85
|
+
return self._request_json("PATCH", path, payload=payload)
|
|
86
|
+
|
|
87
|
+
def delete(self, path: str) -> None:
|
|
88
|
+
self._request_json("DELETE", path)
|
|
89
|
+
|
|
70
90
|
@staticmethod
|
|
71
91
|
def _error_detail(body: bytes) -> str:
|
|
72
92
|
if not body:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yswj-internal-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Command-line client for 元生无界内部管理系统
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,7 +8,7 @@ Requires-Dist: keyring<26,>=25.7
|
|
|
8
8
|
|
|
9
9
|
# 元生无界内部管理系统 CLI
|
|
10
10
|
|
|
11
|
-
命令名:`yswj-internal`。CLI 通过系统开放 API
|
|
11
|
+
命令名:`yswj-internal`。CLI 通过系统开放 API 读取和写入数据,权限由 API Key 和服务端账号权限共同控制。
|
|
12
12
|
|
|
13
13
|
## 安装
|
|
14
14
|
|
|
@@ -35,7 +35,7 @@ pipx install ./cli
|
|
|
35
35
|
macOS 或 Windows 用户在系统页面复制自己的 API Key 后,可让 AI 在本机执行一条命令完成安装和配置:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
uvx --from yswj-internal-cli==0.
|
|
38
|
+
uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
需要验证已授权模块时,可加 `--verify projects` 等参数。验证最多请求一条记录,不打印业务数据。其他系统可运行 `yswj-internal setup`,在本地终端的隐藏提示中输入 Key。
|
|
@@ -72,6 +72,31 @@ yswj-internal activities list --created-since 2026-11-01T00:00:00+08:00 --limit
|
|
|
72
72
|
|
|
73
73
|
`export` 会顺序读取所有页并写入指定文件;CSV 按页流式写盘,避免大批数据占满内存,交互终端会显示读取进度。大数据集建议使用 CSV 和增量时间筛选。联系人私密字段、项目财务字段以及各数据范围仍遵循该 Key 的权限;CLI 不会扩大 Key 权限。
|
|
74
74
|
|
|
75
|
+
## 写入命令
|
|
76
|
+
|
|
77
|
+
API Key 默认只读。在系统页面为具体数据模块分别授予新建、修改或删除权限后,CLI 才可执行对应命令;当前账号的网页端业务规则仍然生效。写入内容使用 UTF-8 JSON 文件,`--input -` 可从标准输入读取,但不要将业务数据放进命令参数。
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# leads.json 内容示例:{"name":"新客户商机","company":"示例公司"}
|
|
81
|
+
yswj-internal leads create --input leads.json
|
|
82
|
+
|
|
83
|
+
# change.json 内容示例:{"name":"更新后的商机名"}
|
|
84
|
+
yswj-internal leads update 101 --input change.json
|
|
85
|
+
|
|
86
|
+
# 项目经理提交修改审批,change.json 可包含 status 和 reason
|
|
87
|
+
yswj-internal projects request-change 501 --input change.json
|
|
88
|
+
|
|
89
|
+
# 管理员或项目经理上级可按网页规则直接修改项目
|
|
90
|
+
yswj-internal projects update 501 --input change.json
|
|
91
|
+
|
|
92
|
+
# 删除需要输入记录 ID 确认;非交互脚本需显式指定 --yes
|
|
93
|
+
yswj-internal leads delete 101
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
所有资源均可 `create`、`update`。只有商机、渠道、联系人和跟进记录可 `delete`;项目和待跟进任务通过 `update` 修改 `status`。项目经理不能直接修改项目,须使用 `request-change` 提交审批。联系人电话、微信、邮箱和项目合同编号的写入还需单独的敏感字段权限;写入权限不自动授予读取这些字段的权限。删除商机或渠道会影响关联跟进记录和联系人关联,执行前请核对目标。
|
|
97
|
+
|
|
98
|
+
写入接口的字段、状态码和副作用见 [开放 API 规范](../docs/api-v1.md)。
|
|
99
|
+
|
|
75
100
|
## 命令帮助
|
|
76
101
|
|
|
77
102
|
```bash
|
|
@@ -177,3 +177,67 @@ def test_setup_detects_truncated_key_after_saving():
|
|
|
177
177
|
|
|
178
178
|
with pytest.raises(config_module.ConfigError, match="保存后校验失败"):
|
|
179
179
|
cli_module._save_and_check_key(TruncatingStore(), "prod", secret)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_write_parser_matches_web_delete_abilities():
|
|
183
|
+
parser = create_parser()
|
|
184
|
+
for resource in cli_module.RESOURCES:
|
|
185
|
+
create = parser.parse_args([resource, "create", "--input", "record.json"])
|
|
186
|
+
update = parser.parse_args([resource, "update", "7", "--input", "-"])
|
|
187
|
+
assert (create.action, create.input) == ("create", "record.json")
|
|
188
|
+
assert (update.action, update.id, update.input) == ("update", 7, "-")
|
|
189
|
+
if resource in cli_module.DELETABLE_RESOURCES:
|
|
190
|
+
assert parser.parse_args([resource, "delete", "7", "--yes"]).yes
|
|
191
|
+
else:
|
|
192
|
+
with pytest.raises(SystemExit):
|
|
193
|
+
parser.parse_args([resource, "delete", "7"])
|
|
194
|
+
assert parser.parse_args(["projects", "request-change", "7", "--input", "change.json"]).id == 7
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_cli_create_update_and_delete_use_explicit_payload_and_confirmation(tmp_path, monkeypatch, capsys):
|
|
198
|
+
calls = []
|
|
199
|
+
|
|
200
|
+
class FakeClient:
|
|
201
|
+
def post_json(self, path, payload):
|
|
202
|
+
calls.append(("POST", path, payload))
|
|
203
|
+
return {"id": 7, **payload}
|
|
204
|
+
|
|
205
|
+
def patch_json(self, path, payload):
|
|
206
|
+
calls.append(("PATCH", path, payload))
|
|
207
|
+
return {"id": 7, **payload}
|
|
208
|
+
|
|
209
|
+
def delete(self, path):
|
|
210
|
+
calls.append(("DELETE", path))
|
|
211
|
+
|
|
212
|
+
monkeypatch.setattr(cli_module, "_api_client", lambda args, store: (FakeClient(), "prod"))
|
|
213
|
+
payload_path = tmp_path / "record.json"
|
|
214
|
+
payload_path.write_text('{"name":"新建商机"}', encoding="utf-8")
|
|
215
|
+
assert cli_module.main(["leads", "create", "--input", str(payload_path)]) == 0
|
|
216
|
+
monkeypatch.setattr(cli_module.sys, "stdin", io.StringIO('{"name":"更新商机"}'))
|
|
217
|
+
assert cli_module.main(["leads", "update", "7", "--input", "-"]) == 0
|
|
218
|
+
payload_path.write_text('{"status":"active","reason":"客户已启动"}', encoding="utf-8")
|
|
219
|
+
assert cli_module.main(["projects", "request-change", "501", "--input", str(payload_path)]) == 0
|
|
220
|
+
assert cli_module.main(["leads", "delete", "7"]) == 1
|
|
221
|
+
assert cli_module.main(["leads", "delete", "7", "--yes"]) == 0
|
|
222
|
+
assert calls == [
|
|
223
|
+
("POST", "leads", {"name": "新建商机"}),
|
|
224
|
+
("PATCH", "leads/7", {"name": "更新商机"}),
|
|
225
|
+
("POST", "projects/501/change-requests", {"status": "active", "reason": "客户已启动"}),
|
|
226
|
+
("DELETE", "leads/7"),
|
|
227
|
+
]
|
|
228
|
+
out = capsys.readouterr()
|
|
229
|
+
assert "新建商机" in out.out and "更新商机" in out.out
|
|
230
|
+
assert "删除需要确认" in out.err
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_write_payload_rejects_invalid_and_oversized_input(tmp_path):
|
|
234
|
+
payload_path = tmp_path / "record.json"
|
|
235
|
+
payload_path.write_text('[]', encoding="utf-8")
|
|
236
|
+
with pytest.raises(config_module.ConfigError, match="非空 JSON 对象"):
|
|
237
|
+
cli_module._read_write_payload(str(payload_path))
|
|
238
|
+
payload_path.write_text('{"name":', encoding="utf-8")
|
|
239
|
+
with pytest.raises(config_module.ConfigError, match="有效 JSON"):
|
|
240
|
+
cli_module._read_write_payload(str(payload_path))
|
|
241
|
+
payload_path.write_text("x" * (cli_module.MAX_WRITE_JSON_SIZE + 1), encoding="utf-8")
|
|
242
|
+
with pytest.raises(config_module.ConfigError, match="超过 1 MB"):
|
|
243
|
+
cli_module._read_write_payload(str(payload_path))
|
|
@@ -9,9 +9,9 @@ from yswj_internal_cli.client import APIClient, APIError
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class FakeResponse:
|
|
12
|
-
def __init__(self, payload):
|
|
13
|
-
self.body = json.dumps(payload).encode("utf-8")
|
|
14
|
-
self.headers = {"Content-Type": "application/json"}
|
|
12
|
+
def __init__(self, payload=None, *, empty=False):
|
|
13
|
+
self.body = b"" if empty else json.dumps(payload).encode("utf-8")
|
|
14
|
+
self.headers = {"Content-Type": "" if empty else "application/json"}
|
|
15
15
|
|
|
16
16
|
def __enter__(self):
|
|
17
17
|
return self
|
|
@@ -78,7 +78,7 @@ def test_single_page_list_does_not_fetch_the_next_page(monkeypatch):
|
|
|
78
78
|
("status", "expected"),
|
|
79
79
|
[
|
|
80
80
|
(401, "API Key 无效、已撤销或已过期"),
|
|
81
|
-
(403, "
|
|
81
|
+
(403, "没有此操作的数据范围、字段或写入权限"),
|
|
82
82
|
],
|
|
83
83
|
)
|
|
84
84
|
def test_auth_errors_are_actionable_and_do_not_expose_the_key(monkeypatch, status, expected):
|
|
@@ -95,3 +95,29 @@ def test_auth_errors_are_actionable_and_do_not_expose_the_key(monkeypatch, statu
|
|
|
95
95
|
|
|
96
96
|
assert expected in str(error.value)
|
|
97
97
|
assert "crm_live_sensitive-secret" not in str(error.value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_write_methods_send_json_and_delete_accepts_empty_204(monkeypatch):
|
|
101
|
+
requests = []
|
|
102
|
+
responses = [FakeResponse({"id": 8}), FakeResponse({"id": 8, "name": "更新"}), FakeResponse(empty=True)]
|
|
103
|
+
|
|
104
|
+
def fake_urlopen(request, timeout):
|
|
105
|
+
requests.append(request)
|
|
106
|
+
assert timeout == 30.0
|
|
107
|
+
return responses.pop(0)
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr(client_module, "urlopen", fake_urlopen)
|
|
110
|
+
api = APIClient("https://example.com", "crm_live_sensitive-secret")
|
|
111
|
+
assert api.post_json("leads", {"name": "新建"}) == {"id": 8}
|
|
112
|
+
assert api.patch_json("leads/8", {"name": "更新"})["name"] == "更新"
|
|
113
|
+
assert api.delete("leads/8") is None
|
|
114
|
+
|
|
115
|
+
assert [request.get_method() for request in requests] == ["POST", "PATCH", "DELETE"]
|
|
116
|
+
assert [request.full_url for request in requests] == [
|
|
117
|
+
"https://example.com/api/open/v1/leads",
|
|
118
|
+
"https://example.com/api/open/v1/leads/8",
|
|
119
|
+
"https://example.com/api/open/v1/leads/8",
|
|
120
|
+
]
|
|
121
|
+
assert [json.loads(request.data) for request in requests[:2]] == [{"name": "新建"}, {"name": "更新"}]
|
|
122
|
+
assert all(request.get_header("X-api-key") == "crm_live_sensitive-secret" for request in requests)
|
|
123
|
+
assert all("crm_live_sensitive-secret" not in request.full_url for request in requests)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/requires.txt
RENAMED
|
File without changes
|
{yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|