Skip to main content

tdm_server_rust/repository/
member_repo.rs

1//! 组员数据访问层 (Member Repository)
2//!
3//! 封装 `membertb`、`memberpost`、`post` 等表的查询。
4
5use crate::entity::member::{InvitationCode, Member, MemberCache, MemberEpisode, Post};
6use chrono::{DateTime, Utc};
7use sqlx::{MySql, Pool, Row};
8use std::collections::HashMap;
9
10/// 组员仓储
11pub struct MemberRepository {
12    /// 连接池
13    pool: Pool<MySql>,
14}
15
16impl MemberRepository {
17    /// 构造仓储
18    pub fn new(pool: Pool<MySql>) -> Self {
19        Self { pool }
20    }
21
22    /// 按 ID 查询组员(含岗位列表,单 SQL JOIN)。
23    ///
24    /// # 返回值
25    ///
26    /// 返回包含岗位 ID 列表的 [`Member`]。
27    ///
28    /// # Errors
29    ///
30    /// - `AppError::business("组员不存在喵")` — ID 不存在
31    /// - `AppError::Database` — 查询失败
32    #[tracing::instrument(skip_all, level = "debug")]
33    pub async fn get_by_id(&self, id: i32) -> crate::error::ApiResult<Member> {
34        self.get_auth_snapshot(id).await
35    }
36
37    /// 鉴权用组员快照(单 SQL,含岗位 ID)
38    #[tracing::instrument(skip_all, level = "debug")]
39    pub async fn get_auth_snapshot(&self, id: i32) -> crate::error::ApiResult<Member> {
40        let row = sqlx::query(
41            "SELECT m.Id, m.username, m.intern, m.email, m.registrationTime, m.lastSubmitTime, \
42             GROUP_CONCAT(p.post ORDER BY p.post) AS postIdList \
43             FROM membertb m \
44             LEFT JOIN memberpost mp ON m.Id = mp.memberId \
45             LEFT JOIN post p ON p.post = mp.postId \
46             WHERE m.Id = ? \
47             GROUP BY m.Id, m.username, m.intern, m.email, m.registrationTime, m.lastSubmitTime",
48        )
49        .bind(id)
50        .fetch_optional(&self.pool)
51        .await?
52        .ok_or_else(|| crate::error::AppError::business("组员不存在喵"))?;
53        let mut m = row_to_member(&row);
54        m.post_ids = parse_post_id_list(row.try_get("postIdList").ok());
55        m.posts = m.post_ids.iter().map(|p| Post { post: *p }).collect();
56        Ok(m)
57    }
58
59    /// 登录查询:按用户名或邮箱查找组员及 bcrypt 密码哈希。
60    ///
61    /// # 返回值
62    ///
63    /// - `Ok(Some((member, pwd_hash)))` — 找到匹配组员
64    /// - `Ok(None)` — 用户名不存在
65    ///
66    /// # Errors
67    ///
68    /// - `AppError::Database` — 查询失败
69    #[tracing::instrument(skip_all, level = "debug")]
70    pub async fn find_for_login(
71        &self,
72        username: &str,
73    ) -> crate::error::ApiResult<Option<(Member, String)>> {
74        let row = sqlx::query(
75            "SELECT Id, username, password, intern, email, registrationTime,\
76             lastSubmitTime FROM membertb WHERE username = ? OR email = ?",
77        )
78        .bind(username)
79        .bind(username)
80        .fetch_optional(&self.pool)
81        .await?;
82        Ok(row.map(|r| {
83            let pwd: String = r.get("password");
84            (row_to_member(&r), pwd)
85        }))
86    }
87
88    /// 分页查询组员(先分页取 id,再批量 enrich 岗位/驻站数)
89    #[tracing::instrument(skip_all, level = "debug")]
90    pub async fn page_list(
91        &self,
92        username: Option<&str>,
93        post: Option<i16>,
94        intern: Option<i16>,
95        email: Option<&str>,
96        page: i32,
97        page_size: i32,
98    ) -> crate::error::ApiResult<(i64, Vec<Member>)> {
99        let mut filter_sql = String::new();
100        append_list_filters(&mut filter_sql, username, post, intern, email);
101
102        let join_sql = " FROM membertb m INNER JOIN memberpost mp ON mp.memberId = m.Id";
103        let where_sql = format!(" WHERE 1=1{filter_sql}");
104        let count_sql = format!("SELECT COUNT(DISTINCT m.Id) AS cnt{join_sql}{where_sql}");
105
106        let page = page.max(1);
107        let page_size = page_size.max(1);
108        let offset = (page - 1) * page_size;
109        let page_sql = format!(
110            "SELECT m.Id, m.username, m.intern, m.registrationTime, m.lastSubmitTime, m.email, \
111             COUNT(DISTINCT ms.Id) AS stationCount, \
112             GROUP_CONCAT(DISTINCT p.post ORDER BY p.post) AS postIdList \
113             {join_sql} \
114             LEFT JOIN post p ON p.post = mp.postId \
115             LEFT JOIN mangastationed ms ON ms.memberId = m.Id \
116             {where_sql} \
117             GROUP BY m.Id, m.username, m.intern, m.registrationTime, m.lastSubmitTime, m.email \
118             ORDER BY m.lastSubmitTime DESC, m.Id LIMIT ? OFFSET ?"
119        );
120
121        let pool_count = self.pool.clone();
122        let pool_page = self.pool.clone();
123        let mut count_q = sqlx::query(&count_sql);
124        count_q = bind_list_filters(count_q, username, post, intern, email);
125        let mut page_q = sqlx::query(&page_sql);
126        page_q = bind_list_filters(page_q, username, post, intern, email);
127        page_q = page_q.bind(page_size).bind(offset);
128
129        let (count_row, page_rows) =
130            tokio::try_join!(count_q.fetch_one(&pool_count), page_q.fetch_all(&pool_page),)?;
131        let total: i64 = count_row.get("cnt");
132        if page_rows.is_empty() {
133            return Ok((total, Vec::new()));
134        }
135
136        let mut members = Vec::with_capacity(page_rows.len());
137        for row in page_rows {
138            let mut m = row_to_member(&row);
139            if let Ok(c) = row.try_get::<i64, _>("stationCount") {
140                m.station_count = Some(c as i32);
141            }
142            m.post_ids = parse_post_id_list(row.try_get("postIdList").ok());
143            m.posts = m.post_ids.iter().map(|p| Post { post: *p }).collect();
144            members.push(m);
145        }
146        Ok((total, members))
147    }
148
149    /// 全量组员缓存列表(单 SQL 聚合岗位,对齐 Java MPJ join)
150    #[tracing::instrument(skip_all, level = "debug")]
151    pub async fn all_cache(&self) -> crate::error::ApiResult<Vec<MemberCache>> {
152        let rows = sqlx::query(
153            "SELECT m.Id, m.username, m.intern, m.email, \
154             GROUP_CONCAT(DISTINCT p.post ORDER BY p.post) AS postIdList \
155             FROM membertb m \
156             LEFT JOIN memberpost mp ON m.Id = mp.memberId \
157             LEFT JOIN post p ON p.post = mp.postId \
158             GROUP BY m.Id, m.username, m.intern, m.email \
159             ORDER BY m.Id",
160        )
161        .fetch_all(&self.pool)
162        .await?;
163        Ok(rows
164            .into_iter()
165            .map(|row| MemberCache {
166                id: row.get("Id"),
167                username: row.try_get("username").ok(),
168                intern: row.get("intern"),
169                email: row.try_get("email").ok(),
170                posts: parse_post_id_list(row.try_get("postIdList").ok()),
171            })
172            .collect())
173    }
174
175    /// 删除组员
176    #[tracing::instrument(skip_all, level = "debug")]
177    pub async fn delete_members(&self, ids: &[i32]) -> crate::error::ApiResult<()> {
178        if ids.is_empty() {
179            return Ok(());
180        }
181        let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
182        let related = sqlx::query(&format!(
183            "SELECT Id FROM mangaepisodetb WHERE providerId IN ({p}) OR translatorId IN ({p}) \
184             OR proofreaderId IN ({p}) OR lettererId IN ({p}) OR reviewerId IN ({p}) LIMIT 1",
185            p = placeholders
186        ))
187        .fetch_optional(&self.pool)
188        .await?;
189        if related.is_some() {
190            return Err(crate::error::AppError::business("该组员已经绑定漫画了喵!"));
191        }
192        for id in ids {
193            sqlx::query("DELETE FROM memberpost WHERE memberId = ?")
194                .bind(id)
195                .execute(&self.pool)
196                .await?;
197            sqlx::query("DELETE FROM membertb WHERE Id = ?")
198                .bind(id)
199                .execute(&self.pool)
200                .await?;
201        }
202        Ok(())
203    }
204
205    /// 新增组员
206    #[tracing::instrument(skip_all, level = "debug")]
207    pub async fn insert_member(&self, m: &Member) -> crate::error::ApiResult<i32> {
208        let r = sqlx::query(
209            "INSERT INTO membertb(username, password, intern, email, registrationTime) VALUES (?, ?, ?, ?, NOW())",
210        )
211        .bind(&m.username)
212        .bind(&m.password)
213        .bind(m.intern)
214        .bind(&m.email)
215        .execute(&self.pool)
216        .await?;
217        Ok(r.last_insert_id() as i32)
218    }
219
220    /// 更新组员
221    #[tracing::instrument(skip_all, level = "debug")]
222    pub async fn update_member(&self, m: &Member) -> crate::error::ApiResult<()> {
223        sqlx::query(
224            "UPDATE membertb SET username = COALESCE(?, username), password = COALESCE(?, password), \
225             intern = COALESCE(?, intern), email = COALESCE(?, email) WHERE Id = ?",
226        )
227        .bind(&m.username)
228        .bind(&m.password)
229        .bind(m.intern)
230        .bind(&m.email)
231        .bind(m.id)
232        .execute(&self.pool)
233        .await?;
234        Ok(())
235    }
236
237    /// 更新岗位
238    #[tracing::instrument(skip_all, level = "debug")]
239    pub async fn replace_posts(
240        &self,
241        member_id: i32,
242        post_ids: &[i32],
243    ) -> crate::error::ApiResult<()> {
244        sqlx::query("DELETE FROM memberpost WHERE memberId = ?")
245            .bind(member_id)
246            .execute(&self.pool)
247            .await?;
248        for pid in post_ids {
249            sqlx::query("INSERT INTO memberpost(memberId, postId) VALUES (?, ?)")
250                .bind(member_id)
251                .bind(pid)
252                .execute(&self.pool)
253                .await?;
254        }
255        Ok(())
256    }
257
258    /// 改密
259    #[tracing::instrument(skip_all, level = "debug")]
260    pub async fn update_password(&self, id: i32, new_pwd: &str) -> crate::error::ApiResult<()> {
261        sqlx::query("UPDATE membertb SET password = ? WHERE Id = ?")
262            .bind(new_pwd)
263            .bind(id)
264            .execute(&self.pool)
265            .await?;
266        Ok(())
267    }
268
269    /// 取密码哈希
270    #[tracing::instrument(skip_all, level = "debug")]
271    pub async fn get_password(&self, id: i32) -> crate::error::ApiResult<String> {
272        let row = sqlx::query("SELECT password FROM membertb WHERE Id = ?")
273            .bind(id)
274            .fetch_one(&self.pool)
275            .await?;
276        Ok(row.get("password"))
277    }
278
279    /// 用户名重复检查
280    #[tracing::instrument(skip_all, level = "debug")]
281    pub async fn exists_username(&self, username: &str) -> crate::error::ApiResult<bool> {
282        let row = sqlx::query("SELECT username FROM membertb WHERE username = ?")
283            .bind(username)
284            .fetch_optional(&self.pool)
285            .await?;
286        Ok(row.is_some())
287    }
288
289    /// 邮箱重复检查
290    #[tracing::instrument(skip_all, level = "debug")]
291    pub async fn exists_email(&self, email: &str) -> crate::error::ApiResult<bool> {
292        let row = sqlx::query("SELECT email FROM membertb WHERE email = ?")
293            .bind(email)
294            .fetch_optional(&self.pool)
295            .await?;
296        Ok(row.is_some())
297    }
298
299    /// 邀请码有效
300    #[tracing::instrument(skip_all, level = "debug")]
301    pub async fn is_valid_invitation(&self, code: i32) -> crate::error::ApiResult<bool> {
302        let row = sqlx::query("SELECT code FROM invitationcode WHERE code = ?")
303            .bind(code)
304            .fetch_optional(&self.pool)
305            .await?;
306        Ok(row.is_some())
307    }
308
309    /// 邀请码列表
310    #[tracing::instrument(skip_all, level = "debug")]
311    pub async fn list_invitation_codes(&self) -> crate::error::ApiResult<Vec<InvitationCode>> {
312        let rows = sqlx::query("SELECT Id, code FROM invitationcode")
313            .fetch_all(&self.pool)
314            .await?;
315        Ok(rows
316            .into_iter()
317            .map(|r| InvitationCode {
318                id: Some(r.get("Id")),
319                code: r.get("code"),
320            })
321            .collect())
322    }
323
324    /// 删除邀请码
325    #[tracing::instrument(skip_all, level = "debug")]
326    pub async fn delete_invitation(&self, id: i32) -> crate::error::ApiResult<()> {
327        sqlx::query("DELETE FROM invitationcode WHERE Id = ?")
328            .bind(id)
329            .execute(&self.pool)
330            .await?;
331        Ok(())
332    }
333
334    /// 新增邀请码
335    #[tracing::instrument(skip_all, level = "debug")]
336    pub async fn add_invitation(&self, code: i32) -> crate::error::ApiResult<()> {
337        sqlx::query("INSERT INTO invitationcode(code) VALUES (?)")
338            .bind(code)
339            .execute(&self.pool)
340            .await?;
341        Ok(())
342    }
343
344    /// 组员话数分页(SQL COUNT + LIMIT)
345    #[tracing::instrument(skip_all, level = "debug")]
346    pub async fn page_member_episodes(
347        &self,
348        member_id: i32,
349        page: i32,
350        page_size: i32,
351    ) -> crate::error::ApiResult<(i64, Vec<crate::entity::member::MemberEpisodeVo>)> {
352        use crate::repository::episode_mapper::{
353            row_to_member_episode, EPISODE_WORKFLOW_FROM, EPISODE_WORKFLOW_SELECT,
354        };
355        let where_sql = "WHERE me.providerId = ? OR me.translatorId = ? OR me.proofreaderId = ? \
356             OR me.lettererId = ? OR me.timerId = ? OR me.reviewerId = ?";
357        let count_sql = format!("SELECT COUNT(*) AS cnt {EPISODE_WORKFLOW_FROM} {where_sql}");
358        let page = page.max(1);
359        let page_size = page_size.max(1);
360        let offset = (page - 1) * page_size;
361        let data_sql = format!(
362            "SELECT {EPISODE_WORKFLOW_SELECT}, m.mangaTranName AS mangaName, m.category \
363             {EPISODE_WORKFLOW_FROM} \
364             LEFT JOIN mangatb m ON m.Id = me.mangaId \
365             {where_sql} ORDER BY me.Id DESC LIMIT ? OFFSET ?"
366        );
367
368        let pool_count = self.pool.clone();
369        let pool_data = self.pool.clone();
370        let mut count_q = sqlx::query(&count_sql);
371        let mut data_q = sqlx::query(&data_sql);
372        for _ in 0..6 {
373            count_q = count_q.bind(member_id);
374            data_q = data_q.bind(member_id);
375        }
376        data_q = data_q.bind(page_size).bind(offset);
377        let (count_row, rows) =
378            tokio::try_join!(count_q.fetch_one(&pool_count), data_q.fetch_all(&pool_data),)?;
379        let total: i64 = count_row.get("cnt");
380        Ok((total, rows.iter().map(row_to_member_episode).collect()))
381    }
382
383    /// 组员话数列表(全量,仅供兼容)
384    #[tracing::instrument(skip_all, level = "debug")]
385    pub async fn list_member_episodes(
386        &self,
387        member_id: i32,
388    ) -> crate::error::ApiResult<Vec<crate::entity::member::MemberEpisodeVo>> {
389        use crate::repository::episode_mapper::{
390            row_to_member_episode, EPISODE_WORKFLOW_FROM, EPISODE_WORKFLOW_SELECT,
391        };
392        let sql = format!(
393            "SELECT {EPISODE_WORKFLOW_SELECT}, m.mangaTranName AS mangaName, m.category \
394             {EPISODE_WORKFLOW_FROM} \
395             LEFT JOIN mangatb m ON m.Id = me.mangaId \
396             WHERE me.providerId = ? OR me.translatorId = ? OR me.proofreaderId = ? \
397             OR me.lettererId = ? OR me.timerId = ? OR me.reviewerId = ? \
398             ORDER BY me.Id DESC"
399        );
400        let rows = sqlx::query(&sql)
401            .bind(member_id)
402            .bind(member_id)
403            .bind(member_id)
404            .bind(member_id)
405            .bind(member_id)
406            .bind(member_id)
407            .fetch_all(&self.pool)
408            .await?;
409        Ok(rows.iter().map(row_to_member_episode).collect())
410    }
411
412    /// 接稿
413    #[tracing::instrument(skip_all, level = "debug")]
414    pub async fn take_episode(&self, ep: &MemberEpisode) -> crate::error::ApiResult<()> {
415        let col = post_column(&ep.my_name)?;
416        let sql = format!("UPDATE mangaepisodetb SET {col} = ?, updateTime = NOW() WHERE Id = ?");
417        sqlx::query(&sql)
418            .bind(ep.member_id)
419            .bind(ep.episode_id)
420            .execute(&self.pool)
421            .await?;
422        let detail_prefix = post_detail_prefix(&ep.my_name)?;
423        let detail_col = format!("{detail_prefix}SetupTime");
424        let detail_sql =
425            format!("UPDATE mangaepisodedetail SET {detail_col} = NOW() WHERE episodeId = ?");
426        sqlx::query(&detail_sql)
427            .bind(ep.episode_id)
428            .execute(&self.pool)
429            .await?;
430        sqlx::query(
431            "UPDATE mangatb SET updateTime = NOW() WHERE Id = (SELECT mangaId FROM mangaepisodetb WHERE Id = ?)",
432        )
433        .bind(ep.episode_id)
434        .execute(&self.pool)
435        .await?;
436        Ok(())
437    }
438
439    /// 交稿
440    #[tracing::instrument(skip_all, level = "debug")]
441    pub async fn submit_episode(&self, ep: &MemberEpisode) -> crate::error::ApiResult<()> {
442        let detail_prefix = post_detail_prefix(&ep.my_name)?;
443        let detail_col = format!("{detail_prefix}UpdateTime");
444        let detail_sql =
445            format!("UPDATE mangaepisodedetail SET {detail_col} = NOW() WHERE episodeId = ?");
446        sqlx::query(&detail_sql)
447            .bind(ep.episode_id)
448            .execute(&self.pool)
449            .await?;
450        sqlx::query("UPDATE membertb SET lastSubmitTime = NOW() WHERE Id = ?")
451            .bind(ep.member_id)
452            .execute(&self.pool)
453            .await?;
454        sqlx::query("UPDATE mangaepisodetb SET updateTime = NOW() WHERE Id = ?")
455            .bind(ep.episode_id)
456            .execute(&self.pool)
457            .await?;
458        sqlx::query(
459            "UPDATE mangatb SET updateTime = NOW() WHERE Id = (SELECT mangaId FROM mangaepisodetb WHERE Id = ?)",
460        )
461        .bind(ep.episode_id)
462        .execute(&self.pool)
463        .await?;
464        sqlx::query("INSERT INTO workreminder(episodeId, memberId, myName) VALUES (?, ?, ?)")
465            .bind(ep.episode_id)
466            .bind(ep.member_id)
467            .bind(&ep.my_name)
468            .execute(&self.pool)
469            .await?;
470        Ok(())
471    }
472
473    /// 三月未交稿组员(对齐 Java MemberMapper.getMemberReminderRss)
474    #[tracing::instrument(skip_all, level = "debug")]
475    pub async fn get_member_reminder_rss(
476        &self,
477    ) -> crate::error::ApiResult<Vec<crate::entity::rss::MemberReminderRssRow>> {
478        let rows = sqlx::query(
479            "SELECT Id, username, intern, email, lastSubmitTime FROM membertb \
480             WHERE lastSubmitTime <= DATE_SUB(NOW(), INTERVAL 3 MONTH) \
481             ORDER BY lastSubmitTime",
482        )
483        .fetch_all(&self.pool)
484        .await?;
485        Ok(rows
486            .iter()
487            .map(|r| crate::entity::rss::MemberReminderRssRow {
488                id: r.get("Id"),
489                username: r.try_get("username").ok(),
490                intern: r.get("intern"),
491                email: r.try_get("email").ok(),
492                last_submit_time: r.try_get("lastSubmitTime").ok(),
493            })
494            .collect())
495    }
496
497    /// 查询组员岗位 ID 列表
498    #[tracing::instrument(skip_all, level = "debug")]
499    pub async fn get_post_ids_by_member(
500        &self,
501        member_id: i32,
502    ) -> crate::error::ApiResult<Vec<i32>> {
503        let rows = sqlx::query("SELECT postId FROM memberpost WHERE memberId = ?")
504            .bind(member_id)
505            .fetch_all(&self.pool)
506            .await?;
507        Ok(rows.iter().map(|r| r.get("postId")).collect())
508    }
509
510    /// 常驻漫画岗位分组
511    #[tracing::instrument(skip_all, level = "debug")]
512    pub async fn stationed_manga_post_groups(
513        &self,
514        member_id: i32,
515    ) -> crate::error::ApiResult<HashMap<i32, Vec<i32>>> {
516        let rows = sqlx::query(
517            "SELECT ms.mangaId, ms.post FROM mangastationed ms \
518             JOIN mangatb m ON m.Id = ms.mangaId WHERE ms.memberId = ? AND ms.status = 1",
519        )
520        .bind(member_id)
521        .fetch_all(&self.pool)
522        .await?;
523        let mut map: HashMap<i32, Vec<i32>> = HashMap::new();
524        for r in rows {
525            let manga_id: i32 = r.get("mangaId");
526            let post: i32 = r.get("post");
527            map.entry(manga_id).or_default().push(post);
528        }
529        Ok(map)
530    }
531
532    /// 批量查询组员岗位 ID
533    #[tracing::instrument(skip_all, level = "debug")]
534    pub async fn get_posts_map(
535        &self,
536        member_ids: &[i32],
537    ) -> crate::error::ApiResult<std::collections::HashMap<i32, Vec<i32>>> {
538        self.get_post_ids_map(member_ids).await
539    }
540
541    /// 批量查询组员岗位 ID
542    #[tracing::instrument(skip_all, level = "debug")]
543    async fn get_post_ids_map(
544        &self,
545        member_ids: &[i32],
546    ) -> crate::error::ApiResult<HashMap<i32, Vec<i32>>> {
547        if member_ids.is_empty() {
548            return Ok(HashMap::new());
549        }
550        let placeholders = member_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
551        let sql = format!(
552            "SELECT memberpost.memberId AS memberId, p.post AS post \
553             FROM post p JOIN memberpost ON p.post = memberpost.postId \
554             WHERE memberpost.memberId IN ({placeholders})"
555        );
556        let mut q = sqlx::query(&sql);
557        for id in member_ids {
558            q = q.bind(id);
559        }
560        let rows = q.fetch_all(&self.pool).await?;
561        let mut map: HashMap<i32, Vec<i32>> = HashMap::new();
562        for row in rows {
563            let member_id: i32 = row.get("memberId");
564            let post: i32 = row.get("post");
565            map.entry(member_id).or_default().push(post);
566        }
567        Ok(map)
568    }
569}
570
571/// 追加组员列表筛选条件
572fn append_list_filters(
573    sql: &mut String,
574    username: Option<&str>,
575    post: Option<i16>,
576    intern: Option<i16>,
577    email: Option<&str>,
578) {
579    if username.is_some() {
580        sql.push_str(" AND m.username LIKE CONCAT('%', ?, '%')");
581    }
582    if post.is_some() {
583        sql.push_str(" AND m.Id IN (SELECT memberId FROM memberpost WHERE postId = ?)");
584    }
585    if intern.is_some() {
586        sql.push_str(" AND m.intern = ?");
587    }
588    if email.is_some() {
589        sql.push_str(" AND m.email = ?");
590    }
591}
592
593/// 绑定组员列表筛选参数
594fn bind_list_filters<'q>(
595    mut q: sqlx::query::Query<'q, MySql, sqlx::mysql::MySqlArguments>,
596    username: Option<&'q str>,
597    post: Option<i16>,
598    intern: Option<i16>,
599    email: Option<&'q str>,
600) -> sqlx::query::Query<'q, MySql, sqlx::mysql::MySqlArguments> {
601    if let Some(u) = username {
602        q = q.bind(u);
603    }
604    if let Some(p) = post {
605        q = q.bind(p);
606    }
607    if let Some(i) = intern {
608        q = q.bind(i);
609    }
610    if let Some(e) = email {
611        q = q.bind(e);
612    }
613    q
614}
615
616/// 解析 GROUP_CONCAT 岗位 ID 列表
617fn parse_post_id_list(raw: Option<String>) -> Vec<i32> {
618    raw.map(|s| {
619        s.split(',')
620            .filter_map(|part| part.trim().parse::<i32>().ok())
621            .collect()
622    })
623    .unwrap_or_default()
624}
625
626fn row_to_member(row: &sqlx::mysql::MySqlRow) -> Member {
627    Member {
628        id: row.get("Id"),
629        username: row.try_get("username").ok(),
630        password: row.try_get("password").ok(),
631        intern: row.get("intern"),
632        email: row.try_get("email").ok(),
633        registration_time: row.try_get::<DateTime<Utc>, _>("registrationTime").ok(),
634        last_submit_time: row.try_get::<DateTime<Utc>, _>("lastSubmitTime").ok(),
635        posts: vec![],
636        post_ids: vec![],
637        station_count: None,
638        station_id: None,
639        post: None,
640        status: None,
641        manga_id: None,
642        manga_name: None,
643    }
644}
645
646fn post_column(name: &str) -> crate::error::ApiResult<&'static str> {
647    match name {
648        "translator" => Ok("translatorId"),
649        "proofreader" => Ok("proofreaderId"),
650        "letterer" => Ok("lettererId"),
651        "timer" => Ok("timerId"),
652        "reviewer" => Ok("reviewerId"),
653        "provider" => Ok("providerId"),
654        _ => Err(crate::error::AppError::business("未知岗位喵")),
655    }
656}
657
658/// mangaepisodedetail 时间字段前缀(lettererSetupTime / lettererUpdateTime)
659fn post_detail_prefix(name: &str) -> crate::error::ApiResult<&'static str> {
660    match name {
661        "translator" => Ok("translator"),
662        "proofreader" => Ok("proofreader"),
663        "letterer" => Ok("letterer"),
664        "timer" => Ok("timer"),
665        "reviewer" => Ok("reviewer"),
666        "provider" => Ok("provider"),
667        _ => Err(crate::error::AppError::business("未知岗位喵")),
668    }
669}