Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions docs/lld/LLD-0022-weekly-challenge-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

## 1. 목적 / 배경

주간 챌린지는 그룹 단위로 특정 기간 동안 사진 인증을 수행하는 챌린지다.
주간 챌린지는 전역 원본을 한 번 운영하고, 각 그룹이 연결된 별도 진행 항목에서 사진 인증을 수행하는 챌린지다.
앱은 이번 주 챌린지 목록, 챌린지 상세, 그룹원 인증 이미지, 내 인증 업로드 결과를 조회할 수 있어야 한다.

## 2. 범위
Expand All @@ -23,27 +23,28 @@
- 주간 챌린지 상세 조회.
- 그룹원 인증 이미지 조회.
- 주간 챌린지 인증 imageKey와 이미지 crop region 저장.
- 운영자 주간 사진 챌린지 생성.
- 운영자 전역 주간 사진 챌린지 등록·조회·수정·삭제.
- 그룹 구성원 권한 검증.
- 중복 인증 방지.
- Swagger 성공/예외 응답 문서화.

### Out of scope

- 주간 챌린지 자동 배정/생성 배치.
- 완료된 전역 주간 챌린지의 재사용 정책.
- 챌린지 완료 자동 판정.
- 공유용 합성 이미지 생성.
- 주간 챌린지 완료 알림 발송.

## 3. 데이터 모델

- `challenge`: 주간 챌린지 마스터. `challengeType = PHOTO`.
- `group_challenge`: 그룹에서 특정 기간 동안 진행하는 챌린지 인스턴스.
- `challenge`: 사진 챌린지 제목과 설명 메타데이터. `challengeType = PHOTO`.
- `global_weekly_challenge`: 전역 운영 원본. `challenge_id`, 시작일, 마감일을 소유한다.
- `group_challenge`: 그룹에서 특정 기간 동안 진행하는 챌린지 인스턴스. 전역 항목은 `global_weekly_challenge_id`로 원본을 연결하고, 기존 그룹별 항목은 이 값이 `null`인 레거시 데이터로 유지한다.
- `challenge_proof`: 그룹 챌린지에 대한 회원별 사진 인증 기록.
- `image_key`: 원본 이미지 key.
- `crop_x`, `crop_y`, `crop_width`, `crop_height`: 원본 이미지 기준 관심 영역 정규화 좌표. 없으면 null.

운영자는 관리자 API로 임의의 `PHOTO` 타입 챌린지와 그룹 챌린지 인스턴스를 함께 생성한다.
운영자는 전역 관리자 API로 원본을 한 번 생성한다. 생성 시 모든 활성 그룹에 연결된 진행 인스턴스를 만들고, 새 그룹 생성 시 진행 중인 원본도 자동으로 연결한다.
관리자 API는 `X-Admin-Api-Key` 헤더가 `ADMIN_API_KEY` 환경변수와 일치해야 호출할 수 있다.

## 4. API 동작
Expand Down Expand Up @@ -109,10 +110,10 @@ POST /api/v1/groups/{groupId}/weekly-challenges/{groupChallengeId}/proofs

인증은 `IN_PROGRESS` 상태이며 시작일과 마감일 사이에 있는 챌린지에서만 가능하다.

### 운영자 주간 사진 챌린지 생성
### 운영자 전역 주간 사진 챌린지 관리

```http
POST /api/v1/admin/groups/{groupId}/weekly-challenges
POST /api/v1/admin/weekly-challenges
X-Admin-Api-Key: {ADMIN_API_KEY}
```

