@sokeai/cli 1.0.61 → 1.0.63
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/package.json +1 -1
- package/scripts/e2e-test.sh +11 -7
- package/skills/SKILL.md +195 -0
- package/skills/soke-ai-training/SKILL.md +93 -0
- package/skills/soke-cli/345/256/211/350/243/205/346/214/207/345/215/227.md +416 -0
- package/skills/soke-course/soke-cli/345/256/211/350/243/205/346/214/207/345/215/227.md +98 -79
- package/skills/soke-learning-map/SKILL.md +24 -14
- package/skills/soke-learning-map/soke-cli/345/256/211/350/243/205/346/214/207/345/215/227.md +99 -80
- package/skills/soke-lesson/SKILL.md +37 -34
- package/skills/soke-photo-gallery/SKILL.md +287 -0
- package/skills/oss-upload/skill.md +0 -50
- package/skills/oss-upload/upload.py +0 -355
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: soke-photo-gallery
|
|
3
|
+
summary: 授客图片库管理(查询分类/图片列表、上传图片、批量删除),上传自动处理 OSS 存储
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
description: "授客图片库管理:查询图片分类列表、查询图片库列表、上传本地图片到图片库(自动先上传 OSS 再创建记录)、批量删除图片。当用户需要管理图片库、上传图片、查询图片、删除图片时使用。"
|
|
6
|
+
metadata:
|
|
7
|
+
requires:
|
|
8
|
+
bins: ["soke-cli"]
|
|
9
|
+
cliHelp: "soke-cli photo-gallery --help"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# 图片库管理 (soke-photo-gallery)
|
|
13
|
+
|
|
14
|
+
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../soke-shared/SKILL.md`](../soke-shared/SKILL.md),其中包含认证、配置、权限处理**
|
|
15
|
+
|
|
16
|
+
## 使用前提
|
|
17
|
+
|
|
18
|
+
1. 已安装 `soke-cli`
|
|
19
|
+
2. 已登录授权: `soke-cli auth login`
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
soke-cli auth login
|
|
23
|
+
soke-cli config show # 验证登录状态
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 核心概念
|
|
27
|
+
|
|
28
|
+
- **图片库(Photo Gallery)**: 企业统一管理图片资源的模块
|
|
29
|
+
- **分类(Category)**: 图片可以归属到某个分类,树形结构,`parent_id="1"` 为顶级
|
|
30
|
+
- **OSS 上传**: 图片文件先上传到阿里云 OSS,再将 OSS 路径注册到图片库
|
|
31
|
+
|
|
32
|
+
## 端到端工作流
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
36
|
+
│ 1. (可选) 查询分类 → 获取 category-id │
|
|
37
|
+
│ 2. 上传图片 → 本地文件 → OSS → 图片库记录 │
|
|
38
|
+
│ 3. 查询图片列表 → 验证上传结果 │
|
|
39
|
+
│ 3.1 查询图片详情 → 获取单张图片完整 URL │
|
|
40
|
+
│ 4. (可选) 批量删除 → 清理图片 │
|
|
41
|
+
└──────────────────────────────────────────────────────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 步骤1: 查询图片分类(可选)
|
|
47
|
+
|
|
48
|
+
上传时如需归入某个分类,先查询分类 ID。
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
soke-cli photo-gallery +list-categories
|
|
52
|
+
|
|
53
|
+
# JSON 输出供解析:
|
|
54
|
+
soke-cli photo-gallery +list-categories --format json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**响应关键字段**:
|
|
58
|
+
- `data.list.all[].uuid` → 分类 ID(上传时传入 `--category-id`)
|
|
59
|
+
- `data.list.all[].title` → 分类名称
|
|
60
|
+
- `data.list.all[].parent_id` → 父分类 ID,`"1"` 为顶级
|
|
61
|
+
|
|
62
|
+
**参数**:
|
|
63
|
+
| 参数 | 必填 | 默认 | 说明 |
|
|
64
|
+
|------|------|------|------|
|
|
65
|
+
| `--page` | 否 | 1 | 页码 |
|
|
66
|
+
| `--limit` | 否 | 100 | 每页数量 |
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 步骤2: 上传图片到图片库
|
|
71
|
+
|
|
72
|
+
`+create` 命令自动完成两步操作:
|
|
73
|
+
1. 调用 OSS 上传脚本将本地文件上传到阿里云 OSS
|
|
74
|
+
2. 调用图片库创建接口将 OSS 路径注册到图片库
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 上传图片,不指定分类
|
|
78
|
+
soke-cli photo-gallery +create \
|
|
79
|
+
--file "./banner.png" \
|
|
80
|
+
--title "首页横幅"
|
|
81
|
+
|
|
82
|
+
# 上传图片,指定分类
|
|
83
|
+
soke-cli photo-gallery +create \
|
|
84
|
+
--file "./banner.png" \
|
|
85
|
+
--title "首页横幅" \
|
|
86
|
+
--category-id "CATEGORY-UUID"
|
|
87
|
+
|
|
88
|
+
# JSON 输出
|
|
89
|
+
soke-cli photo-gallery +create \
|
|
90
|
+
--file "./banner.png" \
|
|
91
|
+
--title "首页横幅" \
|
|
92
|
+
--format json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**参数**:
|
|
96
|
+
| 参数 | 必填 | 默认 | 说明 |
|
|
97
|
+
|------|------|------|------|
|
|
98
|
+
| `--file` | 是 | — | 本地图片文件路径(支持 jpg/jpeg/png,最大 5 MB) |
|
|
99
|
+
| `--title` | 是 | — | 图片标题 |
|
|
100
|
+
| `--category-id` | 否 | empty | 分类 ID,不填则归入「未分类」 |
|
|
101
|
+
|
|
102
|
+
**响应提取**:
|
|
103
|
+
- `data.uuid` → 图片 UUID(批量删除时使用)
|
|
104
|
+
- `data.object` → OSS 对象路径
|
|
105
|
+
- `data.filename` → 文件名
|
|
106
|
+
|
|
107
|
+
**支持的图片格式**: jpg、jpeg、png(最大 5 MB)
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 步骤3: 查询图片库列表
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# 查询全部图片
|
|
115
|
+
soke-cli photo-gallery +list
|
|
116
|
+
|
|
117
|
+
# 按分类筛选
|
|
118
|
+
soke-cli photo-gallery +list --category-id "CATEGORY-UUID"
|
|
119
|
+
|
|
120
|
+
# 按文件名关键词搜索
|
|
121
|
+
soke-cli photo-gallery +list --file-keyword "banner"
|
|
122
|
+
|
|
123
|
+
# 分页
|
|
124
|
+
soke-cli photo-gallery +list --page 1 --limit 20
|
|
125
|
+
|
|
126
|
+
# JSON 输出
|
|
127
|
+
soke-cli photo-gallery +list --format json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**参数**:
|
|
131
|
+
| 参数 | 必填 | 默认 | 说明 |
|
|
132
|
+
|------|------|------|------|
|
|
133
|
+
| `--page` | 否 | 1 | 页码 |
|
|
134
|
+
| `--limit` | 否 | 20 | 每页数量 |
|
|
135
|
+
| `--category-id` | 否 | — | 分类 ID 筛选 |
|
|
136
|
+
| `--file-keyword` | 否 | — | 文件名关键词搜索 |
|
|
137
|
+
| `--from` | 否 | — | 来源筛选 |
|
|
138
|
+
|
|
139
|
+
**响应关键字段**:
|
|
140
|
+
- `data.list[].uuid` → 图片 UUID(删除时使用)
|
|
141
|
+
- `data.list[].filename` → 文件名
|
|
142
|
+
- `data.list[].photoUri` → 图片访问 URL
|
|
143
|
+
- `data.list[].category_title` → 所属分类名称
|
|
144
|
+
- `data.count` → 总数量
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 步骤3.1: 获取图片详情
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
# 获取单张图片详情(含完整访问 URL)
|
|
152
|
+
soke-cli photo-gallery +get --uuid "IMAGE-UUID"
|
|
153
|
+
|
|
154
|
+
# JSON 输出
|
|
155
|
+
soke-cli photo-gallery +get --uuid "IMAGE-UUID" --format json
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**参数**:
|
|
159
|
+
| 参数 | 必填 | 说明 |
|
|
160
|
+
|------|------|------|
|
|
161
|
+
| `--uuid` | 是 | 图片 UUID |
|
|
162
|
+
|
|
163
|
+
**响应关键字段**:
|
|
164
|
+
- `data.uuid` → 图片 UUID
|
|
165
|
+
- `data.storage` → 存储类型(aliyun)
|
|
166
|
+
- `data.photoUri` → 图片完整访问 URL
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 步骤4: 批量删除图片(可选)
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
# 删除单张图片
|
|
174
|
+
soke-cli photo-gallery +batch-delete --uuids "IMAGE-UUID"
|
|
175
|
+
|
|
176
|
+
# 批量删除多张图片(逗号分隔)
|
|
177
|
+
soke-cli photo-gallery +batch-delete --uuids "UUID1,UUID2,UUID3"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**参数**:
|
|
181
|
+
| 参数 | 必填 | 说明 |
|
|
182
|
+
|------|------|------|
|
|
183
|
+
| `--uuids` | 是 | 图片 UUID,多个用逗号分隔 |
|
|
184
|
+
|
|
185
|
+
> **注意**: 删除操作不可恢复,执行前请确认 UUID 正确。
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 可用命令全览
|
|
190
|
+
|
|
191
|
+
| 命令 | 说明 | 风险 |
|
|
192
|
+
|------|------|------|
|
|
193
|
+
| `+list-categories` | 查询图片分类列表 | read |
|
|
194
|
+
| `+list` | 查询图片库列表 | read |
|
|
195
|
+
| `+get` | 获取图片详情(含完整访问 URL) | read |
|
|
196
|
+
| `+create` | 上传图片到图片库 | write |
|
|
197
|
+
| `+batch-delete` | 批量删除图片 | write |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 完整示例
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
# === 1. 查询分类,选一个合适的分类 ===
|
|
205
|
+
soke-cli photo-gallery +list-categories --format json
|
|
206
|
+
|
|
207
|
+
# === 2. 上传图片 ===
|
|
208
|
+
RESULT=$(soke-cli photo-gallery +create \
|
|
209
|
+
--file "./logo.png" \
|
|
210
|
+
--title "公司Logo" \
|
|
211
|
+
--category-id "CATEGORY-UUID" \
|
|
212
|
+
--format json)
|
|
213
|
+
|
|
214
|
+
IMAGE_UUID=$(echo "$RESULT" | jq -r '.data.uuid')
|
|
215
|
+
echo "图片UUID: $IMAGE_UUID"
|
|
216
|
+
|
|
217
|
+
# === 3. 验证上传结果 ===
|
|
218
|
+
soke-cli photo-gallery +list --file-keyword "logo" --format json
|
|
219
|
+
|
|
220
|
+
# === 3.1 获取图片详情和访问 URL ===
|
|
221
|
+
soke-cli photo-gallery +get --uuid "$IMAGE_UUID" --format json
|
|
222
|
+
|
|
223
|
+
# === 4. 批量上传 ===
|
|
224
|
+
for img in banner.jpg logo.png icon.png; do
|
|
225
|
+
soke-cli photo-gallery +create \
|
|
226
|
+
--file "./$img" \
|
|
227
|
+
--title "${img%.*}" \
|
|
228
|
+
--category-id "CATEGORY-UUID"
|
|
229
|
+
done
|
|
230
|
+
|
|
231
|
+
# === 5. 清理不需要的图片 ===
|
|
232
|
+
soke-cli photo-gallery +batch-delete --uuids "$IMAGE_UUID"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## API 接口映射
|
|
238
|
+
|
|
239
|
+
| CLI 命令 | API 路径 | 方法 |
|
|
240
|
+
|----------|----------|------|
|
|
241
|
+
| `+list-categories` | `/skills/photoGallery/category/list` | GET |
|
|
242
|
+
| `+list` | `/skills/photoGallery/list` | GET |
|
|
243
|
+
| `+get` | `/skills/photoGallery/detail/{uuid}` | GET |
|
|
244
|
+
| `+create` | OSS 上传 + `/skills/photoGallery/create` | POST |
|
|
245
|
+
| `+batch-delete` | `/skills/photoGallery/batchDelete` | DELETE |
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## 调试与故障排除
|
|
250
|
+
|
|
251
|
+
### 常见错误速查
|
|
252
|
+
|
|
253
|
+
| 错误 | 原因 | 解决 |
|
|
254
|
+
|------|------|------|
|
|
255
|
+
| `未登录授权` | Token 过期 | `soke-cli auth login` |
|
|
256
|
+
| `文件不存在` | 路径错误 | 使用绝对路径或检查相对路径 |
|
|
257
|
+
| `OSS上传失败` | 网络问题或 Token 失效 | 重新登录后重试 |
|
|
258
|
+
| `不支持的图片格式` | 文件非 jpg/jpeg/png | 转换为支持的格式后重试 |
|
|
259
|
+
| `文件大小超过限制` | 文件超过 5 MB | 压缩图片后重试 |
|
|
260
|
+
|
|
261
|
+
### 调试检查清单
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
# 1. 确认登录状态
|
|
265
|
+
soke-cli config show
|
|
266
|
+
|
|
267
|
+
# 2. 查询图片列表确认上传成功
|
|
268
|
+
soke-cli photo-gallery +list --format json | jq '.data.list[0]'
|
|
269
|
+
|
|
270
|
+
# 3. 始终使用 --format json 获取结构化输出
|
|
271
|
+
soke-cli photo-gallery +list --format json
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## 注意事项
|
|
277
|
+
|
|
278
|
+
1. **上传图片是两步操作**: 先 OSS 上传再注册图片库,两步都必须成功
|
|
279
|
+
2. **格式限制**: 仅支持 jpg/jpeg/png,其他格式会在上传前报错
|
|
280
|
+
3. **大小限制**: 单张图片不超过 5 MB,超过会在上传前报错
|
|
281
|
+
4. **删除不可恢复**: `+batch-delete` 执行前请确认 UUID 正确
|
|
282
|
+
5. **分类 ID 可选**: 不指定 `--category-id` 时图片归入「未分类」(category_id="empty")
|
|
283
|
+
|
|
284
|
+
## 相关文档
|
|
285
|
+
|
|
286
|
+
- [`../soke-shared/SKILL.md`](../soke-shared/SKILL.md) — 认证、配置、权限
|
|
287
|
+
- [`../../docs/photo-gallery-api.md`](../../docs/photo-gallery-api.md) — 图片库接口文档
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: oss-upload
|
|
3
|
-
description: 上传本地文件到阿里云OSS存储
|
|
4
|
-
skill_version: 1.0.0
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# OSS文件上传工具
|
|
8
|
-
|
|
9
|
-
这个skill用于将本地文件上传到阿里云OSS对象存储。
|
|
10
|
-
|
|
11
|
-
## 功能特性
|
|
12
|
-
|
|
13
|
-
- 通过授客AI接口动态获取上传签名
|
|
14
|
-
- 支持表单POST方式上传文件(无需安装阿里云SDK)
|
|
15
|
-
- 自动处理认证和签名
|
|
16
|
-
- 支持自定义文件路径
|
|
17
|
-
- 支持进度显示
|
|
18
|
-
|
|
19
|
-
## 使用方法
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
# 上传单个文件
|
|
23
|
-
soke-cli oss upload <本地文件路径> [--object-name <OSS对象名>]
|
|
24
|
-
|
|
25
|
-
# 示例
|
|
26
|
-
soke-cli oss upload ./document.pdf
|
|
27
|
-
soke-cli oss upload ./document.pdf --object-name uploads/2024/document.pdf
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## 参数说明
|
|
31
|
-
|
|
32
|
-
- `file`: 本地文件路径(必填)
|
|
33
|
-
- `--object-name`: OSS中的对象名称(可选,默认使用文件名)
|
|
34
|
-
|
|
35
|
-
## 配置要求
|
|
36
|
-
|
|
37
|
-
需要先登录授客AI:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
|
|
41
|
-
# 用户登录
|
|
42
|
-
soke-cli auth login
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## 实现原理
|
|
46
|
-
|
|
47
|
-
1. 从配置文件读取用户访问令牌(UserToken)
|
|
48
|
-
2. 调用授客AI接口获取OSS上传签名和配置
|
|
49
|
-
3. 使用获取的签名通过表单方式上传文件到OSS
|
|
50
|
-
4. 无需在本地配置OSS密钥,更安全便捷
|
|
@@ -1,355 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
阿里云OSS文件上传工具
|
|
5
|
-
通过授客AI接口获取签名后上传文件
|
|
6
|
-
仅使用Python标准库,无需安装第三方依赖
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import os
|
|
10
|
-
import sys
|
|
11
|
-
import json
|
|
12
|
-
import urllib.request
|
|
13
|
-
import urllib.parse
|
|
14
|
-
import urllib.error
|
|
15
|
-
import mimetypes
|
|
16
|
-
import uuid
|
|
17
|
-
import time
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
|
|
21
|
-
|
|
22
|
-
def load_config():
|
|
23
|
-
"""加载配置文件获取access_token"""
|
|
24
|
-
config_path = os.path.expanduser("~/.soke-cli/config.json")
|
|
25
|
-
if not os.path.exists(config_path):
|
|
26
|
-
print("错误: 配置文件不存在,请先运行 'soke-cli config init'")
|
|
27
|
-
sys.exit(1)
|
|
28
|
-
|
|
29
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
30
|
-
config = json.load(f)
|
|
31
|
-
|
|
32
|
-
if 'UserToken' not in config or not config['UserToken']:
|
|
33
|
-
print("错误: 未找到用户令牌,请先运行 'soke-cli auth login' 登录")
|
|
34
|
-
sys.exit(1)
|
|
35
|
-
|
|
36
|
-
return config
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def get_upload_signature(access_token, api_base_url, max_retries=3):
|
|
40
|
-
"""从接口获取上传签名和配置"""
|
|
41
|
-
params = urllib.parse.urlencode({
|
|
42
|
-
'lang': 'zh',
|
|
43
|
-
'utcoffset': '-28800'
|
|
44
|
-
})
|
|
45
|
-
signature_url = f"{api_base_url}/skills/uploadFile/signature?{params}"
|
|
46
|
-
|
|
47
|
-
headers = {
|
|
48
|
-
'Authorization': f'{access_token}',
|
|
49
|
-
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
for attempt in range(max_retries):
|
|
53
|
-
try:
|
|
54
|
-
if attempt > 0:
|
|
55
|
-
print(f"正在重试... ({attempt + 1}/{max_retries})")
|
|
56
|
-
time.sleep(2 ** attempt) # 指数退避: 2s, 4s, 8s
|
|
57
|
-
else:
|
|
58
|
-
print("正在获取上传签名...")
|
|
59
|
-
|
|
60
|
-
req = urllib.request.Request(signature_url, method='POST', headers=headers)
|
|
61
|
-
|
|
62
|
-
with urllib.request.urlopen(req, timeout=30) as response:
|
|
63
|
-
response_data = response.read().decode('utf-8')
|
|
64
|
-
result = json.loads(response_data)
|
|
65
|
-
|
|
66
|
-
# 检查响应格式
|
|
67
|
-
# 支持多种格式:
|
|
68
|
-
# 1. {"code": 0, "message": "success", "data": {...}}
|
|
69
|
-
# 2. {"code": "get_success", "status": "ok", "message": "获取成功", "data": {...}}
|
|
70
|
-
# 3. {"success": true, "err_code": 0, "err_message": "success", "data": {...}}
|
|
71
|
-
code = result.get('code')
|
|
72
|
-
status = result.get('status', '')
|
|
73
|
-
success = result.get('success')
|
|
74
|
-
err_code = result.get('err_code')
|
|
75
|
-
|
|
76
|
-
# 判断是否成功
|
|
77
|
-
is_success = (
|
|
78
|
-
(isinstance(code, int) and code == 0) or # 数字格式
|
|
79
|
-
(isinstance(code, str) and code in ['get_success', 'success']) or # 字符串格式
|
|
80
|
-
(status == 'ok') or # 通过status判断
|
|
81
|
-
(success is True and err_code == 0) # success格式
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
if not is_success:
|
|
85
|
-
print(f"✗ 获取签名失败: {result.get('message', result.get('err_message', '未知错误'))}")
|
|
86
|
-
return None
|
|
87
|
-
|
|
88
|
-
# 提取签名数据
|
|
89
|
-
data = result.get('data', {})
|
|
90
|
-
if not data:
|
|
91
|
-
print("✗ 响应数据为空")
|
|
92
|
-
return None
|
|
93
|
-
|
|
94
|
-
print("✓ 签名获取成功")
|
|
95
|
-
return data
|
|
96
|
-
|
|
97
|
-
except urllib.error.HTTPError as e:
|
|
98
|
-
error_msg = f"HTTP {e.code} {e.reason}"
|
|
99
|
-
try:
|
|
100
|
-
error_body = e.read().decode('utf-8')
|
|
101
|
-
except:
|
|
102
|
-
error_body = ""
|
|
103
|
-
|
|
104
|
-
# 503/502/504 等服务端错误可以重试
|
|
105
|
-
if e.code in [502, 503, 504] and attempt < max_retries - 1:
|
|
106
|
-
print(f"✗ {error_msg} - 服务暂时不可用,将自动重试...")
|
|
107
|
-
continue
|
|
108
|
-
else:
|
|
109
|
-
print(f"✗ HTTP错误: {error_msg}")
|
|
110
|
-
if error_body:
|
|
111
|
-
print(f"响应内容: {error_body}")
|
|
112
|
-
if e.code == 503:
|
|
113
|
-
print("\n提示: 503错误通常表示服务端暂时不可用,请稍后重试")
|
|
114
|
-
print("可能的原因:")
|
|
115
|
-
print(" 1. 服务正在维护或重启")
|
|
116
|
-
print(" 2. 服务负载过高")
|
|
117
|
-
print(" 3. 网络连接问题")
|
|
118
|
-
return None
|
|
119
|
-
|
|
120
|
-
except urllib.error.URLError as e:
|
|
121
|
-
if attempt < max_retries - 1:
|
|
122
|
-
print(f"✗ 网络错误: {str(e.reason)} - 将自动重试...")
|
|
123
|
-
continue
|
|
124
|
-
else:
|
|
125
|
-
print(f"✗ 请求失败: {str(e.reason)}")
|
|
126
|
-
return None
|
|
127
|
-
|
|
128
|
-
except json.JSONDecodeError as e:
|
|
129
|
-
print(f"✗ 响应解析失败: {str(e)}")
|
|
130
|
-
return None
|
|
131
|
-
|
|
132
|
-
except Exception as e:
|
|
133
|
-
print(f"✗ 发生错误: {str(e)}")
|
|
134
|
-
return None
|
|
135
|
-
|
|
136
|
-
print(f"✗ 已重试 {max_retries} 次,仍然失败")
|
|
137
|
-
return None
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def create_multipart_form_data(fields, file_path):
|
|
141
|
-
"""创建multipart/form-data格式的请求体"""
|
|
142
|
-
boundary = f'----WebKitFormBoundary{uuid.uuid4().hex[:16]}'
|
|
143
|
-
|
|
144
|
-
body = []
|
|
145
|
-
|
|
146
|
-
# 添加表单字段
|
|
147
|
-
for key, value in fields.items():
|
|
148
|
-
body.append(f'--{boundary}'.encode())
|
|
149
|
-
body.append(f'Content-Disposition: form-data; name="{key}"'.encode())
|
|
150
|
-
body.append(b'')
|
|
151
|
-
body.append(str(value).encode())
|
|
152
|
-
|
|
153
|
-
# 添加文件
|
|
154
|
-
filename = os.path.basename(file_path)
|
|
155
|
-
mimetype = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
|
|
156
|
-
|
|
157
|
-
body.append(f'--{boundary}'.encode())
|
|
158
|
-
body.append(f'Content-Disposition: form-data; name="file"; filename="{filename}"'.encode())
|
|
159
|
-
body.append(f'Content-Type: {mimetype}'.encode())
|
|
160
|
-
body.append(b'')
|
|
161
|
-
|
|
162
|
-
with open(file_path, 'rb') as f:
|
|
163
|
-
body.append(f.read())
|
|
164
|
-
|
|
165
|
-
body.append(f'--{boundary}--'.encode())
|
|
166
|
-
body.append(b'')
|
|
167
|
-
|
|
168
|
-
content_type = f'multipart/form-data; boundary={boundary}'
|
|
169
|
-
body_bytes = b'\r\n'.join(body)
|
|
170
|
-
|
|
171
|
-
return content_type, body_bytes
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def upload_file(file_path, object_name, signature_data, corp_id):
|
|
175
|
-
"""上传文件到OSS"""
|
|
176
|
-
if not os.path.exists(file_path):
|
|
177
|
-
print(f"错误: 文件不存在: {file_path}")
|
|
178
|
-
sys.exit(1)
|
|
179
|
-
|
|
180
|
-
# 从签名数据中提取上传配置
|
|
181
|
-
upload_url = signature_data.get('host')
|
|
182
|
-
access_key_id = signature_data.get('accessid') or signature_data.get('OSSAccessKeyId')
|
|
183
|
-
policy = signature_data.get('policy')
|
|
184
|
-
signature = signature_data.get('signature')
|
|
185
|
-
callback = signature_data.get('callback', '')
|
|
186
|
-
dir_prefix = signature_data.get('dir', '')
|
|
187
|
-
|
|
188
|
-
if not all([upload_url, access_key_id, policy, signature]):
|
|
189
|
-
print("✗ 签名数据不完整")
|
|
190
|
-
print(f"签名数据: {json.dumps(signature_data, indent=2, ensure_ascii=False)}")
|
|
191
|
-
return False
|
|
192
|
-
|
|
193
|
-
# 获取文件信息
|
|
194
|
-
file_size = os.path.getsize(file_path)
|
|
195
|
-
file_name = os.path.basename(file_path)
|
|
196
|
-
file_ext = os.path.splitext(file_name)[1].lstrip('.') # 移除点号,如 pdf
|
|
197
|
-
|
|
198
|
-
# 构建完整的对象路径:CorpID/dir_prefix/object_name
|
|
199
|
-
if corp_id:
|
|
200
|
-
# 如果有CorpID,添加为顶层目录
|
|
201
|
-
if dir_prefix:
|
|
202
|
-
# 移除dir_prefix开头的斜杠(如果有)
|
|
203
|
-
dir_prefix = dir_prefix.lstrip('/')
|
|
204
|
-
full_path = f"{corp_id}/{dir_prefix}{object_name}"
|
|
205
|
-
else:
|
|
206
|
-
full_path = f"{corp_id}/{object_name}"
|
|
207
|
-
else:
|
|
208
|
-
# 如果没有CorpID,使用原逻辑
|
|
209
|
-
if dir_prefix and not object_name.startswith(dir_prefix):
|
|
210
|
-
full_path = dir_prefix + object_name
|
|
211
|
-
else:
|
|
212
|
-
full_path = object_name
|
|
213
|
-
|
|
214
|
-
# 准备表单数据
|
|
215
|
-
form_fields = {
|
|
216
|
-
'key': full_path,
|
|
217
|
-
'policy': policy,
|
|
218
|
-
'OSSAccessKeyId': access_key_id,
|
|
219
|
-
'signature': signature,
|
|
220
|
-
'success_action_status': '200',
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
# 如果有callback,添加到表单
|
|
224
|
-
if callback:
|
|
225
|
-
form_fields['callback'] = callback
|
|
226
|
-
|
|
227
|
-
# 显示上传信息
|
|
228
|
-
print(f"\n正在上传文件: {file_path}")
|
|
229
|
-
print(f"文件名称: {file_name}")
|
|
230
|
-
print(f"文件大小: {file_size} 字节")
|
|
231
|
-
print(f"文件后缀: {file_ext if file_ext else '(无后缀)'}")
|
|
232
|
-
if corp_id:
|
|
233
|
-
print(f"企业ID: {corp_id}")
|
|
234
|
-
print(f"目标路径: {full_path}")
|
|
235
|
-
print(f"上传地址: {upload_url}")
|
|
236
|
-
|
|
237
|
-
try:
|
|
238
|
-
# 创建multipart/form-data请求体
|
|
239
|
-
content_type, body = create_multipart_form_data(form_fields, file_path)
|
|
240
|
-
|
|
241
|
-
headers = {
|
|
242
|
-
'Content-Type': content_type,
|
|
243
|
-
'Content-Length': str(len(body))
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
req = urllib.request.Request(upload_url, data=body, headers=headers, method='POST')
|
|
247
|
-
|
|
248
|
-
with urllib.request.urlopen(req, timeout=300) as response:
|
|
249
|
-
response_code = response.getcode()
|
|
250
|
-
response_body = response.read().decode('utf-8')
|
|
251
|
-
|
|
252
|
-
if response_code == 200:
|
|
253
|
-
file_url = f"{upload_url.rstrip('/')}/{full_path}"
|
|
254
|
-
|
|
255
|
-
print(f"\n✓ 上传成功!")
|
|
256
|
-
print(f"=" * 60)
|
|
257
|
-
print(f"文件信息:")
|
|
258
|
-
print(f" - 文件名: {file_name}")
|
|
259
|
-
print(f" - 大小: {file_size} 字节")
|
|
260
|
-
print(f" - 后缀: {file_ext if file_ext else '(无后缀)'}")
|
|
261
|
-
print(f" - OSS路径: {full_path}")
|
|
262
|
-
print(f" - 访问URL: {file_url}")
|
|
263
|
-
print(f"=" * 60)
|
|
264
|
-
|
|
265
|
-
# 如果有callback响应,打印出来
|
|
266
|
-
if response_body:
|
|
267
|
-
try:
|
|
268
|
-
callback_result = json.loads(response_body)
|
|
269
|
-
if callback_result:
|
|
270
|
-
print(f"\n回调响应: {json.dumps(callback_result, indent=2, ensure_ascii=False)}")
|
|
271
|
-
except:
|
|
272
|
-
pass
|
|
273
|
-
|
|
274
|
-
return True
|
|
275
|
-
else:
|
|
276
|
-
print(f"\n✗ 上传失败!")
|
|
277
|
-
print(f"状态码: {response_code}")
|
|
278
|
-
print(f"响应内容: {response_body}")
|
|
279
|
-
return False
|
|
280
|
-
|
|
281
|
-
except urllib.error.HTTPError as e:
|
|
282
|
-
print(f"\n✗ 上传失败: HTTP {e.code} {e.reason}")
|
|
283
|
-
try:
|
|
284
|
-
error_body = e.read().decode('utf-8')
|
|
285
|
-
print(f"响应内容: {error_body}")
|
|
286
|
-
except:
|
|
287
|
-
pass
|
|
288
|
-
return False
|
|
289
|
-
except urllib.error.URLError as e:
|
|
290
|
-
print(f"\n✗ 上传失败: {str(e.reason)}")
|
|
291
|
-
return False
|
|
292
|
-
except Exception as e:
|
|
293
|
-
print(f"\n✗ 发生错误: {str(e)}")
|
|
294
|
-
import traceback
|
|
295
|
-
traceback.print_exc()
|
|
296
|
-
return False
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def main():
|
|
300
|
-
"""主函数"""
|
|
301
|
-
if len(sys.argv) < 2:
|
|
302
|
-
print("用法: python upload.py <本地文件路径> [OSS对象名]")
|
|
303
|
-
print("示例: python upload.py ./document.pdf uploads/2024/document.pdf")
|
|
304
|
-
sys.exit(1)
|
|
305
|
-
|
|
306
|
-
file_path = sys.argv[1]
|
|
307
|
-
|
|
308
|
-
# 检查文件是否存在
|
|
309
|
-
if not os.path.exists(file_path):
|
|
310
|
-
print(f"错误: 文件不存在: {file_path}")
|
|
311
|
-
sys.exit(1)
|
|
312
|
-
|
|
313
|
-
# 检查是否是文件(不是目录)
|
|
314
|
-
if not os.path.isfile(file_path):
|
|
315
|
-
print(f"错误: 路径不是文件: {file_path}")
|
|
316
|
-
sys.exit(1)
|
|
317
|
-
|
|
318
|
-
# 检查文件大小(不能超过2GB)
|
|
319
|
-
file_size = os.path.getsize(file_path)
|
|
320
|
-
if file_size > MAX_FILE_SIZE:
|
|
321
|
-
print(f"错误: 文件大小超过限制")
|
|
322
|
-
print(f" 当前大小: {file_size / (1024 * 1024 * 1024):.2f} GB ({file_size} 字节)")
|
|
323
|
-
print(f" 最大限制: 2 GB ({MAX_FILE_SIZE} 字节)")
|
|
324
|
-
sys.exit(1)
|
|
325
|
-
|
|
326
|
-
# 如果未指定对象名,使用文件名
|
|
327
|
-
if len(sys.argv) >= 3:
|
|
328
|
-
object_name = sys.argv[2]
|
|
329
|
-
else:
|
|
330
|
-
object_name = os.path.basename(file_path)
|
|
331
|
-
|
|
332
|
-
# 加载配置
|
|
333
|
-
config = load_config()
|
|
334
|
-
access_token = config.get('UserToken')
|
|
335
|
-
api_base_url = config.get('APIBaseURL', 'https://app2651.eapps.dingtalkcloud.com/NewSoke')
|
|
336
|
-
corp_id = config.get('CorpID', '')
|
|
337
|
-
|
|
338
|
-
if not corp_id:
|
|
339
|
-
print("警告: 未找到CorpID配置,文件将上传到根目录")
|
|
340
|
-
print("建议在配置文件中添加 CorpID 字段")
|
|
341
|
-
|
|
342
|
-
# 获取上传签名
|
|
343
|
-
signature_data = get_upload_signature(access_token, api_base_url)
|
|
344
|
-
if not signature_data:
|
|
345
|
-
print("✗ 无法获取上传签名")
|
|
346
|
-
sys.exit(1)
|
|
347
|
-
|
|
348
|
-
# 上传文件
|
|
349
|
-
success = upload_file(file_path, object_name, signature_data, corp_id)
|
|
350
|
-
|
|
351
|
-
sys.exit(0 if success else 1)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if __name__ == '__main__':
|
|
355
|
-
main()
|