Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
03fe473
[feat] 게시판 도메인 테이블 및 엔티티 추가
You-Hyuk Sep 10, 2026
91051d7
[feat] 엔티티 카드 조회 공용 컴포넌트 추가
You-Hyuk Sep 10, 2026
c245aa9
[feat] 엔티티 검색(멘션 자동완성) API 추가
You-Hyuk Sep 10, 2026
0bc3fea
[feat] 게시글 CRUD API 추가
You-Hyuk Sep 10, 2026
d9fcc66
[feat] 게시글 추천/추천취소 API 구현
You-Hyuk Sep 10, 2026
3accc6e
[feat] 엔티티별 게시글 백링크 조회 API 구현
You-Hyuk Sep 10, 2026
c0dd15c
[feat] 게시글 통합 검색 API 구현
You-Hyuk Sep 10, 2026
fa17b32
[fix] 게시글 REVIEW/INFO 카테고리 태그 필수 검증 제거
You-Hyuk Sep 11, 2026
ccb3fa4
[feat] 이번 주 인기 게시글/트렌딩 태그 조회 API 추가
You-Hyuk Sep 11, 2026
9c743e6
[chore] develop 브랜치 도입에 따른 CI 대상 브랜치 및 배포 흐름 문서 갱신
You-Hyuk Sep 11, 2026
effc695
[feat] 게시글 본문 글자수 상한(10000자) 서버 검증 추가
You-Hyuk Sep 11, 2026
0e04469
[feat] 게시글 태그 최대 10개 제한 및 본문 글자수·태그 제한 테스트 추가
You-Hyuk Sep 11, 2026
094ed72
[feat] 댓글 도메인 테이블 추가
You-Hyuk Sep 11, 2026
f377295
[feat] 댓글·답글 엔티티 및 Post.commentCount 추가
You-Hyuk Sep 11, 2026
f02db3c
[feat] 댓글 Repository 추가
You-Hyuk Sep 11, 2026
1da7e06
[feat] 댓글 DTO·예외·ErrorCode 추가
You-Hyuk Sep 11, 2026
97e3c84
[feat] 댓글 Service 구현
You-Hyuk Sep 11, 2026
43fc174
[feat] 댓글 Controller 구현
You-Hyuk Sep 11, 2026
952e483
[test] CommentRepository 커스텀 쿼리 통합 테스트 추가
You-Hyuk Sep 11, 2026
a6c899c
[refactor] 댓글 기능 /simplify 정리
You-Hyuk Sep 11, 2026
2ccd465
[feat] 댓글 작성 content 500자 제한 추가
You-Hyuk Sep 11, 2026
a979fc8
[fix] 삭제된 댓글도 작성자 닉네임·좋아요 수는 노출하도록 수정
You-Hyuk Sep 11, 2026
18d2b1b
[fix] 게시글 삭제 시 댓글·좋아요·추천 연쇄 삭제 및 댓글 목록 정렬 안정성 개선
You-Hyuk Sep 14, 2026
51fcb4c
[fix] 게시글 입력값 검증 강화 (본문 크기 우회 방지, 검색어 와일드카드 이스케이프, 태그 중복 제거)
You-Hyuk Sep 14, 2026
691d404
[fix] 게시글 작성·수정 요청 검증 보완 (entityTags null 원소, 수정 시 공백 제목 거부)
You-Hyuk Sep 14, 2026
039b29c
[fix] 삭제된 댓글 좋아요 차단 및 좋아요·추천 카운트 응답 정확도 개선
You-Hyuk Sep 14, 2026
6ed95d5
[fix] SecurityConfig 게시글 검색 API 공개 설정 정리
You-Hyuk Sep 14, 2026
7e59331
[chore] CI 워크플로 GITHUB_TOKEN 권한 최소화
You-Hyuk Sep 14, 2026
c7fda70
[fix] Tiptap 텍스트 추출 시 블록 경계에서만 구분자 삽입
You-Hyuk Sep 14, 2026
c9ed1c6
[fix] 게시글 삭제·추천 취소 시 CodeRabbit 지적 안정성 이슈 수정
You-Hyuk Sep 14, 2026
256fcbb
[merge] #116 -> develop
You-Hyuk Sep 14, 2026
6c01cb0
[feat] 게시글 검색 최소 길이 검증 추가
You-Hyuk Sep 15, 2026
dd2ed5d
[feat] 엔티티 멘션 검색 API 무한스크롤 페이지네이션 지원
You-Hyuk Sep 15, 2026
45e6513
[feat] EntityCardResponse에 releaseGroupId 필드 추가
You-Hyuk Sep 15, 2026
2be4886
[feat] TrackRepository에 멘션 검색 쿼리 추가
You-Hyuk Sep 15, 2026
e1bb5ce
[feat] 엔티티 멘션에 TRACK 타입 추가
You-Hyuk Sep 15, 2026
28520a4
[feat] 게시글 전문검색에 트랙명 매칭 추가
You-Hyuk Sep 15, 2026
24a3f0e
[fix] 게시글 entityTags 응답에 트랙 앨범 id 누락 수정
You-Hyuk Sep 15, 2026
5e4985b
[feat] 릴리즈 관련 게시글에 트랙 태그 게시글 포함
You-Hyuk Sep 15, 2026
6a71b2f
[fix] 음악 엔티티 멘션 검색에서 트랙명 매칭 제거
You-Hyuk Sep 16, 2026
67869f0
[merge] #119 -> main
You-Hyuk Sep 16, 2026
fc6ce63
[feat] 정책 버전 등록 API 및 PolicyDocument 도메인 추가
You-Hyuk Sep 16, 2026
53ac45f
[feat] UserPolicyAgreement 이력 테이블 및 레거시 동의 데이터 백필 추가
You-Hyuk Sep 16, 2026
22785c2
[feat] User 약관 동의 boolean 필드를 UserPolicyAgreement 이력으로 전환
You-Hyuk Sep 16, 2026
211551f
[feat] PolicyNotificationTarget 도메인 추가
You-Hyuk Sep 16, 2026
3c542e0
[feat] 정책 변경 고지 메일 발송 인프라 추가
You-Hyuk Sep 16, 2026
f88ab4e
[feat] 정책 알림 대상 생성 Batch Step1 Tasklet 추가
You-Hyuk Sep 16, 2026
ce9ce77
[feat] 정책 알림 메일 발송 Batch Step2 및 Job 조립
You-Hyuk Sep 16, 2026
6e11f2a
[feat] 정책 알림 배치 트리거 및 발송 실패 재시도 스케줄러 추가
You-Hyuk Sep 16, 2026
3850776
[fix] 정책 알림 배치 트리거의 JobOperator 사용 오류 수정
You-Hyuk Sep 16, 2026
9a482e6
[refactor] 정책 알림 발송 로직 공통화 및 PolicyService 동시성 안전 처리
You-Hyuk Sep 16, 2026
2c3aff1
[chore] 메일 발송을 위한 prod 환경변수 설정 추가
You-Hyuk Sep 18, 2026
a3cf08c
Merge remote-tracking branch 'origin/develop' into feat/#118-policy-c…
You-Hyuk Sep 18, 2026
623a633
[feat] 정책 알림 메일 발신자 표시 이름 및 로고 삽입
You-Hyuk Sep 18, 2026
70c3843
[fix] 정책 알림 배치 재시도 로직 및 원자성 버그 수정
You-Hyuk Sep 18, 2026
d5a1245
[refactor] 약관 재동의(requiresReconsent) 필드 제거
You-Hyuk Sep 18, 2026
0831dd9
[style] 정책 변경 메일 템플릿 링크 여백 및 발신자 표기 수정
You-Hyuk Sep 18, 2026
dfbb2c4
[merge] #121 -> develop
You-Hyuk Sep 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
6 changes: 5 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ jobs:
V_DISCORD_WEBHOOK_5XX_URL: ${{ secrets.DISCORD_WEBHOOK_5XX_URL }}
V_DISCORD_WEBHOOK_4XX_URL: ${{ secrets.DISCORD_WEBHOOK_4XX_URL }}
V_DISCORD_WEBHOOK_INQUIRY_URL: ${{ secrets.DISCORD_WEBHOOK_INQUIRY_URL }}
V_MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
V_MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 2222
envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL
envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD
script: |
echo "$V_GHCR_TOKEN" | docker login ghcr.io -u "$V_GHCR_USER" --password-stdin

