Skip to main content

pickcat_api_collection/
topic.rs

1//! 主题与内容章节:主题列表/推荐/详情、楼层、发布主题/回帖、投稿审核。
2//!
3//! 参见 <https://pickcat-docs.xiaole6324.fun/topic.html>。
4
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::dto::topic::{
9    CreatePostResponseDTO, CreateReplyDTO, CreateTopicDTO, PostSubmissionDTO, TopicDetailDTO,
10    TopicListItemDTO, TopicPostDTO, TopicRecommendationsDTO,
11};
12use crate::dto::user::CursorListDTO;
13use crate::{Error, PickcatAccound};
14
15/// 主题与内容接口,来自文档的「主题与内容」章节
16/// (<https://pickcat-docs.xiaole6324.fun/topic.html>)。
17pub trait TopicBehavior {
18    /// `GET /api/v1/topics` —— 公开。`tags` 中的每个标签都会作为独立的
19    /// `tag` 参数重复传入;`tag_mode` 通常为 `all`。
20    fn list_topics(
21        &self,
22        limit: Option<u32>,
23        tags: &[String],
24        tag_mode: Option<&str>,
25        cursor: Option<&str>,
26    ) -> impl std::future::Future<Output = Result<CursorListDTO<TopicListItemDTO>, Error>> + Send;
27
28    /// `GET /api/v1/topic-recommendations` —— 公开。
29    fn get_topic_recommendations(
30        &self,
31        sort: Option<&str>,
32        limit: Option<u32>,
33        cursor: Option<&str>,
34    ) -> impl std::future::Future<Output = Result<TopicRecommendationsDTO, Error>> + Send;
35
36    /// `GET /api/v1/topics/{topicId}` —— 公开。
37    fn get_topic(
38        &self,
39        topic_id: &str,
40    ) -> impl std::future::Future<Output = Result<TopicDetailDTO, Error>> + Send;
41
42    /// `GET /api/v1/topics/{topicId}/posts` —— 公开,游标分页。
43    fn get_topic_posts(
44        &self,
45        topic_id: &str,
46        limit: Option<u32>,
47        sort: Option<&str>,
48        cursor: Option<&str>,
49    ) -> impl std::future::Future<Output = Result<CursorListDTO<TopicPostDTO>, Error>> + Send;
50
51    /// `POST /api/v1/posts` —— 需要会话,发布主题首楼。
52    ///
53    /// `idempotency_key` 会作为必填的 `Idempotency-Key` 请求头发送;可用
54    /// [`generate_idempotency_key`] 生成。成功返回 `202`(异步审核)。
55    fn create_topic(
56        &self,
57        idempotency_key: &str,
58        title: &str,
59        markdown: &str,
60        kind: &str,
61        tag_ids: Vec<String>,
62    ) -> impl std::future::Future<Output = Result<CreatePostResponseDTO, Error>> + Send;
63
64    /// `POST /api/v1/posts` —— 需要会话,发布回帖(可楼中楼)。
65    fn create_reply(
66        &self,
67        idempotency_key: &str,
68        topic_id: &str,
69        markdown: &str,
70        reply_to_post_number: Option<u64>,
71    ) -> impl std::future::Future<Output = Result<CreatePostResponseDTO, Error>> + Send;
72
73    /// `GET /api/v1/post-submissions` —— 需要会话,投稿审核列表。
74    fn list_post_submissions(
75        &self,
76        content_role: Option<&str>,
77        status: Option<&str>,
78        topic_id: Option<&str>,
79        limit: Option<u32>,
80        cursor: Option<&str>,
81    ) -> impl std::future::Future<Output = Result<CursorListDTO<PostSubmissionDTO>, Error>> + Send;
82
83    /// `GET /api/v1/post-submissions/{submissionId}` —— 需要会话。
84    fn get_post_submission(
85        &self,
86        submission_id: &str,
87    ) -> impl std::future::Future<Output = Result<PostSubmissionDTO, Error>> + Send;
88}
89
90/// 生成一个 UUID v4 形式的字符串,用作 `Idempotency-Key`。
91///
92/// 只用时间戳 + 进程内计数器,不做密码学随机;仅用于保证请求幂等。
93pub fn generate_idempotency_key() -> String {
94    static COUNTER: AtomicU64 = AtomicU64::new(0);
95
96    let nanos = SystemTime::now()
97        .duration_since(UNIX_EPOCH)
98        .map(|d| d.as_nanos())
99        .unwrap_or(0);
100    let count = u128::from(COUNTER.fetch_add(1, Ordering::Relaxed));
101    let value = nanos ^ (count << 64);
102
103    let mut hex: Vec<char> = format!("{value:032x}").chars().collect();
104    hex[12] = '4'; // version 4
105    hex[16] = '8'; // variant
106    let hex: String = hex.into_iter().collect();
107
108    format!(
109        "{}-{}-{}-{}-{}",
110        &hex[0..8],
111        &hex[8..12],
112        &hex[12..16],
113        &hex[16..20],
114        &hex[20..32]
115    )
116}
117
118/// 只在有值时追加查询参数。
119fn push_query(params: &mut Vec<(&str, String)>, key: &'static str, value: Option<String>) {
120    if let Some(value) = value {
121        params.push((key, value));
122    }
123}
124
125impl TopicBehavior for PickcatAccound {
126    async fn list_topics(
127        &self,
128        limit: Option<u32>,
129        tags: &[String],
130        tag_mode: Option<&str>,
131        cursor: Option<&str>,
132    ) -> Result<CursorListDTO<TopicListItemDTO>, Error> {
133        let mut params: Vec<(&str, String)> = Vec::new();
134        push_query(&mut params, "limit", limit.map(|v| v.to_string()));
135        for tag in tags {
136            params.push(("tag", tag.clone()));
137        }
138        push_query(&mut params, "tagMode", tag_mode.map(str::to_string));
139        push_query(&mut params, "cursor", cursor.map(str::to_string));
140
141        let mut request = self.client.get(format!("{}/api/v1/topics", self.base_url));
142        if !params.is_empty() {
143            request = request.query(&params);
144        }
145        Ok(request
146            .send()
147            .await?
148            .json::<CursorListDTO<TopicListItemDTO>>()
149            .await?)
150    }
151
152    async fn get_topic_recommendations(
153        &self,
154        sort: Option<&str>,
155        limit: Option<u32>,
156        cursor: Option<&str>,
157    ) -> Result<TopicRecommendationsDTO, Error> {
158        let mut params: Vec<(&str, String)> = Vec::new();
159        push_query(&mut params, "sort", sort.map(str::to_string));
160        push_query(&mut params, "limit", limit.map(|v| v.to_string()));
161        push_query(&mut params, "cursor", cursor.map(str::to_string));
162
163        let mut request = self
164            .client
165            .get(format!("{}/api/v1/topic-recommendations", self.base_url));
166        if !params.is_empty() {
167            request = request.query(&params);
168        }
169        Ok(request
170            .send()
171            .await?
172            .json::<TopicRecommendationsDTO>()
173            .await?)
174    }
175
176    async fn get_topic(&self, topic_id: &str) -> Result<TopicDetailDTO, Error> {
177        Ok(self
178            .client
179            .get(format!("{}/api/v1/topics/{}", self.base_url, topic_id))
180            .send()
181            .await?
182            .json::<TopicDetailDTO>()
183            .await?)
184    }
185
186    async fn get_topic_posts(
187        &self,
188        topic_id: &str,
189        limit: Option<u32>,
190        sort: Option<&str>,
191        cursor: Option<&str>,
192    ) -> Result<CursorListDTO<TopicPostDTO>, Error> {
193        let mut params: Vec<(&str, String)> = Vec::new();
194        push_query(&mut params, "limit", limit.map(|v| v.to_string()));
195        push_query(&mut params, "sort", sort.map(str::to_string));
196        push_query(&mut params, "cursor", cursor.map(str::to_string));
197
198        let mut request = self.client.get(format!(
199            "{}/api/v1/topics/{}/posts",
200            self.base_url, topic_id
201        ));
202        if !params.is_empty() {
203            request = request.query(&params);
204        }
205        Ok(request
206            .send()
207            .await?
208            .json::<CursorListDTO<TopicPostDTO>>()
209            .await?)
210    }
211
212    async fn create_topic(
213        &self,
214        idempotency_key: &str,
215        title: &str,
216        markdown: &str,
217        kind: &str,
218        tag_ids: Vec<String>,
219    ) -> Result<CreatePostResponseDTO, Error> {
220        let dto = CreateTopicDTO {
221            title: title.to_string(),
222            markdown: markdown.to_string(),
223            kind: kind.to_string(),
224            tag_ids,
225        };
226        Ok(self
227            .client
228            .post(format!("{}/api/v1/posts", self.base_url))
229            .header("Idempotency-Key", idempotency_key)
230            .header("origin", self.base_url.clone())
231            .json(&dto)
232            .send()
233            .await?
234            .json::<CreatePostResponseDTO>()
235            .await?)
236    }
237
238    async fn create_reply(
239        &self,
240        idempotency_key: &str,
241        topic_id: &str,
242        markdown: &str,
243        reply_to_post_number: Option<u64>,
244    ) -> Result<CreatePostResponseDTO, Error> {
245        let dto = CreateReplyDTO {
246            topic_id: topic_id.to_string(),
247            markdown: markdown.to_string(),
248            reply_to_post_number,
249        };
250        Ok(self
251            .client
252            .post(format!("{}/api/v1/posts", self.base_url))
253            .header("Idempotency-Key", idempotency_key)
254            .header("origin", self.base_url.clone())
255            .json(&dto)
256            .send()
257            .await?
258            .json::<CreatePostResponseDTO>()
259            .await?)
260    }
261
262    async fn list_post_submissions(
263        &self,
264        content_role: Option<&str>,
265        status: Option<&str>,
266        topic_id: Option<&str>,
267        limit: Option<u32>,
268        cursor: Option<&str>,
269    ) -> Result<CursorListDTO<PostSubmissionDTO>, Error> {
270        let mut params: Vec<(&str, String)> = Vec::new();
271        push_query(&mut params, "contentRole", content_role.map(str::to_string));
272        push_query(&mut params, "status", status.map(str::to_string));
273        push_query(&mut params, "topicId", topic_id.map(str::to_string));
274        push_query(&mut params, "limit", limit.map(|v| v.to_string()));
275        push_query(&mut params, "cursor", cursor.map(str::to_string));
276
277        let mut request = self
278            .client
279            .get(format!("{}/api/v1/post-submissions", self.base_url));
280        if !params.is_empty() {
281            request = request.query(&params);
282        }
283        Ok(request
284            .send()
285            .await?
286            .json::<CursorListDTO<PostSubmissionDTO>>()
287            .await?)
288    }
289
290    async fn get_post_submission(&self, submission_id: &str) -> Result<PostSubmissionDTO, Error> {
291        Ok(self
292            .client
293            .get(format!(
294                "{}/api/v1/post-submissions/{}",
295                self.base_url, submission_id
296            ))
297            .send()
298            .await?
299            .json::<PostSubmissionDTO>()
300            .await?)
301    }
302}