Skip to content

[Release] BE#1189

Merged
lepitaaar merged 16 commits intomainfrom
develop/be
Feb 13, 2026
Merged

[Release] BE#1189
lepitaaar merged 16 commits intomainfrom
develop/be

Conversation

@lepitaaar
Copy link
Contributor

@lepitaaar lepitaaar commented Feb 13, 2026

v 1.1.5

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 클럽 관리자용 대시보드 추가로 클럽 정보 및 모집 현황 관리 가능
    • 모집 종료 7일, 3일, 1일 전 자동 알림 시스템 구현
    • 개발자 등록 기능 추가
    • 관리 포털에 클럽 및 사전 관리 기능 추가
  • 보안 개선

    • 역할 기반 접근 제어 강화

Zepelown and others added 16 commits February 8, 2026 20:07
- .gitignore: /.cursor 추가
- dev 포털: 스티키 헤더·섹션 네비, 동아리 목록 페이지네이션·ID 복사, 토스트·저장 피드백, 스타일·접근성 정리

Co-authored-by: Cursor <cursoragent@cursor.com>
- edit.html: 동아리 수정 전용 페이지 추가 (약력/모집정보 폼)\n- index.html: 수정 UI 제거, 목록 클릭 시 openEditWindow로 수정 창 오픈

Co-authored-by: Cursor <cursoragent@cursor.com>
- dict-edit.html: 단어사전 수정 전용 페이지 추가\n- index.html: 단어사전 목록·페이지네이션, 단일/CSV 추가, 수정 창 연동, 삭제, 캐시 새로고침

Co-authored-by: Cursor <cursoragent@cursor.com>
…on-MOA-630

[feat] 네비게이션용 데이터 추가
[featuer] 백엔드 스케쥴러 ShedLock 도입하여 중복 스케쥴링 방지
…OA-638

[feature] 동아리 마감전 dday 알림표시
…A-620

[feature] 개발자 페이지 만들어서 관리 쉽게 ㄱㄱ
@lepitaaar lepitaaar self-assigned this Feb 13, 2026
@lepitaaar lepitaaar added 💾 BE Backend 📈 release 릴리즈 배포 labels Feb 13, 2026
@vercel
Copy link

vercel bot commented Feb 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
moadong Ready Ready Preview, Comment Feb 13, 2026 4:18am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
Validation error: Invalid regex pattern for base branch. Received: "**" at "reviews.auto_review.base_branches[0]"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Walkthrough

이 변경 사항은 개발자 포털 UI, 관리자용 클럽 관리 API, 모집 마감 D-day 알림 기능, 그리고 사용자 역할 관리 시스템을 추가합니다. ShedLock을 이용한 분산 스케줄링 잠금을 도입하고 스케줄 주기를 조정합니다.

Changes

