yswj-internal-cli 0.1.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.0/PKG-INFO +73 -0
- yswj_internal_cli-0.1.0/README.md +65 -0
- yswj_internal_cli-0.1.0/pyproject.toml +23 -0
- yswj_internal_cli-0.1.0/setup.cfg +4 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli/AI_SETUP.md +160 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli/__init__.py +3 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli/__main__.py +4 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli/cli.py +301 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli/client.py +130 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli/config.py +191 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli/output.py +189 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli.egg-info/PKG-INFO +73 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli.egg-info/SOURCES.txt +19 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli.egg-info/dependency_links.txt +1 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli.egg-info/entry_points.txt +2 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli.egg-info/requires.txt +1 -0
- yswj_internal_cli-0.1.0/src/yswj_internal_cli.egg-info/top_level.txt +1 -0
- yswj_internal_cli-0.1.0/tests/test_cli.py +84 -0
- yswj_internal_cli-0.1.0/tests/test_client.py +97 -0
- yswj_internal_cli-0.1.0/tests/test_config.py +99 -0
- yswj_internal_cli-0.1.0/tests/test_output.py +40 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yswj-internal-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for 元生无界内部管理系统
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keyring<26,>=25.7
|
|
8
|
+
|
|
9
|
+
# 元生无界内部管理系统 CLI
|
|
10
|
+
|
|
11
|
+
命令名:`yswj-internal`。CLI 通过系统开放 API 读取数据,权限仍由 API Key 和服务端账号权限控制。
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
需要 Python 3.10 或更新版本。发布到 PyPI 后,建议用 `pipx` 安装隔离环境:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pipx install yswj-internal-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
也可以在虚拟环境中安装:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python -m pip install yswj-internal-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
在本仓库开发时,也可以从本地目录安装:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pipx install ./cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
给 AI 助手配置时,请先阅读 `src/yswj_internal_cli/AI_SETUP.md`。安装后的 Python 包中也包含 `yswj_internal_cli/AI_SETUP.md`。
|
|
34
|
+
|
|
35
|
+
## 配置
|
|
36
|
+
|
|
37
|
+
先在系统的“API Key 管理”页面申请 Key,再设置站点地址和密钥:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
yswj-internal --profile prod config set-server http://39.107.72.34:6611 --activate
|
|
41
|
+
yswj-internal --profile prod config set-key
|
|
42
|
+
yswj-internal config show
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`set-key` 会隐藏输入内容。若系统密钥库可用,Key 会保存到系统密钥库;否则保存到本地配置文件,并在 macOS/Linux 上限制为当前用户可读写。配置文件位于 `~/.config/yswj-internal/config.json`,可用 `YSWJ_INTERNAL_CONFIG` 覆盖。
|
|
46
|
+
|
|
47
|
+
当前线上地址使用 HTTP。HTTP 不加密传输,请优先使用 HTTPS 地址;配置 HTTP 地址时 CLI 会显示提醒。
|
|
48
|
+
|
|
49
|
+
脚本环境也可以通过 `YSWJ_INTERNAL_PROFILE`、`YSWJ_INTERNAL_BASE_URL` 和 `YSWJ_INTERNAL_API_KEY` 临时覆盖配置。不要把 Key 放在 URL、代码仓库或命令行参数中。
|
|
50
|
+
|
|
51
|
+
## 数据命令
|
|
52
|
+
|
|
53
|
+
列表默认输出表格;需要程序处理时选择 JSON,需要电子表格时选择 CSV。
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
yswj-internal leads list
|
|
57
|
+
yswj-internal --profile prod leads list --all --updated-since 2026-11-01T00:00:00+08:00 --format json
|
|
58
|
+
yswj-internal contacts get 201 --format json
|
|
59
|
+
yswj-internal projects export --format csv --output projects.csv --updated-since 2026-11-01T00:00:00+08:00
|
|
60
|
+
yswj-internal activities list --created-since 2026-11-01T00:00:00+08:00 --limit 50
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
支持 `leads`、`channels`、`contacts`、`activities`、`follow-up-tasks` 和 `projects`。普通列表默认只取 100 条,最多 500 条;全量导出默认每页 500 条并顺序翻页,减少请求次数。全量读取仅在显式使用 `--all` 或 `export` 时进行。商机、渠道、联系人、待跟进任务和项目支持 `--updated-since`;跟进记录支持 `--created-since`。时间必须包含时区;定期同步建议优先使用增量时间筛选。
|
|
64
|
+
|
|
65
|
+
`export` 会顺序读取所有页并写入指定文件;CSV 按页流式写盘,避免大批数据占满内存,交互终端会显示读取进度。大数据集建议使用 CSV 和增量时间筛选。联系人私密字段、项目财务字段以及各数据范围仍遵循该 Key 的权限;CLI 不会扩大 Key 权限。
|
|
66
|
+
|
|
67
|
+
## 命令帮助
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
yswj-internal --help
|
|
71
|
+
yswj-internal leads list --help
|
|
72
|
+
yswj-internal config --help
|
|
73
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# 元生无界内部管理系统 CLI
|
|
2
|
+
|
|
3
|
+
命令名:`yswj-internal`。CLI 通过系统开放 API 读取数据,权限仍由 API Key 和服务端账号权限控制。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
需要 Python 3.10 或更新版本。发布到 PyPI 后,建议用 `pipx` 安装隔离环境:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install yswj-internal-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
也可以在虚拟环境中安装:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m pip install yswj-internal-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
在本仓库开发时,也可以从本地目录安装:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pipx install ./cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
给 AI 助手配置时,请先阅读 `src/yswj_internal_cli/AI_SETUP.md`。安装后的 Python 包中也包含 `yswj_internal_cli/AI_SETUP.md`。
|
|
26
|
+
|
|
27
|
+
## 配置
|
|
28
|
+
|
|
29
|
+
先在系统的“API Key 管理”页面申请 Key,再设置站点地址和密钥:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
yswj-internal --profile prod config set-server http://39.107.72.34:6611 --activate
|
|
33
|
+
yswj-internal --profile prod config set-key
|
|
34
|
+
yswj-internal config show
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`set-key` 会隐藏输入内容。若系统密钥库可用,Key 会保存到系统密钥库;否则保存到本地配置文件,并在 macOS/Linux 上限制为当前用户可读写。配置文件位于 `~/.config/yswj-internal/config.json`,可用 `YSWJ_INTERNAL_CONFIG` 覆盖。
|
|
38
|
+
|
|
39
|
+
当前线上地址使用 HTTP。HTTP 不加密传输,请优先使用 HTTPS 地址;配置 HTTP 地址时 CLI 会显示提醒。
|
|
40
|
+
|
|
41
|
+
脚本环境也可以通过 `YSWJ_INTERNAL_PROFILE`、`YSWJ_INTERNAL_BASE_URL` 和 `YSWJ_INTERNAL_API_KEY` 临时覆盖配置。不要把 Key 放在 URL、代码仓库或命令行参数中。
|
|
42
|
+
|
|
43
|
+
## 数据命令
|
|
44
|
+
|
|
45
|
+
列表默认输出表格;需要程序处理时选择 JSON,需要电子表格时选择 CSV。
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
yswj-internal leads list
|
|
49
|
+
yswj-internal --profile prod leads list --all --updated-since 2026-11-01T00:00:00+08:00 --format json
|
|
50
|
+
yswj-internal contacts get 201 --format json
|
|
51
|
+
yswj-internal projects export --format csv --output projects.csv --updated-since 2026-11-01T00:00:00+08:00
|
|
52
|
+
yswj-internal activities list --created-since 2026-11-01T00:00:00+08:00 --limit 50
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
支持 `leads`、`channels`、`contacts`、`activities`、`follow-up-tasks` 和 `projects`。普通列表默认只取 100 条,最多 500 条;全量导出默认每页 500 条并顺序翻页,减少请求次数。全量读取仅在显式使用 `--all` 或 `export` 时进行。商机、渠道、联系人、待跟进任务和项目支持 `--updated-since`;跟进记录支持 `--created-since`。时间必须包含时区;定期同步建议优先使用增量时间筛选。
|
|
56
|
+
|
|
57
|
+
`export` 会顺序读取所有页并写入指定文件;CSV 按页流式写盘,避免大批数据占满内存,交互终端会显示读取进度。大数据集建议使用 CSV 和增量时间筛选。联系人私密字段、项目财务字段以及各数据范围仍遵循该 Key 的权限;CLI 不会扩大 Key 权限。
|
|
58
|
+
|
|
59
|
+
## 命令帮助
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
yswj-internal --help
|
|
63
|
+
yswj-internal leads list --help
|
|
64
|
+
yswj-internal config --help
|
|
65
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yswj-internal-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Command-line client for 元生无界内部管理系统"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["keyring>=25.7,<26"]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
yswj-internal = "yswj_internal_cli.cli:main"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.package-data]
|
|
20
|
+
yswj_internal_cli = ["AI_SETUP.md"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# AI 助手配置指南:元生无界内部管理系统 CLI
|
|
2
|
+
|
|
3
|
+
本文供 AI 助手读取并按步骤配置 `yswj-internal-cli`。CLI 包名是 `yswj-internal-cli`,实际命令名是 `yswj-internal`。
|
|
4
|
+
|
|
5
|
+
## 目标和边界
|
|
6
|
+
|
|
7
|
+
- 配置 CLI 连接用户有权访问的元生无界内部管理系统,并做最小化连通性验证。
|
|
8
|
+
- CLI 仅通过开放 API 读取数据。API Key 的数据范围和权限由系统服务端执行,CLI 不会增加账号权限。
|
|
9
|
+
- 默认只验证配置和认证,不读取、导出或汇总业务数据。只有用户另行提出数据任务时,才读取所需数据。
|
|
10
|
+
- API Key 是凭据。不得让用户把完整 Key 发在 AI 对话中;不得把 Key 放在命令参数、URL、代码、日志、仓库或普通输出中,也不得读取配置文件来查看 Key。
|
|
11
|
+
|
|
12
|
+
## 需要用户完成的事项
|
|
13
|
+
|
|
14
|
+
1. 若尚未安装,请在可访问系统的电脑上连接公司的 OpenVPN。线上系统地址为 `http://39.107.72.34:6611`。连接失败时先确认 VPN 状态和网络路由,再排查 CLI。
|
|
15
|
+
2. 用户在系统的“API Key 管理”页面创建自己的 Key,选择实际需要的数据范围、只读权限和有效期。完整 Key 创建后只显示一次。
|
|
16
|
+
3. 用户在 AI 能访问的本地终端中直接输入 Key。优先使用 CLI 的隐藏输入提示,不要在 AI 聊天窗口粘贴 Key。
|
|
17
|
+
|
|
18
|
+
若用户使用的是测试或其他环境,使用用户明确给出的站点根地址。站点地址只包含协议、主机和可选端口;不要附加 `/api`、`/api/open/v1`、路径、查询参数或片段。
|
|
19
|
+
|
|
20
|
+
## AI 配置流程
|
|
21
|
+
|
|
22
|
+
### 1. 检查或安装 CLI
|
|
23
|
+
|
|
24
|
+
先检查命令是否已安装:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
yswj-internal --version
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
若命令不存在,在联网且允许访问 PyPI 的环境中,优先使用已有的隔离安装工具:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pipx install yswj-internal-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
或:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv tool install yswj-internal-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
没有上述工具时,可在用户自己的 Python 环境中安装:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python3 -m pip install --user yswj-internal-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
安装后重新检查 `yswj-internal --version`。若 shell 找不到命令,确认安装目录已加入 `PATH`,或重新打开终端。不要在未经用户同意时使用管理员权限安装。
|
|
49
|
+
|
|
50
|
+
若 PyPI 尚未发布此包或当前环境无法访问 PyPI,不要安装同名的其他包;说明原因,并请用户提供可信的内部安装来源。
|
|
51
|
+
|
|
52
|
+
### 2. 配置服务地址
|
|
53
|
+
|
|
54
|
+
生产环境配置为 `prod` profile:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
yswj-internal --profile prod config set-server http://39.107.72.34:6611 --activate
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
当前线上入口使用 HTTP,因此 CLI 会提示传输未加密。连接线上系统时先确认 OpenVPN 已连接,并只在受信任的 VPN 网络内使用。若用户提供了 HTTPS 站点地址,则按用户提供的 HTTPS 地址配置。
|
|
61
|
+
|
|
62
|
+
### 3. 安全保存 API Key
|
|
63
|
+
|
|
64
|
+
引导用户在本地终端运行:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
yswj-internal --profile prod config set-key
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
CLI 会隐藏输入并要求确认。AI 不应代替用户收集或回显 Key。如果 AI 不能安全地把终端提示交给用户,应暂停在此步骤,请用户自己运行上面的命令;不要改用在聊天中发送密钥的方式。
|
|
71
|
+
|
|
72
|
+
`config set-key --stdin` 仅供已有安全密钥管理器直接提供标准输入时使用。不要用会将 Key 写入 shell 历史、进程参数或日志的 `echo`、`printf`、命令替换或重定向来拼接输入。
|
|
73
|
+
|
|
74
|
+
CLI 优先将 Key 保存到操作系统密钥库;密钥库不可用时,会将 Key 保存到权限受限的配置文件。默认位置为:
|
|
75
|
+
|
|
76
|
+
- macOS/Linux:`~/.config/yswj-internal/config.json`(或 `$XDG_CONFIG_HOME/yswj-internal/config.json`)
|
|
77
|
+
- Windows:`%APPDATA%\yswj-internal\config.json`
|
|
78
|
+
|
|
79
|
+
可用 `YSWJ_INTERNAL_CONFIG` 指定其他配置文件路径。AI 可运行 `config path` 查看路径,但不得打开文件读取密钥。
|
|
80
|
+
|
|
81
|
+
### 4. 检查配置
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
yswj-internal --profile prod config show
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
确认显示正确的服务地址,且 API Key 状态为“系统密钥库”“本地权限受限配置文件”或“环境变量已设置”。此命令不会显示完整 Key。不要通过 `cat`、`type`、编辑器或脚本读取配置文件。
|
|
88
|
+
|
|
89
|
+
支持多个环境;其他环境使用单独的 profile,例如 `staging`:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
yswj-internal --profile staging config set-server https://staging.example.com --activate
|
|
93
|
+
yswj-internal --profile staging config set-key
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
不要把上面的示例测试地址当作真实地址。使用用户给出的环境地址。
|
|
97
|
+
|
|
98
|
+
### 5. 最小化验证连接和权限
|
|
99
|
+
|
|
100
|
+
CLI 没有独立的认证探测接口。只有在用户已知某个被 Key 授权的数据范围时,才使用对应模块验证。一次只取一条记录、只输出 ID:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
yswj-internal --profile prod projects list --limit 1 --columns id
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
可选模块为 `leads`、`channels`、`contacts`、`activities`、`follow-up-tasks` 和 `projects`。将示例中的 `projects` 换成 Key 明确获准的模块。如果用户未说明授权模块,不要轮流探测所有模块;仅检查 `config show`,并说明需要一个已授权模块才能验证 API 权限。
|
|
107
|
+
|
|
108
|
+
单条列表请求可能返回空结果;只要命令成功,仍可证明服务可达且 Key 有该模块的读取权限。不要自动加 `--all`、`export`,也不要输出其他业务字段作为连通性检查。
|
|
109
|
+
|
|
110
|
+
## 高效读取规则
|
|
111
|
+
|
|
112
|
+
- 普通 `list` 默认最多请求 100 条;明确需要完整集合时才使用 `--all` 或 `export`。
|
|
113
|
+
- 查询一个资源时优先设置有时区的增量条件,减少数据库和网络负担。例如:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
yswj-internal --profile prod leads list --updated-since 2026-11-01T00:00:00+08:00 --limit 100
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- 商机、渠道、联系人、待跟进任务和项目支持 `--updated-since`;跟进记录使用 `--created-since`。时间须为带时区的 ISO 8601 格式。
|
|
120
|
+
- 大批量数据优先使用 CSV 导出和增量时间筛选。导出文件包含业务数据,应保存在用户指定的位置,不要未经请求上传、提交代码或发送给第三方。
|
|
121
|
+
- JSON 适合后续程序处理;表格输出适合人工查看。只请求任务所需字段时可使用 `--columns`(仅适用于表格列表)。
|
|
122
|
+
- 对 401、403 或网络错误进行诊断时,不要重试密钥猜测或扩大数据范围。
|
|
123
|
+
|
|
124
|
+
## 常见错误处理
|
|
125
|
+
|
|
126
|
+
| CLI 结果 | AI 应采取的动作 |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| 无法连接、超时、连接被拒绝 | 先确认 OpenVPN 已连接,再确认服务地址为站点根地址;必要时做一次重试。不要修改 API Key。 |
|
|
129
|
+
| HTTP 401 | Key 无效、已撤销或过期。让用户在系统页面检查或重新签发,然后通过隐藏提示更新。不要要求用户把 Key 发到聊天中。 |
|
|
130
|
+
| HTTP 403 | Key 未获准访问当前模块或字段。确认测试模块确实在 Key 范围内;请用户通过系统页面调整授权,不要尝试其他模块来枚举数据。 |
|
|
131
|
+
| HTTP 404 | 确认地址没有多余路径,ID 和资源名称正确;API 路径由 CLI 自动添加。 |
|
|
132
|
+
| HTTP 500 或非 JSON 响应 | 可能是服务端异常或站点地址不正确。保留状态码和不含密钥的错误内容,提示用户联系系统维护人员。 |
|
|
133
|
+
| 配置未设置 | 先设置 profile 的站点地址,再通过隐藏提示保存 Key。 |
|
|
134
|
+
|
|
135
|
+
诊断或汇报时不得复制 Authorization 请求头、完整 URL 中的凭据、完整 Key 或业务记录内容。
|
|
136
|
+
|
|
137
|
+
## 参考命令
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# 查看命令说明
|
|
141
|
+
yswj-internal --help
|
|
142
|
+
yswj-internal leads list --help
|
|
143
|
+
|
|
144
|
+
# 仅列出一页,默认 100 条
|
|
145
|
+
yswj-internal --profile prod leads list --limit 50
|
|
146
|
+
|
|
147
|
+
# 读取一条记录
|
|
148
|
+
yswj-internal --profile prod contacts get 201 --format json
|
|
149
|
+
|
|
150
|
+
# 在用户要求导出时,按增量时间筛选并写入 CSV
|
|
151
|
+
yswj-internal --profile prod projects export \
|
|
152
|
+
--updated-since 2026-11-01T00:00:00+08:00 \
|
|
153
|
+
--format csv --output projects.csv
|
|
154
|
+
|
|
155
|
+
# 更换或删除当前 profile 的密钥
|
|
156
|
+
yswj-internal --profile prod config set-key
|
|
157
|
+
yswj-internal --profile prod config remove-key
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
脚本也可使用 `YSWJ_INTERNAL_PROFILE`、`YSWJ_INTERNAL_BASE_URL` 和 `YSWJ_INTERNAL_API_KEY` 覆盖配置。优先使用隐藏提示保存 Key;若确需环境变量,应通过秘密管理器注入,避免将其写入持久化 shell 配置或日志。
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from getpass import getpass
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .client import APIClient, APIError
|
|
12
|
+
from .config import ConfigError, ConfigStore, normalize_server_url
|
|
13
|
+
from .output import pagination_summary, render_result, stream_csv_export, write_result
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RESOURCE_FILTERS = {
|
|
17
|
+
"leads": "updated_since",
|
|
18
|
+
"channels": "updated_since",
|
|
19
|
+
"contacts": "updated_since",
|
|
20
|
+
"activities": "created_since",
|
|
21
|
+
"follow-up-tasks": "updated_since",
|
|
22
|
+
"projects": "updated_since",
|
|
23
|
+
}
|
|
24
|
+
RESOURCES = tuple(RESOURCE_FILTERS)
|
|
25
|
+
MAX_PAGE_SIZE = 500
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _page_limit(value: str) -> int:
|
|
29
|
+
try:
|
|
30
|
+
parsed = int(value)
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise argparse.ArgumentTypeError("每页条数必须是整数。") from exc
|
|
33
|
+
if not 1 <= parsed <= MAX_PAGE_SIZE:
|
|
34
|
+
raise argparse.ArgumentTypeError("每页条数范围为 1 到 500。")
|
|
35
|
+
return parsed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _offset(value: str) -> int:
|
|
39
|
+
try:
|
|
40
|
+
parsed = int(value)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise argparse.ArgumentTypeError("偏移量必须是整数。") from exc
|
|
43
|
+
if parsed < 0:
|
|
44
|
+
raise argparse.ArgumentTypeError("偏移量不能小于 0。")
|
|
45
|
+
return parsed
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _record_id(value: str) -> int:
|
|
49
|
+
try:
|
|
50
|
+
parsed = int(value)
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
raise argparse.ArgumentTypeError("记录 ID 必须是整数。") from exc
|
|
53
|
+
if parsed <= 0:
|
|
54
|
+
raise argparse.ArgumentTypeError("记录 ID 必须大于 0。")
|
|
55
|
+
return parsed
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _aware_timestamp(value: str) -> str:
|
|
59
|
+
text = value.strip()
|
|
60
|
+
normalized = f"{text[:-1]}+00:00" if text.endswith(("Z", "z")) else text
|
|
61
|
+
try:
|
|
62
|
+
parsed = datetime.fromisoformat(normalized)
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
raise argparse.ArgumentTypeError("时间请使用 ISO 8601 格式,例如 2026-11-01T00:00:00+08:00。") from exc
|
|
65
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
66
|
+
raise argparse.ArgumentTypeError("时间必须包含时区,例如 +08:00 或 Z。")
|
|
67
|
+
return text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _columns(value: str) -> list[str]:
|
|
71
|
+
columns = [column.strip() for column in value.split(",") if column.strip()]
|
|
72
|
+
if not columns:
|
|
73
|
+
raise argparse.ArgumentTypeError("至少指定一个字段名。")
|
|
74
|
+
return columns
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _add_list_options(parser: argparse.ArgumentParser, resource: str, *, export: bool = False) -> None:
|
|
78
|
+
parser.add_argument("--limit", type=_page_limit, default=None, help="每页条数(1–500;默认列表 100,全量导出 500)")
|
|
79
|
+
parser.add_argument("--offset", type=_offset, default=0, help="起始偏移量,默认 0")
|
|
80
|
+
filter_name = RESOURCE_FILTERS[resource]
|
|
81
|
+
option_name = "--updated-since" if filter_name == "updated_since" else "--created-since"
|
|
82
|
+
parser.add_argument(option_name, dest=filter_name, type=_aware_timestamp, help="只读取此时间及之后的变更,时间必须含时区")
|
|
83
|
+
if not export:
|
|
84
|
+
parser.add_argument("--all", action="store_true", help="自动读取后续分页;普通列表默认只读取一页")
|
|
85
|
+
parser.add_argument("--format", choices=("table", "json", "csv"), default="table", help="输出格式,默认 table")
|
|
86
|
+
parser.add_argument("--columns", type=_columns, help="表格字段,例如 id,name,updated_at")
|
|
87
|
+
parser.add_argument("--output", help="将结果写入文件")
|
|
88
|
+
else:
|
|
89
|
+
parser.add_argument("--format", choices=("json", "csv"), default="csv", help="导出格式,默认 csv")
|
|
90
|
+
parser.add_argument("--output", required=True, help="导出文件路径")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
94
|
+
parser = argparse.ArgumentParser(
|
|
95
|
+
prog="yswj-internal",
|
|
96
|
+
description="元生无界内部管理系统只读数据 CLI。",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument("--version", action="version", version=f"yswj-internal {__version__}")
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--profile",
|
|
101
|
+
default=os.environ.get("YSWJ_INTERNAL_PROFILE"),
|
|
102
|
+
help="使用指定环境,例如 prod;默认使用当前环境,也可设置 YSWJ_INTERNAL_PROFILE",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument("--config", help="配置文件路径,也可通过 YSWJ_INTERNAL_CONFIG 指定")
|
|
105
|
+
root = parser.add_subparsers(dest="command", required=True)
|
|
106
|
+
|
|
107
|
+
config_parser = root.add_parser("config", help="配置服务地址、API Key 和环境")
|
|
108
|
+
config_commands = config_parser.add_subparsers(dest="config_action", required=True)
|
|
109
|
+
|
|
110
|
+
set_server = config_commands.add_parser("set-server", help="设置当前环境的系统站点地址")
|
|
111
|
+
set_server.add_argument("url", help="系统站点根地址,例如 http://39.107.72.34:6611")
|
|
112
|
+
set_server.add_argument("--activate", action="store_true", help="设置后将此环境设为当前环境")
|
|
113
|
+
|
|
114
|
+
set_key = config_commands.add_parser("set-key", help="安全保存 API Key")
|
|
115
|
+
set_key.add_argument("--stdin", action="store_true", help="从标准输入读取 Key,供密钥管理脚本调用")
|
|
116
|
+
|
|
117
|
+
use_profile = config_commands.add_parser("use", help="切换当前环境")
|
|
118
|
+
use_profile.add_argument("name", help="环境名称")
|
|
119
|
+
config_commands.add_parser("show", help="显示环境配置和 Key 保存状态(不会显示完整 Key)")
|
|
120
|
+
config_commands.add_parser("remove-key", help="删除当前环境保存的 API Key")
|
|
121
|
+
config_commands.add_parser("path", help="显示配置文件路径")
|
|
122
|
+
|
|
123
|
+
for resource in RESOURCES:
|
|
124
|
+
resource_parser = root.add_parser(resource, help=f"读取 {resource} 数据")
|
|
125
|
+
actions = resource_parser.add_subparsers(dest="action", required=True)
|
|
126
|
+
|
|
127
|
+
list_parser = actions.add_parser("list", help="读取列表")
|
|
128
|
+
_add_list_options(list_parser, resource)
|
|
129
|
+
|
|
130
|
+
get_parser = actions.add_parser("get", help="读取单条记录")
|
|
131
|
+
get_parser.add_argument("id", type=_record_id, help="记录 ID")
|
|
132
|
+
get_parser.add_argument("--format", choices=("json", "table"), default="json", help="输出格式,默认 json")
|
|
133
|
+
get_parser.add_argument("--output", help="将结果写入文件")
|
|
134
|
+
|
|
135
|
+
export_parser = actions.add_parser("export", help="读取所有分页并导出")
|
|
136
|
+
_add_list_options(export_parser, resource, export=True)
|
|
137
|
+
|
|
138
|
+
return parser
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _selected_profile(store: ConfigStore, requested: str | None) -> str:
|
|
142
|
+
return store.active_profile(requested)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _config_command(args: argparse.Namespace, store: ConfigStore) -> int:
|
|
146
|
+
profile = _selected_profile(store, args.profile)
|
|
147
|
+
|
|
148
|
+
if args.config_action == "set-server":
|
|
149
|
+
server_url = normalize_server_url(args.url)
|
|
150
|
+
store.set_server(profile, server_url, activate=args.activate)
|
|
151
|
+
suffix = ",并设为当前环境" if args.activate else ""
|
|
152
|
+
print(f"已设置环境 {profile} 的服务地址:{server_url}{suffix}")
|
|
153
|
+
if server_url.startswith("http://"):
|
|
154
|
+
print("警告:HTTP 连接未加密,API Key 可能被网络中间人读取;生产环境建议使用 HTTPS。", file=sys.stderr)
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
if args.config_action == "set-key":
|
|
158
|
+
if args.stdin:
|
|
159
|
+
secret = sys.stdin.readline().rstrip("\r\n")
|
|
160
|
+
else:
|
|
161
|
+
if not sys.stdin.isatty():
|
|
162
|
+
raise ConfigError("非交互环境请使用 `config set-key --stdin`。")
|
|
163
|
+
secret = getpass("请输入 API Key:")
|
|
164
|
+
confirmation = getpass("请再次输入 API Key:")
|
|
165
|
+
if secret != confirmation:
|
|
166
|
+
raise ConfigError("两次输入的 API Key 不一致。")
|
|
167
|
+
storage = store.set_api_key(profile, secret)
|
|
168
|
+
if storage == "keyring":
|
|
169
|
+
print(f"已为环境 {profile} 保存 API Key 到系统密钥库。")
|
|
170
|
+
else:
|
|
171
|
+
print(f"系统密钥库不可用,API Key 已保存到权限受限的配置文件:{store.path}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
if args.config_action == "use":
|
|
175
|
+
store.set_active_profile(args.name)
|
|
176
|
+
print(f"当前环境已切换为 {args.name}。")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
if args.config_action == "remove-key":
|
|
180
|
+
removed = store.remove_api_key(profile)
|
|
181
|
+
print(f"已删除环境 {profile} 的 API Key。" if removed else f"环境 {profile} 没有保存 API Key。")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
if args.config_action == "path":
|
|
185
|
+
print(store.path)
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
if args.config_action == "show":
|
|
189
|
+
data = store.load()
|
|
190
|
+
names = [profile] if args.profile else sorted(data["profiles"])
|
|
191
|
+
if not names:
|
|
192
|
+
print(f"尚未配置环境。配置文件:{store.path}")
|
|
193
|
+
return 0
|
|
194
|
+
active = data.get("active_profile", "default")
|
|
195
|
+
for name in names:
|
|
196
|
+
item = store.profile(name)
|
|
197
|
+
marker = "(当前)" if name == active else ""
|
|
198
|
+
server_url = item.get("server_url", "未设置")
|
|
199
|
+
if os.environ.get("YSWJ_INTERNAL_API_KEY") and name == profile:
|
|
200
|
+
key_state = "环境变量已设置"
|
|
201
|
+
elif item.get("key_storage") == "keyring":
|
|
202
|
+
key_state = "系统密钥库"
|
|
203
|
+
elif item.get("api_key"):
|
|
204
|
+
key_state = "本地权限受限配置文件"
|
|
205
|
+
else:
|
|
206
|
+
key_state = "未设置"
|
|
207
|
+
print(f"{name}{marker}\n 服务地址:{server_url}\n API Key:{key_state}")
|
|
208
|
+
print(f"配置文件:{store.path}")
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _api_client(args: argparse.Namespace, store: ConfigStore) -> tuple[APIClient, str]:
|
|
215
|
+
profile = _selected_profile(store, args.profile)
|
|
216
|
+
item = store.profile(profile)
|
|
217
|
+
raw_url = os.environ.get("YSWJ_INTERNAL_BASE_URL") or item.get("server_url")
|
|
218
|
+
if not raw_url:
|
|
219
|
+
raise ConfigError(
|
|
220
|
+
f"环境 {profile} 未设置服务地址。运行 `yswj-internal --profile {profile} config set-server <系统站点根地址>`。"
|
|
221
|
+
)
|
|
222
|
+
server_url = normalize_server_url(raw_url)
|
|
223
|
+
|
|
224
|
+
api_key = os.environ.get("YSWJ_INTERNAL_API_KEY") or store.api_key(profile)
|
|
225
|
+
if not api_key:
|
|
226
|
+
raise ConfigError(
|
|
227
|
+
f"环境 {profile} 未设置 API Key。请先在系统申请 Key,再运行 `yswj-internal --profile {profile} config set-key`。"
|
|
228
|
+
)
|
|
229
|
+
return APIClient(server_url=server_url, api_key=api_key), profile
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _query_params(args: argparse.Namespace, *, export: bool = False) -> dict:
|
|
233
|
+
if args.limit is not None:
|
|
234
|
+
limit = args.limit
|
|
235
|
+
elif export or getattr(args, "all", False):
|
|
236
|
+
limit = MAX_PAGE_SIZE
|
|
237
|
+
else:
|
|
238
|
+
limit = 100
|
|
239
|
+
params = {"limit": limit, "offset": args.offset}
|
|
240
|
+
resource = getattr(args, "resource", args.command)
|
|
241
|
+
filter_name = RESOURCE_FILTERS[resource]
|
|
242
|
+
filter_value = getattr(args, filter_name, None)
|
|
243
|
+
if filter_value:
|
|
244
|
+
params[filter_name] = filter_value
|
|
245
|
+
return params
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _data_command(args: argparse.Namespace, store: ConfigStore) -> int:
|
|
249
|
+
client, profile = _api_client(args, store)
|
|
250
|
+
resource = getattr(args, "resource", args.command)
|
|
251
|
+
|
|
252
|
+
if args.action == "get":
|
|
253
|
+
payload = client.get_json(f"{resource}/{args.id}")
|
|
254
|
+
content = render_result(payload, args.format, resource, is_list=False)
|
|
255
|
+
write_result(content, args.output, args.format)
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
is_export = args.action == "export"
|
|
259
|
+
params = _query_params(args, export=is_export)
|
|
260
|
+
output_format = args.format
|
|
261
|
+
output_path = args.output
|
|
262
|
+
stream_csv = output_format == "csv" and (is_export or getattr(args, "all", False))
|
|
263
|
+
|
|
264
|
+
if getattr(args, "columns", None) and output_format != "table":
|
|
265
|
+
raise ConfigError("--columns 仅适用于 table 输出。")
|
|
266
|
+
|
|
267
|
+
if stream_csv:
|
|
268
|
+
count = stream_csv_export(client, resource, params, output_path)
|
|
269
|
+
if output_path:
|
|
270
|
+
print(f"已导出 {count} 条记录到 {output_path}(环境:{profile})。", file=sys.stderr)
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
payload = client.list_records(resource, params, all_pages=is_export or getattr(args, "all", False))
|
|
274
|
+
columns = getattr(args, "columns", None)
|
|
275
|
+
content = render_result(payload, output_format, resource, is_list=True, columns=columns)
|
|
276
|
+
if output_format == "table":
|
|
277
|
+
summary = pagination_summary(payload, all_pages=is_export or getattr(args, "all", False))
|
|
278
|
+
content = f"{content.rstrip()}\n{summary}\n" if payload["data"] else f"{summary}\n"
|
|
279
|
+
write_result(content, output_path, output_format)
|
|
280
|
+
if output_format == "csv" and not is_export and not getattr(args, "all", False):
|
|
281
|
+
print(pagination_summary(payload, all_pages=False), file=sys.stderr)
|
|
282
|
+
if is_export:
|
|
283
|
+
print(f"已导出 {len(payload['data'])} 条记录到 {output_path}(环境:{profile})。", file=sys.stderr)
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv: list[str] | None = None) -> int:
|
|
288
|
+
parser = create_parser()
|
|
289
|
+
args = parser.parse_args(argv)
|
|
290
|
+
store = ConfigStore(args.config)
|
|
291
|
+
try:
|
|
292
|
+
if args.command == "config":
|
|
293
|
+
return _config_command(args, store)
|
|
294
|
+
return _data_command(args, store)
|
|
295
|
+
except (APIError, ConfigError, OSError, ValueError) as exc:
|
|
296
|
+
print(f"错误:{exc}", file=sys.stderr)
|
|
297
|
+
return 1
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
raise SystemExit(main())
|