From 20619746954c03050c63b724878aa16eb03a554b Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sat, 11 Jul 2026 11:37:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=86=B5=EA=B3=84=20+=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대시보드(체크인 기반): - V5 마이그레이션: spots.region(시군구) + withdrawal_log - AdminStatsController/Service: 회원수(티어/상태)·금일 신규/탈퇴·DAU/WAU/MAU(체크인 distinct)·시군구 지역 - 접속자 일/주/월 버킷은 DB 함수 의존 없이 서버 집계(H2·PG 공용) - WithdrawalLog 엔티티 + MemberAdmin.delete 훅(금일 탈퇴 집계) - StatsSeedData(local): 시군구 스팟·최근 30일 체크인·탈퇴 더미 회원 상세: - GET /api/admin/members/{id}: 강아지/체크인/매칭(양방향)/구독 이력 조립 - Subscription 엔티티 추가(로컬 H2 테이블 생성 + prod 검증) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/AdminMemberDetailService.java | 98 ++++++++++ .../dognation/admin/AdminStatsController.java | 43 +++++ .../dognation/admin/AdminStatsService.java | 170 ++++++++++++++++++ .../admin/MemberAdminController.java | 12 +- .../dognation/admin/MemberAdminService.java | 11 +- .../dog/dognation/admin/dto/MemberDetail.java | 32 ++++ .../dog/dognation/admin/dto/RegionStat.java | 5 + .../dog/dognation/admin/dto/StatsSummary.java | 25 +++ .../dog/dognation/admin/dto/VisitorPoint.java | 5 + .../dog/dognation/config/StatsSeedData.java | 109 +++++++++++ .../domain/billing/Subscription.java | 54 ++++++ .../com/dog/dognation/domain/spot/Spot.java | 4 + .../dognation/domain/user/WithdrawalLog.java | 49 +++++ .../domain/user/WithdrawalLogRepository.java | 6 + src/main/resources/db/migration/V5__stats.sql | 16 ++ 15 files changed, 637 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/dog/dognation/admin/AdminMemberDetailService.java create mode 100644 src/main/java/com/dog/dognation/admin/AdminStatsController.java create mode 100644 src/main/java/com/dog/dognation/admin/AdminStatsService.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/MemberDetail.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/RegionStat.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/StatsSummary.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/VisitorPoint.java create mode 100644 src/main/java/com/dog/dognation/config/StatsSeedData.java create mode 100644 src/main/java/com/dog/dognation/domain/billing/Subscription.java create mode 100644 src/main/java/com/dog/dognation/domain/user/WithdrawalLog.java create mode 100644 src/main/java/com/dog/dognation/domain/user/WithdrawalLogRepository.java create mode 100644 src/main/resources/db/migration/V5__stats.sql diff --git a/src/main/java/com/dog/dognation/admin/AdminMemberDetailService.java b/src/main/java/com/dog/dognation/admin/AdminMemberDetailService.java new file mode 100644 index 0000000..6d5cc36 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminMemberDetailService.java @@ -0,0 +1,98 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.MemberAdminResponse; +import com.dog.dognation.admin.dto.MemberDetail; +import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.user.User; +import com.dog.dognation.domain.user.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Date; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 관리자 회원 상세 조회. 강아지/체크인/매칭/구독 이력을 조립한다. + */ +@Service +public class AdminMemberDetailService { + + private static final int HISTORY_LIMIT = 20; + + private final UserRepository userRepository; + private final JdbcTemplate jdbc; + + public AdminMemberDetailService(UserRepository userRepository, JdbcTemplate jdbc) { + this.userRepository = userRepository; + this.jdbc = jdbc; + } + + @Transactional(readOnly = true) + public MemberDetail detail(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.")); + + return new MemberDetail( + MemberAdminResponse.from(user), + dogs(userId), + checkIns(userId), + matches(userId), + subscriptions(userId)); + } + + private List dogs(Long userId) { + return jdbc.query(""" + SELECT id, name, breed, size, gender, neutered, birth_date, image_url + FROM dogs WHERE user_id = ? ORDER BY created_at ASC + """, (rs, i) -> { + Date bd = rs.getDate("birth_date"); + return new MemberDetail.Dog( + rs.getLong("id"), rs.getString("name"), rs.getString("breed"), + rs.getString("size"), rs.getString("gender"), rs.getBoolean("neutered"), + bd == null ? null : bd.toLocalDate(), rs.getString("image_url")); + }, userId); + } + + private List checkIns(Long userId) { + return jdbc.query(""" + SELECT s.name AS spot_name, s.region AS region, c.checked_in_at AS checked_in_at + FROM check_ins c JOIN spots s ON c.spot_id = s.id + WHERE c.user_id = ? + ORDER BY c.checked_in_at DESC + LIMIT ? + """, (rs, i) -> new MemberDetail.CheckIn( + rs.getString("spot_name"), rs.getString("region"), + rs.getObject("checked_in_at", OffsetDateTime.class)), userId, HISTORY_LIMIT); + } + + private List matches(Long userId) { + return jdbc.query(""" + SELECT m.id AS id, m.status AS status, m.created_at AS created_at, + CASE WHEN rd.user_id = ? THEN '보냄' ELSE '받음' END AS direction, + CASE WHEN rd.user_id = ? THEN td.name ELSE rd.name END AS counterpart + FROM matching_requests m + JOIN dogs rd ON m.requester_dog_id = rd.id + JOIN dogs td ON m.target_dog_id = td.id + WHERE rd.user_id = ? OR td.user_id = ? + ORDER BY m.created_at DESC + LIMIT ? + """, (rs, i) -> new MemberDetail.Match( + rs.getLong("id"), rs.getString("direction"), rs.getString("counterpart"), + rs.getString("status"), rs.getObject("created_at", OffsetDateTime.class)), + userId, userId, userId, userId, HISTORY_LIMIT); + } + + private List subscriptions(Long userId) { + return jdbc.query(""" + SELECT tier, platform, started_at, expires_at, is_active + FROM subscriptions WHERE user_id = ? ORDER BY started_at DESC + """, (rs, i) -> new MemberDetail.Subscription( + rs.getString("tier"), rs.getString("platform"), + rs.getObject("started_at", OffsetDateTime.class), + rs.getObject("expires_at", OffsetDateTime.class), + rs.getBoolean("is_active")), userId); + } +} diff --git a/src/main/java/com/dog/dognation/admin/AdminStatsController.java b/src/main/java/com/dog/dognation/admin/AdminStatsController.java new file mode 100644 index 0000000..8d13529 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminStatsController.java @@ -0,0 +1,43 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.RegionStat; +import com.dog.dognation.admin.dto.StatsSummary; +import com.dog.dognation.admin.dto.VisitorPoint; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 관리자 대시보드 통계 API. (/api/admin/stats — AdminInterceptor 로 ADMIN 전용) + */ +@RestController +@RequestMapping("/api/admin/stats") +public class AdminStatsController { + + private final AdminStatsService statsService; + + public AdminStatsController(AdminStatsService statsService) { + this.statsService = statsService; + } + + /** 요약 카드 — 회원수(티어/상태) · 금일 신규/탈퇴 · DAU/WAU/MAU. */ + @GetMapping("/summary") + public StatsSummary summary() { + return statsService.summary(); + } + + /** 접속자 추이 — unit=day|week|month (체크인 기반). */ + @GetMapping("/visitors") + public List visitors(@RequestParam(defaultValue = "day") String unit) { + return statsService.visitors(unit); + } + + /** 시군구 지역 통계 (체크인 기반). */ + @GetMapping("/regions") + public List regions() { + return statsService.regions(); + } +} diff --git a/src/main/java/com/dog/dognation/admin/AdminStatsService.java b/src/main/java/com/dog/dognation/admin/AdminStatsService.java new file mode 100644 index 0000000..4ab7ef8 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminStatsService.java @@ -0,0 +1,170 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.RegionStat; +import com.dog.dognation.admin.dto.StatsSummary; +import com.dog.dognation.admin.dto.VisitorPoint; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 관리자 대시보드 통계. + * '접속자'와 '지역'은 모두 체크인(check_ins)을 근거로 집계한다(별도 추적 테이블 없음). + * 날짜 버킷팅(일/주/월, distinct 사용자)은 DB 함수 의존을 피하려 자바에서 처리한다. + */ +@Service +public class AdminStatsService { + + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("MM/dd"); + private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyy-MM"); + + private final JdbcTemplate jdbc; + + public AdminStatsService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** 체크인 1건 = (사용자, 날짜). */ + private record Event(long userId, LocalDate date) { + } + + @Transactional(readOnly = true) + public StatsSummary summary() { + long total = count("SELECT COUNT(*) FROM users"); + List byTier = counts( + "SELECT subscription_tier, COUNT(*) FROM users GROUP BY subscription_tier"); + List byStatus = counts( + "SELECT status, COUNT(*) FROM users GROUP BY status"); + + long signups = count("SELECT COUNT(*) FROM users WHERE CAST(created_at AS DATE) = CURRENT_DATE"); + long withdrawals = count( + "SELECT COUNT(*) FROM withdrawal_log WHERE CAST(withdrawn_at AS DATE) = CURRENT_DATE"); + + List recent = events(30); + LocalDate today = LocalDate.now(); + long dau = distinctUsersSince(recent, today); + long wau = distinctUsersSince(recent, today.minusDays(6)); + long mau = distinctUsersSince(recent, today.minusDays(29)); + + return new StatsSummary( + new StatsSummary.Members(total, byTier, byStatus), + new StatsSummary.Today(signups, withdrawals), + new StatsSummary.Visitors(dau, wau, mau)); + } + + /** 접속자(체크인 순사용자) 추이. unit = day(최근 30일)/week(최근 12주)/month(최근 12개월). */ + @Transactional(readOnly = true) + public List visitors(String unit) { + LocalDate today = LocalDate.now(); + return switch (unit == null ? "day" : unit) { + case "week" -> weekly(events(7 * 12), today, 12); + case "month" -> monthly(events(31 * 12), today, 12); + default -> daily(events(30), today, 30); + }; + } + + /** 시군구(체크인 스팟 지역)별 통계. */ + @Transactional(readOnly = true) + public List regions() { + return jdbc.query(""" + SELECT COALESCE(s.region, '미지정') AS region, + COUNT(*) AS check_ins, + COUNT(DISTINCT c.user_id) AS users + FROM check_ins c + JOIN spots s ON c.spot_id = s.id + GROUP BY COALESCE(s.region, '미지정') + ORDER BY check_ins DESC + """, (rs, i) -> new RegionStat( + rs.getString("region"), rs.getLong("check_ins"), rs.getLong("users"))); + } + + // ---------------- helpers ---------------- + + private List events(int lastNDays) { + OffsetDateTime since = OffsetDateTime.now().minusDays(lastNDays); + return jdbc.query( + "SELECT user_id, CAST(checked_in_at AS DATE) AS d FROM check_ins WHERE checked_in_at >= ?", + (rs, i) -> new Event(rs.getLong("user_id"), rs.getDate("d").toLocalDate()), + since); + } + + private long distinctUsersSince(List events, LocalDate from) { + Set users = new HashSet<>(); + for (Event e : events) { + if (!e.date().isBefore(from)) { + users.add(e.userId()); + } + } + return users.size(); + } + + private List daily(List events, LocalDate today, int days) { + Map> byDay = new HashMap<>(); + for (Event e : events) { + byDay.computeIfAbsent(e.date(), k -> new HashSet<>()).add(e.userId()); + } + List out = new ArrayList<>(); + for (int i = days - 1; i >= 0; i--) { + LocalDate d = today.minusDays(i); + out.add(new VisitorPoint(d.format(DAY_FMT), size(byDay, d))); + } + return out; + } + + private List weekly(List events, LocalDate today, int weeks) { + Map> byWeek = new HashMap<>(); + for (Event e : events) { + LocalDate ws = e.date().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + byWeek.computeIfAbsent(ws, k -> new HashSet<>()).add(e.userId()); + } + LocalDate thisWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + List out = new ArrayList<>(); + for (int i = weeks - 1; i >= 0; i--) { + LocalDate ws = thisWeek.minusWeeks(i); + out.add(new VisitorPoint(ws.format(DAY_FMT) + "주", size(byWeek, ws))); + } + return out; + } + + private List monthly(List events, LocalDate today, int months) { + Map> byMonth = new HashMap<>(); + for (Event e : events) { + byMonth.computeIfAbsent(YearMonth.from(e.date()), k -> new HashSet<>()).add(e.userId()); + } + YearMonth thisMonth = YearMonth.from(today); + List out = new ArrayList<>(); + for (int i = months - 1; i >= 0; i--) { + YearMonth m = thisMonth.minusMonths(i); + Set s = byMonth.get(m); + out.add(new VisitorPoint(m.format(MONTH_FMT), s == null ? 0 : s.size())); + } + return out; + } + + private long size(Map> map, LocalDate key) { + Set s = map.get(key); + return s == null ? 0 : s.size(); + } + + private long count(String sql) { + Long n = jdbc.queryForObject(sql, Long.class); + return n == null ? 0 : n; + } + + private List counts(String sql) { + return jdbc.query(sql, (rs, i) -> new StatsSummary.Count(rs.getString(1), rs.getLong(2))); + } +} diff --git a/src/main/java/com/dog/dognation/admin/MemberAdminController.java b/src/main/java/com/dog/dognation/admin/MemberAdminController.java index 9c720ea..b493192 100644 --- a/src/main/java/com/dog/dognation/admin/MemberAdminController.java +++ b/src/main/java/com/dog/dognation/admin/MemberAdminController.java @@ -1,6 +1,7 @@ package com.dog.dognation.admin; import com.dog.dognation.admin.dto.MemberAdminResponse; +import com.dog.dognation.admin.dto.MemberDetail; import com.dog.dognation.admin.dto.RoleUpdateRequest; import com.dog.dognation.admin.dto.StatusUpdateRequest; import com.dog.dognation.admin.dto.TierUpdateRequest; @@ -28,9 +29,12 @@ import java.util.List; public class MemberAdminController { private final MemberAdminService memberAdminService; + private final AdminMemberDetailService memberDetailService; - public MemberAdminController(MemberAdminService memberAdminService) { + public MemberAdminController(MemberAdminService memberAdminService, + AdminMemberDetailService memberDetailService) { this.memberAdminService = memberAdminService; + this.memberDetailService = memberDetailService; } @GetMapping @@ -38,6 +42,12 @@ public class MemberAdminController { return memberAdminService.list(); } + /** 회원 상세 — 강아지/체크인/매칭/구독 이력 포함. */ + @GetMapping("/{id}") + public MemberDetail detail(@PathVariable Long id) { + return memberDetailService.detail(id); + } + @PutMapping("/{id}/status") public MemberAdminResponse updateStatus( @PathVariable Long id, diff --git a/src/main/java/com/dog/dognation/admin/MemberAdminService.java b/src/main/java/com/dog/dognation/admin/MemberAdminService.java index 4814186..e3120bf 100644 --- a/src/main/java/com/dog/dognation/admin/MemberAdminService.java +++ b/src/main/java/com/dog/dognation/admin/MemberAdminService.java @@ -6,6 +6,8 @@ import com.dog.dognation.domain.auth.AuthSessionRepository; import com.dog.dognation.domain.user.SubscriptionTier; import com.dog.dognation.domain.user.User; import com.dog.dognation.domain.user.UserRepository; +import com.dog.dognation.domain.user.WithdrawalLog; +import com.dog.dognation.domain.user.WithdrawalLogRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,10 +28,14 @@ public class MemberAdminService { private final UserRepository userRepository; private final AuthSessionRepository sessionRepository; + private final WithdrawalLogRepository withdrawalLogRepository; - public MemberAdminService(UserRepository userRepository, AuthSessionRepository sessionRepository) { + public MemberAdminService(UserRepository userRepository, + AuthSessionRepository sessionRepository, + WithdrawalLogRepository withdrawalLogRepository) { this.userRepository = userRepository; this.sessionRepository = sessionRepository; + this.withdrawalLogRepository = withdrawalLogRepository; } @Transactional(readOnly = true) @@ -88,6 +94,9 @@ public class MemberAdminService { throw new ApiException(HttpStatus.BAD_REQUEST, "본인 계정은 삭제할 수 없습니다."); } User target = mustFind(targetId); + // 탈퇴 로그 기록(금일 탈퇴 통계) 후 삭제 + withdrawalLogRepository.save( + new WithdrawalLog(target.getId(), target.getEmail(), target.getSubscriptionTier().name())); sessionRepository.deleteByUserId(targetId); userRepository.delete(target); } diff --git a/src/main/java/com/dog/dognation/admin/dto/MemberDetail.java b/src/main/java/com/dog/dognation/admin/dto/MemberDetail.java new file mode 100644 index 0000000..508ef41 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/MemberDetail.java @@ -0,0 +1,32 @@ +package com.dog.dognation.admin.dto; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 관리자 회원 상세 — 기본 정보 + 강아지/체크인/매칭/구독 이력. + */ +public record MemberDetail( + MemberAdminResponse member, + List dogs, + List checkIns, + List matches, + List subscriptions) { + + public record Dog(Long id, String name, String breed, String size, String gender, + boolean neutered, LocalDate birthDate, String imageUrl) { + } + + public record CheckIn(String spotName, String region, OffsetDateTime checkedInAt) { + } + + /** direction: 보냄/받음, counterpart: 상대 강아지 이름. */ + public record Match(Long id, String direction, String counterpartDog, String status, + OffsetDateTime createdAt) { + } + + public record Subscription(String tier, String platform, OffsetDateTime startedAt, + OffsetDateTime expiresAt, boolean active) { + } +} diff --git a/src/main/java/com/dog/dognation/admin/dto/RegionStat.java b/src/main/java/com/dog/dognation/admin/dto/RegionStat.java new file mode 100644 index 0000000..4f2dcb6 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/RegionStat.java @@ -0,0 +1,5 @@ +package com.dog.dognation.admin.dto; + +/** 시군구 지역 통계 — 체크인 수 + 방문 사용자 수. */ +public record RegionStat(String region, long checkIns, long users) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/StatsSummary.java b/src/main/java/com/dog/dognation/admin/dto/StatsSummary.java new file mode 100644 index 0000000..77c4846 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/StatsSummary.java @@ -0,0 +1,25 @@ +package com.dog.dognation.admin.dto; + +import java.util.List; + +/** + * 관리자 대시보드 요약. + * - members: 총원 + 티어별/상태별 분포 + * - today: 금일 신규가입/탈퇴 + * - visitors: 체크인 기반 DAU/WAU/MAU + */ +public record StatsSummary(Members members, Today today, Visitors visitors) { + + public record Members(long total, List byTier, List byStatus) { + } + + public record Today(long signups, long withdrawals) { + } + + public record Visitors(long dau, long wau, long mau) { + } + + /** key(티어/상태) 별 집계값. */ + public record Count(String key, long count) { + } +} diff --git a/src/main/java/com/dog/dognation/admin/dto/VisitorPoint.java b/src/main/java/com/dog/dognation/admin/dto/VisitorPoint.java new file mode 100644 index 0000000..c0aef39 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/VisitorPoint.java @@ -0,0 +1,5 @@ +package com.dog.dognation.admin.dto; + +/** 접속자 추이 한 점 — label(날짜/주/월) + 방문자 수(체크인한 순 사용자). */ +public record VisitorPoint(String label, long count) { +} diff --git a/src/main/java/com/dog/dognation/config/StatsSeedData.java b/src/main/java/com/dog/dognation/config/StatsSeedData.java new file mode 100644 index 0000000..88ba53d --- /dev/null +++ b/src/main/java/com/dog/dognation/config/StatsSeedData.java @@ -0,0 +1,109 @@ +package com.dog.dognation.config; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * local 프로파일 전용 통계 더미 (관리자 대시보드 시연용). + * 시군구 스팟 + 방문자/강아지 + 최근 30일 체크인 + 탈퇴 로그를 채운다. + * AdminSeedData(@Order 1) 다음, 앱 시드보다 앞서 실행된다. + */ +@Component +@Profile("local") +@Order(2) +public class StatsSeedData implements CommandLineRunner { + + private final JdbcTemplate jdbc; + + public StatsSeedData(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public void run(String... args) { + // 시군구 스팟 + long[] spots = { + insertSpot("여의도 한강공원", "TRAIL", 37.528, 126.932, "영등포구"), + insertSpot("성수 서울숲공원", "PLAYGROUND", 37.544, 127.037, "성동구"), + insertSpot("올림픽공원", "PARK", 37.520, 127.121, "송파구"), + insertSpot("양재천 산책로", "TRAIL", 37.484, 127.043, "강남구"), + insertSpot("경의선숲길", "PARK", 37.559, 126.925, "마포구"), + }; + + // 방문자 + 강아지 + String[] tiers = {"FREE", "AD_FREE", "PREMIUM", "FREE", "FREE", "PREMIUM"}; + List dogs = new ArrayList<>(); + List users = new ArrayList<>(); + for (int i = 0; i < tiers.length; i++) { + long uid = insertUser("visitor" + (i + 1) + "@dog.com", "방문자" + (i + 1), tiers[i]); + users.add(uid); + dogs.add(insertDog(uid, "댕이" + (i + 1))); + } + + // 최근 30일 체크인 (일별 방문자 수 변동 + 지역 분산) + OffsetDateTime now = OffsetDateTime.now(); + int total = 0; + for (int day = 0; day < 30; day++) { + int active = 3 + (day % 5); // 3~7명/일 + for (int u = 0; u < active; u++) { + int idx = (day + u) % users.size(); + long spot = spots[(day + u) % spots.length]; + OffsetDateTime at = now.minusDays(day).minusHours(u); + jdbc.update(""" + INSERT INTO check_ins(spot_id, user_id, dog_id, checked_in_at, expires_at) + VALUES (?, ?, ?, ?, ?) + """, spot, users.get(idx), dogs.get(idx), at, at.plusHours(2)); + total++; + } + } + + // 탈퇴 로그 — 금일 2건 + 최근 며칠 3건 + insertWithdrawal("left1@dog.com", "FREE", now); + insertWithdrawal("left2@dog.com", "PREMIUM", now); + insertWithdrawal("left3@dog.com", "FREE", now.minusDays(2)); + insertWithdrawal("left4@dog.com", "AD_FREE", now.minusDays(4)); + insertWithdrawal("left5@dog.com", "FREE", now.minusDays(6)); + + System.out.printf("[stats seed] 스팟 %d, 방문자 %d, 체크인 %d 준비 완료%n", + spots.length, users.size(), total); + } + + private long insertUser(String email, String nickname, String tier) { + jdbc.update(""" + INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at) + VALUES (?, ?, ?, 'ACTIVE', 'USER', 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, email, nickname, tier); + return jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email); + } + + private long insertDog(long userId, String name) { + jdbc.update(""" + INSERT INTO dogs(user_id, name, neutered, created_at) + VALUES (?, ?, false, CURRENT_TIMESTAMP) + """, userId, name); + return jdbc.queryForObject( + "SELECT id FROM dogs WHERE user_id = ? AND name = ?", Long.class, userId, name); + } + + private long insertSpot(String name, String type, double lat, double lng, String region) { + jdbc.update(""" + INSERT INTO spots(name, type, latitude, longitude, region, created_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, name, type, lat, lng, region); + return jdbc.queryForObject("SELECT id FROM spots WHERE name = ?", Long.class, name); + } + + private void insertWithdrawal(String email, String tier, OffsetDateTime at) { + jdbc.update(""" + INSERT INTO withdrawal_log(user_id, email, tier, withdrawn_at) + VALUES (NULL, ?, ?, ?) + """, email, tier, at); + } +} diff --git a/src/main/java/com/dog/dognation/domain/billing/Subscription.java b/src/main/java/com/dog/dognation/domain/billing/Subscription.java new file mode 100644 index 0000000..ce7b6c5 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/billing/Subscription.java @@ -0,0 +1,54 @@ +package com.dog.dognation.domain.billing; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** + * 인앱결제(IAP) 구독 이력 — 애플/구글 영수증 기준. (subscriptions 테이블) + * 현재는 스키마/조회용 매핑만 제공한다. 실제 IAP 연동 시 확장. + */ +@Entity +@Table(name = "subscriptions") +public class Subscription { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 20) + private String tier; // AD_FREE / PREMIUM + + @Column(nullable = false, length = 20) + private String platform; // APPLE / GOOGLE + + @Column(name = "original_txn_id", nullable = false) + private String originalTxnId; + + @Column(name = "started_at", nullable = false) + private OffsetDateTime startedAt; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + @Column(name = "is_active", nullable = false) + private boolean active = true; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + protected Subscription() { + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/dog/dognation/domain/spot/Spot.java b/src/main/java/com/dog/dognation/domain/spot/Spot.java index be47a79..8bf6ace 100644 --- a/src/main/java/com/dog/dognation/domain/spot/Spot.java +++ b/src/main/java/com/dog/dognation/domain/spot/Spot.java @@ -31,6 +31,10 @@ public class Spot { private Double longitude; + /** 시군구(예: 성동구). 관리자 지역 통계용. */ + @Column(length = 30) + private String region; + @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt = OffsetDateTime.now(); diff --git a/src/main/java/com/dog/dognation/domain/user/WithdrawalLog.java b/src/main/java/com/dog/dognation/domain/user/WithdrawalLog.java new file mode 100644 index 0000000..075b366 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/user/WithdrawalLog.java @@ -0,0 +1,49 @@ +package com.dog.dognation.domain.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** + * 탈퇴 로그. 회원 삭제 시 기록해 '금일 탈퇴 수' 등 통계에 사용한다. + * 삭제된 회원을 참조하므로 users 로의 FK 는 두지 않는다. + */ +@Entity +@Table(name = "withdrawal_log") +public class WithdrawalLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(length = 255) + private String email; + + @Column(length = 20) + private String tier; + + @Column(name = "withdrawn_at", nullable = false) + private OffsetDateTime withdrawnAt = OffsetDateTime.now(); + + protected WithdrawalLog() { + } + + public WithdrawalLog(Long userId, String email, String tier) { + this.userId = userId; + this.email = email; + this.tier = tier; + this.withdrawnAt = OffsetDateTime.now(); + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/dog/dognation/domain/user/WithdrawalLogRepository.java b/src/main/java/com/dog/dognation/domain/user/WithdrawalLogRepository.java new file mode 100644 index 0000000..b26671e --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/user/WithdrawalLogRepository.java @@ -0,0 +1,6 @@ +package com.dog.dognation.domain.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WithdrawalLogRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V5__stats.sql b/src/main/resources/db/migration/V5__stats.sql new file mode 100644 index 0000000..7b48533 --- /dev/null +++ b/src/main/resources/db/migration/V5__stats.sql @@ -0,0 +1,16 @@ +-- ============================================================ +-- V5: 관리자 통계 — 스팟 시군구 지역 + 탈퇴 로그 +-- ============================================================ + +-- 스팟 시군구(지역) — 체크인/매칭 지역 통계용 +ALTER TABLE spots ADD COLUMN region VARCHAR(30); + +-- 탈퇴 로그 — 회원 삭제 시 기록(금일 탈퇴 수 집계). 삭제된 회원 참조라 FK 없음. +CREATE TABLE withdrawal_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT, + email VARCHAR(255), + tier VARCHAR(20), + withdrawn_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_withdrawal_log_at ON withdrawal_log(withdrawn_at);