feat(admin): 대시보드 통계 + 회원 상세
CI / build (push) Failing after 13m11s

대시보드(체크인 기반):
- 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) <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-11 11:37:28 +09:00
parent ccfde5dc95
commit 2061974695
15 changed files with 637 additions and 2 deletions
@@ -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<MemberDetail.Dog> 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<MemberDetail.CheckIn> 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<MemberDetail.Match> 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<MemberDetail.Subscription> 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);
}
}
@@ -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<VisitorPoint> visitors(@RequestParam(defaultValue = "day") String unit) {
return statsService.visitors(unit);
}
/** 시군구 지역 통계 (체크인 기반). */
@GetMapping("/regions")
public List<RegionStat> regions() {
return statsService.regions();
}
}
@@ -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;
/**
* 관리자 대시보드 통계.
* '접속자'와 '지역'은 모두 <b>체크인(check_ins)</b>을 근거로 집계한다(별도 추적 테이블 없음).
* 날짜 버킷팅(일/주/월, 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<StatsSummary.Count> byTier = counts(
"SELECT subscription_tier, COUNT(*) FROM users GROUP BY subscription_tier");
List<StatsSummary.Count> 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<Event> 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<VisitorPoint> 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<RegionStat> 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<Event> 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<Event> events, LocalDate from) {
Set<Long> users = new HashSet<>();
for (Event e : events) {
if (!e.date().isBefore(from)) {
users.add(e.userId());
}
}
return users.size();
}
private List<VisitorPoint> daily(List<Event> events, LocalDate today, int days) {
Map<LocalDate, Set<Long>> byDay = new HashMap<>();
for (Event e : events) {
byDay.computeIfAbsent(e.date(), k -> new HashSet<>()).add(e.userId());
}
List<VisitorPoint> 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<VisitorPoint> weekly(List<Event> events, LocalDate today, int weeks) {
Map<LocalDate, Set<Long>> 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<VisitorPoint> 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<VisitorPoint> monthly(List<Event> events, LocalDate today, int months) {
Map<YearMonth, Set<Long>> 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<VisitorPoint> out = new ArrayList<>();
for (int i = months - 1; i >= 0; i--) {
YearMonth m = thisMonth.minusMonths(i);
Set<Long> s = byMonth.get(m);
out.add(new VisitorPoint(m.format(MONTH_FMT), s == null ? 0 : s.size()));
}
return out;
}
private long size(Map<LocalDate, Set<Long>> map, LocalDate key) {
Set<Long> 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<StatsSummary.Count> counts(String sql) {
return jdbc.query(sql, (rs, i) -> new StatsSummary.Count(rs.getString(1), rs.getLong(2)));
}
}
@@ -1,6 +1,7 @@
package com.dog.dognation.admin; package com.dog.dognation.admin;
import com.dog.dognation.admin.dto.MemberAdminResponse; 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.RoleUpdateRequest;
import com.dog.dognation.admin.dto.StatusUpdateRequest; import com.dog.dognation.admin.dto.StatusUpdateRequest;
import com.dog.dognation.admin.dto.TierUpdateRequest; import com.dog.dognation.admin.dto.TierUpdateRequest;
@@ -28,9 +29,12 @@ import java.util.List;
public class MemberAdminController { public class MemberAdminController {
private final MemberAdminService memberAdminService; private final MemberAdminService memberAdminService;
private final AdminMemberDetailService memberDetailService;
public MemberAdminController(MemberAdminService memberAdminService) { public MemberAdminController(MemberAdminService memberAdminService,
AdminMemberDetailService memberDetailService) {
this.memberAdminService = memberAdminService; this.memberAdminService = memberAdminService;
this.memberDetailService = memberDetailService;
} }
@GetMapping @GetMapping
@@ -38,6 +42,12 @@ public class MemberAdminController {
return memberAdminService.list(); return memberAdminService.list();
} }
/** 회원 상세 — 강아지/체크인/매칭/구독 이력 포함. */
@GetMapping("/{id}")
public MemberDetail detail(@PathVariable Long id) {
return memberDetailService.detail(id);
}
@PutMapping("/{id}/status") @PutMapping("/{id}/status")
public MemberAdminResponse updateStatus( public MemberAdminResponse updateStatus(
@PathVariable Long id, @PathVariable Long id,
@@ -6,6 +6,8 @@ import com.dog.dognation.domain.auth.AuthSessionRepository;
import com.dog.dognation.domain.user.SubscriptionTier; import com.dog.dognation.domain.user.SubscriptionTier;
import com.dog.dognation.domain.user.User; import com.dog.dognation.domain.user.User;
import com.dog.dognation.domain.user.UserRepository; 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.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -26,10 +28,14 @@ public class MemberAdminService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuthSessionRepository sessionRepository; 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.userRepository = userRepository;
this.sessionRepository = sessionRepository; this.sessionRepository = sessionRepository;
this.withdrawalLogRepository = withdrawalLogRepository;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -88,6 +94,9 @@ public class MemberAdminService {
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 계정은 삭제할 수 없습니다."); throw new ApiException(HttpStatus.BAD_REQUEST, "본인 계정은 삭제할 수 없습니다.");
} }
User target = mustFind(targetId); User target = mustFind(targetId);
// 탈퇴 로그 기록(금일 탈퇴 통계) 후 삭제
withdrawalLogRepository.save(
new WithdrawalLog(target.getId(), target.getEmail(), target.getSubscriptionTier().name()));
sessionRepository.deleteByUserId(targetId); sessionRepository.deleteByUserId(targetId);
userRepository.delete(target); userRepository.delete(target);
} }
@@ -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<Dog> dogs,
List<CheckIn> checkIns,
List<Match> matches,
List<Subscription> 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) {
}
}
@@ -0,0 +1,5 @@
package com.dog.dognation.admin.dto;
/** 시군구 지역 통계 — 체크인 수 + 방문 사용자 수. */
public record RegionStat(String region, long checkIns, long users) {
}
@@ -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<Count> byTier, List<Count> 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) {
}
}
@@ -0,0 +1,5 @@
package com.dog.dognation.admin.dto;
/** 접속자 추이 한 점 — label(날짜/주/월) + 방문자 수(체크인한 순 사용자). */
public record VisitorPoint(String label, long count) {
}
@@ -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<Long> dogs = new ArrayList<>();
List<Long> 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);
}
}
@@ -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;
}
}
@@ -31,6 +31,10 @@ public class Spot {
private Double longitude; private Double longitude;
/** 시군구(예: 성동구). 관리자 지역 통계용. */
@Column(length = 30)
private String region;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now(); private OffsetDateTime createdAt = OffsetDateTime.now();
@@ -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;
}
}
@@ -0,0 +1,6 @@
package com.dog.dognation.domain.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface WithdrawalLogRepository extends JpaRepository<WithdrawalLog, Long> {
}
@@ -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);