Files
simapi-cj/src/helpers/SimApiHttpClient.cj
T
2026-08-16 12:46:15 +08:00

170 lines
6.9 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\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<T>,如 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<String> = []
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<T>query 串 = SignFields + AppId + timestamp + nonce,整体拼 AppKey 取 MD5 作为 sign。
* @param url 请求路径(相对路径,自动拼接 server)。
* @param body 请求体 JSON 字符串(可选)。
* @param queries 额外查询参数(可选)。
* @return 响应 data 字段反序列化后的 T。
*/
public func signQuery<T>(url: String, body!: String = "", queries!: HashMap<String, String> = HashMap<String, String>()): T where T <: ISerialization<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<T>(path, body)
}
/**
* 发起 AES 加密请求:body 加密后放入 {"data": "..."} 提交。
* 对齐 C# AesQuery<T>SimApiOneFieldRequest<string> { Data = Encrypt(body, AppKey) })。
* @param url 请求路径(相对路径,自动拼接 server)。
* @param body 请求体 JSON 字符串。
* @return 响应 data 字段反序列化后的 T。
*/
public func aesQuery<T>(url: String, body: String): T where T <: ISerialization<T> {
var target = "${server}${url}"
if (let Some(name) <- appIdName) {
target = "${target}?${name}=${appId}"
}
let encrypted = aesEncrypt(body)
let req = "{\"data\":\"${encrypted}\"}"
return query<T>(target, req)
}
/**
* 发起 AES 加密 + 签名请求。
* 对齐 C# AesSignQuery<T>。
* @param url 请求路径(相对路径,自动拼接 server)。
* @param body 请求体 JSON 字符串。
* @param queries 额外查询参数(可选)。
* @return 响应 data 字段反序列化后的 T。
*/
public func aesSignQuery<T>(url: String, body: String, queries!: HashMap<String, String> = HashMap<String, String>()): T where T <: ISerialization<T> {
let encrypted = aesEncrypt(body)
let req = "{\"data\":\"${encrypted}\"}"
return signQuery<T>(url, body: req, queries: queries)
}
/**
* 发起 POST 请求并反序列化 SimApiBaseResponse<T>,返回 data 字段。
* 对齐 C# Query<T>
* ErrorWhenFalse(IsSuccessStatusCode) → ReadFromJsonAsync<SimApiBaseResponse<T>> → ErrorWhen(Code != 200) → return Data。
* 注意:必须 noProxy(),否则会走系统代理(192.168.0.250:8118)导致连接被拒。
*/
private func query<T>(url: String, body: String): T where T <: ISerialization<T> {
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<SimApiResponse<T>>()
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 + PKCS7Base64(IV + 密文)
SimApiAesUtil.encrypt(plain, appKey)
}
private static func generateNonce(): String {
// 对齐 C#nonce 直接用 Guid.NewGuid()
SimApiUtil.newGuid()
}
/// 从完整 URL 提取 hosthttps://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 ""
}
}
}