Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3ddf46d
refactor: 트랙 콘텐츠 관리 권한 검사를 TrackAccessGuard로 모은다
Oct 16, 2026
e0fbc2c
refactor: 쓰이지 않는 코드를 지운다
Oct 16, 2026
6c57b2f
perf: 지연 로딩 연관을 배치로 읽어 회원 목록의 N+1 쿼리를 없앤다
Oct 16, 2026
91938e0
fix: 새 항목의 표시 순서를 개수가 아닌 마지막 순서 + 1로 정한다
Oct 16, 2026
38df7ee
fix: 게임 빌드 웹훅을 업로드 중인 빌드에만 반영한다
Oct 16, 2026
981d253
fix: 회원 한 명의 Slack 사진 동기화를 회원 행 잠금 밖에서 한다
Oct 16, 2026
4c20b6e
chore: 만료된 refresh token·비밀번호 재설정 토큰을 매일 정리한다
Oct 16, 2026
7f55aa7
refactor: 메일 제목·본문 생성을 발송 수단과 분리한다
Oct 16, 2026
a5e5b88
refactor: S3 업로드 URL 서명을 S3UploadUrlSigner 하나로 모은다
Oct 16, 2026
98fbdf5
fix: 같은 회원의 동시 예약이 하루 3시간 한도를 넘지 않게 한다
Oct 16, 2026
3006f02
Merge branch 'main' into perf/batch-fetch
ff1451 Oct 18, 2026
6341ec6
Merge remote-tracking branch 'origin/chore/expired-token-cleanup' int…
ff1451 Oct 18, 2026
cbd2b58
Merge remote-tracking branch 'origin/refactor/mail-message-builder' i…
ff1451 Oct 18, 2026
4e7a5b8
Merge remote-tracking branch 'origin/refactor/s3-presign-helper' into…
ff1451 Oct 18, 2026
25af54b
Merge remote-tracking branch 'origin/fix/slack-sync-outside-lock' int…
ff1451 Oct 18, 2026
732c652
Merge remote-tracking branch 'origin/fix/game-build-webhook-state' in…
ff1451 Oct 18, 2026
1a0e2c1
Merge remote-tracking branch 'origin/refactor/remove-dead-code' into …
ff1451 Oct 18, 2026
196ff1c
Merge remote-tracking branch 'origin/refactor/track-access-policy' in…
ff1451 Oct 18, 2026
aa15f6d
Merge remote-tracking branch 'origin/fix/display-order-after-delete' …
ff1451 Oct 18, 2026
4218ef7
Merge remote-tracking branch 'origin/fix/reservation-concurrency' int…
ff1451 Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public AdminActivityCategoryResponse createCategory(ActivityCategoryCreateReques
throw new ActivityException(ActivityExceptionType.CATEGORY_SLUG_DUPLICATED);
}