Cohort / File(s) Summary
의존성 및 설정
backend/build.gradle, backend/src/main/java/moadong/global/config/MongoConfig.java, backend/src/main/java/moadong/global/config/SecurityConfig.java
ShedLock 라이브러리 의존성 추가, MongoDB를 통한 분산 잠금 제공자 구성, /api/admin/** 경로에 DEVELOPER 역할 요구 설정
모집 마감 D-day 알림
backend/src/main/java/moadong/club/service/RecruitmentDdayNotifier.java, backend/src/main/java/moadong/club/util/RecruitmentDdayNotificationBuilder.java, backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java, backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java, backend/src/main/java/moadong/fcm/enums/FcmAction.java
매일 08:00에 실행되는 D-day 알림 스케줄러 추가, 7일/3일/1일 남았을 때 FCM 알림 발송, 분산 잠금 적용, RecruitmentStateChecker 스케줄 주기를 1시간에서 10분으로 변경
클럽 관리 API
backend/src/main/java/moadong/club/controller/ClubAdminController.java, backend/src/main/java/moadong/club/payload/response/ClubListResponse.java, backend/src/main/java/moadong/club/repository/ClubRepository.java, backend/src/main/java/moadong/club/service/ClubProfileService.java
관리자용 REST API 컨트롤러 추가 (전체 클럽 조회, 클럽 정보/모집정보 업데이트), 응답 DTO 추가, 모집 상태 기반 조회 메서드 추가
사용자 역할 및 인증
backend/src/main/java/moadong/user/entity/User.java, backend/src/main/java/moadong/user/entity/enums/UserRole.java, backend/src/main/java/moadong/user/controller/DevAuthController.java, backend/src/main/java/moadong/user/payload/request/DevRegisterRequest.java, backend/src/main/java/moadong/user/service/UserCommandService.java, backend/src/main/java/moadong/user/payload/request/UserRegisterRequest.java
사용자 역할 열거형 추가 (CLUB_ADMIN, DEVELOPER), 개발자 등록 엔드포인트 및 요청 DTO 추가, User 엔티티에 role 필드 추가, registerDeveloper 메서드로 보안 토큰 기반 개발자 등록 지원
개발자 포털 정적 페이지
backend/src/main/resources/static/dev/index.html, backend/src/main/resources/static/dev/edit.html, backend/src/main/resources/static/dev/dict-edit.html, backend/src/main/java/moadong/global/controller/DevPortalController.java
개발자 포털 메인 페이지 추가 (로그인, 클럽 관리, 단어사전 관리), 클럽 편집 페이지 추가, 단어사전 항목 편집 페이지 추가, /dev 경로 리다이렉트 컨트롤러 추가
기타
backend/.gitignore
firebase.json 재추가, /.cursor 무시 규칙 추가

Sequence Diagram(s)

sequenceDiagram
    participant Scheduler as Scheduler
    participant RDN as RecruitmentDdayNotifier
    participant Repo as ClubRepository
    participant Builder as RecruitmentDdayNotificationBuilder
    participant FCM as Firebase Cloud Messaging
    
    Scheduler->>RDN: sendDdayNotifications() at 08:00 KST
    RDN->>Repo: findAllByClubRecruitmentInformation_ClubRecruitmentStatus(OPEN)
    Repo-->>RDN: List<Club>
    
    loop for each Club with recruitmentEnd
        RDN->>RDN: Calculate daysLeft (7, 3, or 1)
        alt daysLeft matches (7, 3, 1)
            RDN->>Builder: build(club, daysLeft)
            Builder-->>RDN: Firebase Message
            RDN->>FCM: Send Message
            FCM-->>RDN: Success
        end
    end
    RDN-->>Scheduler: Notification Summary
Loading
sequenceDiagram
    participant Client as Client
    participant DevAuth as DevAuthController
    participant UserCmd as UserCommandService
    participant UserRepo as UserRepository
    participant ClubCmd as ClubCommandService
    participant DB as Database
    
    Client->>DevAuth: POST /auth/dev/register with DevRegisterRequest & secret
    DevAuth->>UserCmd: registerDeveloper(request)
    UserCmd->>UserCmd: Validate devRegistrationSecret
    UserCmd->>UserRepo: Check existing userId
    alt User exists
        UserCmd-->>DevAuth: Error - Duplicate user
    else User not found
        UserCmd->>UserCmd: Create User with DEVELOPER role
        UserCmd->>DB: Save User
        UserCmd->>ClubCmd: Create associated Club
        UserCmd->>DB: Save Club
        UserCmd-->>DevAuth: Success
        DevAuth-->>Client: Success Response
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

✨ Feature

Suggested reviewers

  • Zepelown
🚥 Pre-merge checks | ✅ 1 | ❌ 3
❌ Failed checks (2 warnings, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (112 files):

⚔️ .github/ISSUE_TEMPLATE/jira-issue-form.yml (content)
⚔️ .gitignore (content)
⚔️ README.md (content)
⚔️ backend/.gitignore (content)
⚔️ backend/build.gradle (content)
⚔️ backend/src/main/java/moadong/club/repository/ClubRepository.java (content)
⚔️ backend/src/main/java/moadong/club/service/ClubProfileService.java (content)
⚔️ backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java (content)
⚔️ backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java (content)
⚔️ backend/src/main/java/moadong/global/config/MongoConfig.java (content)
⚔️ backend/src/main/java/moadong/global/config/SecurityConfig.java (content)
⚔️ backend/src/main/java/moadong/user/entity/User.java (content)
⚔️ backend/src/main/java/moadong/user/payload/request/UserRegisterRequest.java (content)
⚔️ backend/src/main/java/moadong/user/service/UserCommandService.java (content)
⚔️ frontend/.gitignore (content)
⚔️ frontend/.prettierrc (content)
⚔️ frontend/.storybook/main.ts (content)
⚔️ frontend/codecov.yml (content)
⚔️ frontend/eslint.config.mjs (content)
⚔️ frontend/jest.setup.ts (content)
⚔️ frontend/package-lock.json (content)
⚔️ frontend/package.json (content)
⚔️ frontend/public/index.html (content)
⚔️ frontend/public/mockServiceWorker.js (content)
⚔️ frontend/src/App.tsx (content)
⚔️ frontend/src/apis/auth/refreshAccessToken.ts (content)
⚔️ frontend/src/assets/images/banners/banner_desktop1.png (content)
⚔️ frontend/src/assets/images/banners/banner_desktop2.png (content)
⚔️ frontend/src/assets/images/banners/banner_mobile1.png (content)
⚔️ frontend/src/assets/images/banners/banner_mobile2.png (content)
⚔️ frontend/src/assets/images/icons/category_button/category_all_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/category_button/category_hobby_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/category_button/category_performance_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/category_button/category_religion_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/category_button/category_sport_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/category_button/category_study_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/category_button/category_volunteer_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/next_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/prev_button_icon.svg (content)
⚔️ frontend/src/assets/images/icons/sns/youtube_icon.svg (content)
⚔️ frontend/src/components/ClubStateBox/ClubStateBox.tsx (content)
⚔️ frontend/src/components/ClubTag/ClubTag.tsx (content)
⚔️ frontend/src/components/common/Button/Button.tsx (content)
⚔️ frontend/src/components/common/CustomDropDown/CustomDropDown.styles.ts (content)
⚔️ frontend/src/components/common/CustomDropDown/CustomDropDown.tsx (content)
⚔️ frontend/src/components/common/CustomTextArea/CustomTextArea.styles.ts (content)
⚔️ frontend/src/components/common/CustomTextArea/CustomTextArea.tsx (content)
⚔️ frontend/src/components/common/Footer/Footer.stories.tsx (content)
⚔️ frontend/src/components/common/Footer/Footer.styles.ts (content)
⚔️ frontend/src/components/common/Footer/Footer.tsx (content)
⚔️ frontend/src/components/common/Header/Header.stories.tsx (content)
⚔️ frontend/src/components/common/Header/Header.styles.ts (content)
⚔️ frontend/src/components/common/Header/Header.tsx (content)
⚔️ frontend/src/components/common/InputField/InputField.styles.ts (content)
⚔️ frontend/src/components/common/InputField/InputField.tsx (content)
⚔️ frontend/src/components/common/Spinner/Spinner.tsx (content)
⚔️ frontend/src/constants/api.ts (content)
⚔️ frontend/src/constants/snsConfig.ts (content)
⚔️ frontend/src/constants/uploadLimit.ts (content)
⚔️ frontend/src/context/AdminClubContext.tsx (content)
⚔️ frontend/src/hooks/useAuth.ts (content)
⚔️ frontend/src/index.tsx (content)
⚔️ frontend/src/pages/AdminPage/AdminPage.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/AdminPage.tsx (content)
⚔️ frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx (content)
⚔️ frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx (content)
⚔️ frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx (content)
⚔️ frontend/src/pages/AdminPage/components/SideBar/SideBar.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/AccountEditTab/AccountEditTab.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/tabs/AccountEditTab/AccountEditTab.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/MakeTags/MakeTags.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/MakeTags/MakeTags.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/SelectTags/SelectTags.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/SelectTags/SelectTags.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/Calendar/Calendar.styles.ts (content)
⚔️ frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/Calendar/Calendar.tsx (content)
⚔️ frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/MarkdownEditor/MarkdownEditor.tsx (content)
⚔️ frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx (content)
⚔️ frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx (content)
⚔️ frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts (content)
⚔️ frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx (content)
⚔️ frontend/src/pages/IntroducePage/IntroducePage.styles.ts (content)
⚔️ frontend/src/pages/IntroducePage/IntroducePage.tsx (content)
⚔️ frontend/src/pages/MainPage/MainPage.styles.ts (content)
⚔️ frontend/src/pages/MainPage/MainPage.tsx (content)
⚔️ frontend/src/pages/MainPage/components/Banner/Banner.styles.ts (content)
⚔️ frontend/src/pages/MainPage/components/Banner/Banner.tsx (content)
⚔️ frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.styles.ts (content)
⚔️ frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx (content)
⚔️ frontend/src/pages/MainPage/components/ClubCard/ClubCard.styles.ts (content)
⚔️ frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx (content)
⚔️ frontend/src/pages/MainPage/components/StatusRadioButton/StatusRadioButton.tsx (content)
⚔️ frontend/src/styles/Global.styles.ts (content)
⚔️ frontend/src/styles/PageContainer.styles.ts (content)
⚔️ frontend/src/types/application.ts (content)
⚔️ frontend/src/types/club.ts (content)
⚔️ frontend/src/utils/getDeadLineText.test.ts (content)
⚔️ frontend/src/utils/getDeadLineText.ts (content)
⚔️ frontend/src/utils/initSDK.ts (content)
⚔️ frontend/src/utils/validateSocialLink.test.ts (content)
⚔️ frontend/tsconfig.json (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
Title check ❓ Inconclusive PR 제목 '[Release] BE'는 매우 모호하고 구체적이지 않습니다. 실제 변경 사항(개발자 인증, FCM 알림, ShedLock 통합, 관리자 포털 등)을 설명하지 않습니다. 제목을 더 구체적으로 변경하세요. 예: '[Release] Add developer auth, admin portal, and recruitment D-day notifications' 같은 주요 변경 사항을 반영하는 제목이 좋습니다.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch develop/be
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch develop/be
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🤖 Fix all issues with AI agents
In `@backend/build.gradle`:
- Around line 77-78: The shedlock dependencies currently use an incompatible
7.6.0 release; update the two artifact versions for net.javacrumbs.shedlock (the
implementation entries for 'shedlock-spring' and 'shedlock-provider-mongo') to a
Spring Boot 3.3.8–compatible 6.x release (for example 6.10.0) so they align with
Spring Framework 6.x and avoid classpath conflicts; change the version strings
for the artifacts 'net.javacrumbs.shedlock:shedlock-spring' and
'net.javacrumbs.shedlock:shedlock-provider-mongo' accordingly and run a build to
confirm dependency resolution.

In `@backend/src/main/java/moadong/club/controller/ClubAdminController.java`:
- Around line 53-58: The updateClubDescription controller method is missing
request-body validation: annotate the ClubRecruitmentInfoUpdateRequest parameter
in ClubAdminController.updateClubDescription with `@Valid` (as done for
ClubInfoRequest) and add the corresponding javax.validation.Valid import so the
request body is validated before calling
clubProfileService.updateClubRecruitmentInfoByClubId.

In `@backend/src/main/java/moadong/club/service/RecruitmentDdayNotifier.java`:
- Around line 46-47: The code calls club.getClubRecruitmentInformation() and
immediately accesses info.getRecruitmentEnd(), which can throw an NPE if
getClubRecruitmentInformation() returns null; update RecruitmentDdayNotifier to
defensively check the returned ClubRecruitmentInformation (e.g., assign to a
local variable info, if info == null then skip processing this club or log a
warning) before referencing info.getRecruitmentEnd(), ensuring you handle the
null case cleanly (continue/return or fallback behavior) so recruitmentEnd is
only accessed when info is non-null.
- Around line 45-60: In RecruitmentDdayNotifier, isolate failures during
individual notifications by wrapping the call to
club.sendPushNotification(notificationBuilder.build(club, daysLeft)) in a
try-catch (e.g., catch Exception or RuntimeException), so one club's exception
doesn't break the loop over openClubs; log the error with identifying info
(club.getId(), club.getName(), daysLeft) and continue, and only increment
sentCount on successful send.

In `@backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java`:
- Around line 27-28: The distributed lock duration in RecruitmentStateChecker is
too short: `@SchedulerLock` currently sets lockAtMostFor = "1s"/"1m" while the
scheduled fixedRate is 10 minutes, which can allow another instance to acquire
the lock mid-run; update the `@SchedulerLock` on the RecruitmentStateChecker
scheduled method to increase lockAtMostFor to roughly the schedule length (e.g.
"10m" or slightly longer like "11m") so the lock covers the entire execution
window and prevent concurrent runs; keep or minimally adjust lockAtLeastFor if
you need a minimum hold, but ensure lockAtMostFor >= scheduled fixedRate.

In `@backend/src/main/java/moadong/global/config/MongoConfig.java`:
- Around line 21-24: The lockProvider currently calls
mongoClient.getDatabase("shedLock") which hardcodes and creates a separate DB;
change lockProvider in MongoConfig to use the application's configured database
instead (e.g., obtain the DB name from configuration or reuse an existing
MongoDatabase/MongoTemplate bean). Concretely: replace
mongoClient.getDatabase("shedLock") with
mongoClient.getDatabase(applicationDatabaseName) where applicationDatabaseName
is injected from properties (e.g., `@Value`("${spring.data.mongodb.database}") or
from a MongoProperties/MongoTemplate/MongoDatabase bean) and pass that into new
MongoLockProvider(...); ensure the property or bean is added to the
constructor/parameters of lockProvider so environments reuse the app DB rather
than creating "shedLock".

In `@backend/src/main/java/moadong/global/config/SecurityConfig.java`:
- Around line 42-52: SecurityFilterChain securityFilterChain currently
configures CSRF and request authorization but omits stateless session
management—add the import for SessionCreationPolicy and configure the
HttpSecurity inside securityFilterChain to use sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) so Spring
Security does not create sessions when using JwtAuthenticationFilter
(constructed with jwtProvider and userDetailsService); ensure this call is
chained before adding the JwtAuthenticationFilter.

In `@backend/src/main/java/moadong/global/controller/DevPortalController.java`:
- Around line 6-12: The /dev and /dev/ endpoints (DevPortalController.devPortal)
currently allow public access and expose static resources; update the
SecurityConfig to explicitly protect the /dev/** path (including static assets
like /dev/index.html and /dev/edit.html) by adding a request matcher such as
requestMatchers("/dev/**") (or antMatchers("/dev/**") depending on your Spring
Security version) and require an appropriate authority (e.g.,
.hasRole("DEVELOPER") or authenticated()) so only authorized developer users can
reach DevPortalController and its resources; ensure the rule precedes any
permissive matchers so it is enforced and keep existing protections like
`@PreAuthorize`("isAuthenticated()") for API endpoints intact.

In `@backend/src/main/resources/static/dev/dict-edit.html`:
- Around line 61-64: The splitter in parseInputWordsStr currently uses
`/[,,\s]+/`, which treats whitespace as a delimiter and breaks multi-word
entries like "동아리 활동"; update parseInputWordsStr to split only on commas (both
ASCII and full-width) by replacing the regex with one that matches only commas
(e.g., `/[,,]+/`) and preserve trimming/filtering logic; also apply the same
change to the identical implementation in index.html (the occurrence referenced
in the review) so both files behave consistently.

In `@backend/src/main/resources/static/dev/edit.html`:
- Line 108: The code constructs fetch URLs by concatenating '/api/club/' +
clubId without encoding, which can break for IDs with special characters; update
each occurrence that builds the club API URL (the fetch calls using API_BASE +
'/api/club/' + clubId) to wrap clubId with encodeURIComponent(clubId) — mirror
the existing approach used for dictId in dict-edit.html — so all fetches
(including the ones that currently call headers()) use encoded club IDs for
safe, consistent URLs.

In `@backend/src/main/resources/static/dev/index.html`:
- Around line 599-606: The upload sends an empty "Bearer " header because
getToken() is concatenated unconditionally in the fetch headers; change the
fetch header construction (the object passed to fetch in the POST to API_BASE +
'/api/admin/word-dictionary/csv') to only include Authorization when getToken()
returns a non-empty value (reuse the existing headers() behavior or call
headers() instead of building h = { 'Authorization': 'Bearer ' + getToken() });
ensure getToken() is checked and omit the Authorization property when it is
falsy so no empty Bearer header is sent.
- Around line 415-418: Replace the direct innerHTML insertion of the error
message with safe DOM text assignment: instead of setting tbody.innerHTML =
'<tr><td colspan="3">' + (e.message || '오류') + '</td></tr>' (and the similar
occurrence at the other spot), create a tr and td element, set td.colSpan = 3
(or 4 for the second occurrence as suggested), set td.textContent = (e.message
|| '오류'), append td to tr, then append tr to tbody; keep using
banner.textContent and banner.classList manipulation as-is.
🧹 Nitpick comments (12)
backend/src/main/java/moadong/global/config/SecurityConfig.java (1)

33-34: 주석 처리된 코드가 남아있습니다.

GCP Storage 관련 주석 처리된 코드(credentials, Storage bean)가 여전히 남아 있습니다. 더 이상 필요 없다면 제거하여 코드 가독성을 높이는 것을 권장합니다.

Also applies to: 55-62

backend/src/main/java/moadong/user/entity/User.java (1)

54-55: role 필드에 @NotNull 어노테이션 누락 검토 필요.

다른 필드(status, emailVerified 등)는 @Builder.Default@NotNull을 함께 사용하고 있으나, role에는 @NotNull이 없습니다. 기존 MongoDB 문서와의 호환성을 위한 의도적인 설계라면 괜찮지만, 신규 생성 시 null 방지를 위해 일관성 있게 @NotNull을 추가하는 것을 권장합니다.

backend/src/main/java/moadong/user/service/UserCommandService.java (2)

67-88: 시크릿 비교 시 타이밍 공격에 취약할 수 있습니다.

Line 70의 devRegistrationSecret.equals(request.secret())는 일반 문자열 비교로, 타이밍 사이드 채널 공격에 이론적으로 취약합니다. MessageDigest.isEqual() 또는 Spring Security의 상수 시간 비교를 사용하는 것을 권장합니다.

또한 registerUser와 로직이 상당 부분 중복됩니다. 공통 부분을 추출하는 것을 고려해 보세요.

🔒 타이밍 안전한 비교로 변경
+import java.security.MessageDigest;
+
     `@Transactional`
     public User registerDeveloper(DevRegisterRequest request) {
         if (devRegistrationSecret == null || devRegistrationSecret.isBlank()
-                || !devRegistrationSecret.equals(request.secret())) {
+                || !MessageDigest.isEqual(
+                    devRegistrationSecret.getBytes(), 
+                    request.secret().getBytes())) {
             throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
         }

49-50: devRegistrationSecret 필드가 final이 아니므로 필드 주입으로 동작합니다.

@RequiredArgsConstructorfinal 필드만 생성자에 포함하므로, @Value 어노테이션이 붙은 이 필드는 필드 주입으로 처리됩니다. 동작에는 문제가 없지만, 생성자 주입 일관성을 위해 final로 선언하고 생성자 매개변수로 받는 방식을 고려해 보세요.

♻️ 생성자 주입으로 변경
-    `@Value`("${app.dev-registration-secret:}")
-    private String devRegistrationSecret; // set by Spring after construction (field injection)
+    private final String devRegistrationSecret;

그리고 생성자에 @Value를 적용하거나, 별도의 @Configuration 클래스에서 주입하도록 변경합니다. @RequiredArgsConstructor를 사용 중이므로, Lombok의 lombok.configlombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value를 추가하면 생성자 파라미터에 @Value가 자동 복사됩니다.

backend/src/main/java/moadong/user/controller/DevAuthController.java (1)

16-29: 공개 엔드포인트에 대한 rate limiting 부재.

/auth/dev/register는 인증 없이 접근 가능한 엔드포인트입니다. 시크릿 키가 유일한 보호 수단이므로, 무차별 대입 공격(brute-force)에 대비한 rate limiting 또는 IP 기반 제한을 추가하는 것을 권장합니다.

backend/src/main/resources/static/dev/edit.html (1)

65-76: getToken, headers, showResult 등 유틸리티 함수가 3개 파일에 중복됩니다.

edit.html, dict-edit.html, index.html 모두에 getToken(), headers(), showResult() 등의 동일한 헬퍼 함수가 복사되어 있습니다. 공통 JS 파일(예: /dev/common.js)로 추출하면 유지보수가 쉬워집니다.

backend/src/main/resources/static/dev/index.html (2)

302-310: navigator.clipboard.writeText의 Promise 거부가 처리되지 않습니다.

Line 305, 355, 448에서 navigator.clipboard.writeText()를 호출하지만 반환된 Promise를 처리하지 않습니다. HTTPS가 아닌 환경이나 권한이 없는 경우 실패할 수 있으며, 이때 unhandled rejection이 발생합니다.

🔧 수정 제안 (Line 305 예시)
-        navigator.clipboard.writeText(t);
+        navigator.clipboard.writeText(t).catch(() => {});

243-257: showLogin 함수의 매개변수명이 의미와 반대로 읽힐 수 있습니다.

showLogin(show) 함수에서 showtrue일 때 로그인 폼을 숨기고 토큰 영역을 보여줍니다. 매개변수명이 isLoggedIn이면 가독성이 더 좋겠습니다.

backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java (1)

30-41: 모든 클럽을 로드 후 개별 저장하는 비효율적인 패턴입니다.

findAll()로 전체 클럽을 로드하고, ALWAYS 상태인 클럽도 포함하여 모두 가져온 뒤 개별적으로 save()를 호출합니다. 두 가지 개선이 가능합니다:

  1. ALWAYS가 아닌 클럽만 조회하도록 리포지토리 쿼리를 추가하거나, 새로 추가된 findAllByClubRecruitmentInformation_ClubRecruitmentStatus를 활용
  2. 변경된 클럽만 모아서 saveAll()로 일괄 저장
backend/src/main/java/moadong/club/service/ClubProfileService.java (2)

61-71: getAllClubsForAdmin에 페이지네이션이 없습니다.

현재 findAll()로 전체 클럽을 한 번에 로드합니다. 클럽 수가 증가하면 메모리와 응답 시간 문제가 발생할 수 있습니다. Pageable을 사용한 페이지네이션 도입을 권장합니다.


84-109: updateClubInfoByClubIdupdateClubRecruitmentInfoByClubId는 기존 메서드와 거의 동일한 중복 코드입니다.

Lines 38-44의 updateClubInfo와 Lines 84-92의 updateClubInfoByClubId는 클럽 조회 방식만 다르고 나머지 로직이 동일합니다. updateClubRecruitmentInfo/updateClubRecruitmentInfoByClubId도 마찬가지입니다. 클럽 조회를 공통 private 메서드로 추출하면 중복을 줄일 수 있습니다.

예시
private Club findClubByIdOrThrow(String clubId) {
    ObjectId objectId = ObjectIdConverter.convertString(clubId);
    return clubRepository.findClubById(objectId)
            .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
}
backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java (1)

89-95: buildNotificationDataRecruitmentDdayNotificationBuilder.buildData가 중복됩니다.

RecruitmentDdayNotificationBuilder.java(lines 41-46)의 buildData 메서드와 동일한 로직입니다. 경로/액션/클럽ID 맵 생성을 하나의 공유 유틸리티로 추출하면 향후 경로 변경 시 한 곳만 수정하면 됩니다.

Comment on lines +77 to +78
implementation 'net.javacrumbs.shedlock:shedlock-spring:7.6.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-mongo:7.6.0'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

ShedLock 7.6.0 compatibility Spring Boot 3.3.8 Java 17

💡 Result:

  • Java 17: OK — ShedLock 7.x requires JVM 17+. [1]
  • Spring Boot 3.3.8: Not a good match for ShedLock 7.6.0. ShedLock 7.x is tested with Spring Boot 3.4/3.5/4.x and Spring Framework 6.2/7.0 (not 3.3 / Spring 6.1). [1]
  • Why: shedlock-spring:7.6.0 pulls in Spring Framework spring-context 7.0.3, which aligns with Spring Framework 7 (Boot 4 line), not Boot 3.3.x’s Spring Framework 6.1 line. [2][3]
  • Recommended for Spring Boot 3.3.8: Use ShedLock 6.x (tested with Spring Boot 3.3 and Spring 6.1/6.2). [1] The latest 6.x line shown on Maven Repository is 6.10.0. [4]

Sources:
[1] ShedLock GitHub compatibility matrix https://github.com/lukas-krecan/ShedLock
[2] shedlock-spring:7.6.0 dependencies (shows spring-context 7.0.3) https://mvnrepository.com/artifact/net.javacrumbs.shedlock/shedlock-spring/7.6.0/dependencies
[3] Spring Boot 3.3 system requirements (Spring Framework 6.1.x line) https://docs.enterprise.spring.io/spring-boot/system-requirements.html
[4] Maven Repository versions list for shedlock-spring (shows 6.10.0 as latest 6.x) https://mvnrepository.com/artifact/net.javacrumbs.shedlock/shedlock-spring


ShedLock 7.6.0은 Spring Boot 3.3.8과 호환되지 않습니다.

ShedLock 7.6.0은 Spring Framework 7.x (Spring Boot 4.x)를 기준으로 개발되었으나, Spring Boot 3.3.8은 Spring Framework 6.1.x를 사용합니다. shedlock-spring:7.6.0이 의존하는 spring-context 7.0.3이 프로젝트의 Spring Framework 버전과 충돌합니다. Spring Boot 3.3.8과 호환되는 **ShedLock 6.x 버전(예: 6.10.0)**을 사용하세요.

🤖 Prompt for AI Agents
In `@backend/build.gradle` around lines 77 - 78, The shedlock dependencies
currently use an incompatible 7.6.0 release; update the two artifact versions
for net.javacrumbs.shedlock (the implementation entries for 'shedlock-spring'
and 'shedlock-provider-mongo') to a Spring Boot 3.3.8–compatible 6.x release
(for example 6.10.0) so they align with Spring Framework 6.x and avoid classpath
conflicts; change the version strings for the artifacts
'net.javacrumbs.shedlock:shedlock-spring' and
'net.javacrumbs.shedlock:shedlock-provider-mongo' accordingly and run a build to
confirm dependency resolution.

Comment on lines +53 to +58
public ResponseEntity<?> updateClubDescription(
@CurrentUser CustomUserDetails user,
@PathVariable String clubId,
@RequestBody ClubRecruitmentInfoUpdateRequest request) {
clubProfileService.updateClubRecruitmentInfoByClubId(clubId, request, user);
return Response.ok("success update club description");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ClubRecruitmentInfoUpdateRequest@Valid 어노테이션이 누락되었습니다.

Line 45의 ClubInfoRequest에는 @Valid가 적용되어 있지만, Line 56의 ClubRecruitmentInfoUpdateRequest에는 빠져 있어 요청 본문 검증이 수행되지 않습니다.

🔧 제안 수정
-            `@RequestBody` ClubRecruitmentInfoUpdateRequest request) {
+            `@RequestBody` `@Valid` ClubRecruitmentInfoUpdateRequest request) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public ResponseEntity<?> updateClubDescription(
@CurrentUser CustomUserDetails user,
@PathVariable String clubId,
@RequestBody ClubRecruitmentInfoUpdateRequest request) {
clubProfileService.updateClubRecruitmentInfoByClubId(clubId, request, user);
return Response.ok("success update club description");
public ResponseEntity<?> updateClubDescription(
`@CurrentUser` CustomUserDetails user,
`@PathVariable` String clubId,
`@RequestBody` `@Valid` ClubRecruitmentInfoUpdateRequest request) {
clubProfileService.updateClubRecruitmentInfoByClubId(clubId, request, user);
return Response.ok("success update club description");
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/controller/ClubAdminController.java`
around lines 53 - 58, The updateClubDescription controller method is missing
request-body validation: annotate the ClubRecruitmentInfoUpdateRequest parameter
in ClubAdminController.updateClubDescription with `@Valid` (as done for
ClubInfoRequest) and add the corresponding javax.validation.Valid import so the
request body is validated before calling
clubProfileService.updateClubRecruitmentInfoByClubId.

Comment on lines +45 to +60
for (Club club : openClubs) {
ClubRecruitmentInformation info = club.getClubRecruitmentInformation();
ZonedDateTime recruitmentEnd = info.getRecruitmentEnd();

if (recruitmentEnd == null) {
continue;
}

long daysLeft = ChronoUnit.DAYS.between(today, recruitmentEnd.toLocalDate());

if (NOTIFICATION_DAYS.contains(daysLeft)) {
log.info("D-Day 알림 전송 - clubId: {}, clubName: {}, D-{}", club.getId(), club.getName(), daysLeft);
club.sendPushNotification(notificationBuilder.build(club, daysLeft));
sentCount++;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

개별 클럽 알림 전송 실패 시 나머지 클럽이 알림을 받지 못합니다.

club.sendPushNotification()에서 예외가 발생하면 루프 전체가 중단되어 이후 클럽들이 알림을 받지 못하게 됩니다. 개별 전송을 try-catch로 감싸서 실패를 격리해야 합니다.

🛡️ 제안 수정
         for (Club club : openClubs) {
             ClubRecruitmentInformation info = club.getClubRecruitmentInformation();
             ZonedDateTime recruitmentEnd = info.getRecruitmentEnd();
 
             if (recruitmentEnd == null) {
                 continue;
             }
 
             long daysLeft = ChronoUnit.DAYS.between(today, recruitmentEnd.toLocalDate());
 
             if (NOTIFICATION_DAYS.contains(daysLeft)) {
-                log.info("D-Day 알림 전송 - clubId: {}, clubName: {}, D-{}", club.getId(), club.getName(), daysLeft);
-                club.sendPushNotification(notificationBuilder.build(club, daysLeft));
-                sentCount++;
+                try {
+                    log.info("D-Day 알림 전송 - clubId: {}, clubName: {}, D-{}", club.getId(), club.getName(), daysLeft);
+                    club.sendPushNotification(notificationBuilder.build(club, daysLeft));
+                    sentCount++;
+                } catch (Exception e) {
+                    log.error("D-Day 알림 전송 실패 - clubId: {}, clubName: {}", club.getId(), club.getName(), e);
+                }
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (Club club : openClubs) {
ClubRecruitmentInformation info = club.getClubRecruitmentInformation();
ZonedDateTime recruitmentEnd = info.getRecruitmentEnd();
if (recruitmentEnd == null) {
continue;
}
long daysLeft = ChronoUnit.DAYS.between(today, recruitmentEnd.toLocalDate());
if (NOTIFICATION_DAYS.contains(daysLeft)) {
log.info("D-Day 알림 전송 - clubId: {}, clubName: {}, D-{}", club.getId(), club.getName(), daysLeft);
club.sendPushNotification(notificationBuilder.build(club, daysLeft));
sentCount++;
}
}
for (Club club : openClubs) {
ClubRecruitmentInformation info = club.getClubRecruitmentInformation();
ZonedDateTime recruitmentEnd = info.getRecruitmentEnd();
if (recruitmentEnd == null) {
continue;
}
long daysLeft = ChronoUnit.DAYS.between(today, recruitmentEnd.toLocalDate());
if (NOTIFICATION_DAYS.contains(daysLeft)) {
try {
log.info("D-Day 알림 전송 - clubId: {}, clubName: {}, D-{}", club.getId(), club.getName(), daysLeft);
club.sendPushNotification(notificationBuilder.build(club, daysLeft));
sentCount++;
} catch (Exception e) {
log.error("D-Day 알림 전송 실패 - clubId: {}, clubName: {}", club.getId(), club.getName(), e);
}
}
}
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/service/RecruitmentDdayNotifier.java`
around lines 45 - 60, In RecruitmentDdayNotifier, isolate failures during
individual notifications by wrapping the call to
club.sendPushNotification(notificationBuilder.build(club, daysLeft)) in a
try-catch (e.g., catch Exception or RuntimeException), so one club's exception
doesn't break the loop over openClubs; log the error with identifying info
(club.getId(), club.getName(), daysLeft) and continue, and only increment
sentCount on successful send.

Comment on lines +46 to +47
ClubRecruitmentInformation info = club.getClubRecruitmentInformation();
ZonedDateTime recruitmentEnd = info.getRecruitmentEnd();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

getClubRecruitmentInformation()이 null을 반환할 경우 NPE 가능성.

OPEN 상태인 클럽이라면 일반적으로 모집 정보가 존재하겠지만, 방어적으로 null 체크를 추가하면 더 안전합니다.

🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/service/RecruitmentDdayNotifier.java`
around lines 46 - 47, The code calls club.getClubRecruitmentInformation() and
immediately accesses info.getRecruitmentEnd(), which can throw an NPE if
getClubRecruitmentInformation() returns null; update RecruitmentDdayNotifier to
defensively check the returned ClubRecruitmentInformation (e.g., assign to a
local variable info, if info == null then skip processing this club or log a
warning) before referencing info.getRecruitmentEnd(), ensuring you handle the
null case cleanly (continue/return or fallback behavior) so recruitmentEnd is
only accessed when info is non-null.

Comment on lines +27 to +28
@Scheduled(fixedRate = 10 * 60 * 1000) // 10분마다 실행
@SchedulerLock(name="RecruitmentStateChecker", lockAtMostFor = "1m", lockAtLeastFor = "1s")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

lockAtMostFor가 실행 주기에 비해 너무 짧을 수 있습니다.

스케줄 주기는 10분이지만 lockAtMostFor는 1분입니다. 클럽 수가 많아질 경우 findAll() + 개별 save() 루프가 1분을 초과할 수 있으며, 이 경우 락이 해제되어 다른 인스턴스가 동시에 같은 작업을 실행할 수 있습니다. lockAtMostFor를 실행 주기(10분)에 가깝게 설정하는 것이 안전합니다.

제안
-    `@SchedulerLock`(name="RecruitmentStateChecker", lockAtMostFor = "1m", lockAtLeastFor = "1s")
+    `@SchedulerLock`(name="RecruitmentStateChecker", lockAtMostFor = "9m", lockAtLeastFor = "30s")
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java`
around lines 27 - 28, The distributed lock duration in RecruitmentStateChecker
is too short: `@SchedulerLock` currently sets lockAtMostFor = "1s"/"1m" while the
scheduled fixedRate is 10 minutes, which can allow another instance to acquire
the lock mid-run; update the `@SchedulerLock` on the RecruitmentStateChecker
scheduled method to increase lockAtMostFor to roughly the schedule length (e.g.
"10m" or slightly longer like "11m") so the lock covers the entire execution
window and prevent concurrent runs; keep or minimally adjust lockAtLeastFor if
you need a minimum hold, but ensure lockAtMostFor >= scheduled fixedRate.

Comment on lines +6 to +12
@Controller
public class DevPortalController {

@GetMapping({"/dev", "/dev/"})
public String devPortal() {
return "redirect:/dev/index.html";
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for Spring Security configuration files and classes
find . -type f -name "*.java" -o -name "*.yml" -o -name "*.yaml" -o -name "*.properties" | head -20

# Search for Spring Security related configurations
echo "=== Searching for Spring Security configurations ==="
rg -l "SecurityFilterChain|WebSecurityConfigurerAdapter|@EnableWebSecurity" --type=java

Repository: Moadong/moadong

Length of output: 1298


🏁 Script executed:

#!/bin/bash
# Search for /dev path restrictions in security configs
echo "=== Searching for /dev path configuration ==="
rg -n "/dev" --type=java --type=yml --type=yaml -B 3 -A 3 | grep -E "(Security|Config|@Bean|@Configuration)" -A 5 -B 5 | head -100

# Search for antMatchers or permitAll configurations
echo "=== Searching for security matchers ==="
rg "antMatchers|mvcMatchers|permitAll" --type=java -B 2 -A 2

Repository: Moadong/moadong

Length of output: 696


🏁 Script executed:

#!/bin/bash
# Look for the main security configuration class
echo "=== Looking for security configuration ==="
fd -e java | xargs grep -l "WebSecurityConfigurerAdapter\|SecurityFilterChain" 2>/dev/null

# Check application configuration files
echo "=== Checking application configuration files ==="
fd -e yml -o -e yaml -o -e properties | xargs cat 2>/dev/null | grep -A 10 -B 2 "dev\|security" | head -50

Repository: Moadong/moadong

Length of output: 348


🏁 Script executed:

#!/bin/bash
# Read the SecurityConfig file to see full security configuration
cat -n backend/src/main/java/moadong/global/config/SecurityConfig.java

Repository: Moadong/moadong

Length of output: 3555


🏁 Script executed:

#!/bin/bash
# Check if there are static resource security configurations
rg -n "addResourceHandlers|static|dev" backend/src/main/java/moadong/global/config/ --type=java

# Check for any interceptors or other security mechanisms
rg -n "Interceptor|Filter|Security" backend/src/main/java/moadong/global/config/ --type=java | head -30

# Check if /dev is mentioned elsewhere in security-related code
rg -n '"/dev"' backend/src/main/java/moadong --type=java

Repository: Moadong/moadong

Length of output: 3068


🏁 Script executed:

#!/bin/bash
# Check if there are static resources under /dev
echo "=== Looking for /dev static resources ==="
find . -path "*/src/main/resources/static/dev*" -o -path "*/public/dev*" -o -path "*/frontend/*dev*" 2>/dev/null

# Check the structure of frontend/public directories
echo "=== Checking frontend/public structure ==="
ls -la frontend/public 2>/dev/null || ls -la frontend 2>/dev/null || echo "No frontend directory found"

# Check if dev resources are in resources/static
echo "=== Checking resources/static ==="
find backend/src/main/resources -type d -name "dev" -o -type f -name "*dev*" 2>/dev/null | head -20

# Check for any frontend build output that might serve /dev
echo "=== Checking for dev portal files ==="
fd -e html -e jsx -e js | xargs grep -l "dev\|Dev" 2>/dev/null | head -10

Repository: Moadong/moadong

Length of output: 1251


🏁 Script executed:

#!/bin/bash
# Check the content of dev portal HTML files to see if they have any authentication
echo "=== Checking dev/index.html ==="
cat -n backend/src/main/resources/static/dev/index.html | head -50

echo "=== Checking dev/edit.html ==="
head -30 backend/src/main/resources/static/dev/edit.html

# Check if there's any client-side auth or redirect logic
echo "=== Searching for authentication checks in dev files ==="
grep -n "auth\|login\|token" backend/src/main/resources/static/dev/*.html 2>/dev/null || echo "No auth checks found in HTML"

Repository: Moadong/moadong

Length of output: 8838


🏁 Script executed:

#!/bin/bash
# Check what operations require authentication in the dev portal
echo "=== Checking dev/index.html for API calls and auth requirements ==="
grep -n "fetch\|API_BASE\|token\|auth" backend/src/main/resources/static/dev/index.html | head -40

# Check if there are any `@PreAuthorize` or similar annotations on the APIs that /dev might call
echo "=== Checking for API authorization ==="
rg -n "@PreAuthorize|@Secured|@RolesAllowed" backend/src/main/java --type=java | head -20

Repository: Moadong/moadong

Length of output: 3853


개발자 포털 엔드포인트는 Spring Security 레벨에서 보호되어야 합니다.

/dev/dev/ 엔드포인트가 공개적으로 접근 가능하며, 정적 리소스(/dev/index.html, /dev/edit.html 등)도 노출됩니다. 실제 API 작업(/api/admin/**)은 hasRole("DEVELOPER")@PreAuthorize("isAuthenticated()")로 보호되지만, 엔드포인트 자체에 대한 접근 제어가 없으면 불필요한 정보 노출이나 공격 표면이 증가합니다. SecurityConfig에서 /dev/** 경로를 명시적으로 보호하거나 Spring Security의 requestMatchers를 통해 접근을 제한해야 합니다.

🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/global/controller/DevPortalController.java`
around lines 6 - 12, The /dev and /dev/ endpoints
(DevPortalController.devPortal) currently allow public access and expose static
resources; update the SecurityConfig to explicitly protect the /dev/** path
(including static assets like /dev/index.html and /dev/edit.html) by adding a
request matcher such as requestMatchers("/dev/**") (or antMatchers("/dev/**")
depending on your Spring Security version) and require an appropriate authority
(e.g., .hasRole("DEVELOPER") or authenticated()) so only authorized developer
users can reach DevPortalController and its resources; ensure the rule precedes
any permissive matchers so it is enforced and keep existing protections like
`@PreAuthorize`("isAuthenticated()") for API endpoints intact.

Comment on lines +61 to +64
function parseInputWordsStr(str) {
if (!str || !str.trim()) return [];
return str.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

공백으로도 단어가 분리되어 의도치 않은 결과가 발생할 수 있습니다.

parseInputWordsStr의 정규식 /[,,\s]+/가 공백도 구분자로 사용합니다. 만약 "동아리 활동"처럼 공백이 포함된 단어를 입력해야 한다면 의도대로 동작하지 않습니다. 쉼표만 구분자로 사용하는 것이 더 안전할 수 있습니다. (index.html의 Line 551도 동일)

🔧 쉼표만 구분자로 사용하는 수정안
-      return str.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean);
+      return str.split(/[,,]+/).map(s => s.trim()).filter(Boolean);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function parseInputWordsStr(str) {
if (!str || !str.trim()) return [];
return str.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean);
}
function parseInputWordsStr(str) {
if (!str || !str.trim()) return [];
return str.split(/[,,]+/).map(s => s.trim()).filter(Boolean);
}
🤖 Prompt for AI Agents
In `@backend/src/main/resources/static/dev/dict-edit.html` around lines 61 - 64,
The splitter in parseInputWordsStr currently uses `/[,,\s]+/`, which treats
whitespace as a delimiter and breaks multi-word entries like "동아리 활동"; update
parseInputWordsStr to split only on commas (both ASCII and full-width) by
replacing the regex with one that matches only commas (e.g., `/[,,]+/`) and
preserve trimming/filtering logic; also apply the same change to the identical
implementation in index.html (the occurrence referenced in the review) so both
files behave consistently.

document.getElementById('pageTitle').textContent = '동아리 수정 (' + clubId + ')';
(async function() {
try {
const res = await fetch(API_BASE + '/api/club/' + clubId, { headers: headers() });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

clubIdencodeURIComponent가 누락되었습니다.

Line 108, 162, 190에서 clubId를 URL에 직접 삽입하고 있습니다. dict-edit.html에서는 encodeURIComponent(dictId)를 사용하고 있으므로 일관성을 위해, 그리고 특수문자가 포함된 ID에 대한 안전을 위해 인코딩을 추가해 주세요.

🔧 수정 제안
-          const res = await fetch(API_BASE + '/api/club/' + clubId, { headers: headers() });
+          const res = await fetch(API_BASE + '/api/club/' + encodeURIComponent(clubId), { headers: headers() });

Line 162, 190도 동일하게 적용:

-        const res = await fetch(API_BASE + '/api/admin/club/' + clubId + '/info', { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
+        const res = await fetch(API_BASE + '/api/admin/club/' + encodeURIComponent(clubId) + '/info', { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
-        const res = await fetch(API_BASE + '/api/admin/club/' + clubId + '/description', { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
+        const res = await fetch(API_BASE + '/api/admin/club/' + encodeURIComponent(clubId) + '/description', { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await fetch(API_BASE + '/api/club/' + clubId, { headers: headers() });
const res = await fetch(API_BASE + '/api/club/' + encodeURIComponent(clubId), { headers: headers() });
🤖 Prompt for AI Agents
In `@backend/src/main/resources/static/dev/edit.html` at line 108, The code
constructs fetch URLs by concatenating '/api/club/' + clubId without encoding,
which can break for IDs with special characters; update each occurrence that
builds the club API URL (the fetch calls using API_BASE + '/api/club/' + clubId)
to wrap clubId with encodeURIComponent(clubId) — mirror the existing approach
used for dictId in dict-edit.html — so all fetches (including the ones that
currently call headers()) use encoded club IDs for safe, consistent URLs.

Comment on lines +415 to +418
banner.textContent = '요청 실패: ' + (e.message || '');
banner.className = 'banner error';
banner.classList.remove('hidden');
tbody.innerHTML = '<tr><td colspan="3">' + (e.message || '오류') + '</td></tr>';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

innerHTML에 에러 메시지를 직접 삽입하면 XSS 위험이 있습니다.

Line 418과 525에서 e.messageinnerHTML로 삽입하고 있습니다. 현재 fetch 에러 메시지는 일반적으로 안전하지만, 방어적으로 textContent를 사용하는 것이 좋습니다.

🔧 수정 제안
-        tbody.innerHTML = '<tr><td colspan="3">' + (e.message || '오류') + '</td></tr>';
+        const errRow = document.createElement('tr');
+        const errCell = document.createElement('td');
+        errCell.colSpan = 3;
+        errCell.textContent = e.message || '오류';
+        errRow.appendChild(errCell);
+        tbody.innerHTML = '';
+        tbody.appendChild(errRow);

Line 525도 동일하게 수정 (colspan을 4로).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
banner.textContent = '요청 실패: ' + (e.message || '');
banner.className = 'banner error';
banner.classList.remove('hidden');
tbody.innerHTML = '<tr><td colspan="3">' + (e.message || '오류') + '</td></tr>';
banner.textContent = '요청 실패: ' + (e.message || '');
banner.className = 'banner error';
banner.classList.remove('hidden');
const errRow = document.createElement('tr');
const errCell = document.createElement('td');
errCell.colSpan = 3;
errCell.textContent = e.message || '오류';
errRow.appendChild(errCell);
tbody.innerHTML = '';
tbody.appendChild(errRow);
🤖 Prompt for AI Agents
In `@backend/src/main/resources/static/dev/index.html` around lines 415 - 418,
Replace the direct innerHTML insertion of the error message with safe DOM text
assignment: instead of setting tbody.innerHTML = '<tr><td colspan="3">' +
(e.message || '오류') + '</td></tr>' (and the similar occurrence at the other
spot), create a tr and td element, set td.colSpan = 3 (or 4 for the second
occurrence as suggested), set td.textContent = (e.message || '오류'), append td to
tr, then append tr to tbody; keep using banner.textContent and banner.classList
manipulation as-is.

Comment on lines +599 to +606
btn.disabled = true;
try {
const h = { 'Authorization': 'Bearer ' + getToken() };
const res = await fetch(API_BASE + '/api/admin/word-dictionary/csv', {
method: 'POST',
headers: h,
body: formData
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

CSV 업로드 시 토큰이 없을 때 빈 Bearer 헤더가 전송됩니다.

headers() 함수는 토큰이 비어있으면 Authorization 헤더를 생략하지만, Line 601에서는 getToken() 결과를 확인하지 않고 항상 'Bearer ' + getToken()을 설정합니다. 토큰이 없을 때 "Bearer "가 전송되어 서버에서 예기치 않은 인증 오류가 발생할 수 있습니다.

🔧 수정 제안
-        const h = { 'Authorization': 'Bearer ' + getToken() };
+        const h = {};
+        const t = getToken();
+        if (t) h['Authorization'] = 'Bearer ' + t;
🤖 Prompt for AI Agents
In `@backend/src/main/resources/static/dev/index.html` around lines 599 - 606, The
upload sends an empty "Bearer " header because getToken() is concatenated
unconditionally in the fetch headers; change the fetch header construction (the
object passed to fetch in the POST to API_BASE +
'/api/admin/word-dictionary/csv') to only include Authorization when getToken()
returns a non-empty value (reuse the existing headers() behavior or call
headers() instead of building h = { 'Authorization': 'Bearer ' + getToken() });
ensure getToken() is checked and omit the Authorization property when it is
falsy so no empty Bearer header is sent.

Copy link
Collaborator

@SeongHoonC SeongHoonC left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다

@lepitaaar lepitaaar merged commit 4cc92bc into main Feb 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💾 BE Backend 📈 release 릴리즈 배포

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants