use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Renew {
pub uid: u64,
pub ruid: u64,
pub goods_id: u64,
pub status: u8,
pub next_execute_time: u64,
pub signed_time: u64,
pub signed_price: u64,
pub pay_channel: u8,
pub period: u64,
pub mobile_app: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChargeItem {
pub privilege_type: u64,
pub icon: String,
pub name: String,
pub expire_time: u64,
pub renew: Option<Renew>,
pub start_time: u64,
pub renew_list: Option<Vec<Renew>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChargeUp {
pub up_uid: u64,
pub user_name: String,
pub user_face: String,
pub item: Vec<ChargeItem>,
pub start: u64,
pub high_level_state: u8,
pub elec_reply_state: u8,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChargeRecordData {
pub list: Option<Vec<ChargeUp>>,
pub page: u64,
pub page_size: u64,
pub total_page: u64,
pub total_num: u64,
pub is_more: u8,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UpowerRankUser {
pub rank: u64,
pub mid: u64,
pub nickname: String,
pub avatar: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UpowerRank {
pub total: u64,
pub total_desc: String,
pub list: Vec<UpowerRankUser>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ItemDetailIntro {
pub intro_video_aid: String,
pub welcomes: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UpUserCard {
pub avatar: String,
pub nickname: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UpowerRightCount {
#[serde(flatten)]
pub counts: HashMap<String, u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UpowerItemDetail {
pub upower_rank: UpowerRank,
pub item: ItemDetailIntro,
pub user_card: UpUserCard,
pub upower_level: u8,
pub elec_reply_state: u8,
pub voucher_state: serde_json::Value,
pub upower_right_count: UpowerRightCount,
pub only_contain_medal: bool,
pub privilege_type: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UpCard {
pub mid: u64,
pub nickname: String,
pub official_title: String,
pub avatar: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UserCard {
pub avatar: String,
pub nickname: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChargeFollowInfo {
pub days: u64,
pub up_card: UpCard,
pub user_card: UserCard,
pub remain_days: i64,
pub remain_less_1day: u8,
pub upower_rank: UpowerRank,
pub upower_icon: String,
pub upower_right_count: i64,
pub only_contain_medal: bool,
pub privilege_type: u64,
pub challenge_info: ChallengeInfo,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChallengeInfo {
pub challenge_id: String,
pub description: String,
pub challenge_type: i64,
pub remaining_days: i64,
pub end_time: String,
pub progress: i64,
pub targets: Vec<serde_json::Value>,
pub state: i64,
pub end_time_unix: i64,
pub pub_dyn: i64,
pub dyn_content: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UpInfo {
pub mid: u64,
pub nickname: String,
pub avatar: String,
pub r#type: i32,
pub title: String,
pub upower_state: u8,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RankInfo {
pub mid: u64,
pub nickname: String,
pub avatar: String,
pub rank: u64,
pub day: u64,
pub expire_at: u64,
pub remain_days: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MemberUserInfo {
pub mid: u64,
pub nickname: String,
pub avatar: String,
pub rank: i64,
pub day: u64,
pub expire_at: u64,
pub remain_days: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LevelInfo {
pub privilege_type: u64,
pub name: String,
pub price: u64,
pub member_total: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MemberRankData {
pub up_info: UpInfo,
pub rank_info: Vec<RankInfo>,
pub user_info: MemberUserInfo,
pub member_total: u64,
pub privilege_type: u64,
pub is_charge: bool,
pub tabs: Vec<u64>,
pub level_info: Vec<LevelInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::{ApiEnvelope, BpiClient, BpiResult};
use tracing::info;
fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
let bytes = match endpoint {
"upower-item-detail" => include_bytes!(
"../../tests/contracts/electric/public-read/upower-item-detail/contract.json"
)
.as_slice(),
"upower-member-rank" => include_bytes!(
"../../tests/contracts/electric/public-read/upower-member-rank/contract.json"
)
.as_slice(),
"charge-record" => include_bytes!(
"../../tests/contracts/electric/private-read/charge-record/contract.json"
)
.as_slice(),
"charge-follow-info" => include_bytes!(
"../../tests/contracts/electric/private-read/charge-follow-info/contract.json"
)
.as_slice(),
_ => unreachable!("unknown electric monthly contract endpoint"),
};
EndpointContract::from_slice(bytes)
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_charge_record() {
let bpi = BpiClient::new().expect("client should build");
let resp = bpi.electric().charge_record(1, 1).await;
info!("响应: {:?}", resp);
assert!(resp.is_ok());
if let Ok(data) = resp {
if let Some(list) = data.list {
info!("找到 {} 个正在充电的UP主", list.len());
} else {
info!("没有正在充电的UP主");
}
}
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_upower_item_detail() {
let bpi = BpiClient::new().expect("client should build");
let up_mid = 1265680561;
let resp = bpi.electric().upower_item_detail(up_mid).await;
info!("响应: {:?}", resp);
assert!(resp.is_ok());
if let Ok(data) = resp {
info!(
"UP主 {} 的充电总人数: {}",
data.user_card.nickname, data.upower_rank.total
);
}
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_charge_follow_info() {
let bpi = BpiClient::new().expect("client should build");
let up_mid = 293793435;
let resp = bpi.electric().charge_follow_info(up_mid).await;
info!("响应: {:?}", resp);
assert!(resp.is_ok());
if let Ok(data) = resp {
info!(
"与UP主 {} 的充电关系:已保持 {} 天",
data.up_card.nickname, data.days
);
}
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_upower_member_rank() {
let bpi = BpiClient::new().expect("client should build");
let up_mid = 1265680561;
let resp = bpi.electric().upower_member_rank(up_mid, 1, 10, None).await;
info!("响应: {:?}", resp);
assert!(resp.is_ok());
if let Ok(data) = resp {
info!("当前档位充电用户总数: {}", data.member_total);
if let Some(first_rank) = data.rank_info.first() {
info!("排名第一的用户: {}", first_rank.nickname);
}
}
}
#[test]
fn electric_upower_item_detail_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("upower-item-detail")?;
assert_eq!(contract.name, "electric.upower_item_detail");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://api.bilibili.com/x/upower/item/detail"
);
assert_eq!(
contract.request.query.get("up_mid").map(String::as_str),
Some("1265680561")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[0].response.rust_model.as_deref(),
Some("UpowerItemDetail")
);
Ok(())
}
#[test]
fn electric_upower_member_rank_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("upower-member-rank")?;
assert_eq!(contract.name, "electric.upower_member_rank");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://api.bilibili.com/x/upower/up/member/rank/v2"
);
assert_eq!(
contract.request.query.get("up_mid").map(String::as_str),
Some("1265680561")
);
assert_eq!(
contract.request.query.get("pn").map(String::as_str),
Some("1")
);
assert_eq!(
contract.request.query.get("ps").map(String::as_str),
Some("10")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[0].response.rust_model.as_deref(),
Some("MemberRankData")
);
Ok(())
}
#[test]
fn electric_charge_record_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("charge-record")?;
assert_eq!(contract.name, "electric.charge_record");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://api.live.bilibili.com/xlive/revenue/v1/guard/getChargeRecord"
);
assert_eq!(
contract.request.query.get("page").map(String::as_str),
Some("1")
);
assert_eq!(
contract.request.query.get("type").map(String::as_str),
Some("1")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[1].response.rust_model.as_deref(),
Some("ChargeRecordData")
);
Ok(())
}
#[test]
fn electric_charge_follow_info_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("charge-follow-info")?;
assert_eq!(contract.name, "electric.charge_follow_info");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://api.bilibili.com/x/upower/charge/follow/info"
);
assert_eq!(
contract.request.query.get("up_mid").map(String::as_str),
Some("1265680561")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[1].response.rust_model.as_deref(),
Some("ChargeFollowInfo")
);
Ok(())
}
#[test]
fn electric_monthly_response_fixtures_parse_declared_models() -> BpiResult<()> {
let item_detail = ApiEnvelope::<UpowerItemDetail>::from_slice(include_bytes!(
"../../tests/contracts/electric/public-read/upower-item-detail/responses/success.json"
))?
.into_payload()?;
assert_eq!(item_detail.upower_rank.list.len(), 1);
assert_eq!(item_detail.upower_right_count.counts["100"], 5);
let anonymous_rank = ApiEnvelope::<MemberRankData>::from_slice(include_bytes!(
"../../tests/contracts/electric/public-read/upower-member-rank/responses/anonymous.success.json"
))?
.into_payload()?;
assert_eq!(anonymous_rank.user_info.mid, 0);
let authenticated_rank = ApiEnvelope::<MemberRankData>::from_slice(include_bytes!(
"../../tests/contracts/electric/public-read/upower-member-rank/responses/authenticated.success.json"
))?
.into_payload()?;
assert_eq!(authenticated_rank.user_info.mid, 1);
Ok(())
}
#[test]
fn electric_monthly_private_response_fixtures_parse_declared_models() -> BpiResult<()> {
let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
"../../tests/contracts/electric/private-read/charge-record/responses/anonymous.requires_login.json"
))?
.ensure_success()
.unwrap_err();
assert!(err.requires_login());
let charge_record = ApiEnvelope::<ChargeRecordData>::from_slice(include_bytes!(
"../../tests/contracts/electric/private-read/charge-record/responses/authenticated.success.json"
))?
.into_payload()?;
assert_eq!(charge_record.total_num, 0);
let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
"../../tests/contracts/electric/private-read/charge-follow-info/responses/anonymous.requires_login.json"
))?
.ensure_success()
.unwrap_err();
assert!(err.requires_login());
let follow_info = ApiEnvelope::<ChargeFollowInfo>::from_slice(include_bytes!(
"../../tests/contracts/electric/private-read/charge-follow-info/responses/authenticated.success.json"
))?
.into_payload()?;
assert_eq!(follow_info.up_card.mid, 1265680561);
Ok(())
}
fn local_probe_body(batch: &str, endpoint: &str, profile: &str) -> Option<serde_json::Value> {
let path =
format!("target/bpi-probe-runs/electric/{batch}/{endpoint}/{profile}.response.json");
let bytes = std::fs::read(path).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value
.get("response")
.and_then(|response| response.get("body"))
.cloned()
}
#[test]
fn electric_monthly_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["anonymous", "normal", "vip"] {
if let Some(body) = local_probe_body("public-read", "upower-item-detail", profile) {
let payload = serde_json::from_value::<ApiEnvelope<UpowerItemDetail>>(body)?
.into_payload()?;
assert!(payload.upower_rank.total >= payload.upower_rank.list.len() as u64);
}
if let Some(body) = local_probe_body("public-read", "upower-member-rank", profile) {
let payload =
serde_json::from_value::<ApiEnvelope<MemberRankData>>(body)?.into_payload()?;
assert!(payload.member_total >= payload.rank_info.len() as u64);
}
}
Ok(())
}
#[test]
fn electric_monthly_private_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["anonymous", "normal", "vip"] {
if let Some(body) = local_probe_body("private-read", "charge-record", profile) {
let envelope = serde_json::from_value::<ApiEnvelope<ChargeRecordData>>(body)?;
if profile == "anonymous" {
let err = envelope.ensure_success().unwrap_err();
assert!(err.requires_login());
} else {
let payload = envelope.into_payload()?;
assert!(payload.total_num >= payload.list.as_ref().map_or(0, Vec::len) as u64);
}
}
if let Some(body) = local_probe_body("private-read", "charge-follow-info", profile) {
let envelope = serde_json::from_value::<ApiEnvelope<ChargeFollowInfo>>(body)?;
if profile == "anonymous" {
let err = envelope.ensure_success().unwrap_err();
assert!(err.requires_login());
} else {
let payload = envelope.into_payload()?;
assert!(payload.upower_rank.total >= payload.upower_rank.list.len() as u64);
}
}
}
Ok(())
}
}