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, invalidate_auth_snapshot, invalidate_member_all,
15        invalidate_task_tracking, member_all_cache_key,
16    },
17    common::PageBean,
18    entity::{
19        enums::MemberInternEnum,
20        member::{
21            InvitationCode, Member, MemberAddRequest, MemberCache, MemberEpisode, MemberEpisodeVo,
22            MemberListVo, MemberLoginRequest, MemberSelfUpdateRequest, MemberUpdateRequest, Reg,
23            ResetPassword, StationedManga,
24        },
25    },
26    error::{ApiResult, AppError},
27    repository::{manga_repo::MangaRepository, member_repo::MemberRepository},
28    service::rss_service::{RssRefreshScope, RssService},
29    utils::{jwt::JwtUtil, page::slice_rows},
30};
31use bcrypt::{hash, verify, DEFAULT_COST};
32use std::sync::Arc;
33
34/// 组员服务
35///
36/// 提供组员账号生命周期管理的完整业务逻辑。
37/// 所有方法为关联函数,通过 `&AppState` 获取数据库连接和配置。
38pub struct MemberService;
39
40impl MemberService {
41    /// 验证凭证并签发 JWT Token。
42    ///
43    /// 查询组员 → bcrypt 密码比对 → 检查退组状态 → 签发 JWT。
44    ///
45    /// # 返回值
46    ///
47    /// - `Ok(Some(token))` — 登录成功,返回 JWT 字符串,有效期由 `jwt.expire_ms` 配置
48    /// - `Ok(None)` — 用户名不存在或密码不匹配
49    ///
50    /// # Errors
51    ///
52    /// - `AppError::LoginExpired("你已经退出了提灯喵猫娘化计划喵……")` — 组员 intern=4(已退组)
53    /// - `AppError::Database` — 数据库查询失败
54    /// - `AppError::Internal` — JWT 签发失败(密钥算法不可用)
55    #[tracing::instrument(skip_all, level = "debug")]
56    pub async fn login(state: &AppState, req: MemberLoginRequest) -> ApiResult<Option<String>> {
57        let repo = MemberRepository::new(state.db.clone());
58        let Some((member, pwd_hash)) = repo.find_for_login(&req.username).await? else {
59            return Ok(None);
60        };
61        if !verify(&req.password, &pwd_hash).unwrap_or(false) {
62            return Ok(None);
63        }
64        if MemberInternEnum::is_left(member.intern) {
65            return Err(AppError::login_expired("你已经退出了提灯喵猫娘化计划喵……"));
66        }
67        let util = JwtUtil::new(&state.config.jwt.sign_key, state.config.jwt.expire_ms);
68        let jwt = util
69            .generate(member.id)
70            .map_err(|e| AppError::Internal(e.to_string()))?;
71        Ok(Some(jwt))
72    }
73
74    /// 注册新组员
75    #[tracing::instrument(skip_all, level = "debug")]
76    pub async fn register_member(state: &AppState, reg: Reg) -> ApiResult<()> {
77        let repo = MemberRepository::new(state.db.clone());
78        if !repo.is_valid_invitation(reg.invitationcode).await? {
79            return Err(AppError::business("邀请码无效喵!"));
80        }
81        Self::check_duplicate(&repo, &reg.username, &reg.email).await?;
82        let mut member = Member {
83            username: Some(reg.username),
84            email: Some(reg.email),
85            password: Some(Self::encode_pwd(&reg.password)),
86            intern: 1,
87            ..Default::default()
88        };
89        Self::add_new_member(&repo, &mut member).await?;
90        Self::invalidate_member_caches(state, &[member.id]).await;
91        Ok(())
92    }
93
94    /// 分页查询组员
95    #[tracing::instrument(skip_all, level = "debug")]
96    pub async fn page(
97        state: &AppState,
98        page: i32,
99        page_size: i32,
100        username: Option<String>,
101        post: Option<i16>,
102        intern: Option<i16>,
103        email: Option<String>,
104    ) -> ApiResult<PageBean<MemberListVo>> {
105        let repo = MemberRepository::new(state.db.clone());
106        let (total, rows) = repo
107            .page_list(
108                username.as_deref(),
109                post,
110                intern,
111                email.as_deref(),
112                page,
113                page_size,
114            )
115            .await?;
116        Ok(slice_rows(
117            rows.into_iter().map(MemberListVo::from).collect(),
118            total,
119        ))
120    }
121
122    /// 全量组员缓存列表
123    #[tracing::instrument(skip_all, level = "debug")]
124    pub async fn get_member_list(state: &AppState) -> ApiResult<Vec<MemberCache>> {
125        let key = member_all_cache_key();
126        if let Some(cached) = state.member_all_cache.get(key).await {
127            return Ok((*cached).clone());
128        }
129        let list = MemberRepository::new(state.db.clone()).all_cache().await?;
130        state
131            .member_all_cache
132            .insert(key.to_string(), Arc::new(list.clone()))
133            .await;
134        Ok(list)
135    }
136
137    /// 批量删除组员
138    #[tracing::instrument(skip_all, level = "debug")]
139    pub async fn delete(state: &AppState, ids: Vec<i32>) -> ApiResult<()> {
140        MemberRepository::new(state.db.clone())
141            .delete_members(&ids)
142            .await?;
143        Self::invalidate_member_caches(state, &ids).await;
144        Ok(())
145    }
146
147    /// 新增组员
148    #[tracing::instrument(skip_all, level = "debug")]
149    pub async fn new_member(state: &AppState, req: MemberAddRequest) -> ApiResult<()> {
150        let repo = MemberRepository::new(state.db.clone());
151        Self::check_duplicate(&repo, &req.username, &req.email).await?;
152        let mut member = Member {
153            username: Some(req.username),
154            password: Some(Self::encode_pwd(&req.password)),
155            email: Some(req.email),
156            intern: req.intern,
157            ..Default::default()
158        };
159        Self::add_new_member(&repo, &mut member).await?;
160        let id = member.id;
161        repo.replace_posts(id, &req.post_ids).await?;
162        Self::invalidate_member_caches(state, &[id]).await;
163        Ok(())
164    }
165
166    /// 按 ID 查询组员
167    #[tracing::instrument(skip_all, level = "debug")]
168    pub async fn get_member_by_id(state: &AppState, id: i32) -> ApiResult<Member> {
169        get_auth_snapshot_cached(state, id).await
170    }
171
172    /// 更新组员信息
173    #[tracing::instrument(skip_all, level = "debug")]
174    pub async fn update_member(state: &AppState, req: MemberUpdateRequest) -> ApiResult<()> {
175        let repo = MemberRepository::new(state.db.clone());
176        let mut member = repo.get_by_id(req.id).await?;
177        if let Some(u) = req.username {
178            member.username = Some(u);
179        }
180        if let Some(p) = req.password {
181            member.password = Some(Self::encode_pwd(&p));
182        }
183        if let Some(e) = req.email {
184            member.email = Some(e);
185        }
186        if let Some(i) = req.intern {
187            member.intern = i;
188        }
189        repo.update_member(&member).await?;
190        if let Some(post_ids) = req.post_ids {
191            repo.replace_posts(req.id, &post_ids).await?;
192        }
193        Self::invalidate_member_caches(state, &[req.id]).await;
194        Ok(())
195    }
196
197    /// 组员自助更新昵称与 QQ
198    #[tracing::instrument(skip_all, level = "debug")]
199    pub async fn update_member_self(
200        state: &AppState,
201        req: MemberSelfUpdateRequest,
202    ) -> ApiResult<()> {
203        let repo = MemberRepository::new(state.db.clone());
204        let mut member = repo.get_by_id(req.id).await?;
205        if let Some(u) = req.username {
206            member.username = Some(u);
207        }
208        if let Some(e) = req.email {
209            member.email = Some(e);
210        }
211        repo.update_member(&member).await?;
212        Self::invalidate_member_caches(state, &[req.id]).await;
213        Ok(())
214    }
215
216    /// 查询组员常驻漫画
217    #[tracing::instrument(skip_all, level = "debug")]
218    pub async fn find_stationed_mangas(
219        state: &AppState,
220        id: i32,
221    ) -> ApiResult<Vec<StationedManga>> {
222        let member_repo = MemberRepository::new(state.db.clone());
223        let manga_repo = MangaRepository::new(state.db.clone());
224        let groups = member_repo.stationed_manga_post_groups(id).await?;
225        if groups.is_empty() {
226            return Ok(Vec::new());
227        }
228        let manga_ids: Vec<i32> = groups.keys().copied().collect();
229        let mangas = manga_repo.get_manga_responses_by_ids(&manga_ids).await?;
230        let mut list = Vec::with_capacity(groups.len());
231        for (manga_id, posts) in groups {
232            if let Some(manga) = mangas.get(&manga_id) {
233                list.push(StationedManga {
234                    manga: manga.clone(),
235                    posts,
236                });
237            }
238        }
239        list.sort_by(|a, b| b.manga.update_time.cmp(&a.manga.update_time));
240        Ok(list)
241    }
242
243    /// 修改密码
244    #[tracing::instrument(skip_all, level = "debug")]
245    pub async fn update_member_password(state: &AppState, reset: ResetPassword) -> ApiResult<()> {
246        let repo = MemberRepository::new(state.db.clone());
247        let old_hash = repo.get_password(reset.id).await?;
248        if !verify(&reset.old_pwd, &old_hash).unwrap_or(false) {
249            return Err(AppError::business("原密码不正确喵"));
250        }
251        repo.update_password(reset.id, &Self::encode_pwd(&reset.new_pwd))
252            .await?;
253        Self::invalidate_member_caches(state, &[reset.id]).await;
254        Ok(())
255    }
256
257    /// 分页查询组员话数
258    #[tracing::instrument(skip_all, level = "debug")]
259    pub async fn page_episode(
260        state: &AppState,
261        page: i32,
262        page_size: i32,
263        id: i32,
264    ) -> ApiResult<PageBean<MemberEpisodeVo>> {
265        let (total, rows) = MemberRepository::new(state.db.clone())
266            .page_member_episodes(id, page, page_size)
267            .await?;
268        Ok(slice_rows(rows, total))
269    }
270
271    /// 接稿
272    #[tracing::instrument(skip_all, level = "debug")]
273    pub async fn take_episode(state: &AppState, ep: MemberEpisode) -> ApiResult<()> {
274        MemberRepository::new(state.db.clone())
275            .take_episode(&ep)
276            .await?;
277        invalidate_task_tracking(state).await;
278        Ok(())
279    }
280
281    /// 交稿
282    #[tracing::instrument(skip_all, level = "debug")]
283    pub async fn submit_episode(state: &AppState, ep: MemberEpisode) -> ApiResult<()> {
284        MemberRepository::new(state.db.clone())
285            .submit_episode(&ep)
286            .await?;
287        invalidate_task_tracking(state).await;
288        RssService::refresh(
289            state,
290            RssRefreshScope::WorkflowSubmit {
291                post_name: ep.my_name.clone(),
292            },
293        );
294        Ok(())
295    }
296
297    /// 邀请码列表
298    #[tracing::instrument(skip_all, level = "debug")]
299    pub async fn get_invitation_codes(state: &AppState) -> ApiResult<Vec<InvitationCode>> {
300        MemberRepository::new(state.db.clone())
301            .list_invitation_codes()
302            .await
303    }
304
305    /// 删除邀请码
306    #[tracing::instrument(skip_all, level = "debug")]
307    pub async fn delete_invitation_code(state: &AppState, id: i32) -> ApiResult<()> {
308        MemberRepository::new(state.db.clone())
309            .delete_invitation(id)
310            .await
311    }
312
313    /// 新增邀请码
314    #[tracing::instrument(skip_all, level = "debug")]
315    pub async fn add_invitation_code(state: &AppState, code: InvitationCode) -> ApiResult<()> {
316        MemberRepository::new(state.db.clone())
317            .add_invitation(code.code)
318            .await
319    }
320
321    #[tracing::instrument(skip_all, level = "debug")]
322    async fn check_duplicate(
323        repo: &MemberRepository,
324        username: &str,
325        email: &str,
326    ) -> ApiResult<()> {
327        if repo.exists_username(username).await? {
328            return Err(AppError::unique("用户名"));
329        }
330        if repo.exists_email(email).await? {
331            return Err(AppError::unique("邮箱"));
332        }
333        Ok(())
334    }
335
336    #[tracing::instrument(skip_all, level = "debug")]
337    async fn add_new_member(repo: &MemberRepository, member: &mut Member) -> ApiResult<()> {
338        let id = repo.insert_member(member).await?;
339        member.id = id;
340        repo.replace_posts(id, &[5]).await?;
341        Ok(())
342    }
343
344    fn encode_pwd(pwd: &str) -> String {
345        hash(pwd, DEFAULT_COST).unwrap_or_else(|_| pwd.to_string())
346    }
347
348    /// 组员数据变更后同步失效相关缓存
349    #[tracing::instrument(skip_all, level = "debug")]
350    async fn invalidate_member_caches(state: &AppState, member_ids: &[i32]) {
351        invalidate_member_all(state).await;
352        for id in member_ids {
353            invalidate_auth_snapshot(state, *id).await;
354        }
355    }
356}