1use 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
43pub struct MemberService;
48
49impl MemberService {
50 #[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 #[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, ®.username, ®.email).await?;
91 let mut member = Member {
92 username: Some(reg.username),
93 email: Some(reg.email),
94 password: Some(Self::encode_pwd(®.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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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
543fn 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
556fn 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
583fn 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}