Expand All @@ -125,7 +126,15 @@ X-Admin-Api-Key: {ADMIN_API_KEY}
}
```

요청한 그룹에 사진 챌린지 템플릿과 진행 인스턴스를 생성한다. 현재는 운영 입력을 위한 API만 제공하며, 별도 관리자 웹 화면은 이 API 위에 추가할 수 있다.
전역 원본과 사진 챌린지 메타데이터를 생성하고, 모든 활성 그룹에 원본을 참조하는 진행 인스턴스를 생성한다.

```http
GET /api/v1/admin/weekly-challenges
PUT /api/v1/admin/weekly-challenges/{globalWeeklyChallengeId}
DELETE /api/v1/admin/weekly-challenges/{globalWeeklyChallengeId}
```

수정은 원본과 연결된 그룹 진행 인스턴스의 기간에 반영한다. 삭제는 원본과 사진 챌린지 메타데이터를 비활성화하며, 연결된 그룹 인증 데이터는 보존한다. 기존 그룹별 관리자 API는 레거시 그룹별 항목과의 호환성을 위해 유지한다.

## 5. 예외 코드

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public WeeklyChallengeListResult getWeeklyChallenges(Long groupId) {
.collect(Collectors.toMap(Challenge::getId, Function.identity()));

return new WeeklyChallengeListResult(groupChallenges.stream()
.filter(groupChallenge -> groupChallenge.getGlobalWeeklyChallengeId() == null)
.filter(groupChallenge -> challengesById.containsKey(groupChallenge.getChallengeId()))
.map(groupChallenge -> WeeklyChallengeResult.from(groupChallenge, challengesById.get(groupChallenge.getChallengeId())))
.toList());
Expand All @@ -82,6 +83,7 @@ public WeeklyChallengeResult updateWeeklyChallenge(
validateGroup(groupId);
GroupChallenge groupChallenge = groupChallengeRepository.findByIdAndGroupIdAndDeletedAtIsNull(groupChallengeId, groupId)
.orElseThrow(() -> new GeneralException(ErrorStatus.CHALLENGE_NOT_FOUND));
validateLegacyWeeklyChallenge(groupChallenge);
Challenge challenge = challengeRepository.findByIdAndChallengeTypeAndDeletedAtIsNull(
groupChallenge.getChallengeId(),
ChallengeType.PHOTO
Expand All @@ -97,6 +99,7 @@ public void deleteWeeklyChallenge(Long groupId, Long groupChallengeId) {
validateGroup(groupId);
GroupChallenge groupChallenge = groupChallengeRepository.findByIdAndGroupIdAndDeletedAtIsNull(groupChallengeId, groupId)
.orElseThrow(() -> new GeneralException(ErrorStatus.CHALLENGE_NOT_FOUND));
validateLegacyWeeklyChallenge(groupChallenge);
groupChallenge.delete();
}

Expand All @@ -106,6 +109,12 @@ private void validateGroup(Long groupId) {
.orElseThrow(() -> new GeneralException(ErrorStatus.GROUP_NOT_FOUND));
}

private void validateLegacyWeeklyChallenge(GroupChallenge groupChallenge) {
if (groupChallenge.getGlobalWeeklyChallengeId() != null) {
throw new GeneralException(ErrorStatus.CHALLENGE_VALIDATION_FAILED);
}
}

public record WeeklyChallengeCreateCommand(String title, String description, LocalDate startsOn, LocalDate endsOn) {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package cmc.mody.challenge.application;

import cmc.mody.challenge.domain.Challenge;
import cmc.mody.challenge.domain.ChallengeType;
import cmc.mody.challenge.domain.GlobalWeeklyChallenge;
import cmc.mody.challenge.domain.GroupChallenge;
import cmc.mody.challenge.infrastructure.repository.ChallengeRepository;
import cmc.mody.challenge.infrastructure.repository.GlobalWeeklyChallengeRepository;
import cmc.mody.challenge.infrastructure.repository.GroupChallengeRepository;
import cmc.mody.common.api.exception.GeneralException;
import cmc.mody.common.api.status.ErrorStatus;
import cmc.mody.common.id.IdGenerator;
import cmc.mody.grouping.domain.ModyGroup;
import cmc.mody.grouping.infrastructure.repository.ModyGroupRepository;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class GlobalWeeklyChallengeService {
private final IdGenerator idGenerator;
private final ChallengeRepository challengeRepository;
private final GlobalWeeklyChallengeRepository globalWeeklyChallengeRepository;
private final GroupChallengeRepository groupChallengeRepository;
private final ModyGroupRepository modyGroupRepository;

@Transactional
public GlobalWeeklyChallengeCreateResult create(GlobalWeeklyChallengeCommand command) {
Challenge challenge = challengeRepository.save(new Challenge(
idGenerator.nextId(),
ChallengeType.PHOTO,
command.title(),
command.description()
));
GlobalWeeklyChallenge globalWeeklyChallenge = globalWeeklyChallengeRepository.save(new GlobalWeeklyChallenge(
idGenerator.nextId(),
challenge.getId(),
command.startsOn(),
command.endsOn()
));
int linkedGroupCount = linkToActiveGroups(globalWeeklyChallenge);
return GlobalWeeklyChallengeCreateResult.from(globalWeeklyChallenge, challenge, linkedGroupCount);
}

@Transactional(readOnly = true)
public GlobalWeeklyChallengeListResult getAll() {
List<GlobalWeeklyChallengeResult> challenges = globalWeeklyChallengeRepository
.findByDeletedAtIsNullOrderByStartsOnDescIdDesc()
.stream()
.filter(GlobalWeeklyChallenge::isActive)
.map(this::toResult)
.toList();
return new GlobalWeeklyChallengeListResult(challenges);
}

@Transactional
public GlobalWeeklyChallengeResult update(Long globalWeeklyChallengeId, GlobalWeeklyChallengeCommand command) {
GlobalWeeklyChallenge globalWeeklyChallenge = getActiveGlobalWeeklyChallenge(globalWeeklyChallengeId);
Challenge challenge = getWeeklyChallenge(globalWeeklyChallenge.getChallengeId());
challenge.update(command.title(), command.description());
globalWeeklyChallenge.updatePeriod(command.startsOn(), command.endsOn());
groupChallengeRepository.findByGlobalWeeklyChallengeIdAndDeletedAtIsNull(globalWeeklyChallengeId)
.forEach(groupChallenge -> groupChallenge.updatePeriod(command.startsOn(), command.endsOn()));
return toResult(globalWeeklyChallenge);
}

@Transactional
public void delete(Long globalWeeklyChallengeId) {
GlobalWeeklyChallenge globalWeeklyChallenge = getActiveGlobalWeeklyChallenge(globalWeeklyChallengeId);
Challenge challenge = getWeeklyChallenge(globalWeeklyChallenge.getChallengeId());
globalWeeklyChallenge.delete();
challenge.delete();
}

@Transactional
public void initializeForNewGroup(Long groupId) {
LocalDate today = LocalDate.now();
globalWeeklyChallengeRepository
.findByStartsOnLessThanEqualAndEndsOnGreaterThanEqualAndDeletedAtIsNull(today, today)
.stream()
.filter(GlobalWeeklyChallenge::isActive)
.filter(globalWeeklyChallenge -> !groupChallengeRepository
.existsByGroupIdAndGlobalWeeklyChallengeIdAndDeletedAtIsNull(groupId, globalWeeklyChallenge.getId()))
.forEach(globalWeeklyChallenge -> groupChallengeRepository.save(new GroupChallenge(
idGenerator.nextId(),
groupId,
globalWeeklyChallenge.getChallengeId(),
globalWeeklyChallenge.getId(),
globalWeeklyChallenge.getStartsOn(),
globalWeeklyChallenge.getEndsOn()
)));
}

private int linkToActiveGroups(GlobalWeeklyChallenge globalWeeklyChallenge) {
List<ModyGroup> groups = modyGroupRepository.findByDeletedAtIsNullOrderByCreatedAtDesc()
.stream()
.filter(ModyGroup::isActive)
.toList();
List<GroupChallenge> groupChallenges = groups.stream()
.map(group -> new GroupChallenge(
idGenerator.nextId(),
group.getId(),
globalWeeklyChallenge.getChallengeId(),
globalWeeklyChallenge.getId(),
globalWeeklyChallenge.getStartsOn(),
globalWeeklyChallenge.getEndsOn()
))
.toList();
groupChallengeRepository.saveAll(groupChallenges);
return groupChallenges.size();
}

private GlobalWeeklyChallengeResult toResult(GlobalWeeklyChallenge globalWeeklyChallenge) {
Challenge challenge = getWeeklyChallenge(globalWeeklyChallenge.getChallengeId());
long linkedGroupCount = groupChallengeRepository
.countByGlobalWeeklyChallengeIdAndDeletedAtIsNull(globalWeeklyChallenge.getId());
return GlobalWeeklyChallengeResult.from(globalWeeklyChallenge, challenge, linkedGroupCount);
}

private GlobalWeeklyChallenge getActiveGlobalWeeklyChallenge(Long globalWeeklyChallengeId) {
return globalWeeklyChallengeRepository.findByIdAndDeletedAtIsNull(globalWeeklyChallengeId)
.filter(GlobalWeeklyChallenge::isActive)
.orElseThrow(() -> new GeneralException(ErrorStatus.CHALLENGE_NOT_FOUND));
}

private Challenge getWeeklyChallenge(Long challengeId) {
return challengeRepository.findByIdAndChallengeTypeAndDeletedAtIsNull(challengeId, ChallengeType.PHOTO)
.filter(Challenge::isActive)
.orElseThrow(() -> new GeneralException(ErrorStatus.CHALLENGE_NOT_FOUND));
}

public record GlobalWeeklyChallengeCommand(
String title,
String description,
LocalDate startsOn,
LocalDate endsOn
) {
}

public record GlobalWeeklyChallengeCreateResult(
Long globalWeeklyChallengeId,
Long challengeId,
String title,
String description,
LocalDate startsOn,
LocalDate endsOn,
int linkedGroupCount
) {
private static GlobalWeeklyChallengeCreateResult from(
GlobalWeeklyChallenge globalWeeklyChallenge,
Challenge challenge,
int linkedGroupCount
) {
return new GlobalWeeklyChallengeCreateResult(
globalWeeklyChallenge.getId(),
challenge.getId(),
challenge.getTitle(),
challenge.getDescription(),
globalWeeklyChallenge.getStartsOn(),
globalWeeklyChallenge.getEndsOn(),
linkedGroupCount
);
}
}

public record GlobalWeeklyChallengeListResult(List<GlobalWeeklyChallengeResult> challenges) {
}

public record GlobalWeeklyChallengeResult(
Long globalWeeklyChallengeId,
Long challengeId,
String title,
String description,
LocalDate startsOn,
LocalDate endsOn,
long linkedGroupCount
) {
private static GlobalWeeklyChallengeResult from(
GlobalWeeklyChallenge globalWeeklyChallenge,
Challenge challenge,
long linkedGroupCount
) {
return new GlobalWeeklyChallengeResult(
globalWeeklyChallenge.getId(),
challenge.getId(),
challenge.getTitle(),
challenge.getDescription(),
globalWeeklyChallenge.getStartsOn(),
globalWeeklyChallenge.getEndsOn(),
linkedGroupCount
);
}
}
}
43 changes: 43 additions & 0 deletions src/main/java/cmc/mody/challenge/domain/GlobalWeeklyChallenge.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmc.mody.challenge.domain;

import cmc.mody.common.domain.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(
name = "global_weekly_challenge",
indexes = {
@Index(name = "idx_global_weekly_challenge_period", columnList = "starts_on, ends_on")
}
)
public class GlobalWeeklyChallenge extends BaseEntity {
@Column(name = "challenge_id", nullable = false, unique = true)
private Long challengeId;

@Column(name = "starts_on", nullable = false)
private LocalDate startsOn;

@Column(name = "ends_on", nullable = false)
private LocalDate endsOn;

public GlobalWeeklyChallenge(Long id, Long challengeId, LocalDate startsOn, LocalDate endsOn) {
super(id);
this.challengeId = challengeId;
this.startsOn = startsOn;
this.endsOn = endsOn;
}

public void updatePeriod(LocalDate startsOn, LocalDate endsOn) {
this.startsOn = startsOn;
this.endsOn = endsOn;
}
}
15 changes: 15 additions & 0 deletions src/main/java/cmc/mody/challenge/domain/GroupChallenge.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public class GroupChallenge extends BaseEntity {
@Column(name = "challenge_id", nullable = false)
private Long challengeId;

@Column(name = "global_weekly_challenge_id")
private Long globalWeeklyChallengeId;

@Column(name = "starts_on", nullable = false)
private LocalDate startsOn;

Expand All @@ -47,9 +50,21 @@ public class GroupChallenge extends BaseEntity {
private LocalDateTime endedAt;

public GroupChallenge(Long id, Long groupId, Long challengeId, LocalDate startsOn, LocalDate endsOn) {
this(id, groupId, challengeId, null, startsOn, endsOn);
}

public GroupChallenge(
Long id,
Long groupId,
Long challengeId,
Long globalWeeklyChallengeId,
LocalDate startsOn,
LocalDate endsOn
) {
super(id);
this.groupId = groupId;
this.challengeId = challengeId;
this.globalWeeklyChallengeId = globalWeeklyChallengeId;
this.startsOn = startsOn;
this.endsOn = endsOn;
}
Expand Down
Loading
Loading