jpsclient 2.7.5 → 2.7.6

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: b7014fe7a27b5b18567a2026f0df7f02efc5cb41fd9756e031377f8d7af74d65
4
- data.tar.gz: e0e02d786ede3bdb12b5d5f4d42f4a65d9414420835de19230b638be24f4bc66
3
+ metadata.gz: 0d61ea8f97063390fa28c05be9b39979a18f9eb671aead52d2f075f9be04ee8c
4
+ data.tar.gz: 05f18f0cb982cca4bc27233aaef5cdf2456138723de40ab3d89ea5909995b0b1
5
5
  SHA512:
6
- metadata.gz: ed31e0a4b90af88a0a21596ea29aaa29442be00defb71bc90a05b750bc9d304cbf08aec40dab57d9fcbc20f50ddeabb013564cdfc0273ff3d009fc7ae44768e8
7
- data.tar.gz: 7573c0e55b1be5fa61290621f678cfc038e617919407b0b4e1e0d81d31fea909fd6adb31b867ebf77f03d4bcf647bf4c269883716cae501a7344a1d55a6f3f4a
6
+ metadata.gz: 45ce3c0e73d8384391fece49e0b8256697a99b371aa98859fd6e649baadd527a32ccf1af93b4db14545f7a769000f9484d629bd908bf4382656977bea70026b7
7
+ data.tar.gz: 6612908940d4ce6a89e4503b9cf54529fcbb9a9d9c1bb5f6dd4a51814ab8c41aee69165cf25e56d612bfa68dadbb3892044504c666299f461ad13ee92024689e
@@ -23,10 +23,15 @@ module JPSClient
23
23
  end
24
24
 
25
25
  # 获取简单上传预签名URL(用于小文件直接上传,使用 media 配置)
26
+ #
27
+ # 传了 contentType 时服务端会把 content-type 纳入签名(X-Amz-SignedHeaders 含
28
+ # content-type),调用方 PUT 时必须发送完全相同的值,否则 S3 返回 403
29
+ #
26
30
  # @param s3_key [String] S3 文件路径
31
+ # @param content_type [String] 文件 Content-Type,决定对象最终类型
27
32
  # @param upload_config [UploadConfig] 上传配置
28
33
  # @return [Hash] 包含预签名URL的响应
29
- def get_simple_sign_url(s3_key:, upload_config:)
34
+ def get_simple_sign_url(s3_key:, content_type: "", upload_config:)
30
35
  config = @request_config && @request_config["file_sign_url"]
31
36
  raise JPSClient::ExceptionError, "Missing config for file_sign_url" unless config && config["url"]
32
37
 
@@ -34,6 +39,7 @@ module JPSClient
34
39
 
