/* * Copyright (c) 2025 SimcuTeam. All rights reserved. * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 */ package simcu::simapi.helpers import std.collection.* import std.io.* import stdx.net.http.* import stdx.net.tls.* import stdx.net.tls.common.* import simapi_serialization.* import simcu::simapi.communications.* import simcu::simapi.configurations.* import simcu::simapi.exceptions.* /** * HTTP 客户端:用于调用其他带签名/AES 的 SimApi 服务。 * 对齐 C# 的 SimApi.Helpers.SimApiHttpClient: * - 内部使用 stdx.net.http 的 HttpClient(等价 .NET 的 System.Net.Http.HttpClient) * - 返回泛型 T(反序列化响应 body 的 data 字段),不再返回 String * @param T 响应 data 的数据类型(任意类,simapi_serialization 反射反序列化)。 */ public open class SimApiHttpClient { 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 = [] public init(options!: SimApiOptions = SimApiOptions()) { let httpOptions = options.simApiHttpClientOptions server = httpOptions.server appId = httpOptions.appId appKey = httpOptions.appKey } /** * 发起签名请求(GET query 签名 + POST body)。 * 对齐 C# SignQuery:query 串 = SignFields + AppId + timestamp + nonce,整体拼 AppKey 取 MD5 作为 sign。 * @param url 请求路径(相对路径,自动拼接 server)。 * @param body 请求体 JSON 字符串(可选)。 * @param queries 额外查询参数(可选)。 * @return 响应 data 字段反序列化后的 T。 */ public func signQuery(url: String, body!: String = "", queries!: HashMap = HashMap()): T { 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(path, body) } /** * 发起 AES 加密请求:body 加密后放入 {"data": "..."} 提交。 * 对齐 C# AesQuery(SimApiOneFieldRequest { Data = Encrypt(body, AppKey) })。 * @param url 请求路径(相对路径,自动拼接 server)。 * @param body 请求体 JSON 字符串。 * @return 响应 data 字段反序列化后的 T。 */ public func aesQuery(url: String, body: String): T { var target = "${server}${url}" if (let Some(name) <- appIdName) { target = "${target}?${name}=${appId}" } let encrypted = aesEncrypt(body) let req = SimApiUtil.json(Some(SimApiOneFieldRequest(encrypted))) return query(target, req) } /** * 发起 AES 加密 + 签名请求。 * 对齐 C# AesSignQuery。 * @param url 请求路径(相对路径,自动拼接 server)。 * @param body 请求体 JSON 字符串。 * @param queries 额外查询参数(可选)。 * @return 响应 data 字段反序列化后的 T。 */ public func aesSignQuery(url: String, body: String, queries!: HashMap = HashMap()): T { let encrypted = aesEncrypt(body) let req = SimApiUtil.json(Some(SimApiOneFieldRequest(encrypted))) return signQuery(url, body: req, queries: queries) } /** * 发起 POST 请求并反序列化 SimApiResponse,返回 data 字段。 * 对齐 C# Query: * ErrorWhenFalse(IsSuccessStatusCode) → ReadFromJsonAsync> → ErrorWhen(Code != 200) → return Data。 * 注意:必须 noProxy(),否则会走系统代理(192.168.0.250:8118)导致连接被拒。 * 反序列化使用 simapi_serialization(Deserialize 免约束)。 */ private func query(url: String, body: String): T { let client = ClientBuilder(). noProxy(). tlsConfig(buildTlsConfig(url)). readTimeout(Duration.second * 30). build() try { let request = HttpRequestBuilder(). post(). url(url). header("Content-Type", "application/json"). body(body). build() let response = client.send(request) try { SimApiError.errorWhenFalse(isSuccess(response.status), code: Int64(response.status), message: "HTTP ERROR: ${response.status}") let json = readBodyText(response.body) let result = JsonSerializer.Deserialize>(json) SimApiError.errorWhen(result.code != 200, code: result.code, message: result.message) return result.data.getOrThrow() } finally { response.close() } } finally { client.close() } } /// 2xx 视为成功 private static func isSuccess(status: UInt16): Bool { status >= 200 && status < 300 } /// 读取响应体 InputStream 为字符串 private static func readBodyText(body: InputStream): String { var buffer = Array(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 } 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 "" } } }