Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
a3c9a37
#250 [Fix] 투표 비율 계산 기준을 preVoteOption에서 postVoteOption으로 수정 (#252)
HYH0804 May 27, 2026
a38c141
#254 [Refactor] 관점 등록/수정 시 GPT 검수 코드 제거 (#256)
HYH0804 Jun 3, 2026
cee2c5d
#255 [Refactor] VoteStatsResponse OptionStat에 title 필드 추가 (#257)
HYH0804 Jun 3, 2026
53533b8
#258 [Feat] 출석체크 API 추가
si-zero Jun 3, 2026
77db7a1
#260 [Feat] 스웨거 어드민/유저 분리
si-zero Jun 4, 2026
a9d4293
[Feat] 알림기능 (#263)
HYH0804 Jun 11, 2026
b5a681b
#262 [Fix] APNs 자격증명 S3 다운로드 시 임시파일 충돌 오류 수정
HYH0804 Jun 11, 2026
d281fa3
#264 [Fix] AdMob SSV 포인트 미적립 버그 수정
si-zero Jun 11, 2026
968d6a3
#262 merge (#266)
HYH0804 Jun 14, 2026
d041631
[Chore] adMob 크롤링 파일 설정: adMob 크롤링 파일 설정 #269
si-zero Jun 16, 2026
ec25882
#270 [Feat] 설정 파일 추가
si-zero Jun 16, 2026
4c45999
[Chore] JWT필터 설정 #271
si-zero Jun 16, 2026
e0c9c1c
#275 [Fix] AdMob SSV 콜백 파라미터 optional 처리 및 서명 검증 활성화
si-zero Jun 18, 2026
ab2cb78
#277 [Fix] AdMob SSV 콜백 URL 검증 요청 처리 실패
si-zero Jun 18, 2026
04d947c
#279 [Fix] AdMob SSV 엔드포인트 검증 실패 시 항상 200 반환
si-zero Jun 18, 2026
d9ea3b5
#283 [Fix] /me/content-activities voteSide null 반환 버그
si-zero Jun 19, 2026
ec27b01
#285 [Feat] content-activities 응답에 option_title 필드 추가
si-zero Jun 20, 2026
6663e7a
#287 [Fix] AdMob SSV 서명 검증 오류로 인한 포인트 미지급 버그
si-zero Jun 24, 2026
39aca84
#289 [Fix] 오늘의 배틀 개수 변경
si-zero Jul 5, 2026
11e2da1
#293 [Fix] 사전투표 철학자 아이콘 미노출
si-zero Jul 5, 2026
aa449f0
#297 [Fix] 댓글 작성 시각이 9시간 전으로 표시되는 버그
si-zero Jul 6, 2026
3259a1a
#301 [Fix] robots.txt 파일 부재로 AdMob app-ads.txt 크롤링 차단
si-zero Jul 6, 2026
e91819b
#305 [Fix] S3 오디오 조각 재다운로드 시 FileAlreadyExistsException으로 TTS 파이프라인 실패
si-zero Jul 6, 2026
d64d5ca
#309 [Fix] JwtFilter 자체 화이트리스트에 /robots.txt 누락으로 401 발생
si-zero Jul 6, 2026
ff16d85
#316 [Fix] 포인트 정책표 대비 코드 불일치 4건 수정
si-zero Jul 7, 2026
f50c749
Merge branch 'main' into dev
si-zero Jul 7, 2026
d9e28a1
#321 [Feat] 알림 미읽음 여부 확인 API 추가
si-zero Jul 7, 2026
4279341
#313 [Hotfix] Railway 배포 환경 FFmpeg 자동 설치 적용 (#320)
jucheonsu Jul 7, 2026
f77048c
#327 [Fix] 알림 전체 읽음 처리 후 모두 읽음 신호가 전달되지 않던 문제 수정
si-zero Jul 13, 2026
99886f9
#331 [Docs] 출석체크 API 명세서를 실제 코드에 맞춰 갱신
si-zero Jul 13, 2026
087b8c0
#333 [Fix] app-ads.txt가 text/html로 응답되어 AdMob 크롤러가 무시하는 문제 수정
si-zero Jul 13, 2026
d33ff1f
#337 [Feat] 인앱 알림 테스트용 API 추가
si-zero Jul 14, 2026
17f35a4
#341 [Fix] 미문서화/중복 포인트 지급 로직 정리
si-zero Jul 15, 2026
acbe078
관리자 페이지를 새 admin-web(Next.js) 앱으로 리다이렉트 (#346)
Jul 15, 2026
de75bdf
#349 [Feat] admin.web.url env 주입 + 기본값 picke.up.railway.app (#350)
Jul 15, 2026
9493351
#352 [Fix] JwtFilter 화이트리스트 /api/v1/auth 전체 매칭으로 logout 인증 우회, 500 에러 발생
si-zero Jul 16, 2026
00bfcb3
#356 [Feat] 이용약관/개인정보처리방침 페이지 백엔드 서빙
si-zero Jul 20, 2026
99c936d
#361 [Feat] 관리자용 테스트 계정 로컬 로그인 기능 추가
si-zero Jul 24, 2026
f0a837d
#360 [Refactor] 1인 1관점 제한을 해제하고 1인 다수 관점 등록 허용
si-zero Jul 24, 2026
9fb601f
#366 [Feat] 내 콘텐츠 활동 내역(댓글 탭)에 내가 작성한 관점 포함
si-zero Jul 24, 2026
6a1abde
#370 [Fix] 사전투표만 한 기록도 내 배틀 기록에 노출되는 버그
si-zero Jul 24, 2026
80f5b9c
[Feat] 관점 200자 제한 해제 (#375)
si-zero Aug 2, 2026
f9bc278
#378 [Feat] 관리자 예약 푸시알림(제목/소제목/발송시간) 설정 및 자동발송
si-zero Aug 8, 2026
99f4765
#382 [Fix] 내 콘텐츠 활동 댓글 정보 매핑 오류 수정 (#383)
jucheonsu Aug 9, 2026
79cb355
#387 [Refactor] 철학자 태그 제한 및 가치관 육각형 계산 보정 (#388)
jucheonsu Aug 13, 2026
bdbe3b3
#391 [Remove] 미사용 파일 정리 (비활성 CI 워크플로우 + 레거시 admin 프론트)
si-zero Aug 13, 2026
a82c575
#392 [CI/CD] Java CI 파이프라인 브랜치명 오류 및 지속적 빌드 실패 수정
si-zero Aug 13, 2026
70ccc90
#395 [Feat] 관리자용 푸시 알림 발송 테스트 API 추가
si-zero Aug 16, 2026
2e72bf7
#396 [Fix] Android FCM 푸시에 title/body가 실리지 않아 알림이 안 뜨던 문제 수정
si-zero Aug 16, 2026
1fc8488
#401 [Fix] FCM 만료된 디바이스 토큰이 정리되지 않아 반복 발송 실패 로그가 쌓이는 문제 수정
si-zero Aug 17, 2026
a4d3b31
#405 [Feat] 어드민 예약 알림 On/Off 전환 API 추가 및 관리자 알림 API 문서화
si-zero Aug 26, 2026
0b9a374
#407 [Feat] 어드민 유저 검색 API 추가
si-zero Aug 26, 2026
869de77
#409 [Feat] 어드민 공지사항 수정/삭제 API 추가
si-zero Aug 26, 2026
087fea5
#411 [Feat] 어드민 공지 발송 결과 조회 API 추가
si-zero Aug 26, 2026
4bdbdf3
#413 [Feat] 어드민 공지 발송 대상자 수 미리보기 API 추가
si-zero Aug 26, 2026
76e8097
#414 [Feat] 어드민 공지 작성 옵션(카테고리) 조회 API 추가
si-zero Aug 26, 2026
26116e8
#415 [Feat] 어드민 예약 알림 테스트 발송 API 추가
si-zero Aug 26, 2026
4234c29
#423 [Docs] 누락된 마이그레이션 SQL 파일 보완 (#409, #411)
si-zero Aug 30, 2026
7aaa1f4
#421 [Feat] 유저 일별 활동 트래킹(DAU/MAU 기반) 인프라 구축
si-zero Aug 30, 2026
d3b9343
#425 [Feat] 어드민 대시보드 오늘 요약 API 추가
si-zero Sep 1, 2026
2129d35
#427 [Feat] 어드민 대시보드 DAU/MAU 추이 API 추가
si-zero Sep 1, 2026
d8fe672
#429 [Feat] 어드민 대시보드 신규 가입자 추이 API 추가
si-zero Sep 1, 2026
6eea0d4
#431 [Feat] 어드민 대시보드 배틀 참여율 API 추가
si-zero Sep 1, 2026
1e134bc
#433 [Feat] 어드민 대시보드 출석 체크율 API 추가
si-zero Sep 1, 2026
cba91cb
#436 [Feat] 어드민 대시보드 크레딧 지급 현황 API 추가
si-zero Sep 2, 2026
23496d1
#444 [Refactor] AWS S3를 Railway Bucket으로 마이그레이션 (개발 서버)
si-zero Sep 7, 2026
7b0b68f
🐛 Fix: BattleVoteServiceImplTest 시간대 불일치로 15시(UTC) 이후 CI 실패 (#442)
Roy-wonji Sep 7, 2026
c99065e
✨ Feat: ad.picke.store 제휴 광고(쿠팡 파트너스·애드픽) 연동 (#443)
Roy-wonji Sep 8, 2026
d22f21a
#441 [Fix] 쿠팡 파트너스 ID 환경변수 누락 시 앱 부팅 실패 해소 (#448)
Roy-wonji Sep 8, 2026
9745f2e
#441 [Fix] 광고 스웨거 기본 서버를 문서를 연 주소로 바꿈 (#449)
Roy-wonji Sep 8, 2026
e5a0422
#441 [Fix] 필수 쿼리 파라미터 누락 시 500 대신 400 응답 (#450)
Roy-wonji Sep 8, 2026
c3bd52b
#441 [Feat] 광고 스웨거 탭에 운영 광고 도메인 추가 (#451)
Roy-wonji Sep 8, 2026
476b0dd
#441 [Feat] 애드픽 쇼핑·핫딜 상품을 광고 재고로 편입 (#454)
Roy-wonji Sep 8, 2026
495f772
#441 [Feat] 애드픽 쇼핑 상품에 더 높은 로테이션 가중치 부여 (#457)
Roy-wonji Sep 9, 2026
093278c
AdFit 수익과 비용을 기록해 관리자 ROI를 확인하도록 지원
Roy-wonji Sep 10, 2026
73a4214
test: 사용자 광고 API 조회 계약 검증 (#460)
Roy-wonji Sep 10, 2026
27e7b79
fix: 사용자 광고에서 설치·가입 캠페인 제외 (#463)
Roy-wonji Sep 10, 2026
a612828
feat: AdFit 일별 수익 자동 조회 연결 (#465)
Roy-wonji Sep 10, 2026
6891d33
#455 [Feat] 배틀 시나리오 감정 톤 + 철학자 보이스 매핑 인프라
si-zero Sep 10, 2026
291918c
#467 [Feat] 배틀 대본 붙여넣기 파서 (배틀·시나리오 자동 등록)
si-zero Sep 11, 2026
3ed96b8
#469 [Feat] 배틀 대본 파서 대사를 문장 종결부호 단위로 분리
si-zero Sep 11, 2026
8efa2ad
#471 [Feat] 배틀 대본 파서 summary·description·선택지 title 매핑 수정
si-zero Sep 11, 2026
073dfd5
#473 [Feat] 시나리오 선택지에 선택의 시간 설명 텍스트(title) 추가
si-zero Sep 11, 2026
58ce7ad
#475 [Feat] 철학자 보이스 매핑에 이미지 키(image_key) 추가
si-zero Sep 12, 2026
759f746
#477 [Docs] 철학자 보이스 매핑에 실제 이미지 키 반영
si-zero Sep 12, 2026
7a41a14
#479 [Fix] 철학자 보이스 imageKey 응답을 호출 가능한 경로로 변환
si-zero Sep 12, 2026
4cf5227
#481 [Fix] 배틀 옵션 태그 수정 시 유니크 제약 위반(23505) 발생
si-zero Sep 12, 2026
a7b0c3b
#483 [Fix] 로컬 draft→S3 promote가 DB 트랜잭션과 분리되어 orphan 파일/유실 위험
si-zero Sep 12, 2026
e3e5bd8
feat: 애드픽 쇼핑 재고 확대 + AdFit 세션 갱신 + Mixpanel·Sentry 지표 (#487)
Roy-wonji Sep 14, 2026
d6566c3
개발 서버도 운영과 같은 관리자 지표 계약을 제공하게 한다
Roy-wonji Sep 14, 2026
9ef0aa0
feat: Sentry 전체 관측 데이터와 가입 추이 개발 반영 (#492)
Roy-wonji Sep 14, 2026
df9acca
[Fix] Sentry trace metric 시계열 집계 오류 수정 (#494)
Roy-wonji Sep 14, 2026
88661ac
feat: Sentry 회원가입 지표를 일별 시계열로 제공 (#496)
Roy-wonji Sep 14, 2026
1e01dde
feat: Sentry 사용자 이벤트 전체 집계 (#498)
Roy-wonji Sep 14, 2026
8935a92
fix: Sentry 이벤트 전체 조회 복구 (#501)
Roy-wonji Sep 14, 2026
724c75e
fix: Sentry 회원가입 사용자 결측값 유지 (#503)
Roy-wonji Sep 14, 2026
e1fa37d
fix: Sentry 이벤트 배치 누락 제거 (#505)
Roy-wonji Sep 14, 2026
9d43c11
feat: Mixpanel 이벤트 퍼널 이탈율 조회 (#507)
Roy-wonji Sep 28, 2026
abeca8e
Merge remote-tracking branch 'origin/main' into merge/#507
Roy-wonji Sep 28, 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
11 changes: 11 additions & 0 deletions docs/api-specs/admin-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@
- `properties.time`은 프로젝트 타임존 기준 epoch 초이고 `from_date`·`to_date` 경계도 같은 타임존을 따른다. 둘을 같은 타임존으로 묶어야 Mixpanel 화면 숫자와 맞는다. `picke.analytics.mixpanel.project-zone`(기본 `UTC`)로 맞춘다. 이 프로젝트는 UTC로 실측 확인했다.
- 경계 하루가 타임존 차이로 걸쳐 들어올 수 있어 요청 기간 밖 이벤트는 버린다.

### 퍼널 (이탈율)

- `GET /api/v1/admin/analytics/mixpanel/funnel?from&to&steps=A,B,C&windowDays=7`: ADMIN 전용.
- 고유 사용자 기준이다. 한 사람이 같은 단계를 여러 번 밟아도 한 번으로 센다.
- 다음 단계는 앞 단계 **이후**여야 하고, 첫 단계로부터 `windowDays` 안에 끝나야 한다. Mixpanel 화면의 Unique Conversion·전환 윈도우와 같은 규칙이다.
- 단계는 이벤트 이름만 쓰거나 `event:property=value`로 속성까지 좁힌다. 배틀 흐름은 선택·재생·투표가 모두 `battle_step` 한 이벤트로 들어오고 `step_name`으로만 갈리므로 속성을 좁히지 않으면 한 단계로 뭉친다.
- 단계는 2~8개, `windowDays`는 1~30. 원본 이벤트를 받으므로 기간은 31일까지.
- 분모가 0이면 전환율은 0%가 아니라 `null`이다. 아무도 진입하지 않은 것과 전환 실패를 구분한다.
- 검증: 2026-08-01~08-31 `battle_step → screen_view → community_action` 7일 윈도우로 24 / 24 / 2 (8.33%) — Mixpanel 콘솔 퍼널 화면 숫자와 일치한다.
- 실측 MVP 퍼널(같은 기간, `battle_step:step_name=pre_vote → audio_end → post_vote → community_action`): 24 → 14 → 11 → 1. 사후 투표에서 댓글 참여로 넘어갈 때 90.9%가 이탈한다.

### 왜 집계 API를 안 쓰는가

- **현재 Picke의 Mixpanel 플랜은 Query API를 허용하지 않는다.** 2026-09-14 실측: `/api/query/segmentation`·`/api/query/insights` 모두 `HTTP 402 Your plan does not allow API calls`. 인증은 통과하므로 자격 문제가 아니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
public class AdminAnalyticsController {

private static final long MAX_DAYS = 366;
private static final int MAX_FUNNEL_STEPS = 8;
private static final int MAX_FUNNEL_WINDOW_DAYS = 30;

private final MixpanelClient mixpanelClient;
private final SentryClient sentryClient;
Expand All @@ -37,6 +39,20 @@ public ApiResponse<MixpanelEventReport> mixpanel(
return ApiResponse.onSuccess(mixpanelClient.fetchDailyCounts(events, from, to));
}

@GetMapping("/mixpanel/funnel")
@Operation(summary = "Mixpanel 이벤트 퍼널 이탈율",
description = "steps 순서대로 밟은 고유 사용자를 센다. 다음 단계는 앞 단계 이후여야 하고 첫 단계로부터 windowDays 안에 끝나야 한다")
public ApiResponse<MixpanelFunnelReport> mixpanelFunnel(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
@RequestParam List<String> steps,
@RequestParam(defaultValue = "7") int windowDays) {
validateRange(from, to, MixpanelClient.MAX_DAYS);
validateSteps(steps);
validateWindow(windowDays);
return ApiResponse.onSuccess(mixpanelClient.fetchFunnel(steps, windowDays, from, to));
}

@GetMapping("/sentry")
@Operation(summary = "Sentry 사용자 이벤트와 프로젝트 관측 데이터",
description = "analytics_event 태그의 모든 사용자 이벤트와 오류·로그·성능·세션·릴리즈를 프로젝트별로 제공한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED")
Expand All @@ -47,6 +63,25 @@ public ApiResponse<SentryIssueReport> sentry(
return ApiResponse.onSuccess(sentryClient.fetchUnresolvedIssues(from, to));
}

private void validateSteps(List<String> steps) {
if (steps == null || steps.size() < 2) {
throw new IllegalArgumentException("퍼널은 단계가 2개 이상이어야 합니다.");
}
if (steps.size() > MAX_FUNNEL_STEPS) {
throw new IllegalArgumentException("퍼널 단계는 " + MAX_FUNNEL_STEPS + "개까지입니다.");
}
if (steps.stream().anyMatch(step -> step == null || step.isBlank())) {
throw new IllegalArgumentException("퍼널 단계 이름이 비어 있습니다.");
}
}

private void validateWindow(int windowDays) {
if (windowDays < 1 || windowDays > MAX_FUNNEL_WINDOW_DAYS) {
throw new IllegalArgumentException(
"전환 윈도우는 1일부터 " + MAX_FUNNEL_WINDOW_DAYS + "일까지입니다.");
}
}

private void validateRange(LocalDate from, LocalDate to, long maxDays) {
long days = ChronoUnit.DAYS.between(from, to) + 1;
if (days < 1 || days > maxDays) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
Expand Down Expand Up @@ -231,6 +233,177 @@ private void add(LocalDate date, Instant occurredAt, String distinctId) {
}
}

/**
* 지정한 순서대로 이벤트를 밟은 고유 사용자를 센다.
*
* <p>집계 API 의 퍼널 리포트를 쓸 수 없어(플랜 제한) 원본 이벤트로 직접 계산한다.
* 규칙은 Mixpanel 화면과 같다. 다음 단계는 앞 단계 이후여야 하고, 첫 단계로부터
* {@code windowDays} 안에 끝나야 한다. 2026-08 실데이터로 Mixpanel 화면 숫자와 일치를 확인했다.
*/
public MixpanelFunnelReport fetchFunnel(
List<String> steps, int windowDays, LocalDate from, LocalDate to) {
if (!StringUtils.hasText(apiSecret)) {
return MixpanelFunnelReport.empty(AnalyticsStatus.NOT_CONFIGURED, steps, windowDays, from, to);
}

AnalyticsHttpResponse response = transport.get(uri(from, to),
Map.of("Authorization", "Basic " + basicCredentials()));
if (!response.isSuccess()) {
log.warn("[Mixpanel] 퍼널용 원본 이벤트 조회 실패: status={}", response.statusCode());
return MixpanelFunnelReport.empty(AnalyticsStatus.UNAVAILABLE, steps, windowDays, from, to);
}

try {
long[] reached = walkFunnel(response.body(), steps, windowDays, from, to);
return funnelReport(steps, windowDays, from, to, reached);
} catch (Exception e) {
log.warn("[Mixpanel] 퍼널 계산 실패: {}", e.getClass().getSimpleName());
return MixpanelFunnelReport.empty(AnalyticsStatus.UNAVAILABLE, steps, windowDays, from, to);
}
}

/**
* 단계 하나를 가리키는 조건. {@code battle_step} 처럼 이벤트 이름만 쓰거나
* {@code battle_step:step_name=pre_vote} 처럼 속성까지 좁힌다.
*
* <p>배틀 흐름은 선택·재생·투표가 모두 {@code battle_step} 한 이벤트로 들어오고
* {@code step_name} 으로만 갈린다. 속성을 못 좁히면 이 구간이 한 단계로 뭉쳐 이탈율을 볼 수 없다.
*/
private record StepMatcher(String spec, String event, String property, String value) {
static StepMatcher parse(String spec) {
int marker = spec.indexOf(':');
if (marker < 0) {
return new StepMatcher(spec, spec, null, null);
}
String event = spec.substring(0, marker);
String filter = spec.substring(marker + 1);
int equals = filter.indexOf('=');
if (equals < 0) {
throw new IllegalArgumentException("퍼널 단계 속성은 property=value 형태여야 합니다: " + spec);
}
return new StepMatcher(spec, event, filter.substring(0, equals), filter.substring(equals + 1));
}

boolean matches(String event, JsonNode properties) {
if (!this.event.equals(event)) {
return false;
}
if (property == null) {
return true;
}
JsonNode actual = properties.get(property);
return actual != null && !actual.isNull() && value.equals(actual.asText());
}
}

/** 사용자별로 단계 이벤트만 모아 시간순으로 훑는다. 단계에 없는 이벤트는 담지 않아 메모리를 아낀다. */
private long[] walkFunnel(
String body, List<String> steps, int windowDays, LocalDate from, LocalDate to) throws Exception {
List<StepMatcher> matchers = steps.stream().map(StepMatcher::parse).toList();
Map<String, List<long[]>> byUser = new HashMap<>();

for (String line : body.split("\n")) {
if (line.isBlank()) {
continue;
}
JsonNode node = objectMapper.readTree(line);
String event = node.path("event").asText(null);
JsonNode time = node.path("properties").path("time");
if (event == null || !time.isNumber()) {
throw new IllegalArgumentException("Mixpanel export line is missing event or time.");
}
JsonNode properties = node.path("properties");
int step = -1;
for (int index = 0; index < matchers.size(); index++) {
if (matchers.get(index).matches(event, properties)) {
step = index;
break;
}
}
if (step < 0) {
continue;
}
Instant occurredAt = Instant.ofEpochSecond(time.asLong());
LocalDate date = occurredAt.atZone(projectZone).toLocalDate();
if (date.isBefore(from) || date.isAfter(to)) {
continue;
}
String distinctId = text(properties.get("distinct_id"));
if (distinctId == null) {
// 사용자를 못 가리는 이벤트는 고유 전환을 셀 수 없다.
continue;
}
byUser.computeIfAbsent(distinctId, key -> new ArrayList<>())
.add(new long[]{time.asLong(), step});
}

long[] reached = new long[steps.size()];
long window = (long) windowDays * 86_400L;
for (List<long[]> events : byUser.values()) {
events.sort(Comparator.comparingLong(entry -> entry[0]));
Long entry = firstTimeOf(events, 0);
if (entry == null) {
continue;
}
reached[0]++;
long cursor = entry;
long deadline = entry + window;
for (int step = 1; step < steps.size(); step++) {
Long next = nextTimeOf(events, step, cursor, deadline);
if (next == null) {
break;
}
reached[step]++;
cursor = next;
}
}
return reached;
}

private Long firstTimeOf(List<long[]> events, int step) {
return nextTimeOf(events, step, Long.MIN_VALUE, Long.MAX_VALUE);
}

private Long nextTimeOf(List<long[]> events, int step, long notBefore, long notAfter) {
for (long[] entry : events) {
if (entry[1] == step && entry[0] >= notBefore && entry[0] <= notAfter) {
return entry[0];
}
}
return null;
}

private MixpanelFunnelReport funnelReport(
List<String> steps, int windowDays, LocalDate from, LocalDate to, long[] reached) {
long entered = reached[0];
List<MixpanelFunnelReport.Step> rows = new ArrayList<>();
for (int index = 0; index < steps.size(); index++) {
long users = reached[index];
Long previous = index == 0 ? null : reached[index - 1];
rows.add(new MixpanelFunnelReport.Step(
index + 1,
steps.get(index),
users,
percentage(users, entered),
previous == null ? null : percentage(users, previous),
previous == null ? null : previous - users,
previous == null ? null : percentage(previous - users, previous)));
}
long completed = reached[reached.length - 1];
return new MixpanelFunnelReport(AnalyticsStatus.CONNECTED, Instant.now(), from, to, windowDays,
entered, completed, percentage(completed, entered), rows);
}

/** 분모가 0이면 0%가 아니라 null 이다. 아무도 진입하지 않은 것과 전환 실패를 구분한다. */
private Double percentage(long value, long total) {
if (total <= 0) {
return null;
}
return BigDecimal.valueOf(value * 100.0 / total)
.setScale(2, RoundingMode.HALF_UP)
.doubleValue();
}

private String basicCredentials() {
// 레거시 방식은 API 비밀을 사용자명 자리에 두고 비밀번호를 비운다.
return Base64.getEncoder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.swyp.picke.domain.admin.analytics;

import java.time.Instant;
import java.time.LocalDate;
import java.util.List;

/**
* 지정한 이벤트 순서를 사용자가 끝까지 밟았는지 센 퍼널. 원본 이벤트로 서버가 직접 계산한다.
*
* <p>고유 사용자 기준이다. 한 사람이 같은 단계를 여러 번 밟아도 한 번으로 센다.
* 다음 단계는 앞 단계 이후에 일어나야 하고, 첫 단계로부터 {@code windowDays} 안에 끝나야 한다.
* Mixpanel 화면의 Unique Conversion·전환 윈도우와 같은 규칙이다.
*
* @param enteredUsers 첫 단계를 밟은 사용자 수.
* @param completedUsers 마지막 단계까지 밟은 사용자 수.
* @param conversionRate 첫 단계 대비 마지막 단계 전환율(%). 첫 단계가 0명이면 null.
*/
public record MixpanelFunnelReport(
AnalyticsStatus status,
Instant fetchedAt,
LocalDate from,
LocalDate to,
int windowDays,
Long enteredUsers,
Long completedUsers,
Double conversionRate,
List<Step> steps) {

/**
* @param order 1부터 시작하는 단계 순서.
* @param users 이 단계까지 밟은 사용자 수.
* @param conversionFromEntry 첫 단계 대비 전환율(%).
* @param conversionFromPrevious 앞 단계 대비 전환율(%). 첫 단계는 null.
* @param droppedFromPrevious 앞 단계에서 이탈한 사용자 수. 첫 단계는 null.
* @param dropOffFromPrevious 앞 단계 대비 이탈율(%). 첫 단계는 null.
*/
public record Step(
int order,
String event,
Long users,
Double conversionFromEntry,
Double conversionFromPrevious,
Long droppedFromPrevious,
Double dropOffFromPrevious) {
}

static MixpanelFunnelReport empty(
AnalyticsStatus status, List<String> steps, int windowDays, LocalDate from, LocalDate to) {
List<Step> emptySteps = java.util.stream.IntStream.range(0, steps.size())
.mapToObj(index -> new Step(index + 1, steps.get(index), null, null, null, null, null))
.toList();
return new MixpanelFunnelReport(status, null, from, to, windowDays, null, null, null, emptySteps);
}
}
Loading
Loading