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
9 changes: 5 additions & 4 deletions linkareer-data-mapping-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

| 컬럼명 | 설명 |
|--------|------|
| `/상세링크` | 링커리어 상세 링크 |
| `상세링크` 또는 `/상세링크` | 링커리어 상세 링크 |
| `활동유형` | 대외활동 / 공모전/해커톤 / 교육 / 강연/세미나 |
| `출처` | 링커리어 |
| `제목` | 활동 제목 |
Expand Down Expand Up @@ -117,7 +117,7 @@

- `26.3 ~ 26.10`은 `2026-03-01 ~ 2026-10-01`처럼 월 첫날로 변환한다.
- `2026.05.17 ~ 2026.08.07`은 해당 날짜 그대로 변환한다.
- `-` 또는 비어 있으면 `start_date = recruitment_start_date`, `end_date = null`, `duration = -1`로 저장한다.
- `-` 또는 비어 있으면 `start_date = recruitment_start_date`, `end_date = null`, `duration = 0`으로 저장한다.
- 기간 계산이 가능하면 `duration`은 시작일과 종료일 사이의 일수로 저장한다.

### status
Expand All @@ -130,10 +130,11 @@
### URL, 이미지, 본문

- `홈페이지` -> `activity_homepage_url`
- `/상세링크` -> `activity_application_url`. 상대경로이면 링커리어 도메인을 붙여 절대 URL로 만든다.
- `상세링크` 또는 `/상세링크` -> `activity_application_url`. 상대경로이면 링커리어 도메인을 붙여 절대 URL로 만든다.
- `썸네일이미지` -> `activity_thumbnail_url`
- `상세내용` -> `description`
- `description`은 2000자 제한이다. 2000자를 초과하면 핵심 모집 정보가 남도록 1997자 이내로 줄이고 `...`을 붙인다.
- URL 필드는 255자 제한이다. `activity_homepage_url`이 255자를 초과하면 잘라서 깨진 URL을 저장하지 말고 `null`로 둔다.
- `description`은 2000자 제한이다. 이모지 등으로 서버 검증 길이가 달라질 수 있으므로 핵심 모집 정보가 남도록 1900자 이내로 줄이고 `...`을 붙인다.
- `benefit`은 본문에서 혜택이 명확히 분리될 때만 추출하고, 불명확하면 빈 문자열로 둔다.

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ interface ArchiveRepository : JpaRepository<Archive, Long> {
member: Member,
): Archive?

fun existsByActivityIdAndMemberIdAndDeletedAtIsNull(
activityId: Long,
memberId: Long,
): Boolean

