Skip to content

Commit 9bf6c2b

Browse files
catturtle123claude
andauthored
✨ feat: 회원 탈퇴 API 구현 (API-028) (#57)
* ✨ feat: 회원 탈퇴 API 구현 (API-028) - DELETE /api/v1/members/me 엔드포인트 추가 - 회원 삭제 시 연관 데이터(답변, 회고 등) 유지 (ON DELETE SET NULL) - member_id를 Option<i64>로 변경하여 탈퇴 멤버 처리 - 트랜잭션으로 refresh_token과 member 삭제 원자적 처리 - Terraform S3 backend 마이그레이션 (v2) Co-Authored-By: Claude Opus 4.5 <[email protected]> * 🎨 style: cargo fmt 적용 * 🔀 merge: dev 병합 및 member_response Option 처리 * 🐛 fix: PDF에 탈퇴 멤버 표시 및 AI 분석 빈 데이터 검증 추가 * 🐛 fix: 탈퇴 멤버로 인한 카테고리 필터링 fallback 추가 --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 54d9339 commit 9bf6c2b

File tree

15 files changed

+570
-55
lines changed

15 files changed

+570
-55
lines changed

ci/terraform/backend.tf

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@
1616
#
1717
# =============================================================================
1818

19-
# 1단계 완료 후 주석 해제하세요
19+
# S3 Backend 활성화됨
2020
terraform {
2121
backend "s3" {
22-
bucket = "web-team-3-terraform-state"
22+
bucket = "web-team-3-tf-state-v2"
2323
key = "terraform.tfstate"
2424
region = "ap-northeast-2"
2525
encrypt = true
26-
dynamodb_table = "web-team-3-terraform-lock"
26+
dynamodb_table = "web-team-3-tf-lock-v2"
2727
}
2828
}
2929

@@ -32,10 +32,10 @@ terraform {
3232
# =============================================================================
3333

3434
resource "aws_s3_bucket" "terraform_state" {
35-
bucket = "${var.project_name}-terraform-state"
35+
bucket = "web-team-3-tf-state-v2"
3636

3737
tags = {
38-
Name = "${var.project_name}-terraform-state"
38+
Name = "web-team-3-tf-state-v2"
3939
}
4040
}
4141

@@ -65,7 +65,7 @@ resource "aws_s3_bucket_public_access_block" "terraform_state" {
6565
}
6666

6767
resource "aws_dynamodb_table" "terraform_lock" {
68-
name = "${var.project_name}-terraform-lock"
68+
name = "web-team-3-tf-lock-v2"
6969
billing_mode = "PAY_PER_REQUEST"
7070
hash_key = "LockID"
7171

@@ -75,6 +75,6 @@ resource "aws_dynamodb_table" "terraform_lock" {
7575
}
7676

7777
tags = {
78-
Name = "${var.project_name}-terraform-lock"
78+
Name = "web-team-3-tf-lock-v2"
7979
}
8080
}

ci/terraform/rds.tf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ resource "aws_db_instance" "main" {
7777
publicly_accessible = false
7878
multi_az = var.db_multi_az
7979

80-
# 백업 설정
81-
backup_retention_period = 7
80+
# 백업 설정 (프리티어: 0으로 설정)
81+
backup_retention_period = 0
8282
backup_window = "03:00-04:00"
8383
maintenance_window = "Mon:04:00-Mon:05:00"
8484

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
use serde::Serialize;
2+
use utoipa::ToSchema;
3+
4+
use crate::utils::BaseResponse;
5+
6+
/// 회원 탈퇴 성공 응답 (Swagger 문서용)
7+
#[derive(Debug, Serialize, ToSchema)]
8+
#[serde(rename_all = "camelCase")]
9+
pub struct SuccessWithdrawResponse {
10+
pub is_success: bool,
11+
pub code: String,
12+
pub message: String,
13+
pub result: Option<()>,
14+
}
15+
16+
impl From<BaseResponse<()>> for SuccessWithdrawResponse {
17+
fn from(res: BaseResponse<()>) -> Self {
18+
Self {
19+
is_success: res.is_success,
20+
code: res.code,
21+
message: res.message,
22+
result: res.result,
23+
}
24+
}
25+
}

codes/server/src/domain/member/entity/member_response.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
66
pub struct Model {
77
#[sea_orm(primary_key)]
88
pub member_response_id: i64,
9-
pub member_id: i64,
9+
pub member_id: Option<i64>,
1010
pub response_id: i64,
1111
}
1212

@@ -17,7 +17,7 @@ pub enum Relation {
1717
from = "Column::MemberId",
1818
to = "super::member::Column::MemberId",
1919
on_update = "NoAction",
20-
on_delete = "Cascade"
20+
on_delete = "SetNull"
2121
)]
2222
Member,
2323
#[sea_orm(

codes/server/src/domain/member/entity/member_retro.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub struct Model {
2626
#[sea_orm(primary_key)]
2727
pub member_retro_id: i64,
2828
pub personal_insight: Option<String>,
29-
pub member_id: i64,
29+
pub member_id: Option<i64>,
3030
pub retrospect_id: i64,
3131
pub status: RetrospectStatus,
3232
pub submitted_at: Option<DateTime>,
@@ -39,7 +39,7 @@ pub enum Relation {
3939
from = "Column::MemberId",
4040
to = "super::member::Column::MemberId",
4141
on_update = "NoAction",
42-
on_delete = "Cascade"
42+
on_delete = "SetNull"
4343
)]
4444
Member,
4545
#[sea_orm(

codes/server/src/domain/member/entity/member_retro_room.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub enum RoomRole {
1515
pub struct Model {
1616
#[sea_orm(primary_key)]
1717
pub member_retrospect_room_id: i64,
18-
pub member_id: i64,
18+
pub member_id: Option<i64>,
1919
pub retrospect_room_id: i64,
2020
pub role: RoomRole,
2121
#[sea_orm(default_value = "1")]
@@ -29,7 +29,7 @@ pub enum Relation {
2929
from = "Column::MemberId",
3030
to = "super::member::Column::MemberId",
3131
on_update = "NoAction",
32-
on_delete = "Cascade"
32+
on_delete = "SetNull"
3333
)]
3434
Member,
3535
#[sea_orm(
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
use axum::{extract::State, Json};
2+
3+
use super::service::MemberService;
4+
use crate::state::AppState;
5+
use crate::utils::auth::AuthUser;
6+
use crate::utils::error::AppError;
7+
use crate::utils::BaseResponse;
8+
9+
/// [API-028] 서비스 탈퇴
10+
///
11+
/// 현재 로그인한 사용자의 계정을 삭제하고 서비스를 탈퇴 처리합니다.
12+
/// - 탈퇴 시 해당 사용자와 연결된 모든 개인 정보 및 데이터는 즉시 파기되며, 이는 복구가 불가능합니다.
13+
#[utoipa::path(
14+
delete,
15+
path = "/api/v1/members/me",
16+
security(
17+
("bearer_auth" = [])
18+
),
19+
responses(
20+
(status = 200, description = "회원 탈퇴 성공", body = SuccessWithdrawResponse),
21+
(status = 401, description = "인증 실패", body = ErrorResponse),
22+
(status = 404, description = "존재하지 않는 사용자", body = ErrorResponse),
23+
(status = 500, description = "서버 내부 오류", body = ErrorResponse)
24+
),
25+
tag = "Member"
26+
)]
27+
pub async fn withdraw(
28+
State(state): State<AppState>,
29+
user: AuthUser,
30+
) -> Result<Json<BaseResponse<()>>, AppError> {
31+
let member_id: i64 = user
32+
.0
33+
.sub
34+
.parse()
35+
.map_err(|_| AppError::Unauthorized("잘못된 인증 정보입니다.".into()))?;
36+
37+
MemberService::withdraw(state, member_id).await?;
38+
39+
Ok(Json(BaseResponse {
40+
is_success: true,
41+
code: "COMMON200".to_string(),
42+
message: "회원 탈퇴가 성공적으로 완료되었습니다.".to_string(),
43+
result: None,
44+
}))
45+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
pub mod dto;
12
pub mod entity;
3+
pub mod handler;
4+
pub mod service;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, TransactionTrait};
2+
use tracing::info;
3+
4+
use crate::domain::member::entity::member;
5+
use crate::domain::member::entity::refresh_token;
6+
use crate::state::AppState;
7+
use crate::utils::error::AppError;
8+
9+
pub struct MemberService;
10+
11+
impl MemberService {
12+
/// 회원 탈퇴 처리
13+
///
14+
/// 사용자 계정 정보만 삭제하고, 연관 데이터(답변, 회고 등)는 유지합니다.
15+
/// - refresh_token: 삭제 (세션 정보)
16+
/// - member: 삭제 (계정 정보)
17+
/// - member_response, member_retro, member_retro_room: FK가 NULL로 설정됨 (ON DELETE SET NULL)
18+
pub async fn withdraw(state: AppState, member_id: i64) -> Result<(), AppError> {
19+
// 사용자 존재 여부 확인
20+
let member = member::Entity::find_by_id(member_id)
21+
.one(&state.db)
22+
.await
23+
.map_err(|e| AppError::InternalError(e.to_string()))?;
24+
25+
if member.is_none() {
26+
return Err(AppError::MemberNotFound(
27+
"존재하지 않는 사용자입니다.".to_string(),
28+
));
29+
}
30+
31+
// 트랜잭션으로 refresh_token과 member 삭제를 원자적으로 처리
32+
let txn = state
33+
.db
34+
.begin()
35+
.await
36+
.map_err(|e| AppError::InternalError(e.to_string()))?;
37+
38+
// 1. refresh_token 삭제
39+
refresh_token::Entity::delete_many()
40+
.filter(refresh_token::Column::MemberId.eq(member_id))
41+
.exec(&txn)
42+
.await
43+
.map_err(|e| AppError::InternalError(e.to_string()))?;
44+
45+
// 2. member 삭제 (연관 테이블의 member_id는 ON DELETE SET NULL로 자동 NULL 처리)
46+
member::Entity::delete_by_id(member_id)
47+
.exec(&txn)
48+
.await
49+
.map_err(|e| AppError::InternalError(e.to_string()))?;
50+
51+
txn.commit()
52+
.await
53+
.map_err(|e| AppError::InternalError(e.to_string()))?;
54+
55+
info!("Member {} has been withdrawn successfully", member_id);
56+
57+
Ok(())
58+
}
59+
}

0 commit comments

Comments
 (0)