k3cloud-sdk 0.4.7 → 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 +4 -4
- data/.rubocop.yml +46 -1
- data/CHANGELOG.md +118 -2
- data/Gemfile +5 -1
- data/Gemfile.lock +50 -1
- data/README.md +245 -51
- data/k3cloud.gemspec +8 -2
- data/lib/k3cloud/configuration.rb +48 -5
- data/lib/k3cloud/errors/configuration_error.rb +6 -0
- data/lib/k3cloud/errors/k3cloud_error.rb +9 -4
- data/lib/k3cloud/http.rb +85 -20
- data/lib/k3cloud/k3cloud_api.rb +45 -6
- data/lib/k3cloud/utils/base64_utils.rb +10 -12
- data/lib/k3cloud/utils/const_define.rb +23 -21
- data/lib/k3cloud/utils/md5_utils.rb +16 -23
- data/lib/k3cloud/version.rb +1 -1
- data/lib/k3cloud/web_api_client.rb +45 -17
- data/lib/k3cloud.rb +65 -8
- metadata +10 -8
|
@@ -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,7 +31,26 @@ module K3cloud
|
|
|
29
31
|
# 组织编码,启用多组织时配置对应的组织编码才有效
|
|
30
32
|
attr_accessor :org_num
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
# 连接/请求超时(秒)。
|
|
35
|
+
# verify_ssl 仅对 https 生效(http URL 自动跳过 SSL 校验);
|
|
36
|
+
# 私有云 https 自签证书时可设为 false 关闭校验,默认 true。
|
|
37
|
+
attr_accessor :connect_timeout, :request_timeout, :verify_ssl
|
|
38
|
+
|
|
39
|
+
# 日志输出格式::text(默认,常规 Logger 格式)或 :json(每行一个 JSON)
|
|
40
|
+
attr_accessor :log_format
|
|
41
|
+
|
|
42
|
+
# 请求重试:对超时与 5xx 进行重试。retry_max=0 表示不重试
|
|
43
|
+
attr_accessor :retry_max, :retry_interval, :retry_backoff
|
|
44
|
+
|
|
45
|
+
DEFAULT_LCID = 2052
|
|
46
|
+
DEFAULT_CONNECT_TIMEOUT = 30
|
|
47
|
+
DEFAULT_REQUEST_TIMEOUT = 60
|
|
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
|
|
33
54
|
|
|
34
55
|
def initialize(options = {})
|
|
35
56
|
@acct_id = options[:acct_id]
|
|
@@ -39,14 +60,36 @@ module K3cloud
|
|
|
39
60
|
@app_secret = options[:app_secret]
|
|
40
61
|
@server_url = options[:server_url]
|
|
41
62
|
@org_num = options[:org_num]
|
|
42
|
-
@
|
|
43
|
-
@
|
|
63
|
+
@lcid = options.fetch(:lcid, DEFAULT_LCID)
|
|
64
|
+
@connect_timeout = options.fetch(:connect_timeout, DEFAULT_CONNECT_TIMEOUT)
|
|
65
|
+
@request_timeout = options.fetch(:request_timeout, DEFAULT_REQUEST_TIMEOUT)
|
|
66
|
+
@verify_ssl = options.fetch(:verify_ssl, true)
|
|
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)
|
|
44
71
|
|
|
45
72
|
yield(self) if block_given?
|
|
46
73
|
end
|
|
47
74
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
|
|
84
|
+
class << self
|
|
85
|
+
def default
|
|
86
|
+
@default ||= Configuration.new
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 测试或重新配置时重置默认配置
|
|
90
|
+
def reset_default!
|
|
91
|
+
@default = nil
|
|
92
|
+
end
|
|
50
93
|
end
|
|
51
94
|
end
|
|
52
95
|
end
|
|
@@ -14,17 +14,22 @@ module K3cloud
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.parse(json)
|
|
17
|
+
return nil if json.nil? || json.to_s.empty?
|
|
18
|
+
|
|
19
|
+
json = json.to_s
|
|
17
20
|
index = json.index("{")
|
|
18
|
-
json = json[index..-1] if index
|
|
21
|
+
json = json[index..-1] if index
|
|
19
22
|
|
|
20
23
|
parsed_json = JSON.parse(json)
|
|
21
24
|
kd_error = K3cloudError.new
|
|
22
25
|
kd_error.message = parsed_json["Message"]
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
inner_wrapper = parsed_json["InnerExWrapper"]
|
|
27
|
+
inner_exception = parsed_json["InnerException"]
|
|
28
|
+
kd_error.inner_ex_wrapper = inner_wrapper ? K3cloudError.parse(inner_wrapper.to_json) : nil
|
|
29
|
+
kd_error.inner_exception = inner_exception ? K3cloudError.parse(inner_exception.to_json) : nil
|
|
25
30
|
kd_error
|
|
26
31
|
rescue StandardError => e
|
|
27
|
-
K3cloud.logger.error(
|
|
32
|
+
K3cloud.logger.error("Failed to parse exception message: #{e.message}")
|
|
28
33
|
nil
|
|
29
34
|
end
|
|
30
35
|
end
|
data/lib/k3cloud/http.rb
CHANGED
|
@@ -9,51 +9,116 @@ require "json"
|
|
|
9
9
|
module K3cloud
|
|
10
10
|
# HTTP Client
|
|
11
11
|
class Http
|
|
12
|
-
attr_accessor :url, :header, :body, :connect_timeout, :request_timeout, :status_code
|
|
12
|
+
attr_accessor :url, :header, :body, :connect_timeout, :request_timeout, :verify_ssl, :status_code
|
|
13
|
+
attr_reader :retry_count
|
|
13
14
|
|
|
14
|
-
|
|
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
|
+
)
|
|
15
22
|
@url = url
|
|
16
23
|
@header = header || {}
|
|
17
24
|
@body = body
|
|
18
25
|
@connect_timeout = connect_timeout
|
|
19
26
|
@request_timeout = request_timeout
|
|
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
|
|
20
32
|
end
|
|
21
33
|
|
|
22
34
|
def post
|
|
23
35
|
uri = URI.parse(@url)
|
|
24
|
-
http =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
38
63
|
|
|
64
|
+
def send_request(http, request)
|
|
39
65
|
response = http.request(request)
|
|
40
66
|
@status_code = response.code.to_i
|
|
67
|
+
raise RetryableError, "server error status=#{@status_code}" if @status_code >= 500
|
|
68
|
+
|
|
41
69
|
raise K3cloud::ResponseError, "status: #{response.code}, desc: #{response.body}" if @status_code >= 400
|
|
42
70
|
|
|
43
71
|
response.body
|
|
72
|
+
rescue RetryableError, K3cloud::ResponseError
|
|
73
|
+
raise
|
|
44
74
|
rescue Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout => e
|
|
45
|
-
raise
|
|
75
|
+
raise RetryableError, "timeout: #{e.message}"
|
|
46
76
|
rescue Net::HTTPServerError => e
|
|
47
|
-
raise
|
|
48
|
-
rescue => e
|
|
77
|
+
raise RetryableError, "server error: #{e.message}"
|
|
78
|
+
rescue StandardError => e
|
|
49
79
|
raise K3cloud::ResponseError, "Unexpected error: #{e.message}"
|
|
50
80
|
end
|
|
51
81
|
|
|
52
|
-
|
|
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
|
|
90
|
+
|
|
91
|
+
def build_http(uri)
|
|
92
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
93
|
+
configure_ssl!(http) if uri.scheme == "https"
|
|
94
|
+
http.open_timeout = @connect_timeout
|
|
95
|
+
http.read_timeout = @request_timeout
|
|
96
|
+
http.write_timeout = @request_timeout if http.respond_to?(:write_timeout=)
|
|
97
|
+
http.ssl_timeout = @request_timeout if http.respond_to?(:ssl_timeout=) && uri.scheme == "https"
|
|
98
|
+
http
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 仅 https 生效。http URL 不调用此方法,verify_ssl 配置对其无意义。
|
|
102
|
+
def configure_ssl!(http)
|
|
103
|
+
http.use_ssl = true
|
|
104
|
+
http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
105
|
+
end
|
|
106
|
+
|
|
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
|
|
115
|
+
end
|
|
53
116
|
|
|
54
|
-
# Generate User-Agent string with client info
|
|
55
117
|
def generate_user_agent
|
|
56
118
|
"Kingdee/Ruby WebApi SDK(v#{K3cloud::VERSION}) (Ruby #{RUBY_VERSION}; #{RUBY_PLATFORM})"
|
|
57
119
|
end
|
|
120
|
+
|
|
121
|
+
# 内部异常:可重试的瞬时错误(超时/5xx),区别于最终抛出的 ResponseError。
|
|
122
|
+
class RetryableError < StandardError; end
|
|
58
123
|
end
|
|
59
124
|
end
|
data/lib/k3cloud/k3cloud_api.rb
CHANGED
|
@@ -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,13 +153,34 @@ module K3cloud
|
|
|
135
153
|
|
|
136
154
|
private
|
|
137
155
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 []
|
|
144
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) }
|
|
145
184
|
end
|
|
146
185
|
end
|
|
147
186
|
end
|
|
@@ -2,18 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
4
|
|
|
5
|
-
module
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
end
|
|
5
|
+
module K3cloud
|
|
6
|
+
module Base64Utils
|
|
7
|
+
def self.encoding_to_base64(buffer)
|
|
8
|
+
b64buffer = Base64.strict_encode64(buffer.pack("C*"))
|
|
9
|
+
b64buffer.force_encoding("UTF-8")
|
|
10
|
+
end
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
raise e
|
|
12
|
+
def self.decoding_from_base64(base64)
|
|
13
|
+
buffer = base64.encode("UTF-8")
|
|
14
|
+
Base64.strict_decode64(buffer).bytes
|
|
15
|
+
end
|
|
18
16
|
end
|
|
19
17
|
end
|
|
@@ -1,43 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
4
|
-
|
|
3
|
+
module K3cloud
|
|
4
|
+
module ConstDefine
|
|
5
|
+
X_API_CLIENT_ID = "X-Api-ClientID"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
X_API_AUTH_VERSION = "X-Api-Auth-Version"
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
X_API_TIMESTAMP = "x-api-timestamp"
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
X_API_NONCE = "x-api-nonce"
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
X_API_SIGN_HEADERS = "x-api-signheaders"
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
X_API_SIGNATURE = "X-Api-Signature"
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
X_KD_APP_KEY = "X-Kd-Appkey"
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
X_KD_APP_DATA = "X-Kd-Appdata"
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
X_KD_SIGNATURE = "X-Kd-Signature"
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
KD_SERVICE_SESSIONID = "kdservice-sessionid"
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
SESSION_ID = "ASP.NET_SessionId"
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
X_KD_SID = "SID"
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
COOKIE_SET = "Set-Cookie"
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
BEGIN_METHOD_HEADER = "beginmethod"
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
QUERY_METHOD_HEADER = "querymethod"
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
BEGIN_METHOD_METHOD = "BeginQueryImpl"
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
QUERY_METHOD_METHOD = "QueryAsyncResult"
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
QUERY_INVODE_STATE_PENDING = 0
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
QUERY_INVODE_STATE_RUNNING = 1
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
QUERY_INVODE_STATE_COMPLETE = 2
|
|
44
|
+
end
|
|
43
45
|
end
|
|
@@ -3,33 +3,26 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "base64"
|
|
5
5
|
|
|
6
|
-
module
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
m.update(data_str.encode("UTF-8"))
|
|
10
|
-
s = m.digest
|
|
11
|
-
result = ""
|
|
6
|
+
module K3cloud
|
|
7
|
+
module MD5Utils
|
|
8
|
+
UNSECURE_KEY = "0054f397c6234378b09ca7d3e5debce7"
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
# 返回 32 位小写十六进制 MD5。原来的逐字节位运算 hack 在 Ruby 下会得到 `nil`,
|
|
11
|
+
# 且被 rescue 吞掉返回空串,故直接使用 Digest::MD5.hexdigest。
|
|
12
|
+
def self.encrypt(data_str)
|
|
13
|
+
Digest::MD5.hexdigest(data_str.encode("UTF-8"))
|
|
15
14
|
end
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
e.backtrace
|
|
20
|
-
""
|
|
21
|
-
end
|
|
16
|
+
def self.hash_mac(data, secret)
|
|
17
|
+
raise ArgumentError, "secret is required for HMAC" if secret.nil? || secret.to_s.empty?
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
rescue StandardError => e
|
|
28
|
-
e.backtrace
|
|
29
|
-
nil
|
|
30
|
-
end
|
|
19
|
+
kd_mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), secret, data)
|
|
20
|
+
hex_string = bytes_to_hex(kd_mac.bytes)
|
|
21
|
+
Base64.strict_encode64(hex_string)
|
|
22
|
+
end
|
|
31
23
|
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
def self.bytes_to_hex(bytes)
|
|
25
|
+
bytes.map { |byte| byte.to_s(16).rjust(2, "0") }.join
|
|
26
|
+
end
|
|
34
27
|
end
|
|
35
28
|
end
|
data/lib/k3cloud/version.rb
CHANGED
|
@@ -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,19 +12,29 @@ 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)
|
|
23
34
|
raise K3cloud::ResponseError, result if error.nil?
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
inner_msg = error.inner_ex_wrapper&.message
|
|
37
|
+
message = inner_msg ? "#{error.message} --> #{inner_msg}" : error.message
|
|
26
38
|
raise K3cloud::ResponseError, message
|
|
27
39
|
|
|
28
40
|
else
|
|
@@ -36,34 +48,54 @@ module K3cloud
|
|
|
36
48
|
|
|
37
49
|
private
|
|
38
50
|
|
|
51
|
+
def ensure_configured!
|
|
52
|
+
return if @validated
|
|
53
|
+
|
|
54
|
+
@config.validate!
|
|
55
|
+
@validated = true
|
|
56
|
+
end
|
|
57
|
+
|
|
39
58
|
def execute_json(service_name, parameters)
|
|
40
59
|
url = build_url(service_name)
|
|
41
60
|
K3cloud.logger.info("Request URL: #{url}")
|
|
42
61
|
|
|
43
62
|
header = build_header(url_path(url))
|
|
44
63
|
body = { parameters: parameters }
|
|
45
|
-
request = Http.new(
|
|
64
|
+
request = Http.new(
|
|
65
|
+
url: url,
|
|
66
|
+
header: header,
|
|
67
|
+
body: body,
|
|
68
|
+
connect_timeout: @config.connect_timeout,
|
|
69
|
+
request_timeout: @config.request_timeout,
|
|
70
|
+
verify_ssl: @config.verify_ssl,
|
|
71
|
+
retry_max: @config.retry_max,
|
|
72
|
+
retry_interval: @config.retry_interval,
|
|
73
|
+
retry_backoff: @config.retry_backoff
|
|
74
|
+
)
|
|
46
75
|
request.post
|
|
47
76
|
end
|
|
48
77
|
|
|
49
78
|
def build_url(service_name)
|
|
50
|
-
base_url = if @config.server_url.nil? || @config.server_url.empty?
|
|
51
|
-
"https://api.kingdee.com/galaxyapi/"
|
|
52
|
-
else
|
|
53
|
-
@config.server_url.to_s.strip.chomp("/")
|
|
54
|
-
end
|
|
55
79
|
"#{base_url}/#{service_name}.common.kdsvc"
|
|
56
80
|
end
|
|
57
81
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
def base_url
|
|
83
|
+
server = @config.server_url
|
|
84
|
+
if server.nil? || server.empty?
|
|
85
|
+
"https://api.kingdee.com/galaxyapi"
|
|
62
86
|
else
|
|
63
|
-
|
|
87
|
+
server.to_s.strip.chomp("/")
|
|
64
88
|
end
|
|
65
89
|
end
|
|
66
90
|
|
|
91
|
+
# 仅 https/http 的 URL 才能切片出签名用 path,否则说明 URL 构造异常。
|
|
92
|
+
def url_path(url)
|
|
93
|
+
raise ArgumentError, "Unsupported URL scheme: #{url.inspect}" unless url.start_with?("http")
|
|
94
|
+
|
|
95
|
+
index = url.index("/", 10)
|
|
96
|
+
index ? url[index..-1] : "/"
|
|
97
|
+
end
|
|
98
|
+
|
|
67
99
|
# @note: https://vip.kingdee.com/knowledge/specialDetail/229961573895771136?category=229964512944566016&id=423060878259269120&productLineId=1
|
|
68
100
|
def build_header(url)
|
|
69
101
|
header = {}
|
|
@@ -94,9 +126,6 @@ module K3cloud
|
|
|
94
126
|
header[ConstDefine::X_KD_SIGNATURE] = kd_signature
|
|
95
127
|
end
|
|
96
128
|
header
|
|
97
|
-
rescue StandardError => e
|
|
98
|
-
K3cloud.logger.error({ errmsg: "Build header exception.", backtrace: "#{e.backtrace}", type: 'error', lever: 'ERROR' })
|
|
99
|
-
{}
|
|
100
129
|
end
|
|
101
130
|
|
|
102
131
|
def decode_sec(sec)
|
|
@@ -106,8 +135,7 @@ module K3cloud
|
|
|
106
135
|
end
|
|
107
136
|
|
|
108
137
|
def x_or_sec(buffer)
|
|
109
|
-
|
|
110
|
-
pwd = sec_key.encode("UTF-8").bytes
|
|
138
|
+
pwd = XOR_SEC_KEY.encode("UTF-8").bytes
|
|
111
139
|
|
|
112
140
|
buffer.each_with_index do |byte, i|
|
|
113
141
|
buffer[i] = byte ^ pwd[i % pwd.length]
|