Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/cloudcomputinginha/demo/DemoApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
@EnableAsync
public class DemoApplication {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package cloudcomputinginha.demo.config.auth;

import cloudcomputinginha.demo.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;

@Component
@RequiredArgsConstructor
public class GuestAccountCleaner {
private final MemberRepository memberRepository;

@Scheduled(fixedDelay = 3600000)
@Transactional
public void deleteOldGuestAccounts() {
LocalDateTime threshold = LocalDateTime.now().minusHours(1);
memberRepository.deleteByIsGuestTrueAndCreatedAtBefore(threshold);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ public class JwtProvider {
private final long ACCESS_TOKEN_EXPIRATION = 1000 * 60 * 60; // 1시간
private final long REFRESH_TOKEN_EXPIRATION = 1000L * 60 * 60 * 24 * 14; // 2주

public String generateAccessToken(Long memberId) {
public String generateAccessToken(Long memberId, boolean isGuest) {
Date now = new Date();
SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes());

return Jwts.builder()
.setSubject(String.valueOf(memberId))
.claim("isGuest", isGuest)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRATION))
.signWith(key, SignatureAlgorithm.HS256)
Expand Down Expand Up @@ -67,14 +68,17 @@ public Long getMemberIdFromToken(String token) {
}

public Map<String, String> reissueTokens(String oldRefreshToken, Member member) {
if (member.isGuest()) {
throw new JwtException("게스트는 토큰 재발급이 불가능합니다.");
}
if (!validateToken(oldRefreshToken)) {
throw new JwtException("유효하지 않은 refresh token 입니다.");
}
if (!oldRefreshToken.equals(member.getRefreshToken())) {
throw new JwtException("저장된 refresh token과 일치하지 않습니다.");
}

String newAccessToken = generateAccessToken(member.getId());
String newAccessToken = generateAccessToken(member.getId(), false);
String newRefreshToken = generateRefreshToken(member.getId());

member.setRefreshToken(newRefreshToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

//경로별 인가 작업
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/auth/GOOGLE", "/auth/google/callback").permitAll()
.requestMatchers("/auth/GOOGLE", "/auth/google/callback", "/auth/guest").permitAll()
.requestMatchers("/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-resources/**",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import cloudcomputinginha.demo.apiPayload.ApiResponse;
import cloudcomputinginha.demo.config.auth.JwtProvider;
import cloudcomputinginha.demo.config.auth.dto.GuestLoginResponse;
import cloudcomputinginha.demo.config.auth.dto.TokenReissueRequestDto;
import cloudcomputinginha.demo.config.auth.dto.TokenReissueResponseDto;
import cloudcomputinginha.demo.config.auth.service.OauthService;
Expand Down Expand Up @@ -80,4 +81,10 @@ public ApiResponse<TokenReissueResponseDto> reissueToken(@RequestBody TokenReiss
return ApiResponse.onSuccess(responseDto);

}

@PostMapping("/guest")
@Operation(summary = "게스트 로그인 API", description = "게스트 사용자 전용 로그인 기능입니다.")
public ApiResponse<GuestLoginResponse> guestLogin() {
return ApiResponse.onSuccess(oauthService.loginAsGuest());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package cloudcomputinginha.demo.config.auth.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class GuestLoginResponse {
private String accessToken;
private Long memberId;
private String randomName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import cloudcomputinginha.demo.config.auth.JwtProvider;
import cloudcomputinginha.demo.config.auth.domain.GoogleUser;
import cloudcomputinginha.demo.config.auth.dto.GuestLoginResponse;
import cloudcomputinginha.demo.domain.Member;
import cloudcomputinginha.demo.domain.enums.SocialProvider;
import cloudcomputinginha.demo.repository.MemberRepository;
Expand All @@ -11,6 +12,7 @@
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.List;
import java.util.Random;

@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -51,7 +53,7 @@ public void oauthLoginCallback(SocialProvider socialProvider, String code) {
return memberRepository.save(newMember);
});

String accessToken = jwtProvider.generateAccessToken(member.getId());
String accessToken = jwtProvider.generateAccessToken(member.getId(), false);
String refreshToken = jwtProvider.generateRefreshToken(member.getId());

member.setRefreshToken(refreshToken);
Expand All @@ -72,4 +74,15 @@ private SocialOauth findSocialOauthByType(SocialProvider socialProvider) {
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("알 수 없는 SocialLoginType 입니다."));
}

// 게스트 사용자
public GuestLoginResponse loginAsGuest() {
String name = "게스트" + new Random().nextInt(10000);
Member guest = Member.createGuest(name);
memberRepository.save(guest);

String accessToken = jwtProvider.generateAccessToken(guest.getId(), true);

return new GuestLoginResponse(accessToken, guest.getId(), guest.getName());
}
Comment on lines +78 to +87
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Use SecureRandom + transactional save for guest creation

  1. Random isn’t cryptographically strong. SecureRandom prevents predictable guest names.
  2. No transaction → if token generation fails after persisting, the orphan guest remains. Annotate with @Transactional (or handle rollback manually).
  3. Capture the returned entity from save for clarity.
-String name = "게스트" + new Random().nextInt(10_000);
-Member guest = Member.createGuest(name);
-memberRepository.save(guest);
+String name = "게스트" + new SecureRandom().nextInt(10_000);
+Member guest = memberRepository.save(Member.createGuest(name));
📝 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 GuestLoginResponse loginAsGuest() {
String name = "게스트" + new Random().nextInt(10000);
Member guest = Member.createGuest(name);
memberRepository.save(guest);
String accessToken = jwtProvider.generateAccessToken(guest.getId(), true);
return new GuestLoginResponse(accessToken, guest.getId(), guest.getName());
}
// 게스트 사용자
public GuestLoginResponse loginAsGuest() {
String name = "게스트" + new SecureRandom().nextInt(10_000);
Member guest = memberRepository.save(Member.createGuest(name));
String accessToken = jwtProvider.generateAccessToken(guest.getId(), true);
return new GuestLoginResponse(accessToken, guest.getId(), guest.getName());
}
🤖 Prompt for AI Agents
In src/main/java/cloudcomputinginha/demo/config/auth/service/OauthService.java
around lines 78 to 87, replace the use of java.util.Random with
java.security.SecureRandom to generate the guest name for stronger randomness.
Annotate the loginAsGuest method with @Transactional to ensure the guest
creation and token generation occur atomically, preventing orphan guest records
if token generation fails. Also, capture and use the entity returned from
memberRepository.save for clarity and consistency.

}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public static Interview toInterview(InterviewRequestDTO.InterviewCreateDTO reque
.startedAt(combineStartAt(request))
.isOpen(request.getIsOpen() != null ? request.getIsOpen() : false)
.maxParticipants(request.getMaxParticipants())
.hostId(member.getId())
.host(member)
.build();
}

Expand Down Expand Up @@ -130,7 +130,7 @@ public static InterviewResponseDTO.GroupInterviewDetailDTO toInterviewGroupDetai
.startedAt(interview.getStartedAt())
.hostName(
memberInterviewList.stream()
.filter(mi -> mi.getMember().getId().equals(interview.getHostId()))
.filter(mi -> mi.getMember().getId().equals(interview.getHost().getId()))
.findFirst()
.map(mi -> mi.getMember().getName())
.orElse("호스트의 이름을 알 수 없습니다.")
Expand All @@ -140,7 +140,7 @@ public static InterviewResponseDTO.GroupInterviewDetailDTO toInterviewGroupDetai
.map(mi -> InterviewResponseDTO.GroupInterviewParticipantDTO.builder()
.memberId(mi.getMember().getId())
.name(mi.getMember().getName())
.isHost(mi.getMember().getId().equals(interview.getHostId()))
.isHost(mi.getMember().getId().equals(interview.getHost().getId()))
.isSubmitted(mi.getResume() != null && mi.getCoverletter() != null)
.build()
).toList()
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/cloudcomputinginha/demo/domain/Coverletter.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import cloudcomputinginha.demo.domain.common.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
Expand All @@ -21,6 +23,9 @@ public class Coverletter extends BaseEntity {
@JoinColumn(name = "member_id", nullable = false)
private Member member;

@OneToMany(mappedBy = "coverletter", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Qna> qnas = new ArrayList<>();

Comment on lines +26 to +28
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Ensure non-null qnas when using Lombok builder

When the entity is created through Lombok’s @Builder, the qnas field will be null unless explicitly set, because new ArrayList<>() is only executed on field initialisation, not in the builder.
Add @Builder.Default to guarantee an empty list and prevent NPEs when business logic calls coverletter.getQnas().

-    @OneToMany(mappedBy = "coverletter", cascade = CascadeType.ALL, orphanRemoval = true)
-    private List<Qna> qnas = new ArrayList<>();
+    @OneToMany(mappedBy = "coverletter", cascade = CascadeType.ALL, orphanRemoval = true)
+    @Builder.Default
+    private List<Qna> qnas = new ArrayList<>();
📝 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
@OneToMany(mappedBy = "coverletter", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Qna> qnas = new ArrayList<>();
@OneToMany(mappedBy = "coverletter", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<Qna> qnas = new ArrayList<>();
🤖 Prompt for AI Agents
In src/main/java/cloudcomputinginha/demo/domain/Coverletter.java around lines 26
to 28, the qnas list is initialized with new ArrayList<>() but when using
Lombok's @Builder, this initialization is bypassed causing qnas to be null. To
fix this, add the @Builder.Default annotation to the qnas field to ensure it is
initialized to an empty list by default when the builder is used, preventing
potential NullPointerExceptions.

@Column(length = 100)
private String corporateName;

Expand Down
15 changes: 13 additions & 2 deletions src/main/java/cloudcomputinginha/demo/domain/Interview.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import org.hibernate.annotations.DynamicUpdate;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
Expand Down Expand Up @@ -42,12 +44,17 @@ public class Interview extends BaseEntity {
@Column(columnDefinition = "VARCHAR(20)")
private StartType startType;

private Long hostId;
// private Long hostId;

@Builder.Default
@Column(nullable = false)
private Integer currentParticipants = 1;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "host_id", nullable = false)
private Member host;

Comment on lines +53 to +56
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Missing @OnDelete may leave orphan FK rows in DB

@ManyToOne with cascade = REMOVE on the parent side is fine, but deleting a Member will currently fail due to FK constraint on Interview.host_id.
Add @OnDelete(action = OnDeleteAction.CASCADE) (Hibernate) or handle manually in service layer.

🤖 Prompt for AI Agents
In src/main/java/cloudcomputinginha/demo/domain/Interview.java around lines 53
to 56, the @ManyToOne relationship to Member as host lacks the @OnDelete
annotation, causing foreign key constraint failures when deleting a Member. To
fix this, add @OnDelete(action = OnDeleteAction.CASCADE) to the host field to
ensure that deleting a Member cascades and removes related Interview entries,
preventing orphaned foreign key rows.


@Builder.Default
private Integer maxParticipants = 1; //일대일 면접을 기준으로 초기화

Expand All @@ -58,10 +65,14 @@ public class Interview extends BaseEntity {

private LocalDateTime endedAt;

@OneToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
@JoinColumn(name = "interview_option_id", unique = true, nullable = false)
private InterviewOption interviewOption;

@OneToMany(mappedBy = "interview", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<MemberInterview> memberInterviews = new ArrayList<>();


public void updateStartedAt(LocalDateTime startedAt) {
this.startedAt = startedAt;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public class InterviewOption extends BaseEntity {

private Integer answerTime;

@OneToOne(mappedBy = "interviewOption", cascade = CascadeType.ALL)
@OneToOne(mappedBy = "interviewOption")
private Interview interview;

public void updateVoiceType(VoiceType voiceType) {
Expand Down
32 changes: 29 additions & 3 deletions src/main/java/cloudcomputinginha/demo/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import cloudcomputinginha.demo.domain.common.BaseEntity;
import cloudcomputinginha.demo.domain.enums.SocialProvider;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Entity
@Getter
Expand Down Expand Up @@ -33,15 +35,28 @@ public class Member extends BaseEntity {
private String introduction;

@Enumerated(EnumType.STRING)
@Column(length = 10, nullable = false)
@Column(length = 10)
private SocialProvider socialProvider;

@Column(length = 100, nullable = false)
@Column(length = 100)
private String providerId;

@Column(columnDefinition = "TEXT")
private String refreshToken;

@OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Coverletter> coverLetters = new ArrayList<>();

@OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Resume> resumes = new ArrayList<>();

@OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<MemberInterview> memberInterviews = new ArrayList<>();

@OneToMany(mappedBy = "host", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Interview> hostedInterviews = new ArrayList<>();


public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
Expand All @@ -58,4 +73,15 @@ public void updateInfo(String name, String phone, String jobType, String introdu
this.jobType = jobType;
this.introduction = introduction;
}

// 게스트 로그인
private boolean isGuest;

public static Member createGuest(String randomName) {
Member member = new Member();
member.email = "guest_" + UUID.randomUUID().toString().substring(0, 5)+ "@guest.com";
member.name = randomName;
member.isGuest = true;
return member;
}
Comment on lines +78 to +86
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add column annotation & reduce collision risk in guest email generation

  1. Persistence:

    @Column(nullable = false)
    private boolean isGuest;

    Even though a primitive boolean is non-null at JVM level, some dialects still generate a nullable column if nullable isn’t set.

  2. Collision risk: 5 random chars give 1.0M combinations → not huge at scale.
    Switching to 8–10 chars is safer and still keeps the email under the 30-char limit:

-member.email = "guest_" + UUID.randomUUID().toString().substring(0, 5) + "@guest.com";
+member.email = "guest_" + UUID.randomUUID().toString().substring(0, 10) + "@guest.com";
🤖 Prompt for AI Agents
In src/main/java/cloudcomputinginha/demo/domain/Member.java around lines 78 to
86, add the @Column(nullable = false) annotation to the isGuest field to ensure
the database column is non-nullable. Also, increase the substring length in the
UUID used for generating the guest email from 5 to between 8 and 10 characters
to reduce collision risk while keeping the email length reasonable.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import cloudcomputinginha.demo.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.time.LocalDateTime;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
void deleteByIsGuestTrueAndCreatedAtBefore(LocalDateTime threshold);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public interface InterviewCommandService {

InterviewResponseDTO.InterviewCreateResultDTO createInterview(InterviewRequestDTO.InterviewCreateDTO request, Long memberId);

public InterviewResponseDTO.InterviewStartResponseDTO startInterview(Long memberId, Long interviewId, Boolean isAutoMaticStart);
InterviewResponseDTO.InterviewStartResponseDTO startInterview(Long memberId, Long interviewId, Boolean isAutoMaticStart);

InterviewResponseDTO.InterviewUpdateResponseDTO updateInterview(Long memberId, Long interviewId, InterviewRequestDTO.InterviewUpdateDTO request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public InterviewResponseDTO.InterviewUpdateResponseDTO updateInterview(Long memb
Interview interview = interviewRepository.findById(interviewId)
.orElseThrow(() -> new InterviewHandler(ErrorStatus.INTERVIEW_NOT_FOUND));

if (!interview.getHostId().equals(memberId)) {
if (!interview.getHost().getId().equals(memberId)) {
throw new InterviewHandler(ErrorStatus.INTERVIEW_NO_PERMISSION);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public InterviewOptionResponseDTO.InterviewOptionUpdateResponseDTO updateIntervi
Interview interview = interviewRepository.findById(interviewId)
.orElseThrow(() -> new InterviewHandler(ErrorStatus.INTERVIEW_NOT_FOUND));

if (!interview.getHostId().equals(memberId)) {
if (!interview.getHost().getId().equals(memberId)) {
throw new InterviewHandler(ErrorStatus.INTERVIEW_NO_PERMISSION);
}

Expand Down