Skip to main content

bpi_rs/electric/
charge_msg.rs

1use crate::BilibiliRequest;
2use crate::BpiError;
3use crate::BpiResult;
4use crate::electric::ElectricClient;
5use serde::{Deserialize, Serialize};
6
7const ELEC_MESSAGE_ENDPOINT: &str = "https://api.bilibili.com/x/ugcpay/trade/elec/message";
8const ELEC_REMARK_REPLY_ENDPOINT: &str = "https://member.bilibili.com/x/web/elec/remark/reply";
9
10/// 充电留言列表分页信息
11#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct ElecRemarkPager {
13    /// 当前页数
14    pub current: u64,
15    /// 当前分页大小
16    pub size: u64,
17    /// 记录总数
18    pub total: u64,
19}
20
21/// 充电留言列表中的单条留言
22#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct ElecRemarkRecord {
24    pub aid: u64,
25    pub bvid: String,
26    pub id: u64,
27    pub mid: u64,
28    pub reply_mid: u64,
29    pub elec_num: u64,
30    /// UP是否已经回复这条留言 0: 未回复 1: 已回复
31    pub state: u8,
32    /// 留言信息
33    pub msg: String,
34    pub aname: String,
35    pub uname: String,
36    pub avator: String,
37    pub reply_name: String,
38    pub reply_avator: String,
39    pub reply_msg: String,
40    /// 留言时间毫秒级时间戳
41    pub ctime: u64,
42    pub reply_time: u64,
43}
44
45/// 充电留言列表数据
46#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct ElecRemarkList {
48    pub list: Vec<ElecRemarkRecord>,
49    pub pager: ElecRemarkPager,
50}
51
52/// 充电留言详情数据
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct ElecRemarkDetail {
55    pub aid: u64,
56    pub bvid: String,
57    pub id: u64,
58    /// 留言者mid(充电用户)
59    pub mid: u64,
60    /// UP主mid
61    pub reply_mid: u64,
62    pub elec_num: u64,
63    /// UP是否已经回复这条留言 0: 未回复 1: 已回复
64    pub state: u8,
65    /// 留言内容
66    pub msg: String,
67    pub aname: String,
68    /// 留言者用户名
69    pub uname: String,
70    /// 留言者头像
71    pub avator: String,
72    /// UP主用户名
73    pub reply_name: String,
74    /// UP主头像
75    pub reply_avator: String,
76    /// 回复内容
77    pub reply_msg: String,
78    /// 留言时间毫秒级时间戳
79    pub ctime: u64,
80    /// 回复时间毫秒级时间戳
81    pub reply_time: u64,
82}
83
84/// Parameters for sending an electric charge message.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ElectricMessageSendParams {
87    order_id: String,
88    message: String,
89}
90
91impl ElectricMessageSendParams {
92    pub fn new(order_id: impl Into<String>, message: impl Into<String>) -> BpiResult<Self> {
93        Ok(Self {
94            order_id: normalize_non_blank("order_id", order_id.into())?,
95            message: normalize_non_blank("message", message.into())?,
96        })
97    }
98
99    fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
100        vec![
101            ("order_id", self.order_id.clone()),
102            ("message", self.message.clone()),
103            ("csrf", csrf.to_string()),
104        ]
105    }
106}
107
108/// Parameters for replying to an electric charge remark.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ElectricRemarkReplyParams {
111    id: u64,
112    msg: String,
113}
114
115impl ElectricRemarkReplyParams {
116    pub fn new(id: u64, msg: impl Into<String>) -> BpiResult<Self> {
117        if id == 0 {
118            return Err(BpiError::invalid_parameter("id", "id must be non-zero"));
119        }
120
121        Ok(Self {
122            id,
123            msg: normalize_non_blank("msg", msg.into())?,
124        })
125    }
126
127    fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
128        vec![
129            ("id", self.id.to_string()),
130            ("msg", self.msg.clone()),
131            ("csrf", csrf.to_string()),
132        ]
133    }
134}
135
136impl<'a> ElectricClient<'a> {
137    /// Sends an electric charge message and returns the canonical payload result.
138    pub async fn send_message(
139        &self,
140        params: ElectricMessageSendParams,
141    ) -> BpiResult<Option<serde_json::Value>> {
142        let csrf = self.client.csrf()?;
143
144        self.client
145            .post(ELEC_MESSAGE_ENDPOINT)
146            .form(&params.form_pairs(&csrf))
147            .send_bpi_optional_payload("electric.message.send")
148            .await
149    }
150
151    /// Replies to an electric charge remark and returns the canonical payload result.
152    pub async fn reply_remark(&self, params: ElectricRemarkReplyParams) -> BpiResult<u64> {
153        let csrf = self.client.csrf()?;
154
155        self.client
156            .post(ELEC_REMARK_REPLY_ENDPOINT)
157            .form(&params.form_pairs(&csrf))
158            .send_bpi_payload("electric.remark.reply")
159            .await
160    }
161}
162
163fn normalize_non_blank(field: &'static str, value: String) -> BpiResult<String> {
164    let value = value.trim().to_string();
165    if value.is_empty() {
166        return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
167    }
168
169    Ok(value)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::probe::contract::HttpMethod;
176    use crate::probe::endpoint_contract::EndpointContract;
177    use crate::probe::flow::ProbeFlow;
178    use crate::{ApiEnvelope, BpiResult};
179
180    fn remark_list_contract() -> BpiResult<EndpointContract> {
181        EndpointContract::from_slice(include_bytes!(
182            "../../tests/contracts/electric/private-read/remark-list/contract.json"
183        ))
184    }
185
186    fn remark_detail_contract() -> BpiResult<EndpointContract> {
187        EndpointContract::from_slice(include_bytes!(
188            "../../tests/contracts/electric/private-read/remark-detail/contract.json"
189        ))
190    }
191
192    fn normal_remark_detail_flow_contract() -> BpiResult<ProbeFlow> {
193        ProbeFlow::from_slice(include_bytes!(
194            "../../tests/contracts/electric/private-read/remark-detail/flow/normal.contract.json"
195        ))
196    }
197
198    fn vip_remark_detail_flow_contract() -> BpiResult<ProbeFlow> {
199        ProbeFlow::from_slice(include_bytes!(
200            "../../tests/contracts/electric/private-read/remark-detail/flow/vip.contract.json"
201        ))
202    }
203
204    #[test]
205    fn electric_remark_list_contract_matches_endpoint_request() -> BpiResult<()> {
206        let contract = remark_list_contract()?;
207
208        assert_eq!(contract.name, "electric.remark_list");
209        assert_eq!(contract.request.method, HttpMethod::Get);
210        assert_eq!(
211            contract.request.url.as_str(),
212            "https://member.bilibili.com/x/web/elec/remark/list"
213        );
214        assert_eq!(
215            contract.request.query.get("pn").map(String::as_str),
216            Some("1")
217        );
218        assert_eq!(
219            contract.request.query.get("ps").map(String::as_str),
220            Some("10")
221        );
222        assert_eq!(contract.cases.len(), 3);
223        assert_eq!(
224            contract.cases[1].response.rust_model.as_deref(),
225            Some("ElecRemarkList")
226        );
227        Ok(())
228    }
229
230    #[test]
231    fn electric_remark_detail_contract_matches_anonymous_endpoint_request() -> BpiResult<()> {
232        let contract = remark_detail_contract()?;
233
234        assert_eq!(contract.name, "electric.remark_detail");
235        assert_eq!(contract.request.method, HttpMethod::Get);
236        assert_eq!(
237            contract.request.url.as_str(),
238            "https://member.bilibili.com/x/web/elec/remark/detail"
239        );
240        assert_eq!(
241            contract.request.query.get("id").map(String::as_str),
242            Some("1")
243        );
244        assert_eq!(contract.cases.len(), 1);
245        assert_eq!(
246            contract.cases[0].response.error.as_deref(),
247            Some("requires_login")
248        );
249        Ok(())
250    }
251
252    #[test]
253    fn electric_remark_detail_flow_contracts_use_list_id_placeholder() -> BpiResult<()> {
254        for flow in [
255            normal_remark_detail_flow_contract()?,
256            vip_remark_detail_flow_contract()?,
257        ] {
258            assert!(matches!(
259                flow.name.as_str(),
260                "electric.remark_detail.normal.flow" | "electric.remark_detail.vip.flow"
261            ));
262            assert_eq!(flow.steps.len(), 2);
263            assert_eq!(flow.steps[0].name, "remark-list");
264            assert_eq!(
265                flow.steps[0].extract.get("remark_id").map(String::as_str),
266                Some("/response/body/data/list/0/id")
267            );
268            assert_eq!(flow.steps[1].name, "remark-detail");
269            assert_eq!(
270                flow.steps[1].contract["request"]["query"]["id"],
271                "${remark_id}"
272            );
273        }
274
275        Ok(())
276    }
277
278    #[test]
279    fn electric_remark_list_response_fixtures_parse_declared_models() -> BpiResult<()> {
280        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
281            "../../tests/contracts/electric/private-read/remark-list/responses/anonymous.requires_login.json"
282        ))?
283        .ensure_success()
284        .unwrap_err();
285        assert!(err.requires_login());
286
287        let list = ApiEnvelope::<ElecRemarkList>::from_slice(include_bytes!(
288            "../../tests/contracts/electric/private-read/remark-list/responses/authenticated.success.json"
289        ))?
290        .into_payload()?;
291        assert_eq!(list.list.len(), 1);
292        Ok(())
293    }
294
295    #[test]
296    fn electric_remark_detail_response_fixtures_parse_declared_models() -> BpiResult<()> {
297        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
298            "../../tests/contracts/electric/private-read/remark-detail/responses/anonymous.requires_login.json"
299        ))?
300        .ensure_success()
301        .unwrap_err();
302        assert!(err.requires_login());
303
304        let detail = ApiEnvelope::<ElecRemarkDetail>::from_slice(include_bytes!(
305            "../../tests/contracts/electric/private-read/remark-detail/responses/authenticated.success.json"
306        ))?
307        .into_payload()?;
308        assert_eq!(detail.id, 1);
309        assert_eq!(detail.msg, "<redacted>");
310        Ok(())
311    }
312
313    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
314        let path = format!(
315            "target/bpi-probe-runs/electric/private-read/{endpoint}/{profile}.response.json"
316        );
317        let bytes = std::fs::read(path).ok()?;
318        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
319        value
320            .get("response")
321            .and_then(|response| response.get("body"))
322            .cloned()
323    }
324
325    fn local_probe_flow_step_body(profile: &str, step: &str) -> Option<serde_json::Value> {
326        let path = format!(
327            "target/bpi-probe-runs/electric/private-read/remark-detail-flow/{profile}.response.json"
328        );
329        let bytes = std::fs::read(path).ok()?;
330        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
331        value
332            .get("steps")?
333            .as_array()?
334            .iter()
335            .find(|entry| entry.get("step").and_then(serde_json::Value::as_str) == Some(step))?
336            .get("result")
337            .and_then(|result| result.get("response"))
338            .and_then(|response| response.get("body"))
339            .cloned()
340    }
341
342    #[test]
343    fn electric_remark_list_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
344        for profile in ["anonymous", "normal", "vip"] {
345            if let Some(body) = local_probe_body("remark-list", profile) {
346                let envelope = serde_json::from_value::<ApiEnvelope<ElecRemarkList>>(body)?;
347                if profile == "anonymous" {
348                    let err = envelope.ensure_success().unwrap_err();
349                    assert!(err.requires_login());
350                } else {
351                    let payload = envelope.into_payload()?;
352                    assert!(payload.pager.total >= payload.list.len() as u64);
353                }
354            }
355        }
356        Ok(())
357    }
358
359    #[test]
360    fn electric_remark_detail_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
361        if let Some(body) = local_probe_body("remark-detail", "anonymous") {
362            let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
363                .ensure_success()
364                .unwrap_err();
365            assert!(err.requires_login());
366        }
367
368        for profile in ["normal", "vip"] {
369            if let Some(body) = local_probe_flow_step_body(profile, "remark-detail") {
370                let detail = serde_json::from_value::<ApiEnvelope<ElecRemarkDetail>>(body)?
371                    .into_payload()?;
372                assert!(detail.id > 0);
373            }
374        }
375        Ok(())
376    }
377}