Expand All @@ -98,6 +100,8 @@ jobs:
printf 'DISCORD_WEBHOOK_5XX_URL=%s\n' "$V_DISCORD_WEBHOOK_5XX_URL" >> ~/compose/be.env
printf 'DISCORD_WEBHOOK_4XX_URL=%s\n' "$V_DISCORD_WEBHOOK_4XX_URL" >> ~/compose/be.env
printf 'DISCORD_WEBHOOK_INQUIRY_URL=%s\n' "$V_DISCORD_WEBHOOK_INQUIRY_URL" >> ~/compose/be.env
printf 'MAIL_USERNAME=%s\n' "$V_MAIL_USERNAME" >> ~/compose/be.env
printf 'MAIL_PASSWORD=%s\n' "$V_MAIL_PASSWORD" >> ~/compose/be.env

chmod +x ~/scripts/deploy.sh
bash ~/scripts/deploy.sh
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ name: CI

on:
pull_request:
branches: [main]
branches: [main, develop]

permissions:
contents: read

jobs:
test:
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ open -a Docker && docker start redis # Docker 데몬이 꺼져 있으면 먼
<img src=".github/deploy-architecture.png" alt="배포 아키텍처" width="800" />
</p>

- **CI** (`ci.yml`): PR 생성 시 PostgreSQL·Redis 컨테이너를 띄워 전체 테스트 실행 + Docker 이미지 빌드 검증
- **CD** (`cd.yml`): `main` 브랜치 push 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행
- 작업 브랜치 → `develop` PR/병합 → (여러 작업 누적 후) `develop` → `main` PR/병합 순으로 운영 서버에 배포됩니다.
- **CI** (`ci.yml`): `main`/`develop`으로의 PR 생성 시 PostgreSQL·Redis 컨테이너를 띄워 전체 테스트 실행 + Docker 이미지 빌드 검증
- **CD** (`cd.yml`): `main` 브랜치 push(= `develop` → `main` 병합) 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행
- Nginx가 `be-blue`(:8080)/`be-green`(:8081) 중 활성 슬롯으로만 트래픽을 전달하고, Redis는 두 슬롯이 공유합니다. Data Pipeline(`data`)도 같은 Docker Compose에 포함되어 별도 인스턴스 없이 함께 배포됩니다.
- 배포 시 standby 슬롯에 새 이미지를 pull → `/actuator/health` 체크 통과 → Nginx upstream 전환 → 이전 슬롯 정지 순으로 무중단 배포합니다.
- DB는 별도 Lightsail 인스턴스(Managed PostgreSQL)로 분리되어 두 슬롯이 공통으로 바라보고, 4xx/5xx 에러·공연 데이터 수집 결과·문의 접수는 각각 Discord Webhook으로 알림됩니다.
Expand Down
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
Expand All @@ -40,6 +43,7 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.security:spring-security-test'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/Coming/Backend/BackendApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
public class BackendApplication {

public static void main(String[] args) {
Expand Down
23 changes: 5 additions & 18 deletions src/main/java/com/Coming/Backend/auth/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand Down Expand Up @@ -51,26 +50,13 @@ public class User extends BaseTimeEntity {
@Column(name = "birth_year")
private Integer birthYear;

@Column(name = "agreed_terms")
private Boolean agreedTerms;

@Column(name = "agreed_privacy")
private Boolean agreedPrivacy;

@Column(name = "agreed_marketing")
private Boolean agreedMarketing;

@Column(name = "agreed_at")
private LocalDateTime agreedAt;

public void completeRegistration(String nickname, int birthYear,
boolean agreedTerms, boolean agreedPrivacy, boolean agreedMarketing) {
public void completeRegistration(String nickname, int birthYear, boolean agreedMarketing) {
this.nickname = nickname;
this.birthYear = birthYear;
this.agreedTerms = agreedTerms;
this.agreedPrivacy = agreedPrivacy;
this.agreedMarketing = agreedMarketing;
this.agreedAt = LocalDateTime.now();
this.role = UserRole.USER;
}

Expand All @@ -91,14 +77,15 @@ public void updateEmail(String email) {
this.email = email;
}

public boolean hasNoEmail() {
return email == null || email.isBlank();
}

public void reactivate() {
this.status = UserStatus.ACTIVE;
this.role = UserRole.PENDING;
this.nickname = null;
this.birthYear = null;
this.agreedTerms = null;
this.agreedPrivacy = null;
this.agreedMarketing = null;
this.agreedAt = null;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.Coming.Backend.auth.repository;

import com.Coming.Backend.auth.entity.User;
import com.Coming.Backend.auth.entity.UserStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Collection;
Expand All @@ -14,4 +15,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findAllByIdIn(Collection<Long> ids);

boolean existsByNickname(String nickname);

List<User> findByStatus(UserStatus status);
}
33 changes: 31 additions & 2 deletions src/main/java/com/Coming/Backend/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
import com.Coming.Backend.auth.repository.UserRepository;
import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository;
import com.Coming.Backend.inquiry.repository.InquiryRepository;
import com.Coming.Backend.policy.entity.PolicyDocument;
import com.Coming.Backend.policy.entity.PolicyType;
import com.Coming.Backend.policy.entity.UserPolicyAgreement;
import com.Coming.Backend.policy.exception.PolicyNotFoundException;
import com.Coming.Backend.policy.repository.PolicyDocumentRepository;
import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
Expand All @@ -43,6 +51,8 @@ public class AuthService {
private final UserFollowArtistRepository userFollowArtistRepository;
private final UserConcertCalendarRepository userConcertCalendarRepository;
private final InquiryRepository inquiryRepository;
private final PolicyDocumentRepository policyDocumentRepository;
private final UserPolicyAgreementRepository userPolicyAgreementRepository;

/**
* Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다.
Expand Down Expand Up @@ -128,11 +138,11 @@ public TokenResponse register(Long userId, RegisterRequest request) {
user.completeRegistration(
request.nickname(),
request.birthYear(),
request.agreedTerms(),
request.agreedPrivacy(),
Boolean.TRUE.equals(request.agreedMarketing())
);
try {
recordPolicyAgreement(userId, PolicyType.TERMS);
recordPolicyAgreement(userId, PolicyType.PRIVACY);
userRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new NicknameDuplicateException();
Expand All @@ -142,6 +152,25 @@ public TokenResponse register(Long userId, RegisterRequest request) {
return new TokenResponse(accessToken);
}

/**
* 현재 시행 중인 정책 버전에 대한 사용자 동의 이력을 기록한다. 이미 동의 이력이 있으면 건너뛴다(탈퇴 후 재가입 대비).
*/
private void recordPolicyAgreement(Long userId, PolicyType type) {
PolicyDocument currentPolicy = policyDocumentRepository
.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc(type, LocalDate.now())
.orElseThrow(PolicyNotFoundException::new);
if (userPolicyAgreementRepository.existsByUserIdAndPolicyId(userId, currentPolicy.getId())) {
return;
}
userPolicyAgreementRepository.save(
UserPolicyAgreement.builder()
.userId(userId)
.policyId(currentPolicy.getId())
.agreedAt(LocalDateTime.now())
.build()
);
}

private void validateRegisterRequest(RegisterRequest request) {
if (!Boolean.TRUE.equals(request.agreedTerms()) || !Boolean.TRUE.equals(request.agreedPrivacy())) {
throw new TermsNotAgreedException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/api/artists/**",
"/api/concerts/**",
"/api/releases/**",
"/api/calendar"
"/api/calendar",
"/api/mentions/search",
"/api/posts/**",
"/api/search",
"/api/entities/**"
).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/auth/register").hasRole("PENDING")
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/Coming/Backend/common/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,28 @@ public enum ErrorCode {
INVALID_INQUIRY_STATUS(HttpStatus.BAD_REQUEST, "변경 불가능한 문의 상태입니다."),
TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 대상입니다."),

// Post
POST_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."),
ALREADY_RECOMMENDED(HttpStatus.CONFLICT, "이미 추천한 게시글입니다."),
NOT_RECOMMENDED(HttpStatus.BAD_REQUEST, "추천하지 않은 게시글입니다."),
POST_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "본문은 10000자를 초과할 수 없습니다."),

// Comment
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."),
INVALID_REPLY_DEPTH(HttpStatus.BAD_REQUEST, "답글에는 답글을 작성할 수 없습니다."),
ALREADY_LIKED(HttpStatus.CONFLICT, "이미 좋아요한 댓글입니다."),
NOT_LIKED(HttpStatus.BAD_REQUEST, "좋아요하지 않은 댓글입니다."),

// Pipeline
PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."),
PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."),
PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."),
PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."),

// Policy
POLICY_VERSION_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 정책 버전입니다."),
POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 정책입니다."),

// Common
RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."),
INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public interface ConcertRepository extends JpaRepository<Concert, Long> {
@Query("UPDATE Concert c SET c.viewCount = c.viewCount + 1 WHERE c.id = :id")
void incrementViewCount(@Param("id") Long id);

@Query(value = "SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC, c.id DESC",
countQuery = "SELECT COUNT(c) FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q")
Page<Concert> searchByTitleForMention(@Param("hidden") Collection<ConcertStatus> hidden, @Param("q") String q, Pageable pageable);

@Query("SELECT c FROM Concert c WHERE c.startDate <= :lastDay AND c.endDate >= :firstDay AND c.status NOT IN :hidden ORDER BY c.startDate ASC")
List<Concert> findByDateRange(@Param("firstDay") LocalDate firstDay, @Param("lastDay") LocalDate lastDay, @Param("hidden") Collection<ConcertStatus> hidden);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.Coming.Backend.policy.batch;

import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.job.parameters.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
@RequiredArgsConstructor
public class PolicyNotificationBatchConfig {

private static final int CHUNK_SIZE = 20;

private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet;
private final PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader;
private final PolicyNotificationMailProcessor policyNotificationMailProcessor;
private final PolicyNotificationTargetWriter policyNotificationTargetWriter;

@Bean
public Job policyNotificationJob() {
return new JobBuilder("policyNotificationJob", jobRepository)
.incrementer(new RunIdIncrementer())
.start(targetCreationStep())
.next(mailSendStep())
.build();
}

@Bean
public Step targetCreationStep() {
return new StepBuilder("targetCreationStep", jobRepository)
.tasklet(policyNotificationTargetCreationTasklet, transactionManager)
.build();
}

@Bean
public Step mailSendStep() {
return new StepBuilder("mailSendStep", jobRepository)
.<PolicyNotificationTarget, PolicyNotificationTarget>chunk(CHUNK_SIZE)
.reader(policyNotificationPendingTargetReader)
.processor(policyNotificationMailProcessor)
.writer(policyNotificationTargetWriter)
.transactionManager(transactionManager)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.Coming.Backend.policy.batch;

import com.Coming.Backend.policy.event.PolicyRegisteredEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.parameters.JobParameters;
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 실행한다.
* AFTER_COMMIT 콜백과 같은 스레드에서 배치(자체 트랜잭션 포함)를 직접 실행하면 커밋 중이던
* 트랜잭션 동기화 상태와 충돌해 JobInterruptedException이 발생하므로, 별도 스레드에서 실행하고
* join으로 대기해 호출자 입장에서는 동기로 완료를 기다리게 한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PolicyNotificationJobTrigger {

private final JobOperator jobOperator;
private final Job policyNotificationJob;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPolicyRegistered(PolicyRegisteredEvent event) {
Thread jobThread = Thread.ofVirtual().start(() -> runJob(event.policyId()));
try {
jobThread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("정책 알림 배치 대기 중 인터럽트 발생 — policyId: {}", event.policyId(), e);
}
}

private void runJob(Long policyId) {
try {
JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext(
new JobParametersBuilder()
.addLong("policyId", policyId)
.toJobParameters()
);
jobOperator.run(policyNotificationJob, jobParameters);
} catch (Exception e) {
log.error("정책 알림 배치 실행 실패 — policyId: {}", policyId, e);
}
}
}
Loading
Loading