@Query("SELECT a FROM Archive a JOIN FETCH a.activity WHERE a.member = :member AND a.activityProgressStatus = :status")
fun findByMemberAndProgressStatus(
@Param("member") member: Member,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ class ArchiveService(
archiveRepository
.findByIdAndMember(archiveId, member) ?: throw BusinessException(ErrorCode.NOT_FOUND_ARCHIVE)

fun existsActiveByActivityIdAndMemberId(
activityId: Long,
memberId: Long,
): Boolean = archiveRepository.existsByActivityIdAndMemberIdAndDeletedAtIsNull(activityId, memberId)

fun findCompletedArchives(
member: Member,
activityType: ActivityType?,
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/picklab/backend/common/model/ErrorCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ enum class ErrorCode(
* 활동 참여 도메인 관련
*/
NOT_FOUND_ACTIVITY_PARTICIPATION(HttpStatus.NOT_FOUND, "해당 활동에 대한 참여 이력이 존재하지 않습니다."),
ALREADY_EXISTS_ACTIVITY_PARTICIPATION(HttpStatus.BAD_REQUEST, "이미 해당 활동을 지원 완료로 표시했습니다."),
CANNOT_CANCEL_ACTIVITY_PARTICIPATION(HttpStatus.BAD_REQUEST, "리뷰 또는 아카이브가 있는 활동 참여는 취소할 수 없습니다."),
CANNOT_UPDATE_ACTIVITY_PROGRESS_STATUS(HttpStatus.BAD_REQUEST, "최종 합격 상태에서만 수료 여부를 수정할 수 있습니다."),

/**
* 아카이브 도메인 관련
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/picklab/backend/common/model/SuccessCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ enum class SuccessCode(
GET_BOOKMARKS(HttpStatus.OK, "북마크 목록 조회에 성공했습니다."),
INCREASE_VIEW_COUNT(HttpStatus.OK, "조회 수 증가에 성공했습니다."),

// Activity Participation 관련
CREATE_ACTIVITY_PARTICIPATION(HttpStatus.CREATED, "활동을 지원 완료로 표시했습니다."),
DELETE_ACTIVITY_PARTICIPATION(HttpStatus.OK, "활동 지원 완료 표시를 취소했습니다."),
UPDATE_ACTIVITY_PARTICIPATION(HttpStatus.OK, "활동 결과를 수정했습니다."),
GET_ACTIVITY_PARTICIPATIONS(HttpStatus.OK, "활동 결과 목록 조회에 성공했습니다."),
GET_ACTIVITY_PARTICIPATION_SUMMARY(HttpStatus.OK, "활동 결과 현황 조회에 성공했습니다."),

// Archive 관련
CREATE_ARCHIVE_SUCCESS(HttpStatus.OK, "아카이브 생성에 성공했습니다."),
UPDATE_ARCHIVE_SUCCESS(HttpStatus.OK, "아카이브 상태 수정에 성공했습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,101 @@
package picklab.backend.participation.application

import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import picklab.backend.activity.domain.service.ActivityService
import picklab.backend.archive.domain.service.ArchiveService
import picklab.backend.common.model.BusinessException
import picklab.backend.common.model.ErrorCode
import picklab.backend.common.model.PageResponse
import picklab.backend.common.model.toPageResponse
import picklab.backend.member.domain.MemberService
import picklab.backend.participation.domain.enums.ApplicationStatus
import picklab.backend.participation.domain.enums.ProgressStatus
import picklab.backend.participation.domain.service.ActivityParticipationService
import picklab.backend.participation.entrypoint.response.ActivityParticipationResultResponse
import picklab.backend.participation.entrypoint.response.ActivityParticipationSummaryResponse
import picklab.backend.review.domain.service.ReviewService

@Component
class ActivityParticipationUseCase
class ActivityParticipationUseCase(
private val memberService: MemberService,
private val activityService: ActivityService,
private val participationService: ActivityParticipationService,
private val reviewService: ReviewService,
private val archiveService: ArchiveService,
) {
@Transactional
fun createAppliedParticipation(
memberId: Long,
activityId: Long,
) {
val member = memberService.findActiveMember(memberId)
val activity = activityService.mustFindById(activityId)

participationService.createAppliedParticipation(member, activity)
}

@Transactional
fun cancelAppliedParticipation(
memberId: Long,
activityId: Long,
) {
val participation = participationService.mustFindByMemberIdAndActivityId(memberId, activityId)
val hasReview = reviewService.existsActiveByActivityIdAndMemberId(activityId, memberId)
val hasArchive = archiveService.existsActiveByActivityIdAndMemberId(activityId, memberId)
if (hasReview || hasArchive) {
throw BusinessException(ErrorCode.CANNOT_CANCEL_ACTIVITY_PARTICIPATION)
}

participationService.delete(participation)
}

@Transactional
fun updateApplicationStatus(
memberId: Long,
participationId: Long,
applicationStatus: ApplicationStatus,
) {
val participation = participationService.mustFindByIdAndMemberId(participationId, memberId)
participationService.updateApplicationStatus(participation, applicationStatus)
}

@Transactional
fun updateProgressStatus(
memberId: Long,
participationId: Long,
progressStatus: ProgressStatus,
) {
val participation = participationService.mustFindByIdAndMemberId(participationId, memberId)
participationService.updateProgressStatus(participation, progressStatus)
}

@Transactional(readOnly = true)
fun getResults(
memberId: Long,
applicationStatuses: List<ApplicationStatus>?,
page: Int,
size: Int,
): PageResponse<ActivityParticipationResultResponse> {
memberService.findActiveMember(memberId)
val pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending())

return participationService
.findResults(memberId, applicationStatuses, pageable)
.toPageResponse { ActivityParticipationResultResponse.from(it) }
}

@Transactional(readOnly = true)
fun getSummary(memberId: Long): ActivityParticipationSummaryResponse {
memberService.findActiveMember(memberId)

return ActivityParticipationSummaryResponse(
appliedCount = participationService.countAll(memberId),
acceptedCount = participationService.countByApplicationStatus(memberId, ApplicationStatus.ACCEPTED),
rejectedCount = participationService.countByApplicationStatus(memberId, ApplicationStatus.REJECTED),
completedCount = participationService.countByProgressStatus(memberId, ProgressStatus.COMPLETED),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class ActivityParticipation(
var applicationStatus: ApplicationStatus,
@Column(name = "progress_status", length = 50, nullable = false)
@Enumerated(EnumType.STRING)
@Comment("진행 상태 (진행 중 / 수료 완료 / 중도 포기)")
@Comment("진행 상태 (미선택 / 진행 중 / 수료 완료 / 중도 포기)")
var progressStatus: ProgressStatus,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
Expand All @@ -37,6 +37,17 @@ class ActivityParticipation(
@JoinColumn(name = "activity_id", nullable = false)
val activity: Activity,
) : SoftDeleteEntity() {
fun updateApplicationStatus(applicationStatus: ApplicationStatus) {
this.applicationStatus = applicationStatus
if (applicationStatus != ApplicationStatus.ACCEPTED) {
this.progressStatus = ProgressStatus.NOT_SELECTED
}
}

fun updateProgressStatus(progressStatus: ProgressStatus) {
this.progressStatus = progressStatus
}

fun canWriteReview(): Boolean =
progressStatus == ProgressStatus.COMPLETED ||
progressStatus == ProgressStatus.DROPPED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package picklab.backend.participation.domain.enums
enum class ProgressStatus(
val label: String,
) {
NOT_SELECTED("미선택"),
IN_PROGRESSING("진행 중"),
COMPLETED("수료 완료"),
DROPPED("중도 포기"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
package picklab.backend.participation.domain.repository

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import picklab.backend.participation.domain.entity.ActivityParticipation
import picklab.backend.participation.domain.enums.ApplicationStatus
import picklab.backend.participation.domain.enums.ProgressStatus

interface ActivityParticipationRepository : JpaRepository<ActivityParticipation, Long> {
fun findByMemberIdAndActivityId(
memberId: Long,
activityId: Long,
): ActivityParticipation?

fun findAllByMemberId(
memberId: Long,
pageable: Pageable,
): Page<ActivityParticipation>

fun findAllByMemberIdAndApplicationStatusIn(
memberId: Long,
applicationStatuses: List<ApplicationStatus>,
pageable: Pageable,
): Page<ActivityParticipation>

fun countByMemberId(memberId: Long): Long

fun countByMemberIdAndApplicationStatus(
memberId: Long,
applicationStatus: ApplicationStatus,
): Long

fun countByMemberIdAndProgressStatus(
memberId: Long,
progressStatus: ProgressStatus,
): Long
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,103 @@
package picklab.backend.participation.domain.service

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import picklab.backend.activity.domain.entity.Activity
import picklab.backend.common.model.BusinessException
import picklab.backend.common.model.ErrorCode
import picklab.backend.member.domain.entity.Member
import picklab.backend.participation.domain.entity.ActivityParticipation
import picklab.backend.participation.domain.enums.ApplicationStatus
import picklab.backend.participation.domain.enums.ProgressStatus
import picklab.backend.participation.domain.repository.ActivityParticipationRepository

@Service
class ActivityParticipationService(
private val participationRepository: ActivityParticipationRepository,
) {
fun createAppliedParticipation(
member: Member,
activity: Activity,
): ActivityParticipation {
if (participationRepository.findByMemberIdAndActivityId(member.id, activity.id) != null) {
throw BusinessException(ErrorCode.ALREADY_EXISTS_ACTIVITY_PARTICIPATION)
}

return participationRepository.save(
ActivityParticipation(
applicationStatus = ApplicationStatus.APPLIED,
progressStatus = ProgressStatus.NOT_SELECTED,
member = member,
activity = activity,
),
)
}

fun mustFindByIdAndMemberId(
id: Long,
memberId: Long,
): ActivityParticipation =
participationRepository
.findById(id)
.filter { it.member.id == memberId }
.orElseThrow { BusinessException(ErrorCode.NOT_FOUND_ACTIVITY_PARTICIPATION) }

fun mustFindByMemberIdAndActivityId(
memberId: Long,
activityId: Long,
): ActivityParticipation =
participationRepository.findByMemberIdAndActivityId(memberId, activityId)
?: throw BusinessException(ErrorCode.NOT_FOUND_ACTIVITY_PARTICIPATION)

fun findResults(
memberId: Long,
applicationStatuses: List<ApplicationStatus>?,
pageable: Pageable,
): Page<ActivityParticipation> =
if (applicationStatuses.isNullOrEmpty()) {
participationRepository.findAllByMemberId(memberId, pageable)
} else {
participationRepository.findAllByMemberIdAndApplicationStatusIn(memberId, applicationStatuses, pageable)
}

fun countAll(memberId: Long): Long = participationRepository.countByMemberId(memberId)

fun countByApplicationStatus(
memberId: Long,
applicationStatus: ApplicationStatus,
): Long = participationRepository.countByMemberIdAndApplicationStatus(memberId, applicationStatus)

fun countByProgressStatus(
memberId: Long,
progressStatus: ProgressStatus,
): Long = participationRepository.countByMemberIdAndProgressStatus(memberId, progressStatus)

fun updateApplicationStatus(
participation: ActivityParticipation,
applicationStatus: ApplicationStatus,
) {
participation.updateApplicationStatus(applicationStatus)
participationRepository.save(participation)
}

fun updateProgressStatus(
participation: ActivityParticipation,
progressStatus: ProgressStatus,
) {
if (participation.applicationStatus != ApplicationStatus.ACCEPTED) {
throw BusinessException(ErrorCode.CANNOT_UPDATE_ACTIVITY_PROGRESS_STATUS)
}

participation.updateProgressStatus(progressStatus)
participationRepository.save(participation)
}

fun delete(participation: ActivityParticipation) {
participation.delete()
participationRepository.save(participation)
}

fun validateCanWriteReview(
memberId: Long,
activityId: Long,
Expand Down
Loading
Loading