Skip to content

Commit efd99ec

Browse files
authored
Merge pull request #72 from Geumpumta/feat/badge
feat: 배지 도메인 및 웰컴/시즌 등수/총 공부시간 배지 지급 로직 추가
2 parents ab04ae5 + 043fbcb commit efd99ec

34 files changed

+1829
-15
lines changed
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package com.gpt.geumpumtabackend.badge.api;
2+
3+
import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest;
4+
import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest;
5+
import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse;
6+
import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse;
7+
import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse;
8+
import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse;
9+
import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse;
10+
import com.gpt.geumpumtabackend.global.aop.AssignUserId;
11+
import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiFailedResponse;
12+
import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiResponses;
13+
import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse;
14+
import com.gpt.geumpumtabackend.global.exception.ExceptionType;
15+
import com.gpt.geumpumtabackend.global.response.ResponseBody;
16+
import io.swagger.v3.oas.annotations.Operation;
17+
import io.swagger.v3.oas.annotations.Parameter;
18+
import io.swagger.v3.oas.annotations.media.Content;
19+
import io.swagger.v3.oas.annotations.media.Schema;
20+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
21+
import io.swagger.v3.oas.annotations.tags.Tag;
22+
import jakarta.validation.Valid;
23+
import org.springframework.http.ResponseEntity;
24+
import org.springframework.security.access.prepost.PreAuthorize;
25+
import org.springframework.web.bind.annotation.*;
26+
27+
import java.util.List;
28+
29+
@Tag(name = "배지 API", description = """
30+
배지 생성/조회/삭제 및 사용자 배지 조회 기능을 제공합니다.
31+
""")
32+
public interface BadgeApi {
33+
34+
@Operation(
35+
summary = "배지 생성",
36+
description = """
37+
ADMIN 권한으로 새로운 배지를 생성합니다.
38+
- code: 배지 고유 코드 (중복 불가)
39+
- badgeType: 배지 종류
40+
- thresholdValue: 누적 시간/연속 일수 계열 배지 기준값
41+
- rank: 시즌 랭킹 배지 등수 값(예: 1,2,3)
42+
"""
43+
)
44+
@ApiResponse(content = @Content(schema = @Schema(implementation = BadgeCreateResponse.class)))
45+
@SwaggerApiResponses(
46+
success = @SwaggerApiSuccessResponse(
47+
response = BadgeCreateResponse.class,
48+
description = "배지 생성 성공"
49+
),
50+
errors = {
51+
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
52+
@SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED),
53+
@SwaggerApiFailedResponse(ExceptionType.BADGE_CODE_ALREADY_EXISTS)
54+
}
55+
)
56+
@PostMapping
57+
@PreAuthorize("isAuthenticated() and hasRole('ADMIN')")
58+
ResponseEntity<ResponseBody<BadgeCreateResponse>> createBadge(
59+
@RequestBody @Valid BadgeCreateRequest request
60+
);
61+
62+
@Operation(
63+
summary = "전체 배지 조회",
64+
description = "ADMIN 권한으로 전체 배지 목록을 조회합니다."
65+
)
66+
@ApiResponse(content = @Content(schema = @Schema(implementation = BadgeResponse.class)))
67+
@SwaggerApiResponses(
68+
success = @SwaggerApiSuccessResponse(
69+
response = BadgeResponse.class,
70+
description = "전체 배지 조회 성공"
71+
),
72+
errors = {
73+
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
74+
@SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED)
75+
}
76+
)
77+
@GetMapping
78+
@PreAuthorize("isAuthenticated() and hasRole('ADMIN')")
79+
ResponseEntity<ResponseBody<List<BadgeResponse>>> getAllBadges();
80+
81+
@Operation(
82+
summary = "배지 삭제",
83+
description = """
84+
ADMIN 권한으로 배지를 삭제합니다.
85+
이미 사용자에게 지급된 이력이 있으면 삭제할 수 없고 B004(BADGE_IN_USE)를 반환합니다.
86+
"""
87+
)
88+
@ApiResponse(content = @Content(schema = @Schema(implementation = ResponseBody.class)))
89+
@SwaggerApiResponses(
90+
success = @SwaggerApiSuccessResponse(description = "배지 삭제 성공"),
91+
errors = {
92+
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
93+
@SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED),
94+
@SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_FOUND),
95+
@SwaggerApiFailedResponse(ExceptionType.BADGE_IN_USE)
96+
}
97+
)
98+
@DeleteMapping("/{badgeId}")
99+
@PreAuthorize("isAuthenticated() and hasRole('ADMIN')")
100+
ResponseEntity<ResponseBody<Void>> deleteBadge(
101+
@PathVariable Long badgeId
102+
);
103+
104+
@Operation(
105+
summary = "내 배지 조회",
106+
description = """
107+
항상 전체 배지 목록을 반환합니다.
108+
각 원소는 아래 정보를 포함합니다.
109+
- owned: 사용자의 배지 보유 여부
110+
- awardedAt: 배지 획득 시각 (owned=false이면 null)
111+
"""
112+
)
113+
@ApiResponse(content = @Content(schema = @Schema(implementation = MyBadgeStatusResponse.class)))
114+
@SwaggerApiResponses(
115+
success = @SwaggerApiSuccessResponse(
116+
response = MyBadgeStatusResponse.class,
117+
description = "내 배지 조회 성공"
118+
),
119+
errors = {
120+
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
121+
@SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND)
122+
}
123+
)
124+
@GetMapping("/me")
125+
@AssignUserId
126+
@PreAuthorize("isAuthenticated() and hasRole('USER')")
127+
ResponseEntity<ResponseBody<List<MyBadgeStatusResponse>>> getMyBadges(
128+
@Parameter(hidden = true) Long userId
129+
);
130+
131+
@Operation(
132+
summary = "대표 배지 설정",
133+
description = """
134+
보유한 배지 중 하나를 대표 배지로 설정합니다.
135+
요청은 badgeCode 기준으로 처리됩니다.
136+
"""
137+
)
138+
@ApiResponse(content = @Content(schema = @Schema(implementation = RepresentativeBadgeResponse.class)))
139+
@SwaggerApiResponses(
140+
success = @SwaggerApiSuccessResponse(
141+
response = RepresentativeBadgeResponse.class,
142+
description = "대표 배지 설정 성공"
143+
),
144+
errors = {
145+
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
146+
@SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND),
147+
@SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_FOUND),
148+
@SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_OWNED)
149+
}
150+
)
151+
@PostMapping("/me/representative-badge")
152+
@AssignUserId
153+
@PreAuthorize("isAuthenticated() and hasRole('USER')")
154+
ResponseEntity<ResponseBody<RepresentativeBadgeResponse>> setRepresentativeBadge(
155+
@RequestBody RepresentativeBadgeRequest request,
156+
@Parameter(hidden = true) Long userId
157+
);
158+
159+
@Operation(
160+
summary = "미확인 배지 조회",
161+
description = """
162+
사용자의 미확인 배지 목록을 조회합니다.
163+
조회된 배지는 같은 요청 트랜잭션에서 확인 처리(notifiedAt 설정)됩니다.
164+
"""
165+
)
166+
@ApiResponse(content = @Content(schema = @Schema(implementation = MyBadgeResponse.class)))
167+
@SwaggerApiResponses(
168+
success = @SwaggerApiSuccessResponse(
169+
response = MyBadgeResponse.class,
170+
description = "미확인 배지 조회 성공"
171+
),
172+
errors = {
173+
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
174+
@SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND)
175+
}
176+
)
177+
@GetMapping("/unnotified")
178+
@AssignUserId
179+
@PreAuthorize("isAuthenticated() and hasRole('USER')")
180+
ResponseEntity<ResponseBody<List<MyBadgeResponse>>> getUnnotifiedBadges(
181+
@Parameter(hidden = true) Long userId
182+
);
183+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.gpt.geumpumtabackend.badge.controller;
2+
3+
import com.gpt.geumpumtabackend.badge.api.BadgeApi;
4+
import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest;
5+
import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest;
6+
import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse;
7+
import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse;
8+
import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse;
9+
import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse;
10+
import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse;
11+
import com.gpt.geumpumtabackend.badge.service.BadgeService;
12+
import com.gpt.geumpumtabackend.global.aop.AssignUserId;
13+
import com.gpt.geumpumtabackend.global.response.ResponseBody;
14+
import com.gpt.geumpumtabackend.global.response.ResponseUtil;
15+
import lombok.RequiredArgsConstructor;
16+
import jakarta.validation.Valid;
17+
import org.springframework.http.ResponseEntity;
18+
import org.springframework.security.access.prepost.PreAuthorize;
19+
import org.springframework.web.bind.annotation.*;
20+
21+
import java.util.List;
22+
23+
@RestController
24+
@RequiredArgsConstructor
25+
@RequestMapping("/api/v1/badge")
26+
public class BadgeController implements BadgeApi {
27+
28+
private final BadgeService badgeService;
29+
30+
@PostMapping
31+
@PreAuthorize("isAuthenticated() and hasRole('ADMIN')")
32+
public ResponseEntity<ResponseBody<BadgeCreateResponse>> createBadge(
33+
@RequestBody @Valid BadgeCreateRequest request
34+
){
35+
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(
36+
badgeService.createBadge(request)
37+
));
38+
}
39+
40+
@GetMapping
41+
@PreAuthorize("isAuthenticated() and hasRole('ADMIN')")
42+
public ResponseEntity<ResponseBody<List<BadgeResponse>>> getAllBadges() {
43+
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(
44+
badgeService.getAllBadges()
45+
));
46+
}
47+
48+
@DeleteMapping("/{badgeId}")
49+
@PreAuthorize("isAuthenticated() and hasRole('ADMIN')")
50+
public ResponseEntity<ResponseBody<Void>> deleteBadge(
51+
@PathVariable Long badgeId
52+
) {
53+
badgeService.deleteBadge(badgeId);
54+
return ResponseEntity.ok(ResponseUtil.createSuccessResponse());
55+
}
56+
57+
@GetMapping("/me")
58+
@AssignUserId
59+
@PreAuthorize("isAuthenticated() and hasRole('USER')")
60+
public ResponseEntity<ResponseBody<List<MyBadgeStatusResponse>>> getMyBadges(
61+
Long userId
62+
){
63+
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(
64+
badgeService.getMyBadges(userId)
65+
));
66+
}
67+
68+
@PostMapping("/me/representative-badge")
69+
@AssignUserId
70+
@PreAuthorize("isAuthenticated() and hasRole('USER')")
71+
public ResponseEntity<ResponseBody<RepresentativeBadgeResponse>> setRepresentativeBadge(
72+
@RequestBody RepresentativeBadgeRequest request,
73+
Long userId
74+
){
75+
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(
76+
badgeService.setRepresentativeBadge(request, userId)
77+
));
78+
}
79+
80+
@GetMapping("/unnotified")
81+
@AssignUserId
82+
@PreAuthorize("isAuthenticated() and hasRole('USER')")
83+
public ResponseEntity<ResponseBody<List<MyBadgeResponse>>> getUnnotifiedBadges(
84+
Long userId
85+
){
86+
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(
87+
badgeService.getUnnotifiedBadges(userId)
88+
));
89+
}
90+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.gpt.geumpumtabackend.badge.domain;
2+
3+
import com.gpt.geumpumtabackend.global.base.BaseEntity;
4+
import jakarta.persistence.*;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.AccessLevel;
8+
import lombok.NoArgsConstructor;
9+
10+
@Entity
11+
@Getter
12+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
13+
public class Badge extends BaseEntity {
14+
15+
@Id
16+
@GeneratedValue(strategy = GenerationType.IDENTITY)
17+
private Long id;
18+
19+
@Column(unique = true)
20+
private String code;
21+
22+
private String name;
23+
24+
private String description;
25+
26+
private String iconUrl;
27+
28+
@Enumerated(EnumType.STRING)
29+
private BadgeType badgeType;
30+
31+
private Long thresholdValue;
32+
33+
@Column(name = "badge_rank")
34+
private Long rank;
35+
36+
@Builder
37+
private Badge(
38+
String code,
39+
String name,
40+
String description,
41+
String iconUrl,
42+
BadgeType badgeType,
43+
Long thresholdValue,
44+
Long rank
45+
) {
46+
this.code = code;
47+
this.name = name;
48+
this.description = description;
49+
this.iconUrl = iconUrl;
50+
this.badgeType = badgeType;
51+
this.thresholdValue = thresholdValue;
52+
this.rank = rank;
53+
}
54+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.gpt.geumpumtabackend.badge.domain;
2+
3+
public enum BadgeType {
4+
WELCOME,
5+
STREAK_DAYS,
6+
TOTAL_HOURS,
7+
SEASON_PERSONAL_RANK
8+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.gpt.geumpumtabackend.badge.domain;
2+
3+
import jakarta.persistence.*;
4+
import lombok.Getter;
5+
import lombok.NoArgsConstructor;
6+
7+
import java.time.LocalDateTime;
8+
9+
@Entity
10+
@Getter
11+
@NoArgsConstructor
12+
@Table(
13+
name = "user_badge",
14+
uniqueConstraints = @UniqueConstraint(name="uk_user_badge", columnNames = {"user_id", "badge_id"})
15+
)
16+
public class UserBadge {
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
private Long id;
20+
21+
@Column(name = "user_id")
22+
private Long userId;
23+
24+
@Column(name = "badge_id")
25+
private Long badgeId;
26+
27+
private LocalDateTime awardedAt;
28+
29+
private LocalDateTime notifiedAt;
30+
31+
public UserBadge(Long userId, Long badgeId, LocalDateTime awardedAt, LocalDateTime notifiedAt) {
32+
this.userId = userId;
33+
this.badgeId = badgeId;
34+
this.awardedAt = awardedAt;
35+
this.notifiedAt = notifiedAt;
36+
}
37+
38+
public void markNotified(LocalDateTime notifiedAt) {
39+
this.notifiedAt = notifiedAt;
40+
}
41+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.gpt.geumpumtabackend.badge.dto.request;
2+
3+
import com.gpt.geumpumtabackend.badge.domain.BadgeType;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
7+
public record BadgeCreateRequest(
8+
@NotBlank String code,
9+
@NotBlank String name,
10+
@NotBlank String description,
11+
@NotBlank String iconUrl,
12+
@NotNull BadgeType badgeType,
13+
Long thresholdValue,
14+
Long rank
15+
) {
16+
}

0 commit comments

Comments
 (0)