pindo 5.0.5 → 5.0.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3875a278d39ee97be6efefd2e2691f398cb0ec145c079e6ff261181e1f7c4cde
4
- data.tar.gz: 463a3b8a6b68376a35017ed3fe9457c86d06ee60eeb3404d02e3aaaca2f152d5
3
+ metadata.gz: cd1d664dc77bc75c43a1816a8316fafb10351c9133a4e9dabf403f010bd1882a
4
+ data.tar.gz: 7e4387a7ad42c6830898bd9466a7ea8131ab92429b78ed5ed61479b07eebe44f
5
5
  SHA512:
6
- metadata.gz: 693df9ad920466ab0f93c13e0f5774d970fa24fefb66ff7f0722ca56e25d687098cddb8808113cc4c02feb97d75d7e02ee4b78a6de9f9ec33c545bf8748c910d
7
- data.tar.gz: ac047d01af53a8b1be66faf6e6aa667e64d67135d32c1d4c8fc4313a59f4df2bb5b18032d2f659eec81f21c3c43a4f8bccfbc6aa0fe94030b86f82e42cf6facd
6
+ metadata.gz: 305e2b32210bf046c6d56a82cff9f799e9bf73672e812aa28e3c78e378b32bc8c4700f5f4f45cfe39e3301427632f6074c2f6b419778c51a21de15dc19d8a676
7
+ data.tar.gz: 991b426167216c8c2f95fbd99400314fe807f892a6975300d145ff1e07684611547f6a89df7fbabd634d5f0f076839928fb89a1787977b313e4b1e332029a997
@@ -49,10 +49,9 @@ module Pindo
49
49
 
50
50
  def do_login()
51
51
 
52
- # username = ask('请输入Boss网站的用户名:')
53
- # password = ask('请输入Boss网站的密码:')
54
- username = "shuangquan"
55
- password = "HIsdfUHfwdh"
52
+ username = ask('请输入Boss网站的用户名:')
53
+ password = ask('请输入Boss网站的密码:')
54
+
56
55
 
57
56
  do_login_req(username:username, password:password)
