@sokeai/cli 1.0.39 → 1.0.53

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.
Files changed (34) hide show
  1. package/README.md +1 -24
  2. package/package.json +2 -2
  3. package/scripts/build-binaries.sh +1 -30
  4. package/scripts/e2e-test.sh +63 -3
  5. package/scripts/install.js +52 -110
  6. package/scripts/local-test.js +10 -85
  7. package/scripts/local-test.sh +52 -7
  8. package/scripts/push.sh +12 -4
  9. package/scripts/release.sh +45 -8
  10. package/scripts/test-auto-detect.js +71 -48
  11. package/skills/README.md +0 -1
  12. package/skills/oss-upload/skill.md +50 -0
  13. package/skills/oss-upload/upload.py +355 -0
  14. package/skills/soke-assign/README.md +310 -0
  15. package/skills/soke-assign/SKILL.md +383 -0
  16. package/skills/soke-course/README.md +168 -92
  17. package/skills/soke-course/SKILL.md +372 -460
  18. package/skills/soke-exam/SKILL.md +565 -0
  19. package/skills/soke-exam/references/exam-get-exam-user.md +212 -0
  20. package/skills/soke-learning-map/SKILL.md +544 -0
  21. package/skills/soke-learning-profile/SKILL.md +0 -1
  22. package/skills/soke-lesson/README.md +53 -0
  23. package/skills/soke-lesson/SKILL.md +510 -0
  24. package/skills/soke-material/README.md +49 -0
  25. package/skills/soke-material/SKILL.md +390 -0
  26. package/scripts/ci/check-legacy-constants.sh +0 -27
  27. package/scripts/ci/check-yaml-env-whitelist.sh +0 -33
  28. package/scripts/mcp-stdout-scan.sh +0 -46
  29. package/scripts/regress-auth.sh +0 -56
  30. package/scripts/regress-baseline.md +0 -84
  31. package/scripts/test-local-reconcile.js +0 -55
  32. package/skills/soke-business-training-report/SKILL.md +0 -58
  33. package/skills/soke-course/SUMMARY.md +0 -236
  34. package/skills/soke-course/templates/batch-create-from-excel.md +0 -75
