2026-08-16 12:46:15 +08:00
|
|
|
|
/*
|
|
|
|
|
|
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
|
|
|
|
|
* 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
package simapi.helpers
|
|
|
|
|
|
|
|
|
|
|
|
import std.collection.*
|
|
|
|
|
|
import std.io.*
|
2026-08-18 01:29:02 +08:00
|
|
|
|
import stdx.net.http.*
|
2026-08-16 12:46:15 +08:00
|
|
|
|
import stdx.net.tls.*
|
|
|
|
|
|
import stdx.net.tls.common.*
|
2026-08-18 00:07:00 +08:00
|
|
|
|
import simapi_serialization.*
|
2026-08-16 12:46:15 +08:00
|
|
|
|
import simapi.communications.*
|
|
|
|
|
|
import simapi.configurations.*
|
|
|
|
|
|
import simapi.exceptions.*
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* HTTP 客户端:用于调用其他带签名/AES 的 SimApi 服务。
|
|
|
|
|
|
* 对齐 C# 的 SimApi.Helpers.SimApiHttpClient:
|
2026-08-18 01:29:02 +08:00
|
|
|
|
* - 内部使用 stdx.net.http 的 HttpClient(等价 .NET 的 System.Net.Http.HttpClient)
|
2026-08-16 12:46:15 +08:00
|
|
|
|
* - 返回泛型 T(反序列化响应 body 的 data 字段),不再返回 String
|
2026-08-18 00:07:00 +08:00
|
|
|
|
* @param T 响应 data 的数据类型(任意类,simapi_serialization 反射反序列化)。
|
2026-08-16 12:46:15 +08:00
|
|
|
|
*/
|
2026-08-16 22:47:04 +08:00
|
|
|
|
public open class SimApiHttpClient {
|
2026-08-16 12:46:15 +08:00
|
|
|
|
public var server: String
|
|
|
|
|
|
public var appId: String
|
|
|
|
|
|
public var appKey: String
|
|
|
|
|
|
public var signName: String = "sign"
|
|
|
|
|
|
public var timestampName: String = "timestamp"
|
|
|
|
|
|
public var nonceName: String = "nonce"
|
|
|
|
|
|
public var appIdName: ?String = Some("appId")
|
|
|
|
|
|
public var signFields: Array<String> = []
|
|
|
|
|
|
|
|
|
|
|
|
public init(options!: SimApiOptions = SimApiOptions()) {
|
|
|
|
|
|
let httpOptions = options.simApiHttpClientOptions
|
|
|
|
|
|
server = httpOptions.server
|
|
|
|
|
|
appId = httpOptions.appId
|
|
|
|
|
|
appKey = httpOptions.appKey
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发起签名请求(GET query 签名 + POST body)。
|
|
|
|
|
|
* 对齐 C# SignQuery<T>:query 串 = SignFields + AppId + timestamp + nonce,整体拼 AppKey 取 MD5 作为 sign。
|
|
|
|
|
|
* @param url 请求路径(相对路径,自动拼接 server)。
|
|
|
|
|
|
* @param body 请求体 JSON 字符串(可选)。
|
|
|
|
|
|
* @param queries 额外查询参数(可选)。
|
|
|
|
|
|
* @return 响应 data 字段反序列化后的 T。
|
|
|
|
|
|
*/
|
2026-08-18 00:07:00 +08:00
|
|
|
|
public func signQuery<T>(url: String, body!: String = "", queries!: HashMap<String, String> = HashMap<String, String>()): T {
|
2026-08-16 12:46:15 +08:00
|
|
|
|
var queryUrl = StringBuilder()
|
|
|
|
|
|
for (field in signFields) {
|
|
|
|
|
|
queryUrl.append("${field}=")
|
|
|
|
|
|
if (let Some(v) <- queries.get(field)) {
|
|
|
|
|
|
queryUrl.append(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
queryUrl.append("&")
|
|
|
|
|
|
}
|
|
|
|
|
|
if (let Some(name) <- appIdName) {
|
|
|
|
|
|
queryUrl.append("${name}=${appId}&")
|
|
|
|
|
|
}
|
|
|
|
|
|
let timestamp = Int64(SimApiUtil.timestampNow)
|
|
|
|
|
|
let nonce = generateNonce()
|
|
|
|
|
|
queryUrl.append("${timestampName}=${timestamp}&${nonceName}=${nonce}")
|
|
|
|
|
|
let signStr = "${queryUrl.toString()}&${appKey}"
|
|
|
|
|
|
var path = "${server}${url}?${queryUrl.toString()}&${signName}=${SimApiUtil.md5(signStr)}"
|
|
|
|
|
|
for ((k, v) in queries) {
|
|
|
|
|
|
if (!signFields.contains(k)) {
|
|
|
|
|
|
path = "${path}&${k}=${v}"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return query<T>(path, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发起 AES 加密请求:body 加密后放入 {"data": "..."} 提交。
|
|
|
|
|
|
* 对齐 C# AesQuery<T>(SimApiOneFieldRequest<string> { Data = Encrypt(body, AppKey) })。
|
|
|
|
|
|
* @param url 请求路径(相对路径,自动拼接 server)。
|
|
|
|
|
|
* @param body 请求体 JSON 字符串。
|
|
|
|
|
|
* @return 响应 data 字段反序列化后的 T。
|
|
|
|
|
|
*/
|
2026-08-18 00:07:00 +08:00
|
|
|
|
public func aesQuery<T>(url: String, body: String): T {
|
2026-08-16 12:46:15 +08:00
|
|
|
|
var target = "${server}${url}"
|
|
|
|
|
|
if (let Some(name) <- appIdName) {
|
|
|
|
|
|
target = "${target}?${name}=${appId}"
|
|
|
|
|
|
}
|
|
|
|
|
|
let encrypted = aesEncrypt(body)
|
2026-08-18 01:24:11 +08:00
|
|
|
|
let req = SimApiUtil.json(Some(SimApiOneFieldRequest<String>(encrypted)))
|
2026-08-16 12:46:15 +08:00
|
|
|
|
return query<T>(target, req)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发起 AES 加密 + 签名请求。
|
|
|
|
|
|
* 对齐 C# AesSignQuery<T>。
|
|
|
|
|
|
* @param url 请求路径(相对路径,自动拼接 server)。
|
|
|
|
|
|
* @param body 请求体 JSON 字符串。
|
|
|
|
|
|
* @param queries 额外查询参数(可选)。
|
|
|
|
|
|
* @return 响应 data 字段反序列化后的 T。
|
|
|
|
|
|
*/
|
2026-08-18 00:07:00 +08:00
|
|
|
|
public func aesSignQuery<T>(url: String, body: String, queries!: HashMap<String, String> = HashMap<String, String>()): T {
|
2026-08-16 12:46:15 +08:00
|
|
|
|
let encrypted = aesEncrypt(body)
|
2026-08-18 01:24:11 +08:00
|
|
|
|
let req = SimApiUtil.json(Some(SimApiOneFieldRequest<String>(encrypted)))
|
2026-08-16 12:46:15 +08:00
|
|
|
|
return signQuery<T>(url, body: req, queries: queries)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-18 00:07:00 +08:00
|
|
|
|
* 发起 POST 请求并反序列化 SimApiResponse<T>,返回 data 字段。
|
2026-08-16 12:46:15 +08:00
|
|
|
|
* 对齐 C# Query<T>:
|
2026-08-18 00:07:00 +08:00
|
|
|
|
* ErrorWhenFalse(IsSuccessStatusCode) → ReadFromJsonAsync<SimApiResponse<T>> → ErrorWhen(Code != 200) → return Data。
|
2026-08-16 12:46:15 +08:00
|
|
|
|
* 注意:必须 noProxy(),否则会走系统代理(192.168.0.250:8118)导致连接被拒。
|
2026-08-18 00:07:00 +08:00
|
|
|
|
* 反序列化使用 simapi_serialization(Deserialize<T> 免约束)。
|
2026-08-16 12:46:15 +08:00
|
|
|
|
*/
|
2026-08-18 00:07:00 +08:00
|
|
|
|
private func query<T>(url: String, body: String): T {
|
2026-08-18 01:29:02 +08:00
|
|
|
|
let client = ClientBuilder().
|
|
|
|
|
|
noProxy().
|
|
|
|
|
|
tlsConfig(buildTlsConfig(url)).
|
|
|
|
|
|
readTimeout(Duration.second * 30).
|
|
|
|
|
|
build()
|
2026-08-16 12:46:15 +08:00
|
|
|
|
try {
|
2026-08-18 01:29:02 +08:00
|
|
|
|
let request = HttpRequestBuilder().
|
|
|
|
|
|
post().
|
|
|
|
|
|
url(url).
|
|
|
|
|
|
header("Content-Type", "application/json").
|
|
|
|
|
|
body(body).
|
|
|
|
|
|
build()
|
2026-08-16 12:46:15 +08:00
|
|
|
|
let response = client.send(request)
|
|
|
|
|
|
try {
|
2026-08-18 01:29:02 +08:00
|
|
|
|
SimApiError.errorWhenFalse(isSuccess(response.status), code: Int64(response.status),
|
|
|
|
|
|
message: "HTTP ERROR: ${response.status}")
|
|
|
|
|
|
let json = readBodyText(response.body)
|
2026-08-18 00:07:00 +08:00
|
|
|
|
let result = JsonSerializer.Deserialize<SimApiResponse<T>>(json)
|
2026-08-18 01:15:15 +08:00
|
|
|
|
SimApiError.errorWhen(result.code != 200, code: result.code, message: result.message)
|
|
|
|
|
|
return result.data.getOrThrow()
|
2026-08-16 12:46:15 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
response.close()
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
client.close()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 01:29:02 +08:00
|
|
|
|
/// 2xx 视为成功
|
|
|
|
|
|
private static func isSuccess(status: UInt16): Bool {
|
|
|
|
|
|
status >= 200 && status < 300
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 读取响应体 InputStream 为字符串
|
|
|
|
|
|
private static func readBodyText(body: InputStream): String {
|
|
|
|
|
|
var buffer = Array<Byte>(4096, repeat: 0)
|
|
|
|
|
|
var sb = StringBuilder()
|
|
|
|
|
|
var read = body.read(buffer)
|
|
|
|
|
|
while (read > 0) {
|
|
|
|
|
|
sb.appendFromUtf8(buffer.slice(0, read))
|
|
|
|
|
|
read = body.read(buffer)
|
|
|
|
|
|
}
|
|
|
|
|
|
sb.toString()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 构建 TLS 配置:信任所有证书 + SNI 域名
|
|
|
|
|
|
private static func buildTlsConfig(url: String): TlsClientConfig {
|
|
|
|
|
|
var tls = TlsClientConfig()
|
|
|
|
|
|
tls.verifyMode = CertificateVerifyMode.TrustAll
|
|
|
|
|
|
let host = extractHost(url)
|
|
|
|
|
|
if (!host.isEmpty()) {
|
|
|
|
|
|
tls.serverName = Some(host)
|
|
|
|
|
|
}
|
|
|
|
|
|
tls
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 12:46:15 +08:00
|
|
|
|
private func aesEncrypt(plain: String): String {
|
|
|
|
|
|
// 对齐 C#:SimApiAesUtil.Encrypt(plain, AppKey)(AES-256-CBC + PKCS7,Base64(IV + 密文))
|
|
|
|
|
|
SimApiAesUtil.encrypt(plain, appKey)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static func generateNonce(): String {
|
|
|
|
|
|
// 对齐 C#:nonce 直接用 Guid.NewGuid()
|
|
|
|
|
|
SimApiUtil.newGuid()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 从完整 URL 提取 host(https://host[:port]/path → host)。
|
|
|
|
|
|
private static func extractHost(fullUrl: String): String {
|
|
|
|
|
|
match (fullUrl.indexOf("://")) {
|
|
|
|
|
|
case Some(i) =>
|
|
|
|
|
|
let rest = fullUrl[i + 3..]
|
|
|
|
|
|
let slash = rest.indexOf("/") ?? rest.size
|
|
|
|
|
|
let q = rest.indexOf("?") ?? rest.size
|
|
|
|
|
|
let end = if (slash < q) { slash } else { q }
|
|
|
|
|
|
return rest[0..end]
|
|
|
|
|
|
case None => return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|