58
57
  end
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'webrick'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'uri'
7
+ require 'securerandom'
8
+ require 'digest'
9
+ require 'base64'
10
+ require 'fileutils'
11
+ require 'pindo/base/aeshelper'
12
+ require 'pindo/config/pindouserlocalconfig'
13
+ require 'pindo/client/httpclient'
14
+
15
+ module Pindo
16
+
17
+ class PgyerFeishuOAuthCLI
18
+ attr_reader :access_token, :username, :expires_at
19
+
20
+ def initialize(client_id)
21
+ @client_id = client_id
22
+ @feishu_auth_url = 'https://passport.feishu.cn/suite/passport/oauth/authorize?'
23
+
24
+ # 保持原有的redirect_uri,飞书OAuth流程中仍使用这个URL
25
+ # 前端UI会检测state=terminal_login并自动跳转到localhost:8899
26
+ @redirect_uri = 'https://pgyerapps.com/login'
27
+ @pgyer_api_endpoint = 'https://api.pgyerapps.com/api/user/lark_qr_login'
28
+
29
+ @state = 'terminal_login'
30
+
31
+ @larkScopeList = [
32
+ 'task:task:write',
33
+ 'task:section:write',
34
+ 'task:custom_field:write',
35
+ 'task:tasklist:write'
36
+ ];
37
+
38
+ @access_token = nil
39
+ @username = nil
40
+ @expires_at = nil
41
+ end
42
+
43
+ # 启动授权流程
44
+ def authorize
45
+ # 不再检查已存储的令牌,直接开始授权流程
46
+ authorization_uri = build_authorization_uri
47
+ puts "正在打开浏览器进行飞书OAuth授权..."
48
+ puts "授权URI: #{authorization_uri}"
49
+ puts "注意:授权成功后,网页将自动跳转至本地http://localhost:8899"
50
+
51
+ # 在浏览器中打开授权URL
52
+ open_browser(authorization_uri)
53
+
54
+ # 启动本地服务器处理回调
55
+ code = start_callback_server
56
+
57
+ # 如果自动获取失败,提示用户手动输入
58
+ if code.nil?
59
+ puts "自动获取授权码失败,您可以手动输入:"
60
+ puts "1. 授权码 (直接复制'code='后面的内容)"
61
+ puts "2. 完整回调URL (例如: http://localhost:8899/?code=xxxx...)"
62
+ print "> "
63
+ input = STDIN.gets.chomp
64
+
65
+ if input.start_with?("http")
66
+ # 尝试从URL中提取code
67
+ begin
68
+ uri = URI(input)
69
+ query_params = URI.decode_www_form(uri.query || '').to_h
70
+ code = query_params['code']
71
+ if code
72
+ puts "从URL中成功提取授权码"
73
+ end
74
+ rescue => e
75
+ puts "无法从URL中提取授权码: #{e.message}"
76
+ end
77
+ else
78
+ # 将输入直接作为code
79
+ code = input unless input.empty?
80
+ end
81
+ end
82
+
83
+ if code
84
+ puts "成功获取授权码!正在使用飞书身份登录Pgyer..."
85
+ puts "code: #{code}"
86
+ if login_pgyer_with_feishu(code:code)
87
+ puts "Pgyer登录成功!"
88
+ # 登录成功后,返回true,外部程序可以通过访问access_token属性获取token
89
+ return true
90
+ end
91
+ return false
92
+ else
93
+ puts "授权失败"
94
+ return false
95
+ end
96
+ end
97
+
98
+ # 验证Pgyer令牌
99
+ def validate_pgyer_token(token = nil, expires_at = nil)
100
+ token_to_check = token || @access_token
101
+ expiration_time = expires_at || @expires_at
102
+
103
+ # 首先检查token是否存在
104
+ return false unless token_to_check
105
+
106
+ # 然后检查token是否过期
107
+ if expiration_time && Time.now.to_i > expiration_time
108
+ puts "令牌已过期,需要重新登录"
109
+ return false
110
+ end
111
+
112
+ # 最后验证token是否有效
113
+ uri = URI("https://www.pgyer.com/api/user/profile")
114
+ request = Net::HTTP::Get.new(uri)
115
+ request['Authorization'] = "Bearer #{token_to_check}"
116
+
117
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
118
+ http.request(request)
119
+ end
120
+
121
+ return response.code == '200'
122
+ end
123
+
124
+ # 使用code登录Pgyer, 并且Pgyer返回token
125
+ def login_pgyer_with_feishu(code: nil)
126
+ return false unless code
127
+
128
+ # 构造请求体
129
+ body_params = {
130
+ code: code,
131
+ redirectUri: @redirect_uri,
132
+ scope: @larkScopeList.join(' ')
133
+ }
134
+
135
+ puts "请求Pgyer API: #{@pgyer_api_endpoint}"
136
+ puts "请求参数: #{body_params.to_json}"
137
+
138
+ # 使用HttpClient发送请求
139
+ con = HttpClient.create_instance_with_proxy
140
+
141
+ begin
142
+ res = con.post do |req|
143
+ req.url @pgyer_api_endpoint
144
+ req.headers['Content-Type'] = 'application/json'
145
+ req.body = body_params.to_json
146
+ end
147
+
148
+ puts "API响应状态码: #{res.status}"
149
+
150
+ # 处理响应
151
+ result = nil
152
+ if !res.body.nil?
153
+ begin
154
+ result = JSON.parse(res.body)
155
+ # puts "解析后的响应: #{result.inspect}"
156
+
157
+ if result['code'] == 200 && !result['data'].nil? && !result['data']['token'].nil?
158
+ @access_token = result['data']['token']
159
+ @username = result['data']['username'] if result['data']['username']
160
+ # 设置token有效期为7天后
161
+ @expires_at = Time.now.to_i + 6 * 24 * 60 * 60 # 7天的秒数
162
+ return true
163
+ else
164
+ error_msg = result['meta'] && result['meta']['message'] ? result['meta']['message'] : '未知错误'
165
+ puts "Pgyer登录失败: #{error_msg}"
166
+ return false
167
+ end
168
+ rescue => e
169
+ puts "解析响应失败: #{e.message}"
170
+ puts "原始响应: #{res.body[0..200]}"
171
+ return false
172
+ end
173
+ else
174
+ puts "请求返回空响应"
175
+ return false
176
+ end
177
+ rescue => e
178
+ puts "请求过程中出错: #{e.class} - #{e.message}"
179
+ return false
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+
186
+ # 构建授权URI
187
+ def build_authorization_uri
188
+ uri = URI(@feishu_auth_url)
189
+
190
+ params = {
191
+ 'client_id' => @client_id,
192
+ 'redirect_uri' => @redirect_uri,
193
+ 'response_type' => 'code',
194
+ 'state' => @state,
195
+ 'scope' => @larkScopeList.join(' ')
196
+ }
197
+
198
+ uri.query = URI.encode_www_form(params)
199
+ uri.to_s
200
+ end
201
+
202
+ # 在浏览器中打开URL
203
+ def open_browser(url)
204
+ if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
205
+ system("start", url)
206
+ elsif RbConfig::CONFIG['host_os'] =~ /darwin/
207
+ system("open", url)
208
+ elsif RbConfig::CONFIG['host_os'] =~ /linux|bsd/
209
+ system("xdg-open", url)
210
+ else
211
+ puts "无法自动打开浏览器,请手动访问: #{url}"
212
+ end
213
+ end
214
+
215
+ # 启动本地服务器处理回调
216
+ def start_callback_server
217
+ code = nil
218
+
219
+ puts "启动本地服务器,监听端口8899..."
220
+
221
+ # 使用本地8899端口处理回调,不管redirect_uri配置如何
222
+ server = WEBrick::HTTPServer.new(
223
+ Port: 8899,
224
+ Logger: WEBrick::Log.new("/dev/null"),
225
+ AccessLog: []
226
+ )
227
+
228
+ # 处理根路径的请求
229
+ server.mount_proc '/' do |req, res|
230
+ begin
231
+ # puts "接收到请求: #{req.request_line}"
232
+ # puts "请求参数: #{req.query_string}"
233
+
234
+ # 安全解析请求参数
235
+ begin
236
+ query_params = URI.decode_www_form(req.query_string || '').to_h
237
+ puts "解析的参数: #{query_params.inspect}"
238
+ rescue => e
239
+ puts "解析请求参数失败: #{e.message}"
240
+ query_params = {}
241
+ end
242
+
243
+ # if query_params['state'] != @state
244
+ # puts "状态不匹配: 接收到 #{query_params['state']},期望 #{@state}"
245
+ # res.content_type = "text/html; charset=UTF-8"
246
+ # res.body = "状态不匹配,可能存在CSRF攻击风险"
247
+ # elsif query_params['error']
248
+ if query_params['error']
249
+ puts "授权错误: #{query_params['error']}"
250
+ res.content_type = "text/html; charset=UTF-8"
251
+ res.body = "授权错误: #{query_params['error']}"
252
+ elsif query_params['code']
253
+ code = query_params['code']
254
+ puts "成功获取授权码: #{code}"
255
+ res.content_type = "text/html; charset=UTF-8"
256
+ res.body = <<-HTML
257
+ <!DOCTYPE html>
258
+ <html>
259
+ <head>
260
+ <meta charset="UTF-8">
261
+ <title>飞书授权成功</title>
262
+ <style>
263
+ body { font-family: Arial, sans-serif; text-align: center; padding: 40px; }
264
+ .container { max-width: 600px; margin: 0 auto; }
265
+ .success { color: #4CAF50; }
266
+ .code { font-family: monospace; background: #f5f5f5; padding: 10px; border-radius: 4px; word-break: break-all; }
267
+ .countdown { font-weight: bold; color: #FF5722; }
268
+ .autoclose-banner {
269
+ background-color: #333; color: white; padding: 10px;
270
+ position: fixed; top: 0; left: 0; right: 0;
271
+ display: flex; justify-content: space-between; align-items: center;
272
+ }
273
+ .close-btn {
274
+ background: #f44336; color: white; border: none;
275
+ padding: 5px 10px; cursor: pointer; border-radius: 3px;
276
+ }
277
+ </style>
278
+ <script>
279
+ // 尝试多种方法关闭窗口
280
+ function attemptClose() {
281
+ try {
282
+ // 方法1: 最基本的关闭尝试
283
+ window.close();
284
+
285
+ // 方法2: 对于某些浏览器需要历史记录操作
286
+ window.history.back();
287
+
288
+ // 方法3: 空白页替换
289
+ window.location.href = "about:blank";
290
+
291
+ // 方法4: 尝试使用opener关系
292
+ if (window.opener) {
293
+ window.opener.focus();
294
+ window.close();
295
+ }
296
+
297
+ // 如果以上方法都失败了,显示手动关闭提示
298
+ setTimeout(function() {
299
+ document.getElementById('close-message').style.display = 'block';
300
+ document.getElementById('countdown-container').style.display = 'none';
301
+ }, 1000);
302
+ } catch (e) {
303
+ console.error("关闭窗口失败:", e);
304
+ document.getElementById('close-message').style.display = 'block';
305
+ document.getElementById('countdown-container').style.display = 'none';
306
+ }
307
+ }
308
+
309
+ // 倒计时函数
310
+ var secondsLeft = 3;
311
+ function updateCountdown() {
312
+ document.getElementById('countdown').innerText = secondsLeft;
313
+ if (secondsLeft <= 0) {
314
+ attemptClose();
315
+ } else {
316
+ secondsLeft -= 1;
317
+ setTimeout(updateCountdown, 1000);
318
+ }
319
+ }
320
+
321
+ // 初始化
322
+ window.onload = function() {
323
+ updateCountdown();
324
+ }
325
+ </script>
326
+ </head>
327
+ <body>
328
+ <div class="autoclose-banner">
329
+ <span>授权成功! 此窗口将自动关闭 (<span id="countdown" class="countdown">3</span>)</span>
330
+ <button class="close-btn" onclick="attemptClose()">立即关闭</button>
331
+ </div>
332
+
333
+ <div class="container">
334
+ <h1 class="success">飞书授权成功!</h1>
335
+ <p>已获取授权码,正在返回命令行...</p>
336
+
337
+ <div id="countdown-container">
338
+ <p>此窗口将在 <span id="countdown-text" class="countdown">3</span> 秒后自动关闭</p>
339
+ </div>
340
+
341
+ <div id="close-message" style="display: none;">
342
+ <p>自动关闭失败,请手动关闭此窗口</p>
343
+ <button class="close-btn" onclick="attemptClose()">尝试再次关闭</button>
344
+ </div>
345
+
346
+ <p>授权码:</p>
347
+ <div class="code">#{code}</div>
348
+ </div>
349
+
350
+ <script>
351
+ // 同步倒计时显示
352
+ document.getElementById('countdown-text').innerText = secondsLeft;
353
+ </script>
354
+ </body>
355
+ </html>
356
+ HTML
357
+ else
358
+ puts "未获取到授权码"
359
+ res.content_type = "text/html; charset=UTF-8"
360
+ res.body = "未获取到授权码"
361
+ end
362
+
363
+ # 给用户更多时间看到页面内容,并让JavaScript倒计时完成
364
+ puts "4秒后关闭服务器..."
365
+ Thread.new do
366
+ sleep 4 # 设置为4秒,比JavaScript的3秒稍长一点,确保客户端有足够时间执行
367
+ server.shutdown
368
+ end
369
+ rescue => e
370
+ puts "处理请求时出错: #{e.class} - #{e.message}"
371
+ puts e.backtrace.join("\n")
372
+
373
+ # 确保即使出错也返回有效的响应
374
+ res.content_type = "text/html; charset=UTF-8"
375
+ res.body = "处理请求时出错"
376
+
377
+ # 出错时也关闭服务器
378
+ Thread.new do
379
+ sleep 4 # 与正常情况保持一致
380
+ server.shutdown
381
+ end
382
+ end
383
+ end
384
+
385
+ # 捕获Ctrl+C以允许用户中断
386
+ trap('INT') { server.shutdown }
387
+
388
+ # puts "正在监听http://localhost:8899等待飞书重定向..."
389
+ # puts "您也可以手动访问: http://localhost:8899/?code=YOUR_CODE&state=terminal_login"
390
+
391
+ # 在线程中运行服务器,最多等待3分钟
392
+ thread = Thread.new { server.start }
393
+ begin
394
+ thread.join(180) # 最多等待3分钟
395
+ rescue Timeout::Error
396
+ puts "授权超时"
397
+ server.shutdown
398
+ end
399
+
400
+ if code
401
+ puts "成功获取授权码: #{code}"
402
+ else
403
+ puts "未获取到授权码"
404
+ end
405
+
406
+ code
407
+ end
408
+
409
+
410
+ end
411
+
412
+ end
@@ -1,9 +1,8 @@
1
-
2
1
  require 'uri'
3
2
  require 'pindo/base/aeshelper'
4
3
  require 'pindo/config/pindouserlocalconfig'
5
4
  require 'pindo/client/httpclient'
6
-
5
+ require 'pindo/client/pgyer_feishu_oauth_cli'
7
6
 
8
7
  module Pindo
9
8
 
@@ -35,124 +34,44 @@ module Pindo
35
34
 
36
35
  end
37
36
 
38
- def get_faraday_instance
39
-
40
- end
41
-
42
-
43
37
  def do_login(force_login:false)
44
38
 
39
+ login_success = false
40
+ need_req_login = true
45
41
 
46
-
47
- login_success = false
48
- @token = load_token()
49
- if !@token.nil? && !@token["token"].nil? && !force_login
50
- login_success = true
51
-
52
- # puts "用户#{@token["username"]}登录pgyer成功!!!"
53
- # puts
54
-
55
- else
56
- username = nil
57
- if !@token.nil? && !@token["username"].nil?
58
- username = @token["username"]
59
- checksum_password = nil
60
- if !@token["password"].nil?
61
- checksum_password = @token["password"]
62
- end
63
- end
64
- token = do_login_req(username:username, checksum_password:checksum_password)
65
- if !token.nil? && !token["token"].nil?
66
- @token = token
67
- login_success = true
68
- end
69
- end
70
-
71
- return login_success
72
- end
73
-
74
- def do_login_req(username:nil, checksum_password:nil)
75
-
76
-
77
- login_name = username
78
- if login_name.nil? || login_name.empty?
79
- login_name = ask('请输入pgger网站的usernmae:') || nil
80
- end
81
-
82
- checksum_pass = checksum_password
83
- if checksum_pass.nil? || checksum_pass.empty?
84
- login_passwork = ask('请输入pgger网站的密码:') || nil
85
- checksum_pass = Digest::MD5.hexdigest(login_passwork)
86
- end
87
-
88
- if !login_name.nil? && !login_name.empty? && !checksum_pass.nil? && !checksum_pass.empty?
89
-
42
+ if force_login
43
+ need_req_login = true
90
44
  else
91
- raise Informative, "请输入正确的用户名和密码!"
92
- end
93
-
94
-
95
- Funlog.instance.fancyinfo_start("正在登录pgyer...")
96
-
97
-
98
- result_data = do_send_code(username:login_name)
99
-
100
-
101
- boss_url = @baseurl + @request_config["do_login"]
102
- body_params = {
103
- username:login_name,
104
- phoneCode:result_data["msg"],
105
- password:checksum_pass
106
- }
107
-
108
- # puts JSON.pretty_generate(body_params)
109
-
110
- login_response_data = nil
111
-
112
- begin
113
-
114
- con = HttpClient.create_instance_with_proxy
115
- res = con.post do |req|
116
- req.url boss_url
117
- req.headers['Content-Type'] = 'application/json'
118
- req.body = body_params.to_json
45
+ @token = load_token()
46
+ if !@token.nil? && !@token["token"].nil? && !@token["username"].nil?
47
+ # 检查token是否已过期(超过7天)
48
+ if @token["expires_at"] && Time.now.to_i < @token["expires_at"]
49
+ need_req_login = false
50
+ else
51
+ Funlog.instance.fancyinfo_error("令牌已过期,需要重新登录...")
52
+ login_success = true
53
+ end
54
+ else
55
+ need_req_login = true
56
+ end
119
57
  end
120
-
121
- if !res.body.nil?
122
-
123
- login_response_data = JSON.parse(res.body)
58
+ if need_req_login
59
+ login_handle = PgyerFeishuOAuthCLI.new("cli_a7db8213883ed00d")
60
+ result = login_handle.authorize
61
+ @token = {}
62
+ @token["token"] = login_handle.access_token
63
+ @token["username"] = login_handle.username
64
+ @token["expires_at"] = login_handle.expires_at
65
+ store_token(token:@token)
66
+ login_success = true
67
+ else
68
+ login_success = true
124
69
  end
125
70
 
126
- rescue => error
127
- Funlog.instance.fancyinfo_error("pgyer登录失败!")
128
- puts "登录失败,请重试!!!"
129
- end
130
-
131
-
132
-
133
- # puts JSON.pretty_generate(login_response_data)
134
-
135
- if !login_response_data.nil? && !login_response_data["code"].nil? && login_response_data["code"].to_s.eql?("200")
136
-
137
- token = {}
138
- token= login_response_data["data"]
139
- token["password"] = checksum_pass
140
- # puts JSON.pretty_generate(token)
141
- store_token(token:token)
142
-
143
- Funlog.instance.fancyinfo_success("用户:#{@token["username"]}登录pgyer成功!")
144
-
145
- else
146
- if File.exist?(@pgyer_token_file)
147
- FileUtils.rm_rf(@pgyer_token_file)
148
- end
149
- Funlog.instance.fancyinfo_error("pgyer登录失败!")
150
- end
151
-
152
- return token
153
-
71
+ return login_success
154
72
  end
155
73
 
74
+
156
75
  def load_token()
157
76
 
158
77
  @token = nil
@@ -165,6 +84,12 @@ module Pindo
165
84
  temp_token = data_string
166
85
  temp_token = JSON.parse(data_string)
167
86
  if !temp_token.nil? && !temp_token["token"].nil? && !temp_token["username"].nil?
87
+ # 检查token是否已过期
88
+ if temp_token["expires_at"] && Time.now.to_i > temp_token["expires_at"]
89
+ Funlog.instance.fancyinfo_error("令牌已过期,需要重新登录...")
90
+ return nil
91
+ end
92
+
168
93
  @token = temp_token
169
94
  Funlog.instance.fancyinfo_success("读取pgyer token成功!")
170
95
  end
@@ -1,10 +1,10 @@
1
-
2
1
  require 'uri'
3
2
  require 'json'
4
3
  require 'faraday'
5
4
  require 'securerandom'
6
5
  require 'pindo/base/aeshelper'
7
6
  require 'typhoeus'
7
+ require 'thread'
8
8
 
9
9
  module Pindo
10
10
 
@@ -44,6 +44,13 @@ module Pindo
44
44
  @pgyer_aes_key = config_json["pgyerapps_aes_key"]
45
45
 
46
46
  @token = load_token
47
+
48
+ # 添加互斥锁用于线程安全
49
+ @upload_eTags_mutex = Mutex.new
50
+ @tasks_queue_mutex = Mutex.new
51
+ @active_tasks_mutex = Mutex.new
52
+ @upload_failed_mutex = Mutex.new # 为上传失败状态添加互斥锁
53
+ @upload_failed = false
47
54
 
48
55
  rescue => error
49
56
  raise Informative, "PgyerUploadClient 初始化失败!"
@@ -72,9 +79,12 @@ module Pindo
72
79
 
73
80
  def upload_file(binary_file:nil, isAttach:false)
74
81
 
82
+ raise Informative, "上传文件不能为空" if binary_file.nil? || !File.exist?(binary_file)
83
+
75
84
  @upload_binary_file = binary_file
76
85
  @file_size = File.size(@upload_binary_file)
77
86
  @progress_bar = PgyerUploadProgressBar.new(upload_total_size:@file_size)
87
+ @upload_failed = false # 重置上传失败标志
78
88
 
79
89
  extension = File.extname(@upload_binary_file)
80
90
  filename = File.basename(@upload_binary_file)
@@ -104,135 +114,231 @@ module Pindo
104
114
 
105
115
  upload_result = nil
106
116
 
107
- file_size_param = 1.00 * @file_size / 1024 /1024
108
- result_data = post_upload_url_req(upload_path_key:upload_path_key, file_ceil_size:file_size_param.ceil)
109
-
110
- upload_id= result_data["data"]["uploadId"]
111
- @upload_params_list = result_data["data"]["uploadParamsList"]
112
- task_num = @upload_params_list.length
113
- retry_count = 5
114
- if task_num < 2
115
- task_num = 2
116
- end
117
-
118
- if task_num > 100
119
- task_num = 100
120
- end
121
-
122
- puts "线程个数: #{task_num}"
123
- puts "重试次数: #{retry_count}"
124
- puts
125
-
126
- Funlog.instance.fancyinfo_start("开始上传...")
127
- @upload_eTags = []
117
+ begin
118
+ file_size_param = 1.00 * @file_size / 1024 /1024
119
+ result_data = post_upload_url_req(upload_path_key:upload_path_key, file_ceil_size:file_size_param.ceil)
120
+
121
+ if result_data.nil? || !result_data.has_key?("data") || !result_data["data"].has_key?("uploadId")
122
+ raise Informative, "获取上传ID失败,请检查网络或服务器状态"
123
+ end
124
+
125
+ upload_id = result_data["data"]["uploadId"]
126
+ # 创建统一的任务队列
127
+ @tasks_queue = Queue.new
128
+ @worker_threads = []
129
+ upload_params_list = result_data["data"]["uploadParamsList"]
130
+ upload_item_num = upload_params_list.length
131
+ @expected_parts = upload_item_num # 保存预期的分片数量
132
+ task_num = upload_item_num
133
+ retry_count = 5
134
+
135
+ # 合理限制线程数
136
+ if task_num < 2
137
+ task_num = 2
138
+ end
128
139
 
129
- # single_task_upload_data_req( )
140
+ if task_num > 30
141
+ task_num = 30
142
+ end
130
143
 
144
+ # 根据系统CPU核心数自动调整线程数
145
+ available_cores = Etc.respond_to?(:nprocessors) ? Etc.nprocessors : 4
146
+ task_num = [task_num, available_cores * 2].min
147
+
148
+ # 设置重试次数并将所有任务加入队列
149
+ upload_params_list.each do |item|
150
+ item["retryCount"] = retry_count
151
+ @tasks_queue.push(item)
152
+ end
131
153
 
132
- multi_task_upload_data_req(task_num:task_num, retry_count:retry_count)
154
+ puts "切分个数: #{upload_item_num}"
155
+ puts "线程个数: #{task_num}"
156
+ puts "重试次数: #{retry_count}"
157
+ puts
158
+
159
+ Funlog.instance.fancyinfo_start("开始上传...")
160
+ @upload_eTags = []
161
+ @active_tasks = 0 # 跟踪活动任务数量
162
+
163
+ continuous_upload_data_req(concurrency:task_num)
164
+
165
+ # 检查上传是否全部成功
166
+ if upload_failed? || @upload_eTags.length != @expected_parts
167
+ upload_result = nil
168
+ Funlog.instance.fancyinfo_error("文件#{@upload_binary_file} 上传失败! 😭😭😭")
169
+ return upload_result
170
+ end
133
171
 
134
- result_data = post_upload_finish_req(upload_path_key:upload_path_key, upload_id:upload_id, eTags:@upload_eTags)
172
+ result_data = post_upload_finish_req(upload_path_key:upload_path_key, upload_id:upload_id, eTags:@upload_eTags)
135
173
 
136
- if result_data["code"] == 200
137
- upload_result = upload_path_key
138
- Funlog.instance.fancyinfo_success("文件#{@upload_binary_file} 上传成功! 😎😎😎")
139
- else
140
- upload_result = nil
141
- Funlog.instance.fancyinfo_error("文件#{@upload_binary_file} 上传失败! 😭😭😭")
174
+ if result_data && result_data["code"] == 200
175
+ upload_result = upload_path_key
176
+ Funlog.instance.fancyinfo_success("文件#{@upload_binary_file} 上传成功! 😎😎😎")
177
+ else
178
+ upload_result = nil
179
+ error_msg = result_data && result_data["msg"] ? result_data["msg"] : "未知错误"
180
+ Funlog.instance.fancyinfo_error("文件#{@upload_binary_file} 上传失败: #{error_msg} 😭😭😭")
181
+ end
182
+
183
+ rescue => e
184
+ upload_result = nil
185
+ Funlog.instance.fancyinfo_error("文件上传过程发生异常: #{e.message} 😭😭😭")
186
+ ensure
187
+ # 确保所有工作线程都被清理
188
+ cleanup_worker_threads
142
189
  end
143
190
 
144
191
  return upload_result
145
192
 
146
193
  end
147
194
 
148
-
149
- def multi_task_upload_data_req(task_num:1, retry_count: 3)
150
-
151
- for i in 0..@upload_params_list.length-1 do
152
- @upload_params_list[i]["retryCount"] = retry_count
153
- end
154
-
155
- while @upload_params_list.size > 0
156
- upload_params_list_temp = []
157
- #每次最大5个线程上传
158
- for i in 1..task_num do
159
- upload_params_list_temp << @upload_params_list.shift
160
- end
161
- hydra = Typhoeus::Hydra.new
162
- while upload_params_list_temp.size > 0
163
- upload_params_item = upload_params_list_temp.shift
164
- unless upload_params_item.nil?
165
- single_task_upload_part_data_req(upload_params_item:upload_params_item, hydra_handle:hydra)
166
- end
167
- end
168
- hydra.run
169
- end
170
-
195
+ # 安全地检查上传失败状态
196
+ def upload_failed?
197
+ @upload_failed_mutex.synchronize { @upload_failed }
171
198
  end
172
-
173
- def single_task_upload_data_req()
174
-
175
- for i in 0..@upload_params_list.length-1 do
176
- @upload_params_list[i]["retryCount"] = 3
199
+
200
+ # 安全地设置上传失败状态
201
+ def set_upload_failed(error_msg = nil)
202
+ @upload_failed_mutex.synchronize do
203
+ @upload_failed = true
204
+ Funlog.instance.fancyinfo_error("上传失败: #{error_msg}") if error_msg
177
205
  end
178
-
179
- while @upload_params_list.size > 0
180
- upload_params_item = @upload_params_list.shift
181
- single_task_upload_part_data_req(upload_params_item:upload_params_item)
206
+ end
207
+
208
+ # 清理所有工作线程
209
+ def cleanup_worker_threads
210
+ @worker_threads.each do |thread|
211
+ # 尝试安全终止线程
212
+ thread.exit if thread.alive?
182
213
  end
183
-
184
-
185
- return @eTags
214
+ @worker_threads.clear
186
215
  end
187
216
 
188
- def single_task_upload_part_data_req(upload_params_item:nil, hydra_handle:nil)
189
-
217
+ def continuous_upload_data_req(concurrency:1)
218
+ # 初始化活动任务计数和条件变量
219
+ @active_tasks = 0
220
+ @task_complete_cv = ConditionVariable.new
221
+
222
+ # 初始化连续上传,最多启动concurrency个并发任务
223
+ start_tasks = [concurrency, @tasks_queue.size].min
224
+ start_tasks.times { schedule_next_task }
225
+
226
+ # 设置超时保护
227
+ timeout_seconds = 300 # 5分钟超时
228
+ start_time = Time.now
229
+
230
+ # 等待所有任务完成
231
+ @tasks_queue_mutex.synchronize do
232
+ while (@active_tasks > 0 || !@tasks_queue.empty?) && !upload_failed?
233
+ # 添加超时保护
234
+ remaining_time = timeout_seconds - (Time.now - start_time)
235
+ if remaining_time <= 0
236
+ set_upload_failed("上传任务超时")
237
+ break
238
+ end
239
+
240
+ # 等待任务完成通知,最多等待30秒
241
+ @task_complete_cv.wait(@tasks_queue_mutex, [remaining_time, 30].min)
242
+ end
243
+ end
244
+
245
+ # 检查所有分片是否都上传成功
246
+ if @upload_eTags.length != @expected_parts && !upload_failed?
247
+ set_upload_failed("部分分片上传失败,已上传#{@upload_eTags.length}/#{@expected_parts}")
248
+ end
249
+ end
250
+
251
+ def schedule_next_task
252
+ # 检查是否应该停止调度
253
+ return if upload_failed?
254
+
255
+ # 尝试从队列中获取下一个任务
256
+ @tasks_queue_mutex.synchronize do
257
+ unless @tasks_queue.empty?
258
+ upload_params_item = @tasks_queue.pop
259
+ @active_tasks_mutex.synchronize { @active_tasks += 1 }
260
+
261
+ # 异步处理任务,不阻塞主线程
262
+ worker_thread = Thread.new do
263
+ begin
264
+ process_upload_task(upload_params_item)
265
+ rescue => e
266
+ # 捕获并记录任务处理过程中的异常
267
+ set_upload_failed("处理分片#{upload_params_item["partNo"]}时出错: #{e.message}")
268
+ ensure
269
+ # 任务完成后,减少活动任务计数并通知等待线程
270
+ @active_tasks_mutex.synchronize { @active_tasks -= 1 }
271
+
272
+ # 如果队列不为空,调度下一个任务
273
+ schedule_next_task if !upload_failed?
274
+
275
+ # 通知等待线程任务已完成
276
+ @tasks_queue_mutex.synchronize { @task_complete_cv.broadcast }
277
+ end
278
+ end
279
+
280
+ # 保存线程引用以便后续清理
281
+ @worker_threads << worker_thread
282
+ end
283
+ end
284
+ end
285
+
286
+ def process_upload_task(upload_params_item)
190
287
  upload_url = upload_params_item["signedUrl"]
191
288
  part_no = upload_params_item["partNo"]
192
-
289
+
193
290
  file_size_ele = 1024 * 1024 * 5 #5M
194
- start_position = file_size_ele * (part_no -1)
291
+ start_position = file_size_ele * (part_no - 1)
195
292
  if part_no * file_size_ele > @file_size
196
- read_length = @file_size - start_position
293
+ read_length = @file_size - start_position
197
294
  else
198
- read_length = file_size_ele
295
+ read_length = file_size_ele
199
296
  end
200
-
297
+
201
298
  file = File.open(@upload_binary_file, "rb")
202
- file.seek(start_position)
203
- put_data = file.read(read_length)
204
-
205
- request = create_req(upload_url:upload_url, body_data:put_data, read_length:read_length)
206
- request.on_progress do |dltotal, dlnow, ultotal, ulnow|
207
- if ulnow
208
- @progress_bar.update_upload_index(upload_part:part_no, upload_size:ulnow)
209
- @progress_bar.update_upload_progress()
299
+ begin
300
+ file.seek(start_position)
301
+ put_data = file.read(read_length)
302
+
303
+ request = create_req(upload_url:upload_url, body_data:put_data, read_length:read_length)
304
+
305
+ # 设置上传进度回调
306
+ upload_size_last = 0
307
+ request.on_progress do |dltotal, dlnow, ultotal, ulnow|
308
+ if ulnow && ulnow > upload_size_last
309
+ upload_size_last = ulnow
310
+ @progress_bar.update_upload_index(upload_part:part_no, upload_size:ulnow)
311
+ @progress_bar.update_upload_progress()
312
+ end
210
313
  end
211
- end
212
-
213
- request.on_complete do |response|
314
+
315
+ # 设置请求超时
316
+ request.options[:timeout] = 300 # 5分钟超时
317
+
318
+ # 执行请求并等待完成
319
+ response = request.run
320
+
321
+ # 处理响应结果
214
322
  if response.success?
215
- @progress_bar.complete_upload_index(upload_part:part_no, complete_size:read_length)
216
- etag = response.headers["ETag"]
217
- eTag_item = { partNumber: part_no, tag: etag}
218
- @upload_eTags << eTag_item
323
+ @progress_bar.complete_upload_index(upload_part:part_no, complete_size:read_length)
324
+ etag = response.headers["ETag"]
325
+ if etag.nil? || etag.empty?
326
+ raise "服务器返回的ETag为空"
327
+ end
328
+ eTag_item = { partNumber: part_no, tag: etag}
329
+ @upload_eTags_mutex.synchronize { @upload_eTags << eTag_item }
219
330
  else
220
- @progress_bar.delete_upload_index(upload_part:part_no)
221
- upload_params_item["retryCount"] = upload_params_item["retryCount"] - 1
222
- if upload_params_item["retryCount"] > 0
223
- # @upload_params_list.push(upload_params_item)
224
- single_task_upload_part_data_req(upload_params_item:upload_params_item, hydra_handle:hydra_handle)
225
- else
226
- Funlog.instance.fancyinfo_error("文件#{@upload_binary_file} 上传失败! 😭😭😭")
227
- raise Informative, "上传文件失败"
228
- end
331
+ @progress_bar.delete_upload_index(upload_part:part_no)
332
+ upload_params_item["retryCount"] = upload_params_item["retryCount"] - 1
333
+ if upload_params_item["retryCount"] > 0
334
+ # 重试任务
335
+ @tasks_queue_mutex.synchronize { @tasks_queue.push(upload_params_item) }
336
+ else
337
+ set_upload_failed("文件#{@upload_binary_file} 分片#{part_no}上传失败: HTTP #{response.code}")
338
+ end
229
339
  end
230
- end
231
-
232
- if hydra_handle.nil?
233
- request.run
234
- else
235
- hydra_handle.queue(request)
340
+ ensure
341
+ file.close
236
342
  end
237
343
  end
238
344
 
@@ -284,26 +390,30 @@ module Pindo
284
390
  tags:eTags
285
391
  }
286
392
 
287
- con = HttpClient.create_instance_with_proxy
288
- res = con.post do |req|
289
- req.url boss_url
290
- req.headers['Content-Type'] = 'application/json'
291
- req.headers['token'] = @token["token"]
292
- req.body = body_params.to_json
293
- end
393
+ begin
394
+ con = HttpClient.create_instance_with_proxy
395
+ res = con.post do |req|
396
+ req.url boss_url
397
+ req.headers['Content-Type'] = 'application/json'
398
+ req.headers['token'] = @token["token"]
399
+ req.body = body_params.to_json
400
+ req.options.timeout = 120 # 设置2分钟超时
401
+ end
294
402
 
403
+ result_data = nil
404
+ if !res.body.nil?
405
+ result_data = JSON.parse(res.body)
406
+ end
295
407
 
296
- result_data = nil
297
- if !res.body.nil?
298
- result_data = JSON.parse(res.body)
408
+ return result_data
409
+ rescue => e
410
+ Funlog.instance.fancyinfo_error("完成上传请求失败: #{e.message}")
411
+ return nil
299
412
  end
300
-
301
- return result_data
302
-
303
413
  end
304
414
 
305
415
 
306
- def post_upload_url_req(upload_path_key:nil, file_ceil_size:nil)
416
+ def post_upload_url_req(upload_path_key:nil, file_ceil_size:nil)
307
417
 
308
418
  boss_url = @baseurl + @request_config["multi_signed_url_upload"]
309
419
 
@@ -313,22 +423,26 @@ module Pindo
313
423
  fileSize:file_ceil_size
314
424
  }
315
425
 
316
- con = HttpClient.create_instance_with_proxy
317
- res = con.post do |req|
318
- req.url boss_url
319
- req.headers['Content-Type'] = 'application/json'
320
- req.headers['token'] = @token["token"]
321
- req.body = body_params.to_json
322
- end
426
+ begin
427
+ con = HttpClient.create_instance_with_proxy
428
+ res = con.post do |req|
429
+ req.url boss_url
430
+ req.headers['Content-Type'] = 'application/json'
431
+ req.headers['token'] = @token["token"]
432
+ req.body = body_params.to_json
433
+ req.options.timeout = 60 # 设置1分钟超时
434
+ end
323
435
 
436
+ result_data = nil
437
+ if !res.body.nil?
438
+ result_data = JSON.parse(res.body)
439
+ end
324
440
 
325
- result_data = nil
326
- if !res.body.nil?
327
- result_data = JSON.parse(res.body)
441
+ return result_data
442
+ rescue => e
443
+ Funlog.instance.fancyinfo_error("获取上传URL失败: #{e.message}")
444
+ return nil
328
445
  end
329
-
330
- return result_data
331
-
332
446
  end
333
447
 
334
448
  class PgyerUploadProgressBar
@@ -341,50 +455,58 @@ module Pindo
341
455
  attr_accessor :is_done
342
456
 
343
457
  def initialize(upload_total_size:nil, draw_char:'>')
344
-
345
-
346
458
  @upload_total_size = upload_total_size
347
459
  @draw_char = draw_char
348
460
  @last_update_time = (Time.now.to_f * 1000).to_i #毫秒
349
461
 
350
462
  @complete_size = 0
351
463
  @update_ing_size = {}
352
-
353
464
  @is_done = false
465
+
466
+ # 添加互斥锁来保护进度条更新
467
+ @mutex = Mutex.new
354
468
  end
355
469
 
356
470
  def update_upload_index(upload_part:nil, upload_size:nil)
357
- @update_ing_size[upload_part] = upload_size
471
+ @mutex.synchronize do
472
+ @update_ing_size[upload_part] = upload_size
473
+ end
358
474
  end
359
475
 
360
476
  def delete_upload_index(upload_part:nil)
361
- @update_ing_size[upload_part] = 0
477
+ @mutex.synchronize do
478
+ @update_ing_size[upload_part] = 0
479
+ end
362
480
  end
363
481
 
364
482
  def complete_upload_index(upload_part:nil, complete_size:nil)
365
- @complete_size = @complete_size + complete_size
366
- @update_ing_size[upload_part] = 0
483
+ @mutex.synchronize do
484
+ @complete_size = @complete_size + complete_size
485
+ @update_ing_size[upload_part] = 0
486
+ end
367
487
  end
368
488
 
369
489
  def update_upload_progress()
370
490
  time_now = (Time.now.to_f * 1000).to_i #毫秒
371
491
  if time_now - @last_update_time > 80
372
- @last_update_time = time_now
373
- total_num = @upload_total_size
374
- index_num = @complete_size
375
- @update_ing_size.each do |key, value|
376
- index_num = index_num + value
377
- end
378
-
379
- progress_str = sprintf("%.2f", 100.0 * index_num / total_num )
380
- total_size = sprintf("%.2f", 1.00 * total_num / 1024 /1024 )
381
- upload_size = sprintf("%.2f", 1.00 * index_num / 1024 /1024 )
382
- index = 40.0 * index_num / total_num
383
- upload_message = "已上传:#{upload_size}MB|#{progress_str}\%【" + (@draw_char * (index/1).floor).ljust(40.0, '_') + "】Total:#{total_size}MB"
384
- Funlog.instance.fancyinfo_update(upload_message)
385
- if index_num == total_num && !@is_done
386
- @is_done = true
387
- Funlog.instance.fancyinfo_success(upload_message)
492
+ @mutex.synchronize do
493
+ @last_update_time = time_now
494
+ total_num = @upload_total_size
495
+ index_num = @complete_size
496
+ @update_ing_size.each do |key, value|
497
+ index_num = index_num + value
498
+ end
499
+
500
+ progress_str = sprintf("%.2f", 100.0 * index_num / total_num )
501
+ total_size = sprintf("%.2f", 1.00 * total_num / 1024 /1024 )
502
+ upload_size = sprintf("%.2f", 1.00 * index_num / 1024 /1024 )
503
+ index = 40.0 * index_num / total_num
504
+ upload_message = "已上传:#{upload_size}MB|#{progress_str}\%【" + (@draw_char * (index/1).floor).ljust(40.0, '_') + "】Total:#{total_size}MB"
505
+ Funlog.instance.fancyinfo_update(upload_message)
506
+ if index_num == total_num && !@is_done
507
+ @is_done = true
508
+ Funlog.instance.fancyinfo_success(upload_message)
509
+ end
388
510
  end
389
511
  end
390
512
  end
data/lib/pindo/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Pindo
2
2
 
3
- VERSION = "5.0.5"
3
+ VERSION = "5.0.7"
4
4
 
5
5
  class VersionCheck
6
6
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pindo
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.5
4
+ version: 5.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - wade
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-20 00:00:00.000000000 Z
10
+ date: 2025-04-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: claide
@@ -133,16 +133,22 @@ dependencies:
133
133
  name: faraday-retry
134
134
  requirement: !ruby/object:Gem::Requirement
135
135
  requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.0'
136
139
  - - ">="
137
140
  - !ruby/object:Gem::Version
138
- version: '0'
141
+ version: 1.0.3
139
142
  type: :runtime
140
143
  prerelease: false
141
144
  version_requirements: !ruby/object:Gem::Requirement
142
145
  requirements:
146
+ - - "~>"
147
+ - !ruby/object:Gem::Version
148
+ version: '1.0'
143
149
  - - ">="
144
150
  - !ruby/object:Gem::Version
145
- version: '0'
151
+ version: 1.0.3
146
152
  - !ruby/object:Gem::Dependency
147
153
  name: typhoeus
148
154
  requirement: !ruby/object:Gem::Requirement
@@ -183,6 +189,26 @@ dependencies:
183
189
  - - ">="
184
190
  - !ruby/object:Gem::Version
185
191
  version: 1.15.4
192
+ - !ruby/object:Gem::Dependency
193
+ name: webrick
194
+ requirement: !ruby/object:Gem::Requirement
195
+ requirements:
196
+ - - "~>"
197
+ - !ruby/object:Gem::Version
198
+ version: '1.8'
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: 1.8.1
202
+ type: :runtime
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - "~>"
207
+ - !ruby/object:Gem::Version
208
+ version: '1.8'
209
+ - - ">="
210
+ - !ruby/object:Gem::Version
211
+ version: 1.8.1
186
212
  - !ruby/object:Gem::Dependency
187
213
  name: bundler
188
214
  requirement: !ruby/object:Gem::Requirement
@@ -207,30 +233,36 @@ dependencies:
207
233
  name: rake
208
234
  requirement: !ruby/object:Gem::Requirement
209
235
  requirements:
236
+ - - "~>"
237
+ - !ruby/object:Gem::Version
238
+ version: '13.0'
210
239
  - - ">="
211
240
  - !ruby/object:Gem::Version
212
- version: '0'
241
+ version: 13.0.6
213
242
  type: :development
214
243
  prerelease: false
215
244
  version_requirements: !ruby/object:Gem::Requirement
216
245
  requirements:
246
+ - - "~>"
247
+ - !ruby/object:Gem::Version
248
+ version: '13.0'
217
249
  - - ">="
218
250
  - !ruby/object:Gem::Version
219
- version: '0'
251
+ version: 13.0.6
220
252
  - !ruby/object:Gem::Dependency
221
253
  name: rspec
222
254
  requirement: !ruby/object:Gem::Requirement
223
255
  requirements:
224
- - - ">="
256
+ - - "~>"
225
257
  - !ruby/object:Gem::Version
226
- version: '0'
258
+ version: '3.12'
227
259
  type: :development
228
260
  prerelease: false
229
261
  version_requirements: !ruby/object:Gem::Requirement
230
262
  requirements:
231
- - - ">="
263
+ - - "~>"
232
264
  - !ruby/object:Gem::Version
233
- version: '0'
265
+ version: '3.12'
234
266
  description: easy work for deploy, dev
235
267
  email:
236
268
  - wade@gmail.com
@@ -258,6 +290,7 @@ files:
258
290
  - lib/pindo/client/feishuclient.rb
259
291
  - lib/pindo/client/giteeclient.rb
260
292
  - lib/pindo/client/httpclient.rb
293
+ - lib/pindo/client/pgyer_feishu_oauth_cli.rb
261
294
  - lib/pindo/client/pgyerclient.rb
262
295
  - lib/pindo/client/pgyeruploadclient.rb
263
296
  - lib/pindo/client/tgateclient.rb