Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1c811a1
feat(batch): Spring Batch dependency 추가 DP-428
projectmiluju Sep 15, 2025
72a1d76
feat(batch): Spring Batch properties 설정 추가 DP-428
projectmiluju Sep 15, 2025
ffa0077
feat(error): JSON parse error 추가 DP-428
projectmiluju Sep 15, 2025
045b185
chore: README 수정
Shin-Yu-1 Sep 15, 2025
582fe57
chore: README 수정
Shin-Yu-1 Sep 15, 2025
aacc74b
feat(cache): 온도 랭킹 캐시를 위한 ReputationCacheHolder 추가 DP-428
projectmiluju Sep 15, 2025
9f9607e
feat(batch): Redis 기반 온도 랭킹 관리용 ReputationRankingWriter 추가 DP-428
projectmiluju Sep 15, 2025
f1d6ee6
feat(cache): ReputationRankingCacheService 추가하여 Redis 캐시에서 온도 랭킹 조회 기…
projectmiluju Sep 15, 2025
1023a32
feat(batch): ReputationRankingJobConfig 추가하여 온도 랭킹 계산 작업 구성 DP-428
projectmiluju Sep 15, 2025
dbb9e55
feat(batch): ReputationRankingJobTrigger 추가하여 온도 랭킹 작업 트리거 기능 구현 DP-428
projectmiluju Sep 15, 2025
3cf981f
refactor(controller): 온도 랭킹 조회에서 ReputationRankingScheduler를 Reputati…
projectmiluju Sep 15, 2025
14edc60
feat(cache): ReputationRankRedisKeys 클래스 추가하여 온도 랭킹 Redis 키 상수 정의 DP-428
projectmiluju Sep 15, 2025
852c898
fix(service): 지역별 랭킹 정확도를 위해 mainRegions를 풀네임으로 통일 DP-428
projectmiluju Sep 15, 2025
3215f80
refactor(scheduler): 레거시 스케줄러를 조건부 프로퍼티로 활성화하도록 변경 DP-428
projectmiluju Sep 15, 2025
39f11af
refactor(scheduler): EvaluationScheduler를 조건부 프로퍼티로 활성화하도록 변경 DP-428
projectmiluju Sep 15, 2025
60c16c3
feat(job): 평가 마감 처리를 위한 EvaluationCloseJobConfig 추가 DP-428
projectmiluju Sep 15, 2025
305f56c
feat(job): 평가 마감 잡 스케줄링용 EvaluationCloseJobTrigger 추가 DP-428
projectmiluju Sep 15, 2025
93d1a6f
feat(batch): 평가 마감 및 점수 갱신을 위한 EvaluationCloseTasklet 추가 DP-428
projectmiluju Sep 15, 2025
93ede17
refactor(config): 사용하지 않는 import문 제거 DP-428
projectmiluju Sep 15, 2025
0eed95c
feat(job): 프로젝트 상태 업데이트를 위한 JobConfig 추가 DP-428
projectmiluju Sep 15, 2025
4c32205
feat(batch): 프로젝트 상태 업데이트를 위한 ProjectStatusUpdateJobTrigger 추가 DP-428
projectmiluju Sep 15, 2025
e2ed4d7
feat(job): 스터디 상태 업데이트를 위한 JobConfig 추가 DP-428
projectmiluju Sep 15, 2025
868259b
feat(batch): 스터디 상태 업데이트를 위한 StudyStatusUpdateJobTrigger 추가 DP-428
projectmiluju Sep 15, 2025
a727ab3
Merge pull request #243 from DeepDirect/fix/readme
projectmiluju Sep 15, 2025
8ea7292
Merge pull request #244 from DeepDirect/refactor/DP-428-batch
projectmiluju Sep 15, 2025
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
93 changes: 92 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,92 @@
# DDOK
# 📖 똑DDOK — 당신 곁의 동료, 딸깍!

> 지도 기반 프로젝트·스터디 매칭 & 팀 협업 플랫폼

<br />

지도 기반으로 스터디/프로젝트를 빠르게 찾고 참여하고, 팀 협업(채팅·일정·알림)까지 한 곳에서 처리하는 플랫폼입니다.

