Skip to main content

tdm_server_rust/service/
member_service.rs

1//! 组员业务服务 (Member Service)
2//!
3//! 组员管理的核心业务逻辑:
4//! - 登录/注册/密码重置
5//! - 组员 CRUD(增删改查、列表、详情)
6//! - 岗位管理
7//! - 常驻漫画申请与审批
8//! - 邀请码管理
9//! - 话数接稿/交稿
10
11use crate::{
12    app::AppState,
13    cache::{
14        get_auth_snapshot_cached, get_list_page_json_cached, get_member_all_cached,
15        get_member_all_json_cached, get_member_episode_json_cached,
16        get_stationed_mangas_json_cached, get_workbench_overview_json_cached,
17        get_workbench_tasks_json_cached, load_stationed_mangas, member_episode_cache_key,
18        member_page_cache_key, member_workbench_overview_cache_key,
19        member_workbench_tasks_cache_key, on_episode_mutated, on_member_mutated,
20        set_list_page_json_cached, set_member_all_cached, set_member_episode_json_cached,
21        set_stationed_mangas_json_cached, set_workbench_overview_json_cached,
22        set_workbench_tasks_json_cached, stationed_mangas_cache_key,
23    },
24    common::PageBean,
25    entity::{
26        enums::{MemberInternEnum, PostEnum},
27        member::{
28            InvitationCode, Member, MemberAddRequest, MemberCache, MemberEpisode, MemberEpisodeVo,
29            MemberListVo, MemberLoginRequest, MemberSelfUpdateRequest, MemberUpdateRequest,
30            MemberWorkbenchOverview, MemberWorkbenchTaskGroup, Reg, ResetPassword, StationedManga,
31        },
32    },
33    error::{ApiResult, AppError},
34    repository::{
35        episode_repo::EpisodeRepository, member_repo::MemberRepository, member_workbench_assemble,
36    },
37    service::rss_service::{RssRefreshScope, RssService},
38    utils::{jwt::JwtUtil, page::slice_rows},
39};
40use bcrypt::{hash, verify, DEFAULT_COST};
41use std::sync::Arc;
42
43/// 组员服务
44///
45/// 提供组员账号生命周期管理的完整业务逻辑。
46/// 所有方法为关联函数,通过 `&AppState` 获取数据库连接和配置。
47pub struct MemberService;
48
49impl MemberService {
50    /// 验证凭证并签发 JWT Token。
51    ///
52    /// 查询组员 → bcrypt 密码比对 → 检查退组状态 → 签发 JWT。
53    ///
54    /// # 返回值
55    ///
56    /// - `Ok(Some(token))` — 登录成功,返回 JWT 字符串,有效期由 `jwt.expire_ms` 配置
57    /// - `Ok(None)` — 用户名不存在或密码不匹配
58    ///
59    /// # Errors
60    ///
61    /// - `AppError::LoginExpired("你已经退出了提灯喵猫娘化计划喵……")` — 组员 intern=4(已退组)
62    /// - `AppError::Database` — 数据库查询失败
63    /// - `AppError::Internal` — JWT 签发失败(密钥算法不可用)
64    #[tracing::instrument(skip_all, level = "debug")]
65    pub async fn login(state: &AppState, req: MemberLoginRequest) -> ApiResult<Option<String>> {
66        let repo = MemberRepository::new(state.db.clone());
67        let Some((member, pwd_hash)) = repo.find_for_login(&req.username).await? else {
68            return Ok(None);
69        };
70        if !verify(&req.password, &pwd_hash).unwrap_or(false) {
71            return Ok(None);
72        }
73        if MemberInternEnum::is_left(member.intern) {
74            return Err(AppError::login_expired("你已经退出了提灯喵猫娘化计划喵……"));
75        }
76        let util = JwtUtil::new(&state.config.jwt.sign_key, state.config.jwt.expire_ms);
77        let jwt = util
78            .generate(member.id)
79            .map_err(|e| AppError::Internal(e.to_string()))?;
80        Ok(Some(jwt))
81    }
82
83    /// 注册新组员
84    #[tracing::instrument(skip_all, level = "debug")]
85    pub async fn register_member(state: &AppState, reg: Reg) -> ApiResult<()> {
86        let repo = MemberRepository::new(state.db.clone());
87        if !repo.is_valid_invitation(reg.invitationcode).await? {
88            return Err(AppError::business("邀请码无效喵!"));
89        }
90        Self::check_duplicate(&repo, &reg.username, &reg.email).await?;
91        let mut member = Member {
92            username: Some(reg.username),
93            email: Some(reg.email),
94            password: Some(Self::encode_pwd(&reg.password)),
95            intern: 1,
96            ..Default::default()
97        };
98        Self::add_new_member(&repo, &mut member).await?;
99        on_member_mutated(state, &[member.id]).await;
100        Ok(())
101    }
102
103    /// 分页查询组员
104    #[tracing::instrument(skip_all, level = "debug")]
105    pub async fn page(
106        state: &AppState,
107        page: i32,
108        page_size: i32,
109        username: Option<String>,
110        post: Option<i16>,
111        intern: Option<i16>,
112        email: Option<String>,
113    ) -> ApiResult<PageBean<MemberListVo>> {
114        let repo = MemberRepository::new(state.db.clone());
115        let (total, rows) = repo
116            .page_list(
117                username.as_deref(),
118                post,
119                intern,
120                email.as_deref(),
121                page,
122                page_size,
123            )
124            .await?;
125        Ok(slice_rows(
126            rows.into_iter().map(MemberListVo::from).collect(),
127            total,
128        ))
129    }
130
131    /// 组员分页预序列化 JSON(短 TTL 缓存)
132    #[tracing::instrument(skip_all, level = "debug")]
133    pub async fn page_json(
134        state: &AppState,
135        page: i32,
136        page_size: i32,
137        username: Option<String>,
138        post: Option<i16>,
139        intern: Option<i16>,
140        email: Option<String>,
141    ) -> ApiResult<Arc<Vec<u8>>> {
142        let key = member_page_cache_key(
143            state,
144            page,
145            page_size,
146            username.as_deref(),
147            post,
148            intern,
149            email.as_deref(),
150        );
151        if let Some(json) = get_list_page_json_cached(state, &key).await {
152            return Ok(json);
153        }
154        let page_data = Self::page(state, page, page_size, username, post, intern, email).await?;
155        set_list_page_json_cached(state, key.clone(), page_data).await;
156        get_list_page_json_cached(state, &key)
157            .await
158            .ok_or_else(|| AppError::business("组员分页缓存写入失败"))
159    }
160
161    /// 全量组员缓存列表
162    #[tracing::instrument(skip_all, level = "debug")]
163    pub async fn get_member_list(state: &AppState) -> ApiResult<Vec<MemberCache>> {
164        if let Some(cached) = get_member_all_cached(state).await {
165            return Ok(cached);
166        }
167        let list = MemberRepository::new(state.db.clone()).all_cache().await?;
168        set_member_all_cached(state, list.clone()).await;
169        Ok(list)
170    }
171
172    /// 全量组员列表预序列化 JSON(缓存命中零拷贝)
173    #[tracing::instrument(skip_all, level = "debug")]
174    pub async fn get_member_list_json(state: &AppState) -> ApiResult<Arc<Vec<u8>>> {
175        if let Some(json) = get_member_all_json_cached(state).await {
176            return Ok(json);
177        }
178        Self::get_member_list(state).await?;
179        get_member_all_json_cached(state)
180            .await
181            .ok_or_else(|| AppError::business("members/all 缓存写入失败"))
182    }
183
184    /// 批量删除组员
185    #[tracing::instrument(skip_all, level = "debug")]
186    pub async fn delete(state: &AppState, ids: Vec<i32>) -> ApiResult<()> {
187        MemberRepository::new(state.db.clone())
188            .delete_members(&ids)
189            .await?;
190        on_member_mutated(state, &ids).await;
191        Ok(())
192    }
193
194    /// 新增组员
195    #[tracing::instrument(skip_all, level = "debug")]
196    pub async fn new_member(state: &AppState, req: MemberAddRequest) -> ApiResult<()> {
197        let repo = MemberRepository::new(state.db.clone());
198        Self::check_duplicate(&repo, &req.username, &req.email).await?;
199        let mut member = Member {
200            username: Some(req.username),
201            password: Some(Self::encode_pwd(&req.password)),
202            email: Some(req.email),
203            intern: req.intern,
204            ..Default::default()
205        };
206        Self::add_new_member(&repo, &mut member).await?;
207        let id = member.id;
208        repo.replace_posts(id, &req.post_ids).await?;
209        on_member_mutated(state, &[id]).await;
210        Ok(())
211    }
212
213    /// 按 ID 查询组员
214    #[tracing::instrument(skip_all, level = "debug")]
215    pub async fn get_member_by_id(state: &AppState, id: i32) -> ApiResult<Member> {
216        get_auth_snapshot_cached(state, id).await
217    }
218
219    /// 更新组员信息
220    #[tracing::instrument(skip_all, level = "debug")]
221    pub async fn update_member(state: &AppState, req: MemberUpdateRequest) -> ApiResult<()> {
222        let repo = MemberRepository::new(state.db.clone());
223        let mut member = repo.get_by_id(req.id).await?;
224        if let Some(u) = req.username {
225            member.username = Some(u);
226        }
227        if let Some(p) = req.password {
228            member.password = Some(Self::encode_pwd(&p));
229        }
230        if let Some(e) = req.email {
231            member.email = Some(e);
232        }
233        if let Some(a) = req.avatar_url {
234            member.avatar_url = normalize_avatar_url(a);
235        }
236        if let Some(i) = req.intern {
237            member.intern = i;
238        }
239        let posts_to_replace =
240            posts_for_member_update(&member.post_ids, req.post_ids, member.intern);
241        if let Some(post_ids) = posts_to_replace {
242            repo.update_member_and_posts(&member, &post_ids).await?;
243        } else {
244            repo.update_member(&member).await?;
245        }
246        on_member_mutated(state, &[req.id]).await;
247        Ok(())
248    }
249
250    /// 组员自助更新昵称与 QQ
251    #[tracing::instrument(skip_all, level = "debug")]
252    pub async fn update_member_self(
253        state: &AppState,
254        req: MemberSelfUpdateRequest,
255    ) -> ApiResult<()> {
256        let repo = MemberRepository::new(state.db.clone());
257        let mut member = repo.get_by_id(req.id).await?;
258        if let Some(u) = req.username {
259            member.username = Some(u);
260        }
261        if let Some(e) = req.email {
262            member.email = Some(e);
263        }
264        if let Some(a) = req.avatar_url {
265            member.avatar_url = normalize_avatar_url(a);
266        }
267        repo.update_member(&member).await?;
268        on_member_mutated(state, &[req.id]).await;
269        Ok(())
270    }
271
272    /// 查询组员常驻漫画(短 TTL 缓存)
273    #[tracing::instrument(skip_all, level = "debug")]
274    pub async fn find_stationed_mangas(
275        state: &AppState,
276        id: i32,
277    ) -> ApiResult<Vec<StationedManga>> {
278        Ok(load_stationed_mangas(state, id).await?)
279    }
280
281    /// 查询组员常驻漫画预序列化 JSON(短 TTL 缓存)
282    #[tracing::instrument(skip_all, level = "debug")]
283    pub async fn find_stationed_mangas_json(state: &AppState, id: i32) -> ApiResult<Arc<Vec<u8>>> {
284        let key = stationed_mangas_cache_key(id);
285        if let Some(json) = get_stationed_mangas_json_cached(state, &key).await {
286            return Ok(json);
287        }
288        let data = Self::find_stationed_mangas(state, id).await?;
289        set_stationed_mangas_json_cached(state, key.clone(), data).await;
290        get_stationed_mangas_json_cached(state, &key)
291            .await
292            .ok_or_else(|| AppError::business("stationedMangas 缓存写入失败"))
293    }
294
295    /// 修改密码
296    #[tracing::instrument(skip_all, level = "debug")]
297    pub async fn update_member_password(state: &AppState, reset: ResetPassword) -> ApiResult<()> {
298        let repo = MemberRepository::new(state.db.clone());
299        let old_hash = repo.get_password(reset.id).await?;
300        if !verify(&reset.old_pwd, &old_hash).unwrap_or(false) {
301            return Err(AppError::business("原密码不正确喵"));
302        }
303        repo.update_password(reset.id, &Self::encode_pwd(&reset.new_pwd))
304            .await?;
305        on_member_mutated(state, &[reset.id]).await;
306        Ok(())
307    }
308
309    /// 分页查询组员话数
310    #[tracing::instrument(skip_all, level = "debug")]
311    pub async fn page_episode(
312        state: &AppState,
313        page: i32,
314        page_size: i32,
315        id: i32,
316    ) -> ApiResult<PageBean<MemberEpisodeVo>> {
317        let (total, rows) = MemberRepository::new(state.db.clone())
318            .page_member_episodes(id, page, page_size)
319            .await?;
320        Ok(slice_rows(rows, total))
321    }
322
323    /// 组员话数分页预序列化 JSON(短 TTL 缓存)
324    #[tracing::instrument(skip_all, level = "debug")]
325    pub async fn page_episode_json(
326        state: &AppState,
327        page: i32,
328        page_size: i32,
329        id: i32,
330    ) -> ApiResult<Arc<Vec<u8>>> {
331        let key = member_episode_cache_key(id, page, page_size);
332        if let Some(json) = get_member_episode_json_cached(state, &key).await {
333            return Ok(json);
334        }
335        let page_data = Self::page_episode(state, page, page_size, id).await?;
336        set_member_episode_json_cached(state, key.clone(), page_data).await;
337        get_member_episode_json_cached(state, &key)
338            .await
339            .ok_or_else(|| AppError::business("memberepisode 缓存写入失败"))
340    }
341
342    /// 工作台总览(统计卡 + Tab 计数)
343    #[tracing::instrument(skip_all, level = "debug")]
344    pub async fn get_workbench_overview(
345        state: &AppState,
346        member_id: i32,
347    ) -> ApiResult<MemberWorkbenchOverview> {
348        let stationed = Self::find_stationed_mangas(state, member_id).await?;
349        MemberRepository::new(state.db.clone())
350            .get_workbench_overview(member_id, stationed.len() as i32)
351            .await
352    }
353
354    /// 工作台总览预序列化 JSON(短 TTL 缓存)
355    #[tracing::instrument(skip_all, level = "debug")]
356    pub async fn get_workbench_overview_json(
357        state: &AppState,
358        member_id: i32,
359    ) -> ApiResult<Arc<Vec<u8>>> {
360        let key = member_workbench_overview_cache_key(member_id);
361        if let Some(json) = get_workbench_overview_json_cached(state, &key).await {
362            return Ok(json);
363        }
364        let data = Self::get_workbench_overview(state, member_id).await?;
365        set_workbench_overview_json_cached(state, key.clone(), data).await;
366        get_workbench_overview_json_cached(state, &key)
367            .await
368            .ok_or_else(|| AppError::business("workbench overview 缓存写入失败"))
369    }
370
371    /// 工作台在做任务按漫画组分页(Tab + View + 关键词 + 最新接稿时间排序)
372    #[tracing::instrument(skip_all, level = "debug")]
373    pub async fn page_workbench_tasks(
374        state: &AppState,
375        member_id: i32,
376        tab: Option<&str>,
377        view: Option<&str>,
378        keyword: Option<&str>,
379        page: i32,
380        page_size: i32,
381    ) -> ApiResult<PageBean<MemberWorkbenchTaskGroup>> {
382        let tab = member_workbench_assemble::normalize_workbench_tab(tab);
383        let view = member_workbench_assemble::normalize_workbench_view(view);
384        let (total, rows) = MemberRepository::new(state.db.clone())
385            .page_workbench_tasks(member_id, tab, view, keyword, page, page_size)
386            .await?;
387        Ok(crate::utils::page::slice_rows(rows, total))
388    }
389
390    /// 工作台在做任务漫画组分页预序列化 JSON(短 TTL 缓存)
391    #[tracing::instrument(skip_all, level = "debug")]
392    pub async fn page_workbench_tasks_json(
393        state: &AppState,
394        member_id: i32,
395        tab: Option<&str>,
396        view: Option<&str>,
397        keyword: Option<&str>,
398        page: i32,
399        page_size: i32,
400    ) -> ApiResult<Arc<Vec<u8>>> {
401        let page = page.max(1);
402        let page_size = page_size.clamp(1, 100);
403        let tab = member_workbench_assemble::normalize_workbench_tab(tab);
404        let view = member_workbench_assemble::normalize_workbench_view(view);
405        let keyword = keyword.map(str::trim).filter(|v| !v.is_empty());
406        let key = member_workbench_tasks_cache_key(member_id, tab, view, keyword, page, page_size);
407        if let Some(json) = get_workbench_tasks_json_cached(state, &key).await {
408            return Ok(json);
409        }
410        let data = Self::page_workbench_tasks(
411            state,
412            member_id,
413            Some(tab),
414            Some(view),
415            keyword,
416            page,
417            page_size,
418        )
419        .await?;
420        set_workbench_tasks_json_cached(state, key.clone(), data).await;
421        get_workbench_tasks_json_cached(state, &key)
422            .await
423            .ok_or_else(|| AppError::business("workbench tasks 缓存写入失败"))
424    }
425
426    /// 接稿
427    ///
428    /// 鉴权规则(管理员即审稿岗位 post=4,可重新分派,跳过以下校验):
429    /// - 只能为本人接稿(`ep.member_id == 当前登录 ID`)
430    /// - 必须为正式组员(`intern == 0`)
431    /// - 必须持有对应岗位
432    /// - 岗位须空缺,防止覆盖他人接稿
433    #[tracing::instrument(skip_all, level = "debug")]
434    pub async fn take_episode(
435        state: &AppState,
436        ep: MemberEpisode,
437        operator: Option<Member>,
438    ) -> ApiResult<()> {
439        let is_admin = operator
440            .as_ref()
441            .is_some_and(Member::has_reviewer_permission);
442        if !is_admin {
443            let me = operator
444                .as_ref()
445                .ok_or_else(|| AppError::business("请先登录喵"))?;
446            if ep.member_id != me.id {
447                return Err(AppError::business("只能为自己接稿喵"));
448            }
449            if me.intern != MemberInternEnum::REGULAR {
450                return Err(AppError::business("实习 / 非正式组员暂不能接稿喵"));
451            }
452            let post_id = take_role_post_id(&ep.my_name)?;
453            if !me.post_ids.contains(&post_id) {
454                return Err(AppError::business("你没有该岗位权限,先去申请常驻喵"));
455            }
456        }
457        MemberRepository::new(state.db.clone())
458            .take_episode(&ep, !is_admin)
459            .await?;
460        let manga_id = EpisodeRepository::new(state.db.clone())
461            .get_manga_id_by_episode_id(ep.episode_id)
462            .await?
463            .ok_or_else(|| AppError::business("话数不存在喵"))?;
464        on_episode_mutated(state, manga_id, &[ep.member_id]).await;
465        RssService::refresh(state, RssRefreshScope::EpisodePipeline);
466        Ok(())
467    }
468
469    /// 交稿
470    #[tracing::instrument(skip_all, level = "debug")]
471    pub async fn submit_episode(state: &AppState, ep: MemberEpisode) -> ApiResult<()> {
472        let notify_next = MemberRepository::new(state.db.clone())
473            .submit_episode(&ep)
474            .await?;
475        let manga_id = EpisodeRepository::new(state.db.clone())
476            .get_manga_id_by_episode_id(ep.episode_id)
477            .await?
478            .ok_or_else(|| AppError::business("话数不存在喵"))?;
479        on_episode_mutated(state, manga_id, &[ep.member_id]).await;
480        if notify_next {
481            RssService::refresh(
482                state,
483                RssRefreshScope::WorkflowSubmit {
484                    post_name: ep.my_name.clone(),
485                },
486            );
487        }
488        Ok(())
489    }
490
491    /// 邀请码列表
492    #[tracing::instrument(skip_all, level = "debug")]
493    pub async fn get_invitation_codes(state: &AppState) -> ApiResult<Vec<InvitationCode>> {
494        MemberRepository::new(state.db.clone())
495            .list_invitation_codes()
496            .await
497    }
498
499    /// 删除邀请码
500    #[tracing::instrument(skip_all, level = "debug")]
501    pub async fn delete_invitation_code(state: &AppState, id: i32) -> ApiResult<()> {
502        MemberRepository::new(state.db.clone())
503            .delete_invitation(id)
504            .await
505    }
506
507    /// 新增邀请码
508    #[tracing::instrument(skip_all, level = "debug")]
509    pub async fn add_invitation_code(state: &AppState, code: InvitationCode) -> ApiResult<()> {
510        MemberRepository::new(state.db.clone())
511            .add_invitation(code.code)
512            .await
513    }
514
515    #[tracing::instrument(skip_all, level = "debug")]
516    async fn check_duplicate(
517        repo: &MemberRepository,
518        username: &str,
519        email: &str,
520    ) -> ApiResult<()> {
521        if repo.exists_username(username).await? {
522            return Err(AppError::unique("用户名"));
523        }
524        if repo.exists_email(email).await? {
525            return Err(AppError::unique("邮箱"));
526        }
527        Ok(())
528    }
529
530    #[tracing::instrument(skip_all, level = "debug")]
531    async fn add_new_member(repo: &MemberRepository, member: &mut Member) -> ApiResult<()> {
532        let id = repo.insert_member(member).await?;
533        member.id = id;
534        repo.replace_posts(id, &[5]).await?;
535        Ok(())
536    }
537
538    fn encode_pwd(pwd: &str) -> String {
539        hash(pwd, DEFAULT_COST).unwrap_or_else(|_| pwd.to_string())
540    }
541}
542
543/// 岗位名 → 岗位 ID(用于接稿岗位权限校验)。
544fn take_role_post_id(my_name: &str) -> ApiResult<i32> {
545    match my_name {
546        "provider" => Ok(PostEnum::PROVIDER),
547        "translator" => Ok(PostEnum::TRANSLATOR),
548        "proofreader" => Ok(PostEnum::PROOFREADER),
549        "letterer" => Ok(PostEnum::LETTERER),
550        "timer" => Ok(PostEnum::TIMER),
551        "reviewer" => Ok(PostEnum::REVIEWER),
552        _ => Err(AppError::business("未知岗位喵")),
553    }
554}
555
556/// 计算组员更新后需要持久化的岗位列表。
557///
558/// 退休状态是服务端权限边界:无论前端是否提交岗位列表,都不能保留或重新加入审稿岗位。
559fn posts_for_member_update(
560    current_post_ids: &[i32],
561    requested_post_ids: Option<Vec<i32>>,
562    intern: i16,
563) -> Option<Vec<i32>> {
564    let is_retired = intern == MemberInternEnum::RETIRED;
565    match requested_post_ids {
566        Some(mut post_ids) => {
567            if is_retired {
568                post_ids.retain(|post_id| *post_id != PostEnum::REVIEWER);
569            }
570            Some(post_ids)
571        }
572        None if is_retired && current_post_ids.contains(&PostEnum::REVIEWER) => Some(
573            current_post_ids
574                .iter()
575                .copied()
576                .filter(|post_id| *post_id != PostEnum::REVIEWER)
577                .collect(),
578        ),
579        None => None,
580    }
581}
582
583/// 规范化头像字段:空字符串表示清空头像。
584fn normalize_avatar_url(value: String) -> Option<String> {
585    let trimmed = value.trim().to_string();
586    if trimmed.is_empty() {
587        None
588    } else {
589        Some(trimmed)
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::posts_for_member_update;
596    use crate::entity::enums::{MemberInternEnum, PostEnum};
597
598    #[test]
599    fn retirement_removes_reviewer_from_requested_posts() {
600        let posts = posts_for_member_update(
601            &[PostEnum::TRANSLATOR, PostEnum::REVIEWER],
602            Some(vec![PostEnum::TRANSLATOR, PostEnum::REVIEWER]),
603            MemberInternEnum::RETIRED,
604        );
605        assert_eq!(posts, Some(vec![PostEnum::TRANSLATOR]));
606    }
607
608    #[test]
609    fn retirement_without_post_payload_cleans_existing_reviewer() {
610        let posts = posts_for_member_update(
611            &[PostEnum::PROOFREADER, PostEnum::REVIEWER],
612            None,
613            MemberInternEnum::RETIRED,
614        );
615        assert_eq!(posts, Some(vec![PostEnum::PROOFREADER]));
616    }
617
618    #[test]
619    fn ordinary_profile_update_does_not_replace_posts() {
620        let posts = posts_for_member_update(&[PostEnum::REVIEWER], None, MemberInternEnum::REGULAR);
621        assert_eq!(posts, None);
622    }
623}