35
40
  body_params = {
36
41
  s3Key: s3_key,
42
+ contentType: content_type,
37
43
  uploadType: upload_config.upload_type,
38
44
  bucketName: upload_config.media_bucket_name,
39
45
  region: upload_config.media_region
@@ -43,7 +49,9 @@ module JPSClient
43
49
  end
44
50
 
45
51
  # 获取分片预签名URL
46
- def get_file_sign_url(s3_key:, upload_id:, part_id:, upload_config:)
52
+ #
53
+ # @param content_type [String] 文件 Content-Type,与 init_file_multipart 保持一致
54
+ def get_file_sign_url(s3_key:, upload_id:, part_id:, content_type: "", upload_config:)
47
55
  config = @request_config && @request_config["file_sign_url"]
48
56
  raise JPSClient::ExceptionError, "Missing config for file_sign_url" unless config && config["url"]
49
57
 
@@ -51,6 +59,7 @@ module JPSClient
51
59
 
52
60
  body_params = {
53
61
  s3Key: s3_key,
62
+ contentType: content_type,
54
63
  uploadType: upload_config.upload_type,
55
64
  uploadId: upload_id,
56
65
  partId: part_id,
@@ -10,6 +10,7 @@ require 'jpsclient/http/http_client'
10
10
  require 'jpsclient/upload/upload_config'
11
11
  require 'jpsclient/upload/upload_progress'
12
12
  require 'jpsclient/utils/logger'
13
+ require 'jpsclient/utils/content_type'
13
14
 
14
15
  module JPSClient
15
16
 
@@ -20,8 +21,12 @@ module JPSClient
20
21
  attr_accessor :file_size
21
22
  attr_accessor :progress_bar
22
23
  attr_reader :jps_client
24
+ attr_reader :s3_key # 本次上传使用的 S3 Key,供调用方自行拼装访问地址
23
25
 
24
- def initialize(jps_client)
26
+ # @param jps_client [Client] JPS 客户端实例
27
+ # @param upload_config [UploadConfig, nil] 上传配置,传入时覆盖客户端配置。
28
+ # 媒体大文件走分片上传时由 UploadMediaClient 注入 media 桶配置
29
+ def initialize(jps_client, upload_config: nil)
25
30
  raise ExceptionError, "必须提供 Client 实例" unless jps_client
26
31
 
27
32
  @jps_client = jps_client
@@ -30,7 +35,7 @@ module JPSClient
30
35
  config_json = @jps_client.config_json
31
36
 
32
37
  # 加载上传配置
33
- @upload_config = UploadConfig.from_json(config_json["upload_config"])
38
+ @upload_config = upload_config || UploadConfig.from_json(config_json["upload_config"])
34
39
  unless @upload_config && @upload_config.valid?
35
40
  raise ExceptionError, "上传配置无效或不完整"
36
41
  end
@@ -66,6 +71,7 @@ module JPSClient
66
71
  # 使用配置中的路径前缀
67
72
  path_prefix = isAttach ? @upload_config.attach_url : @upload_config.default_url
68
73
  s3_key = "#{path_prefix}#{file_uuid}#{extension}"
74
+ @s3_key = s3_key
69
75
 
70
76
  # 计算文件大小和分片信息
71
77
  total_mb = sprintf("%.2f", @file_size / 1024.0 / 1024.0)
@@ -86,12 +92,29 @@ module JPSClient
86
92
 
87
93
  upload_result = nil
88
94
 
95
+ # contentType 决定 CreateMultipartUpload 后对象的最终类型,按扩展名推导,
96
+ # 让 apk/zip 等安装包不再一律落成 application/octet-stream。
97
+ # 分片 PUT 也发同一个值:预签名 URL 若把 content-type 纳入签名,两边不一致会 403
98
+ @content_type = ContentType.for_file(@upload_binary_file)
99
+ content_type = @content_type
100
+
101
+ if JPSClient.debug?
102
+ puts [
103
+ "[JPS_DEBUG] 分片上传参数: #{filename}",
104
+ "[JPS_DEBUG] s3Key: #{s3_key}",
105
+ "[JPS_DEBUG] bucketName: #{@upload_config.bucket_name}",
106
+ "[JPS_DEBUG] region: #{@upload_config.region}",
107
+ "[JPS_DEBUG] uploadType: #{@upload_config.upload_type}",
108
+ "[JPS_DEBUG] contentType: #{content_type}"
109
+ ].join("\n")
110
+ end
111
+
89
112
  begin
90
113
  # 步骤1: 初始化多部分上传
91
114
  Logger.instance.fancyinfo_start("初始化上传...")
92
115
  init_result = @jps_client.init_file_multipart(
93
116
  s3_key: s3_key,
94
- content_type: "",
117
+ content_type: content_type,
95
118
  upload_config: @upload_config
96
119
  )
97
120
 
@@ -321,6 +344,7 @@ module JPSClient
321
344
  s3_key: s3_key,
322
345
  upload_id: upload_id,
323
346
  part_id: part_no,
347
+ content_type: @content_type.to_s,
324
348
  upload_config: @upload_config
325
349
  )
326
350
 
@@ -381,7 +405,7 @@ module JPSClient
381
405
  method: :put,
382
406
  body: put_data,
383
407
  headers: {
384
- 'Content-Type' => 'application/octet-stream',
408
+ 'Content-Type' => @content_type || ContentType::DEFAULT,
385
409
  'Content-Length' => read_length.to_s
386
410
  },
387
411
  timeout: chunk_timeout,
@@ -105,6 +105,30 @@ module JPSClient
105
105
  return 5
106
106
  end
107
107
 
108
+ # 返回一份把 media_* 提升为主字段的副本
109
+ #
110
+ # 分片上传链路(init_multipart / sign_url / complete_multipart)统一读
111
+ # bucket_name / region / default_url,媒体大文件要落到 media 桶,就用这份视图,
112
+ # 而不是就地改写配置,避免影响同一进程里的安装包上传
113
+ #
114
+ # @return [UploadConfig] 新的配置实例,原实例不变
115
+ def to_media_config
116
+ self.class.new(
117
+ region: @media_region,
118
+ bucket_name: @media_bucket_name,
119
+ default_url: @media_default_url,
120
+ attach_url: @attach_url,
121
+ upload_type: @upload_type,
122
+ concurrent_workers: @concurrent_workers,
123
+ chunk_size_mb: @chunk_size_mb,
124
+ max_retry_times: @max_retry_times,
125
+ timeout_seconds: @timeout_seconds,
126
+ media_region: @media_region,
127
+ media_bucket_name: @media_bucket_name,
128
+ media_default_url: @media_default_url
129
+ )
130
+ end
131
+
108
132
  # 验证配置完整性
109
133
  # 注意:移除了对 access_key_id 和 access_key_secret 的验证
110
134
  # 因为客户端使用预签名 URL,不直接使用 AWS 凭证
@@ -4,6 +4,7 @@ require 'thread'
4
4
  require 'jpsclient/base/exception'
5
5
  require 'jpsclient/upload/upload_config'
6
6
  require 'jpsclient/utils/logger'
7
+ require 'jpsclient/utils/content_type'
7
8
 
8
9
  module JPSClient
9
10
  # Media 文件上传客户端
@@ -90,38 +91,13 @@ module JPSClient
90
91
  @upload_failed = false
91
92
  @upload_results = []
92
93
 
93
- # 准备任务队列
94
- @tasks_queue = Queue.new
95
- @worker_threads = []
96
- @expected_files = valid_files.size
97
- @active_tasks = 0
98
-
99
- # 获取并发和重试配置
100
- concurrent_workers = [@upload_config.concurrent_workers, valid_files.size].min
101
- retry_count = @upload_config.max_retry_times
102
-
103
- puts "准备上传 #{valid_files.size} 个文件"
104
- puts "并发上传线程数: #{concurrent_workers}"
105
- puts "失败重试次数: #{retry_count}"
106
- puts
107
-
108
- # 创建文件上传任务
109
- valid_files.each do |file_path|
110
- task_item = {
111
- "file_path" => file_path,
112
- "retry_count" => retry_count
113
- }
114
- @tasks_queue.push(task_item)
115
- end
116
-
117
- # 开始并发上传
118
- Logger.instance.fancyinfo_start("开始上传...")
94
+ # 按分片大小分流:装不满一个分片的文件一次 PUT 传完,超出的走分片上传
95
+ # 大文件不并发,是因为分片上传自身已经并发传分片,再套一层会让线程数和进度条都失控
96
+ chunk_size = @upload_config.chunk_size_bytes
97
+ small_files, large_files = valid_files.partition { |f| File.size(f) <= chunk_size }
119
98
 
120
- begin
121
- concurrent_upload(concurrency: concurrent_workers)
122
- ensure
123
- cleanup_worker_threads
124
- end
99
+ upload_small_files(small_files) if small_files.any?
100
+ large_files.each { |file_path| upload_large_file(file_path) }
125
101
 
126
102
  # 收集结果
127
103
  @upload_results.each do |item|
@@ -159,6 +135,86 @@ module JPSClient
159
135
 
160
136
  private
161
137
 
138
+ # 并发简单上传(单个分片装得下的文件)
139
+ #
140
+ # @param file_paths [Array<String>] 文件路径列表
141
+ def upload_small_files(file_paths)
142
+ @tasks_queue = Queue.new
143
+ @worker_threads = []
144
+ @expected_files = file_paths.size
145
+ @active_tasks = 0
146
+
147
+ concurrent_workers = [@upload_config.concurrent_workers, file_paths.size].min
148
+ retry_count = @upload_config.max_retry_times
149
+
150
+ puts "准备上传 #{file_paths.size} 个文件"
151
+ puts "并发上传线程数: #{concurrent_workers}"
152
+ puts "失败重试次数: #{retry_count}"
153
+ puts
154
+
155
+ file_paths.each do |file_path|
156
+ @tasks_queue.push("file_path" => file_path, "retry_count" => retry_count)
157
+ end
158
+
159
+ Logger.instance.fancyinfo_start("开始上传...")
160
+
161
+ begin
162
+ concurrent_upload(concurrency: concurrent_workers)
163
+ ensure
164
+ cleanup_worker_threads
165
+ end
166
+ end
167
+
168
+ # 分片上传单个大文件(超过 chunk_size_mb 的文件)
169
+ #
170
+ # 复用安装包的分片链路(init_multipart / sign_url / complete_multipart),
171
+ # 但注入 media 桶配置,保证媒体文件仍然落在 media 桶、走 media 静态域名
172
+ #
173
+ # @param file_path [String] 文件路径
174
+ def upload_large_file(file_path)
175
+ file_name = File.basename(file_path)
176
+ puts "分片上传大文件: #{file_name}(#{(File.size(file_path) / 1024.0 / 1024.0).round(1)}MB)"
177
+
178
+ client = UploadClient.new(@jps_client, upload_config: @upload_config.to_media_config)
179
+
180
+ url = begin
181
+ client.upload_file(binary_file: file_path)
182
+ rescue => e
183
+ Logger.instance.fancyinfo_error("#{file_name} 分片上传异常: #{e.message}")
184
+ nil
185
+ end
186
+
187
+ if url.to_s.empty?
188
+ @results_mutex.synchronize do
189
+ @upload_results << {
190
+ "file_path" => file_path,
191
+ "url" => nil,
192
+ "success" => false,
193
+ "error" => "分片上传失败"
194
+ }
195
+ end
196
+ puts " ❌ #{file_name}"
197
+ return
198
+ end
199
+
200
+ # complete 可能回退成 s3Key,也可能返回带 X-Amz-Signature 的预签名地址(30 分钟过期)。
201
+ # 这两种都不能写进 JPS 提交记录,统一改用媒体静态域名 + s3Key
202
+ unless static_media_url?(url)
203
+ url = "#{static_base_url}/#{client.s3_key.to_s.sub(%r{\A/}, '')}"
204
+ end
205
+
206
+ @results_mutex.synchronize do
207
+ @upload_results << {
208
+ "file_path" => file_path,
209
+ "url" => url,
210
+ "success" => true,
211
+ "error" => nil
212
+ }
213
+ end
214
+
215
+ puts " ✅ #{file_name}"
216
+ end
217
+
162
218
  # 并发上传控制
163
219
  def concurrent_upload(concurrency: 1)
164
220
  @worker_threads = []
@@ -235,9 +291,16 @@ module JPSClient
235
291
  extension = File.extname(file_path)
236
292
  s3_key = "#{@upload_config.media_default_url}#{file_uuid}#{extension}"
237
293
 
294
+ # Content-Type 按扩展名推导,并跟着签名请求一起发给服务端:
295
+ # 服务端会把它纳入签名,下面 PUT 时必须发送同一个值
296
+ content_type = ContentType.for_file(file_path)
297
+
298
+ debug_print_upload_params(file_name, s3_key, content_type)
299
+
238
300
  # 获取预签名 URL
239
301
  sign_result = @jps_client.get_simple_sign_url(
240
302
  s3_key: s3_key,
303
+ content_type: content_type,
241
304
  upload_config: @upload_config
242
305
  )
243
306
 
@@ -265,7 +328,7 @@ module JPSClient
265
328
  method: :put,
266
329
  body: file_content,
267
330
  headers: {
268
- 'Content-Type' => 'application/octet-stream',
331
+ 'Content-Type' => content_type,
269
332
  'Content-Length' => file_size.to_s
270
333
  },
271
334
  timeout: timeout,
@@ -298,6 +361,21 @@ module JPSClient
298
361
  end
299
362
  end
300
363
 
364
+ # 调试模式(JPS_DEBUG)下打印本次上传使用的 S3 参数
365
+ # 并发上传时逐行 puts 会互相穿插,这里一次性输出整块
366
+ def debug_print_upload_params(file_name, s3_key, content_type)
367
+ return unless JPSClient.debug?
368
+
369
+ puts [
370
+ "[JPS_DEBUG] media 上传参数: #{file_name}",
371
+ "[JPS_DEBUG] s3Key: #{s3_key}",
372
+ "[JPS_DEBUG] bucketName: #{@upload_config.media_bucket_name}",
373
+ "[JPS_DEBUG] region: #{@upload_config.media_region}",
374
+ "[JPS_DEBUG] uploadType: #{@upload_config.upload_type}",
375
+ "[JPS_DEBUG] contentType: #{content_type}"
376
+ ].join("\n")
377
+ end
378
+
301
379
  # 处理重试
302
380
  def handle_retry(task_item, error_reason)
303
381
  file_path = task_item["file_path"]
@@ -346,21 +424,33 @@ module JPSClient
346
424
  [[calculated, 30].max, 300].min
347
425
  end
348
426
 
427
+ # 是否是可以长期访问的地址:必须是 http 开头且不带 S3 预签名参数
428
+ def static_media_url?(url)
429
+ value = url.to_s
430
+ value.start_with?("http") && !value.include?("X-Amz-Signature")
431
+ end
432
+
433
+ # 取预签名 PUT 地址:sign_url 返回的 url 才带 X-Amz-Signature,
434
+ # api_gateway_url 是上传完成后的静态访问地址,PUT 到它上面必然失败
349
435
  def signed_upload_url(sign_result)
350
436
  data = sign_result&.dig("data")
351
437
  return nil unless data.is_a?(Hash)
352
438
 
353
- data["url"] || data["signUrl"] || data["sign_url"] ||
354
- data["apiGatewayUrl"] || data["api_gateway_url"]
439
+ data["url"] || data["signUrl"] || data["sign_url"]
355
440
  end
356
441
 
442
+ # 取上传完成后的访问地址,用于写回 JPS 提交记录
443
+ #
444
+ # 优先 api_gateway_url:sign_url 返回的静态域名地址就是给上传后更新数据用的。
445
+ # 这里绝不能回退到 data["url"],那是带 X-Amz-Signature 且 30 分钟过期的预签名地址,
446
+ # 写进 commit_log 后链接很快失效
357
447
  def uploaded_media_url(sign_result, s3_key)
358
448
  data = sign_result&.dig("data")
359
449
  if data.is_a?(Hash)
360
- url = data["fileUrl"] || data["file_url"] ||
450
+ url = data["apiGatewayUrl"] || data["api_gateway_url"] ||
451
+ data["fileUrl"] || data["file_url"] ||
361
452
  data["staticUrl"] || data["static_url"] ||
362
- data["httpUrl"] || data["http_url"] ||
363
- data["url"]
453
+ data["httpUrl"] || data["http_url"]
364
454
  return url unless url.to_s.empty?
365
455
  end
366
456
 
@@ -0,0 +1,43 @@
1
+ module JPSClient
2
+ # 上传文件的 Content-Type 推导
3
+ #
4
+ # S3 对象最终的 Content-Type 由两处决定:简单上传是 PUT 时发送的 header,
5
+ # 分片上传是 CreateMultipartUpload 的 contentType。两条链路统一走这里的映射,
6
+ # 避免所有对象都存成 application/octet-stream,导致浏览器无法内联预览图片/视频、
7
+ # Android 无法识别 apk 安装包。
8
+ module ContentType
9
+ # 无法识别扩展名时的兜底类型
10
+ DEFAULT = 'application/octet-stream'.freeze
11
+
12
+ # 扩展名 → Content-Type
13
+ # .ipa / .exe 保持 application/octet-stream:这是安装包分发的惯例值,
14
+ # 换成其他类型对客户端没有收益
15
+ EXTENSION_MAP = {
16
+ '.zip' => 'application/zip',
17
+ '.apk' => 'application/vnd.android.package-archive',
18
+ '.ipa' => DEFAULT,
19
+ '.exe' => DEFAULT,
20
+ '.png' => 'image/png',
21
+ '.jpg' => 'image/jpeg',
22
+ '.jpeg' => 'image/jpeg',
23
+ '.gif' => 'image/gif',
24
+ '.bmp' => 'image/bmp',
25
+ '.webp' => 'image/webp',
26
+ '.mp4' => 'video/mp4',
27
+ '.mov' => 'video/quicktime',
28
+ '.avi' => 'video/x-msvideo',
29
+ '.mkv' => 'video/x-matroska',
30
+ '.webm' => 'video/webm'
31
+ }.freeze
32
+
33
+ module_function
34
+
35
+ # 根据文件扩展名推导 Content-Type
36
+ #
37
+ # @param file_path [String] 文件路径
38
+ # @return [String] Content-Type,无法识别时返回 DEFAULT
39
+ def for_file(file_path)
40
+ EXTENSION_MAP.fetch(::File.extname(file_path.to_s).downcase, DEFAULT)
41
+ end
42
+ end
43
+ end
@@ -1,3 +1,3 @@
1
1
  module JPSClient
2
- VERSION = '2.7.5'
2
+ VERSION = '2.7.6'
3
3
  end
data/lib/jpsclient.rb CHANGED
@@ -12,6 +12,7 @@ require 'jpsclient/base/client'
12
12
  # 工具模块
13
13
  require 'jpsclient/utils/logger'
14
14
  require 'jpsclient/utils/aes'
15
+ require 'jpsclient/utils/content_type'
15
16
 
16
17
  # HTTP 模块
17
18
  require 'jpsclient/http/http_client'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jpsclient
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.5
4
+ version: 2.7.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Your Name
@@ -274,6 +274,7 @@ files:
274
274
  - lib/jpsclient/upload/upload_media_client.rb
275
275
  - lib/jpsclient/upload/upload_progress.rb
276
276
  - lib/jpsclient/utils/aes.rb
277
+ - lib/jpsclient/utils/content_type.rb
277
278
  - lib/jpsclient/utils/debug.rb
278
279
  - lib/jpsclient/utils/logger.rb
279
280
  - lib/jpsclient/version.rb
@@ -296,7 +297,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
296
297
  - !ruby/object:Gem::Version
297
298
  version: '0'
298
299
  requirements: []
299
- rubygems_version: 4.0.10
300
+ rubygems_version: 4.0.11
300
301
  specification_version: 4
301
302
  summary: JPS Platform API Client with Full API Support
302
303
  test_files: []