diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 5d6813c..8dedf5f 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -71,11 +71,11 @@ jobs: scripts/verify-production-migration-005.sh scripts/verify-production-migration-006.sh - - name: Verify production migrations 007-013 schema + - name: Verify production migrations 007-015 schema working-directory: ${{ github.workspace }} run: | - chmod +x scripts/run-production-migrations.sh scripts/verify-production-migration-007-013.sh - scripts/verify-production-migration-007-013.sh + chmod +x scripts/run-production-migrations.sh scripts/verify-production-migration-007-015.sh + scripts/verify-production-migration-007-015.sh - run: ./gradlew spotlessCheck - run: ./gradlew test build -x spotlessCheck diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 94532d0..08e1130 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -107,11 +107,11 @@ jobs: scripts/verify-production-migration-005.sh scripts/verify-production-migration-006.sh - - name: Verify production migrations 007-013 schema + - name: Verify production migrations 007-015 schema working-directory: ${{ github.workspace }} run: | - chmod +x scripts/run-production-migrations.sh scripts/verify-production-migration-007-013.sh - scripts/verify-production-migration-007-013.sh + chmod +x scripts/run-production-migrations.sh scripts/verify-production-migration-007-015.sh + scripts/verify-production-migration-007-015.sh - name: Build and Test with Gradle run: ./gradlew test build -x spotlessCheck diff --git a/.gitignore b/.gitignore index 44a9d17..5850adc 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ frontend/.next/ frontend/node_modules/ frontend/out/ frontend/*.log +frontend/next-env.d.ts diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java index f87f4a9..c74b8d5 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java @@ -6,6 +6,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import com.ikae.snowthing.domain.member.validation.NicknamePolicy; + import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,7 +17,7 @@ public class MemberProfileUpdateRequest { @NotBlank(message = "닉네임은 필수 입력값입니다.") - @Pattern(regexp = "^[a-zA-Z0-9가-힣]{2,10}$", message = "닉네임은 2자 이상 10자 이하의 한글, 영문, 숫자이어야 합니다.") + @Pattern(regexp = NicknamePolicy.REGEXP, message = NicknamePolicy.MESSAGE) private String nickname; private String bio; diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java index 4e01914..dde27ce 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Size; import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.validation.NicknamePolicy; import lombok.Builder; import lombok.Getter; @@ -35,7 +36,7 @@ public class MemberSignUpRequest { private String emailVerificationToken; @NotBlank(message = "닉네임은 필수 입력값입니다.") - @jakarta.validation.constraints.Size(min = 2, max = 20, message = "닉네임은 2자 이상 20자 이하이어야 합니다.") + @Pattern(regexp = NicknamePolicy.REGEXP, message = NicknamePolicy.MESSAGE) private String nickname; private String bio; diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepository.java index a64265a..bf43882 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepository.java @@ -2,7 +2,12 @@ import java.util.Optional; +import jakarta.persistence.LockModeType; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.ikae.snowthing.domain.member.entity.Member; @@ -15,4 +20,8 @@ public interface MemberRepository extends JpaRepository, MemberRep Optional findByPublicId(String publicId); Optional findByEmail(String email); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT m FROM Member m WHERE m.id = :memberId") + Optional findByIdForUpdate(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java index ce3b8ed..0532b12 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java @@ -21,7 +21,9 @@ public class MasterDataService { private final RidingStyleRepository ridingStyleRepository; public List getAllResorts() { - return resortRepository.findAll().stream().map(ResortResponse::from).toList(); + return resortRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc().stream() + .map(ResortResponse::from) + .toList(); } public List getAllRidingStyles() { diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/validation/NicknamePolicy.java b/backend/src/main/java/com/ikae/snowthing/domain/member/validation/NicknamePolicy.java new file mode 100644 index 0000000..dc36038 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/validation/NicknamePolicy.java @@ -0,0 +1,11 @@ +package com.ikae.snowthing.domain.member.validation; + +public final class NicknamePolicy { + + public static final int MIN_LENGTH = 2; + public static final int MAX_LENGTH = 10; + public static final String REGEXP = "^[a-zA-Z0-9가-힣]{2,10}$"; + public static final String MESSAGE = "닉네임은 2자 이상 10자 이하의 한글, 영문, 숫자이어야 합니다."; + + private NicknamePolicy() {} +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java b/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java index 5907dc7..e34f7ab 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java @@ -35,6 +35,8 @@ public class PostService { private static final String ANONYMOUS_CATEGORY_CODE = "ANONYMOUS"; private static final String MARKET_CATEGORY_CODE = "MARKET"; + private static final String CREW_CATEGORY_CODE = "CREW"; + private static final String SEASON_ROOM_CATEGORY_CODE = "SEASON_ROOM"; private static final String DEFAULT_WRITER_IP = "127.0.0.1"; private static final int FIRST_IMAGE_SORT_ORDER = 1; private static final int MAX_OFFSET_PAGE = 100; @@ -59,6 +61,15 @@ public PostResponse createPost( () -> new CustomAuthException(ErrorCode.POST_CATEGORY_NOT_FOUND)); rejectMarketCategory(category); + if (isAnonymousDisallowedCategory(category.getCode())) { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); + } + if (request.isAnonymous()) { + throw new CustomAuthException(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } + } + Member member = null; String encodedPassword = null; @@ -208,6 +219,10 @@ public PostResponse updatePost( rejectMarketPost(post); rejectMarketCategory(category); + if (isAnonymousDisallowedCategory(category.getCode()) && post.isAnonymous()) { + throw new CustomAuthException(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } + post.update(request.title(), request.content(), category); post.replaceImages(toPostImages(request.imageUrls())); return PostResponse.from(post); @@ -304,6 +319,11 @@ private void rejectMarketCategory(PostCategory category) { } } + private boolean isAnonymousDisallowedCategory(String categoryCode) { + return CREW_CATEGORY_CODE.equalsIgnoreCase(categoryCode) + || SEASON_ROOM_CATEGORY_CODE.equalsIgnoreCase(categoryCode); + } + private boolean isAdmin(CustomUserDetails userDetails) { return userDetails != null && userDetails.getAuthorities().stream() diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportAdminController.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportAdminController.java new file mode 100644 index 0000000..7605640 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportAdminController.java @@ -0,0 +1,35 @@ +package com.ikae.snowthing.domain.resortreport.controller; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ikae.snowthing.domain.resortreport.dto.ResortReportModerationStatusUpdateRequest; +import com.ikae.snowthing.domain.resortreport.service.ResortReportService; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/admin/resort-reports") +@RequiredArgsConstructor +public class ResortReportAdminController { + + private final ResortReportService resortReportService; + + @PatchMapping("/{reportId}/moderation-status") + public ResponseEntity updateModerationStatus( + @PathVariable Long reportId, + @Valid @RequestBody ResortReportModerationStatusUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + resortReportService.updateModerationStatus( + reportId, request.moderationStatus(), userDetails); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java new file mode 100644 index 0000000..381d86d --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java @@ -0,0 +1,56 @@ +package com.ikae.snowthing.domain.resortreport.controller; + +import jakarta.validation.Valid; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import com.ikae.snowthing.domain.resortreport.dto.ResortReportCreateRequest; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportResponse; +import com.ikae.snowthing.domain.resortreport.service.ResortReportService; +import com.ikae.snowthing.global.common.dto.CursorPageResponse; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/resort-reports") +@RequiredArgsConstructor +public class ResortReportController { + + private final ResortReportService resortReportService; + + @PostMapping + public ResponseEntity createReport( + @Valid @RequestBody ResortReportCreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + if (userDetails == null || userDetails.getMember() == null) { + throw new CustomException(ErrorCode.INVALID_CREDENTIALS); + } + ResortReportResponse response = + resortReportService.createReport(userDetails.getMember().getId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/today") + public ResponseEntity> getTodayReports( + @RequestParam(required = false) Long resortId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + CursorPageResponse responses = + resortReportService.getTodayReports(resortId, page, size, userDetails); + return ResponseEntity.ok(responses); + } + + @DeleteMapping("/{reportId}") + public ResponseEntity deleteReport( + @PathVariable Long reportId, @AuthenticationPrincipal CustomUserDetails userDetails) { + resortReportService.deleteReport(reportId, userDetails); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportCreateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportCreateRequest.java new file mode 100644 index 0000000..b30a5b3 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportCreateRequest.java @@ -0,0 +1,25 @@ +package com.ikae.snowthing.domain.resortreport.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ResortReportCreateRequest { + + @NotNull(message = "리조트를 선택해 주세요.") + private Long resortId; + + @NotBlank(message = "설질 제보 내용을 입력해 주세요.") + @Size(max = 100, message = "설질 제보 내용은 최대 100자까지 작성할 수 있습니다.") + private String content; +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportModerationStatusUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportModerationStatusUpdateRequest.java new file mode 100644 index 0000000..9a6c518 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportModerationStatusUpdateRequest.java @@ -0,0 +1,8 @@ +package com.ikae.snowthing.domain.resortreport.dto; + +import jakarta.validation.constraints.NotNull; + +import com.ikae.snowthing.domain.resortreport.entity.ResortReportStatus; + +public record ResortReportModerationStatusUpdateRequest( + @NotNull ResortReportStatus moderationStatus) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportResponse.java new file mode 100644 index 0000000..074a53b --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportResponse.java @@ -0,0 +1,36 @@ +package com.ikae.snowthing.domain.resortreport.dto; + +import java.time.OffsetDateTime; +import java.time.ZoneId; + +import com.ikae.snowthing.domain.resortreport.entity.ResortReport; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ResortReportResponse { + + private final Long reportId; + private final Long resortId; + private final String resortName; + private final String resortCode; + private final String authorNickname; + private final String content; + private final OffsetDateTime createdAt; + private final boolean canDelete; + + public static ResortReportResponse from(ResortReport report, boolean canDelete) { + return ResortReportResponse.builder() + .reportId(report.getId()) + .resortId(report.getResort().getId()) + .resortName(report.getResort().getName()) + .resortCode(report.getResort().getCode()) + .authorNickname(report.getAuthor().getNickname()) + .content(report.getContent()) + .createdAt(report.getCreatedAt().atZone(ZoneId.of("Asia/Seoul")).toOffsetDateTime()) + .canDelete(canDelete) + .build(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReport.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReport.java new file mode 100644 index 0000000..d338fd1 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReport.java @@ -0,0 +1,100 @@ +package com.ikae.snowthing.domain.resortreport.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.*; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "resort_report", + indexes = { + @Index( + name = "idx_resort_report_status_created", + columnList = "status, created_at DESC, report_id DESC"), + @Index( + name = "idx_resort_report_resort_status_created", + columnList = "resort_id, status, created_at DESC, report_id DESC"), + @Index( + name = "idx_resort_report_member_created", + columnList = "member_id, created_at DESC") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ResortReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resort_id", nullable = false) + private Resort resort; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member author; + + @Column(name = "content", nullable = false, length = 100) + private String content; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ResortReportStatus status; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + public ResortReport(Resort resort, Member author, String content, LocalDateTime createdAt) { + if (author == null) { + throw new CustomException(ErrorCode.MEMBER_NOT_FOUND); + } + if (resort == null || !resort.isActive()) { + throw new CustomException(ErrorCode.RESORT_NOT_FOUND); + } + this.resort = resort; + this.author = author; + this.content = validateAndNormalizeContent(content); + this.createdAt = createdAt != null ? createdAt : LocalDateTime.now(); + this.status = ResortReportStatus.NORMAL; + } + + public void softDelete(LocalDateTime deletedAt) { + this.status = ResortReportStatus.DELETED; + this.deletedAt = deletedAt; + } + + public void changeModerationStatus(ResortReportStatus status) { + this.status = status; + this.deletedAt = null; + } + + public boolean isDeleted() { + return status == ResortReportStatus.DELETED; + } + + private static String validateAndNormalizeContent(String rawContent) { + if (rawContent == null) { + throw new CustomException(ErrorCode.INVALID_RESORT_REPORT_CONTENT); + } + String trimmed = rawContent.trim(); + if (trimmed.isEmpty() || trimmed.length() > 100) { + throw new CustomException(ErrorCode.INVALID_RESORT_REPORT_CONTENT); + } + return trimmed; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReportStatus.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReportStatus.java new file mode 100644 index 0000000..a213f1c --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReportStatus.java @@ -0,0 +1,8 @@ +package com.ikae.snowthing.domain.resortreport.entity; + +public enum ResortReportStatus { + NORMAL, + HIDDEN, + BLOCKED, + DELETED +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java new file mode 100644 index 0000000..e0a237e --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java @@ -0,0 +1,63 @@ +package com.ikae.snowthing.domain.resortreport.repository; + +import java.time.LocalDateTime; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ikae.snowthing.domain.resortreport.entity.ResortReport; +import com.ikae.snowthing.domain.resortreport.entity.ResortReportStatus; + +public interface ResortReportRepository extends JpaRepository { + + @Query( + value = + "SELECT r FROM ResortReport r " + + "JOIN FETCH r.resort " + + "JOIN FETCH r.author " + + "WHERE r.status = :status " + + "AND r.createdAt >= :start AND r.createdAt < :end " + + "ORDER BY r.createdAt DESC, r.id DESC", + countQuery = + "SELECT COUNT(r) FROM ResortReport r " + + "WHERE r.status = :status " + + "AND r.createdAt >= :start AND r.createdAt < :end") + Page findTodayReports( + @Param("status") ResortReportStatus status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + Pageable pageable); + + @Query( + value = + "SELECT r FROM ResortReport r " + + "JOIN FETCH r.resort " + + "JOIN FETCH r.author " + + "WHERE r.resort.id = :resortId AND r.status = :status " + + "AND r.createdAt >= :start AND r.createdAt < :end " + + "ORDER BY r.createdAt DESC, r.id DESC", + countQuery = + "SELECT COUNT(r) FROM ResortReport r " + + "WHERE r.resort.id = :resortId AND r.status = :status " + + "AND r.createdAt >= :start AND r.createdAt < :end") + Page findTodayReportsByResortId( + @Param("resortId") Long resortId, + @Param("status") ResortReportStatus status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + Pageable pageable); + + long countByAuthorIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThan( + Long memberId, LocalDateTime start, LocalDateTime end); + + @Query("SELECT r FROM ResortReport r JOIN FETCH r.author WHERE r.id = :reportId") + java.util.Optional findByIdWithAuthor(@Param("reportId") Long reportId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM ResortReport r WHERE r.createdAt < :cutoff") + int deleteCreatedBefore(@Param("cutoff") LocalDateTime cutoff); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportCleanupService.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportCleanupService.java new file mode 100644 index 0000000..bbaac5c --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportCleanupService.java @@ -0,0 +1,30 @@ +package com.ikae.snowthing.domain.resortreport.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.resortreport.repository.ResortReportRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ResortReportCleanupService { + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private final ResortReportRepository resortReportRepository; + private final Clock clock; + + @Transactional + @Scheduled(cron = "${snowthing.resort-report.cleanup-cron:0 40 4 1 * *}", zone = "Asia/Seoul") + public int deletePreviousMonthReports() { + LocalDate currentMonth = LocalDate.now(clock.withZone(KST_ZONE)).withDayOfMonth(1); + return resortReportRepository.deleteCreatedBefore(currentMonth.atStartOfDay()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java new file mode 100644 index 0000000..6a8e73b --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java @@ -0,0 +1,196 @@ +package com.ikae.snowthing.domain.resortreport.service; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.ZoneId; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportCreateRequest; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportResponse; +import com.ikae.snowthing.domain.resortreport.entity.ResortReport; +import com.ikae.snowthing.domain.resortreport.entity.ResortReportStatus; +import com.ikae.snowthing.domain.resortreport.repository.ResortReportRepository; +import com.ikae.snowthing.global.common.dto.CursorPageResponse; +import com.ikae.snowthing.global.common.dto.CursorPageResponse.PageInfo; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ResortReportService { + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + private static final int MAX_PAGE = 100; + private static final int MAX_PAGE_SIZE = 100; + private static final int DAILY_REPORT_LIMIT = 5; + + private final ResortReportRepository resortReportRepository; + private final ResortRepository resortRepository; + private final MemberRepository memberRepository; + private final Clock clock; + + @Transactional + public ResortReportResponse createReport(Long memberId, ResortReportCreateRequest request) { + LocalDateTime now = LocalDateTime.now(clock.withZone(KST_ZONE)); + validateSeason(now.toLocalDate()); + + Member author = + memberRepository + .findByIdForUpdate(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + LocalDateTime todayStart = now.toLocalDate().atStartOfDay(); + long todayCount = + resortReportRepository + .countByAuthorIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThan( + memberId, todayStart, todayStart.plusDays(1)); + if (todayCount >= DAILY_REPORT_LIMIT) { + throw new CustomException(ErrorCode.RESORT_REPORT_DAILY_LIMIT_EXCEEDED); + } + + Resort resort = + resortRepository + .findById(request.getResortId()) + .filter(Resort::isActive) + .orElseThrow(() -> new CustomException(ErrorCode.RESORT_NOT_FOUND)); + + ResortReport report = + ResortReport.builder() + .resort(resort) + .author(author) + .content(request.getContent()) + .createdAt(now) + .build(); + + ResortReport savedReport = resortReportRepository.save(report); + return ResortReportResponse.from(savedReport, true); + } + + public CursorPageResponse getTodayReports( + Long resortId, int page, int size, CustomUserDetails userDetails) { + validatePage(page, size); + LocalDate today = LocalDate.now(clock.withZone(KST_ZONE)); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime tomorrowStart = today.plusDays(1).atStartOfDay(); + + Page reports; + if (resortId != null) { + reports = + resortReportRepository.findTodayReportsByResortId( + resortId, + ResortReportStatus.NORMAL, + todayStart, + tomorrowStart, + PageRequest.of(page - 1, size)); + } else { + reports = + resortReportRepository.findTodayReports( + ResortReportStatus.NORMAL, + todayStart, + tomorrowStart, + PageRequest.of(page - 1, size)); + } + + List content = + reports.getContent().stream() + .map( + report -> + ResortReportResponse.from( + report, canDelete(report, userDetails))) + .toList(); + return new CursorPageResponse<>( + content, + PageInfo.ofOffset( + page, + reports.getTotalPages(), + reports.getTotalElements(), + reports.hasNext(), + size)); + } + + @Transactional + public void deleteReport(Long reportId, CustomUserDetails userDetails) { + Member member = requireMember(userDetails); + ResortReport report = + resortReportRepository + .findByIdWithAuthor(reportId) + .filter(candidate -> !candidate.isDeleted()) + .orElseThrow(() -> new CustomException(ErrorCode.RESORT_REPORT_NOT_FOUND)); + if (!report.getAuthor().getId().equals(member.getId()) && !isAdmin(member)) { + throw new CustomException(ErrorCode.ACCESS_DENIED); + } + report.softDelete(LocalDateTime.now(clock.withZone(KST_ZONE))); + } + + @Transactional + public void updateModerationStatus( + Long reportId, ResortReportStatus status, CustomUserDetails userDetails) { + Member member = requireMember(userDetails); + if (!isAdmin(member)) { + throw new CustomException(ErrorCode.ACCESS_DENIED); + } + if (status != ResortReportStatus.NORMAL + && status != ResortReportStatus.HIDDEN + && status != ResortReportStatus.BLOCKED) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + ResortReport report = + resortReportRepository + .findByIdWithAuthor(reportId) + .filter(candidate -> !candidate.isDeleted()) + .orElseThrow(() -> new CustomException(ErrorCode.RESORT_REPORT_NOT_FOUND)); + report.changeModerationStatus(status); + } + + private void validateSeason(LocalDate date) { + Month month = date.getMonth(); + if (month.getValue() >= Month.MAY.getValue() + && month.getValue() <= Month.SEPTEMBER.getValue()) { + throw new CustomException(ErrorCode.RESORT_REPORT_SEASON_CLOSED); + } + } + + private void validatePage(int page, int size) { + if (page < 1 || page > MAX_PAGE) { + throw new CustomException(ErrorCode.RESORT_REPORT_PAGE_LIMIT_EXCEEDED); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new CustomException(ErrorCode.RESORT_REPORT_INVALID_PAGE_SIZE); + } + } + + private Member requireMember(CustomUserDetails userDetails) { + if (userDetails == null || userDetails.getMember() == null) { + throw new CustomException(ErrorCode.INVALID_CREDENTIALS); + } + return userDetails.getMember(); + } + + private boolean canDelete(ResortReport report, CustomUserDetails userDetails) { + if (userDetails == null || userDetails.getMember() == null) { + return false; + } + Member member = userDetails.getMember(); + return report.getAuthor().getId().equals(member.getId()) || isAdmin(member); + } + + private boolean isAdmin(Member member) { + return member.getRole() == Role.ROLE_ADMIN; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/common/dto/CursorPageResponse.java b/backend/src/main/java/com/ikae/snowthing/global/common/dto/CursorPageResponse.java index 01f8128..0ee7533 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/common/dto/CursorPageResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/global/common/dto/CursorPageResponse.java @@ -4,6 +4,10 @@ /** 웹 Offset 및 모바일 Keyset Cursor 공통 Wrapper 응답 DTO */ public record CursorPageResponse(List content, PageInfo pageInfo) { + public CursorPageResponse { + content = List.copyOf(content); + } + public record PageInfo( Integer page, // 웹용: 현재 페이지 번호 (모바일 시 null) Integer totalPages, // 웹용: 전체 페이지 수 (모바일 시 null) diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java index 77adefa..5daee8b 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java @@ -150,6 +150,15 @@ public void run(String... args) { categoryRepository.save(PostCategory.builder().name("중고장터").code("MARKET").build()); } + if (categoryRepository.findByCode("CREW").isEmpty()) { + categoryRepository.save(PostCategory.builder().name("동호회 모집").code("CREW").build()); + } + + if (categoryRepository.findByCode("SEASON_ROOM").isEmpty()) { + categoryRepository.save( + PostCategory.builder().name("시즌방 모집").code("SEASON_ROOM").build()); + } + if (marketCategoryRepository.count() == 0) { marketCategoryRepository.saveAll( List.of( diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java index aa6d030..9c1c3a0 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java @@ -171,7 +171,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { HttpMethod.GET, "/api/v1/market-listings/preview", "/api/v1/carpools/**", - "/api/carpools/**") + "/api/carpools/**", + "/api/v1/resort-reports/**") .permitAll() .requestMatchers( HttpMethod.POST, diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index 3be54d7..e4f6437 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -34,6 +34,7 @@ public enum ErrorCode { HttpStatus.BAD_REQUEST, "POST_005", "게시판 조회의 최대 한계선은 100페이지(2,000개 글)까지입니다. 더 이전 글은 검색 기능을 이용해 주세요."), + ANONYMOUS_POST_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "POST_006", "해당 게시판은 익명 작성을 지원하지 않습니다."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않거나 이미 삭제된 댓글입니다."), PARENT_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_002", "존재하지 않는 부모 댓글입니다."), INVALID_COMMENT_PARENT(HttpStatus.BAD_REQUEST, "COMMENT_003", "동일한 게시글의 댓글에만 대댓글을 달 수 있습니다."), @@ -98,6 +99,18 @@ public enum ErrorCode { FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE_002", "파일 크기는 최대 5MB까지 업로드 가능합니다."), FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE_003", "존재하지 않는 파일입니다."), FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_004", "파일 업로드에 실패했습니다."), + RESORT_NOT_FOUND(HttpStatus.NOT_FOUND, "RESORT_001", "존재하지 않거나 비활성화된 리조트입니다."), + INVALID_RESORT_REPORT_CONTENT( + HttpStatus.BAD_REQUEST, "REPORT_001", "설질 제보 내용은 1자 이상 100자 이하여야 합니다."), + RESORT_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "REPORT_002", "존재하지 않거나 확인할 수 없는 설질 제보입니다."), + RESORT_REPORT_DAILY_LIMIT_EXCEEDED( + HttpStatus.TOO_MANY_REQUESTS, "REPORT_003", "설질 제보는 하루에 최대 5개까지 작성할 수 있습니다."), + RESORT_REPORT_SEASON_CLOSED( + HttpStatus.FORBIDDEN, "REPORT_004", "설질 제보는 10월부터 다음 해 4월까지만 작성할 수 있습니다."), + RESORT_REPORT_PAGE_LIMIT_EXCEEDED( + HttpStatus.BAD_REQUEST, "REPORT_005", "설질 제보는 최대 100페이지까지 조회할 수 있습니다."), + RESORT_REPORT_INVALID_PAGE_SIZE( + HttpStatus.BAD_REQUEST, "REPORT_006", "설질 제보 페이지 크기는 1 이상 100 이하이어야 합니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); private final HttpStatus status; diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/dto/NicknamePolicyValidationTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/dto/NicknamePolicyValidationTest.java new file mode 100644 index 0000000..90906b8 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/dto/NicknamePolicyValidationTest.java @@ -0,0 +1,64 @@ +package com.ikae.snowthing.domain.member.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class NicknamePolicyValidationTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + @BeforeAll + static void setUpValidator() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void closeValidatorFactory() { + validatorFactory.close(); + } + + @Test + @DisplayName("회원가입과 프로필 수정은 10자 닉네임을 허용한다") + void acceptsTenCharacterNickname() { + String nickname = "가".repeat(10); + + assertThat(validator.validate(signUpRequest(nickname))) + .noneMatch(violation -> violation.getPropertyPath().toString().equals("nickname")); + assertThat(validator.validate(profileUpdateRequest(nickname))) + .noneMatch(violation -> violation.getPropertyPath().toString().equals("nickname")); + } + + @Test + @DisplayName("회원가입과 프로필 수정은 11자 닉네임을 거절한다") + void rejectsElevenCharacterNickname() { + String nickname = "가".repeat(11); + + assertThat(validator.validate(signUpRequest(nickname))) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("nickname")); + assertThat(validator.validate(profileUpdateRequest(nickname))) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("nickname")); + } + + private MemberSignUpRequest signUpRequest(String nickname) { + return MemberSignUpRequest.builder() + .email("nickname@snowthing.org") + .password("Password123!") + .emailVerificationToken("verification-token") + .nickname(nickname) + .build(); + } + + private MemberProfileUpdateRequest profileUpdateRequest(String nickname) { + return MemberProfileUpdateRequest.builder().nickname(nickname).build(); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java index 336bc1f..8bd328b 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java @@ -91,6 +91,26 @@ void setUp() { .name("자유게시판") .code("FREE") .build())); + + categoryRepository + .findByCode("CREW") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder() + .name("동호회 모집") + .code("CREW") + .build())); + + categoryRepository + .findByCode("SEASON_ROOM") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder() + .name("시즌방 모집") + .code("SEASON_ROOM") + .build())); } @Nested @@ -155,6 +175,91 @@ void createPost_unauthorized() { .extracting("errorCode") .isEqualTo(ErrorCode.INVALID_CREDENTIALS); } + + @Test + @DisplayName("로그인 회원이 동호회 및 시즌방 홍보글을 정상적으로 등록한다.") + void createPost_recruitBoards_success_member() { + PostCreateRequest crewRequest = + PostCreateRequest.builder() + .categoryCode("CREW") + .title("휘닉스파크 주말 크루 모집") + .content("크루원 모집합니다.") + .isAnonymous(false) + .build(); + + PostResponse crewResponse = + postService.createPost(crewRequest, userDetails1, "127.0.0.1"); + assertThat(crewResponse.categoryCode()).isEqualTo("CREW"); + assertThat(crewResponse.writerName()).isEqualTo("보더1호"); + + PostCreateRequest roomRequest = + PostCreateRequest.builder() + .categoryCode("SEASON_ROOM") + .title("하이원 26/27 시즌방 모집") + .content("주말조 남 2명 모집합니다.") + .isAnonymous(false) + .build(); + + PostResponse roomResponse = + postService.createPost(roomRequest, userDetails2, "127.0.0.1"); + assertThat(roomResponse.categoryCode()).isEqualTo("SEASON_ROOM"); + assertThat(roomResponse.writerName()).isEqualTo("보더2호"); + } + + @Test + @DisplayName("동호회 및 시즌방 홍보 게시판에 익명 작성을 시도하면 ANONYMOUS_POST_NOT_ALLOWED 예외가 발생한다.") + void createPost_recruitBoards_anonymous_rejected() { + PostCreateRequest crewAnonRequest = + PostCreateRequest.builder() + .categoryCode("CREW") + .title("익명 크루 모집") + .content("익명 내용") + .isAnonymous(true) + .anonymousPassword("Pass1234!") + .build(); + + assertThatThrownBy( + () -> + postService.createPost( + crewAnonRequest, userDetails1, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + + PostCreateRequest roomAnonRequest = + PostCreateRequest.builder() + .categoryCode("SEASON_ROOM") + .title("익명 시즌방 모집") + .content("익명 내용") + .isAnonymous(true) + .anonymousPassword("Pass1234!") + .build(); + + assertThatThrownBy( + () -> + postService.createPost( + roomAnonRequest, userDetails2, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } + + @Test + @DisplayName("비로그인 유저가 동호회/시즌방 게시판 작성을 시도하면 INVALID_CREDENTIALS 예외가 발생한다.") + void createPost_recruitBoards_unauthorized_rejected() { + PostCreateRequest crewRequest = + PostCreateRequest.builder() + .categoryCode("CREW") + .title("비로그인 크루 모집") + .content("내용") + .isAnonymous(false) + .build(); + + assertThatThrownBy(() -> postService.createPost(crewRequest, null, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_CREDENTIALS); + } } @Nested @@ -342,6 +447,37 @@ void updatePost_forbidden_otherUser() { .isEqualTo(ErrorCode.ACCESS_DENIED); } + @Test + @DisplayName("익명 게시글을 동호회/시즌방 홍보 카테고리로 변경 시도 시 ANONYMOUS_POST_NOT_ALLOWED 예외가 발생한다.") + void updatePost_anonymousToRecruitBoard_rejected() { + PostCreateRequest anonReq = + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명 원본글") + .content("익명 내용") + .isAnonymous(true) + .anonymousPassword("Password123!") + .build(); + + PostResponse anonPost = postService.createPost(anonReq, null, "127.0.0.1"); + + PostUpdateRequest updateToCrewReq = + PostUpdateRequest.builder() + .categoryCode("CREW") + .title("크루로 변경 시도") + .content("변경 내용") + .anonymousPassword("Password123!") + .build(); + + assertThatThrownBy( + () -> + postService.updatePost( + anonPost.publicId(), updateToCrewReq, null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } + @Test @DisplayName( "작성자 본인은 Soft Delete로 게시글을 정상 삭제하며 DB에 is_deleted=true, status=DELETED, deleted_at이 기록된다.") diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportControllerTest.java new file mode 100644 index 0000000..22bb4db --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportControllerTest.java @@ -0,0 +1,210 @@ +package com.ikae.snowthing.domain.resortreport.controller; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportCreateRequest; +import com.ikae.snowthing.domain.resortreport.service.ResortReportService; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@Import(ResortReportControllerTest.FixedClockConfig.class) +class ResortReportControllerTest { + + @TestConfiguration + static class FixedClockConfig { + @Bean + @Primary + Clock fixedClock() { + return Clock.fixed(Instant.parse("2026-12-06T06:30:00Z"), ZoneId.of("Asia/Seoul")); + } + } + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private MemberRepository memberRepository; + @Autowired private ResortRepository resortRepository; + @Autowired private ResortReportService resortReportService; + + private Member member; + private CustomUserDetails userDetails; + private Resort resort; + + @BeforeEach + void setUp() { + member = + memberRepository.save( + Member.builder() + .email("testrider@snowthing.com") + .password("password123!") + .nickname("테스트라이더") + .build()); + userDetails = new CustomUserDetails(member); + + resort = + resortRepository.save( + Resort.builder() + .name("하이원리조트") + .code("HIGH1_TEST") + .regionName("강원") + .displayOrder(1) + .active(true) + .build()); + } + + @Test + @DisplayName("로그인 회원이 올바른 설질 제보를 등록하면 201 Created를 반환한다") + void createReport_Authenticated_Success() throws Exception { + ResortReportCreateRequest request = + ResortReportCreateRequest.builder() + .resortId(resort.getId()) + .content("마운틴탑 아테나 슬로프 설질 훌륭합니다.") + .build(); + + mockMvc.perform( + post("/api/v1/resort-reports") + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.reportId").isNumber()) + .andExpect(jsonPath("$.resortName").value("하이원리조트")) + .andExpect(jsonPath("$.authorNickname").value("테스트라이더")) + .andExpect(jsonPath("$.content").value("마운틴탑 아테나 슬로프 설질 훌륭합니다.")); + } + + @Test + @DisplayName("비로그인 사용자가 설질 제보를 등록하려고 하면 401 Unauthorized를 반환한다") + void createReport_Unauthenticated_Fail() throws Exception { + ResortReportCreateRequest request = + ResortReportCreateRequest.builder() + .resortId(resort.getId()) + .content("익명 제보 시도") + .build(); + + mockMvc.perform( + post("/api/v1/resort-reports") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("내용이 비어있는 설질 제보를 등록하면 400 Bad Request를 반환한다") + void createReport_InvalidContent_Fail() throws Exception { + ResortReportCreateRequest request = + ResortReportCreateRequest.builder().resortId(resort.getId()).content(" ").build(); + + mockMvc.perform( + post("/api/v1/resort-reports") + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비로그인 사용자도 오늘 등록된 설질 제보 목록을 정상 조회할 수 있다 (200 OK)") + void getTodayReports_Success() throws Exception { + // 제보 1건 등록 + resortReportService.createReport( + member.getId(), + ResortReportCreateRequest.builder() + .resortId(resort.getId()) + .content("오늘 설질 굿!") + .build()); + + mockMvc.perform(get("/api/v1/resort-reports/today").param("page", "1").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].content").value("오늘 설질 굿!")) + .andExpect(jsonPath("$.content[0].resortName").value("하이원리조트")) + .andExpect(jsonPath("$.pageInfo.totalElements").value(1)); + } + + @Test + @DisplayName("작성자는 자신의 설질 제보를 삭제할 수 있다") + void deleteOwnReport() throws Exception { + long reportId = + resortReportService + .createReport( + member.getId(), + ResortReportCreateRequest.builder() + .resortId(resort.getId()) + .content("삭제할 제보") + .build()) + .getReportId(); + + mockMvc.perform( + delete("/api/v1/resort-reports/{reportId}", reportId) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("DELETED is returned as INVALID_INPUT when requested by an admin") + void updateModerationStatus_Deleted_ReturnsBadRequest() throws Exception { + Member admin = + memberRepository.save( + Member.builder() + .email("admin@snowthing.com") + .password("password123!") + .nickname("admin") + .role(Role.ROLE_ADMIN) + .build()); + long reportId = + resortReportService + .createReport( + member.getId(), + ResortReportCreateRequest.builder() + .resortId(resort.getId()) + .content("invalid moderation status") + .build()) + .getReportId(); + + mockMvc.perform( + patch("/api/v1/admin/resort-reports/{reportId}/moderation-status", reportId) + .with(csrf()) + .with(user(new CustomUserDetails(admin))) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"moderationStatus\":\"DELETED\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("INVALID_INPUT")); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportCleanupServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportCleanupServiceTest.java new file mode 100644 index 0000000..ecccaa5 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportCleanupServiceTest.java @@ -0,0 +1,37 @@ +package com.ikae.snowthing.domain.resortreport.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.ikae.snowthing.domain.resortreport.repository.ResortReportRepository; + +@ExtendWith(MockitoExtension.class) +class ResortReportCleanupServiceTest { + + @Mock private ResortReportRepository resortReportRepository; + + @Test + void deletesReportsBeforeCurrentKstMonth() { + Clock clock = Clock.fixed(Instant.parse("2027-01-31T19:40:00Z"), ZoneId.of("Asia/Seoul")); + ResortReportCleanupService service = + new ResortReportCleanupService(resortReportRepository, clock); + LocalDateTime cutoff = LocalDateTime.of(2027, 2, 1, 0, 0); + given(resortReportRepository.deleteCreatedBefore(cutoff)).willReturn(12); + + int deletedCount = service.deletePreviousMonthReports(); + + assertThat(deletedCount).isEqualTo(12); + verify(resortReportRepository).deleteCreatedBefore(cutoff); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java new file mode 100644 index 0000000..9cdf9bb --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java @@ -0,0 +1,300 @@ +package com.ikae.snowthing.domain.resortreport.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportCreateRequest; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportResponse; +import com.ikae.snowthing.domain.resortreport.entity.ResortReport; +import com.ikae.snowthing.domain.resortreport.entity.ResortReportStatus; +import com.ikae.snowthing.domain.resortreport.repository.ResortReportRepository; +import com.ikae.snowthing.global.common.dto.CursorPageResponse; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@ExtendWith(MockitoExtension.class) +class ResortReportServiceTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final Clock WINTER_CLOCK = + Clock.fixed(Instant.parse("2026-12-06T06:30:00Z"), KST); + + @Mock private ResortReportRepository resortReportRepository; + @Mock private ResortRepository resortRepository; + @Mock private MemberRepository memberRepository; + + private ResortReportService resortReportService; + + @BeforeEach + void setUp() { + resortReportService = + new ResortReportService( + resortReportRepository, resortRepository, memberRepository, WINTER_CLOCK); + } + + @Test + @DisplayName("회원 행 잠금 후 당일 제보 수를 확인하고 제보를 저장한다") + void createReportSuccess() { + Member author = member(1L, "눈꽃보더", Role.ROLE_USER); + Resort resort = resort(10L); + ResortReportCreateRequest request = request(resort.getId()); + given(memberRepository.findByIdForUpdate(author.getId())).willReturn(Optional.of(author)); + given(resortRepository.findById(resort.getId())).willReturn(Optional.of(resort)); + given(resortReportRepository.save(any(ResortReport.class))) + .willAnswer( + invocation -> { + ResortReport saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 100L); + return saved; + }); + + ResortReportResponse response = resortReportService.createReport(author.getId(), request); + + assertThat(response.getReportId()).isEqualTo(100L); + assertThat(response.getCreatedAt().getOffset().getTotalSeconds()).isEqualTo(9 * 60 * 60); + assertThat(response.isCanDelete()).isTrue(); + verify(memberRepository).findByIdForUpdate(author.getId()); + verify(resortReportRepository) + .countByAuthorIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThan( + eq(author.getId()), any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("삭제 여부와 무관한 당일 제보 수가 5개면 등록을 거절한다") + void createReportRejectsDailyLimit() { + Member author = member(1L, "눈꽃보더", Role.ROLE_USER); + given(memberRepository.findByIdForUpdate(author.getId())).willReturn(Optional.of(author)); + given( + resortReportRepository + .countByAuthorIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThan( + eq(author.getId()), any(), any())) + .willReturn(5L); + + assertThatThrownBy(() -> resortReportService.createReport(author.getId(), request(10L))) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.RESORT_REPORT_DAILY_LIMIT_EXCEEDED); + } + + @Test + @DisplayName("5월부터 9월까지는 제보 등록을 거절한다") + void createReportRejectsOffSeason() { + ResortReportService summerService = + new ResortReportService( + resortReportRepository, + resortRepository, + memberRepository, + Clock.fixed(Instant.parse("2026-07-01T00:00:00Z"), KST)); + + assertThatThrownBy(() -> summerService.createReport(1L, request(10L))) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.RESORT_REPORT_SEASON_CLOSED); + } + + @Test + @DisplayName("10월에는 시즌 준비 제보를 등록할 수 있다") + void createReportAllowsOctober() { + ResortReportService octoberService = + new ResortReportService( + resortReportRepository, + resortRepository, + memberRepository, + Clock.fixed(Instant.parse("2026-10-06T15:30:00Z"), KST)); + Member author = member(1L, "눈꽃보더", Role.ROLE_USER); + Resort resort = resort(10L); + given(memberRepository.findByIdForUpdate(author.getId())).willReturn(Optional.of(author)); + given(resortRepository.findById(resort.getId())).willReturn(Optional.of(resort)); + given(resortReportRepository.save(any(ResortReport.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + ResortReportResponse response = + octoberService.createReport(author.getId(), request(resort.getId())); + + assertThat(response.getContent()).isEqualTo("설질이 좋아요"); + } + + @Test + @DisplayName("오늘의 공개 제보를 페이지 정보와 삭제 권한을 포함해 반환한다") + void getTodayReports() { + Member author = member(1L, "눈꽃보더", Role.ROLE_USER); + ResortReport report = report(1L, author, resort(10L)); + given( + resortReportRepository.findTodayReports( + eq(ResortReportStatus.NORMAL), any(), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(report))); + + CursorPageResponse result = + resortReportService.getTodayReports(null, 1, 20, new CustomUserDetails(author)); + + assertThat(result.content()).hasSize(1); + assertThat(result.content().getFirst().isCanDelete()).isTrue(); + assertThat(result.pageInfo().totalElements()).isEqualTo(1); + } + + @Test + @DisplayName("작성자는 자신의 제보를 소프트 삭제할 수 있다") + void authorCanSoftDelete() { + Member author = member(1L, "눈꽃보더", Role.ROLE_USER); + ResortReport report = report(1L, author, resort(10L)); + given(resortReportRepository.findByIdWithAuthor(report.getId())) + .willReturn(Optional.of(report)); + + resortReportService.deleteReport(report.getId(), new CustomUserDetails(author)); + + assertThat(report.getStatus()).isEqualTo(ResortReportStatus.DELETED); + assertThat(report.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("다른 회원은 제보를 삭제할 수 없다") + void otherMemberCannotDelete() { + Member author = member(1L, "작성자", Role.ROLE_USER); + Member other = member(2L, "다른회원", Role.ROLE_USER); + ResortReport report = report(1L, author, resort(10L)); + given(resortReportRepository.findByIdWithAuthor(report.getId())) + .willReturn(Optional.of(report)); + + assertThatThrownBy( + () -> + resortReportService.deleteReport( + report.getId(), new CustomUserDetails(other))) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("관리자는 다른 회원의 제보를 삭제할 수 있다") + void adminCanDelete() { + Member author = member(1L, "작성자", Role.ROLE_USER); + Member admin = member(2L, "관리자", Role.ROLE_ADMIN); + ResortReport report = report(1L, author, resort(10L)); + given(resortReportRepository.findByIdWithAuthor(report.getId())) + .willReturn(Optional.of(report)); + + resortReportService.deleteReport(report.getId(), new CustomUserDetails(admin)); + + assertThat(report.getStatus()).isEqualTo(ResortReportStatus.DELETED); + } + + @ParameterizedTest + @EnumSource( + value = ResortReportStatus.class, + names = {"NORMAL", "HIDDEN", "BLOCKED"}) + @DisplayName("An admin can set every allowed moderation status") + void adminCanSetAllowedModerationStatus(ResortReportStatus status) { + Member author = member(1L, "작성자", Role.ROLE_USER); + Member admin = member(2L, "관리자", Role.ROLE_ADMIN); + ResortReport report = report(1L, author, resort(10L)); + given(resortReportRepository.findByIdWithAuthor(report.getId())) + .willReturn(Optional.of(report)); + + resortReportService.updateModerationStatus( + report.getId(), status, new CustomUserDetails(admin)); + + assertThat(report.getStatus()).isEqualTo(status); + } + + @Test + @DisplayName("A non-admin receives ACCESS_DENIED when changing moderation status") + void nonAdminCannotChangeModerationStatus() { + Member member = member(1L, "member", Role.ROLE_USER); + + assertThatThrownBy( + () -> + resortReportService.updateModerationStatus( + 1L, + ResortReportStatus.DELETED, + new CustomUserDetails(member))) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("An admin receives INVALID_INPUT when requesting DELETED moderation status") + void adminCannotSetDeletedModerationStatus() { + Member admin = member(1L, "admin", Role.ROLE_ADMIN); + + assertThatThrownBy( + () -> + resortReportService.updateModerationStatus( + 1L, + ResortReportStatus.DELETED, + new CustomUserDetails(admin))) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + private ResortReportCreateRequest request(Long resortId) { + return ResortReportCreateRequest.builder().resortId(resortId).content("설질이 좋아요").build(); + } + + private ResortReport report(Long id, Member author, Resort resort) { + ResortReport report = + ResortReport.builder() + .resort(resort) + .author(author) + .content("설질이 좋아요") + .createdAt(LocalDateTime.of(2026, 12, 6, 15, 30)) + .build(); + ReflectionTestUtils.setField(report, "id", id); + return report; + } + + private Resort resort(Long id) { + Resort resort = + Resort.builder() + .name("하이원") + .code("HIGH1") + .regionName("강원") + .displayOrder(1) + .active(true) + .build(); + ReflectionTestUtils.setField(resort, "id", id); + return resort; + } + + private Member member(Long id, String nickname, Role role) { + Member member = + Member.builder() + .email(nickname + "@snowthing.com") + .nickname(nickname) + .password("hashed") + .role(role) + .build(); + ReflectionTestUtils.setField(member, "id", id); + return member; + } +} diff --git a/database/production/014_migration_crew_and_season_room.sql b/database/production/014_migration_crew_and_season_room.sql new file mode 100644 index 0000000..6e14cfe --- /dev/null +++ b/database/production/014_migration_crew_and_season_room.sql @@ -0,0 +1,13 @@ +-- ==================================================================== +-- Snowthing Production Migration: 014_migration_crew_and_season_room.sql +-- Version: 014 +-- Description: 동호회 모집(CREW) 및 시즌방 모집(SEASON_ROOM) 게시판 카테고리 추가 +-- ==================================================================== + +INSERT INTO `post_category` (`name`, `code`) +SELECT '동호회 모집', 'CREW' +WHERE NOT EXISTS (SELECT 1 FROM `post_category` WHERE `code` = 'CREW'); + +INSERT INTO `post_category` (`name`, `code`) +SELECT '시즌방 모집', 'SEASON_ROOM' +WHERE NOT EXISTS (SELECT 1 FROM `post_category` WHERE `code` = 'SEASON_ROOM'); diff --git a/database/production/015_migration_resort_report.sql b/database/production/015_migration_resort_report.sql new file mode 100644 index 0000000..609eb17 --- /dev/null +++ b/database/production/015_migration_resort_report.sql @@ -0,0 +1,16 @@ +-- Migration 015: 오늘의 설질 (Resort Report) 테이블 및 인덱스 추가 + +CREATE TABLE IF NOT EXISTS resort_report ( + report_id BIGINT AUTO_INCREMENT PRIMARY KEY, + resort_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + content VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'NORMAL', + deleted_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_resort_report_resort FOREIGN KEY (resort_id) REFERENCES resort (resort_id) ON DELETE CASCADE, + CONSTRAINT fk_resort_report_member FOREIGN KEY (member_id) REFERENCES member (member_id) ON DELETE CASCADE, + INDEX idx_resort_report_status_created (status, created_at DESC, report_id DESC), + INDEX idx_resort_report_resort_status_created (resort_id, status, created_at DESC, report_id DESC), + INDEX idx_resort_report_member_created (member_id, created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/conception/006_recruit_boards/01_requirements.md b/docs/conception/006_recruit_boards/01_requirements.md new file mode 100644 index 0000000..6c7a177 --- /dev/null +++ b/docs/conception/006_recruit_boards/01_requirements.md @@ -0,0 +1,39 @@ +# 동호회 및 시즌방 홍보 게시판 요구사항 + +## 1. 목적 + +스노우보드/스키 커뮤니티에서 동호회원(크루원) 모집 및 시즌방(투자자·멤버) 모집을 위한 순수 공개 홍보 플랫폼을 제공합니다. +플랫폼은 폐쇄형 크루 관리나 금전 계약에 직접 개입하지 않고, 기존 게시판 인프라(`Post`)를 재사용하여 정보 공유와 댓글 소통의 장을 지원합니다. + +## 2. 사용자 시나리오 + +1. **동호회 홍보글 작성**: + - 로그인한 회원이 글쓰기 화면에서 '동호회 모집' 카테고리를 선택합니다. + - 크루 소개, 주 베이스 리조트, 라이딩 성향, 지원 자격, 오픈카톡 링크 등을 제목 및 본문에 자유롭게 작성합니다. + - 익명 작성은 지원되지 않으므로 회원 본인의 닉네임으로 등록됩니다. +2. **시즌방 홍보글 작성**: + - 로그인한 회원이 글쓰기 화면에서 '시즌방 모집' 카테고리를 선택합니다. + - 대상 리조트, 숙소 위치, 모집 인원 및 성비, 투자 비용, 방 규칙 등을 제목 및 본문에 작성합니다. + - 익명 작성은 지원되지 않으므로 회원 본인의 닉네임으로 등록됩니다. +3. **목록 및 상세 조회**: + - 방문자는 '전체글' 탭 또는 전용 '동호회 모집', '시즌방 모집' 탭에서 홍보글 목록을 열람합니다. + - 상세 페이지에서 본문 내용을 확인하고 댓글/대댓글을 통해 문의 및 소통합니다. + +## 3. 기능 요구사항 + +| ID | 요구사항 | +|---|---| +| RECRUIT-F-001 | `post_category`에 '동호회 모집'(`CREW`) 및 '시즌방 모집'(`SEASON_ROOM`) 카테고리를 제공한다. | +| RECRUIT-F-002 | 두 게시판은 **로그인 회원만 작성 가능**하며, 비회원 작성 및 익명(`isAnonymous = true`) 작성을 원천 차단한다. | +| RECRUIT-F-003 | 익명 글쓰기 시도 시 표준 비즈니스 에러 코드 `POST_006` (`ANONYMOUS_POST_NOT_ALLOWED`)를 반환한다. | +| RECRUIT-F-004 | 기존 게시글 수정 시에도 익명 글이 `CREW`나 `SEASON_ROOM` 카테고리로 변경되는 것을 차단한다. | +| RECRUIT-F-005 | '전체글' 목록 조회 시 두 카테고리의 게시글도 함께 정상 노출된다 (방식 A). | +| RECRUIT-F-006 | 상단 내비게이션 드롭다운 메뉴 및 게시판 카테고리 탭에 '동호회 모집', '시즌방 모집'을 노출한다. | +| RECRUIT-F-007 | 글쓰기 화면에서 두 카테고리 선택 시 '익명으로 작성' 체크박스 및 익명 비밀번호 입력을 숨긴다. | + +## 4. 비기능 요구사항 + +- **무결성 및 안정성**: 신규 테이블 추가 없이 기존 검증된 `Post`, `Comment`, `PostImage` 인프라를 100% 재사용하여 시스템 복잡도를 최소화한다. +- **예외 처리 표준화**: 문자열 리터럴 예외를 금지하고 `ErrorCode.ANONYMOUS_POST_NOT_ALLOWED`와 `CustomAuthException`으로 일원화한다. +- **DTO 불변성**: DTO 생성 시 컬렉션 방어적 복사 원칙을 준수한다. +- **계층 분리**: 서비스 레이어에서 세션/서블릿에 직접 결합하지 않는다. diff --git a/docs/conception/006_recruit_boards/02_domain-model.md b/docs/conception/006_recruit_boards/02_domain-model.md new file mode 100644 index 0000000..6b1c0cd --- /dev/null +++ b/docs/conception/006_recruit_boards/02_domain-model.md @@ -0,0 +1,46 @@ +# 동호회 및 시즌방 홍보 게시판 도메인 모델 + +## 1. 도메인 구조 개요 + +기존 게시판 도메인(`Post`)을 재사용하는 범용 커뮤니티 파티셔닝 구조입니다. + +```mermaid +classDiagram + class PostCategory { + +Long id + +String name + +String code ("CREW", "SEASON_ROOM") + } + + class Post { + +Long id + +String publicId + +String title + +String content + +boolean isAnonymous + +int viewCount + +int commentCount + +int likeCount + +PostStatus status + } + + class Member { + +Long id + +String publicId + +String nickname + +Role role + } + + PostCategory "1" <-- "N" Post : category + Member "1" <-- "N" Post : member (필수) +``` + +## 2. 도메인 불변식 (Invariants) + +1. **작성자 필수성 (Non-null Member)**: + - `post.category.code IN ('CREW', 'SEASON_ROOM')`인 경우 `post.member`는 반드시 `NOT NULL`이어야 한다. +2. **익명성 금지 (Non-anonymous)**: + - `post.isAnonymous`는 반드시 `false`여야 한다. + - `post.anonymousPassword`는 반드시 `NULL`이어야 한다. +3. **카테고리 불변식**: + - `post.category.code`는 `post_category` 테이블의 정식 등록 코드(`CREW`, `SEASON_ROOM`)여야 한다. diff --git a/docs/conception/006_recruit_boards/03_erd.md b/docs/conception/006_recruit_boards/03_erd.md new file mode 100644 index 0000000..3e5c1bd --- /dev/null +++ b/docs/conception/006_recruit_boards/03_erd.md @@ -0,0 +1,45 @@ +# 동호회 및 시즌방 홍보 게시판 ERD 스펙 + +## 1. ERD 관계도 + +기존 `post_category`와 `post` 테이블을 그대로 사용하며, 신규 물리 테이블 추가는 없습니다. + +```mermaid +erDiagram + POST_CATEGORY ||--o{ POST : "1:N" + MEMBER ||--o{ POST : "1:N" + + POST_CATEGORY { + bigint category_id PK + varchar(50) name + varchar(50) code UK + } + + POST { + bigint post_id PK + varchar(36) public_id UK + bigint category_id FK + bigint member_id FK + varchar(200) title + longtext content + varchar(45) writer_ip + boolean is_anonymous + varchar(255) anonymous_password + int view_count + int comment_count + int like_count + int dislike_count + boolean has_image + varchar(20) status + boolean is_deleted + datetime created_at + datetime updated_at + } +``` + +## 2. 신규 기준데이터 (마스터 레코드) + +| category_id (자동증가) | name | code | 비고 | +|---|---|---|---| +| Auto | 동호회 모집 | `CREW` | 익명 작성 불가 | +| Auto | 시즌방 모집 | `SEASON_ROOM` | 익명 작성 불가 | diff --git a/docs/conception/006_recruit_boards/04_api-spec.md b/docs/conception/006_recruit_boards/04_api-spec.md new file mode 100644 index 0000000..5e6518b --- /dev/null +++ b/docs/conception/006_recruit_boards/04_api-spec.md @@ -0,0 +1,79 @@ +# 동호회 및 시즌방 홍보 게시판 API 명세서 + +기존 게시글 API(`/api/v1/posts`)를 그대로 사용하며, 카테고리 코드(`categoryCode`) 파라미터로 구분합니다. + +## 1. 게시글 목록 조회 + +- **엔드포인트**: `GET /api/v1/posts` +- **쿼리 파라미터**: + - `categoryCode` (string, optional): `CREW` (동호회 모집), `SEASON_ROOM` (시즌방 모집), 또는 빈 값 (전체글) + - `page` (int, optional): 페이지 번호 (1부터 시작, 기본값 1) + - `size` (int, optional): 페이지 크기 (기본값 20, 최대 100) + - `keyword` (string, optional): 검색어 + - `searchType` (string, optional): `TITLE`, `WRITER`, `CONTENT`, `TITLE_CONTENT` +- **응답 (200 OK)**: + ```json + { + "content": [ + { + "publicId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "categoryName": "동호회 모집", + "categoryCode": "CREW", + "title": "[휘닉스파크] 26/27 스노우보드 주말 라이딩 크루원 모집합니다", + "writerNickname": "파우더보더", + "thumbnailImageUrl": null, + "hasImage": false, + "viewCount": 15, + "commentCount": 3, + "likeCount": 2, + "dislikeCount": 0, + "status": "NORMAL", + "isDeleted": false, + "createdAt": "2026-10-06T16:00:00" + } + ], + "pageInfo": { + "currentPage": 1, + "size": 20, + "totalElements": 1, + "totalPages": 1, + "hasNext": false + } + } + ``` + +--- + +## 2. 게시글 등록 + +- **엔드포인트**: `POST /api/v1/posts` +- **인증**: 필수 (로그인 세션 필요) +- **요청 Body**: + ```json + { + "categoryCode": "CREW", // 또는 "SEASON_ROOM" + "title": "동호회/시즌방 홍보 제목", + "content": "홍보 본문 내용", + "isAnonymous": false, + "anonymousPassword": null, + "imageUrls": [] + } + ``` +- **예외 응답**: + - **비로그인 사용자가 요청 시**: + - HTTP 401 Unauthorized (`ErrorCode.INVALID_CREDENTIALS` / `AUTH_001`) + - **`isAnonymous = true`로 요청 시**: + - HTTP 400 Bad Request (`ErrorCode.ANONYMOUS_POST_NOT_ALLOWED` / `POST_006`) + ```json + { + "code": "POST_006", + "message": "해당 게시판은 익명 작성을 지원하지 않습니다." + } + ``` + +--- + +## 3. 게시글 상세 조회 / 수정 / 삭제 + +기존 `GET /api/v1/posts/{publicId}`, `PUT /api/v1/posts/{publicId}`, `DELETE /api/v1/posts/{publicId}` 엔드포인트를 그대로 사용합니다. +- 수정 시에도 익명 게시글이 `CREW` 또는 `SEASON_ROOM`으로 카테고리 변경을 시도하면 `POST_006` 예외가 발생합니다. diff --git a/docs/conception/007_resort_report/01_requirements.md b/docs/conception/007_resort_report/01_requirements.md new file mode 100644 index 0000000..124f14f --- /dev/null +++ b/docs/conception/007_resort_report/01_requirements.md @@ -0,0 +1,38 @@ +# 오늘의 설질(Resort Report) 요구사항 명세서 + +## 1. 목적 + +- 로그인 회원이 활성 리조트의 당일 설질을 100자 이내로 빠르게 공유한다. +- 일반 게시글과 달리 제목·댓글·반응·이미지 없이 전용 `resort_report` 테이블에서 관리한다. +- 조회와 작성 날짜는 서버의 한국 표준시(`Asia/Seoul`)를 기준으로 한다. + +## 2. 등록 규칙 + +- 로그인 회원만 등록할 수 있으며 비로그인 요청은 `401 AUTH_001`을 반환한다. +- 활성 리조트 ID와 공백 제거 후 1~100자인 내용을 받는다. +- 작성 가능 기간은 매년 10월 1일 00:00부터 다음 해 4월 30일 23:59:59.999999까지다. 5~9월에는 `403 REPORT_004`를 반환한다. +- 회원 한 명이 작성할 수 있는 제보는 KST 날짜별 최대 5개다. 삭제·숨김·차단된 제보도 도배 우회를 막기 위해 당일 한도에 포함한다. +- 별도 메모리 제한기나 카운터 테이블을 사용하지 않는다. 회원 행을 비관적 잠금한 뒤 `member_id + created_at`으로 기존 제보를 집계해 동시 요청을 직렬화한다. + +## 3. 조회 규칙 + +- 로그인 여부와 관계없이 KST 오늘 `[00:00, 다음 날 00:00)`에 작성된 `NORMAL` 상태만 조회한다. +- `resortId` 필터와 1부터 시작하는 페이지를 지원하며 페이지 크기와 최대 페이지는 각각 100이다. +- 정렬은 `createdAt DESC, reportId DESC`로 고정해 같은 시각에도 순서가 안정적이어야 한다. +- 생성 시각은 시간대가 포함된 ISO-8601(`+09:00`)로 반환한다. +- 응답의 `canDelete`는 현재 사용자가 작성자 또는 관리자인지 서버가 계산한다. +- 리조트 선택지는 하드코딩하지 않고 활성 리조트 마스터 API를 사용한다. 마스터 조회 실패 시 작성 UI를 비활성화한다. + +## 4. 삭제·운영 규칙 + +- 작성자와 관리자는 제보를 삭제할 수 있으며 즉시 `DELETED` 상태와 `deleted_at`을 기록하는 소프트 삭제를 수행한다. +- 관리자는 `NORMAL`, `HIDDEN`, `BLOCKED` 상태로 운영 조치할 수 있다. 삭제된 제보는 운영 상태 변경으로 복구하지 않는다. +- 매월 1일 04:40 KST에 현재 월 이전의 모든 제보를 물리 삭제한다. 따라서 DB에는 현재 월 데이터만 유지되며, 소프트 삭제 데이터도 다음 월 정리 시 제거된다. +- 월별 물리 삭제는 복구 기간이 최대 한 달뿐이라는 대가가 있다. 장기 감사 보존이 필요해지면 별도 보관소나 보존 기간 정책을 도입해야 한다. + +## 5. 비기능 요구사항 + +- 공개 목록은 상태·시간, 리조트별 목록은 리조트·상태·시간, 일일 한도는 회원·시간 복합 인덱스를 사용한다. +- 컬렉션 응답은 방어적 복사를 수행한다. +- 예외는 `ErrorCode`와 `CustomException`으로만 표현한다. +- 컨트롤러는 Repository를 직접 호출하지 않고 웹 인증 처리와 비즈니스 로직을 분리한다. diff --git a/docs/conception/007_resort_report/02_domain-model.md b/docs/conception/007_resort_report/02_domain-model.md new file mode 100644 index 0000000..bc708c4 --- /dev/null +++ b/docs/conception/007_resort_report/02_domain-model.md @@ -0,0 +1,49 @@ +# 오늘의 설질(Resort Report) 도메인 모델 설계서 + +## 1. 모델 + +```mermaid +classDiagram + class Member { + +Long id + +Role role + } + class Resort { + +Long id + +Boolean active + } + class ResortReport { + +Long id + +Member author + +Resort resort + +String content + +ResortReportStatus status + +LocalDateTime deletedAt + +LocalDateTime createdAt + +softDelete(deletedAt) + +changeModerationStatus(status) + } + class ResortReportStatus { + NORMAL + HIDDEN + BLOCKED + DELETED + } + ResortReport --> Member : author N:1 + ResortReport --> Resort : target N:1 + ResortReport --> ResortReportStatus +``` + +## 2. 책임과 불변 조건 + +- `ResortReport`는 활성 리조트, 가입 회원, 공백 제거 후 1~100자 내용으로만 생성된다. +- 최초 상태는 `NORMAL`이다. +- 작성자 삭제는 `DELETED`와 삭제 시각을 함께 기록한다. +- 관리 상태 변경은 `NORMAL`, `HIDDEN`, `BLOCKED`만 허용하고 서비스 계층에서 관리자 권한과 허용 상태를 검사한다. +- 조회 시각과 작성 제한은 KST `Clock`을 주입받은 서비스에서 계산해 테스트 가능성을 유지한다. + +## 3. 일일 한도 동시성 + +등록 트랜잭션은 먼저 회원 PK 행을 `PESSIMISTIC_WRITE`로 잠근다. 같은 회원의 요청은 이 잠금에서 직렬화된 뒤 KST 당일 범위의 기존 제보를 집계하므로, 두 요청이 동시에 4개를 보고 모두 저장하는 경쟁 조건을 막는다. 서로 다른 회원은 서로 다른 행을 잠그므로 영향을 받지 않는다. + +기존 테이블을 직접 집계하는 방식은 구조가 단순하고 별도 정합성 관리가 필요 없지만 작성 이력에 대한 COUNT 비용을 지불한다. 월별 정리와 `(member_id, created_at)` 인덱스로 범위를 작게 유지하며, 트래픽이 크게 증가하면 원자적 카운터 저장소를 별도 설계한다. diff --git a/docs/conception/007_resort_report/03_erd.md b/docs/conception/007_resort_report/03_erd.md new file mode 100644 index 0000000..488eb02 --- /dev/null +++ b/docs/conception/007_resort_report/03_erd.md @@ -0,0 +1,40 @@ +# 오늘의 설질(Resort Report) ERD 설계서 + +```mermaid +erDiagram + MEMBER ||--o{ RESORT_REPORT : writes + RESORT ||--o{ RESORT_REPORT : receives + RESORT_REPORT { + bigint report_id PK + bigint resort_id FK + bigint member_id FK + varchar(100) content + varchar(20) status + datetime(6) deleted_at + datetime(6) created_at + } +``` + +```sql +CREATE TABLE IF NOT EXISTS resort_report ( + report_id BIGINT AUTO_INCREMENT PRIMARY KEY, + resort_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + content VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'NORMAL', + deleted_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_resort_report_resort FOREIGN KEY (resort_id) REFERENCES resort (resort_id) ON DELETE CASCADE, + CONSTRAINT fk_resort_report_member FOREIGN KEY (member_id) REFERENCES member (member_id) ON DELETE CASCADE, + INDEX idx_resort_report_status_created (status, created_at DESC, report_id DESC), + INDEX idx_resort_report_resort_status_created (resort_id, status, created_at DESC, report_id DESC), + INDEX idx_resort_report_member_created (member_id, created_at DESC) +); +``` + +## 인덱스 근거 + +- `status, created_at, report_id`: 공개 전체 목록의 상태 동등 조건, 당일 범위, 안정적인 최신순 정렬을 지원한다. +- `resort_id, status, created_at, report_id`: 리조트 필터의 동등 조건을 선두에 배치한다. +- `member_id, created_at`: 상태와 무관하게 회원별 당일 작성량을 집계한다. +- 인덱스가 쓰기·삭제 비용과 저장 공간을 늘리는 대가가 있지만, 현재 기능의 세 가지 핵심 쿼리를 테이블 전체 스캔하지 않게 한다. diff --git a/docs/conception/007_resort_report/04_api-spec.md b/docs/conception/007_resort_report/04_api-spec.md new file mode 100644 index 0000000..c57aed8 --- /dev/null +++ b/docs/conception/007_resort_report/04_api-spec.md @@ -0,0 +1,77 @@ +# 오늘의 설질(Resort Report) API 명세서 + +## 등록 + +`POST /api/v1/resort-reports` — 로그인 필요 + +```json +{ "resortId": 1, "content": "아테나 상단 단단하고 엣지 잘 잡힙니다." } +``` + +성공: `201 Created` + +```json +{ + "reportId": 101, + "resortId": 1, + "resortName": "하이원", + "resortCode": "HIGH1", + "authorNickname": "눈꽃라이더", + "content": "아테나 상단 단단하고 엣지 잘 잡힙니다.", + "createdAt": "2026-12-06T15:30:00+09:00", + "canDelete": true +} +``` + +실패: `401 AUTH_001`, `404 RESORT_001`, `400 REPORT_001`, `429 REPORT_003`, `403 REPORT_004`. + +## 오늘 목록 + +`GET /api/v1/resort-reports/today?resortId=1&page=1&size=20` — 공개 + +- `resortId`: 선택 +- `page`: 기본 1, 범위 1~100 +- `size`: 기본 20, 범위 1~100 + +```json +{ + "content": [ + { + "reportId": 101, + "resortId": 1, + "resortName": "하이원", + "resortCode": "HIGH1", + "authorNickname": "눈꽃라이더", + "content": "아테나 상단 단단하고 엣지 잘 잡힙니다.", + "createdAt": "2026-12-06T15:30:00+09:00", + "canDelete": false + } + ], + "pageInfo": { + "page": 1, + "totalPages": 1, + "totalElements": 1, + "nextCursor": null, + "hasNext": false, + "pageSize": 20 + } +} +``` + +실패: `400 REPORT_005`, `400 REPORT_006`. + +## 삭제 + +`DELETE /api/v1/resort-reports/{reportId}` — 작성자 또는 관리자, 성공 `204 No Content`. + +실패: `401 AUTH_001`, `403 AUTH_002`, `404 REPORT_002`. + +## 관리자 상태 변경 + +`PATCH /api/v1/admin/resort-reports/{reportId}/moderation-status` — 관리자 전용 + +```json +{ "moderationStatus": "HIDDEN" } +``` + +허용값은 `NORMAL`, `HIDDEN`, `BLOCKED`이며 성공 시 `204 No Content`다. 삭제는 일반 삭제 API를 사용한다. diff --git a/docs/project/work.md b/docs/project/work.md index 9c42cc3..54c8ec0 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,16 @@ +## 동호회 및 시즌방 홍보 게시판 분리 구현 (2026-10-06) + +- 상태: 구현·전체 테스트·프로덕션 빌드 통과 완료 +- 동호회원 모집(`CREW`) 및 시즌방 모집(`SEASON_ROOM`)을 위한 순수 공개 홍보 플랫폼 게시판 신설 +- 기존 `post` 엔티티 및 댓글·이미지 인프라를 재사용하는 카테고리 분리 아키텍처 적용 +- 두 게시판은 허위/스팸 방지를 위해 익명 글쓰기(`isAnonymous = true`) 및 비로그인 작성을 원천 차단 (`ErrorCode.ANONYMOUS_POST_NOT_ALLOWED`, `POST_006`) +- 익명 게시글을 동호회/시즌방 홍보 카테고리로 변경하는 수정 요청 또한 차단 가드 적용 +- '전체글' 탭 조회 시 두 홍보 카테고리 글도 함께 통합 노출되도록 구성 +- DB 마이그레이션 `014_migration_crew_and_season_room.sql` 작성 및 `DataInitializer.java` 시드 반영 +- `scripts/verify-production-migration-007-013.sh`에 014 마이그레이션 및 카테고리 무결성 검증 추가 +- 상단 내비게이션 드롭다운 메뉴 및 게시판 카테고리 탭, 글쓰기 화면에 `동호회 모집`, `시즌방 모집` 반영 +- 백엔드 전체 테스트(306건) 100% 통과 및 프론트엔드 Next.js 16 Turbopack 프로덕션 빌드 통과 + ## 카풀 자동 경로·유가 계산 연동 (2026-10-03) - 상태: 구현·전체 테스트·외부 API 실제 호출 검증 완료 @@ -1992,11 +2005,47 @@ - 실제 생성된 Configuration Set 이름에 맞춰 저장소·운영 문서·EC2 환경변수를 `snowthing-mail-transactional`로 통일하고 SES 어댑터 테스트와 diff 검사를 통과했습니다. SES 호출에서 Configuration Set의 존재는 확인됐으나 EC2 역할이 새 Configuration Set ARN의 `ses:SendEmail`을 허용하지 않아 `AccessDeniedException`이 남아 있습니다. - EC2 역할의 Identity·`snowthing-mail-transactional` Configuration Set 권한과 `help@snowthing.org` 발신 조건을 반영한 뒤 AWS mailbox simulator 발송이 성공했고 SES `MessageId`를 확인했습니다. SES 인프라 경로는 검증됐으며 실제 사용자 수신과 애플리케이션 로그 검증은 이메일 인증 코드 운영 배포 후 진행합니다. - 로컬 MySQL과 백엔드·프런트엔드 개발 서버를 기동하고 회원가입·비밀번호 재설정 페이지가 모두 정상 응답하는지 확인했습니다. Orca 브라우저에 두 화면을 열어 현재 구현을 변경하지 않은 상태로 데스크톱 레이아웃과 모바일 대응 요소를 검토했으며, 디자인 수정은 사용자와 개선안을 확정한 뒤 진행합니다. - - 회원가입과 비밀번호 재설정 화면의 `인증번호 받기` 문구를 `이메일 인증`으로 변경했습니다. 회원가입 버튼은 미인증 상태에서도 누를 수 있게 하고, 누르면 이메일 영역으로 이동·포커스하면서 `이메일 인증이 필요합니다.`를 인라인 경고로 표시하도록 보완했습니다. 프런트엔드 ESLint와 실제 브라우저의 버튼 문구·미인증 클릭 동작을 확인했습니다. - 선택값인 출발 지역은 회원가입 단계에서 받지 않도록 입력란·프런트 상태·가입 요청 필드에서 제거했습니다. 백엔드의 nullable 필드와 프로필 수정 기능은 유지하고, 닉네임 입력란은 데스크톱에서 한 줄 전체 너비로 재배치했습니다. 프런트엔드 ESLint와 실제 브라우저 화면을 확인했습니다. - - 이메일 기능 PR을 준비하면서 동호회·시즌방 작업과 커밋 범위를 분리했습니다. 누락됐던 `007~013` 운영 마이그레이션 검증 스크립트를 이메일 브랜치에 추가하고 백엔드 CI와 배포 워크플로우가 같은 스크립트를 실행하도록 맞췄습니다. - - 브랜치 분리 후 전체 빌드에서 공유 `ErrorCode` 파일의 이메일 오류 코드가 동호회 커밋에 섞여 빠진 문제를 확인했습니다. `EMAIL_001~010`만 이메일 브랜치로 옮겨 이메일 기능이 독립적으로 컴파일되도록 바로잡았습니다. - - 이메일 인증 회원가입 및 비밀번호 재설정 변경을 `feature/email-verification-password-reset` 브랜치로 푸시하고 GitHub PR #35를 생성했습니다. PR에는 이메일 기능과 관련 문서·마이그레이션·CI 변경만 포함했습니다. - - PR 리뷰를 반영해 SES 클라이언트 타임아웃처럼 발송 결과가 불확실한 경우에는 실제 요청 ID와 인증 가능 상태를 유지하고, 명확한 발송 실패만 `SEND_FAILED`로 기록하도록 구분했습니다. 동시 가입 중 발생한 닉네임 unique 위반은 `DUPLICATE_NICKNAME`으로 변환하며, 정리 작업은 아직 유효한 인증 토큰이 있는 레코드를 보존하도록 쿼리와 테스트를 보완했습니다. 요청 스레드를 점유하는 비밀번호 재설정 응답 패딩은 전용 비동기 실행 정책이 필요한 별도 성능 개선 범위로 남겼습니다. - - PR #35 머지 커밋 `4f0085f`의 백엔드·프런트엔드 운영 배포를 완료했습니다. 운영 마이그레이션 `007~013`, 컨테이너 헬스 체크와 ECR release 승격을 통과했으며, 누락됐던 카카오 REST·오피넷 서버 키는 EC2 환경 파일에, 카카오 지도 JavaScript 키는 GitHub Actions 변수에 등록했습니다. SES mailbox simulator 가입 인증 요청은 HTTP 200과 `SENT`·message ID 저장을, 비밀번호 재설정 요청은 HTTP 202를 확인했습니다. - - 더 이상 사용하지 않는 Gemini PR 자동 리뷰 워크플로를 제거했습니다. Java CI와 백엔드·프런트엔드 배포 워크플로는 그대로 유지합니다. +- **오늘의 설질(Resort Report) 실시간 한 줄 제보 기능 구현 (2026-10-06)** + - 브랜치: `feature/resort-report` (`feature/recruit-boards`에서 분리 생성) + - 설계 문서 작성: `docs/conception/007_resort_report/` (`01_requirements.md`, `02_domain-model.md`, `03_erd.md`, `04_api-spec.md`) + - 데이터베이스 마이그레이션: `database/production/015_migration_resort_report.sql` 생성 및 로컬 MySQL 적용 (`resort_report` 테이블 및 `(created_at DESC, resort_id)` 복합 인덱스) + - 백엔드 구현: + - `ErrorCode.java`: `RESORT_NOT_FOUND` (404), `INVALID_RESORT_REPORT_CONTENT` (400) 추가 + - `ResortReport.java` 엔티티 생성 (FetchType.LAZY 매핑, 1~100자 본문 검증) + - `ResortReportRepository.java` 인터페이스 생성 (JPQL `JOIN FETCH`로 Resort/Member N+1 원천 차단) + - `ResortReportCreateRequest.java`, `ResortReportResponse.java` DTO 생성 + - `ResortReportService.java` 비즈니스 로직 구현 (KST 기준 당일 범위 00:00:00~현재 필터링, 회원/리조트 검증) + - `ResortReportController.java` API 엔드포인트 구현 (`POST /api/v1/resort-reports`, `GET /api/v1/resort-reports/today`) + - `SecurityConfig.java`: `GET /api/v1/resort-reports/**` 비로그인 허용 (Public), POST 등록은 로그인 회원 전용 + - 단위 및 통합 테스트 작성 (`ResortReportServiceTest`, `ResortReportControllerTest`) + - 프론트엔드 연동: + - `frontend/app/lib/api.ts`: `resortReports` API 엔드포인트 및 `ResortReportItem` 타입 인터페이스 추가 + - `frontend/app/lib/resortReports.ts`: `formatReportTime` 시간 포맷팅 헬퍼 추가 + - `frontend/app/page.tsx`: 메인 화면 사이드바 설질 위젯 실시간 등록(`csrfFetch`) 및 오늘 제보 목록 연동 + - `frontend/app/resort-reports/page.tsx`: 당일 설질 제보 전체 목록 및 리조트별 필터링 연동 + - `frontend/app/page.tsx`: 미완성 목업 상태인 '전국 주요 스키장 실시간 슬로프 & 설질 현황' 패널(`resort-panel`)은 추후 크롤링/연동 완성 시까지 JSX 주석으로 임시 비활성화 + - 검증: + - 백엔드 전체 테스트 통과 (`./gradlew test` 100% 성공) + - 프론트엔드 프로덕션 빌드 통과 (`npm run build` 성공) + - 백엔드(8080) 및 프론트엔드(3000) 로컬 개발 서버 정상 구동 확인 + - `./gradlew.bat spotlessCheck test` 통과 (320+건 전체 성공) 및 `npm run lint` 통과 (0 errors, 0 warnings) + - GitHub PR #38(`feat: 동호회·시즌방 홍보 게시판 분리 및 오늘의 설질 제보 구현`) 생성 완료 + - PR #38 리뷰 반영으로 비로그인 모집글 작성 오류를 `401 AUTH_001`로 바로잡고 익명 작성 시도인 경우만 `400 POST_006`을 반환하도록 분리했다. + - 설질 제보는 KST 기준 10월~4월에만 작성 가능하고 회원당 하루 5개로 제한했다. 별도 메모리·카운터 테이블 없이 회원 행 비관적 잠금 후 기존 제보 테이블을 직접 집계하며, 삭제·숨김·차단 데이터도 당일 한도에 포함한다. + - 작성자와 관리자의 소프트 삭제, 관리자 숨김·차단, 매월 1일 04:40 KST 이전 달 데이터 물리 삭제를 추가했다. + - 공개 목록을 `NORMAL` 상태와 KST 당일의 시작·종료 범위로 제한하고 1부터 시작하는 페이지 응답, 안정적인 ID 보조 정렬, `+09:00` 시각, 서버 계산 삭제 권한을 반환하도록 변경했다. + - 전체·리조트별 조회와 회원별 당일 집계에 맞춰 015 마이그레이션 인덱스를 재설계하고 운영 마이그레이션 검증 범위를 `007~015`로 확장했다. + - 프런트의 하드코딩 리조트 ID 대체값과 미사용 제보 목업을 제거했다. 활성 리조트 마스터 실패 시 등록을 차단하고 시즌 안내, 실제 전체 건수, 페이지 이동, 제보 삭제를 연결했다. + - 백엔드 전체 테스트와 Spotless, 프런트엔드 ESLint와 production build를 통과했다. Docker MySQL 8의 격리 스키마에서 운영 마이그레이션 `001~015` 최초 적용·재실행과 015 컬럼·인덱스·외래 키를 검증한 뒤 임시 컨테이너를 제거했다. + - 홈페이지 오픈과 시즌 준비 기간을 반영해 설질 제보 작성 시작일을 매년 11월 1일에서 10월 1일(KST)로 앞당겼다. 더보기 화면은 별도 상세 없이 당일 목록·리조트 필터·페이지 이동만 제공한다. + - 활성 리조트 13개를 유지하면서 기본 ` setTransactionMethod(code)} />{label})} -
+
본문 *
diff --git a/frontend/app/market/[publicId]/page.tsx b/frontend/app/market/[publicId]/page.tsx index 10343a3..b0e2f76 100644 --- a/frontend/app/market/[publicId]/page.tsx +++ b/frontend/app/market/[publicId]/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { use, useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Footer, TopNav } from "../../components/SiteChrome"; +import { CommentProfileAvatar } from "../../components/CommentProfileAvatar"; import { API_ENDPOINTS } from "../../lib/api"; import { csrfFetch } from "../../lib/csrfFetch"; @@ -259,9 +260,5 @@ export default function MarketDetailPage({ params }: { params: Promise<{ publicI function MarketCommentAvatar({ item }: { item: CommentItem }) { const nickname = item.writer?.nickname ?? "탈퇴한 회원"; - return item.writer?.profileImageUrl ? ( - - ) : ( - - ); + return ; } diff --git a/frontend/app/market/new/page.tsx b/frontend/app/market/new/page.tsx index 49767f1..712d2fc 100644 --- a/frontend/app/market/new/page.tsx +++ b/frontend/app/market/new/page.tsx @@ -164,7 +164,7 @@ export default function MarketCreatePage() {
거래 방식 *{transactionMethods.map(([code, label]) => )}
- +
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 86be715..6cee998 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -2,27 +2,20 @@ import Link from "next/link"; import Image from "next/image"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Footer, TopNav } from "./components/SiteChrome"; import LiveChatSection, { MemberProfile } from "./components/LiveChatSection"; -import { API_ENDPOINTS } from "./lib/api"; -import { RESORT_MAP, RESORT_OPTIONS } from "./lib/resortTags"; -import { formatKoreanCalendarDate, RESORT_REPORT_PREVIEWS } from "./lib/resortReports"; +import { CompactSelect } from "./components/CompactSelect"; +import { API_ENDPOINTS, OffsetPage, ResortReportItem } from "./lib/api"; +import { RESORT_MAP } from "./lib/resortTags"; +import { formatKoreanCalendarDate, formatReportTime, getResortReportDisplayName, isResortReportSeason } from "./lib/resortReports"; +import { csrfFetch } from "./lib/csrfFetch"; const HERO_IMAGES = [ "https://lh3.googleusercontent.com/aida/AEtjO1UbsQy3vUnB80P1wDDnbGosvZS9vqvYFfKYsbt-ATgRpqmc2zAPzC52mv7kE-dFt3s-FEwC34VCTJRYlYi_Rv20X4gbV1Ot4EXHI4_0yNB7xgvC-4jj_0S5zRoyhDgx6tmmOf3WlnzXxe1_njPrVcsEQvpsjpP-uLoumLkQrGk_Sl87eNShpSVr4YpqH1lzrGDTFYJBa1ek0ZngAq1VNj9Hp9K8uVOjTkHDEFp6cfh7IlqT2pMxpgODiMr9", "https://lh3.googleusercontent.com/aida/AEtjO1Vcno6No205vArthV-VYm_1xWKA9tsOEYUO3gVlGWnI5gZTow2ELVmgfr8pg185_aUMpvVZ8e4z4F1PbMyRxb3M7hGNaZtOc5set_3eOhXq7bWRfEt2wrA2p8NYhZJHoVinT-4qax2j-zrOhbTWQ0yXmg6rzznW_92J_zQJ-rcSAncKtYkzAsWdayvEpFAYDbpp_Q2WsQeagnmchLIQKsY5uzWyAbfa4iaRW8TVldr_G1j_UNfHRcspz22v", ]; -const resorts = [ - { name: "휘닉스 평창", status: "정상운영", temp: "-4.5°C", open: "12 / 18면", detail: "야간 8", snow: "+4cm (압설 양호)", crowd: "쾌적 (대기 3분)", slopes: "펭귄/챔피언 슬로프", tone: "good" }, - { name: "비발디파크", status: "정상운영", temp: "-2.1°C", open: "9 / 12면", detail: "새벽운영", snow: "강설 (하단 아이스 약간)", crowd: "보통 (초급 혼잡)", slopes: "발라드/테크노", tone: "warn" }, - { name: "하이원 리조트", status: "전면개방", temp: "-6.8°C", open: "18 / 18면", detail: "전코스", snow: "+6cm (극상 파우더)", crowd: "쾌적 (대기 없음)", slopes: "마운틴탑/아테나", tone: "good" }, - { name: "모나 용평", status: "정상운영", temp: "-5.2°C", open: "22 / 28면", detail: "", snow: "강설 하드팩 (엣징 양호)", crowd: "쾌적 (레인보우 여유)", slopes: "레드/골드/레인보우", tone: "good" }, - { name: "웰리힐리파크", status: "정상운영", temp: "-5.0°C", open: "14 / 19면", detail: "", snow: "압설 (C3 모글밭 주의)", crowd: "쾌적 (대기 2분)", slopes: "에코/챌린지", tone: "good" }, - { name: "지산 포레스트", status: "야간운영", temp: "-1.5°C", open: "6 / 7면", detail: "", snow: "인공설 압설 (슬러시 약간)", crowd: "혼잡 (대기 10분)", slopes: "1/2/3번 슬로프", tone: "busy" }, -]; - interface HomePost { publicId: string; categoryName: string; @@ -80,12 +73,91 @@ export default function HomePage() { const [feedsLoading, setFeedsLoading] = useState(true); const [marketPreviews, setMarketPreviews] = useState([]); const [carpools, setCarpools] = useState([]); - const [snowReportResort, setSnowReportResort] = useState("PHOENIX"); + const [snowReportResort, setSnowReportResort] = useState("1"); const [snowReportContent, setSnowReportContent] = useState(""); + const [todayReports, setTodayReports] = useState([]); + const [resortMasterList, setResortMasterList] = useState<{ id: number; name: string }[]>([]); + const [resortMasterError, setResortMasterError] = useState(false); + const [submittingReport, setSubmittingReport] = useState(false); const [koreanToday, setKoreanToday] = useState(() => formatKoreanCalendarDate(new Date())); - const resortRef = useRef(null); + const reportSeasonOpen = isResortReportSeason(new Date()); const activeFeedConfig = HOME_FEED_TABS.find((tab) => tab.key === activeFeed) ?? HOME_FEED_TABS[0]; + const loadTodayReports = async () => { + try { + const response = await fetch(API_ENDPOINTS.resortReports.today(undefined, 1, 10), { credentials: "include" }); + if (response.ok) { + const data: OffsetPage = await response.json(); + setTodayReports(Array.isArray(data.content) ? data.content : []); + } + } catch { + setTodayReports([]); + } + }; + + useEffect(() => { + void fetch(API_ENDPOINTS.resortReports.today(undefined, 1, 10), { credentials: "include" }) + .then((response) => (response.ok ? response.json() : Promise.reject())) + .then((data: OffsetPage) => setTodayReports(Array.isArray(data.content) ? data.content : [])) + .catch(() => setTodayReports([])); + }, []); + + useEffect(() => { + void fetch(API_ENDPOINTS.master.resorts, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : Promise.reject())) + .then((data: { id: number; name: string }[]) => { + if (Array.isArray(data) && data.length > 0) { + setResortMasterList(data); + setSnowReportResort(String(data[0].id)); + setResortMasterError(false); + } else { + setResortMasterError(true); + } + }) + .catch(() => setResortMasterError(true)); + }, []); + + const handleSnowReportSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!snowReportContent.trim() || submittingReport) return; + if (!reportSeasonOpen) { + alert("설질 제보는 10월부터 다음 해 4월까지만 등록할 수 있습니다."); + return; + } + if (!profile) { + alert("설질 제보는 로그인 후 등록할 수 있습니다."); + return; + } + const resortId = Number(snowReportResort); + if (!resortId) { + alert("리조트를 선택해 주세요."); + return; + } + + setSubmittingReport(true); + try { + const res = await csrfFetch(API_ENDPOINTS.resortReports.create, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + resortId, + content: snowReportContent.trim(), + }), + }); + if (res.ok) { + setSnowReportContent(""); + await loadTodayReports(); + } else { + const data = await res.json().catch(() => ({})); + alert(data.message || "설질 제보 등록에 실패했습니다."); + } + } catch { + alert("서버 통신 중 오류가 발생했습니다."); + } finally { + setSubmittingReport(false); + } + }; + useEffect(() => { void (async () => { try { @@ -190,22 +262,6 @@ export default function HomePage() { -
-
-

전국 주요 스키장 실시간 슬로프 & 설질 현황 (10분 주기 갱신)

-
슬로프캠 전체보기 ›
-
-
- {resorts.map((resort) => ( -
-
{resort.name}{resort.status}{resort.temp}
-
슬로프 오픈
{resort.open} {resort.detail && `(${resort.detail})`}
신설 / 설질
{resort.snow}
리프트 혼잡도
{resort.crowd}
-
{resort.slopes}웹캠 보기 ›
-
- ))} -
-
-
@@ -237,19 +293,27 @@ export default function HomePage() {
; } diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts deleted file mode 100644 index 9edff1c..0000000 --- a/frontend/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/scripts/verify-production-migration-007-013.sh b/scripts/verify-production-migration-007-015.sh similarity index 71% rename from scripts/verify-production-migration-007-013.sh rename to scripts/verify-production-migration-007-015.sh index 1d4feed..2e6abf7 100644 --- a/scripts/verify-production-migration-007-013.sh +++ b/scripts/verify-production-migration-007-015.sh @@ -38,7 +38,9 @@ for version in \ 010_migration_carpool_query_index_and_resort_coordinates \ 011_migration_carpool_manual_fallback_snapshots \ 012_migration_carpool_latest_sort_index \ - 013_migration_email_verification; do + 013_migration_email_verification \ + 014_migration_crew_and_season_room \ + 015_migration_resort_report; do grep -q "Migration already applied: $version" <<< "$second_run_output" [[ "$(mysql_test --execute="SELECT COUNT(*) FROM schema_migration WHERE version = '$version'")" == "1" ]] done @@ -46,6 +48,8 @@ done [[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$DB_NAME' AND table_name = 'carpool_detail'")" == "1" ]] [[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = '$DB_NAME' AND table_name = 'carpool_detail' AND column_name IN ('cost_mode', 'equipment_load_available')")" == "2" ]] [[ "$(mysql_test --execute="SELECT COUNT(*) FROM post_category WHERE code = 'CARPOOL'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM post_category WHERE code = 'CREW'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM post_category WHERE code = 'SEASON_ROOM'")" == "1" ]] [[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = '$DB_NAME' AND table_name = 'resort' AND column_name IN ('route_latitude', 'route_longitude')")" == "2" ]] [[ "$(mysql_test --execute="SELECT COUNT(*) FROM resort WHERE code IN ('PHOENIX', 'VIVALDI', 'HIGH1', 'YONGPYONG', 'WELLI_HILLI', 'JISAN', 'KONJIAM', 'MUJU', 'EDEN_VALLEY', 'ELYSIAN', 'ALPENSIA', 'OAK_VALLEY', 'O2_RESORT') AND route_latitude IS NOT NULL AND route_longitude IS NOT NULL")" == "13" ]] [[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'carpool_detail' AND index_name = 'idx_carpool_detail_departure_created_post'")" == "departure_at,created_at,post_id" ]] @@ -57,5 +61,11 @@ done [[ "$(mysql_test --execute="SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'email_verification' AND index_name IN ('uk_email_verification_email_purpose', 'idx_email_verification_token_purpose', 'idx_email_verification_status_expires_at', 'idx_email_verification_expires_at')")" == "4" ]] [[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema = '$DB_NAME' AND table_name = 'email_verification' AND constraint_type = 'CHECK' AND constraint_name IN ('chk_email_verification_purpose', 'chk_email_verification_status', 'chk_email_verification_failed_attempt_count', 'chk_email_verification_send_count')")" == "4" ]] [[ "$(mysql_test --execute="SELECT COUNT(*) FROM member WHERE email_verified_at IS NOT NULL")" == "0" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report' AND column_name IN ('report_id', 'resort_id', 'member_id', 'content', 'status', 'deleted_at', 'created_at')")" == "7" ]] +[[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report' AND index_name = 'idx_resort_report_status_created'")" == "status,created_at,report_id" ]] +[[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report' AND index_name = 'idx_resort_report_resort_status_created'")" == "resort_id,status,created_at,report_id" ]] +[[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report' AND index_name = 'idx_resort_report_member_created'")" == "member_id,created_at" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema = '$DB_NAME' AND table_name = 'resort_report' AND constraint_name IN ('fk_resort_report_resort', 'fk_resort_report_member')")" == "2" ]] -echo "Production migrations 007-013 verification passed." +echo "Production migrations 007-015 verification passed."