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
2 changes: 1 addition & 1 deletion infra/monitoring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| 감시 Lambda | `lambdas/` | 버지니아 `slo-calculator`(Rule Flag·버짓·이상 탐지) · `resource-monitor`(ECS·RES·RDS·백업), 오리건·버지니아 `edge-probe` |
| 경보 | `alerting/catalog.py` → `p0_p1.py` | 카탈로그 ID 그대로. P0 = SLO-01/02·API-01·EDGE-01·NATIVE-01/02·RES-01~04·DB-10/11·OBS-04. 1분 주기 평가, P0 는 충족 후 1~2분 안에 호출(10/15 리뷰) |
| 전달 | `alerting/routing.py` · `slack_channels.py` · `p0_mention.py` · `slack_fallback.py` · `security_events.py` | 서울·버지니아 토픽 → Amazon Q Slack, P0 멘션, 전달 실패 → 이메일, 보안 이벤트 |
| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup-<env>-ops`(OPS-1~6) · `ruleup-<env>-observe`(OBS-1~4) · `ruleup-prod-business`(①~④) · `ruleup-stg-business`(④ 회원 구성만, `business_dashboard.py stg`) |
| 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup-<env>-ops`(OPS-1~6) · `ruleup-<env>-observe`(OBS-1~4) · `ruleup-prod-business`(①~④, prod 전용) |
| 배포 보호 | `.github/scripts/monitoring_preflight.py` | ALARM·stale·EligibleCount 누락·계측 불일치·BudgetFreeze (기본 warn) |
| 재무장 | `lambdas/rearm.py` | 저트래픽 예외 — 독립 정상 증거가 있을 때만 P0 Flag=0 1회 + 감사 로그 |

