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
a55d84e
[fix] 리프레쉬 토큰 갱신 로직 개선
KiSeungMin Mar 15, 2026
cab1c58
[fix] 오답노트 수정 시, 이미지 분석을 진행하지 않도록 수정
KiSeungMin Mar 22, 2026
0604898
[Fix] 리프레쉬 토큰 갱신 로직 개선
KiSeungMin Mar 22, 2026
c0a25d0
[feat] 태그 생성 api 구현
KiSeungMin Mar 22, 2026
caac624
[feat] 태그 조회 api 구현
KiSeungMin Mar 22, 2026
4e39aaf
[feat] 태그 삭제 api 구현
KiSeungMin Mar 22, 2026
29a6592
[feat] 오답노트 등록 시, 태그를 추가할 수 있는 api 구현
KiSeungMin Mar 22, 2026
bbd315d
[feat] 오답노트 응답에 태그 추가
KiSeungMin Mar 22, 2026
42a0b9a
[feat] 오답노트 등록 시 태그도 함께 등록되도록 개선
KiSeungMin Mar 22, 2026
7158f87
[feat] 오답노트 삭제 시 태그도 함께 삭제되도록 개선
KiSeungMin Mar 22, 2026
1ac172f
[fix] 토큰 갱신 로직 개선
KiSeungMin Mar 22, 2026
23221a9
[fix] 태그 여러 개 삭제 기능 구현
KiSeungMin Mar 22, 2026
15bc886
[fix] 태그 기반 오답노트 조회 api 구현
KiSeungMin Mar 22, 2026
dc25c38
[feat] 검색 텍스트 기반 오답노트 조회 api 구현
KiSeungMin Mar 22, 2026
0b87367
[feat] 최근 사용한 태그 목록 조회 api 구현
KiSeungMin Mar 23, 2026
d8f7207
[Feat] 오답노트 태그, 검색 기능 구현
KiSeungMin Mar 23, 2026
5e6aa73
[fix] CI/CD 키체인 충돌 문제 해결
KiSeungMin Mar 23, 2026
49f23a1
[fix] CI/CD 키체인 충돌 문제 해결
KiSeungMin Mar 23, 2026
3ad81d8
[fix] ci/cd 동작 테스트
KiSeungMin Mar 23, 2026
f002621
[fix] 키체인 저장 방식 수정
KiSeungMin Mar 23, 2026
8335c2c
[fix] mysql-exporter 오류 문제 해결
KiSeungMin Mar 23, 2026
14932b9
[fix] mysql-exporter 버전 최신화
KiSeungMin Mar 23, 2026
e4e1d74
[fix] mysql-exporter를 명시 플래그 방식으로 변경
KiSeungMin Mar 23, 2026
7e7d89a
[test] ci/cd 문제 해결 완료
KiSeungMin Mar 23, 2026
79906b5
[Fix] CICD 키체인 충돌 문제 해결
KiSeungMin Mar 23, 2026
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
18 changes: 13 additions & 5 deletions .github/workflows/ci-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,25 @@ jobs:

- name: Deploy with Docker Compose
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
export DOCKER_CONFIG=/tmp/.docker-ci
mkdir -p "$DOCKER_CONFIG"
DOCKER="docker --config $DOCKER_CONFIG"
AUTH_B64=$(printf '%s:%s' "${{ secrets.DOCKER_USERNAME }}" "${{ secrets.DOCKER_PASSWORD }}" | base64 | tr -d '\n')
cat > "$DOCKER_CONFIG/config.json" << EOF
{"auths":{"https://index.docker.io/v1/":{"auth":"$AUTH_B64"}}}
EOF

docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG}
docker tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest
$DOCKER pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG}
$DOCKER tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest

docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d mysql-dev redis-dev

docker-compose -f docker-compose.dev.yml --env-file .env.dev stop app-dev || true
docker-compose -f docker-compose.dev.yml --env-file .env.dev rm -f app-dev || true
docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d app-dev

docker inspect ono-app-dev --format='Running Image: {{.Config.Image}}'
docker inspect ono-app-dev --format='Image ID: {{.Image}}'
$DOCKER inspect ono-app-dev --format='Running Image: {{.Config.Image}}'
$DOCKER inspect ono-app-dev --format='Image ID: {{.Image}}'

echo "Waiting for development application to be healthy..."
timeout=60
Expand All @@ -152,6 +158,8 @@ jobs:

