Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.leets7th.job_is_be.domain.job.controller;

import com.leets7th.job_is_be.domain.job.service.JobService;
import com.leets7th.job_is_be.global.response.ApiResponse;
import com.leets7th.job_is_be.global.status.SuccessStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/jobs")
@RequiredArgsConstructor
public class JobController {

private final JobService jobService;

// TODO 인증 세팅 후 @AuthenticationPrincipal로 교체
@PostMapping("/{jobId}/save")
public ResponseEntity<ApiResponse<Void>> saveJob(
@PathVariable Long jobId,
@RequestParam Long userId
) {
jobService.saveJob(userId, jobId);
return ApiResponse.success(SuccessStatus.JOB_SAVE_SUCCESS);
}

// TODO 인증 세팅 후 @AuthenticationPrincipal로 교체
@DeleteMapping("/{jobId}/save")
public ResponseEntity<ApiResponse<Void>> unsaveJob(
@PathVariable Long jobId,
@RequestParam Long userId
) {
jobService.unsaveJob(userId, jobId);
return ApiResponse.success(SuccessStatus.JOB_UNSAVE_SUCCESS);
}
Comment on lines +17 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

userId@RequestParam으로 수신하면 IDOR 취약점이 발생합니다.

TODO로 인증 적용 후 @AuthenticationPrincipal로 교체 예정인 것은 확인했습니다. 하지만 현재 상태로 배포하면 임의의 userId를 쿼리 파라미터로 전달하여 다른 사용자의 공고 저장/취소를 조작할 수 있습니다. 인증 설정 전까지는 최소한 컨트롤러 단에서 세션/토큰 기반 사용자 검증을 수행하거나, 해당 엔드포인트를 비활성화하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/leets7th/job_is_be/domain/job/controller/JobController.java`
around lines 17 - 35, JobController의 saveJob 및 unsaveJob에서 클라이언트가 전달한
`@RequestParam` userId를 신뢰하지 않도록 수정하세요. 인증 설정 전까지는 세션/토큰에서 검증된 사용자 ID를 사용하고 요청 값과
불일치하면 거부하거나, 검증 수단이 없으면 두 엔드포인트를 비활성화해 다른 사용자의 저장 상태를 변경할 수 없게 하세요.

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public Job createFrom(JobPosting posting) {
.sourceUrl(posting.getSourceUrl())
.postedAt(posting.getConfirmTime())
.deadlineAt(posting.getDueTime())
.locationFull(posting.getLocationFull())
.mainTasks(posting.getMainTasks())
.requirements(posting.getRequirements())
.preferredPoints(posting.getPreferredPoints())
.skillTags(posting.getSkillTags())
.skillsInferred(posting.getSkillsInferred())
.build();
applyStatus(job, resolveStatus(posting));
return job;
Expand All @@ -34,7 +40,9 @@ public void updateFrom(Job job, JobPosting posting) {
job.syncFrom(posting.getCompany(), posting.getPosition(), resolveCareerLevel(posting),
posting.getEmploymentType(), Boolean.TRUE.equals(posting.getIsRemote()),
posting.getSourceUrl(), posting.getConfirmTime(), posting.getDueTime(),
resolveStatus(posting));
resolveStatus(posting),
posting.getLocationFull(), posting.getMainTasks(), posting.getRequirements(),
posting.getPreferredPoints(), posting.getSkillTags(), posting.getSkillsInferred());
}

private void applyStatus(Job job, JobStatus status) {
Expand Down
42 changes: 40 additions & 2 deletions src/main/java/com/leets7th/job_is_be/domain/job/entity/Job.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

import java.time.OffsetDateTime;
import java.util.List;

/**
* 채용 공고
Expand Down Expand Up @@ -73,11 +76,32 @@ public class Job extends BaseEntity {
@Column(name = "editor_note", length = 1000)
private String editorNote; // Editor's Note (DET-01)

@Column(name = "location_full", length = 500)
private String locationFull;

@Column(name = "main_tasks", columnDefinition = "TEXT")
private String mainTasks;

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

@Column(name = "preferred_points", columnDefinition = "TEXT")
private String preferredPoints;

@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "skill_tags")
private List<String> skillTags;

@Column(name = "skills_inferred")
private Boolean skillsInferred;

@Builder
public Job(Company company, JobCategory jobCategory, Region region, String title,
String careerLevel, String employmentType, boolean remoteAvailable,
boolean salaryDisclosed, String source, Long externalId, String sourceUrl,
OffsetDateTime postedAt, OffsetDateTime deadlineAt, String editorNote) {
OffsetDateTime postedAt, OffsetDateTime deadlineAt, String editorNote,
String locationFull, String mainTasks, String requirements,
String preferredPoints, List<String> skillTags, Boolean skillsInferred) {
this.company = company;
this.jobCategory = jobCategory;
this.region = region;
Expand All @@ -92,6 +116,12 @@ public Job(Company company, JobCategory jobCategory, Region region, String title
this.postedAt = postedAt;
this.deadlineAt = deadlineAt;
this.editorNote = editorNote;
this.locationFull = locationFull;
this.mainTasks = mainTasks;
this.requirements = requirements;
this.preferredPoints = preferredPoints;
this.skillTags = skillTags;
this.skillsInferred = skillsInferred;
this.status = JobStatus.ACTIVE;
}

Expand All @@ -110,7 +140,9 @@ public boolean isOpenEnded() {
// 크롤링 재수집 시 (source, externalId)로 매칭된 기존 공고에 최신 원문 내용을 반영
public void syncFrom(Company company, String title, String careerLevel, String employmentType,
boolean remoteAvailable, String sourceUrl,
OffsetDateTime postedAt, OffsetDateTime deadlineAt, JobStatus status) {
OffsetDateTime postedAt, OffsetDateTime deadlineAt, JobStatus status,
String locationFull, String mainTasks, String requirements,
String preferredPoints, List<String> skillTags, Boolean skillsInferred) {
this.company = company;
this.title = title;
this.careerLevel = careerLevel;
Expand All @@ -120,5 +152,11 @@ public void syncFrom(Company company, String title, String careerLevel, String e
this.postedAt = postedAt;
this.deadlineAt = deadlineAt;
this.status = status;
this.locationFull = locationFull;
this.mainTasks = mainTasks;
this.requirements = requirements;
this.preferredPoints = preferredPoints;
this.skillTags = skillTags;
this.skillsInferred = skillsInferred;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;


/**
* 저장 목록
*/
@Entity
@Table(
name = "saved_jobs",
Expand All @@ -38,16 +36,14 @@ public class SavedJob extends BaseEntity {
private Job job;

@Column(name = "saved_at", nullable = false)
private LocalDateTime savedAt;
private OffsetDateTime savedAt;

// 지원함 등 자기보고 상태 — 추후 지원 현황 트래킹에 활용
@Column(name = "self_reported_status", length = 20)
private String selfReportedStatus; // 지원함 등 자기보고 상태 (§6.2)

@Column(name = "unsaved_at")
private LocalDateTime unsavedAt; // 해제 시각 (undo 5초)
private String selfReportedStatus;

@Builder
public SavedJob(User user, Job job, LocalDateTime savedAt) {
public SavedJob(User user, Job job, OffsetDateTime savedAt) {
this.user = user;
this.job = job;
this.savedAt = savedAt;
Expand All @@ -56,9 +52,5 @@ public SavedJob(User user, Job job, LocalDateTime savedAt) {
public void markSelfReportedStatus(String status) {
this.selfReportedStatus = status;
}

public void unsave(LocalDateTime now) {
this.unsavedAt = now;
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.leets7th.job_is_be.domain.job.repository;

import com.leets7th.job_is_be.domain.job.entity.SavedJob;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface SavedJobRepository extends JpaRepository<SavedJob, Long> {

boolean existsByUserIdAndJobId(Long userId, Long jobId);

Optional<SavedJob> findByUserIdAndJobId(Long userId, Long jobId);

void deleteByUserIdAndJobId(Long userId, Long jobId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.leets7th.job_is_be.domain.job.service;

import com.leets7th.job_is_be.domain.job.entity.Job;
import com.leets7th.job_is_be.domain.job.entity.SavedJob;
import com.leets7th.job_is_be.domain.job.repository.JobRepository;
import com.leets7th.job_is_be.domain.job.repository.SavedJobRepository;
import com.leets7th.job_is_be.domain.user.entity.User;
import com.leets7th.job_is_be.domain.user.repository.UserRepository;
import com.leets7th.job_is_be.global.exception.GeneralException;
import com.leets7th.job_is_be.global.status.ErrorStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;

@Service
@RequiredArgsConstructor
public class JobService {

private final JobRepository jobRepository;
private final SavedJobRepository savedJobRepository;
private final UserRepository userRepository;

@Transactional
public void saveJob(Long userId, Long jobId) {
if (savedJobRepository.existsByUserIdAndJobId(userId, jobId)) {
throw new GeneralException(ErrorStatus.JOB_ALREADY_SAVED);
}

User user = userRepository.findById(userId)
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
Job job = jobRepository.findById(jobId)
.orElseThrow(() -> new GeneralException(ErrorStatus.JOB_NOT_FOUND));

try {
savedJobRepository.save(SavedJob.builder()
.user(user)
.job(job)
.savedAt(OffsetDateTime.now())
.build());
} catch (DataIntegrityViolationException e) {
throw new GeneralException(ErrorStatus.JOB_ALREADY_SAVED);
}
}
Comment thread
jihoonkim501 marked this conversation as resolved.

@Transactional
public void unsaveJob(Long userId, Long jobId) {
if (!savedJobRepository.existsByUserIdAndJobId(userId, jobId)) {
throw new GeneralException(ErrorStatus.JOB_NOT_SAVED);
}

savedJobRepository.deleteByUserIdAndJobId(userId, jobId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class PersonalityTestAnswer extends BaseEntity {
@Column(name = "choice_value", nullable = false, length = 50)
private String choiceValue; // 선택한 카드 값

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "answered_at", nullable = false)
private LocalDateTime answeredAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ public class Resume extends BaseEntity {
@Column(name = "is_active", nullable = false)
private boolean active;

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "uploaded_at", nullable = false)
private LocalDateTime uploadedAt;

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "deleted_at")
private LocalDateTime deletedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public class User extends BaseEntity {
@Column(nullable = false, length = 20)
private UserStatus status;

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "withdrawn_at")
private LocalDateTime withdrawnAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,14 @@ public class UserProfile extends BaseEntity {
@Column(name = "is_job_test_completed", nullable = false)
private boolean jobTestCompleted;

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "job_test_completed_at")
private LocalDateTime jobTestCompletedAt;

@Column(name = "onboarding_completed", nullable = false)
private boolean onboardingCompleted;

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "onboarding_completed_at")
private LocalDateTime onboardingCompletedAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,15 @@ public class UserWithdrawal extends BaseEntity {
@Column(name = "reason_code", length = 30)
private String reasonCode; // 선택 입력, 강제 아님

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "requested_at", nullable = false)
private LocalDateTime requestedAt;

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "scheduled_deletion_at")
private LocalDateTime scheduledDeletionAt; // 신청일 + 30일

// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
@Column(name = "restored_at")
private LocalDateTime restoredAt;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package com.leets7th.job_is_be.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

// BaseEntity의 @CreatedDate, @LastModifiedDate 활성화
import java.time.OffsetDateTime;
import java.util.Optional;

@Configuration
@EnableJpaAuditing
@EnableJpaAuditing(dateTimeProviderRef = "offsetDateTimeProvider")
public class JpaConfig {

@Bean
public DateTimeProvider offsetDateTimeProvider() {
return () -> Optional.of(OffsetDateTime.now());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ public enum ErrorStatus implements BaseStatus {
/**
* Deck
*/
DECK_NOT_FOUND(HttpStatus.NOT_FOUND, "DECK_404_1", "덱을 찾을 수 없습니다.");
DECK_NOT_FOUND(HttpStatus.NOT_FOUND, "DECK_404_1", "덱을 찾을 수 없습니다."),

/**
* Job
*/
JOB_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_404_1", "공고를 찾을 수 없습니다."),
JOB_ALREADY_SAVED(HttpStatus.CONFLICT, "JOB_409_1", "이미 저장된 공고입니다."),
JOB_NOT_SAVED(HttpStatus.NOT_FOUND, "JOB_404_2", "저장되지 않은 공고입니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ public enum SuccessStatus implements BaseStatus {
* Briefing
*/
BRIEFING_TODAY_SUCCESS(HttpStatus.OK, "BRIEFING_200", "오늘의 브리핑을 조회했습니다."),
BRIEFING_STATUS_SUCCESS(HttpStatus.OK, "BRIEFING_200_2", "오늘의 브리핑 덱 카드 목록을 조회했습니다.");
BRIEFING_STATUS_SUCCESS(HttpStatus.OK, "BRIEFING_200_2", "오늘의 브리핑 덱 카드 목록을 조회했습니다."),

/**
* Job
*/
JOB_SAVE_SUCCESS(HttpStatus.CREATED, "JOB_201_1", "공고를 저장했습니다."),
JOB_UNSAVE_SUCCESS(HttpStatus.OK, "JOB_200_1", "공고 저장을 취소했습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down