@@ -48,10 +48,10 @@ check_tools() {
48
48
  check_git_status() {
49
49
  echo -e "${YELLOW}检查 Git 状态...${NC}"
50
50
 
51
- # 检查当前分支是否为 master
51
+ # 检查当前分支是否为 soke-cli
52
52
  CURRENT_BRANCH=$(git branch --show-current)
53
- if [ "$CURRENT_BRANCH" != "master" ]; then
54
- echo -e "${RED}错误: 当前分支为 $CURRENT_BRANCH,发布必须在 master 分支上进行${NC}"
53
+ if [ "$CURRENT_BRANCH" != "soke-cli" ]; then
54
+ echo -e "${RED}错误: 当前分支为 $CURRENT_BRANCH,发布必须在 soke-cli 分支上进行${NC}"
55
55
  exit 1
56
56
  fi
57
57
 
@@ -62,6 +62,19 @@ check_git_status() {
62
62
  exit 1
63
63
  fi
64
64
 
65
+ # 配置 GitHub 远程仓库
66
+ GITHUB_REMOTE="git@github.com:sokeai/soke-cli.git"
67
+ if git remote get-url github &> /dev/null; then
68
+ CURRENT_URL=$(git remote get-url github)
69
+ if [ "$CURRENT_URL" != "$GITHUB_REMOTE" ]; then
70
+ echo -e "${YELLOW}更新 github 远程仓库地址...${NC}"
71
+ git remote set-url github "$GITHUB_REMOTE"
72
+ fi
73
+ else
74
+ echo -e "${YELLOW}添加 github 远程仓库...${NC}"
75
+ git remote add github "$GITHUB_REMOTE"
76
+ fi
77
+
65
78
  echo -e "${GREEN}✓ 工作目录干净${NC}"
66
79
  echo ""
67
80
  }
@@ -161,13 +174,34 @@ npm install -g @sokeai/cli
161
174
  publish_npm() {
162
175
  echo -e "${YELLOW}发布到 NPM...${NC}"
163
176
 
164
- # 检查是否已登录
177
+ # 如果有环境变量,优先配置(会持久化到 ~/.npmrc)
178
+ if [ -n "$NPM_TOKEN" ]; then
179
+ npm config set //registry.npmjs.org/:_authToken="${NPM_TOKEN}"
180
+ echo -e "${GREEN}✓ 已通过 NPM_TOKEN 配置认证${NC}"
181
+ fi
182
+
183
+ # 验证认证是否有效(支持 npm login 和 token 两种方式)
165
184
  if ! npm whoami &> /dev/null; then
166
- echo -e "${RED}错误: 未登录 NPM${NC}"
167
- echo "请先运行: npm login"
185
+ echo -e "${RED}错误: NPM 认证失败${NC}"
186
+ echo ""
187
+ echo "可能原因:"
188
+ echo " 1. 未配置认证令牌"
189
+ echo " 2. 令牌已过期或被撤销"
190
+ echo ""
191
+ echo "解决方法(任选其一):"
192
+ echo " 方法1: 持久化配置令牌(推荐,一次配置永久生效)"
193
+ echo " npm config set //registry.npmjs.org/:_authToken=npm_xxx"
194
+ echo ""
195
+ echo " 方法2: 交互式登录(需要浏览器)"
196
+ echo " npm login"
197
+ echo ""
198
+ echo "令牌创建地址: https://www.npmjs.com/settings/<YOUR_USER>/tokens"
199
+ echo "注意: 需要创建 Granular Access Token,并勾选「Bypass 2FA」"
168
200
  exit 1
169
201
  fi
170
202
 
203
+ echo -e "${GREEN}✓ NPM 认证已就绪 ($(npm whoami 2>/dev/null))${NC}"
204
+
171
205
  # 发布
172
206
  npm publish --access public
173
207
 
@@ -190,7 +224,7 @@ main() {
190
224
 
191
225
  check_tools
192
226
  check_git_status
193
- check_tag
227
+ # check_tag
194
228
  run_tests
195
229
  build_binaries
196
230
  create_tag
@@ -221,9 +255,12 @@ echo " 4. 创建 Git 标签并推送"
221
255
  echo " 5. 上传到 GitHub Releases"
222
256
  echo " 6. 发布到 NPM"
223
257
  echo ""
224
- read -p "确认继续? (y/N) " -n 1 -r
258
+ read -p "确认继续? [Y/n] " -n 1 -r
225
259
  echo ""
226
260
 
261
+ # 默认是 Y
262
+ REPLY=${REPLY:-Y}
263
+
227
264
  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
228
265
  echo "已取消发布"
229
266
  exit 0
@@ -5,14 +5,66 @@
5
5
  */
6
6
 
7
7
  const fs = require('fs');
8
- const os = require('os');
9
8
  const path = require('path');
10
- const {
11
- detectSkillNames,
12
- parseSkillMetadata,
13
- pruneManagedSkillDirs,
14
- pruneRegistrySkills
15
- } = require('./install.js');
9
+
10
+ // 复制 detectSkillNames 函数
11
+ function detectSkillNames(packagedSkillsDir) {
12
+ if (!fs.existsSync(packagedSkillsDir)) return [];
13
+
14
+ try {
15
+ const entries = fs.readdirSync(packagedSkillsDir, { withFileTypes: true });
16
+ return entries
17
+ .filter(entry => entry.isDirectory() && entry.name.startsWith('soke-'))
18
+ .map(entry => entry.name)
19
+ .sort();
20
+ } catch (_) {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ // 复制 parseSkillMetadata 函数
26
+ function parseSkillMetadata(skillDir) {
27
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
28
+ if (!fs.existsSync(skillMdPath)) {
29
+ return null;
30
+ }
31
+
32
+ try {
33
+ const content = fs.readFileSync(skillMdPath, 'utf8');
34
+
35
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
36
+ if (!frontmatterMatch) return null;
37
+
38
+ const frontmatter = frontmatterMatch[1];
39
+ const metadata = {};
40
+
41
+ const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
42
+ if (nameMatch) metadata.name = nameMatch[1].trim();
43
+
44
+ const summaryMatch = frontmatter.match(/^summary:\s*(.+)$/m);
45
+ if (summaryMatch) metadata.summary = summaryMatch[1].trim();
46
+
47
+ const descMatch = frontmatter.match(/^description:\s*["'](.+)["']$/m);
48
+ if (descMatch) {
49
+ metadata.description = descMatch[1].trim();
50
+ } else {
51
+ const descMatch2 = frontmatter.match(/^description:\s*(.+)$/m);
52
+ if (descMatch2) metadata.description = descMatch2[1].trim();
53
+ }
54
+
55
+ const versionMatch = frontmatter.match(/^version:\s*(.+)$/m);
56
+ if (versionMatch) metadata.version = versionMatch[1].trim();
57
+
58
+ const binsMatch = frontmatter.match(/bins:\s*\[(.+?)\]/);
59
+ if (binsMatch) {
60
+ metadata.bins = binsMatch[1].split(',').map(b => b.trim().replace(/['"]/g, ''));
61
+ }
62
+
63
+ return metadata;
64
+ } catch (_) {
65
+ return null;
66
+ }
67
+ }
16
68
 
17
69
  // 测试
18
70
  const packageRoot = path.join(__dirname, '..');
@@ -50,13 +102,19 @@ for (const skillName of skillNames) {
50
102
  // 验证结果
51
103
  console.log('📊 验证结果:\n');
52
104
 
53
- const requiredSkills = ['soke-shared'];
54
- const missingRequiredSkills = requiredSkills.filter(s => !skillNames.includes(s));
55
- if (missingRequiredSkills.length > 0) {
56
- console.log(`❌ 缺少基础 skills: ${missingRequiredSkills.join(', ')}`);
57
- process.exit(1);
105
+ const expectedSkills = ['soke-course', 'soke-exam', 'soke-shared'];
106
+ const missingSkills = expectedSkills.filter(s => !skillNames.includes(s));
107
+ const extraSkills = skillNames.filter(s => !expectedSkills.includes(s));
108
+
109
+ if (missingSkills.length > 0) {
110
+ console.log(`❌ 缺少的 skills: ${missingSkills.join(', ')}`);
111
+ } else {
112
+ console.log('✅ 所有预期的 skills 都已检测到');
113
+ }
114
+
115
+ if (extraSkills.length > 0) {
116
+ console.log(`ℹ️ 额外的 skills: ${extraSkills.join(', ')}`);
58
117
  }
59
- console.log('✅ 基础 skills 检测通过');
60
118
 
61
119
  console.log('');
62
120
  console.log('🎉 测试完成!');
@@ -65,38 +123,3 @@ console.log('💡 提示:');
65
123
  console.log(' - 新增 skill 时,只需在 skills/ 目录下创建 soke-* 目录');
66
124
  console.log(' - 确保每个 skill 都有 SKILL.md 文件,包含完整的 frontmatter');
67
125
  console.log(' - install.js 会自动检测并注册所有 skills');
68
-
69
- // 验证清理逻辑
70
- const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'soke-skill-prune-'));
71
- const targetDir = path.join(tempRoot, 'skills');
72
- fs.mkdirSync(targetDir, { recursive: true });
73
- fs.mkdirSync(path.join(targetDir, 'soke-stale'));
74
- fs.mkdirSync(path.join(targetDir, 'soke-course'));
75
- fs.mkdirSync(path.join(targetDir, 'custom-skill'));
76
-
77
- const removedDirs = pruneManagedSkillDirs(targetDir, ['soke-course']);
78
- if (!removedDirs.includes('soke-stale')) {
79
- console.error('❌ pruneManagedSkillDirs 未删除过期 managed skill');
80
- process.exit(1);
81
- }
82
- if (!fs.existsSync(path.join(targetDir, 'custom-skill'))) {
83
- console.error('❌ pruneManagedSkillDirs 错误删除了非受管 skill');
84
- process.exit(1);
85
- }
86
-
87
- const registry = {
88
- skills: [
89
- { name: 'soke-stale' },
90
- { name: 'soke-course' },
91
- { name: 'custom-skill' }
92
- ]
93
- };
94
- const removedEntries = pruneRegistrySkills(registry, ['soke-course']);
95
- if (removedEntries !== 1) {
96
- console.error('❌ pruneRegistrySkills 未正确移除过期条目');
97
- process.exit(1);
98
- }
99
- if (!registry.skills.find((entry) => entry.name === 'custom-skill')) {
100
- console.error('❌ pruneRegistrySkills 错误移除了非受管条目');
101
- process.exit(1);
102
- }
package/skills/README.md CHANGED
@@ -13,7 +13,6 @@ npm install -g @sokeai/cli
13
13
 
14
14
  2. 已完成配置和认证:
15
15
  ```bash
16
- soke-cli config init
17
16
  soke-cli auth login
18
17
  ```
19
18
 
@@ -0,0 +1,50 @@
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密钥,更安全便捷
@@ -0,0 +1,355 @@
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()