) {
+ val path = Path.of(outFile).toAbsolutePath().normalize()
+ path.parent?.let { Files.createDirectories(it) }
+ objectMapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), rows)
+ }
}
private data class BatchArgs(
@@ -67,6 +106,8 @@ private data class BatchArgs(
val delayMs: Long = 200,
val withDetails: Boolean = true,
val detailDelayMs: Long = 100,
+ val outFile: String? = null,
+ val dumpOnly: Boolean = false,
) {
companion object {
fun from(args: ApplicationArguments): BatchArgs {
@@ -84,6 +125,8 @@ private data class BatchArgs(
delayMs = single("delayMs")?.toLong() ?: 200L,
withDetails = withDetails,
detailDelayMs = single("detailDelayMs")?.toLong() ?: 100L,
+ outFile = single("outFile"),
+ dumpOnly = args.containsOption("dumpOnly"),
)
}
}
@@ -116,4 +159,4 @@ private fun DetailSession.toCrawledDetailSession(): CrawledDetailSession =
endDate = endDate,
startTime = startTime,
endTime = endTime
- )
\ No newline at end of file
+ )
diff --git a/hangsha/batch/test.json b/hangsha/batch/test.json
new file mode 100644
index 0000000..88fa091
--- /dev/null
+++ b/hangsha/batch/test.json
@@ -0,0 +1,368 @@
+[ {
+ "dataSeq" : "PGM012001770",
+ "majorTypes" : [ "의료 인공지능 융합인재 양성 사업단", "교육(특강/세미나)" ],
+ "title" : "[ SNU AI.MED Talks 26 ] 2026 의료 인공지능 융합인재 양성 사업단 세미나 시리즈 8th",
+ "status" : "마감임박",
+ "operationMode" : "온오프라인 병행",
+ "applyStart" : "2026-04-24",
+ "applyEnd" : "2026-04-29",
+ "activityStart" : "2026-04-29",
+ "activityEnd" : "2026-04-29",
+ "applyCount" : 16,
+ "capacity" : 0,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F9f870cbfa173b335167b7a32131b59c7271859a5025696a9247e242912cb262a.png",
+ "tags" : [ "#의료인공지능" ],
+ "mainContentHtml" : "2026 서울대학교 의료 인공지능 융합인재 양성 사업단 세미나 시리즈 ‘SNU AI.MED Talks 26’의 여덟번째 세미나에 여러분을 초대합니다.
\n 이번 세미나에서는 서울시립대 도시보건대학원 이기일 교수의 ‘저출산 고령화 시대의 과제’를 주제로 진행됩니다.
- 일시 : 2026. 4. 29.(수) 17:00-18:30
\n - 장소 : 연건 34동 함춘회관 201호 가천홀 및 온라인(via Zoom) 동시 진행
\n - ID : 864 5144 7871 (하단 포스터 QR 코드 접속 가능)
\n - https://snu-ac-kr.zoom.us/j/86451447871
\n
\n많은 관심과 참여 부탁드립니다.
\n 감사합니다.
\n 의료 인공지능 융합인재 양성 사업단 드림
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "연건 34동 함춘회관 201호 가천홀",
+ "startDate" : "2026-04-29",
+ "endDate" : "2026-04-29",
+ "startTime" : "17:00",
+ "endTime" : "18:30"
+ } ]
+}, {
+ "dataSeq" : "PGM012001769",
+ "majorTypes" : [ "아동가족학과", "교육(특강/세미나)" ],
+ "title" : "2026학년도 아동가족학과 Field Connection 4차 특강",
+ "status" : "모집중",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-24",
+ "applyEnd" : "2026-05-07",
+ "activityStart" : "2026-05-07",
+ "activityEnd" : "2026-05-07",
+ "applyCount" : 24,
+ "capacity" : 80,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F16f0822b9ac99339225a6c351dc4acf7257e034618bd5b9dcf8b5625b1099e07.png",
+ "tags" : [ "#아동", "#가족", "#로봇", "#IoRT" ],
+ "mainContentHtml" : "2026학년도 아동가족학과 Field Connection 4차 특강을 아래와 같이 개최합니다.
\n - 일시: 2026. 5. 7. (목) 오후 1시
\n - 장소: 222동 B102호 최병오홀
\n - 연사: 곽소나 선임연구원 (한국과학기술연구원 지능인터랙션연구센터)
\n - 주제: Designing Robots for Everyday Life
\n
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "-",
+ "startDate" : "2026-05-07",
+ "endDate" : "2026-05-07",
+ "startTime" : "13:00",
+ "endTime" : "14:30"
+ } ]
+}, {
+ "dataSeq" : "PGM012001768",
+ "majorTypes" : [ "인권센터", "기타" ],
+ "title" : "2026 학생-인권단체 자원활동 연계 프로그램(스누게더)",
+ "status" : "모집중",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-23",
+ "applyEnd" : "2026-05-07",
+ "activityStart" : "2026-06-17",
+ "activityEnd" : "2026-12-31",
+ "applyCount" : 1,
+ "capacity" : 0,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F09fc5a134ed11909e2199e17bc9a4018405c52b1b1a6994d99519620d4c44422.png",
+ "tags" : [ "#스누게더", "#스누게더" ],
+ "mainContentHtml" : "
\n인권센터에서는 올해도 학생들이 인권단체에서 자원활동 할 수 있도록 연계하는 프로그램을 실시합니다.
\n참여단체(11개 단체) : 반도체 노동자의 건강과 인권지킴이 반올림, 아시아 평화를 향한 이주, 아디(Asian Dignity Initiative), 한국이주여성인권센터, 이주민센터 친구, 언론인권센터, 한국성폭력상담소, 인권과 평화를 위한 국제민주연대, 전쟁과여성인권박물관, 한국비정규직노동센터, 진실의힘(상세 소개는 첨부 파일 참조)
\n\n ★ 참가신청자는 자원 활동을 희망하는 인권단체에서 안내한 내용 및 참여학생 후기 (첨부파일)를 충분히 숙지한 후, 첨부된 신청서를 작성하여 5월 7일(목) 까지 SNU비교과를 통해 주시기 바랍니다. 자원활동을 희망하는 단체를 신청서에 기재해 주시고, 제출시 한글파일로, 파일명은 [2026 스누게더_이름] 으로 해주시기 바랍니다.\n
\n★ 신청 마감은 5월 7일(목 자정입니다. 단체에서 지원서를 검토한 후 선발을 결정하게 되며 단체에 따라 면접을 통해 선정하는 경우도 있습니다. 결과는 개별적으로 알려드릴 예정입니다.
\n★ 확정된 참가자는 6월 16일(화) 18:00(장소 추후 공지) 인권센터에서 진행될 오리엔테이션에 꼭 참여하셔야 합니다. 오리엔테이션 참가가 어려운 경우, 사전에 오리엔테이션에 관해 인권센터와 협의해 주시기 바랍니다.
\n\n 문의: hradmin@snu.ac.kr / 02-880-2421, 2428\n
\n[신청서 양식] 2026 스누게더_이름.hwp
\n2026 인권-자원활동 연계프로그램 스누게더 참여단체 안내문 및 참가학생 후기.pdf
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "-",
+ "startDate" : "2026-06-17",
+ "endDate" : "2026-12-31",
+ "startTime" : "00:00",
+ "endTime" : "00:00"
+ } ]
+}, {
+ "dataSeq" : "PGM012001767",
+ "majorTypes" : [ "대학생활문화원", "사회공헌(봉사)" ],
+ "title" : "이웃사랑 4월 2차 특별단기봉사활동 및 나눔교육",
+ "status" : "마감임박",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-23",
+ "applyEnd" : "2026-04-27",
+ "activityStart" : "2026-04-29",
+ "activityEnd" : "2026-04-29",
+ "applyCount" : 8,
+ "capacity" : 10,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F3f5e5d5e080a162be4b1ab4eb7d5b8f30d6d76496be248a99e0be3c57cfc7540.png",
+ "tags" : [ "#봉사활동" ],
+ "mainContentHtml" : "이웃사랑 4월 2차 특별단기봉사활동 및 나눔교육
\n* 봉사명: 경로식당 배식봉사 모집
\n* 내용: 경로식당 배식 봉사 활동
\n* 일시: 4월 29일(수) 10시 30분~13시 30분(봉사 활동), 13시 30분~14시 30분(나눔 교육 및 식사 제공)
\n* 장소: 관악노인종합복지관 경로식당(보라매병원역 도보 4분)
\n* 신청: 4월 27일(월) 오후 13시까지로 연장합니다.
\n* 신청 방법: 비교과 신청
\n 또한, 이웃사랑 카카오톡 채널 혹은 snuebon@snu.ac.kr로 [특단(4월 2차)/이름/생년월일/성별/학번/연락처/이메일/소속대학/소속학과/1365아이디/식사 유의사항(비건, 알러지 등)]를 작성하여 신청하는 것도 가능합니다^^
\n 예시: 특단(4월 2차)/김특단/2003.01.01/남/2024-12345/010-1234-1234/snuebon@snu.ac.kr/사회과학대학/사회복지학과/snuebon123/식사 유의사항 없음
\n
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "관악노인종합복지관",
+ "startDate" : "2026-04-29",
+ "endDate" : "2026-04-29",
+ "startTime" : "10:30",
+ "endTime" : "13:30"
+ }, {
+ "round" : 2,
+ "location" : "근처 식당",
+ "startDate" : "2026-04-29",
+ "endDate" : "2026-04-29",
+ "startTime" : "13:30",
+ "endTime" : "14:30"
+ } ]
+}, {
+ "dataSeq" : "PGM012001764",
+ "majorTypes" : [ "경력개발센터", "교육(특강/세미나)" ],
+ "title" : "면접 전략 특강",
+ "status" : "모집중",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-23",
+ "applyEnd" : "2026-04-30",
+ "activityStart" : null,
+ "activityEnd" : null,
+ "applyCount" : 148,
+ "capacity" : 220,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F02fadd127f9692e6d231cf56bf45baa596ceea3163f6fd8c98cd9129832dd113.png",
+ "tags" : [ "#면접전략특강접" ],
+ "mainContentHtml" : "
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "-",
+ "startDate" : null,
+ "endDate" : null,
+ "startTime" : null,
+ "endTime" : null
+ } ]
+}, {
+ "dataSeq" : "PGM012001762",
+ "majorTypes" : [ "학술정보서비스과", "교육(특강/세미나)" ],
+ "title" : "제3회 중앙도서관 연구자 커리어 멘토링 특강",
+ "status" : "모집중",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-22",
+ "applyEnd" : "2026-05-20",
+ "activityStart" : "2026-05-21",
+ "activityEnd" : "2026-05-21",
+ "applyCount" : 19,
+ "capacity" : 50,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F7d85e2cb49fa63f76af494bf22bf1f662638faf0a797b1209045a38ff1cb411c.png",
+ "tags" : [ "#연구역량", "#논문작성", "#리서치", "#연구자", "#멘토링", "#특강", "#연구수행", "#학술", "#선배연구자", "#공학", "#이공계", "#연구", "#출판" ],
+ "mainContentHtml" : "제3회 '중앙도서관 연구자 커리어 멘토링 특강'《연구에서 출판까지: 이공계 논문 작성법》
\n\n - 일시 : 2026. 5. 21.(목) 14:00-15:30
\n - 장소 : 중앙도서관 관정관(62-1동) 3층 양두석홀
\n - 강사 : 이윤경(신소재공동연구소 연구원)\n
\n - 2025 한국고분자학회 2025년도 권순기우수학위논문상 수상
\n - 2024 서울대학교 공과대학 재료공학부 우수 졸업논문상
\n - 2022, 2024 대한화학회 제130회, 134회 학술발표회 BKCS 포스터상(대한화학회)
\n - 2022, 2023 4단계 BK21사업 창의인재 재료교육연구단 참여대학원생 우수 연구실적상(4단계 BK21 창의인재 재료교육연구단)
\n - 2021 Nature Conference 'Bio-inspired Nanomaterials' Best Poster Awards - Silver(Nature Conference)
\n
\n - 내용 : 논문 작성법(기본·심화·기타 글쓰기)과 연구자로서의 여정·시야·관점\n
\n - 기본: Research article
\n - 심화 (1): (Mini)review article
\n - 심화 (2): Perspectives
\n - 기타 글쓰기: Reviewer comment와 response letter
\n
\n
\n\n - 문의 : 중앙도서관 학술연구지원서비스 02-880-5300, libserv@snu.ac.kr
\n
\n※ 신청하신 분들께는 참여 안내 메일을 교육 2일전과 당일에 보내드립니다.
\n
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "중앙도서관 관정관 3층 양두석홀",
+ "startDate" : "2026-05-21",
+ "endDate" : "2026-05-21",
+ "startTime" : "14:00",
+ "endTime" : "15:30"
+ } ]
+}, {
+ "dataSeq" : "PGM012001761",
+ "majorTypes" : [ "대학생활문화원", "교육(특강/세미나)" ],
+ "title" : "알렉산더테크닉(비대면) 2026년 5-6월",
+ "status" : "모집중",
+ "operationMode" : "온라인",
+ "applyStart" : "2026-04-21",
+ "applyEnd" : "2026-04-30",
+ "activityStart" : "2026-05-12",
+ "activityEnd" : "2026-06-16",
+ "applyCount" : 21,
+ "capacity" : 0,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F023add7058f3962c84dd382b4b0249f3ee35cc586d642338b862cdc2666da5b8.png",
+ "tags" : [ "#건강", "#스트레스", "#긴장", "#이완", "#호흡" ],
+ "mainContentHtml" : "안녕하세요, 서울대학교 대학생활문화원에서 아래와 같이 2026년 5-6월 알렉산더테크닉 프로그램(비대면)을 운영하오니 많은 관심과 참여 부탁드립니다.
\n 본 프로그램은 비대면(ZOOM) 강좌입니다.
\n (※ 대면 알렉산더테크닉 프로그램과 커리큘럼이 상이합니다. 중복 수강 가능합니다. 참여자 선정은 선착순이 아니라 종합적 고려를 통하여 이루어집니다.)
* 반드시 모든 회기 참여 가능하신 분만 신청해주시기 바랍니다.
\n * 알렉산더테크닉 소개 영상을 시청하신 후 신청 여부를 결정하시길 권장드립니다: (https://www.youtube.com/watch?v=7kr-K9OAgcQ&t=2498s)
\n (포스터 글자가 잘 보이지 않을 경우, 페이지 크기를 확대하여 확인해주시기 바랍니다)
\n
\n
\n
\n * 이미지를 통해 내용을 확인하기 어려우신 경우, 하단에 위치한 설명글을 참고해주시기 바랍니다.
\n * 신청링크: extra.snu.ac.kr/ptfol/pgm/view.do?dataSeq=PGM012001761
\n* 알렉산더테크닉은 매주 똑같은 내용의 단회성 프로그램이 아닌 연속형 프로그램(6회기)으로 진행됩니다. 모든 회기 참여 가능하신 분들만 신청해주기 바랍니다 :)
\n ---
\n 편안한 마음과 몸을 위한 바른 자세 ALEXANDER TECHNIQUE
\n 알렉산더테크닉 교육은 ‘의식적인 나의 사용’을 통한 몸의 사용법을 연습합니다. 앉고, 서고, 숨쉬는 등의 일상적인 동작을 할 때 우리 몸을 어떻게 사용해야 불필요한 에너지를 절약하고 건강할 수 있는지를 다룹니다. 이와 동시에 몰려오는 생각과 감정을 잠시 멈추고, 고요한 쉼의 시간을 가짐으로써 긴장 및 스트레스를 완화하여 몸과 마음의 건강을 증진시키는 데 목적이 있습니다.
▶개요
\n ■ 강 의 시 간: 5월 12일~6월 16일, 매주 화요일 21:00~22:00, 총 6회기
\n ■ 강 사: 최현묵(한국알렉산더테크닉협회 회장)
\n ■ 대 상: 교수, 교직원, 강사, 대학원생, 학부생 등의 서울대 구성원 모두
\n ■ 강 의 장 소: ZOOM 온라인 강의
\n ■ 신 청 방 법: 비교과관리시스템(본문 링크) 또는 QR 코드
\n ■ 신 청 기 한: 4월 30일(목) 오전 10시
\n ■ 참 여 확 정: 4월 30일(목) 오후 4시까지 개별 연락
\n ■ 비 용: 무료
\n ■ 문 의: winny525@snu.ac.kr
▶프로그램 진행 내용
\n 1. 의식적인 몸 사용 연습
\n ① 감각 깨우기
\n ② 공간 의식
\n ③ 의자를 활용한 교육. 가장 효율적인 앉기 자세를 배우며 앉은 상태로 어깨를 푸는 등 꼭 눕지 않고 일상 속에서도 긴장을 내려놓는 방법을 익힘.
\n 또한 서는 과정에서 불필요한 동작을 자각하고 자제하는 방법을 익힘.
\n ④ 거울을 활용하여 서기, 걷기, 멍키(monkey) 자세에서 몰랐던 습관을 스스로 인지함. 이로써 발목, 무릎, 고관절, 어깨 등의 관절을 자유로이 하도록 선택할 수 있게 됨.
\n 2. 올바른 호흡을 통한 올바른 몸의 방향성 찾기
\n ① 매트 위에서 다리를 세우고 누운 자세(semi-supine position)로 위스퍼-아(whispered-ah) 호흡.
\n ② 목-머리-척추를 중심으로 한 핸즈 온(hands-on) 작업을 통해 그라운딩과 몸의 자연스러운 방향성을 도와줄 수 있음.
\n ③ 눕기, 앉기, 멍키, 서기에서의 핸즈 온(hands-on)과 함께 이완 호흡법 교육
▶강좌 운영 관련 Q & A
\n 1. 알렉산더 테크닉(비대면) 수업은 어떻게 진행되나요?
\n 답변 : 알렉산더 테크닉은 체험형 워크숍입니다. 50분 동안 강사가 들려주는 가이드에 따라 자신의 몸을 의식해보고 몸의 긴장이 해소되는 방향성을 몸에게 제안합니다. 한 주간 축적된 스트레스를 내려놓을 수 있으며, 지속적인 참여로 긴장없이 편안한 삶을 스스로 이끌어가기를 기대할 수 있습니다. 편히 누울 수 있는 공간과 조용한 환경을 준비해주세요.
\n 2. 알렉산더 테크닉 수업은 어떻게 진행되나요?
\n 답변: 알렉산더 테크닉은 한 달에서 두 달 단위로 참여자를 모집하여 4~6회 정도 수업내용이 이어지는 심화 프로그램으로 운영됩니다. 매주 수업내용이 약간씩 다른 커리큘럼으로 구성되어 있기에 매주 화요일 모두 참여 가능하신 분들만 신청해주시면 감사하겠습니다.
\n 3. 비용이 있나요?
\n 답변 : 비용은 무료입니다. 본 프로그램은 대학생활문화원에서 지원합니다.
▶알렉산더 테크닉 교육 관련 Q & A
\n 1. 알렉산더 테크닉은 주로 어떤 목적으로 활용되나요?
\n 답변 : 알렉산더 테크닉은 만성 근골격계 통증 문제 극복이나 전인적 웰빙, 스트레스 대처능력 향상 등을 위하여 활용되는 훈련법으로, 주로 보건 의료 분야에서 활용되고, 줄리어드 음악학교, 로열 음악원, 예일 연극학교 등 음악, 연극 등 예술분야, 그리고 체육분야에서 활용되어왔습니다(Eldred 등, 2015; Little 등, 2008; MacPherson , 2015; Preece 등, 2016; Glover 등, 2018; Gross, 2019 등).
\n 2. 알렉산더 테크닉은 어떤 효과가 있나요?
\n 답변 : 알렉산더 테크닉이 신체 통증 질환 환자들의 통증 개선과 전문 분야별(체육계, 예술계)에서 퍼포먼스 향상에 효과가 있다는 연구가 있으며, 이와 함께 알렉산더테크닉의 기전에 대한 이론적 연구들이 진행되고 있습니다.
\n 구체적인 내용으로는 자세 긴장에 대한 자기 조절능력 상승(Loram 등, 2016; Gurfinkel 등, 2006; Cacciatore 등, 2011), 신체 운동 조절능력 개선(Cacciatore 등, 2011; Gurfinkel, 2009; Ivanenko와 Gurfinkel, 2018), 각종 악기 연주자들의 만성적 긴장 완화(Austin과 Ausubel, 1992; Cacciatore 등, 2011; Cacciatore 등, 2014; Cohen 등, 2020; Hamel 등, 2016; O’Neill 등, 2015), 신체 스키마 변화에 따른 긴장 완화와 감정 조절(Gilpin 등, 2015; Moseley와 Flor, 2012; Dum 등, 2016), 만성 통증(목, 허리, 무릎 통증, 파킨슨 환자 거동 문제, 재발성 반복 손상) 개선(Little 등, 2008; MacPherson , 2015; Preece 등, 2016), 호흡 개선(Klein 등, 2014), 스트레스 및 각종 부정적 감정 개선(Glover 등, 2018; Gross 등, 2019; Klein 등, 2014; Valentine 등, 1995; Zhukov, 2019) 등이 있습니다.
\n 3. 어떤 걸 배우나요? 명상과 관련된 강좌인가요?
\n 답변 : 알렉산더 테크닉은 여러 동작들을 취하고 점검해보는 등의 자세 교정과 관련이 높습니다. 올바르지 못한 자세로 무의식적인 근골격 수축을 지속할 때 몸은 긴장을 하고 신체에 통증이 발생합니다. 바르지 못한 자세가 지속될 때 신체 뿐 아니라 심리적으로도 스트레스가 증가할 수 있습니다.
\n 알렉산더 테크닉은 앉고, 서고, 숨 쉬는 등의 일상적인 동작을 통해 스스로의 움직임 패턴을 자각할 수 있도록 하며, 불필요한 동작을 줄이고 건강한 움직임을 가질 수 있도록 합니다. 그리고 강의 시간만큼은 '지금 여기'에 초점을 맞추어 내 몸을 챙길 수 있도록 하여 정서적 환기를 돕고 장기적으로는 삶의 전체적 긴장도를 완화시키는 것을 목적으로 하는 강의입니다.
\n 그리고 강사의 언어적 가이드에 의존하는 것은 일시적이므로, 더 나아가 스스로 자신의 근육과 신경을 재조율하는 방식을 터득할 수 있게 돕습니다.
\n (이하 참고문헌)
▶참고문헌
\n - Austin, J. H., & Ausubel, P. (1992). Enhanced respiratory muscular function in normal adults after lessons in proprioceptive musculoskeletal education without exercises. Chest, 102(2), 486-490.
\n - Cacciatore, T. W., Gurfinkel, V. S., Horak, F. B., Cordo, P. J., & Ames, K. E. (2011). Increased dynamic regulation of postural tone through Alexander Technique training. Human Movement Science, 30(1), 74-89.
\n - Cacciatore, T. W., Gurfinkel, V. S., Horak, F. B., & Day, B. L. (2011). Prolonged weight-shift and altered spinal coordination during sit-to-stand in practitioners of the Alexander Technique. Gait & Posture, 34(4), 496-501.
\n - Cacciatore, T. W., Mian, O. S., Peters, A., & Day, B. L. (2014). Neuromechanical interference of posture on movement: evidence from Alexander technique teachers rising from a chair. Journal of Neurophysiology, 112(3), 719-729.
\n - Cohen, R. G., Baer, J. L., Ravichandra, R., Kral, D., McGowan, C., & Cacciatore, T. W. (2020). Lighten up! Postural instructions affect static and dynamic balance in healthy older adults. Innovation in Aging, 4(2), igz056.
\n - Dum, R. P., Levinthal, D. J., & Strick, P. L. (2016). Motor, cognitive, and affective areas of the cerebral cortex ifluence the adrenal medulla. Proceedings of the National Academy of Sciences of the United States of America, 113(35), 9922-9927.
\n - Eldred, J., Hopton, A., Donnison, E., Woodman, J., & MacPherson, H. (2015). Teachers of the Alexander technique in the UK and the people who take their lessons: A national cross-sectional survey. Complementary Therapies in Medicine, 23(3), 451-461.
\n - Gilpin, H. R., Moseley, G. L., Stanton, T. R., & Newport, R. (2015). Evidence for distorted mental representation of the hand in osteoarthritis. Rheumatology, 54(4), 678-682.
\n - Glover, L., Wolverson, E., & Woods, C. (2022). ’I am teaching them and they are teaching me’: Experiences of teaching Alexander Technique to people with dementia. European Journal of Integrative Medicine, 56, 102200.
\n - Gross, M., Cohen, R., Ravichandra, R., Basye, M., & Norcia, M. (2019). Poised for Parkinson’s: Alexander technique course improves balance, mobility and posture for people with PD. Archives of Physical Medicine and Rehabilitation, 100(12), e193.
\n - Gurfinkel, V., Cacciatore, T. W., Cordo, P., Horak, F., Nutt, J., & Skoss, R. (2006). Postural muscle tone in the body axis of healthy humans. Journal of Neurophysiology, 96(5), 2678-2687.
\n - Hamel, K. A., Ross, C., Schultz, B., O’Neill, M., & Anderson, D. I. (2016). Older adult Alexander technique practitioners walk differently than healthy age-matched controls. Journal of Bodywork and Movement Therapies, 20(4), 751-760.
\n - Ivanenko, Y., & Gurfinkel, V. S. (2018). Human postural control. Frontiers in Neuroscience, 12, 171.
\n - Klein, S.D., Bayard, C., & Wolf, U. (2014). The Alexander technique and musicians: A systematic review of controlled trials. BMC Complementary and Alternative Medicine, 14(1), 414.
\n - Little, P., Lewith, G., Webley, F., Evans, M., Beattie, A., Middleton, K., Barnett, J., Ballard, K., Oxford, F., Smith, P., Yardley, L., Hollinghurst, S., & Sharp, D. (2008). Randomised controlled trial of Alexander technique lessons, exercise, and massage (ATEAM) for chronic and recurrent back pain. BMJ, 337, a884.
\n - Loram, I., Bate, B., Harding, P., Cunningham, R., & Loram, A. (2016). Proactive selective inhibition targeted at the neck muscles: This proximal constraint facilitates learning and regulates global control. IEEE Transactions on Neural Systems and Rehabilitation Engineering, 25(4), 357-369.
\n - MacPherson, H., Tilbrook, H., Richmond, S., Woodman, J., Ballard, K., Atkin, K., Bland, M., Eldred, J., Essex, H., Hewitt, C., Hopton, A., Keding, A., Lansdown, H., Parrott, S., Torgerson, D., Wenham, A., & Watt, I. (2015). Alexander technique lessons or acupuncture sessions for persons with chronic neck pain: A randomized trial. Annals of Internal Medicine, 163(9), 653-662.
\n - Moseley, G. L., & Flor, H. (2012). Targeting cortical representations in the treatment of chronic pain: A review. Neurorehabilitation and Neural Repair, 26(6), 646.
\n - O’Neill, M. M., Anderson, D. I., Allen, D. D., Ross, C., & Hamel, K. A. (2015). Effects of Alexander technique training experience on gait behavior in older adults. Journal of Bodywork and Movement Therapies, 19(3), 473-481.
\n - Preece, S. J., Jones, R. K., Brown, C. A., Cacciatore, T. W., & Jones, A. K. (2016). Reductions in co-contraction following neuromuscular re-education in people with knee osteoarthritis. BMC Musculoskeletal Disorders, 17(1), 372.
\n - Valentine, E. R., Fitzgerald, D. F. P., Gorton, T. L., Hudson, J. A., & Symonds, E. R. C. (1995). The effect of lessons in the Alexander technique on music performance in high and low stress situations. Psychology of Music, 23(2), 129-141.
\n - Zhukov, K. (2019). Current approaches for management of music performance anxiety. An introductory overview. Medical Problems of Performing Artists, 34(1), 53-60.
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "Zoom",
+ "startDate" : "2026-05-12",
+ "endDate" : "2026-05-12",
+ "startTime" : "21:00",
+ "endTime" : "22:00"
+ }, {
+ "round" : 2,
+ "location" : "Zoom",
+ "startDate" : "2026-05-19",
+ "endDate" : "2026-05-19",
+ "startTime" : "21:00",
+ "endTime" : "22:00"
+ }, {
+ "round" : 3,
+ "location" : "Zoom",
+ "startDate" : "2026-05-26",
+ "endDate" : "2026-05-26",
+ "startTime" : "21:00",
+ "endTime" : "22:00"
+ }, {
+ "round" : 4,
+ "location" : "Zoom",
+ "startDate" : "2026-06-02",
+ "endDate" : "2026-06-02",
+ "startTime" : "21:00",
+ "endTime" : "22:00"
+ }, {
+ "round" : 5,
+ "location" : "Zoom",
+ "startDate" : "2026-06-09",
+ "endDate" : "2026-06-09",
+ "startTime" : "21:00",
+ "endTime" : "22:00"
+ }, {
+ "round" : 6,
+ "location" : "Zoom",
+ "startDate" : "2026-06-16",
+ "endDate" : "2026-06-16",
+ "startTime" : "21:00",
+ "endTime" : "22:00"
+ } ]
+}, {
+ "dataSeq" : "PGM012001760",
+ "majorTypes" : [ "대학생활문화원", "교육(특강/세미나)" ],
+ "title" : "알렉산더테크닉(대면) 2026년 5-6월",
+ "status" : "마감임박",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-21",
+ "applyEnd" : "2026-04-30",
+ "activityStart" : "2026-05-07",
+ "activityEnd" : "2026-06-11",
+ "applyCount" : 18,
+ "capacity" : 20,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F7c6cc64c2d97d739cfef1a1584630a743c65f276db6f6eb7ad9f08ebbe6eb719.png",
+ "tags" : [ "#건강", "#스트레스", "#긴장", "#이완", "#호흡" ],
+ "mainContentHtml" : "안녕하세요, 서울대학교 대학생활문화원에서 아래와 같이 2026년 5-6월 알렉산더테크닉(대면) 강좌를 운영하오니 많은 관심과 참여 바랍니다.
\n 본 강좌는 대면(In-person) 강좌이며 대학생활문화원(63동 학생회관 5층)에서 진행됩니다.
\n (※ 비대면 알렉산더테크닉 프로그램과 커리큘럼이 상이합니다. 중복 수강 가능합니다. 참여자 선정은 선착순이 아니라 종합적 고려를 통하여 이루어집니다.)
* 반드시 모든 회기 참여 가능하신 분만 신청해주시기 바랍니다.
\n* 알렉산더테크닉 소개 영상을 시청하신 후 신청 여부를 결정하시길 권장드립니다: (https://www.youtube.com/watch?v=7kr-K9OAgcQ&t=2498s)
\n* 이미지를 통해 내용을 확인하기 어려우신 경우, 이미지 확대 또는 포스터 하단의 내용 참조(혹은 winny525@snu.ac.kr 로 문의)
\n
\n* 신청 링크: extra.snu.ac.kr/ptfol/pgm/view.do?dataSeq=PGM012001760
\n---
\n편안한 마음과 몸을 위한 바른 자세, 알렉산더테크닉 대면 강좌 안내
\n 알렉산더테크닉 교육은 '의식적인 나의 사용'을 통한 몸의 사용법을 연습합니다. 앉고, 서고, 숨쉬는 등의 일상적인 동작을 할 때 우리 몸을 어떻게 사용해야 불필요한 에너지를 절약하고 건강할 수 있는지를 다룹니다. 이와 동시에 몰려오는 생각과 감정을 잠시 멈추고, 고요한 쉼의 시간을 가짐으로써 긴장 및 스트레스를 완화하여 몸과 마음의 건강을 증진시키는 데 목적이 있습니다.
▶개요
\n ■ 강 의 시 간: 5월 7일~6월 11일, 매주 목요일 11:00~12:00, 총 6회기
\n * 6회기 모두 참여 가능한 참여자 모집 / 노쇼 방지를 위해 출석률 50% 이하는 추후 프로그램 신청 불가
\n ■ 강 사: 백희숙(한국알렉산더테크닉협회 소속 강사)
\n ■ 대 상: 교수, 교직원, 강사, 대학원생, 학부생 등의 서울대 구성원 20명
\n ■ 강 의 장 소: 대학생활문화원(63동 학생회관 5층)
\n ■ 신 청 방 법: 비교과관리시스템(본문 링크) 또는 QR 코드(→) 통해 신청
\n ■ 신 청 기 한: 4월 30일(목) 오전 10시
\n ■ 참 여 확 정: 4월 30일(목) 오후 4시까지 참여 여부 개별 연락
\n ■ 비 용: 무료
\n ■ 문 의: winny525@snu.ac.kr
▶커리큘럼
\n 1주차 : 그라운딩과 호흡, 세미수파인 자세
\n 2주차: 서기 습관 수정, 효율적인 구부리기 자세
\n 3주차: 중추조절과 디렉션, 누워서 긴장 해소하기
\n 4주차: 의자의 사용, 좌골훈련, 어깨풀기
\n 5주차: 걷기 습과 수정, 멈추고 선택하기
\n 6주차: 손의 사용, 의자와 컴퓨터, 휴대폰 사용 자세
▶프로그램 관련 Q&A
\n Q1 : 알렉산더 테크닉 비대면(at home) 강좌와 어떤 것이 다른가요?
\n A1 : 대면 수업은 두 달 동안(6회기)의 참여가 가능한 분들을 모집하며, 소수 참여자들이 직접 현장에서 더 적극적으로 자신의 몸과 자세에 대해 느낄 수 있습니다.
\n Q2 : 알렉산더 테크닉 수업은 어떻게 진행되나요?
\n A2 : 알렉산더 테크닉은 각 회기당 50분 동안 진행되며, 매 회기마다 커리큘럼에 따른 새로운 훈련을 하게 됩니다. 강사가 들려주는 가이드에 따라 자신의 몸을 의식해보고 몸의 긴장이 해소되는 방향성을 몸에게 제안합니다. 준비된 의자와 거울을 활용하여 불필요한 동작을 자각할 수 있습니다. 한 주간 축적된 스트레스를 내려놓을 수 있으며, 지속적인 참여로 긴장없이 편안한 삶을 스스로 이끌어가기를 기대할 수 있습니다.
\n Q3 : 비용이 있나요?
\n A3 : 비용은 무료입니다. 본 프로그램은 대학생활문화원에서 지원합니다.
▶알렉산더 테크닉 관련 Q&A
\n Q1 : 알렉산더 테크닉은 주로 어떤 목적으로 활용되나요?
\n A1 : 알렉산더 테크닉은 만성 근골격계 통증 문제 극복이나 전인적 웰빙, 스트레스 대처능력 향상 등을 위하여 활용되는 훈련법으로, 주로 보건 의료 분야에서 활용되고, 줄리어드 음악학교, 로열 음악원, 예일 연극학교 등 음악, 연극 등 예술분야, 그리고 체육분야에서 활용되어왔습니다(Eldred 등, 2015; Little 등, 2008; MacPherson , 2015; Preece 등, 2016; Glover 등, 2018; Gross, 2019 등).
\n Q2 : 알렉산더 테크닉은 어떤 효과가 있나요?
\n A2 : 알렉산더 테크닉이 신체 통증 질환 환자들의 통증 개선과 전문 분야별(체육계, 예술계)에서 퍼포먼스 향상에 효과가 있다는 연구가 있으며, 이와 함께 알렉산더테크닉의 기전에 대한 이론적 연구들이 진행되고 있습니다.
\n 구체적인 내용으로는 자세 긴장에 대한 자기 조절능력 상승(Loram 등, 2016; Gurfinkel 등, 2006; Cacciatore 등, 2011), 신체 운동 조절능력 개선(Cacciatore 등, 2011; Gurfinkel, 2009; Ivanenko와 Gurfinkel, 2018), 각종 악기 연주자들의 만성적 긴장 완화(Austin과 Ausubel, 1992; Cacciatore 등, 2011; Cacciatore 등, 2014; Cohen 등, 2020; Hamel 등, 2016; O’Neill 등, 2015), 신체 스키마 변화에 따른 긴장 완화와 감정 조절(Gilpin 등, 2015; Moseley와 Flor, 2012; Dum 등, 2016), 만성 통증(목, 허리, 무릎 통증, 파킨슨 환자 거동 문제, 재발성 반복 손상) 개선(Little 등, 2008; MacPherson , 2015; Preece 등, 2016), 호흡 개선(Klein 등, 2014), 스트레스 및 각종 부정적 감정 개선(Glover 등, 2018; Gross 등, 2019; Klein 등, 2014; Valentine 등, 1995; Zhukov, 2019) 등이 있습니다.
\n Q3 : 어떤 걸 배우나요? 명상과 관련된 강좌인가요?
\n A3 : 알렉산더 테크닉은 여러 동작들을 취하고 점검해보는 등의 자세 교정과 관련이 높습니다. 올바르지 못한 자세로 무의식적인 근골격 수축을 지속할 때 몸은 긴장을 하고 신체에 통증이 발생합니다. 바르지 못한 자세가 지속될 때 신체 뿐 아니라 심리적으로도 스트레스가 증가할 수 있습니다.
\n 알렉산더 테크닉은 앉고, 서고, 숨 쉬는 등의 일상적인 동작을 통해 스스로의 움직임 패턴을 자각할 수 있도록 하며, 불필요한 동작을 줄이고 건강한 움직임을 가질 수 있도록 합니다. 그리고 강의 시간만큼은 '지금 여기'에 초점을 맞추어 내 몸을 챙길 수 있도록 하여 정서적 환기를 돕고 장기적으로는 삶의 전체적 긴장도를 완화시키는 것을 목적으로 하는 강의입니다.
\n 그리고 강사의 언어적 가이드에 의존하는 것은 일시적이므로, 더 나아가 스스로 자신의 근육과 신경을 재조율하는 방식을 터득할 수 있게 돕습니다.
\n (이하 모두 참고문헌)
▶참고문헌
\n - Austin, J. H., & Ausubel, P. (1992). Enhanced respiratory muscular function in normal adults after lessons in proprioceptive musculoskeletal education without exercises. Chest, 102(2), 486-490.
\n - Cacciatore, T. W., Gurfinkel, V. S., Horak, F. B., Cordo, P. J., & Ames, K. E. (2011). Increased dynamic regulation of postural tone through Alexander Technique training. Human Movement Science, 30(1), 74-89.
\n - Cacciatore, T. W., Gurfinkel, V. S., Horak, F. B., & Day, B. L. (2011). Prolonged weight-shift and altered spinal coordination during sit-to-stand in practitioners of the Alexander Technique. Gait & Posture, 34(4), 496-501.
\n - Cacciatore, T. W., Mian, O. S., Peters, A., & Day, B. L. (2014). Neuromechanical interference of posture on movement: evidence from Alexander technique teachers rising from a chair. Journal of Neurophysiology, 112(3), 719-729.
\n - Cohen, R. G., Baer, J. L., Ravichandra, R., Kral, D., McGowan, C., & Cacciatore, T. W. (2020). Lighten up! Postural instructions affect static and dynamic balance in healthy older adults. Innovation in Aging, 4(2), igz056.
\n - Dum, R. P., Levinthal, D. J., & Strick, P. L. (2016). Motor, cognitive, and affective areas of the cerebral cortex iuence the adrenal medulla. Proceedings of the National Academy of Sciences of the United States of America, 113(35), 9922-9927.
\n - Eldred, J., Hopton, A., Donnison, E., Woodman, J., & MacPherson, H. (2015). Teachers of the Alexander technique in the UK and the people who take their lessons: A national cross-sectional survey. Complementary Therapies in Medicine, 23(3), 451-461.
\n - Gilpin, H. R., Moseley, G. L., Stanton, T. R., & Newport, R. (2015). Evidence for distorted mental representation of the hand in osteoarthritis. Rheumatology, 54(4), 678-682.
\n - Glover, L., Wolverson, E., & Woods, C. (2022). ’I am teaching them and they are teaching me’: Experiences of teaching Alexander Technique to people with dementia. European Journal of Integrative Medicine, 56, 102200.
\n - Gross, M., Cohen, R., Ravichandra, R., Basye, M., & Norcia, M. (2019). Poised for Parkinson’s: Alexander technique course improves balance, mobility and posture for people with PD. Archives of Physical Medicine and Rehabilitation, 100(12), e193.
\n - Gurfinkel, V., Cacciatore, T. W., Cordo, P., Horak, F., Nutt, J., & Skoss, R. (2006). Postural muscle tone in the body axis of healthy humans. Journal of Neurophysiology, 96(5), 2678-2687.
\n - Hamel, K. A., Ross, C., Schultz, B., O’Neill, M., & Anderson, D. I. (2016). Older adult Alexander technique practitioners walk differently than healthy age-matched controls. Journal of Bodywork and Movement Therapies, 20(4), 751-760.
\n - Ivanenko, Y., & Gurfinkel, V. S. (2018). Human postural control. Frontiers in Neuroscience, 12, 171.
\n - Klein, S.D., Bayard, C., & Wolf, U. (2014). The Alexander technique and musicians: A systematic review of controlled trials. BMC Complementary and Alternative Medicine, 14(1), 414.
\n - Little, P., Lewith, G., Webley, F., Evans, M., Beattie, A., Middleton, K., Barnett, J., Ballard, K., Oxford, F., Smith, P., Yardley, L., Hollinghurst, S., & Sharp, D. (2008). Randomised controlled trial of Alexander technique lessons, exercise, and massage (ATEAM) for chronic and recurrent back pain. BMJ, 337, a884.
\n - Loram, I., Bate, B., Harding, P., Cunningham, R., & Loram, A. (2016). Proactive selective inhibition targeted at the neck muscles: This proximal constraint facilitates learning and regulates global control. IEEE Transactions on Neural Systems and Rehabilitation Engineering, 25(4), 357-369.
\n - MacPherson, H., Tilbrook, H., Richmond, S., Woodman, J., Ballard, K., Atkin, K., Bland, M., Eldred, J., Essex, H., Hewitt, C., Hopton, A., Keding, A., Lansdown, H., Parrott, S., Torgerson, D., Wenham, A., & Watt, I. (2015). Alexander technique lessons or acupuncture sessions for persons with chronic neck pain: A randomized trial. Annals of Internal Medicine, 163(9), 653-662.
\n - Moseley, G. L., & Flor, H. (2012). Targeting cortical representations in the treatment of chronic pain: A review. Neurorehabilitation and Neural Repair, 26(6), 646.
\n - O’Neill, M. M., Anderson, D. I., Allen, D. D., Ross, C., & Hamel, K. A. (2015). Effects of Alexander technique training experience on gait behavior in older adults. Journal of Bodywork and Movement Therapies, 19(3), 473-481.
\n - Preece, S. J., Jones, R. K., Brown, C. A., Cacciatore, T. W., & Jones, A. K. (2016). Reductions in co-contraction following neuromuscular re-education in people with knee osteoarthritis. BMC Musculoskeletal Disorders, 17(1), 372.
\n - Valentine, E. R., Fitzgerald, D. F. P., Gorton, T. L., Hudson, J. A., & Symonds, E. R. C. (1995). The effect of lessons in the Alexander technique on music performance in high and low stress situations. Psychology of Music, 23(2), 129-141.
\n - Zhukov, K. (2019). Current approaches for management of music performance anxiety. An introductory overview. Medical Problems of Performing Artists, 34(1), 53-60.
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "63동(학생회관) 5층 대학생활문화원",
+ "startDate" : "2026-05-07",
+ "endDate" : "2026-05-07",
+ "startTime" : "11:00",
+ "endTime" : "12:00"
+ }, {
+ "round" : 2,
+ "location" : "63동(학생회관) 5층 대학생활문화원",
+ "startDate" : "2026-05-14",
+ "endDate" : "2026-05-14",
+ "startTime" : "11:00",
+ "endTime" : "12:00"
+ }, {
+ "round" : 3,
+ "location" : "63동(학생회관) 5층 대학생활문화원",
+ "startDate" : "2026-05-21",
+ "endDate" : "2026-05-21",
+ "startTime" : "11:00",
+ "endTime" : "12:00"
+ }, {
+ "round" : 4,
+ "location" : "63동(학생회관) 5층 대학생활문화원",
+ "startDate" : "2026-05-28",
+ "endDate" : "2026-05-28",
+ "startTime" : "11:00",
+ "endTime" : "12:00"
+ }, {
+ "round" : 5,
+ "location" : "63동(학생회관) 5층 대학생활문화원",
+ "startDate" : "2026-06-04",
+ "endDate" : "2026-06-04",
+ "startTime" : "11:00",
+ "endTime" : "12:00"
+ }, {
+ "round" : 6,
+ "location" : "63동(학생회관) 5층 대학생활문화원",
+ "startDate" : "2026-06-11",
+ "endDate" : "2026-06-11",
+ "startTime" : "11:00",
+ "endTime" : "12:00"
+ } ]
+}, {
+ "dataSeq" : "PGM012001757",
+ "majorTypes" : [ "첨단융합학부", "교육(특강/세미나)" ],
+ "title" : "[첨단융합학부] SNUTI to Silicon Valley(Summer) 2차 사전교육(260430)",
+ "status" : "모집중",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-22",
+ "applyEnd" : "2026-05-03",
+ "activityStart" : "2026-04-30",
+ "activityEnd" : "2026-04-30",
+ "applyCount" : 83,
+ "capacity" : 0,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2Fcfec3d885ebd16d6dc509ca8f6d2e9bd6ea9b29bb0c6cb43122e4e6afb12a330.png",
+ "tags" : [ "#문제해결역량", "#창의적사고역량", "#소통및협업역량" ],
+ "mainContentHtml" : "2026년 SNUTI to Silicon Valley(Summer) 참가자 대상으로 진행된 2차 사전교육입니다.
\n2차 사전교육에서는 디자인씽킹(Design Thinking) 기반의 문제 해결 접근법을 중심으로, 사용자 관점에서 문제를 정의하고 창의적인 해결 아이디어를 도출하는 과정을 다룹니다.
\n 디자인씽킹 전 과정을 이해하고 실습함으로써, 실리콘밸리 현장 활동에 필요한 실질적인 사고 방식과 실행 역량을 강화하는 데 목적이 있습니다.
\n-일시: 2026. 4. 30. (목) 18:20 ~ 20:00
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "18동 312호",
+ "startDate" : "2026-04-30",
+ "endDate" : "2026-04-30",
+ "startTime" : "18:20",
+ "endTime" : "20:00"
+ } ]
+}, {
+ "dataSeq" : "PGM012001756",
+ "majorTypes" : [ "입학진로정보실", "현장학습/인턴" ],
+ "title" : "2026-1 CALS 대학원생을 위한 학술대회 참가 지원 프로그램",
+ "status" : "모집중",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-17",
+ "applyEnd" : "2026-04-30",
+ "activityStart" : "2026-04-17",
+ "activityEnd" : "2026-06-30",
+ "applyCount" : 5,
+ "capacity" : 0,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F27225f817cd01c012a2f85909b7681e68e3d7a879e279e851d198c38fadb8cba.jpg",
+ "tags" : [ "#해외학술대회", "#대학원생" ],
+ "mainContentHtml" : "ㅁ전체 운영 일정: 2026년 4월부터 2026년 6월까지 약 3개월 간 운영
\n\n \n \n | \n 구분 | \n \n 일정 | \n
\n \n | \n 모집 공고 | \n \n 2026. 4. 17.(금) | \n
\n \n | \n 모집 마감 | \n \n 2026. 4. 30.(목) | \n
\n \n | \n 서류 심사 | \n \n 2026. 5. 4.(월) ~ 7.(목) | \n
\n \n | \n 합격자 발표 | \n \n 2026. 5. 12.(화) | \n
\n \n | \n 합격자 오리엔테이션 | \n \n 2026. 5. 13.(수) (예정) | \n
\n \n | \n 해외학술대회 참가 | \n \n 2026. 4월 ~ 2026. 6월 | \n
\n \n | \n 참가 결과 보고 | \n \n 한국 귀국 후 2주 이내 | \n
\n \n
\n\n \n
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "서울대학교",
+ "startDate" : "2026-04-17",
+ "endDate" : "2026-06-30",
+ "startTime" : "17:00",
+ "endTime" : "23:59"
+ } ]
+}, {
+ "dataSeq" : "PGM012001754",
+ "majorTypes" : [ "경력개발센터", "교육(특강/세미나)" ],
+ "title" : "[정원 25명] 대학원생 셀프리더십 향상 교육 프로그램『갤럽 강점검사 기반 워크숍』",
+ "status" : "마감임박",
+ "operationMode" : "오프라인",
+ "applyStart" : "2026-04-20",
+ "applyEnd" : "2026-04-27",
+ "activityStart" : "2026-05-11",
+ "activityEnd" : "2026-05-11",
+ "applyCount" : 41,
+ "capacity" : 0,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F6e751627a2bd5808c2c8965ddaf47d6962b5b20060f5dec898383ceacc40beea.png",
+ "tags" : [ "#대학원생", "#자기이해", "#갤럽강점진단", "#자기발견" ],
+ "mainContentHtml" : "안녕하세요. 서울대학교 경력개발센터입니다.
\n 우리 센터에서 \"대학원생 셀프리더십 향상 교육 프로그램『갤럽 강점검사 기반 워크숍』\"을 실시할 예정입니다. 많은 관심과 참여를 부탁드립니다!
\n※ 본 프로그램은 대학원생(수료, 휴학 포함)을 대상으로 합니다. 비교과관리시스템에서 '학생'으로 신분변경하여 신청하시기 바랍니다.
\n ※ 선착순 선발이 아닌 신청서의 작성 내용을 바탕으로한 직접선발 방식으로 최종 참여자(25명 내외)를 선정합니다.
\n
<문의>
\n 서울대학교 경력개발센터 BK사업담당 윤가영, 황경빈
\n snulink@snu.ac.kr, 880-2520
\n
\n
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "롯데국제교육관(152-1동) 208호",
+ "startDate" : "2026-05-11",
+ "endDate" : "2026-05-11",
+ "startTime" : "13:00",
+ "endTime" : "18:00"
+ } ]
+}, {
+ "dataSeq" : "PGM012001753",
+ "majorTypes" : [ "첨단융합학부", "현장학습/인턴" ],
+ "title" : "2026학년도 하계 첨단융합학부 학부생 연구 인턴십",
+ "status" : "모집중",
+ "operationMode" : "온오프라인 병행",
+ "applyStart" : "2026-04-22",
+ "applyEnd" : "2026-05-14",
+ "activityStart" : "2026-05-26",
+ "activityEnd" : "2026-08-02",
+ "applyCount" : 1,
+ "capacity" : 0,
+ "imageUrl" : "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset-dev/o/events%2F142c83a1701b942293459ecf76bebdf416300bbb0200650b0dcdf36eec41a45b.jpg",
+ "tags" : [ "#연구", "#연구인턴", "#인턴십", "#학부생인턴", "#대학원인턴" ],
+ "mainContentHtml" : "2026학년도 하계 첨단융합학부 학부생 연구 인턴십 안내
\n□ 지원대상: 첨단융합학부 2, 3학년 재학생 (휴학생 신청 불가)
\n□ 대상 연구실: 학부생이 인턴십 수행을 희망하는 학내 대학원 연구실
\n□ 활동기간 및 수행시간: 하계 방학 중 120시간 이상
\n□ 지원내용: 인턴십 활동비 1인당 1,000,000원
\n□ 선발기준: 연구 및 학습계획 종합 평가
\n□ 선발인원: 35명 이내
\n□ 프로그램 일정
\n\n \n \n | \n 구분 | \n \n 하계 인턴십 일정 | \n
\n \n | \n 신청서 제출 | \n \n 2026. 4. 22.(수)~5. 14.(목) | \n
\n \n | \n 선발 공고 | \n \n 2026. 5. 27.(수) | \n
\n \n | \n 필수 교육 수료증 제출* | \n \n 2026. 6. 11.(목)까지 | \n
\n \n | \n 인턴십 수행 | \n \n 하계방학 중 실시하되, 기간은 연구실과 협의 가능 \n 보고서 제출 전까지 총 120시간 이상 활동 필수 | \n
\n \n | \n 보고서 제출** | \n \n 2026. 8. 13.(목)까지 | \n
\n \n | \n 활동비 지급 | \n \n 2026. 9월 중 | \n
\n \n
\n* 수료증 제출 전 실험실 출입 불가
\n** 보고서 미제출시 활동비 미지급
\n□ 신청방법
\n◦ 신청 전 연구 인턴십 가능 여부 확인
\n- 희망하는 연구실 지도교수님께 미리 개별 연락드리고 인턴십 참여 승낙받기
\n◦ 신청서류 3가지 모두 제출
\n- 연구 인턴십 신청서(서식 1-1), 추천서(서식 1-2), 개인정보 수집·이용 동의서(서식 2)
\n- 서식 1-1, 2는 본인 작성, 서식 1-2는 희망 연구실 지도교수님 작성
\n- 서명 후 스캔하여 pdf 파일 1개로 제출
\n□ 필수 교육 이수
\n◦ 교육 이수 및 제출 방법 안내(선발 이후)
\n- 인권/성평등교육(온라인) 이수증, 안전환경교육(온라인) 이수증 필수 제출
\n- 안전환경교육(신규교육) 미이수자는 자체교육 결과(붙임 파일) 필수 제출
\n- 서명 후 스캔하여 pdf 파일 1개로 제출: 비교과 시스템상 과제 부여 예정, 파일명: 필수교육수료_2000-12345_홍길동
\n\n \n \n | \n 구분 | \n \n 이수방법 | \n \n 인정범위 | \n \n 제출방법 | \n
\n \n | \n 인권/성평등교육 | \n \n 서울대학교 인권센터 \n 온라인 인권/성평등 \n 교육 시스템 \n helplms.snu.ac.kr | \n \n 2026년 이수내역 | \n \n 학생이 개별 이수 \n 이후 이수증 제출 | \n
\n \n | \n 안전환경교육 | \n \n (온라인) \n 연구안전통합정보시스템 \n 온라인 안전교육 SAFE \n rsis.snu.ac.kr \n (붙임 파일 참조) | \n \n 26-1학기 이수내역 | \n \n 학생이 개별 이수 \n 이후 이수증 제출 | \n
\n \n | \n (오프라인) \n 신규교육 미이수한 경우 \n 실험실 출입 전 실험실 자체 사전 교육 2시간 이상 이수 | \n \n 각 실험실에서 이수 후 \n 안전환경 자체교육 결과(붙임 파일) 제출 | \n
\n \n
\n□ 유의사항
\n◦ 타 단과대학 ‘학부생 연구 인턴십’ 프로그램, 학부대학에서 운영하는 ‘학부생 연구지원 프로그램’ 등 유사 프로그램과 중복지원 불가
\n◦ 지도교수는 인턴십 과정 참여자의 불성실 등을 사유로 중단 요청 가능하며, 이 경우 활동비 미지급
\n붙임 1. 2026학년도 하계 첨단융합학부 학부생 연구 인턴십 공고.pdf
\n붙임 2. 안전환경교육(정기교육, 온라인) 방법.pdf
\n붙임 3. 안전환경교육(오프라인) 결과보고(양식).xls
\n붙임 4. 2026학년도 하계 첨단융합학부 학부생 연구 인턴십 신청서류 서식.hwp
",
+ "detailSessions" : [ {
+ "round" : 1,
+ "location" : "-",
+ "startDate" : "2026-05-26",
+ "endDate" : "2026-05-26",
+ "startTime" : "00:00",
+ "endTime" : "12:00"
+ }, {
+ "round" : 2,
+ "location" : "-",
+ "startDate" : "2026-07-18",
+ "endDate" : "2026-07-19",
+ "startTime" : "00:00",
+ "endTime" : "00:00"
+ }, {
+ "round" : 3,
+ "location" : ".",
+ "startDate" : "2026-08-01",
+ "endDate" : "2026-08-02",
+ "startTime" : "00:00",
+ "endTime" : "00:00"
+ } ]
+} ]
\ No newline at end of file
diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt
index 7470553..2bac2e2 100644
--- a/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt
+++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt
@@ -8,6 +8,9 @@ import org.springframework.web.multipart.MultipartFile
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
+import com.oracle.bmc.objectstorage.requests.HeadObjectRequest
+import java.io.ByteArrayInputStream
+import java.security.MessageDigest
@Service
class OciUploadService(
@@ -49,4 +52,116 @@ class OciUploadService(
.replace("+", "%20")
return "https://objectstorage.$region.oraclecloud.com/n/$namespace/b/$bucket/o/$encodedObjectName"
}
+
+ fun uploadBytesIfAbsent(
+ prefix: String?,
+ originalFilename: String?,
+ bytes: ByteArray,
+ contentType: String = "application/octet-stream",
+ ): String {
+ require(bytes.isNotEmpty()) { "empty bytes" }
+
+ val ext = extractExtension(originalFilename, contentType, bytes)
+ val sha256 = sha256Hex(bytes)
+ val objectName = buildObjectName(prefix, "$sha256.$ext")
+
+ if (!exists(objectName)) {
+ ByteArrayInputStream(bytes).use { input ->
+ val request = PutObjectRequest.builder()
+ .namespaceName(namespace)
+ .bucketName(bucket)
+ .objectName(objectName)
+ .contentLength(bytes.size.toLong())
+ .contentType(contentType)
+ .putObjectBody(input)
+ .build()
+ objectStorage.putObject(request)
+ }
+ }
+
+ return buildPublicUrl(objectName)
+ }
+
+ private fun exists(objectName: String): Boolean {
+ return try {
+ val request = HeadObjectRequest.builder()
+ .namespaceName(namespace)
+ .bucketName(bucket)
+ .objectName(objectName)
+ .build()
+ objectStorage.headObject(request)
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private fun extractExtension(
+ originalFilename: String?,
+ contentType: String,
+ bytes: ByteArray,
+ ): String {
+ val fromName = originalFilename
+ ?.substringAfterLast('.', "")
+ ?.lowercase()
+ ?.takeIf { it.isNotBlank() }
+
+ if (fromName != null && fromName != "bin") return fromName
+
+ detectImageExtension(bytes)?.let { return it }
+
+ return when (contentType.lowercase()) {
+ "image/jpeg", "image/jpg" -> "jpg"
+ "image/png" -> "png"
+ "image/gif" -> "gif"
+ "image/webp" -> "webp"
+ "image/bmp" -> "bmp"
+ else -> "bin"
+ }
+ }
+
+ private fun sha256Hex(bytes: ByteArray): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
+ return digest.joinToString("") { "%02x".format(it) }
+ }
+
+ private fun detectImageExtension(bytes: ByteArray): String? {
+ if (bytes.size >= 2) {
+ if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte()) {
+ return "jpg"
+ }
+ }
+
+ if (bytes.size >= 8) {
+ if (
+ bytes[0] == 0x89.toByte() &&
+ bytes[1] == 0x50.toByte() &&
+ bytes[2] == 0x4E.toByte() &&
+ bytes[3] == 0x47.toByte() &&
+ bytes[4] == 0x0D.toByte() &&
+ bytes[5] == 0x0A.toByte() &&
+ bytes[6] == 0x1A.toByte() &&
+ bytes[7] == 0x0A.toByte()
+ ) {
+ return "png"
+ }
+ }
+
+ if (bytes.size >= 6) {
+ val header = bytes.copyOfRange(0, 6).toString(Charsets.US_ASCII)
+ if (header == "GIF87a" || header == "GIF89a") {
+ return "gif"
+ }
+ }
+
+ if (bytes.size >= 12) {
+ val riff = bytes.copyOfRange(0, 4).toString(Charsets.US_ASCII)
+ val webp = bytes.copyOfRange(8, 12).toString(Charsets.US_ASCII)
+ if (riff == "RIFF" && webp == "WEBP") {
+ return "webp"
+ }
+ }
+
+ return null
+ }
}
diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/event/model/Event.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/event/model/Event.kt
index 6a063a0..4ce4135 100644
--- a/hangsha/common/src/main/kotlin/com/team1/hangsha/event/model/Event.kt
+++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/event/model/Event.kt
@@ -29,6 +29,8 @@ data class Event(
val eventStart: LocalDateTime? = null,
val eventEnd: LocalDateTime? = null,
+ val isPeriodEvent: Boolean = false,
+
val capacity: Int? = null,
val applyCount: Int = 0,
diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/event/model/EventPeriodPolicy.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/event/model/EventPeriodPolicy.kt
new file mode 100644
index 0000000..3e0c50e
--- /dev/null
+++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/event/model/EventPeriodPolicy.kt
@@ -0,0 +1,27 @@
+package com.team1.hangsha.event.model
+
+import java.time.LocalDateTime
+
+object EventPeriodPolicy {
+ private val periodTitleKeywords = listOf(
+ "공모전",
+ "인턴십",
+ "학생기자단",
+ )
+
+ fun isPeriodEvent(
+ title: String,
+ eventStart: LocalDateTime?,
+ eventEnd: LocalDateTime?,
+ ): Boolean {
+ if (eventStart == null || eventEnd == null) {
+ return true
+ }
+
+ if (periodTitleKeywords.any { keyword -> title.contains(keyword) }) {
+ return true
+ }
+
+ return eventEnd.isAfter(eventStart.plusDays(7))
+ }
+}
\ No newline at end of file
diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/event/repository/EventRepository.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/event/repository/EventRepository.kt
index 6fd86d4..30c8451 100644
--- a/hangsha/common/src/main/kotlin/com/team1/hangsha/event/repository/EventRepository.kt
+++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/event/repository/EventRepository.kt
@@ -8,7 +8,7 @@ import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
interface EventRepository : CrudRepository {
- fun findByApplyLink(applyLink: String): Event?
+ fun existsByApplyLink(applyLink: String): Boolean
@Query(
"""
@@ -27,6 +27,22 @@ interface EventRepository : CrudRepository {
@Param("keyEnd") keyEnd: LocalDateTime?,
): Event?
+ @Modifying
+ @Query(
+ """
+ UPDATE events
+ SET status_id = :closedStatusId
+ WHERE status_id = :recruitingStatusId
+ AND apply_end IS NOT NULL
+ AND apply_end < :now
+ """
+ )
+ fun closeExpiredRecruitingEvents(
+ @Param("recruitingStatusId") recruitingStatusId: Long,
+ @Param("closedStatusId") closedStatusId: Long,
+ @Param("now") now: LocalDateTime,
+ ): Int
+
@Modifying
@Query("DELETE FROM events")
fun deleteAllEventsRaw(): Int
diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/event/service/EventSyncService.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/event/service/EventSyncService.kt
index 6c3467b..ba98cab 100644
--- a/hangsha/common/src/main/kotlin/com/team1/hangsha/event/service/EventSyncService.kt
+++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/event/service/EventSyncService.kt
@@ -10,6 +10,7 @@ import com.team1.hangsha.event.dto.core.CrawledDetailSession
import com.team1.hangsha.event.dto.core.CrawledProgramEvent
import com.team1.hangsha.event.dto.request.EventPatchRequest
import com.team1.hangsha.event.model.Event
+import com.team1.hangsha.event.model.EventPeriodPolicy
import com.team1.hangsha.event.repository.EventRepository
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service
@@ -18,6 +19,7 @@ import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
+import java.time.ZoneId
@Service
class EventSyncService(
@@ -43,15 +45,17 @@ class EventSyncService(
val orgName = e.majorTypes.getOrNull(0)?.trim()?.takeIf { it.isNotBlank() }
val orgId = orgName?.let { getOrCreateCategoryId(orgGroupId, it) }
- val typeName = normalizeProgramType(e.majorTypes.getOrNull(1))
+ val typeName = normalizeProgramType(e)
- val statusId = e.status?.let { findCategoryId(statusGroupId, it) }
+ val statusName = normalizeStatus(e.status)
+ val statusId = statusName?.let { findCategoryId(statusGroupId, it) }
val eventTypeId = typeName?.let { findCategoryId(typeGroupId, it) }
val applyStart = e.applyStart?.let { dateStart(it) }
val applyEnd = e.applyEnd?.let { dateEnd(it) }
val sessions = patchSessionTimesFromMainContent(e.detailSessions, e.mainContentHtml)
+ val hasExistingForApplyLink = eventRepository.existsByApplyLink(applyLink)
data class UnitSpec(
val eventStart: LocalDateTime?,
@@ -92,6 +96,12 @@ class EventSyncService(
null
}
+ val isAllDayFallbackPeriod = sessions.isEmpty()
+ if (existing == null && hasExistingForApplyLink && isAllDayFallbackPeriod) {
+ skipped++
+ continue
+ }
+
val cleanedTags = e.tags
.asSequence()
.map { it.trim() }
@@ -99,9 +109,16 @@ class EventSyncService(
.distinct()
.toList()
+ val title = e.title!!.trim()
+ val isPeriodEvent = EventPeriodPolicy.isPeriodEvent(
+ title = title,
+ eventStart = eventStart,
+ eventEnd = eventEnd,
+ )
+
val model = Event(
id = existing?.id,
- title = e.title!!.trim(),
+ title = title,
imageUrl = e.imageUrl?.trim(),
operationMode = e.operationMode?.trim(),
@@ -112,6 +129,7 @@ class EventSyncService(
applyEnd = applyEnd,
eventStart = eventStart,
eventEnd = eventEnd,
+ isPeriodEvent = isPeriodEvent,
capacity = e.capacity ?: 0,
applyCount = e.applyCount ?: 0,
@@ -134,6 +152,32 @@ class EventSyncService(
return SyncResult(total = events.size, upserted = upserted, skipped = skipped)
}
+ @Transactional
+ fun closeExpiredRecruitingEvents(): Int {
+ // @TODO: 굉장히 하드코딩이긴 한데...
+ val statusGroupId = requireGroupId("모집현황")
+
+ val recruitingStatusId = findCategoryId(statusGroupId, "모집중")
+ ?: throw DomainException(
+ ErrorCode.INTERNAL_ERROR,
+ "Category not found. group=모집현황, name=모집중"
+ )
+
+ val closedStatusId = findCategoryId(statusGroupId, "모집마감")
+ ?: throw DomainException(
+ ErrorCode.INTERNAL_ERROR,
+ "Category not found. group=모집현황, name=모집마감"
+ )
+
+ val now = LocalDateTime.now(ZoneId.of("Asia/Seoul"))
+
+ return eventRepository.closeExpiredRecruitingEvents(
+ recruitingStatusId = recruitingStatusId,
+ closedStatusId = closedStatusId,
+ now = now,
+ )
+ }
+
private fun requireGroupId(name: String): Long {
val group = categoryGroupRepository.findByName(name)
?: throw DomainException(ErrorCode.CATEGORY_GROUP_NOT_FOUND)
@@ -207,9 +251,29 @@ class EventSyncService(
}
}
- private fun normalizeProgramType(raw: String?): String? {
+ private fun normalizeStatus(raw: String?): String? {
val s = raw?.trim()
if (s.isNullOrBlank()) return null
+ return when (s) {
+ "마감임박" -> "모집중"
+ else -> s
+ }
+ }
+
+ private fun normalizeProgramType(e: CrawledProgramEvent): String? {
+ val candidates = buildList {
+ addAll(e.majorTypes)
+ e.title?.let { add(it) }
+ addAll(e.tags)
+ }
+
+ if (candidates.any { it.contains("openlnl", ignoreCase = true) }) {
+ return "OpenLnL"
+ }
+
+ val s = e.majorTypes.getOrNull(1)?.trim()
+ if (s.isNullOrBlank()) return null
+
return when (s) {
"레크리에이션" -> "기타"
else -> s
@@ -262,8 +326,12 @@ class EventSyncService(
if (cleaned.isEmpty()) null else objectMapper.writeValueAsString(cleaned)
}
+ val newTitle = req.title?.trim()?.takeIf { it.isNotBlank() } ?: existing.title
+ val newEventStart = req.eventStart ?: existing.eventStart
+ val newEventEnd = req.eventEnd ?: existing.eventEnd
+
val updated = existing.copy(
- title = req.title?.trim()?.takeIf { it.isNotBlank() } ?: existing.title,
+ title = newTitle,
imageUrl = req.imageUrl?.trim() ?: existing.imageUrl,
operationMode = req.operationMode?.trim() ?: existing.operationMode,
@@ -276,8 +344,14 @@ class EventSyncService(
applyStart = req.applyStart ?: existing.applyStart,
applyEnd = req.applyEnd ?: existing.applyEnd,
- eventStart = req.eventStart ?: existing.eventStart,
- eventEnd = req.eventEnd ?: existing.eventEnd,
+ eventStart = newEventStart,
+ eventEnd = newEventEnd,
+
+ isPeriodEvent = EventPeriodPolicy.isPeriodEvent(
+ title = newTitle,
+ eventStart = newEventStart,
+ eventEnd = newEventEnd,
+ ),
capacity = req.capacity ?: existing.capacity,
applyCount = req.applyCount ?: existing.applyCount,
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/bookmark/repository/BookmarkRepository.kt b/hangsha/src/main/kotlin/com/team1/hangsha/bookmark/repository/BookmarkRepository.kt
index 6d15f07..236e049 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/bookmark/repository/BookmarkRepository.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/bookmark/repository/BookmarkRepository.kt
@@ -114,6 +114,8 @@ private fun ResultSet.toEvent(): Event {
eventStart = getLocalDateTimeOrNull("event_start"),
eventEnd = getLocalDateTimeOrNull("event_end"),
+ isPeriodEvent = getBoolean("is_period_event"),
+
capacity = getInt("capacity").let { if (wasNull()) null else it },
applyCount = getInt("apply_count"),
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/bookmark/service/BookmarkService.kt b/hangsha/src/main/kotlin/com/team1/hangsha/bookmark/service/BookmarkService.kt
index 662f913..f008c86 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/bookmark/service/BookmarkService.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/bookmark/service/BookmarkService.kt
@@ -66,6 +66,7 @@ private fun Event.toEventDtoBookmarked(): EventDto = EventDto(
applyEnd = applyEnd,
eventStart = eventStart,
eventEnd = eventEnd,
+ isPeriodEvent = isPeriodEvent,
capacity = capacity,
applyCount = applyCount,
organization = organization,
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/LocalUploadService.kt b/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/LocalUploadService.kt
deleted file mode 100644
index bb1a3c9..0000000
--- a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/LocalUploadService.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package com.team1.hangsha.common.upload
-
-import com.team1.hangsha.common.error.DomainException
-import com.team1.hangsha.common.error.ErrorCode
-import org.springframework.stereotype.Service
-import org.springframework.web.multipart.MultipartFile
-import java.nio.file.Files
-import java.nio.file.Path
-import java.nio.file.Paths
-import java.util.UUID
-
-@Service
-class LocalUploadService(
- private val props: UploadProperties,
-) {
- /**
- * 유저 프로필 이미지를 로컬 디스크에 저장하고, 공개 URL을 반환합니다.
- */
- fun uploadProfileImage(userId: Long, file: MultipartFile): String {
- if (file.isEmpty || file.size <= 0) {
- throw DomainException(ErrorCode.UPLOAD_FILE_EMPTY)
- }
- if (file.size > props.maxSizeBytes) {
- throw DomainException(ErrorCode.UPLOAD_FAILED, "파일이 너무 큽니다 (max=${props.maxSizeBytes} bytes)")
- }
-
- val contentType = file.contentType ?: ""
- if (!contentType.startsWith("image/")) {
- throw DomainException(ErrorCode.UPLOAD_UNSUPPORTED_MEDIA_TYPE, "이미지 파일만 업로드할 수 있습니다")
- }
-
- val ext = guessExtension(file.originalFilename, contentType)
- val relativeKey = "uploads/users/$userId/${UUID.randomUUID()}.$ext"
-
- val root: Path = Paths.get(props.dir).toAbsolutePath().normalize()
- val target: Path = root.resolve(relativeKey).normalize()
-
- // directory traversal 방지
- if (!target.startsWith(root)) {
- throw DomainException(ErrorCode.UPLOAD_FAILED, "Invalid path")
- }
-
- try {
- Files.createDirectories(target.parent)
- file.inputStream.use { input ->
- Files.copy(input, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
- }
- } catch (e: Exception) {
- throw DomainException(ErrorCode.UPLOAD_FAILED, cause = e)
- }
-
- val base = props.publicBaseUrl.trimEnd('/')
- return "$base/$relativeKey"
- }
-
- private fun guessExtension(originalFilename: String?, contentType: String): String {
- val fromName = originalFilename
- ?.substringAfterLast('.', missingDelimiterValue = "")
- ?.lowercase()
- ?.takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
-
- if (!fromName.isNullOrBlank()) return fromName
-
- return when (contentType.lowercase()) {
- "image/jpeg", "image/jpg" -> "jpg"
- "image/png" -> "png"
- "image/gif" -> "gif"
- "image/webp" -> "webp"
- else -> "bin"
- }
- }
-}
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt b/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt
index b16e3a5..2ee436f 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt
@@ -11,6 +11,8 @@ import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
+// OciUploadService가 common에도 존재하는데 main에도 존재...?
+
@Service
class OciUploadService(
private val objectStorage: ObjectStorage,
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/UploadProperties.kt b/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/UploadProperties.kt
index 701afc1..bff7ac1 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/UploadProperties.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/UploadProperties.kt
@@ -4,7 +4,5 @@ import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "upload")
data class UploadProperties(
- val dir: String,
- val publicBaseUrl: String,
val maxSizeBytes: Long = 10 * 1024 * 1024,
-)
\ No newline at end of file
+)
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/UploadWebConfig.kt b/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/UploadWebConfig.kt
deleted file mode 100644
index 8bfd060..0000000
--- a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/UploadWebConfig.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.team1.hangsha.common.upload
-
-import org.springframework.context.annotation.Configuration
-import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry
-import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
-import java.nio.file.Paths
-
-@Configuration
-class UploadWebConfig(
- private val uploadProperties: UploadProperties,
-) : WebMvcConfigurer {
-
- override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
- val uploadPath = Paths.get(uploadProperties.dir).toAbsolutePath().normalize().toString()
- registry.addResourceHandler("/static/**")
- .addResourceLocations("file:$uploadPath/")
- .setCachePeriod(3600)
- }
-}
\ No newline at end of file
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/config/SecurityConfig.kt b/hangsha/src/main/kotlin/com/team1/hangsha/config/SecurityConfig.kt
index 5021296..a158138 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/config/SecurityConfig.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/config/SecurityConfig.kt
@@ -63,9 +63,8 @@ class SecurityConfig(
// 주최 기관
"/api/v1/category-groups/**",
"/api/v1/categories/**",
- // @TODO: 자동 크롤링 시 삭제 필요
- "/admin/events/sync",
- "/admin/events/delete",
+ // admin
+ "/admin/**",
// 파일 업로드
"/static/**",
"/api/v1/uploads/oci/**",
@@ -73,12 +72,12 @@ class SecurityConfig(
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
- /*
+
.oauth2Login { oauth2 ->
oauth2.userInfoEndpoint { it.userService(customOAuth2UserService) } // 유저 정보 처리 로직
oauth2.successHandler(oAuth2SuccessHandler)
}
- */
+
return http.build()
}
}
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/config/WebConfig.kt b/hangsha/src/main/kotlin/com/team1/hangsha/config/WebConfig.kt
index a01c782..ff6aadc 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/config/WebConfig.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/config/WebConfig.kt
@@ -1,7 +1,6 @@
package com.team1.hangsha.config
import com.team1.hangsha.user.UserArgumentResolver
-import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry
@@ -10,7 +9,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig(
private val userArgumentResolver: UserArgumentResolver,
- @Value("\${upload.dir}") private val uploadDir: String
) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList) {
@@ -19,6 +17,6 @@ class WebConfig(
override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
registry.addResourceHandler("/static/**")
- .addResourceLocations("file:$uploadDir/", "classpath:/static/")
+ .addResourceLocations("classpath:/static/")
}
-}
\ No newline at end of file
+}
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/event/controller/EventSyncController.kt b/hangsha/src/main/kotlin/com/team1/hangsha/event/controller/EventSyncController.kt
index 9879a6d..7433136 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/event/controller/EventSyncController.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/event/controller/EventSyncController.kt
@@ -1,21 +1,92 @@
package com.team1.hangsha.event.controller
+import com.fasterxml.jackson.core.JsonToken
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.team1.hangsha.event.dto.core.CrawledProgramEvent
import com.team1.hangsha.event.dto.request.EventPatchRequest
import com.team1.hangsha.event.repository.EventRepository
import com.team1.hangsha.event.service.EventSyncService
+import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
+import org.springframework.web.server.ResponseStatusException
+import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/events")
class EventSyncController(
private val eventSyncService: EventSyncService,
private val eventRepository: EventRepository,
+ private val objectMapper: ObjectMapper,
) {
+ @PostMapping("/sync")
+ fun sync(
+ @RequestBody events: List,
+ ): Map {
+ return runSync(events)
+ }
+
+ @PostMapping("/sync-file", consumes = ["multipart/form-data"])
+ fun syncByFile(
+ @RequestParam("file") file: MultipartFile,
+ ): Map {
+ file.inputStream.use { input ->
+ objectMapper.factory.createParser(input).use { parser ->
+ if (parser.nextToken() != JsonToken.START_ARRAY) {
+ throw ResponseStatusException(HttpStatus.BAD_REQUEST, "file must be a JSON array")
+ }
+
+ var total = 0
+ var upserted = 0
+ var skipped = 0
+ val buffer = ArrayList(SYNC_BATCH_SIZE)
+
+ while (parser.nextToken() != JsonToken.END_ARRAY) {
+ val event = objectMapper.readValue(parser, CrawledProgramEvent::class.java)
+ buffer.add(event)
+
+ if (buffer.size >= SYNC_BATCH_SIZE) {
+ val result = eventSyncService.sync(buffer)
+ total += result.total
+ upserted += result.upserted
+ skipped += result.skipped
+ buffer.clear()
+ }
+ }
+
+ if (buffer.isNotEmpty()) {
+ val result = eventSyncService.sync(buffer)
+ total += result.total
+ upserted += result.upserted
+ skipped += result.skipped
+ }
+
+ return mapOf(
+ "ok" to true,
+ "total" to total,
+ "upserted" to upserted,
+ "skipped" to skipped,
+ )
+ }
+ }
+ }
+
+ private fun runSync(events: List): Map {
+ val result = eventSyncService.sync(events)
+ return mapOf(
+ "ok" to true,
+ "total" to result.total,
+ "upserted" to result.upserted,
+ "skipped" to result.skipped,
+ )
+ }
+
@DeleteMapping("/delete")
fun deleteAll(): Map {
val deleted = eventRepository.deleteAllEventsRaw()
@@ -32,4 +103,8 @@ class EventSyncController(
fun deleteEvent(
@PathVariable eventId: Long,
): Map = eventSyncService.deleteEvent(eventId)
-}
\ No newline at end of file
+
+ companion object {
+ private const val SYNC_BATCH_SIZE = 500
+ }
+}
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/event/dto/core/EventDto.kt b/hangsha/src/main/kotlin/com/team1/hangsha/event/dto/core/EventDto.kt
index 277c71c..507b866 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/event/dto/core/EventDto.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/event/dto/core/EventDto.kt
@@ -17,6 +17,7 @@ data class EventDto(
val applyEnd: LocalDateTime? = null,
val eventStart: LocalDateTime? = null,
val eventEnd: LocalDateTime? = null,
+ val isPeriodEvent: Boolean = false,
val capacity: Int? = null,
val applyCount: Int,
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/event/dto/response/DetailEventResponse.kt b/hangsha/src/main/kotlin/com/team1/hangsha/event/dto/response/DetailEventResponse.kt
index 5e844b2..447d1f6 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/event/dto/response/DetailEventResponse.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/event/dto/response/DetailEventResponse.kt
@@ -20,6 +20,7 @@ data class DetailEventResponse(
val applyEnd: LocalDateTime? = null,
val eventStart: LocalDateTime? = null,
val eventEnd: LocalDateTime? = null,
+ val isPeriodEvent: Boolean = false,
val capacity: Int? = null,
val applyCount: Int,
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/event/repository/EventQueryRepository.kt b/hangsha/src/main/kotlin/com/team1/hangsha/event/repository/EventQueryRepository.kt
index 7b3fc41..8bc0384 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/event/repository/EventQueryRepository.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/event/repository/EventQueryRepository.kt
@@ -218,6 +218,7 @@ private fun ResultSet.toEvent(): Event {
applyEnd = getLocalDateTimeOrNull("apply_end"),
eventStart = getLocalDateTimeOrNull("event_start"),
eventEnd = getLocalDateTimeOrNull("event_end"),
+ isPeriodEvent = getBoolean("is_period_event"),
capacity = getInt("capacity").let { if (wasNull()) null else it },
applyCount = getInt("apply_count"),
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/event/service/EventService.kt b/hangsha/src/main/kotlin/com/team1/hangsha/event/service/EventService.kt
index a781a40..5e81754 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/event/service/EventService.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/event/service/EventService.kt
@@ -236,6 +236,7 @@ private fun Event.toDto(auth: Boolean, matchedPriority: Int?, isBookmarked: Bool
applyEnd = applyEnd,
eventStart = eventStart,
eventEnd = eventEnd,
+ isPeriodEvent = isPeriodEvent,
capacity = capacity,
applyCount = applyCount,
organization = organization,
@@ -264,6 +265,7 @@ private fun Event.toDetailResponse(auth: Boolean, matchedPriority: Int?, isBookm
applyEnd = applyEnd,
eventStart = eventStart,
eventEnd = eventEnd,
+ isPeriodEvent = isPeriodEvent,
capacity = capacity,
applyCount = applyCount,
organization = organization,
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/user/controller/UserController.kt b/hangsha/src/main/kotlin/com/team1/hangsha/user/controller/UserController.kt
index a63496f..de070a7 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/user/controller/UserController.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/user/controller/UserController.kt
@@ -1,7 +1,6 @@
package com.team1.hangsha.user.controller
import com.fasterxml.jackson.databind.JsonNode
-import com.team1.hangsha.common.upload.LocalUploadService
import com.team1.hangsha.common.upload.dto.UploadResponse
import com.team1.hangsha.user.LoggedInUser
import com.team1.hangsha.user.model.User
@@ -21,7 +20,6 @@ import io.swagger.v3.oas.annotations.media.Schema
@RequestMapping("/api/v1/users/me")
class UserController(
private val userService: UserService,
- private val localUploadService: LocalUploadService,
) {
@GetMapping
fun getMe(
@@ -115,8 +113,8 @@ class UserController(
@Parameter(hidden = true) @LoggedInUser user: User,
@RequestPart("file") file: MultipartFile,
): ResponseEntity {
- val url = localUploadService.uploadProfileImage(user.id!!, file)
+ val url = userService.uploadProfile(user.id!!, file)
userService.updateProfileImageUrl(user.id!!, url)
return ResponseEntity.ok(UploadResponse(url = url))
}
-}
\ No newline at end of file
+}
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/user/dto/core/UserDto.kt b/hangsha/src/main/kotlin/com/team1/hangsha/user/dto/core/UserDto.kt
index c51a9fb..25cc2f3 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/user/dto/core/UserDto.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/user/dto/core/UserDto.kt
@@ -17,7 +17,9 @@ data class UserDto(
id = user.id!!,
username = user.username,
email = user.email,
- profileImageUrl = user.profileImageUrl ?: "https://hangsha.site/static/default-profile.png",
+ profileImageUrl = user.profileImageUrl ?: "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/ax1dvc8vmenm/b/hangsha-asset/o/default/43513b43-2f84-4f0f-8de8-7d61120fe3aa.png",
+ // default-profile.png는 oci에 업로드 해 두었음.
+
interestCategories = interestCategories
)
-}
\ No newline at end of file
+}
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/user/handler/OAuth2SuccessHandler.kt b/hangsha/src/main/kotlin/com/team1/hangsha/user/handler/OAuth2SuccessHandler.kt
index 5496de3..eb5ff22 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/user/handler/OAuth2SuccessHandler.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/user/handler/OAuth2SuccessHandler.kt
@@ -25,6 +25,7 @@ class OAuth2SuccessHandler(
private val tokenHasher: TokenHasher,
private val cookieSupport: AuthCookieSupport,
@Value("\${jwt.refresh-expiration-ms}") private val refreshExpirationMs: Long,
+ @Value("\${app.oauth2.front-redirect-uri}") private val frontRedirectUri: String,
) : SimpleUrlAuthenticationSuccessHandler() {
override fun onAuthenticationSuccess(
@@ -33,8 +34,8 @@ class OAuth2SuccessHandler(
authentication: Authentication
) {
val oAuth2User = authentication.principal as OAuth2User
- val email = oAuth2User.attributes["email"] as String
-
+ val email = oAuth2User.name
+ val isNewUser = oAuth2User.attributes["isNewUser"] as? Boolean ?: false
val user = userRepository.findByEmail(email)
?: throw RuntimeException("User not found after OAuth2 login")
@@ -48,10 +49,9 @@ class OAuth2SuccessHandler(
)
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString())
- // 프론트엔드 주소로 변경(http://localhost:3000/oauth/callback)
- val targetUrl = UriComponentsBuilder.fromUriString("http://localhost:8080/")
+ val targetUrl = UriComponentsBuilder.fromUriString(frontRedirectUri)
.queryParam("accessToken", accessToken)
- // .queryParam("refreshToken", refreshToken) // 필요시 주석 해제
+ .queryParam("isNewUser", isNewUser)
.build().toUriString()
redirectStrategy.sendRedirect(request, response, targetUrl)
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/user/service/CustomOAuth2UserService.kt b/hangsha/src/main/kotlin/com/team1/hangsha/user/service/CustomOAuth2UserService.kt
index 30ab809..90796fc 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/user/service/CustomOAuth2UserService.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/user/service/CustomOAuth2UserService.kt
@@ -10,6 +10,7 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
+import org.springframework.security.oauth2.core.user.DefaultOAuth2User
@Service
class CustomOAuth2UserService(
@@ -33,48 +34,54 @@ class CustomOAuth2UserService(
val authProvider = AuthProvider.valueOf(registrationId.uppercase())
- // --- (이하 저장 로직은 기존과 동일) ---
-
- // 3. 이미 연동된 계정인지 확인
+ var isNewUser = false
val existingIdentity = userIdentityRepository.findByProviderAndProviderUserId(authProvider, providerId)
- if (existingIdentity != null) {
- return oAuth2User
- }
-
- // 4. 이메일로 기존 유저 확인 (계정 연동)
- val existingUser = userRepository.findByEmail(email)
+ if (existingIdentity == null) {
+ val existingUser = userRepository.findByEmail(email)
+
+ if (existingUser != null) {
+ userIdentityRepository.save(
+ UserIdentity(
+ userId = existingUser.id!!,
+ provider = authProvider,
+ providerUserId = providerId,
+ email = email
+ )
+ )
+ } else {
+ isNewUser = true
+ val savedUser = userRepository.save(
+ User(
+ email = email,
+ username = name,
+ profileImageUrl = picture
+ )
+ )
- if (existingUser != null) {
- userIdentityRepository.save(
- UserIdentity(
- userId = existingUser.id!!,
- provider = authProvider,
- providerUserId = providerId,
- email = email
+ userIdentityRepository.save(
+ UserIdentity(
+ userId = savedUser.id!!,
+ provider = authProvider,
+ providerUserId = providerId,
+ email = email
+ )
)
- )
- } else {
- val savedUser = userRepository.save(
- User(
- email = email,
- username = name,
- profileImageUrl = picture
- )
- )
+ }
+ }
- userIdentityRepository.save(
- UserIdentity(
- userId = savedUser.id!!,
- provider = authProvider,
- providerUserId = providerId,
- email = email
- )
+ // 3. attributes에 "email"과 "isNewUser" 정보 추가
+ val customAttributes = oAuth2User.attributes.toMutableMap()
+ customAttributes["email"] = email
+ customAttributes["isNewUser"] = isNewUser
+
+ // 4. 무조건 여기서 묶어서 반환 (조기 반환 버그 해결!)
+ return DefaultOAuth2User(
+ oAuth2User.authorities,
+ customAttributes,
+ "email" // 식별자 키
)
}
- return oAuth2User
-}
-
// 소셜별로 데이터를 꺼내는 도우미 함수
private fun extractAttributes(registrationId: String, attributes: Map): OAuthAttributes {
return when (registrationId) {
diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/user/service/UserService.kt b/hangsha/src/main/kotlin/com/team1/hangsha/user/service/UserService.kt
index 8022aeb..0cd7149 100644
--- a/hangsha/src/main/kotlin/com/team1/hangsha/user/service/UserService.kt
+++ b/hangsha/src/main/kotlin/com/team1/hangsha/user/service/UserService.kt
@@ -11,6 +11,7 @@ import com.team1.hangsha.user.repository.UserIdentityRepository
import com.team1.hangsha.user.model.AuthProvider
import com.team1.hangsha.user.dto.IssuedTokens
import com.team1.hangsha.user.repository.RefreshTokenRepository
+import com.team1.hangsha.common.upload.OciUploadService
import org.springframework.beans.factory.annotation.Value
import com.team1.hangsha.user.AuthCookieSupport
import com.team1.hangsha.user.model.RefreshToken
@@ -23,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional
import org.slf4j.LoggerFactory
import java.net.URI
import java.time.Instant
+import org.springframework.web.multipart.MultipartFile
@Service
class UserService(
@@ -33,6 +35,7 @@ class UserService(
private val tokenHasher: TokenHasher,
private val cookieSupport: AuthCookieSupport,
private val userPreferenceService: UserPreferenceService,
+ private val ociUploadService: OciUploadService,
@Value("\${jwt.refresh-expiration-ms}") private val refreshExpirationMs: Long,
) {
@@ -239,7 +242,7 @@ class UserService(
)
}
- if (s.getDisplayLength() > 15) {
+ if (s.getDisplayLength() > 21) {
throw DomainException(
ErrorCode.INVALID_REQUEST,
"username은 50자를 초과할 수 없습니다"
@@ -276,4 +279,16 @@ class UserService(
user.profileImageUrl = profileImageUrl
userRepository.save(user)
}
-}
\ No newline at end of file
+
+ fun uploadProfile(userId: Long, file: MultipartFile): String {
+ val contentType = file.contentType ?: ""
+ if (!contentType.startsWith("image/")) {
+ throw DomainException(ErrorCode.UPLOAD_UNSUPPORTED_MEDIA_TYPE, "이미지 파일만 업로드할 수 있습니다")
+ }
+
+ return ociUploadService.uploadFile(
+ prefix = "uploads/users/$userId",
+ file = file,
+ )
+ }
+}
diff --git a/hangsha/src/main/resources/application.yml b/hangsha/src/main/resources/application.yml
index 0cf565c..c594ac3 100644
--- a/hangsha/src/main/resources/application.yml
+++ b/hangsha/src/main/resources/application.yml
@@ -85,10 +85,7 @@ springdoc:
path: /api-docs
-# TODO : 삭제 예정. 이미지 업로드는 외부 스토리지로 모두 마이그레이션 할 것.
upload:
- dir: /data/uploads
- public-base-url: "https://hangsha.site/static"
max-size-bytes: 10485760
# TODO: 프로퍼티 정상 주입 확인 목적. 배포 완료 시 내리기.
@@ -105,13 +102,10 @@ spring:
config:
activate:
on-profile: local
-oauth2:
- google: # TODO: 로컬 개발 환경에서 클라이언트 쪽으로의 리다이렉션. 우선 임의로 설정. 확인 필요
- redirect-uri: "http://localhost:3000/login/callback/google"
- naver:
- redirect-uri: "http://localhost:3000/login/callback/naver"
- kakao:
- redirect-uri: "http://localhost:3000/login/callback/kakao"
+app:
+ oauth2:
+ front-redirect-uri: "http://localhost:3000/auth/callback"
+
---
# ==========================================
@@ -121,14 +115,9 @@ spring:
config:
activate:
on-profile: dev
-oauth2:
- google: # TODO : 추후 확인 필요. 정말로 이 리다이렉트 URI가 맞는가?
- redirect-uri: "https://dev-hangsha.wafflestudio.com/login/callback/google"
- naver:
- redirect-uri: "https://dev-hangsha.wafflestudio.com/login/callback/naver"
- kakao:
- redirect-uri: "https://dev-hangsha.wafflestudio.com/login/callback/kakao"
-
+app:
+ oauth2:
+ front-redirect-uri: "https://hangsha-dev.wafflestudio.com/auth/callback"
---
# ==========================================
# prod
@@ -137,10 +126,6 @@ spring:
config:
activate:
on-profile: prod
-oauth2:
- google:
- redirect-uri: "https://hangsha.wafflestudio.com/login/callback/google"
- naver:
- redirect-uri: "https://hangsha.wafflestudio.com/login/callback/naver"
- kakao:
- redirect-uri: "https://hangsha.wafflestudio.com/login/callback/kakao"
+app:
+ oauth2:
+ front-redirect-uri: "https://hangsha.wafflestudio.com/auth/callback"
\ No newline at end of file
diff --git a/hangsha/src/main/resources/db/migration/V16__remove_recreation_category.sql b/hangsha/src/main/resources/db/migration/V16__remove_recreation_category.sql
deleted file mode 100644
index de819ad..0000000
--- a/hangsha/src/main/resources/db/migration/V16__remove_recreation_category.sql
+++ /dev/null
@@ -1,13 +0,0 @@
-UPDATE events e
- JOIN categories r ON r.id = e.event_type_id
- JOIN category_groups cg ON cg.id = r.group_id AND cg.name = '프로그램 유형'
- JOIN categories etc ON etc.group_id = r.group_id AND etc.name = '기타'
- SET e.event_type_id = etc.id
-WHERE r.name = '레크리에이션';
-
-DELETE c
-FROM categories c
-JOIN category_groups cg ON cg.id = c.group_id AND cg.name = '프로그램 유형'
-LEFT JOIN events e ON e.event_type_id = c.id
-WHERE c.name = '레크리에이션'
- AND e.id IS NULL;
\ No newline at end of file
diff --git a/hangsha/src/main/resources/db/migration/V23__add_is_period_event_to_events.sql b/hangsha/src/main/resources/db/migration/V23__add_is_period_event_to_events.sql
new file mode 100644
index 0000000..a4a373c
--- /dev/null
+++ b/hangsha/src/main/resources/db/migration/V23__add_is_period_event_to_events.sql
@@ -0,0 +1,13 @@
+ALTER TABLE events
+ ADD COLUMN is_period_event BOOLEAN NOT NULL DEFAULT FALSE AFTER event_end;
+
+UPDATE events
+SET is_period_event =
+ CASE
+ WHEN event_start IS NULL OR event_end IS NULL THEN TRUE
+ WHEN title LIKE '%공모전%' THEN TRUE
+ WHEN title LIKE '%인턴십%' THEN TRUE
+ WHEN title LIKE '%학생기자단%' THEN TRUE
+ WHEN event_end > DATE_ADD(event_start, INTERVAL 7 DAY) THEN TRUE
+ ELSE FALSE
+ END;
\ No newline at end of file
diff --git a/hangsha/src/main/resources/db/migration/V5__seed_categories.sql b/hangsha/src/main/resources/db/migration/V5__seed_categories.sql
index cf00a6a..c911789 100644
--- a/hangsha/src/main/resources/db/migration/V5__seed_categories.sql
+++ b/hangsha/src/main/resources/db/migration/V5__seed_categories.sql
@@ -10,11 +10,11 @@ INSERT INTO category_groups (name, sort_order) VALUES
-- 모집현황 categories seed
-- ===============================
INSERT INTO categories (group_id, name, sort_order)
-SELECT cg.id, '모집중', 1
+SELECT cg.id, '모집대기', 1
FROM category_groups cg WHERE cg.name = '모집현황';
INSERT INTO categories (group_id, name, sort_order)
-SELECT cg.id, '마감임박', 2
+SELECT cg.id, '모집중', 2
FROM category_groups cg WHERE cg.name = '모집현황';
INSERT INTO categories (group_id, name, sort_order)
@@ -45,9 +45,10 @@ SELECT cg.id, '학습/진로상담', 5
FROM category_groups cg WHERE cg.name = '프로그램 유형';
INSERT INTO categories (group_id, name, sort_order)
-SELECT cg.id, '레크리에이션', 6
+SELECT cg.id, 'OpenLnL', 6
FROM category_groups cg WHERE cg.name = '프로그램 유형';
+
INSERT INTO categories (group_id, name, sort_order)
SELECT cg.id, '기타', 999
-FROM category_groups cg WHERE cg.name = '프로그램 유형';
\ No newline at end of file
+FROM category_groups cg WHERE cg.name = '프로그램 유형';