k3cloud-sdk 0.5.0 → 0.6.0

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: 5dfedb0c66eddf2de607406e2cf083b98f54350412d9cbb2699d177eb66c347a
4
- data.tar.gz: b73a7c6847c615b4d2ec9fcb0dbacce4e69078ba24e204fa2cf850974a28404c
3
+ metadata.gz: ac60c158c0d8bb63fa78e7412f1a7207ce7317f6bbaa1cbe751556740987770d
4
+ data.tar.gz: 8ab6ad5bff05101523fc21ac786d4c7611713bc5283f0cce6e6dd2ea61b5c030
5
5
  SHA512:
6
- metadata.gz: 14bba40c28f9a4909551df05847b713ffce4029928fc5b9a82b020c95d7f1f155fff0a2eccce316b54c261c3bdcc3e085e823702eb3a8bf11787e181c0f52a6e
7
- data.tar.gz: 1339bf2093aad7ee854a1d342d50341f0f0bcaf376daf68e620914271d3576fbcbdfc7ee41f08d8925192a150038809298c06614646042a205a68306ed9971b1
6
+ metadata.gz: '01281a40210d978bdda71290370b0e05a1504ee23f923ab54935e2858e6e66c5ae56ade375f9d674fb077d755fa043091208a4e681045aa9d9acc02befc2170f'
7
+ data.tar.gz: da96968ea62312e524f5abe5bad731506a23b07221060e6aef64751c653bbbd4ae45e8ae6e44f5240567858fa6a20e4bbca92ef831d50e2b4a37a493ba744260
data/.rubocop.yml CHANGED
@@ -25,7 +25,7 @@ Style/Documentation:
25
25
  Enabled: false
26
26
 
27
27
  Gemspec/RequireMFA:
28
- Enabled: false
28
+ Enabled: true
29
29
 
30
30
  Style/FormatStringToken:
31
31
  Enabled: false
@@ -52,4 +52,7 @@ Metrics/CyclomaticComplexity:
52
52
  Max: 12
53
53
 
54
54
  Metrics/ParameterLists:
55
- Max: 8
55
+ Max: 10
56
+
57
+ Metrics/ClassLength:
58
+ Max: 150
data/CHANGELOG.md CHANGED
@@ -5,14 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.6.0]
9
+
10
+ ### Added
11
+
12
+ - 请求重试:`Configuration#retry_max` / `retry_interval` / `retry_backoff`,仅对超时与 5xx 生效,默认重试 3 次指数退避。
13
+ - trace 日志:DEBUG 级记录每次请求的耗时(`elapsed_ms`)、状态码、重试次数、响应字节数。
14
+ - 单据查询分页封装 `execute_bill_query_all(data, limit:)`,自动按 `StartRow`/`Limit` 拉取全部单据。
15
+ - 配置校验:必填字段缺失时抛出 `K3cloud::ConfigurationError`,给出友好提示。
16
+ - 新增 `SECURITY.md` 漏洞上报流程与安全说明。
17
+
18
+ ### Changed
19
+
20
+ - 默认启用请求重试(`retry_max` 默认 3,原 0.5.0 不重试);如需保持不重试请设 `retry_max = 0`。
21
+ - `verify_ssl` 文档明确仅 https 生效(http URL 自动跳过)。
22
+ - 日志调用统一为字符串:text 模式输出干净字符串,json 模式包装为 `{msg, level, time}`。
23
+ - `handle_query_result` 错误识别更鲁棒:兼容顶层 Hash 与行内任意列含 `Message`/`InnerExWrapper`/`InnerException` 的错误对象。
24
+ - `WebApiClient#url_path` 对非 http(s) URL 抛 `ArgumentError`,避免签名错误。
25
+ - gemspec 启用 `metadata["rubygems_mfa_required"] = "true"`,提升发布安全。
26
+ - 异常与日志消息英文化。
27
+ - README 重写为英文,按目录组织并标注全部可调用方法。
9
28
 
10
29
  ## [0.5.0] - 2026-07-17
11
30
 
12
31
  ### ⚠ Breaking Changes
13
32
 
14
33
  - 最低 Ruby 版本提升至 `>= 2.5.0`。
15
- - 默认启用 HTTPS 证书校验;私有云自签证书需显式设置 `Configuration#verify_ssl = false`。
34
+ - 默认启用 HTTPS 证书校验;私有云 https 自签证书需显式设置 `Configuration#verify_ssl = false`。
16
35
  - `Configuration` 为 `lcid`、`connect_timeout`(30s)、`request_timeout`(60s)、`verify_ssl` 提供默认值。
17
36
  - 工具类与常量迁移至 `K3cloud` 命名空间(`K3cloud::Base64Utils`、`K3cloud::MD5Utils`、`K3cloud::ConstDefine`),不再保留无命名空间别名。
18
37
  - `Http` 构造参数改为关键字参数。
@@ -21,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
40
 
22
41
  - 构造签名头失败时不再静默返回空 header,改为抛出异常。
23
42
  - 签名密钥为空时 `MD5Utils.hash_mac` 改为抛出 `ArgumentError`。
43
+ - `K3cloudError.parse` 对空索引做判空保护,避免 `NoMethodError`。
24
44
 
25
45
  ### Fixed
26
46
 
@@ -98,4 +118,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
98
118
 
99
119
  ### Added
100
120
 
