대시보드(체크인 기반):
- 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:
@@ -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);
|
||||||
Reference in New Issue
Block a user