- name: Deploy Monitoring Stack
run: |
docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring pull \
mysql-exporter-dev prometheus-dev grafana-dev
docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring stop prometheus-dev grafana-dev mysql-exporter-dev || true
docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring rm -f prometheus-dev grafana-dev mysql-exporter-dev || true
docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring up -d --force-recreate \
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/ci-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,16 @@ jobs:

- name: Blue-Green Deployment
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
export DOCKER_CONFIG=/tmp/.docker-ci
mkdir -p "$DOCKER_CONFIG"
DOCKER="docker --config $DOCKER_CONFIG"
AUTH_B64=$(printf '%s:%s' "${{ secrets.DOCKER_USERNAME }}" "${{ secrets.DOCKER_PASSWORD }}" | base64 | tr -d '\n')
cat > "$DOCKER_CONFIG/config.json" << EOF
{"auths":{"https://index.docker.io/v1/":{"auth":"$AUTH_B64"}}}
EOF

docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG}
docker tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest
$DOCKER pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG}
$DOCKER tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest

docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d mysql-prod redis-prod

Expand Down Expand Up @@ -212,6 +218,8 @@ jobs:

- name: Deploy Monitoring Stack
run: |
docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring pull \
mysql-exporter-prod prometheus-prod grafana-prod
docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring stop prometheus-prod grafana-prod mysql-exporter-prod || true
docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring rm -f prometheus-prod grafana-prod mysql-exporter-prod || true
docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring up -d --force-recreate \
Expand Down
16 changes: 11 additions & 5 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,25 @@ jobs:

- name: Deploy with Docker Compose
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
export DOCKER_CONFIG=/tmp/.docker-ci
mkdir -p "$DOCKER_CONFIG"
DOCKER="docker --config $DOCKER_CONFIG"
AUTH_B64=$(printf '%s:%s' "${{ secrets.DOCKER_USERNAME }}" "${{ secrets.DOCKER_PASSWORD }}" | base64 | tr -d '\n')
cat > "$DOCKER_CONFIG/config.json" << EOF
{"auths":{"https://index.docker.io/v1/":{"auth":"$AUTH_B64"}}}
EOF

docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG}
docker tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest
$DOCKER pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG}
$DOCKER tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest

docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d mysql-dev redis-dev

docker-compose -f docker-compose.dev.yml --env-file .env.dev stop app-dev || true
docker-compose -f docker-compose.dev.yml --env-file .env.dev rm -f app-dev || true
docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d app-dev

docker inspect ono-app-dev --format='Running Image: {{.Config.Image}}'
docker inspect ono-app-dev --format='Image ID: {{.Image}}'
$DOCKER inspect ono-app-dev --format='Running Image: {{.Config.Image}}'
$DOCKER inspect ono-app-dev --format='Image ID: {{.Image}}'

echo "Waiting for development application to be healthy..."
timeout=60
Expand Down
14 changes: 10 additions & 4 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,16 @@ jobs:

- name: Blue-Green Deployment
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin

docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG}
docker tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest
export DOCKER_CONFIG=/tmp/.docker-ci
mkdir -p "$DOCKER_CONFIG"
DOCKER="docker --config $DOCKER_CONFIG"
AUTH_B64=$(printf '%s:%s' "${{ secrets.DOCKER_USERNAME }}" "${{ secrets.DOCKER_PASSWORD }}" | base64 | tr -d '\n')
cat > "$DOCKER_CONFIG/config.json" << EOF
{"auths":{"https://index.docker.io/v1/":{"auth":"$AUTH_B64"}}}
EOF

$DOCKER pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG}
$DOCKER tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest

docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d mysql-prod redis-prod

Expand Down
7 changes: 5 additions & 2 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,14 @@ services:
- monitoring

mysql-exporter-dev:
image: prom/mysqld-exporter:latest
image: prom/mysqld-exporter:v0.15.1
container_name: ono-mysql-exporter-dev
restart: unless-stopped
command:
- --mysqld.address=mysql-dev:3306
- --mysqld.username=root
environment:
DATA_SOURCE_NAME: root:${MYSQL_ROOT_PASSWORD}@(mysql-dev:3306)/
MYSQLD_EXPORTER_PASSWORD: ${MYSQL_ROOT_PASSWORD}
depends_on:
mysql-dev:
condition: service_healthy
Expand Down
7 changes: 5 additions & 2 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,14 @@ services:
- monitoring

