Skip to content

Commit da566a9

Browse files
authored
[#286]; fix: 멤버정보다운로드 & 로컬h2에 dev&prod db 덤프하는거 구현 (#294)
* [#251]; fix: 멤버 인포 엑셀 다운로드(POST·비밀번호/Bearer) 및 워크북 익스포터 Made-with: Cursor * [#286]; fix: MySQL 덤프·로컬 H2 초기화 스크립트 및 scouter.local-h2-data 설정 Made-with: Cursor * fix: 예약 메일 claim·SENDING 고착 복구 및 스케줄러(app.mail.scheduler) 프로필 분리 Made-with: Cursor * fix(hrms): 수료 시트 익스포터 11열 헤더를 수료학기로 맞춤(develop 파서와 일치) Made-with: Cursor * [#251]; feat: 멤버다운로드 비번 env로 주입 * [#251]; chore: 코드리뷰 반영
1 parent 28029d1 commit da566a9

35 files changed

Lines changed: 936 additions & 43 deletions

.github/workflows/deploy-dev.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ jobs:
164164
MAIL_STORAGE_S3_BUCKET: ${{ secrets.MAIL_STORAGE_S3_BUCKET }}
165165
MAIL_STORAGE_S3_REGION: ${{ secrets.MAIL_STORAGE_S3_REGION }}
166166
MAIL_STORAGE_S3_KEY_PREFIX: ${{ secrets.MAIL_STORAGE_S3_KEY_PREFIX }}
167+
168+
# 멤버 인포 엑셀 HTML 다운로드 (/members/upload 비밀번호). 비어 있으면 Bearer+특권만 허용.
169+
MEMBER_EXCEL_DOWNLOAD_PASSWORD: ${{ secrets.MEMBER_EXCEL_DOWNLOAD_PASSWORD }}
167170
INPUT_DEPLOY_TAG: ${{ github.event.inputs.tag }}
168171

169172
run: |
@@ -205,6 +208,7 @@ jobs:
205208
echo "MAIL_STORAGE_S3_BUCKET=$MAIL_STORAGE_S3_BUCKET" >> .env.dev
206209
echo "MAIL_STORAGE_S3_REGION=$MAIL_STORAGE_S3_REGION" >> .env.dev
207210
echo "MAIL_STORAGE_S3_KEY_PREFIX=$MAIL_STORAGE_S3_KEY_PREFIX" >> .env.dev
211+
echo "MEMBER_EXCEL_DOWNLOAD_PASSWORD=$MEMBER_EXCEL_DOWNLOAD_PASSWORD" >> .env.dev
208212
echo "IMAGE_TAG=$DEPLOY_TAG" >> .env.dev
209213
210214
# create deployment directory structure

.github/workflows/deploy-prod.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ jobs:
151151
MAIL_STORAGE_S3_BUCKET: ${{ secrets.MAIL_STORAGE_S3_BUCKET }}
152152
MAIL_STORAGE_S3_REGION: ${{ secrets.MAIL_STORAGE_S3_REGION }}
153153
MAIL_STORAGE_S3_KEY_PREFIX: ${{ secrets.MAIL_STORAGE_S3_KEY_PREFIX }}
154+
155+
MEMBER_EXCEL_DOWNLOAD_PASSWORD: ${{ secrets.MEMBER_EXCEL_DOWNLOAD_PASSWORD }}
154156
INPUT_DEPLOY_TAG: ${{ github.event.inputs.tag }}
155157

156158
run: |
@@ -191,6 +193,7 @@ jobs:
191193
echo "MAIL_STORAGE_S3_BUCKET=$MAIL_STORAGE_S3_BUCKET" >> .env.prod
192194
echo "MAIL_STORAGE_S3_REGION=$MAIL_STORAGE_S3_REGION" >> .env.prod
193195
echo "MAIL_STORAGE_S3_KEY_PREFIX=$MAIL_STORAGE_S3_KEY_PREFIX" >> .env.prod
196+
echo "MEMBER_EXCEL_DOWNLOAD_PASSWORD=$MEMBER_EXCEL_DOWNLOAD_PASSWORD" >> .env.prod
194197
echo "IMAGE_TAG=$DEPLOY_TAG" >> .env.prod
195198
196199
ssh -i yourssu.pem ubuntu@$HOST_URL "mkdir -p ~/$PROJECT_NAME-prod-api/logs"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ src/main/resources/applicant-sync-mapping-data.yml
1212
.env.local
1313
env.local
1414

15+
### 로컬 H2용 DB 덤프/초기 SQL (민감 정보 가능) ###
16+
.local/
17+
1518
### STS ###
1619
.apt_generated
1720
.classpath

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
# Yourssu-Scouter-Backend
1+
# Yourssu-Scouter-Backend

scripts/mysql-dump-for-local-h2.sh

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env bash
2+
# MySQL(dev/prod 등)에서 데이터만 덤프해 로컬 H2용 SQL 파일을 만듭니다.
3+
# (테이블은 애플리케이션 기동 시 JPA가 이미 만들므로 --no-create-info 권장)
4+
#
5+
# 사용 전: .env.local 에 DB_URL(jdbc:mysql://...), DB_USERNAME, DB_PASSWORD 설정
6+
# set -a && source .env.local && set +a
7+
# ./scripts/mysql-dump-for-local-h2.sh
8+
#
9+
# 출력: .local/h2-init.sql
10+
# 로컬 실행:
11+
# SCOUTER_LOCAL_H2_INIT_SCRIPT=.local/h2-init.sql ./gradlew bootRun
12+
#
13+
# 주의: MySQL 전용 문법이 남으면 H2 RUNSCRIPT가 실패할 수 있습니다. 오류 나면 해당 구문을 수동으로 고치세요.
14+
15+
set -euo pipefail
16+
cd "$(dirname "$0")/.."
17+
18+
if [ -z "${DB_URL:-}" ] || [ -z "${DB_USERNAME:-}" ] || [ -z "${DB_PASSWORD:-}" ]; then
19+
echo "DB_URL, DB_USERNAME, DB_PASSWORD 가 필요합니다 (.env.local 등)."
20+
exit 1
21+
fi
22+
23+
url="${DB_URL#jdbc:mysql://}"
24+
hostport="${url%%/*}"
25+
db_and_query="${url#*/}"
26+
database="${db_and_query%%\?*}"
27+
28+
if [[ "$hostport" == *":"* ]]; then
29+
host="${hostport%%:*}"
30+
port="${hostport##*:}"
31+
else
32+
host="$hostport"
33+
port="3306"
34+
fi
35+
36+
mkdir -p .local
37+
out=".local/h2-init.sql"
38+
39+
echo "-- Generated by mysql-dump-for-local-h2.sh (data only, no DDL)" > "$out"
40+
echo "SET MODE MySQL;" >> "$out"
41+
42+
# -p"..." 는 ps(1) 등에 비밀번호가 노출될 수 있어 MYSQL_PWD 사용 (로컬/개발 전용 스크립트).
43+
(
44+
export MYSQL_PWD="$DB_PASSWORD"
45+
mysqldump \
46+
-h "$host" -P "$port" \
47+
-u "$DB_USERNAME" \
48+
"$database" \
49+
--single-transaction \
50+
--quick \
51+
--set-gtid-purged=OFF \
52+
--skip-comments \
53+
--no-create-info \
54+
--skip-add-locks \
55+
--skip-disable-keys \
56+
--skip-set-charset \
57+
>> "$out"
58+
)
59+
60+
# H2가 자주 거부하는 MySQL 덤프 줄 제거(필요 시 추가)
61+
if command -v sed >/dev/null 2>&1; then
62+
sed -i.bak \
63+
-e '/^\/\*!40101 SET/d' \
64+
-e '/^\/\*!40103 SET/d' \
65+
-e '/^\/\*!40014 SET/d' \
66+
-e '/^\/\*!40000 ALTER TABLE/d' \
67+
-e '/^LOCK TABLES/d' \
68+
-e '/^UNLOCK TABLES/d' \
69+
"$out" && rm -f "${out}.bak"
70+
fi
71+
72+
echo "작성됨: $out"
73+
echo "다음으로 로컬 기동: SCOUTER_LOCAL_H2_INIT_SCRIPT=$out ./gradlew bootRun"

scripts/run-local-with-dev-db.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env bash
22
# 로컬 코드로 실행하되 dev DB 사용 (application-local + application-local-dev-db)
33
# .env.local 에 DB_URL, DB_USERNAME, DB_PASSWORD (dev DB 접속 정보) 설정 필요.
4+
# 운영(prod) DB에 연결하지 마세요. 예약 메일 스케줄러는 local 프로필에서 기본 OFF입니다.
45
# 사용법: ./scripts/run-local-with-dev-db.sh
56

67
set -e
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.yourssu.scouter.common.application.support.configuration
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
4+
import org.springframework.context.annotation.Configuration
5+
import org.springframework.scheduling.annotation.EnableScheduling
6+
7+
@Configuration
8+
@EnableScheduling
9+
@ConditionalOnProperty(name = ["app.mail.scheduler.enabled"], havingValue = "true")
10+
class MailSchedulingConfiguration

src/main/kotlin/com/yourssu/scouter/common/application/support/configuration/WebConfiguration.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,13 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
66
import org.springframework.context.annotation.Configuration
77
import org.springframework.http.HttpHeaders
88
import org.springframework.http.HttpMethod
9-
import org.springframework.scheduling.annotation.EnableScheduling
109
import org.springframework.web.method.support.HandlerMethodArgumentResolver
1110
import org.springframework.web.servlet.config.annotation.CorsRegistry
1211
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
1312
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
1413

1514
@Configuration
1615
@EnableConfigurationProperties(CorsProperties::class)
17-
@EnableScheduling
1816
class WebConfiguration(
1917
private val loginInterceptor: LoginInterceptor,
2018
private val authUserInfoArgumentResolver: AuthUserInfoArgumentResolver,

src/main/kotlin/com/yourssu/scouter/common/business/domain/mail/MailReservationScheduler.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.yourssu.scouter.common.business.domain.mail
22

33
import org.slf4j.LoggerFactory
4+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
45
import org.springframework.scheduling.annotation.Scheduled
56
import org.springframework.stereotype.Component
67

78
@Component
9+
@ConditionalOnProperty(name = ["app.mail.scheduler.enabled"], havingValue = "true")
810
class MailReservationScheduler(
911
private val mailService: MailService,
1012
) {

src/main/kotlin/com/yourssu/scouter/common/business/domain/mail/MailService.kt

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class MailService(
3939
companion object {
4040
private val log = LoggerFactory.getLogger(MailService::class.java)
4141
private const val MAX_RETRY_HOURS = 24L
42+
private const val STUCK_SENDING_MINUTES = 15L
4243
}
4344

4445
fun sendMail(command: MailSendCommand) {
@@ -166,6 +167,13 @@ class MailService(
166167

167168
fun sendReservedMails() {
168169
val now = Instant.now()
170+
val resetCount =
171+
mailReservationWriter.resetStuckSendingReservations(
172+
now.minus(STUCK_SENDING_MINUTES, ChronoUnit.MINUTES),
173+
)
174+
if (resetCount > 0) {
175+
log.warn("SENDING 고착 복구: {}건을 PENDING_SEND로 되돌렸습니다.", resetCount)
176+
}
169177
val reservations = mailReservationReader.readAllPendingBefore(now)
170178
log.info("예약 메일 처리 시작: 기준시각={}, 발송대상건수={}", now, reservations.size)
171179
for (reservation in reservations) {
@@ -178,10 +186,15 @@ class MailService(
178186
now,
179187
delaySeconds,
180188
)
181-
val sent = trySendReservation(reservation)
182-
if (!sent && reservation.reservationTime.plus(MAX_RETRY_HOURS, ChronoUnit.HOURS).isBefore(now)) {
183-
log.error("최대 재시도 기간({}시간) 초과로 예약 삭제: reservationId={}, mailId={}", MAX_RETRY_HOURS, reservation.id, reservation.mailId)
184-
mailReservationWriter.delete(reservation)
189+
val claimed = mailReservationWriter.claimForSendingOrNull(reservation.id!!, now)
190+
if (claimed == null) {
191+
log.debug("예약 claim 실패(다른 인스턴스 처리 중 또는 상태 변경): reservationId={}", reservation.id)
192+
continue
193+
}
194+
val sent = trySendClaimedReservation(claimed)
195+
if (!sent && claimed.reservationTime.plus(MAX_RETRY_HOURS, ChronoUnit.HOURS).isBefore(now)) {
196+
log.error("최대 재시도 기간({}시간) 초과로 예약 삭제: reservationId={}, mailId={}", MAX_RETRY_HOURS, claimed.id, claimed.mailId)
197+
mailReservationWriter.delete(claimed)
185198
}
186199
}
187200
}
@@ -222,33 +235,64 @@ class MailService(
222235
)
223236
}
224237

225-
val sent = trySendReservation(reservation)
238+
val claimed =
239+
mailReservationWriter.claimForSendingOrNull(reservation.id!!, now)
240+
?: run {
241+
val current =
242+
mailReservationReader.readById(reservationId)
243+
?: throw MailReservationNotFoundException("예약을 찾을 수 없습니다. reservationId=$reservationId")
244+
when (current.status) {
245+
MailReservationStatus.SENT ->
246+
throw MailReservationAlreadyProcessedException(
247+
"이미 발송된 메일은 재전송할 수 없습니다. reservationId=$reservationId",
248+
)
249+
MailReservationStatus.SENDING ->
250+
throw MailFailedException(
251+
"다른 작업에서 발송 처리 중입니다. 잠시 후 다시 시도해 주세요. reservationId=$reservationId",
252+
)
253+
else ->
254+
throw MailFailedException(
255+
"예약 메일을 처리할 수 없습니다. reservationId=$reservationId",
256+
)
257+
}
258+
}
259+
260+
val sent = trySendClaimedReservation(claimed)
226261
if (!sent) {
227262
throw MailFailedException(
228263
"메일 발송에 실패했습니다. OAuth 토큰 갱신 또는 네트워크 상태를 확인해 주세요. reservationId=$reservationId",
229264
)
230265
}
231266
}
232267

233-
private fun trySendReservation(
234-
reservation: MailReservation,
235-
): Boolean {
268+
/**
269+
* [MailReservationStatus.SENDING] 상태(claim 직후)인 예약만 발송한다.
270+
*/
271+
private fun trySendClaimedReservation(reservation: MailReservation): Boolean {
236272
if (reservation.status == MailReservationStatus.SENT) {
237273
log.warn("이미 발송된 예약에 대한 발송 시도 무시: reservationId={}", reservation.id)
238274
return false
239275
}
276+
if (reservation.status != MailReservationStatus.SENDING) {
277+
log.warn(
278+
"SENDING이 아닌 예약에 대한 발송 시도 무시: reservationId={}, status={}",
279+
reservation.id,
280+
reservation.status,
281+
)
282+
return false
283+
}
240284
return try {
241285
val mail = mailRepository.findById(reservation.mailId)
242286
if (mail == null) {
243287
log.warn("예약 메일의 원본을 찾을 수 없어 삭제합니다: reservationId={}, mailId={}", reservation.id, reservation.mailId)
244288
mailReservationWriter.delete(reservation)
245-
return@trySendReservation false
289+
return@trySendClaimedReservation false
246290
}
247291
val user = userReader.findByEmail(mail.senderEmailAddress)
248292
if (user == null) {
249293
log.warn("발신자를 찾을 수 없어 예약메일을 삭제합니다: reservationId={}, mailId={}", reservation.id, reservation.mailId)
250294
mailReservationWriter.delete(reservation)
251-
return@trySendReservation false
295+
return@trySendClaimedReservation false
252296
}
253297
log.info(
254298
"예약 메일 발송 직전 제목 상태: reservationId={}, mailId={}, subject=[{}]",
@@ -260,7 +304,7 @@ class MailService(
260304
val refreshedUser = oauth2Service.refreshOAuth2TokenBeforeExpiry(user.id!!, OAuth2Type.GOOGLE, 10L)
261305
val accessToken = refreshedUser.getBearerAccessToken()
262306
mailSender.send(MailData.from(mail), accessToken)
263-
mailReservationRepository.save(reservation.copy(status = MailReservationStatus.SENT))
307+
mailReservationWriter.markAsSent(reservation)
264308
log.info("예약 메일 발송 완료: reservationId={}, mailId={}", reservation.id, reservation.mailId)
265309
true
266310
} catch (e: Exception) {
@@ -271,9 +315,7 @@ class MailService(
271315
e.javaClass.simpleName,
272316
e,
273317
)
274-
if (reservation.status == MailReservationStatus.SCHEDULED) {
275-
mailReservationRepository.save(reservation.copy(status = MailReservationStatus.PENDING_SEND))
276-
}
318+
mailReservationWriter.markAsPendingSend(reservation)
277319
false
278320
}
279321
}
@@ -346,6 +388,11 @@ class MailService(
346388
"이미 발송된 메일은 수정할 수 없습니다. reservationId=$reservationId",
347389
)
348390
}
391+
if (existingReservation.status == MailReservationStatus.SENDING) {
392+
throw MailReservationAlreadyProcessedException(
393+
"발송 처리 중인 예약은 수정할 수 없습니다. reservationId=$reservationId",
394+
)
395+
}
349396
val now = Instant.now()
350397
if (!now.isBefore(existingReservation.reservationTime)) {
351398
throw MailReservationAlreadyProcessedException(
@@ -400,6 +447,11 @@ class MailService(
400447
"이미 발송된 메일은 취소할 수 없습니다. reservationId=$reservationId",
401448
)
402449
}
450+
if (reservation.status == MailReservationStatus.SENDING) {
451+
throw MailReservationAlreadyProcessedException(
452+
"발송 처리 중인 예약은 취소할 수 없습니다. reservationId=$reservationId",
453+
)
454+
}
403455
val now = Instant.now()
404456
if (!now.isBefore(reservation.reservationTime)) {
405457
throw MailReservationAlreadyProcessedException(

0 commit comments

Comments
 (0)