101
- - 初始版本:封装金蝶云星空 OpenApi,涵盖暂存/保存/提交/审核/查看/查询/删除/下推/分配等单据操作及附件、工作流、报表等共 26 个业务方法。
121
+ - 初始版本:封装金蝶云星空 OpenApi,涵盖暂存/保存/提交/审核/查看/查询/删除/下推/分配等单据操作及附件、工作流、报表等共 26 个业务方法。
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- k3cloud-sdk (0.5.0)
4
+ k3cloud-sdk (0.6.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -1,10 +1,33 @@
1
- # K3cloud-sdk
1
+ # K3cloud SDK
2
2
 
3
- Ruby Gem for K3cloud OpenApi that uses cryptographic signature technology to avoid plaintext transmission of keys and enables automatic login.
3
+ Ruby Gem for Kingdee K3Cloud OpenApi. Uses cryptographic signature technology
4
+ to avoid plaintext transmission of keys and supports automatic login.
5
+
6
+ ## Table of Contents
7
+
8
+ - [Installation](#installation)
9
+ - [Configuration](#configuration)
10
+ - [Single Account](#single-account)
11
+ - [Multi Account](#multi-account)
12
+ - [Optional Settings](#optional-settings)
13
+ - [Error Handling](#error-handling)
14
+ - [In Scheduled Jobs](#in-scheduled-jobs-sidekiq--cron--rake)
15
+ - [Available API Methods](#available-api-methods)
16
+ - [Bill Operations](#bill-operations)
17
+ - [Query Operations](#query-operations)
18
+ - [Group Operations](#group-operations)
19
+ - [Attachment Operations](#attachment-operations)
20
+ - [Metadata & Report Operations](#metadata--report-operations)
21
+ - [Workflow & Organization Operations](#workflow--organization-operations)
22
+ - [Logger](#logger)
23
+ - [Development](#development)
24
+ - [Contributing](#contributing)
25
+ - [License](#license)
26
+ - [Code of Conduct](#code-of-conduct)
4
27
 
5
28
  ## Installation
6
29
 
7
- Install the gem and add to the Rails application's Gemfile by executing:
30
+ Install the gem and add it to a Rails application's Gemfile:
8
31
 
9
32
  $ bundle add k3cloud-sdk
10
33
 
@@ -12,85 +35,251 @@ If bundler is not being used to manage dependencies, install the gem by executin
12
35
 
13
36
  $ gem install k3cloud-sdk
14
37
 
15
- ## Usage
38
+ ## Configuration
16
39
 
17
- #### 1. Single Account Configuration:
40
+ ### Single Account
18
41
 
19
- You'll need to configure it in `config/initializers/k3cloud.rb`:
42
+ Configure it in `config/initializers/k3cloud.rb`:
20
43
 
21
44
  ```ruby
22
- K3cloud.configure do |config|
23
- config.acct_id = ENV['K3CLOUD_ACCT_ID']
24
- config.user_name = ENV['K3CLOUD_USERNAME']
25
- config.app_id = ENV['K3CLOUD_APP_ID']
26
- config.app_secret = ENV['K3CLOUD_APP_SECRET']
27
- config.server_url = ENV['K3CLOUD_SERVER_URL']
28
- end
45
+ K3cloud.configure do |config|
46
+ config.acct_id = ENV["K3CLOUD_ACCT_ID"]
47
+ config.user_name = ENV["K3CLOUD_USERNAME"]
48
+ config.app_id = ENV["K3CLOUD_APP_ID"]
49
+ config.app_secret = ENV["K3CLOUD_APP_SECRET"]
50
+ config.server_url = ENV["K3CLOUD_SERVER_URL"] # blank for public cloud
51
+ end
29
52
  ```
30
53
 
31
- Then you can call the k3cloud API using the following code:
54
+ Then call the K3Cloud API directly:
32
55
 
33
56
  ```ruby
34
- data = {
35
- FormId: "",
36
- FieldKeys: "",
37
- FilterString: ""
38
- }
39
- K3cloud.execute_bill_query(data)
57
+ data = {
58
+ FormId: "PRD_MO",
59
+ FieldKeys: "FMaterialId.FNumber, FNoStockInQty",
60
+ FilterString: "FStatus not in (6, 7)"
61
+ }
62
+ K3cloud.execute_bill_query(data)
40
63
  ```
41
64
 
42
- #### 2. Multi Account Configuration:
65
+ ### Multi Account
43
66
 
44
- You'll need to configure it in `config/initializers/k3cloud.rb`:
67
+ Create multiple `K3cloud::Configuration` instances and build clients for each:
45
68
 
46
69
  ```ruby
47
- config1 = K3cloud::Configuration.new do |c|
48
- c.acct_id = ENV['K3CLOUD1_ACCT_ID']
49
- c.user_name = ENV['K3CLOUD1_USERNAME']
50
- c.app_id = ENV['K3CLOUD1_APP_ID']
51
- c.app_secret= ENV['K3CLOUD1_APP_SECRET']
52
- c.server_url = ENV['K3CLOUD1_SERVER_URL']
53
- end
70
+ config1 = K3cloud::Configuration.new do |c|
71
+ c.acct_id = ENV["K3CLOUD1_ACCT_ID"]
72
+ c.user_name = ENV["K3CLOUD1_USERNAME"]
73
+ c.app_id = ENV["K3CLOUD1_APP_ID"]
74
+ c.app_secret = ENV["K3CLOUD1_APP_SECRET"]
75
+ c.server_url = ENV["K3CLOUD1_SERVER_URL"]
76
+ end
77
+
78
+ config2 = K3cloud::Configuration.new do |c|
79
+ c.acct_id = ENV["K3CLOUD2_ACCT_ID"]
80
+ c.user_name = ENV["K3CLOUD2_USERNAME"]
81
+ c.app_id = ENV["K3CLOUD2_APP_ID"]
82
+ c.app_secret = ENV["K3CLOUD2_APP_SECRET"]
83
+ c.server_url = ENV["K3CLOUD2_SERVER_URL"]
84
+ end
85
+
86
+ client1 = K3cloud.new_api(config1)
87
+ client2 = K3cloud.new_api(config2)
54
88
 
55
- config2 = K3cloud::Configuration.new do |c|
56
- c.acct_id = ENV['K3CLOUD2_ACCT_ID']
57
- c.user_name = ENV['K3CLOUD2_USERNAME']
58
- c.app_id = ENV['K3CLOUD2_APP_ID']
59
- c.app_secret= ENV['K3CLOUD2_APP_SECRET']
60
- c.server_url = ENV['K3CLOUD2_SERVER_URL']
89
+ client1.execute_bill_query(data)
90
+ client2.execute_bill_query(data)
91
+ ```
92
+
93
+ ### Optional Settings
94
+
95
+ | Option | Default | Description |
96
+ | --- | --- | --- |
97
+ | `verify_ssl` | `true` | HTTPS certificate verification. Only affects `https` URLs (http URLs skip SSL automatically). Set `false` for private cloud self-signed certificates. |
98
+ | `connect_timeout` | `30` | Connection timeout in seconds. |
99
+ | `request_timeout` | `60` | Request read timeout in seconds. |
100
+ | `retry_max` | `3` | Max retries on timeout / 5xx. `0` disables retry. |
101
+ | `retry_interval` | `1` | First retry interval in seconds, then exponential backoff. |
102
+ | `retry_backoff` | `2` | Backoff multiplier between retries. |
103
+ | `log_format` | `:text` | Logger output format: `:text` or `:json`. |
104
+
105
+ ```ruby
106
+ K3cloud.configure do |config|
107
+ # ... required fields above ...
108
+ config.verify_ssl = true
109
+ config.connect_timeout = 30
110
+ config.request_timeout = 60
111
+ config.retry_max = 3
112
+ config.retry_interval = 1
113
+ config.log_format = :json
114
+ end
115
+ ```
116
+
117
+ ## Error Handling
118
+
119
+ All request failures raise `K3cloud::ResponseError`. A missing or invalid
120
+ configuration raises `K3cloud::ConfigurationError` on the first request. Wrap
121
+ calls accordingly:
122
+
123
+ ```ruby
124
+ begin
125
+ K3cloud.save("PRD_MO", data)
126
+ rescue K3cloud::ConfigurationError => e
127
+ # missing config: app_id / app_secret / acct_id / user_name
128
+ logger.error("Config error: #{e.message}")
129
+ rescue K3cloud::ResponseError => e
130
+ # network or Kingdee business error
131
+ logger.error("Request failed: #{e.message}")
132
+ end
133
+ ```
134
+
135
+ ### In Scheduled Jobs (Sidekiq / cron / rake)
136
+
137
+ The SDK raises `K3cloud::ResponseError` after retries are exhausted. Always
138
+ `rescue` explicitly in scheduled jobs to prevent the exception from bubbling up
139
+ and failing the container/process:
140
+
141
+ ```ruby
142
+ class SyncK3cloudJob
143
+ def perform
144
+ rows = K3cloud.execute_bill_query_all({ FormId: "PRD_MO" }, limit: 1000)
145
+ # ... process rows ...
146
+ rescue K3cloud::ConfigurationError => e
147
+ logger.error("[k3cloud] config error: #{e.message}")
148
+ rescue K3cloud::ResponseError => e
149
+ logger.error("[k3cloud] request failed: #{e.message}")
150
+ # Re-raise so the scheduler (e.g. Sidekiq) can retry the job
151
+ raise e
61
152
  end
153
+ end
154
+ ```
155
+
156
+ > Tip: In scheduled jobs, set a small `retry_max` (e.g. 0 or 1) and let the
157
+ > job scheduler decide the next attempt, instead of sleeping inside the SDK
158
+ > and occupying the worker thread. For example:
159
+ > `K3cloud.configure { |c| c.retry_max = 1 }`.
160
+
161
+ ## Available API Methods
162
+
163
+ All methods are available on both:
164
+ - `K3cloud.<method>` — uses the default account (delegates to `K3cloud.default_api`).
165
+ - `K3cloud.new_api(config).<method>` — uses a specific account configuration.
166
+
167
+ ### Bill Operations
168
+
169
+ | Method | Signature | Description |
170
+ | --- | --- | --- |
171
+ | `draft` | `draft(form_id, data)` | Temporarily save a bill (暂存). |
172
+ | `save` | `save(form_id, data)` | Save a bill (保存). |
173
+ | `batch_save` | `batch_save(form_id, data)` | Batch save bills (批量保存). |
174
+ | `submit` | `submit(form_id, data)` | Submit a bill (提交). |
175
+ | `audit` | `audit(form_id, data)` | Audit a bill (审核). |
176
+ | `un_audit` | `un_audit(form_id, data)` | Un-audit a bill (反审核). |
177
+ | `view` | `view(form_id, data)` | View a bill detail (查看). |
178
+ | `delete` | `delete(form_id, data)` | Delete a bill (删除). |
179
+ | `cancel_assign` | `cancel_assign(form_id, data)` | Cancel assignment (撤销). |
180
+ | `push` | `push(form_id, data)` | Push down a bill (下推). |
181
+ | `execute_operation` | `execute_operation(form_id, op_number, data)` | Status transition: close/reopen/void/unvoid etc. (状态转换). |
182
+ | `allocate` | `allocate(form_id, data)` | Allocate a bill (分配). |
183
+ | `cancel_allocate` | `cancel_allocate(form_id, data)` | Cancel allocation (取消分配). |
184
+ | `disassembly` | `disassembly(form_id, data)` | Disassembly operation (拆单). |
185
+ | `flex_save` | `flex_save(form_id, data)` | Flex save (弹性域保存). |
186
+ | `send_msg` | `send_msg(form_id, data)` | Send message (发送消息). |
187
+
188
+ ### Query Operations
189
+
190
+ | Method | Signature | Description |
191
+ | --- | --- | --- |
192
+ | `execute_bill_query` | `execute_bill_query(data)` | Query bills with parameters `{ FormId:, FieldKeys:, FilterString: }` (单据查询). Returns a 2D array; returns `[]` on error. |
193
+ | `execute_bill_query_all` | `execute_bill_query_all(data, limit: 1000)` | Paginated query that fetches **all** bill rows by auto-injecting `StartRow` and `Limit` until a page returns fewer than `limit` rows (分页批量查询). Returns the merged 2D array. |
194
+
195
+ Example:
196
+
197
+ ```ruby
198
+ # Single-page query (you handle pagination manually)
199
+ rows = K3cloud.execute_bill_query(
200
+ FormId: "PRD_MO",
201
+ FieldKeys: "FMaterialId.FNumber, FNoStockInQty",
202
+ FilterString: "FStatus not in (6, 7)",
203
+ StartRow: 0,
204
+ Limit: 100
205
+ )
62
206
 
63
- client1 = K3cloud.new_api(config1)
64
- client2 = K3cloud.new_api(config2)
207
+ # Paginated query (SDK handles pagination for you)
208
+ all_rows = K3cloud.execute_bill_query_all(
209
+ { FormId: "PRD_MO", FieldKeys: "FMaterialId.FNumber", FilterString: "" },
210
+ limit: 1000
211
+ )
65
212
  ```
66
213
 
67
- Then you can call the k3cloud API using the following code:
214
+ ### Group Operations
215
+
216
+ | Method | Signature | Description |
217
+ | --- | --- | --- |
218
+ | `group_save` | `group_save(form_id, data)` | Save a group (分组保存). |
219
+ | `query_group_info` | `query_group_info(data)` | Query group info (分组信息查询). |
220
+ | `group_delete` | `group_delete(data)` | Delete a group (分组删除). |
221
+
222
+ ### Attachment Operations
223
+
224
+ | Method | Signature | Description |
225
+ | --- | --- | --- |
226
+ | `attachment_upload` | `attachment_upload(data)` | Upload an attachment (附件上传). |
227
+ | `attachment_download` | `attachment_download(data)` | Download an attachment (附件下载). |
228
+
229
+ ### Metadata & Report Operations
230
+
231
+ | Method | Signature | Description |
232
+ | --- | --- | --- |
233
+ | `query_business_info` | `query_business_info(data)` | Query business metadata (元数据查询). |
234
+ | `get_sys_report_data` | `get_sys_report_data(form_id, data)` | Query system report data (查询报表数据). |
235
+
236
+ ### Workflow & Organization Operations
237
+
238
+ | Method | Signature | Description |
239
+ | --- | --- | --- |
240
+ | `workflow_audit` | `workflow_audit(data)` | Workflow audit (工作流审核). |
241
+ | `switch_org` | `switch_org(data)` | Switch organization (切换组织). |
242
+
243
+ ## Logger
244
+
245
+ By default, logs are written to `$stderr` in plain text. You can switch to
246
+ JSON-per-line format (useful for log aggregation) or inject your own logger.
68
247
 
69
248
  ```ruby
70
- data = {
71
- FormId: "",
72
- FieldKeys: "",
73
- FilterString: ""
74
- }
75
- client1.execute_bill_query(data)
76
- client2.execute_bill_query(data)
249
+ # JSON format
250
+ K3cloud.configure { |c| c.log_format = :json }
251
+ K3cloud.reset_logger! # rebuild the default logger after changing log_format
252
+
253
+ # Inject a custom logger (e.g. Rails.logger)
254
+ K3cloud.logger = Rails.logger
77
255
  ```
78
256
 
79
257
  ## Development
80
258
 
81
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
259
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
260
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
261
+ prompt that will allow you to experiment.
82
262
 
83
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
263
+ To install this gem onto your local machine, run `bundle exec rake install`. To
264
+ release a new version, update the version number in `version.rb`, and then run
265
+ `bundle exec rake release`, which will create a git tag for the version, push
266
+ git commits and the created tag, and push the `.gem` file to
267
+ [rubygems.org](https://rubygems.org).
84
268
 
85
269
  ## Contributing
86
270
 
87
- Bug reports and pull requests are welcome on GitHub at https://github.com/zevinto/k3cloud-sdk.
88
- This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/zevinto/k3cloud-sdk/blob/main/CODE_OF_CONDUCT.md).
271
+ Bug reports and pull requests are welcome on GitHub at
272
+ https://github.com/zevinto/k3cloud-sdk. This project is intended to be a safe,
273
+ welcoming space for collaboration, and contributors are expected to adhere to
274
+ the [code of conduct](https://github.com/zevinto/k3cloud-sdk/blob/main/CODE_OF_CONDUCT.md).
89
275
 
90
276
  ## License
91
277
 
92
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
278
+ The gem is available as open source under the terms of the
279
+ [MIT License](https://opensource.org/licenses/MIT).
93
280
 
94
281
  ## Code of Conduct
95
282
 
96
- Everyone interacting in the K3cloud-sdk project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/zevinto/k3cloud-sdk/blob/master/CODE_OF_CONDUCT.md).
283
+ Everyone interacting in the K3cloud-sdk project's codebases, issue trackers,
284
+ chat rooms and mailing lists is expected to follow the
285
+ [code of conduct](https://github.com/zevinto/k3cloud-sdk/blob/main/CODE_OF_CONDUCT.md).
data/k3cloud.gemspec CHANGED
@@ -18,6 +18,8 @@ Gem::Specification.new do |spec|
18
18
  spec.metadata["source_code_uri"] = spec.homepage
19
19
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
20
  spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+ # spec.metadata["rubygems_mfa_required"] = "true"
21
23
 
22
24
  # Specify which files should be added to the gem when it is released.
23
25
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "k3cloud/errors/configuration_error"
4
+
3
5
  module K3cloud
4
6
  class Configuration
5
7
  # 服务Url地址(私有云必须配置金蝶云星空产品地址,K3Cloud/结尾。若为公有云则必须置空)
@@ -29,16 +31,26 @@ module K3cloud
29
31
  # 组织编码,启用多组织时配置对应的组织编码才有效
30
32
  attr_accessor :org_num
31
33
 
32
- # 连接/请求超时(秒)。私有云自签证书时可设 verify_ssl=false 跳过校验
34
+ # 连接/请求超时(秒)。
35
+ # verify_ssl 仅对 https 生效(http URL 自动跳过 SSL 校验);
36
+ # 私有云 https 自签证书时可设为 false 关闭校验,默认 true。
33
37
  attr_accessor :connect_timeout, :request_timeout, :verify_ssl
34
38
 
35
39
  # 日志输出格式::text(默认,常规 Logger 格式)或 :json(每行一个 JSON)
36
40
  attr_accessor :log_format
37
41
 
42
+ # 请求重试:对超时与 5xx 进行重试。retry_max=0 表示不重试
43
+ attr_accessor :retry_max, :retry_interval, :retry_backoff
44
+
38
45
  DEFAULT_LCID = 2052
39
46
  DEFAULT_CONNECT_TIMEOUT = 30
40
47
  DEFAULT_REQUEST_TIMEOUT = 60
41
48
  DEFAULT_LOG_FORMAT = :text
49
+ DEFAULT_RETRY_MAX = 3
50
+ DEFAULT_RETRY_INTERVAL = 1
51
+ DEFAULT_RETRY_BACKOFF = 2
52
+
53
+ REQUIRED_FIELDS = %i[acct_id user_name app_id app_secret].freeze
42
54
 
43
55
  def initialize(options = {})
44
56
  @acct_id = options[:acct_id]
@@ -53,10 +65,22 @@ module K3cloud
53
65
  @request_timeout = options.fetch(:request_timeout, DEFAULT_REQUEST_TIMEOUT)
54
66
  @verify_ssl = options.fetch(:verify_ssl, true)
55
67
  @log_format = options.fetch(:log_format, DEFAULT_LOG_FORMAT)
68
+ @retry_max = options.fetch(:retry_max, DEFAULT_RETRY_MAX)
69
+ @retry_interval = options.fetch(:retry_interval, DEFAULT_RETRY_INTERVAL)
70
+ @retry_backoff = options.fetch(:retry_backoff, DEFAULT_RETRY_BACKOFF)
56
71
 
57
72
  yield(self) if block_given?
58
73
  end
59
74
 
75
+ # 校验必填字段。nil 视为未配置,空串视为兼容历史不阻断。
76
+ def validate!
77
+ missing = REQUIRED_FIELDS.select { |f| public_send(f).nil? }
78
+ return if missing.empty?
79
+
80
+ raise K3cloud::ConfigurationError,
81
+ "K3cloud configuration missing: #{missing.join(", ")}. Please set them in K3cloud.configure."
82
+ end
83
+
60
84
  class << self
61
85
  def default
62
86
  @default ||= Configuration.new
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K3cloud
4
+ # 配置缺失或非法时抛出
5
+ class ConfigurationError < StandardError; end
6
+ end
@@ -29,7 +29,7 @@ module K3cloud
29
29
  kd_error.inner_exception = inner_exception ? K3cloudError.parse(inner_exception.to_json) : nil
30
30
  kd_error
31
31
  rescue StandardError => e
32
- K3cloud.logger.error({ msg: "Failed to parse exception message. #{e.message}", type: "error", level: "ERROR" })
32
+ K3cloud.logger.error("Failed to parse exception message: #{e.message}")
33
33
  nil
34
34
  end
35
35
  end
data/lib/k3cloud/http.rb CHANGED
@@ -10,47 +10,87 @@ module K3cloud
10
10
  # HTTP Client
11
11
  class Http
12
12
  attr_accessor :url, :header, :body, :connect_timeout, :request_timeout, :verify_ssl, :status_code
13
+ attr_reader :retry_count
13
14
 
14
- def initialize(url:, header:, body:, connect_timeout: Configuration::DEFAULT_CONNECT_TIMEOUT,
15
- request_timeout: Configuration::DEFAULT_REQUEST_TIMEOUT, verify_ssl: true)
15
+ # retry_max/interval/backoff: 失败重试参数,仅对超时与 5xx 生效。
16
+ def initialize(
17
+ url:, header:, body:,
18
+ connect_timeout: Configuration::DEFAULT_CONNECT_TIMEOUT,
19
+ request_timeout: Configuration::DEFAULT_REQUEST_TIMEOUT, verify_ssl: true,
20
+ retry_max: 0, retry_interval: 1, retry_backoff: 2
21
+ )
16
22
  @url = url
17
23
  @header = header || {}
18
24
  @body = body
19
25
  @connect_timeout = connect_timeout
20
26
  @request_timeout = request_timeout
21
27
  @verify_ssl = verify_ssl
28
+ @retry_max = retry_max.to_i
29
+ @retry_interval = retry_interval.to_f
30
+ @retry_backoff = retry_backoff.to_f
31
+ @retry_count = 0
22
32
  end
23
33
 
24
34
  def post
25
35
  uri = URI.parse(@url)
26
36
  http = build_http(uri)
37
+ request = build_request(uri)
38
+ body_str = perform_with_retry(http, request)
39
+ log_trace(body_str)
40
+ body_str
41
+ end
27
42
 
28
- request = Net::HTTP::Post.new(uri.request_uri)
29
- request.initialize_http_header(@header)
30
- request["Content-Type"] = "application/json"
31
- request["User-Agent"] = generate_user_agent
32
- request.body = @body.is_a?(String) ? @body : @body.to_json
43
+ private
44
+
45
+ def perform_with_retry(http, request)
46
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
47
+ begin
48
+ send_request(http, request)
49
+ rescue RetryableError => e
50
+ if @retry_count < @retry_max
51
+ @retry_count += 1
52
+ wait = @retry_interval * (@retry_backoff**(@retry_count - 1))
53
+ K3cloud.logger.debug("Request failed, retrying #{@retry_count}/#{@retry_max} " \
54
+ "after #{wait}s: #{e.message} url=#{@url}")
55
+ sleep(wait)
56
+ retry
57
+ end
58
+ raise K3cloud::ResponseError, "Request failed after #{@retry_count} retries: #{e.message}"
59
+ ensure
60
+ @last_elapsed_ms = elapsed_ms_since(started_at)
61
+ end
62
+ end
33
63
 
64
+ def send_request(http, request)
34
65
  response = http.request(request)
35
66
  @status_code = response.code.to_i
67
+ raise RetryableError, "server error status=#{@status_code}" if @status_code >= 500
68
+
36
69
  raise K3cloud::ResponseError, "status: #{response.code}, desc: #{response.body}" if @status_code >= 400
37
70
 
38
71
  response.body
39
- rescue Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout => e
40
- raise K3cloud::ResponseError, "Request timed out: #{e.message}"
41
- rescue K3cloud::ResponseError
72
+ rescue RetryableError, K3cloud::ResponseError
42
73
  raise
74
+ rescue Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout => e
75
+ raise RetryableError, "timeout: #{e.message}"
43
76
  rescue Net::HTTPServerError => e
44
- raise K3cloud::ResponseError, "Server error: #{e.message}"
77
+ raise RetryableError, "server error: #{e.message}"
45
78
  rescue StandardError => e
46
79
  raise K3cloud::ResponseError, "Unexpected error: #{e.message}"
47
80
  end
48
81
 
49
- private
82
+ def build_request(uri)
83
+ request = Net::HTTP::Post.new(uri.request_uri)
84
+ request.initialize_http_header(@header)
85
+ request["Content-Type"] = "application/json"
86
+ request["User-Agent"] = generate_user_agent
87
+ request.body = @body.is_a?(String) ? @body : @body.to_json
88
+ request
89
+ end
50
90
 
51
91
  def build_http(uri)
52
- http = connection(uri) || Net::HTTP.new(uri.host, uri.port)
53
- configure_ssl!(http, uri) if uri.scheme == "https"
92
+ http = Net::HTTP.new(uri.host, uri.port)
93
+ configure_ssl!(http) if uri.scheme == "https"
54
94
  http.open_timeout = @connect_timeout
55
95
  http.read_timeout = @request_timeout
56
96
  http.write_timeout = @request_timeout if http.respond_to?(:write_timeout=)
@@ -58,19 +98,27 @@ module K3cloud
58
98
  http
59
99
  end
60
100
 
61
- def configure_ssl!(http, _uri)
101
+ # 仅 https 生效。http URL 不调用此方法,verify_ssl 配置对其无意义。
102
+ def configure_ssl!(http)
62
103
  http.use_ssl = true
63
104
  http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
64
105
  end
65
106
 
66
- # 子类可覆盖以注入复用的连接(如 Net::HTTP::Persistent)。
67
- def connection(_uri)
68
- nil
107
+ def log_trace(body_str)
108
+ size = body_str ? body_str.bytesize : 0
109
+ K3cloud.logger.debug("HTTP completed POST #{@url} status=#{@status_code} \
110
+ elapsed_ms=#{(@last_elapsed_ms || 0).round(2)} retries=#{@retry_count} resp_bytes=#{size}")
111
+ end
112
+
113
+ def elapsed_ms_since(started_at)
114
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
69
115
  end
70
116
 
71
- # Generate User-Agent string with client info
72
117
  def generate_user_agent
73
118
  "Kingdee/Ruby WebApi SDK(v#{K3cloud::VERSION}) (Ruby #{RUBY_VERSION}; #{RUBY_PLATFORM})"
74
119
  end
120
+
121
+ # 内部异常:可重试的瞬时错误(超时/5xx),区别于最终抛出的 ResponseError。
122
+ class RetryableError < StandardError; end
75
123
  end
76
124
  end
@@ -46,6 +46,24 @@ module K3cloud
46
46
  handle_query_result(rows)
47
47
  end
48
48
 
49
+ # 分页拉取全部单据。data 为基础查询参数(FormId/FieldKeys/FilterString 等),
50
+ # 自动注入 StartRow与 Limit,按分页连续请求直到返回行数少于 limit。
51
+ # 返回合并后的二维数组。limit 建议不超过 1000。
52
+ def execute_bill_query_all(data, limit: 1000)
53
+ base = data.dup
54
+ all = []
55
+ start_row = 0
56
+ loop do
57
+ page = base.merge("StartRow" => start_row, "Limit" => limit)
58
+ rows = execute_bill_query(page)
59
+ all.concat(rows)
60
+ break if rows.nil? || rows.size < limit
61
+
62
+ start_row += limit
63
+ end
64
+ all
65
+ end
66
+
49
67
  # 删除
50
68
  def delete(form_id, data)
51
69
  execute("Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.Delete", [form_id, data])
@@ -135,19 +153,34 @@ module K3cloud
135
153
 
136
154
  private
137
155
 
138
- # 金蝶 ExecuteBillQuery 在出错时会把错误信息放在首行首列(一个 Hash),
139
- # 正常查询则返回二维数组。这里识别并记录错误,避免把错误对象当业务数据返回。
140
- def handle_query_result(rows)
141
- return rows if rows.nil? || rows.empty?
142
-
143
- first_row = rows[0]
144
- result = first_row.nil? || first_row.empty? ? nil : first_row[0]
145
- if result.is_a?(Hash)
146
- K3cloud.logger.error({ msg: result, type: "error", level: "ERROR" })
147
- []
148
- else
149
- rows
156
+ # 金蝶 ExecuteBillQuery 正常返回二维数组;出错时返回值结构不一:
157
+ # 可能是首行首列放置一个含 Message/InnerEx 的 Hash,也可能是顶层 Hash。
158
+ # 这里在多种形态下识别错误,避免把错误对象当作业务数据返回。
159
+ def handle_query_result(result)
160
+ return result if result.nil? || (result.respond_to?(:empty?) && result.empty?)
161
+
162
+ if error_result?(result)
163
+ K3cloud.logger.error("ExecuteBillQuery error: #{result.inspect}")
164
+ return []
150
165
  end
166
+ result
167
+ end
168
+
169
+ def error_result?(result)
170
+ return true if error_hash?(result)
171
+
172
+ first_row = result.is_a?(Array) ? result[0] : nil
173
+ return false unless first_row.is_a?(Array)
174
+
175
+ candidate = first_row.find { |cell| cell.is_a?(Hash) }
176
+ candidate && error_hash?(candidate)
177
+ end
178
+
179
+ def error_hash?(obj)
180
+ return false unless obj.is_a?(Hash)
181
+
182
+ # 错误对象通常含 Message / InnerExWrapper / InnerException 等字段
183
+ ERROR_MARKERS.any? { |k| obj.key?(k) }
151
184
  end
152
185
  end
153
186
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K3cloud
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
3
4
  require "k3cloud/errors/k3cloud_error"
5
+ require "k3cloud/errors/configuration_error"
4
6
  require "k3cloud/errors/response_error"
5
7
  require "k3cloud/utils/md5_utils"
6
8
  require "k3cloud/utils/base64_utils"
@@ -10,13 +12,22 @@ require "k3cloud/http"
10
12
 
11
13
  module K3cloud
12
14
  class WebApiClient
15
+ # 金蝶 ExecuteBillQuery/ResponseError 中标识错误的关键字
16
+ ERROR_MARKERS = %w[Message InnerExWrapper InnerException].freeze
17
+
18
+ # 金蝶 app_id 内嵌 secret 的 XOR 密钥,协议固定值,来源见 README。
19
+ # https://vip.kingdee.com/article/163905337846351872?productLineId=1
20
+ XOR_SEC_KEY = "0054f397c6234378b09ca7d3e5debce7"
21
+
13
22
  attr_accessor :config
14
23
 
15
24
  def initialize(config = Configuration.default)
16
25
  @config = config
26
+ @validated = false
17
27
  end
18
28
 
19
29
  def execute(service_name, parameters)
30
+ ensure_configured!
20
31
  result = execute_json(service_name, parameters)
21
32
  if result.start_with?("response_error:")
22
33
  error = K3cloudError.parse(result)
@@ -37,6 +48,13 @@ module K3cloud
37
48
 
38
49
  private
39
50
 
51
+ def ensure_configured!
52
+ return if @validated
53
+
54
+ @config.validate!
55
+ @validated = true
56
+ end
57
+
40
58
  def execute_json(service_name, parameters)
41
59
  url = build_url(service_name)
42
60
  K3cloud.logger.info("Request URL: #{url}")
@@ -49,7 +67,10 @@ module K3cloud
49
67
  body: body,
50
68
  connect_timeout: @config.connect_timeout,
51
69
  request_timeout: @config.request_timeout,
52
- verify_ssl: @config.verify_ssl
70
+ verify_ssl: @config.verify_ssl,
71
+ retry_max: @config.retry_max,
72
+ retry_interval: @config.retry_interval,
73
+ retry_backoff: @config.retry_backoff
53
74
  )
54
75
  request.post
55
76
  end
@@ -67,11 +88,12 @@ module K3cloud
67
88
  end
68
89
  end
69
90
 
91
+ # 仅 https/http 的 URL 才能切片出签名用 path,否则说明 URL 构造异常。
70
92
  def url_path(url)
71
- return url unless url.start_with?("http")
93
+ raise ArgumentError, "Unsupported URL scheme: #{url.inspect}" unless url.start_with?("http")
72
94
 
73
95
  index = url.index("/", 10)
74
- index ? url[index..-1] : url
96
+ index ? url[index..-1] : "/"
75
97
  end
76
98
 
77
99
  # @note: https://vip.kingdee.com/knowledge/specialDetail/229961573895771136?category=229964512944566016&id=423060878259269120&productLineId=1
@@ -113,8 +135,7 @@ module K3cloud
113
135
  end
114
136
 
115
137
  def x_or_sec(buffer)
116
- sec_key = "0054f397c6234378b09ca7d3e5debce7"
117
- pwd = sec_key.encode("UTF-8").bytes
138
+ pwd = XOR_SEC_KEY.encode("UTF-8").bytes
118
139
 
119
140
  buffer.each_with_index do |byte, i|
120
141
  buffer[i] = byte ^ pwd[i % pwd.length]
data/lib/k3cloud.rb CHANGED
@@ -46,9 +46,16 @@ module K3cloud
46
46
  end
47
47
 
48
48
  API_METHODS = %i[
49
- draft save batch_save submit audit un_audit view execute_bill_query delete cancel_assign push execute_operation
50
- allocate cancel_allocate group_save query_group_info group_delete attachment_upload attachment_download
51
- query_business_info get_sys_report_data flex_save send_msg disassembly workflow_audit switch_org
49
+ execute_bill_query execute_bill_query_all view
50
+ draft save batch_save submit delete
51
+ audit un_audit
52
+ cancel_assign push execute_operation
53
+ allocate cancel_allocate
54
+ group_save query_group_info group_delete
55
+ attachment_upload attachment_download
56
+ query_business_info get_sys_report_data
57
+ flex_save send_msg disassembly workflow_audit
58
+ switch_org
52
59
  ].freeze
53
60
 
54
61
  def_delegators :default_api, *API_METHODS
@@ -71,16 +78,18 @@ module K3cloud
71
78
  end
72
79
 
73
80
  def format_log_json(severity, datetime, msg)
74
- payload = msg.is_a?(Hash) ? msg.dup : { msg: msg.to_s }
75
- payload[:level] = severity
76
- payload[:time] = datetime.strftime("%Y-%m-%dT%H:%M:%S.%L")
77
- "#{payload.to_json}\n"
81
+ "#{{ msg: msg.to_s, level: severity, time: datetime.strftime("%Y-%m-%dT%H:%M:%S.%L") }.to_json}\n"
78
82
  end
79
83
 
80
84
  def format_log_text(severity, datetime, msg)
81
- body = msg.is_a?(Hash) ? msg.inspect : msg.to_s
82
- format("%s, [%s#%d] %5s -- %s\n", severity[0..0],
83
- datetime.strftime("%Y-%m-%dT%H:%M:%S.%L"), Process.pid, severity, body)
85
+ format(
86
+ "%s, [%s#%d] %5s -- %s\n",
87
+ severity[0..0],
88
+ datetime.strftime("%Y-%m-%dT%H:%M:%S.%L"),
89
+ Process.pid,
90
+ severity,
91
+ msg.to_s
92
+ )
84
93
  end
85
94
  end
86
95
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: k3cloud-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zevinto
@@ -34,6 +34,7 @@ files:
34
34
  - lib/k3cloud-sdk.rb
35
35
  - lib/k3cloud.rb
36
36
  - lib/k3cloud/configuration.rb
37
+ - lib/k3cloud/errors/configuration_error.rb
37
38
  - lib/k3cloud/errors/k3cloud_error.rb
38
39
  - lib/k3cloud/errors/response_error.rb
39
40
  - lib/k3cloud/http.rb
@@ -51,6 +52,7 @@ metadata:
51
52
  source_code_uri: https://github.com/zevinto/k3cloud-sdk
52
53
  changelog_uri: https://github.com/zevinto/k3cloud-sdk/blob/main/CHANGELOG.md
53
54
  bug_tracker_uri: https://github.com/zevinto/k3cloud-sdk/issues
55
+ rubygems_mfa_required: 'true'
54
56
  rdoc_options: []
55
57
  require_paths:
56
58
  - lib