mysql-exporter-prod:
image: prom/mysqld-exporter:latest
image: prom/mysqld-exporter:v0.15.1
container_name: ono-mysql-exporter-prod
restart: unless-stopped
command:
- --mysqld.address=mysql-prod:3306
- --mysqld.username=root
environment:
DATA_SOURCE_NAME: root:${MYSQL_ROOT_PASSWORD}@(mysql-prod:3306)/
MYSQLD_EXPORTER_PASSWORD: ${MYSQL_ROOT_PASSWORD}
depends_on:
mysql-prod:
condition: service_healthy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,29 @@ public TokenResponseDto generateTokens(Long userId, Authority authority) {
String accessToken = jwtTokenizer.createAccessToken(String.valueOf(userId), Map.of("authority", authority));
String refreshToken;

// DB에 기존 RefreshToken이 있으면 재사용
// DB에 기존 RefreshToken이 있으면 검증 후 재사용, 만료/무효면 재발급
RefreshToken existingRefreshToken = refreshTokenRepository.findByUserId(userId).orElse(null);
if (existingRefreshToken != null) {
refreshToken = existingRefreshToken.getRefreshToken();
log.info("Reusing existing refresh token for userId: {}", userId);
String existingTokenValue = existingRefreshToken.getRefreshToken();
try {
jwtTokenizer.validateRefreshToken(existingTokenValue);
refreshToken = existingTokenValue;
log.info("Reusing existing refresh token for userId: {}", userId);
} catch (ApplicationException e) {
refreshToken = jwtTokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority));
refreshTokenRepository.deleteByUserId(userId);
refreshTokenRepository.save(RefreshToken.from(userId, authority, refreshToken));
log.info("Existing refresh token was expired/invalid. Issued a new one for userId: {}", userId);
}
} else {
refreshToken = jwtTokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority));
RefreshToken refreshTokenEntity = RefreshToken.from(userId, authority, refreshToken);
refreshTokenRepository.save(refreshTokenEntity);
log.info("Created new refresh token for userId: {}", userId);
}

// Redis에 RefreshToken 캐싱
long expiration = jwtTokenizer.getRefreshTokenExpirationSeconds();
// Redis에 RefreshToken 캐싱 (토큰 실제 남은 만료시간 기준)
long expiration = jwtTokenizer.getRemainingRefreshExpirationTime(refreshToken);
redisTokenService.saveRefreshToken(userId, refreshToken, expiration);