int displayOrder = activityCategoryRepository.findAllByOrderByDisplayOrderAsc().size();
int displayOrder = DisplayOrders.next(activityCategoryRepository.findAllByOrderByDisplayOrderAsc(),
ActivityCategory::getDisplayOrder);
ActivityCategory category = activityCategoryRepository.save(ActivityCategory.builder()
.slug(request.slug())
.name(request.name())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ public interface PasswordResetTokenRepository extends JpaRepository<PasswordRese
@Modifying
@Query("update PasswordResetToken t set t.mailSentAt = :now where t.id = :id")
int markMailSent(@Param("id") Long id, @Param("now") Instant now);

@Transactional
@Modifying
@Query("delete from PasswordResetToken t where t.expiresAt < :cutoff")
int deleteExpiredBefore(@Param("cutoff") Instant cutoff);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
@Modifying
@Query("update RefreshToken r set r.revokedAt = :now where r.id = :id and r.revokedAt is null")
int revokeIfNotRevoked(@Param("id") Long id, @Param("now") Instant now);

@Transactional
@Modifying
@Query("delete from RefreshToken r where r.expiresAt < :cutoff")
int deleteExpiredBefore(@Param("cutoff") Instant cutoff);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.bcsdlab.bcsdinternalapiv2.auth.schedule;

import com.bcsdlab.bcsdinternalapiv2.auth.repository.PasswordResetTokenRepository;
import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
* 만료된 refresh token·비밀번호 재설정 토큰을 지운다. 로그인과 토큰 갱신마다 행이 하나씩 쌓여 지워지지 않았다.
*
* <p>만료된 토큰은 갱신 시 재사용 검사보다 먼저 "만료"로 거절되므로 지워도 재사용 감지에는 영향이 없다.
* 다만 만료 직후 지우면 만료된 쿠키로 요청한 사용자가 "만료" 대신 "유효하지 않음" 안내를 받으므로
* {@link #RETENTION}만큼 여유를 둔다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ExpiredTokenCleanupScheduler {

/** 로그인 유지(remember-me) 유효 기간과 같다. */
static final Duration RETENTION = Duration.ofDays(30);

private final RefreshTokenRepository refreshTokenRepository;
private final PasswordResetTokenRepository passwordResetTokenRepository;
private final Clock clock;

// 매일 04:00(KST). 사용자가 적은 시간대다.
@Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul")
public void deleteExpiredTokens() {
Instant cutoff = Instant.now(clock).minus(RETENTION);
int refreshTokens = refreshTokenRepository.deleteExpiredBefore(cutoff);
int resetTokens = passwordResetTokenRepository.deleteExpiredBefore(cutoff);
log.info("EXPIRED_TOKEN_CLEANUP_DONE: refreshTokens={} passwordResetTokens={} cutoff={}",
refreshTokens, resetTokens, cutoff);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum;
import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository;
import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest;
import com.bcsdlab.bcsdinternalapiv2.member.model.Member;
import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository;
import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders;
import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException;
import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType;
import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage;
import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors;
import java.time.Instant;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand All @@ -25,10 +26,15 @@
@Transactional(readOnly = true)
public class AdminCurriculumService {

private static final AccessErrors ACCESS_ERRORS = new AccessErrors(
CurriculumException::new,
CurriculumExceptionType.MEMBER_NOT_FOUND,
CurriculumExceptionType.CURRICULUM_ACCESS_DENIED);

private final CurriculumRepository curriculumRepository;
private final TrackPageRepository trackPageRepository;
private final AdminCurriculumTreeService adminCurriculumTreeService;
private final MemberRepository memberRepository;
private final TrackAccessGuard trackAccessGuard;

public List<AdminCurriculumSummaryResponse> getCurriculums(Long trackPageId) {
return curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).stream()
Expand All @@ -54,7 +60,9 @@ public AdminCurriculumSummaryResponse createCurriculum(Long trackPageId, Curricu
throw new CurriculumException(CurriculumExceptionType.NAME_REQUIRED);
}

int displayOrder = curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size();
int displayOrder = DisplayOrders.next(
curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId),
Curriculum::getDisplayOrder);
Curriculum curriculum = curriculumRepository.save(Curriculum.builder()
.trackPage(trackPage)
.name(name)
Expand Down Expand Up @@ -101,10 +109,6 @@ private Curriculum findOrThrow(Long id) {
}

private void requireCurriculumAccess(Long memberId, TrackPage trackPage) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new CurriculumException(CurriculumExceptionType.MEMBER_NOT_FOUND));
if (!member.canManage(trackPage.getTrack())) {
throw new CurriculumException(CurriculumExceptionType.CURRICULUM_ACCESS_DENIED);
}
trackAccessGuard.requireManager(memberId, trackPage.getTrack(), ACCESS_ERRORS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumWeekRepository;
import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest;
import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders;
import com.bcsdlab.bcsdinternalapiv2.member.model.Member;
import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository;
import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
Expand All @@ -36,11 +36,16 @@
@Transactional(readOnly = true)
public class AdminCurriculumTreeService {

private static final AccessErrors ACCESS_ERRORS = new AccessErrors(
CurriculumException::new,
CurriculumExceptionType.MEMBER_NOT_FOUND,
CurriculumExceptionType.CURRICULUM_ACCESS_DENIED);

private final CurriculumRepository curriculumRepository;
private final CurriculumWeekRepository curriculumWeekRepository;
private final CurriculumTopicRepository curriculumTopicRepository;
private final CurriculumTopicDetailRepository curriculumTopicDetailRepository;
private final MemberRepository memberRepository;
private final TrackAccessGuard trackAccessGuard;

public AdminCurriculumTreeResponse getTree(Long curriculumId) {
Curriculum curriculum = curriculumRepository.findById(curriculumId)
Expand Down Expand Up @@ -75,7 +80,9 @@ public CurriculumWeekResponse createWeek(Long curriculumId, WeekRequest request,
.orElseThrow(() -> new CurriculumException(CurriculumExceptionType.CURRICULUM_NOT_FOUND));
requireCurriculumAccess(memberId, curriculum.getTrackPage());

int displayOrder = curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId).size();
int displayOrder = DisplayOrders.next(
curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId),
CurriculumWeek::getDisplayOrder);
CurriculumWeek week = curriculumWeekRepository.save(CurriculumWeek.builder()
.curriculum(curriculum)
.weekFrom(request.weekFrom())
Expand Down Expand Up @@ -119,7 +126,8 @@ public void reorderWeeks(Long curriculumId, OrderRequest request, Long memberId)
public CurriculumTopicResponse createTopic(Long weekId, TopicRequest request, Long memberId) {
CurriculumWeek week = findWeekOrThrow(weekId);
requireCurriculumAccess(memberId, week.getCurriculum().getTrackPage());
int displayOrder = curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(weekId).size();
int displayOrder = DisplayOrders.next(curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(weekId),
CurriculumTopic::getDisplayOrder);
CurriculumTopic topic = curriculumTopicRepository.save(CurriculumTopic.builder()
.week(week)
.title(request.title())
Expand Down Expand Up @@ -225,10 +233,6 @@ private CurriculumTopic findTopicOrThrow(Long topicId) {
}

private void requireCurriculumAccess(Long memberId, TrackPage trackPage) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new CurriculumException(CurriculumExceptionType.MEMBER_NOT_FOUND));
if (!member.canManage(trackPage.getTrack())) {
throw new CurriculumException(CurriculumExceptionType.CURRICULUM_ACCESS_DENIED);
}
trackAccessGuard.requireManager(memberId, trackPage.getTrack(), ACCESS_ERRORS);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.bcsdlab.bcsdinternalapiv2.game.model;

import com.bcsdlab.bcsdinternalapiv2.game.exception.GameException;
import com.bcsdlab.bcsdinternalapiv2.game.exception.GameExceptionType;
import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity;
import com.bcsdlab.bcsdinternalapiv2.member.model.Member;
import jakarta.persistence.Column;
Expand Down Expand Up @@ -86,17 +88,39 @@ public void updateStatus(GameBuildStatus status) {
this.status = status;
}

public void applyActive(Integer canvasWidth, Integer canvasHeight, Long storageBytes, String buildFileUrl) {
/**
* 완료 웹훅(ACTIVE)을 반영한다. 업로드 중(PROCESSING)인 빌드만 활성화한다 — 늦게 도착하거나 다시 보낸 웹훅이
* 보관된 옛 빌드를 되살리지 못하게 한다. 이미 ACTIVE면 재전송으로 보고 아무것도 바꾸지 않는다.
*
* @return 이번 호출로 활성화됐으면 true
*/
public boolean applyActive(Integer canvasWidth, Integer canvasHeight, Long storageBytes, String buildFileUrl) {
if (status == GameBuildStatus.ACTIVE) {
return false;
}
requireProcessing();
this.status = GameBuildStatus.ACTIVE;
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
this.storageBytes = storageBytes;
this.buildFileUrl = buildFileUrl;
this.failureReason = null;
return true;
}

/** 실패 웹훅(FAILED)을 반영한다. 업로드 중인 빌드만 실패 처리하고, 이미 FAILED면 아무것도 바꾸지 않는다. */
public void markFailed(String failureReason) {
if (status == GameBuildStatus.FAILED) {
return;
}
requireProcessing();
this.status = GameBuildStatus.FAILED;
this.failureReason = failureReason;
}

private void requireProcessing() {
if (status != GameBuildStatus.PROCESSING) {
throw new GameException(GameExceptionType.GAME_BUILD_INVALID_STATE);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuild;
import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuildStatus;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
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;

public interface GameBuildRepository extends JpaRepository<GameBuild, Long> {

Expand All @@ -13,4 +17,8 @@ public interface GameBuildRepository extends JpaRepository<GameBuild, Long> {
List<GameBuild> findAllByGame_IdOrderByUploadedAtDesc(Long gameId);

List<GameBuild> findAllByGame_IdAndStatus(Long gameId, GameBuildStatus status);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select b from GameBuild b where b.id = :id")
Optional<GameBuild> findByIdForUpdate(@Param("id") Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
import com.bcsdlab.bcsdinternalapiv2.game.repository.GameRepository;
import com.bcsdlab.bcsdinternalapiv2.member.model.Member;
import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository;
import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster;
import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors;
import java.time.Instant;
import java.util.List;
import java.util.Set;
Expand All @@ -38,14 +38,15 @@ public class AdminGameBuildService {
private static final Set<GameBuildStatus> TOKEN_ISSUABLE_STATUSES = Set.of(
GameBuildStatus.PENDING, GameBuildStatus.PROCESSING, GameBuildStatus.FAILED);

private static final String GAME_TRACK_CODE = "GAME";
private static final AccessErrors ACCESS_ERRORS = new AccessErrors(
GameException::new, GameExceptionType.MEMBER_NOT_FOUND, GameExceptionType.GAME_ACCESS_DENIED);

private final GameRepository gameRepository;
private final GameBuildRepository gameBuildRepository;
private final MemberRepository memberRepository;
private final TrackMasterRepository trackMasterRepository;
private final GameBuildProperties gameBuildProperties;
private final GameBuildTokenService gameBuildTokenService;
private final TrackAccessGuard trackAccessGuard;

public List<AdminGameBuildResponse> getBuilds(Long gameId) {
findGameOrThrow(gameId);
Expand Down Expand Up @@ -105,15 +106,6 @@ private GameBuild findBuildOrThrow(Long gameId, Long buildId) {
}

private void requireGameAccess(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new GameException(GameExceptionType.MEMBER_NOT_FOUND));
if (member.isAdmin()) {
return;
}
TrackMaster gameTrack = trackMasterRepository.findByCode(GAME_TRACK_CODE)
.orElseThrow(() -> new GameException(GameExceptionType.GAME_ACCESS_DENIED));
if (!member.canManage(gameTrack)) {
throw new GameException(GameExceptionType.GAME_ACCESS_DENIED);
}
trackAccessGuard.requireGameManager(memberId, ACCESS_ERRORS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders;
import com.bcsdlab.bcsdinternalapiv2.member.model.Member;
import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository;
import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster;
import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
Expand All @@ -33,12 +33,13 @@
@Transactional(readOnly = true)
public class AdminGameMemberService {

private static final String GAME_TRACK_CODE = "GAME";
private static final AccessErrors ACCESS_ERRORS = new AccessErrors(
GameException::new, GameExceptionType.MEMBER_NOT_FOUND, GameExceptionType.GAME_ACCESS_DENIED);

private final GameRepository gameRepository;
private final GameMemberRepository gameMemberRepository;
private final MemberRepository memberRepository;
private final TrackMasterRepository trackMasterRepository;
private final TrackAccessGuard trackAccessGuard;

public List<AdminGameMemberResponse> getMembers(Long gameId) {
findGameOrThrow(gameId);
Expand Down Expand Up @@ -68,7 +69,8 @@ public List<AdminGameMemberResponse> attachMembers(Long gameId, GameMembersAttac
}
}

int nextOrder = gameMemberRepository.findAllByGame_IdOrderByDisplayOrderAsc(gameId).size();
int nextOrder = DisplayOrders.next(gameMemberRepository.findAllByGame_IdOrderByDisplayOrderAsc(gameId),
GameMember::getDisplayOrder);
for (Long memberId : memberIds) {
gameMemberRepository.save(GameMember.builder()
.game(game)
Expand All @@ -82,7 +84,7 @@ public List<AdminGameMemberResponse> attachMembers(Long gameId, GameMembersAttac
@Transactional
public void detachMember(Long gameId, Long memberId, Long requesterId) {
requireGameAccess(requesterId);
Game game = findGameOrThrow(gameId);
findGameOrThrow(gameId);
GameMember assignment = gameMemberRepository.findByGame_IdAndMember_Id(gameId, memberId)
.orElseThrow(() -> new GameException(GameExceptionType.GAME_MEMBER_NOT_FOUND));
gameMemberRepository.delete(assignment);
Expand All @@ -91,7 +93,7 @@ public void detachMember(Long gameId, Long memberId, Long requesterId) {
@Transactional
public void reorder(Long gameId, OrderRequest request, Long requesterId) {
requireGameAccess(requesterId);
Game game = findGameOrThrow(gameId);
findGameOrThrow(gameId);
List<GameMember> assignments = gameMemberRepository.findAllByGame_IdOrderByDisplayOrderAsc(gameId);
Map<Long, GameMember> byId = assignments.stream()
.collect(Collectors.toMap(GameMember::getId, assignment -> assignment));
Expand All @@ -106,15 +108,6 @@ private Game findGameOrThrow(Long id) {
}

private void requireGameAccess(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new GameException(GameExceptionType.MEMBER_NOT_FOUND));
if (member.isAdmin()) {
return;
}
TrackMaster gameTrack = trackMasterRepository.findByCode(GAME_TRACK_CODE)
.orElseThrow(() -> new GameException(GameExceptionType.GAME_ACCESS_DENIED));
if (!member.canManage(gameTrack)) {
throw new GameException(GameExceptionType.GAME_ACCESS_DENIED);
}
trackAccessGuard.requireGameManager(memberId, ACCESS_ERRORS);
}
}
Loading
Loading