프로젝트 기간: 2025.08 ~ 2025.09 (기획 및 개발)
시연영상 [YouTube]()
Link:
Code: [FE](https://github.com/DeepDirect/ddok-fe), [BE](https://github.com/DeepDirect/ddok-be)

---

## 🫶 팀원
| 이름 | 역할 | GitHub 링크 |
|----------|--------------------|------------------------------------------------|
| 정원용 | 팀장, Full Stack, Infra | [@projectmiluju](https://github.com/jihun-dev) |
| 권혜진 | Backend | [@sunsetkk](https://github.com/sunsetkk) |
| 박건 | Frontend | [@Jammanb0](https://github.com/Jammanb0) |
| 박소현 | Frontend | [@ssoogit](https://github.com/ssoogit) |
| 박재경 | Full Stack | [@Shin-Yu-1](https://github.com/Shin-Yu-1) |
| 이은지 | Frontend | [@ebbll](https://github.com/ebbll) |
| 최범근 | Backend | [@vayaconChoi](https://github.com/vayaconChoi) |

<br />

---

## ✨ 주요 기능

- 지도 기반 탐색: 카카오맵에서 주변 스터디/프로젝트/플레이어를 한눈에 확인 (상태/카테고리 필터)
- 포지션 매칭: 역할/경험/시간대 기반 맞춤 필터와 추천
- 원클릭 참여: 오픈톡/댓글 없이 클릭 한 번으로 신청/취소
- 팀 협업: 팀 생성 시 자동 채팅방, 일정 조율(캘린더), 팀 ReadMe
- 신뢰도 시스템: 온도(완주율/기여도), 배지/랭킹으로 책임감과 지속 참여 유도

<br/>

## 🛠️ 기술 스택

| 분류 | 기술명 |
|-------------------|--------------------------------------------------------------------------------------|
| **프레임워크/언어** | Java 17, Spring Boot 3.x, Gradle |
| **DB/스토리지** | PostgreSQL (AWS RDS), H2 (테스트 DB), AWS S3 |
| **ORM/데이터 관리** | Spring Data JPA, Hibernate |
| **캐싱/브로커** | Redis (세션 관리, Pub/Sub) |
| **실시간 통신** | Spring WebSocket, STOMP |
| **보안/인증** | Spring Security, JWT, OAuth2(Kakao), JavaMailSender, CoolSMS |
| **API 문서화** | SpringDoc OpenAPI (Swagger) |
| **검증/유효성** | Hibernate Validator |
| **배치/스케줄링** | Spring Batch, Spring Scheduler |
| **로깅/모니터링** | Logback, Actuator, Sentry |
| **검색/추천(옵션)** | Elasticsearch |
| **배포/인프라** | Docker, AWS EC2, Nginx, Route53

<br />

---

## 📁 디렉토리 구조

```bash
src/
└── main/
└── java/
└── goorm/ddok/
├── badge/ # 배지 발급, 등급 관련 도메인
├── cafe/ # 추천 카페/장소 관련
├── chat/ # 채팅, 채팅 알림 관련
├── evaluation/ # 프로젝트/스터디 종료 평가 로직
├── global/ # 전역 설정, 보안, 예외처리, 공통 유틸
├── map/ # 지도/좌표 관련 기능
├── member/ # 사용자/회원가입/프로필 관리
├── notification/ # 알림(Notification) 처리
├── player/ # 사용자 카드, 필터링, 플레이어 조회
├── project/ # 프로젝트 관련 CRUD
├── reputation/ # 신뢰도(온도) 계산/관리
├── study/ # 스터디 관련 CRUD
├── team/ # 팀 생성/관리/권한
└── DdokApplication # Spring Boot 메인 클래스
```

<br />

---

## 🏃‍➡️ 실행
```bash
./start-dev.sh
```
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
implementation 'co.elastic.clients:elasticsearch-java:8.11.0'

// Spring Batch
implementation "org.springframework.boot:spring-boot-starter-batch"

implementation 'org.json:json:20231013'

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package goorm.ddok.evaluation.batch;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.Set;

@Slf4j
@Component
@RequiredArgsConstructor
public class EvaluationCloseJobTrigger {

private final JobLauncher jobLauncher;
private final Job evaluationCloseJob;
private final JobExplorer jobExplorer;

@EventListener(ApplicationReadyEvent.class)
public void runOnceAtStartup() { triggerInternal("startup"); }

// 매일 자정(Asia/Seoul)
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
public void triggerDailyMidnight() { triggerInternal("daily-midnight"); }

private void triggerInternal(String reason) {
var name = evaluationCloseJob.getName();
Set<JobExecution> running = jobExplorer.findRunningJobExecutions(name);
if (!running.isEmpty()) {
log.warn("Skip {} trigger ({}): already running {}", name, reason, running.size());
return;
}
try {
JobParameters params = new JobParametersBuilder()
.addLong("ts", System.currentTimeMillis())
.addString("reason", reason)
.toJobParameters();
JobExecution exec = jobLauncher.run(evaluationCloseJob, params);
log.info("Triggered {} (reason={}) execId={}", name, reason, exec.getId());
} catch (Exception e) {
log.error("Failed to run {}", name, e);
}
}
}
159 changes: 159 additions & 0 deletions src/main/java/goorm/ddok/evaluation/batch/EvaluationCloseTasklet.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package goorm.ddok.evaluation.batch;

import goorm.ddok.evaluation.domain.EvaluationItem;
import goorm.ddok.evaluation.domain.EvaluationStatus;
import goorm.ddok.evaluation.domain.TeamEvaluation;
import goorm.ddok.evaluation.domain.TeamEvaluationScore;
import goorm.ddok.evaluation.repository.EvaluationItemRepository;
import goorm.ddok.evaluation.repository.TeamEvaluationRepository;
import goorm.ddok.evaluation.repository.TeamEvaluationScoreRepository;
import goorm.ddok.member.domain.User;
import goorm.ddok.member.repository.UserRepository;
import goorm.ddok.reputation.domain.UserReputation;
import goorm.ddok.reputation.repository.UserReputationRepository;
import goorm.ddok.team.domain.TeamMember;
import goorm.ddok.team.repository.TeamMemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Component
@RequiredArgsConstructor
public class EvaluationCloseTasklet {

private final TeamEvaluationRepository evaluationRepository;
private final TeamEvaluationScoreRepository scoreRepository;
private final EvaluationItemRepository itemRepository;
private final TeamMemberRepository teamMemberRepository;
private final UserRepository userRepository;
private final UserReputationRepository userReputationRepository;

/** 완충 상수 */
private static final double C = 10.0;

/**
* 배치 트랜잭션 경계: Step 단위 트랜잭션.
* 평가 라운드별로 saveAll을 최대한 활용해 INSERT 부하를 줄임.
*/
@Transactional
public void run(Instant now) {
List<TeamEvaluation> toClose =
evaluationRepository.findAllByStatusAndClosesAtBefore(EvaluationStatus.OPEN, now);

if (toClose.isEmpty()) {
log.info("No OPEN evaluations to close.");
return;
}

List<EvaluationItem> items = itemRepository.findAll();

for (TeamEvaluation eval : toClose) {
Long teamId = eval.getTeam().getId();
List<TeamMember> members = teamMemberRepository.findByTeamId(teamId);

if (members.isEmpty()) {
eval.setStatus(EvaluationStatus.CLOSED);
evaluationRepository.save(eval);
continue;
}

// 이미 제출된 (evaluator-target) 쌍
List<TeamEvaluationScore> existingScores = scoreRepository.findByEvaluationId(eval.getId());
Set<String> existingPairs = existingScores.stream()
.map(s -> s.getEvaluatorUserId() + "-" + s.getTargetUserId())
.collect(Collectors.toSet());

// target별 고유 평가자 집합(자동입력 전)
Map<Long, Set<Long>> distinctEvaluatorsByTarget = new HashMap<>();
for (TeamEvaluationScore s : existingScores) {
distinctEvaluatorsByTarget
.computeIfAbsent(s.getTargetUserId(), k -> new HashSet<>())
.add(s.getEvaluatorUserId());
}

// 새로 삽입할 점수 버퍼
List<TeamEvaluationScore> toInsertScores = new ArrayList<>();

// 온도 갱신을 위한 캐시 (targetUserId -> rep 엔티티)
Map<Long, UserReputation> repCache = new HashMap<>();

for (TeamMember evaluator : members) {
Long evaluatorId = evaluator.getUser().getId();

for (TeamMember target : members) {
Long targetId = target.getUser().getId();
if (Objects.equals(evaluatorId, targetId)) continue;

String key = evaluatorId + "-" + targetId;
if (existingPairs.contains(key)) continue;

// 1) 자동 채움: 모든 항목 3점 -> bulk insert 대비 버퍼에 쌓기
if (!items.isEmpty()) {
for (EvaluationItem item : items) {
toInsertScores.add(TeamEvaluationScore.builder()
.evaluationId(eval.getId())
.evaluatorUserId(evaluatorId)
.targetUserId(targetId)
.itemId(item.getId())
.score(3)
.createdAt(now)
.build());
}
}

// 2) 온도 갱신 (평균 3점 → 100점 스케일 30)
double targetScore100 = 30.0;

UserReputation rep = repCache.computeIfAbsent(targetId, tid -> {
User targetUser = userRepository.findById(tid).orElse(null);
if (targetUser == null) return null;
return userReputationRepository.findByUserId(tid)
.orElseGet(() -> userReputationRepository.save(
UserReputation.builder().user(targetUser).build()
));
});
if (rep == null) continue;

// 이번 자동 입력 후 고유 평가자 수 n = 기존 + 1
Set<Long> set = distinctEvaluatorsByTarget.computeIfAbsent(targetId, k -> new HashSet<>());
boolean added = set.add(evaluatorId);
if (!added) {
// 이론상 없지만, 방어적으로 continue
continue;
}
long n = set.size();

double alpha = 1.0 / (n + C);
double current = rep.getTemperature().doubleValue();
double updated = current + alpha * (targetScore100 - current);
if (updated < 0.0) updated = 0.0;
if (updated > 100.0) updated = 100.0;

rep.applyTemperature(BigDecimal.valueOf(updated).setScale(1, RoundingMode.HALF_UP));
}
}

// 벌크 저장
if (!toInsertScores.isEmpty()) {
scoreRepository.saveAll(toInsertScores);
}
if (!repCache.isEmpty()) {
userReputationRepository.saveAll(repCache.values());
}

eval.setStatus(EvaluationStatus.CLOSED);
evaluationRepository.save(eval);

log.info("CLOSED evaluationId={} insertedScores={} updatedReps={}",
eval.getId(), toInsertScores.size(), repCache.size());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import goorm.ddok.team.domain.TeamMember;
import goorm.ddok.team.repository.TeamMemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -26,6 +27,7 @@

@Component
@RequiredArgsConstructor
@ConditionalOnProperty(value = "ddok.legacy.evaluation.scheduler.enabled", havingValue = "true", matchIfMissing = false)
public class EvaluationScheduler {

private final TeamEvaluationRepository evaluationRepository;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package goorm.ddok.global.config;

import goorm.ddok.evaluation.batch.EvaluationCloseTasklet;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

import java.time.Instant;

@Slf4j
@Configuration
@RequiredArgsConstructor
public class EvaluationCloseJobConfig {

private final EvaluationCloseTasklet evaluationCloseTasklet;

@Bean
public Job evaluationCloseJob(JobRepository jobRepository, Step evaluationCloseStep) {
return new JobBuilder("evaluationCloseJob", jobRepository)
.incrementer(new RunIdIncrementer())
.start(evaluationCloseStep)
.build();
}

@Bean
public Step evaluationCloseStep(JobRepository jobRepository,
PlatformTransactionManager tx) {
return new StepBuilder("evaluationCloseStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
evaluationCloseTasklet.run(Instant.now());
return org.springframework.batch.repeat.RepeatStatus.FINISHED;
}, tx)
.build();
}
}
Loading