use std::collections::BTreeMap;
use chrono::Utc;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha1::Digest;
use super::super::{BillingError, Result};
const UCLOUD_API_ENDPOINT: &str = "https://api.ucloud.cn";
pub struct UCloudBillingClient {
public_key: String,
private_key: String,
project_id: String,
http_client: Client,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UCloudBillResponse {
#[serde(rename = "RetCode")]
pub ret_code: i32,
#[serde(rename = "Action")]
pub action: Option<String>,
#[serde(rename = "Message")]
pub message: Option<String>,
#[serde(rename = "TotalCount")]
pub total_count: Option<i32>,
#[serde(rename = "Items")]
pub items: Option<Vec<UCloudBillItem>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UCloudBillItem {
#[serde(rename = "ResourceId")]
pub resource_id: Option<String>,
#[serde(rename = "ResourceType")]
pub resource_type: Option<String>,
#[serde(rename = "OrderNo")]
pub order_no: Option<String>,
#[serde(rename = "Amount", deserialize_with = "deserialize_amount", default)]
pub amount: Option<f64>,
#[serde(
rename = "AmountReal",
deserialize_with = "deserialize_amount",
default
)]
pub amount_real: Option<f64>,
#[serde(rename = "Region")]
pub region: Option<String>,
#[serde(rename = "ProductName")]
pub product_name: Option<String>,
#[serde(rename = "ProductCategory")]
pub product_category: Option<String>,
#[serde(rename = "ResourceExtId")]
pub resource_ext_id: Option<String>,
}
fn deserialize_amount<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match v {
Value::Number(n) => Ok(n.as_f64()),
Value::String(s) => Ok(s.parse::<f64>().ok()),
Value::Null => Ok(None),
_ => Ok(None),
}
}
impl UCloudBillingClient {
pub fn new(public_key: String, private_key: String, project_id: String) -> Self {
Self {
public_key,
private_key,
project_id,
http_client: Client::new(),
}
}
fn generate_signature(&self, params: &BTreeMap<String, String>) -> Result<String> {
let mut param_str = String::new();
for (key, value) in params.iter() {
param_str.push_str(key);
param_str.push_str(value);
}
param_str.push_str(&self.private_key);
let mut hasher = sha1::Sha1::new();
hasher.update(param_str.as_bytes());
let result = hasher.finalize();
Ok(hex::encode(result))
}
fn build_common_params(&self, action: &str) -> BTreeMap<String, String> {
let mut params = BTreeMap::new();
params.insert("Action".to_string(), action.to_string());
params.insert("PublicKey".to_string(), self.public_key.clone());
params.insert("ProjectId".to_string(), self.project_id.clone());
params
}
async fn call_api(&self, action: &str, mut params: BTreeMap<String, String>) -> Result<Value> {
let common_params = self.build_common_params(action);
for (k, v) in common_params {
params.insert(k, v);
}
let signature = self.generate_signature(¶ms)?;
params.insert("Signature".to_string(), signature);
let query_string = params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<String>>()
.join("&");
let url = format!("{}/?{}", UCLOUD_API_ENDPOINT, query_string);
tracing::debug!("UCloud API request: {}", url);
let response = self
.http_client
.get(&url)
.send()
.await
.map_err(|e| BillingError::ServiceError(format!("HTTP request failed: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(BillingError::ServiceError(format!(
"UCloud API error: {} - {}",
status, body
)));
}
let json_response = response
.json::<Value>()
.await
.map_err(|e| BillingError::ServiceError(format!("Failed to parse response: {}", e)))?;
Ok(json_response)
}
pub async fn query_bill_list(
&self,
billing_cycle: &str,
offset: Option<i32>,
limit: Option<i32>,
) -> Result<UCloudBillResponse> {
tracing::info!("Querying UCloud billing for cycle {}", billing_cycle);
let mut params = BTreeMap::new();
params.insert("BillingCycle".to_string(), billing_cycle.to_string());
if let Some(off) = offset {
params.insert("Offset".to_string(), off.to_string());
}
if let Some(lim) = limit {
params.insert("Limit".to_string(), lim.to_string());
}
let response = self.call_api("ListUBillDetail", params).await?;
tracing::debug!(
"UCloud API response: {}",
serde_json::to_string_pretty(&response).unwrap_or_default()
);
let result: UCloudBillResponse = serde_json::from_value(response.clone()).map_err(|e| {
BillingError::ServiceError(format!(
"Failed to parse UCloud bill response: {} | raw: {}",
e, response
))
})?;
if result.ret_code != 0 {
return Err(BillingError::ServiceError(format!(
"UCloud API error: code={}, message={:?}",
result.ret_code, result.message
)));
}
Ok(result)
}
pub async fn test_credentials(&self) -> Result<bool> {
let now = Utc::now();
let cycle = now.format("%Y-%m").to_string();
match self.query_bill_list(&cycle, Some(0), Some(1)).await {
Ok(response) => Ok(response.ret_code == 0),
Err(_) => Ok(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = UCloudBillingClient::new(
"test_public_key".to_string(),
"test_private_key".to_string(),
"test_project_id".to_string(),
);
assert_eq!(client.public_key, "test_public_key");
assert_eq!(client.project_id, "test_project_id");
}
}