Expand Down
13 changes: 4 additions & 9 deletions infra/monitoring/alerting/business_dashboard.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
"""사용자 행동 지표 대시보드 ruleup-<env>-business. 노션 「RuleUp 모니터링」 2절 — 서버 지표(ruleup-prod-ops)와 분리해서 본다.
"""사용자 행동 지표 대시보드 ruleup-prod-business. 노션 「RuleUp 모니터링」 2절 — 서버 지표(ruleup-prod-ops)와 분리해서 본다.
지표 원천은 앱 BusinessMetrics·VerificationRateMetrics(biz.*). 경보는 걸지 않는다 — 추세와 자료용 화면이다. 재실행 안전.
prod 전용 — stg 의 가입·로그인 수는 QA 가 만든 값이라 추세로 볼 의미가 없어 stg 는 biz.* 를 내보내지도 않는다(CloudWatchMetricsConfig).
예외는 ④ 회원 구성(biz.members.*) — prod 반영 전 확인용으로 stg 도 내보내므로, stg 대시보드에는 ④ 만 그린다.
④ 회원 구성(biz.members.*)도 같다 — prod 반영 전 확인용으로 stg 에서도 내보내던 예외는 지웠다(#356).

네 묶음: ① 로그인·가입 ② 챌린지 참여(초안·생성·가입) ③ 인증(자동 vs 수동)
④ 회원 구성(나잇대·성별·소셜·관심사·동의·활동·방 참여 경로·참여 방 수 순위).
묶음마다 첫 줄은 「대시보드에서 고른 기간 전체」의 합계·비율(숫자 칸) — 발표·보고 자료에 그대로 옮긴다. 그 아래가 1시간 단위 추세.
④ 는 게이지라 합계가 아니라 「지금 값」이고, 분포 칸(막대·원)도 가장 최근 값을 그린다.
사용: python business_dashboard.py [prod|stg] (기본 prod)"""
사용: python business_dashboard.py [prod] (prod 만 받는다)"""
import boto3, json, sys
R='ap-northeast-2'; ENV=sys.argv[1] if len(sys.argv)>1 else 'prod'
assert ENV in ('prod','stg'), ENV
assert ENV=='prod', f'{ENV}: prod 전용 — stg 는 biz.* 를 내보내지 않는다'
NS=f'RuleUp/App/{ENV}'
cw=boto3.client('cloudwatch',region_name=R)
widgets=[]; y=0
Expand Down Expand Up @@ -90,11 +90,6 @@ def rooms_section(g, latest):
y+=8
row(('참여 경로 추이',[p(k,label=l) for k,l in paths],None))

if ENV=='stg':
members_section()
cw.put_dashboard(DashboardName=f'ruleup-{ENV}-business',DashboardBody=json.dumps({'widgets':widgets},ensure_ascii=False))
print('ok', len(widgets)); sys.exit(0)

# ---------------------------------------------------------------- ① 로그인 · 가입
text(f'## ① 로그인 · 가입 ({ENV})')
totals(('기간 합계',[m('biz.signup.count','result','success',label='가입 완료'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,21 @@ List<UserAgreementState> findByUserIdInAndAgreementType(Collection<UUID> userIds
* <p>동의한 적 없는 사람({@code agreed=false} · 행 없음)은 제외한다. 그들에게 「약관이
* 개정됐어요」는 틀린 문장이고, 필수 약관 미동의는 개정과 무관하게 게이트가 이미 막고 있다.
*
* <p>오래 동의한 순으로 주는 이유는 상한에 걸렸을 때 <b>가장 오래된 버전부터</b> 처리하기
* 위해서다 — 같은 대상을 매일 다시 긁지 않는다.
* <p><b>{@code userId} 커서로 한 쪽씩</b> 준다. 고지를 받아도 재동의하기 전까지는 계속 대상이라,
* 앞에서부터 상한만큼 자르면 매일 같은 사람만 다시 읽고 뒤쪽은 영영 닿지 않는다. 커서는 직전 쪽의
* 마지막 {@code userId} 이고 첫 쪽은 null 이다. PK 선두가 {@code user_id} 라 정렬도 PK 를 탄다.
*/
@Query("""
select s from UserAgreementState s
where s.agreementType = :type
and s.agreed = true
and s.version <> :currentVersion
order by s.agreedAt asc
and (:after is null or s.userId > :after)
order by s.userId asc
""")
List<UserAgreementState> findOutdated(@Param("type") AgreementType type,
@Param("currentVersion") String currentVersion,
@Param("after") UUID after,
Limit limit);

long countByUserId(UUID userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,8 @@ public ApiResponse<TokenResponse> refresh(@RequestBody RefreshRequest request) {
@Operation(
summary = "로그아웃",
description = """
현재 기기의 refreshToken 을 폐기한다. accessToken 은 남은 수명 동안 유효하므로 클라이언트가 함께 지운다.
현재 기기의 세션을 끊는다. 보낸 refreshToken 과 같은 로그인 세션에서 발급된 refreshToken 을 모두 폐기한다
(갱신 경합으로 함께 발급된 토큰 포함). accessToken 은 남은 수명 동안 유효하므로 클라이언트가 함께 지운다.

**멱등**하다. 이미 로그아웃했거나 존재하지 않는 refreshToken 을 보내도 200 으로 성공 처리한다
(로그아웃이 실패해 로그인 상태에 갇히는 편이 더 나쁘다).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,41 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID
/**
* 회전 대상 RT를 배타 잠금으로 읽는다.
* 같은 RT가 동시에 제출돼도 한 요청만 활성 상태를 볼 수 있어야 한다.
* 후발 요청은 선발 트랜잭션 커밋 뒤 revoked 상태를 읽고 재사용 감지 경로로 간다.
* 후발 요청은 선발 트랜잭션 커밋 뒤 revoked 상태를 읽고 회전 유예·재사용 감지 경로로 간다.
* {@link #lockActiveFamily} 를 먼저 잡은 뒤에 부른다.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from RefreshToken t where t.tokenHash = :tokenHash")
Optional<RefreshToken> findByTokenHashForUpdate(@Param("tokenHash") byte[] tokenHash);

/** 이 토큰을 회전해 발급된 후속 토큰들 — 회전 유예 판단용(parent_token_id 인덱스). */
List<RefreshToken> findByParentTokenId(UUID parentTokenId);
/** 잠금 없이 family(=로그인 세션) ID만 읽는다 — family 잠금을 잡기 전 단계. family_id 는 바뀌지 않는다. */
@Query("select t.familyId from RefreshToken t where t.tokenHash = :tokenHash")
Optional<UUID> findFamilyIdByTokenHash(@Param("tokenHash") byte[] tokenHash);

/**
* family 의 살아 있는 RT 전체를 배타 잠금 — 같은 세션의 갱신·로그아웃을 직렬화한다
* (idx_refresh_tokens_family_active 범위 잠금이라 잠금 중엔 이 family 에 새 RT도 끼어들지 못한다).
* 회전 유예로 같은 세션에 RT가 여럿 살아 있을 수 있어 제출된 토큰 한 행만 잠가서는 부족하다.
* 갱신·로그아웃 모두 <b>개별 토큰보다 이 잠금을 먼저</b> 잡아야 서로 교착하지 않는다.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from RefreshToken t where t.familyId = :familyId and t.revokedAt is null")
List<RefreshToken> lockActiveFamily(@Param("familyId") UUID familyId);

/**
* 이 토큰을 회전해 발급된 후속 토큰들 — 회전 유예 판단용(parent_token_id 인덱스).
* 잠금 읽기로 최신 상태를 본다. 일반 읽기는 family 잠금 전 스냅샷이라 그 사이 로그아웃이 폐기한 후속을 살아 있다고 볼 수 있다.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from RefreshToken t where t.parentTokenId = :parentTokenId")
List<RefreshToken> findByParentTokenIdForUpdate(@Param("parentTokenId") UUID parentTokenId);

/** 사용자의 활성 RT 전체 revoke — 로그아웃 전체·신규 기기 로그인·탈퇴·정지 시. */
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update RefreshToken t set t.revokedAt = :now where t.user.id = :userId and t.revokedAt is null")
int revokeAllByUserId(@Param("userId") UUID userId, @Param("now") Instant now);

/** 재사용 감지 시 해당 family의 활성 RT 전체 revoke (탈취 대응). */
/** 해당 family의 활성 RT 전체 revoke — 재사용 감지(탈취 대응)·로그아웃 시. */
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update RefreshToken t set t.revokedAt = :now where t.familyId = :familyId and t.revokedAt is null")
int revokeFamily(@Param("familyId") UUID familyId, @Param("now") Instant now);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,9 +618,13 @@ public TokenResponse refresh(String refreshTokenValue) {
Claims claims = parseRefreshToken(refreshTokenValue);
UUID userId = UUID.fromString(claims.getSubject());

// 같은 RT의 동시 회전을 직렬화한다. 선발 요청이 revoke+새 RT 발급을 커밋한 뒤
// 후발 요청은 revoked=true를 읽어 재사용으로 처리한다.
RefreshToken stored = refreshTokenRepository.findByTokenHashForUpdate(TokenService.sha256(refreshTokenValue))
// 같은 세션(family)의 갱신·로그아웃을 직렬화한다. 선발 요청이 revoke+새 RT 발급을 커밋한 뒤
// 후발 요청은 revoked=true를 읽어 회전 유예·재사용 감지로 간다. 로그아웃이 먼저 끝났으면 유예도 받지 못한다.
byte[] tokenHash = TokenService.sha256(refreshTokenValue);
UUID familyId = refreshTokenRepository.findFamilyIdByTokenHash(tokenHash)
.orElseThrow(() -> new BusinessException(ErrorCode.SESSION_EXPIRED));
refreshTokenRepository.lockActiveFamily(familyId);
RefreshToken stored = refreshTokenRepository.findByTokenHashForUpdate(tokenHash)
.orElseThrow(() -> new BusinessException(ErrorCode.SESSION_EXPIRED));

// 회전 유예 — 방금 회전된 토큰이 몇 초 뒤 같은 기기에서 다시 오는 것은 탈취가 아니라 앱의 갱신 경합이다
Expand Down Expand Up @@ -665,15 +669,23 @@ public TokenResponse refresh(String refreshTokenValue) {
private boolean inRotationGrace(RefreshToken stored) {
if (stored.getReuseDetectedAt() != null || stored.isExpired()) return false;
if (stored.getRevokedAt().isBefore(Instant.now().minus(ROTATION_GRACE))) return false;
List<RefreshToken> successors = refreshTokenRepository.findByParentTokenId(stored.getId());
List<RefreshToken> successors = refreshTokenRepository.findByParentTokenIdForUpdate(stored.getId());
return !successors.isEmpty() && successors.stream().noneMatch(RefreshToken::isRevoked);
}

// ===== 로그아웃 (멱등) =====
/**
* 제출된 RT가 속한 세션(family)의 살아 있는 RT를 모두 폐기한다. 회전 유예로 같은 세션에 RT가 여럿 생길 수 있어
* 받은 토큰 하나만 폐기하면 다른 쪽으로 계속 갱신된다(#376). 다른 기기·다른 로그인 세션은 family 가 달라 그대로다.
* 갱신과 같은 family 잠금을 잡으므로 진행 중인 갱신이 만든 RT까지 함께 폐기된다.
*/
@Transactional
public void logout(String refreshTokenValue) {
refreshTokenRepository.findByTokenHash(TokenService.sha256(refreshTokenValue))
.ifPresent(RefreshToken::revoke); // 없거나 이미 무효여도 성공
refreshTokenRepository.findFamilyIdByTokenHash(TokenService.sha256(refreshTokenValue))
.ifPresent(familyId -> { // 없거나 이미 무효여도 성공
refreshTokenRepository.lockActiveFamily(familyId);
refreshTokenRepository.revokeFamily(familyId, Instant.now());
});
}

// ===== 닉네임 형식/중복 검사 (스펙 4.6) =====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,10 +390,10 @@ private ParamsResult validateParams(RoutineTemplate template,
return new ParamsResult(values, specs);
}

/** 이미지: 업로드 API 가 발급한 본인 소유 URL 만 허용. */
/** 이미지: 업로드 API 가 발급한 본인 소유 URL 만 허용. 행을 잠가 미등록 정리와 줄을 세운다(#381). */
private ChallengeImageUpload validateImage(UUID userId, String imageUrl) {
if (imageUrl == null || imageUrl.isBlank()) return null;
ChallengeImageUpload upload = imageUploadRepository.findByImageUrl(imageUrl)
ChallengeImageUpload upload = imageUploadRepository.findByImageUrlForUpdate(imageUrl)
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_IMAGE_URL));
if (!upload.ownedBy(userId)) throw new BusinessException(ErrorCode.IMAGE_NOT_OWNED);
return upload;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
package com.ruleup.ruleup_backend.challenge.creation;

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

/** challenge_image_uploads 접근. */
public interface ChallengeImageUploadRepository extends JpaRepository<ChallengeImageUpload, Long> {

Optional<ChallengeImageUpload> findByImageUrl(String imageUrl);
/**
* 등록 경로 전용 — 그 행을 잡고 읽는다({@code SELECT ... FOR UPDATE}).
*
* <p>미등록 이미지 정리도 같은 행을 잡은 채 파일을 지우므로 둘이 한 줄로 선다. 정리가 먼저면
* 등록은 행이 사라진 뒤에 읽어 「없는 이미지」로 거절되고, 등록이 먼저면 정리는 등록된 행을
* 보고 건너뛴다. 잠그지 않고 읽으면 정리가 파일을 지우는 사이 등록이 끼어들어 사라진 이미지를
* 가리키는 방이 생긴다(#381).
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM ChallengeImageUpload u WHERE u.imageUrl = :imageUrl")
Optional<ChallengeImageUpload> findByImageUrlForUpdate(@Param("imageUrl") String imageUrl);
}
Loading
Loading