/* * Copyright (c) 2025 SimcuTeam. All rights reserved. * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 */ package simapi.helpers import std.collection.* import std.io.* import stdx.net.tls.* import stdx.net.tls.common.* import soulsoft_net_http.{HttpClient, HttpRequestMessage, JsonContent} import soulsoft_net_http.{HttpMethod as NetHttpMethod} import soulsoft_serialization.* import simapi.communications.* import simapi.configurations.* import simapi.exceptions.* /** * HTTP 客户端:用于调用其他带签名/AES 的 SimApi 服务。 * 对齐 C# 的 SimApi.Helpers.SimApiHttpClient: * - 内部使用 soulsoft_net_http 的 HttpClient(等价 .NET 的 System.Net.Http.HttpClient) * - 返回泛型 T(反序列化响应 body 的 data 字段),不再返回 String * @param T 响应 data 的数据类型(需实现 ISerialization,如 SimApiLoginItem、String、Int64 等)。 */ public 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 signName = httpOptions.signName timestampName = httpOptions.timestampName nonceName = httpOptions.nonceName appIdName = httpOptions.appIdName signFields = httpOptions.signFields } /** * 发起签名请求(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 where T <: ISerialization { 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 where T <: ISerialization { var target = "${server}${url}" if (let Some(name) <- appIdName) { target = "${target}?${name}=${appId}" } let encrypted = aesEncrypt(body) let req = "{\"data\":\"${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 where T <: ISerialization { let encrypted = aesEncrypt(body) let req = "{\"data\":\"${encrypted}\"}" return signQuery(url, body: req, queries: queries) } /** * 发起 POST 请求并反序列化 SimApiBaseResponse,返回 data 字段。 * 对齐 C# Query: * ErrorWhenFalse(IsSuccessStatusCode) → ReadFromJsonAsync> → ErrorWhen(Code != 200) → return Data。 * 注意:必须 noProxy(),否则会走系统代理(192.168.0.250:8118)导致连接被拒。 */ private func query(url: String, body: String): T where T <: ISerialization { let client = HttpClient.create { builder => builder.noProxy() // 支持 https:配置 TLS(信任所有证书 + SNI 域名) var tls = TlsClientConfig() tls.verifyMode = CertificateVerifyMode.TrustAll let host = extractHost(url) if (!host.isEmpty()) { tls.serverName = Some(host) } builder.tlsConfig(tls) } try { let request = HttpRequestMessage(NetHttpMethod.Post, url) request.content = JsonContent.create(body) let response = client.send(request) try { SimApiError.errorWhenFalse(response.isSuccessStatusCode, code: response.statusCode, message: "HTTP ERROR: ${response.statusCode}") let result = response.content.readFromJson>() SimApiError.errorWhen(result._code != 200, code: result._code, message: result._message) return result._data.getOrThrow() } finally { response.close() } } finally { client.close() } } 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 "" } } }