log.info("userId: {} has : generate token with authority: {}", userId, authority);
Expand All @@ -55,6 +64,7 @@ public TokenResponseDto generateTokens(Long userId, Authority authority) {

/**
* ✅ 리프레시 토큰을 이용한 액세스 토큰 갱신
* - refresh token은 재사용하고 access token만 재발급
*/
public TokenResponseDto refreshAccessToken(String refreshToken) {
jwtTokenizer.validateRefreshToken(refreshToken);
Expand All @@ -71,9 +81,10 @@ public TokenResponseDto refreshAccessToken(String refreshToken) {
.orElseThrow(() -> new ApplicationException(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND));

storedToken = refreshTokenEntity.getRefreshToken();
jwtTokenizer.validateRefreshToken(storedToken);

// DB에서 찾은 토큰을 Redis에 다시 캐싱
long expiration = jwtTokenizer.getRefreshTokenExpirationSeconds();
long expiration = jwtTokenizer.getRemainingRefreshExpirationTime(storedToken);
redisTokenService.saveRefreshToken(userId, storedToken, expiration);

log.info("RefreshToken cache miss - loaded from DB for userId: {}", userId);
Expand All @@ -85,8 +96,14 @@ public TokenResponseDto refreshAccessToken(String refreshToken) {
throw new ApplicationException(AuthErrorCase.REFRESH_TOKEN_NOT_EQUAL);
}

log.info("userId: {} has : refresh access token", userId);
return generateTokens(userId, authority);
// 4. access token만 재발급 (refresh token은 만료 연장/교체하지 않음)
String newAccessToken = jwtTokenizer.createAccessToken(String.valueOf(userId), Map.of("authority", authority));

long expiration = jwtTokenizer.getRemainingRefreshExpirationTime(refreshToken);
redisTokenService.saveRefreshToken(userId, refreshToken, expiration);

log.info("userId: {} has : refresh access token (refresh token reused)", userId);
return new TokenResponseDto(newAccessToken, refreshToken);
}

/**
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ public long getRefreshTokenExpirationSeconds() {
return refreshTokenExpiration / 1000;
}

/**
* RefreshToken의 남은 만료 시간을 초 단위로 반환
*/
public long getRemainingRefreshExpirationTime(String token) {
Claims claims = getClaimsFromRefreshToken(token);
Date expiration = claims.getExpiration();
long now = System.currentTimeMillis();
return Math.max(0, (expiration.getTime() - now) / 1000);
}

/**
* AccessToken 만료 시간을 초 단위로 반환
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto;
import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto;
import com.aisip.OnO.backend.problem.dto.ProblemResponseDto;
import com.aisip.OnO.backend.problem.dto.ProblemTagUpdateDto;
import com.aisip.OnO.backend.problem.service.ProblemAnalysisService;
import com.aisip.OnO.backend.problem.service.ProblemService;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -70,6 +71,31 @@ public CommonResponse<CursorPageResponse<ProblemResponseDto>> getProblemsWithCur
return CommonResponse.success(problemService.findProblemsByFolderWithCursor(folderId, cursor, size));
}

// ✅ V2 API: 커서 기반 태그의 문제 조회 (무한 스크롤)
@GetMapping("/tag/{tagId}/V2")
public CommonResponse<CursorPageResponse<ProblemResponseDto>> getProblemsWithCursorByTag(
@PathVariable("tagId") Long tagId,
@RequestParam(value = "cursor", required = false) Long cursor,
@RequestParam(value = "size", defaultValue = "20") int size) {
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
log.info("userId: {} get problems for tagId: {} with cursor: {}, size: {}", userId, tagId, cursor, size);

return CommonResponse.success(problemService.findProblemsByTagWithCursor(tagId, userId, cursor, size));
}

// ✅ V2 API: 제목(contains) 기반 문제 조회 (무한 스크롤)
@GetMapping("/title/V2")
public CommonResponse<CursorPageResponse<ProblemResponseDto>> getProblemsWithCursorByTitle(
@RequestParam("query") String query,
@RequestParam(value = "cursor", required = false) Long cursor,
@RequestParam(value = "size", defaultValue = "20") int size) {
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
log.info("userId: {} search problems by title query: '{}' with cursor: {}, size: {}",
userId, query, cursor, size);

return CommonResponse.success(problemService.findProblemsByTitleWithCursor(query, userId, cursor, size));
}

// ✅ 사용자의 문제 개수 조회
@GetMapping("/problemCount")
public CommonResponse<Long> getUserProblemCount() {
Expand Down Expand Up @@ -157,6 +183,18 @@ public CommonResponse<String> updateProblemPath(@RequestBody ProblemRegisterDto
return CommonResponse.success("문제가 수정되었습니다.");
}

// ✅ 문제 태그 추가/해제
@PatchMapping("/{problemId}/tags")
public CommonResponse<String> updateProblemTags(
@PathVariable("problemId") Long problemId,
@RequestBody ProblemTagUpdateDto problemTagUpdateDto
) {
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
problemService.updateProblemTags(problemId, userId, problemTagUpdateDto);

return CommonResponse.success("문제 태그가 수정되었습니다.");
}

// ✅ 문제 삭제
@DeleteMapping("")
public CommonResponse<String> deleteProblems(
Expand Down Expand Up @@ -184,4 +222,4 @@ public CommonResponse<String> deleteProblemImageData(@RequestParam("imageUrl") S
return CommonResponse.success("문제 이미지 데이터 삭제가 완료되었습니다.");
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ public record ProblemRegisterDto (

Long folderId,

LocalDateTime solvedAt
) {}
LocalDateTime solvedAt,

List<Long> tagIds
) {
public ProblemRegisterDto(
Long problemId,
String memo,
String reference,
Long folderId,
LocalDateTime solvedAt
) {
this(problemId, memo, reference, folderId, solvedAt, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@ public record ProblemRegisterV2Dto(
Long folderId,
LocalDateTime solvedAt,
List<String> problemImageUrls,
List<String> answerImageUrls
List<String> answerImageUrls,
List<Long> tagIds
) {
public ProblemRegisterV2Dto(
Long problemId,
String memo,
String reference,
Long folderId,
LocalDateTime solvedAt,
List<String> problemImageUrls,
List<String> answerImageUrls
) {
this(problemId, memo, reference, folderId, solvedAt, problemImageUrls, answerImageUrls, null);
}
}

Loading
Loading