Xây Dựng Video Platform: Admin Analytics & User Management

Platform đã live — courses, payments, streaming đều chạy ngon. Nhưng mà chúng ta đang "bay mù". Có bao nhiêu subscribers? Revenue tháng này bao nhiêu? Lesson nào hot nhất? User nào đang bị stuck? Trong bài này, ta sẽ build một admin analytics dashboard trả lời tất cả câu hỏi đó chỉ bằng một cái nhìn, cộng thêm user management system để handle các support case.
Ta sẽ giữ analytics đơn giản và query-driven — không cần analytics service riêng, không event streaming. Chỉ cần SQL queries viết chuẩn là có đủ số liệu cần thiết. Với charts, ta dùng Recharts, một composable React charting library được build trên D3.
Thời gian thực hiện: 3–4 giờ
Yêu cầu: Phase 10: Public Course Catalog & SEO
Những gì ta sẽ build trong bài này:
✅ Dashboard overview với key metrics (MRR, subscribers, completions)
✅ Revenue chart với monthly breakdown dùng Recharts
✅ Subscriber growth chart (new vs churned)
✅ Popular lessons và completion rates
✅ User management với search và filtering
✅ Manual subscription grants cho support cases
✅ CSV export cho reports
Analytics API
Dashboard Metrics
// src/main/java/com/videoplatform/api/controller/AdminAnalyticsController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.DashboardMetrics;
import com.videoplatform.api.dto.response.RevenueDataPoint;
import com.videoplatform.api.dto.response.PopularLessonResponse;
import com.videoplatform.api.dto.response.SubscriberGrowthPoint;
import com.videoplatform.api.service.AnalyticsService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/analytics")
@PreAuthorize("hasRole('ADMIN')")
public class AdminAnalyticsController {
private final AnalyticsService analyticsService;
public AdminAnalyticsController(AnalyticsService analyticsService) {
this.analyticsService = analyticsService;
}
@GetMapping("/dashboard")
public ResponseEntity<ApiResponse<DashboardMetrics>> getDashboardMetrics() {
return ResponseEntity.ok(ApiResponse.success(analyticsService.getDashboardMetrics()));
}
@GetMapping("/revenue")
public ResponseEntity<ApiResponse<List<RevenueDataPoint>>> getRevenueChart(
@RequestParam(defaultValue = "12") int months) {
return ResponseEntity.ok(ApiResponse.success(
analyticsService.getMonthlyRevenue(months)));
}
@GetMapping("/subscribers")
public ResponseEntity<ApiResponse<List<SubscriberGrowthPoint>>> getSubscriberGrowth(
@RequestParam(defaultValue = "12") int months) {
return ResponseEntity.ok(ApiResponse.success(
analyticsService.getSubscriberGrowth(months)));
}
@GetMapping("/popular-lessons")
public ResponseEntity<ApiResponse<List<PopularLessonResponse>>> getPopularLessons(
@RequestParam(defaultValue = "10") int limit) {
return ResponseEntity.ok(ApiResponse.success(
analyticsService.getPopularLessons(limit)));
}
}Tất cả endpoint đều được bảo vệ bằng @PreAuthorize("hasRole('ADMIN')") — chỉ admin mới truy cập được analytics data.
DTOs
// src/main/java/com/videoplatform/api/dto/response/DashboardMetrics.java
package com.videoplatform.api.dto.response;
import java.math.BigDecimal;
public record DashboardMetrics(
long totalUsers,
long activeSubscribers,
long totalCourses,
long totalLessons,
BigDecimal monthlyRevenue, // Tháng hiện tại
BigDecimal totalRevenue, // Tổng cộng
double averageCompletionRate, // Phần trăm
long newSubscribersThisMonth,
long churnedThisMonth
) {}// src/main/java/com/videoplatform/api/dto/response/RevenueDataPoint.java
package com.videoplatform.api.dto.response;
import java.math.BigDecimal;
public record RevenueDataPoint(
String month, // "2026-01"
BigDecimal revenue, // đơn vị dollar
long transactionCount
) {}// src/main/java/com/videoplatform/api/dto/response/SubscriberGrowthPoint.java
package com.videoplatform.api.dto.response;
public record SubscriberGrowthPoint(
String month,
long newSubscribers,
long churnedSubscribers,
long totalActive
) {}// src/main/java/com/videoplatform/api/dto/response/PopularLessonResponse.java
package com.videoplatform.api.dto.response;
public record PopularLessonResponse(
Long lessonId,
String lessonTitle,
String courseTitle,
long viewCount,
double completionRate, // Phần trăm viewers đã xem hết
int averageWatchTimeSeconds
) {}AnalyticsService
// src/main/java/com/videoplatform/api/service/AnalyticsService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.response.*;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.repository.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Service
public class AnalyticsService {
private final UserRepository userRepository;
private final SubscriptionRepository subscriptionRepository;
private final CourseRepository courseRepository;
private final LessonRepository lessonRepository;
private final PaymentHistoryRepository paymentRepository;
private final ProgressRepository progressRepository;
public AnalyticsService(
UserRepository userRepository,
SubscriptionRepository subscriptionRepository,
CourseRepository courseRepository,
LessonRepository lessonRepository,
PaymentHistoryRepository paymentRepository,
ProgressRepository progressRepository) {
this.userRepository = userRepository;
this.subscriptionRepository = subscriptionRepository;
this.courseRepository = courseRepository;
this.lessonRepository = lessonRepository;
this.paymentRepository = paymentRepository;
this.progressRepository = progressRepository;
}
@Transactional(readOnly = true)
public DashboardMetrics getDashboardMetrics() {
long totalUsers = userRepository.count();
long activeSubscribers = subscriptionRepository.countByStatus(SubscriptionStatus.ACTIVE);
long totalCourses = courseRepository.count();
long totalLessons = lessonRepository.count();
// Revenue tháng này
LocalDateTime monthStart = YearMonth.now().atDay(1).atStartOfDay();
BigDecimal monthlyRevenue = paymentRepository
.sumAmountByStatusAndCreatedAtAfter("succeeded", monthStart)
.map(cents -> BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP))
.orElse(BigDecimal.ZERO);
// Tổng revenue
BigDecimal totalRevenue = paymentRepository
.sumAmountByStatus("succeeded")
.map(cents -> BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP))
.orElse(BigDecimal.ZERO);
// Trung bình completion rate
double avgCompletion = progressRepository.averageCompletionRate()
.orElse(0.0);
// Subscribers mới tháng này
long newThisMonth = subscriptionRepository.countByCreatedAtAfter(monthStart);
// Churned tháng này
long churnedThisMonth = subscriptionRepository
.countByStatusAndUpdatedAtAfter(SubscriptionStatus.EXPIRED, monthStart);
return new DashboardMetrics(
totalUsers, activeSubscribers, totalCourses, totalLessons,
monthlyRevenue, totalRevenue, avgCompletion,
newThisMonth, churnedThisMonth
);
}
@Transactional(readOnly = true)
public List<RevenueDataPoint> getMonthlyRevenue(int months) {
List<RevenueDataPoint> result = new ArrayList<>();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM");
for (int i = months - 1; i >= 0; i--) {
YearMonth ym = YearMonth.now().minusMonths(i);
LocalDateTime start = ym.atDay(1).atStartOfDay();
LocalDateTime end = ym.atEndOfMonth().atTime(23, 59, 59);
BigDecimal revenue = paymentRepository
.sumAmountByStatusAndCreatedAtBetween("succeeded", start, end)
.map(cents -> BigDecimal.valueOf(cents)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP))
.orElse(BigDecimal.ZERO);
long count = paymentRepository
.countByStatusAndCreatedAtBetween("succeeded", start, end);
result.add(new RevenueDataPoint(ym.format(fmt), revenue, count));
}
return result;
}
@Transactional(readOnly = true)
public List<SubscriberGrowthPoint> getSubscriberGrowth(int months) {
List<SubscriberGrowthPoint> result = new ArrayList<>();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM");
for (int i = months - 1; i >= 0; i--) {
YearMonth ym = YearMonth.now().minusMonths(i);
LocalDateTime start = ym.atDay(1).atStartOfDay();
LocalDateTime end = ym.atEndOfMonth().atTime(23, 59, 59);
long newSubs = subscriptionRepository
.countByCreatedAtBetween(start, end);
long churned = subscriptionRepository
.countByStatusAndUpdatedAtBetween(SubscriptionStatus.EXPIRED, start, end);
// Active tại cuối tháng
long totalActive = subscriptionRepository
.countByStatusAndCurrentPeriodEndAfter(SubscriptionStatus.ACTIVE, start);
result.add(new SubscriberGrowthPoint(
ym.format(fmt), newSubs, churned, totalActive));
}
return result;
}
@Transactional(readOnly = true)
public List<PopularLessonResponse> getPopularLessons(int limit) {
return progressRepository.findPopularLessons(limit);
}
}Điểm đáng chú ý: tất cả method đều dùng @Transactional(readOnly = true) vì chỉ đọc data. Phần revenue, ta iterate qua từng tháng và query database cho mỗi tháng — approach đơn giản, dễ hiểu, và với 12 tháng thì performance hoàn toàn OK.
Repository Queries
// Thêm vào PaymentHistoryRepository
@Query("SELECT SUM(p.amount) FROM PaymentHistory p WHERE p.status = :status AND p.createdAt > :after")
Optional<Long> sumAmountByStatusAndCreatedAtAfter(String status, LocalDateTime after);
@Query("SELECT SUM(p.amount) FROM PaymentHistory p WHERE p.status = :status")
Optional<Long> sumAmountByStatus(String status);
@Query("SELECT SUM(p.amount) FROM PaymentHistory p WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end")
Optional<Long> sumAmountByStatusAndCreatedAtBetween(String status, LocalDateTime start, LocalDateTime end);
long countByStatusAndCreatedAtBetween(String status, LocalDateTime start, LocalDateTime end);// Thêm vào SubscriptionRepository
long countByStatus(SubscriptionStatus status);
long countByCreatedAtAfter(LocalDateTime after);
long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
long countByStatusAndUpdatedAtAfter(SubscriptionStatus status, LocalDateTime after);
long countByStatusAndUpdatedAtBetween(SubscriptionStatus status, LocalDateTime start, LocalDateTime end);
long countByStatusAndCurrentPeriodEndAfter(SubscriptionStatus status, LocalDateTime after);// Thêm vào ProgressRepository
@Query("""
SELECT new com.videoplatform.api.dto.response.PopularLessonResponse(
l.id, l.title, c.title,
COUNT(p),
CAST(SUM(CASE WHEN p.completed = true THEN 1 ELSE 0 END) AS double) / COUNT(p) * 100,
CAST(AVG(p.watchTimeSeconds) AS int)
)
FROM Progress p
JOIN p.lesson l
JOIN l.section s
JOIN s.course c
GROUP BY l.id, l.title, c.title
ORDER BY COUNT(p) DESC
LIMIT :limit
""")
List<PopularLessonResponse> findPopularLessons(int limit);
@Query("SELECT AVG(CASE WHEN p.completed = true THEN 100.0 ELSE (p.watchTimeSeconds * 100.0 / p.lesson.durationSeconds) END) FROM Progress p")
Optional<Double> averageCompletionRate();Điểm hay ở đây: ta dùng JPQL aggregate functions (SUM, COUNT, AVG) để tính toán trực tiếp trong database thay vì load tất cả records vào memory. Query findPopularLessons thậm chí JOIN qua 4 bảng (Progress → Lesson → Section → Course) trong một query duy nhất.
Frontend: Dashboard Overview
Dashboard Page
// web/src/app/admin/analytics/page.tsx
"use client";
import { useEffect, useState } from "react";
import { MetricCard } from "@/components/admin/MetricCard";
import { RevenueChart } from "@/components/admin/RevenueChart";
import { SubscriberChart } from "@/components/admin/SubscriberChart";
import { PopularLessons } from "@/components/admin/PopularLessons";
import { DollarSign, Users, BookOpen, TrendingUp, TrendingDown, BarChart3 } from "lucide-react";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
interface DashboardMetrics {
totalUsers: number;
activeSubscribers: number;
totalCourses: number;
totalLessons: number;
monthlyRevenue: number;
totalRevenue: number;
averageCompletionRate: number;
newSubscribersThisMonth: number;
churnedThisMonth: number;
}
export default function AnalyticsDashboard() {
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("accessToken");
fetch(`${API_BASE}/api/admin/analytics/dashboard`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((data) => setMetrics(data.data))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-32 rounded-lg bg-muted animate-pulse" />
))}
</div>
</div>
);
}
if (!metrics) return <div className="p-8">Failed to load metrics</div>;
return (
<div className="p-8 space-y-8">
<h1 className="text-3xl font-bold">Analytics Dashboard</h1>
{/* Key metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Monthly Revenue"
value={`$${metrics.monthlyRevenue.toFixed(2)}`}
icon={<DollarSign className="h-5 w-5" />}
subtitle={`$${metrics.totalRevenue.toFixed(2)} total`}
/>
<MetricCard
title="Active Subscribers"
value={metrics.activeSubscribers.toString()}
icon={<Users className="h-5 w-5" />}
subtitle={`${metrics.totalUsers} total users`}
/>
<MetricCard
title="New This Month"
value={`+${metrics.newSubscribersThisMonth}`}
icon={<TrendingUp className="h-5 w-5" />}
subtitle={`${metrics.churnedThisMonth} churned`}
trend={metrics.newSubscribersThisMonth > metrics.churnedThisMonth ? "up" : "down"}
/>
<MetricCard
title="Avg Completion"
value={`${metrics.averageCompletionRate.toFixed(1)}%`}
icon={<BarChart3 className="h-5 w-5" />}
subtitle={`${metrics.totalLessons} lessons`}
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<RevenueChart />
<SubscriberChart />
</div>
{/* Popular lessons */}
<PopularLessons />
</div>
);
}Dashboard page là một client component ("use client") vì cần fetch data và quản lý state. Layout dùng grid system — 4 metric cards trên cùng, 2 charts ở giữa, và bảng popular lessons ở dưới. Khi loading, ta hiển thị skeleton animation với animate-pulse để UX mượt hơn.
MetricCard Component
// web/src/components/admin/MetricCard.tsx
import { ReactNode } from "react";
interface Props {
title: string;
value: string;
icon: ReactNode;
subtitle?: string;
trend?: "up" | "down";
}
export function MetricCard({ title, value, icon, subtitle, trend }: Props) {
return (
<div className="rounded-xl border bg-card p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">{title}</span>
<span className="text-muted-foreground">{icon}</span>
</div>
<div className="text-3xl font-bold">{value}</div>
{subtitle && (
<p className={`text-sm mt-1 ${
trend === "up" ? "text-green-600" :
trend === "down" ? "text-red-600" :
"text-muted-foreground"
}`}>
{subtitle}
</p>
)}
</div>
);
}MetricCard nhận trend prop để hiển thị màu xanh (tăng) hoặc đỏ (giảm). Ví dụ: nếu new subscribers nhiều hơn churned thì subtitle sẽ hiển thị màu xanh.
Revenue Chart với Recharts
Cài Recharts
cd web && npm install rechartsRevenue Chart Component
// web/src/components/admin/RevenueChart.tsx
"use client";
import { useEffect, useState } from "react";
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer,
} from "recharts";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
interface RevenueData {
month: string;
revenue: number;
transactionCount: number;
}
export function RevenueChart() {
const [data, setData] = useState<RevenueData[]>([]);
useEffect(() => {
const token = localStorage.getItem("accessToken");
fetch(`${API_BASE}/api/admin/analytics/revenue?months=12`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((res) => setData(res.data));
}, []);
// Format month label: "2026-01" -> "Jan"
const formatMonth = (month: string) => {
const date = new Date(month + "-01");
return date.toLocaleString("en", { month: "short" });
};
return (
<div className="rounded-xl border bg-card p-6">
<h2 className="text-lg font-semibold mb-4">Monthly Revenue</h2>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
className="text-xs"
/>
<YAxis
tickFormatter={(v) => `$${v}`}
className="text-xs"
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border bg-popover p-3 shadow-md">
<p className="font-medium">{label}</p>
<p className="text-primary">
${payload[0].value?.toLocaleString()}
</p>
<p className="text-sm text-muted-foreground">
{payload[0].payload.transactionCount} transactions
</p>
</div>
);
}}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="hsl(var(--primary))"
fill="url(#revenueGradient)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}Revenue chart dùng AreaChart với gradient fill — trông professional hơn nhiều so với line chart đơn giản. Ta dùng CSS variable hsl(var(--primary)) để chart tự động adapt theo theme (light/dark). Custom tooltip hiển thị tháng, revenue, và số transactions.
Subscriber Growth Chart
// web/src/components/admin/SubscriberChart.tsx
"use client";
import { useEffect, useState } from "react";
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Legend,
} from "recharts";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
interface GrowthData {
month: string;
newSubscribers: number;
churnedSubscribers: number;
totalActive: number;
}
export function SubscriberChart() {
const [data, setData] = useState<GrowthData[]>([]);
useEffect(() => {
const token = localStorage.getItem("accessToken");
fetch(`${API_BASE}/api/admin/analytics/subscribers?months=12`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((res) => setData(res.data));
}, []);
const formatMonth = (month: string) => {
const date = new Date(month + "-01");
return date.toLocaleString("en", { month: "short" });
};
return (
<div className="rounded-xl border bg-card p-6">
<h2 className="text-lg font-semibold mb-4">Subscriber Growth</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="month" tickFormatter={formatMonth} className="text-xs" />
<YAxis className="text-xs" />
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border bg-popover p-3 shadow-md">
<p className="font-medium">{label}</p>
<p className="text-green-600">+{payload[0].value} new</p>
<p className="text-red-600">-{payload[1].value} churned</p>
<p className="text-muted-foreground">
{payload[0].payload.totalActive} total active
</p>
</div>
);
}}
/>
<Legend />
<Bar dataKey="newSubscribers" name="New" fill="#22c55e" radius={[4, 4, 0, 0]} />
<Bar dataKey="churnedSubscribers" name="Churned" fill="#ef4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}Subscriber growth chart dùng BarChart với 2 bars: xanh cho new subscribers, đỏ cho churned. radius={[4, 4, 0, 0]} bo tròn góc trên của mỗi bar — chi tiết nhỏ nhưng trông đẹp hơn nhiều. Tooltip hiển thị cả totalActive để admin thấy tổng số active subscribers tại mỗi thời điểm.
Popular Lessons Table
// web/src/components/admin/PopularLessons.tsx
"use client";
import { useEffect, useState } from "react";
import { Eye, CheckCircle, Clock } from "lucide-react";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
interface PopularLesson {
lessonId: number;
lessonTitle: string;
courseTitle: string;
viewCount: number;
completionRate: number;
averageWatchTimeSeconds: number;
}
export function PopularLessons() {
const [lessons, setLessons] = useState<PopularLesson[]>([]);
useEffect(() => {
const token = localStorage.getItem("accessToken");
fetch(`${API_BASE}/api/admin/analytics/popular-lessons?limit=10`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((res) => setLessons(res.data));
}, []);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
return `${mins}m ${seconds % 60}s`;
};
return (
<div className="rounded-xl border bg-card p-6">
<h2 className="text-lg font-semibold mb-4">Popular Lessons</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-3 font-medium text-muted-foreground">Lesson</th>
<th className="pb-3 font-medium text-muted-foreground">Course</th>
<th className="pb-3 font-medium text-muted-foreground text-right">
<Eye className="h-4 w-4 inline mr-1" />Views
</th>
<th className="pb-3 font-medium text-muted-foreground text-right">
<CheckCircle className="h-4 w-4 inline mr-1" />Completion
</th>
<th className="pb-3 font-medium text-muted-foreground text-right">
<Clock className="h-4 w-4 inline mr-1" />Avg Watch
</th>
</tr>
</thead>
<tbody>
{lessons.map((lesson, idx) => (
<tr key={lesson.lessonId} className="border-b last:border-0">
<td className="py-3">
<span className="text-muted-foreground mr-2">#{idx + 1}</span>
{lesson.lessonTitle}
</td>
<td className="py-3 text-muted-foreground">{lesson.courseTitle}</td>
<td className="py-3 text-right font-medium">{lesson.viewCount}</td>
<td className="py-3 text-right">
<span className={
lesson.completionRate >= 80 ? "text-green-600" :
lesson.completionRate >= 50 ? "text-yellow-600" :
"text-red-600"
}>
{lesson.completionRate.toFixed(1)}%
</span>
</td>
<td className="py-3 text-right text-muted-foreground">
{formatTime(lesson.averageWatchTimeSeconds)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}Popular lessons table dùng color-coded completion rate: xanh (trên 80%), vàng (50–80%), đỏ (dưới 50%). Cái này giúp admin nhanh chóng spot những lessons có completion rate thấp — có thể nội dung quá dài, quá khó, hoặc video quality kém.
User Management
User Management API
// src/main/java/com/videoplatform/api/controller/AdminUserController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.request.GrantSubscriptionRequest;
import com.videoplatform.api.dto.response.AdminUserResponse;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.service.AdminUserService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public class AdminUserController {
private final AdminUserService userService;
public AdminUserController(AdminUserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<ApiResponse<Page<AdminUserResponse>>> listUsers(
@RequestParam(defaultValue = "") String search,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String status) {
return ResponseEntity.ok(ApiResponse.success(
userService.searchUsers(search, status, page, size)));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<AdminUserResponse>> getUser(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(userService.getUserDetail(id)));
}
@PostMapping("/{id}/grant-subscription")
public ResponseEntity<ApiResponse<String>> grantSubscription(
@PathVariable Long id,
@RequestBody GrantSubscriptionRequest request) {
userService.grantSubscription(id, request);
return ResponseEntity.ok(ApiResponse.success("Subscription granted"));
}
@PostMapping("/{id}/revoke-subscription")
public ResponseEntity<ApiResponse<String>> revokeSubscription(@PathVariable Long id) {
userService.revokeSubscription(id);
return ResponseEntity.ok(ApiResponse.success("Subscription revoked"));
}
}DTOs
// src/main/java/com/videoplatform/api/dto/response/AdminUserResponse.java
package com.videoplatform.api.dto.response;
import java.time.LocalDateTime;
public record AdminUserResponse(
Long id,
String email,
String name,
String role,
String subscriptionStatus, // ACTIVE, PAST_DUE, EXPIRED, NONE
String planType, // MONTHLY, YEARLY, MANUAL, null
LocalDateTime subscriptionEnd,
int completedLessons,
LocalDateTime createdAt,
LocalDateTime lastLoginAt
) {}// src/main/java/com/videoplatform/api/dto/request/GrantSubscriptionRequest.java
package com.videoplatform.api.dto.request;
public record GrantSubscriptionRequest(
int durationDays, // Số ngày cấp
String reason // Lý do — để audit log
) {}AdminUserService
// src/main/java/com/videoplatform/api/service/AdminUserService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.request.GrantSubscriptionRequest;
import com.videoplatform.api.dto.response.AdminUserResponse;
import com.videoplatform.api.entity.*;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class AdminUserService {
private static final Logger log = LoggerFactory.getLogger(AdminUserService.class);
private final UserRepository userRepository;
private final SubscriptionRepository subscriptionRepository;
private final ProgressRepository progressRepository;
public AdminUserService(
UserRepository userRepository,
SubscriptionRepository subscriptionRepository,
ProgressRepository progressRepository) {
this.userRepository = userRepository;
this.subscriptionRepository = subscriptionRepository;
this.progressRepository = progressRepository;
}
@Transactional(readOnly = true)
public Page<AdminUserResponse> searchUsers(String search, String status, int page, int size) {
var pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
Page<User> users;
if (search != null && !search.isBlank()) {
users = userRepository.findByEmailContainingOrNameContaining(
search, search, pageable);
} else {
users = userRepository.findAll(pageable);
}
return users.map(this::toAdminResponse);
}
@Transactional(readOnly = true)
public AdminUserResponse getUserDetail(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
return toAdminResponse(user);
}
@Transactional
public void grantSubscription(Long userId, GrantSubscriptionRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
// Kiểm tra subscription active hiện tại
subscriptionRepository
.findByUserAndStatus(user, SubscriptionStatus.ACTIVE)
.ifPresent(sub -> {
throw new IllegalStateException("User already has an active subscription");
});
// Tạo manual subscription
Subscription subscription = new Subscription();
subscription.setUser(user);
subscription.setStripeSubscriptionId("manual_" + System.currentTimeMillis());
subscription.setStripeCustomerId("manual");
subscription.setPlanType(PlanType.YEARLY); // Manual grants dùng YEARLY
subscription.setStatus(SubscriptionStatus.ACTIVE);
subscription.setCurrentPeriodStart(LocalDateTime.now());
subscription.setCurrentPeriodEnd(
LocalDateTime.now().plusDays(request.durationDays()));
subscriptionRepository.save(subscription);
log.info("Manual subscription granted to user {} for {} days. Reason: {}",
userId, request.durationDays(), request.reason());
}
@Transactional
public void revokeSubscription(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
subscriptionRepository.findByUserAndStatus(user, SubscriptionStatus.ACTIVE)
.ifPresent(sub -> {
sub.setStatus(SubscriptionStatus.EXPIRED);
sub.setUpdatedAt(LocalDateTime.now());
subscriptionRepository.save(sub);
log.info("Subscription revoked for user {}", userId);
});
}
private AdminUserResponse toAdminResponse(User user) {
var subscription = subscriptionRepository
.findByUserAndStatusIn(user,
List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
.orElse(null);
int completedLessons = (int) progressRepository.countByUserAndCompletedTrue(user);
return new AdminUserResponse(
user.getId(),
user.getEmail(),
user.getName(),
user.getRole().name(),
subscription != null ? subscription.getStatus().name() : "NONE",
subscription != null ? subscription.getPlanType().name() : null,
subscription != null ? subscription.getCurrentPeriodEnd() : null,
completedLessons,
user.getCreatedAt(),
user.getLastLoginAt()
);
}
}Phần grantSubscription tạo một subscription "manual" — không liên quan đến Stripe. stripeSubscriptionId được set thành "manual_" + timestamp để phân biệt với subscription thật từ Stripe. Admin phải nhập reason để có audit trail — quan trọng khi bạn cần trace lại tại sao user X có subscription mà không qua payment.
User Management UI
// web/src/app/admin/users/page.tsx
"use client";
import { useEffect, useState, useCallback } from "react";
import { Search, Gift, Ban, ChevronLeft, ChevronRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
interface AdminUser {
id: number;
email: string;
name: string;
role: string;
subscriptionStatus: string;
planType: string | null;
subscriptionEnd: string | null;
completedLessons: number;
createdAt: string;
lastLoginAt: string | null;
}
export default function UserManagement() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState(true);
const loadUsers = useCallback(async () => {
setLoading(true);
const token = localStorage.getItem("accessToken");
const params = new URLSearchParams({
search,
page: page.toString(),
size: "20",
});
const res = await fetch(`${API_BASE}/api/admin/users?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setUsers(data.data.content);
setTotalPages(data.data.totalPages);
setLoading(false);
}, [search, page]);
useEffect(() => { loadUsers(); }, [loadUsers]);
const handleGrantSubscription = async (userId: number) => {
const days = prompt("Grant subscription for how many days?", "30");
if (!days) return;
const reason = prompt("Reason for grant?", "Support case");
if (!reason) return;
const token = localStorage.getItem("accessToken");
await fetch(`${API_BASE}/api/admin/users/${userId}/grant-subscription`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ durationDays: parseInt(days), reason }),
});
loadUsers();
};
const handleRevokeSubscription = async (userId: number) => {
if (!confirm("Revoke this user's subscription?")) return;
const token = localStorage.getItem("accessToken");
await fetch(`${API_BASE}/api/admin/users/${userId}/revoke-subscription`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
loadUsers();
};
const statusColor = (status: string) => {
switch (status) {
case "ACTIVE": return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
case "PAST_DUE": return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
case "EXPIRED": return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
default: return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
}
};
return (
<div className="p-8 space-y-6">
<h1 className="text-3xl font-bold">User Management</h1>
{/* Thanh search */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name or email..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full pl-10 pr-4 py-2 rounded-lg border bg-background
focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Bảng users */}
<div className="rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="p-3 text-left font-medium">User</th>
<th className="p-3 text-left font-medium">Subscription</th>
<th className="p-3 text-right font-medium">Lessons</th>
<th className="p-3 text-left font-medium">Joined</th>
<th className="p-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t hover:bg-muted/30">
<td className="p-3">
<div className="font-medium">{user.name || "No name"}</div>
<div className="text-muted-foreground">{user.email}</div>
</td>
<td className="p-3">
<Badge className={statusColor(user.subscriptionStatus)}>
{user.subscriptionStatus}
</Badge>
{user.planType && (
<span className="text-xs text-muted-foreground ml-2">
{user.planType}
</span>
)}
</td>
<td className="p-3 text-right">{user.completedLessons}</td>
<td className="p-3 text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="p-3 text-right">
<div className="flex justify-end gap-2">
{user.subscriptionStatus === "NONE" || user.subscriptionStatus === "EXPIRED" ? (
<button
onClick={() => handleGrantSubscription(user.id)}
className="p-1.5 rounded hover:bg-green-100 dark:hover:bg-green-900
text-green-600 transition-colors"
title="Grant subscription"
>
<Gift className="h-4 w-4" />
</button>
) : (
<button
onClick={() => handleRevokeSubscription(user.id)}
className="p-1.5 rounded hover:bg-red-100 dark:hover:bg-red-900
text-red-600 transition-colors"
title="Revoke subscription"
>
<Ban className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Phân trang */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="p-2 rounded border hover:bg-muted disabled:opacity-50"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="p-2 rounded border hover:bg-muted disabled:opacity-50"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}User management UI có search, pagination, và 2 action chính: Grant (icon quà tặng — cho user không có subscription) và Revoke (icon cấm — cho user đang active). Subscription status được hiển thị bằng Badge với màu sắc tương ứng: xanh (ACTIVE), vàng (PAST_DUE), đỏ (EXPIRED), xám (NONE).
CSV Export
Export Endpoint
// Thêm vào AdminAnalyticsController
@GetMapping("/export/revenue")
public ResponseEntity<byte[]> exportRevenue(
@RequestParam(defaultValue = "12") int months) {
List<RevenueDataPoint> data = analyticsService.getMonthlyRevenue(months);
StringBuilder csv = new StringBuilder();
csv.append("Month,Revenue,Transactions\n");
for (var point : data) {
csv.append(String.format("%s,%s,%d\n",
point.month(), point.revenue(), point.transactionCount()));
}
return ResponseEntity.ok()
.header("Content-Type", "text/csv")
.header("Content-Disposition", "attachment; filename=revenue.csv")
.body(csv.toString().getBytes());
}
@GetMapping("/export/users")
public ResponseEntity<byte[]> exportUsers() {
List<AdminUserResponse> users = userService.getAllUsersForExport();
StringBuilder csv = new StringBuilder();
csv.append("ID,Email,Name,Role,Subscription,Plan,Joined\n");
for (var user : users) {
csv.append(String.format("%d,%s,%s,%s,%s,%s,%s\n",
user.id(), user.email(), user.name(), user.role(),
user.subscriptionStatus(),
user.planType() != null ? user.planType() : "",
user.createdAt()));
}
return ResponseEntity.ok()
.header("Content-Type", "text/csv")
.header("Content-Disposition", "attachment; filename=users.csv")
.body(csv.toString().getBytes());
}Export Button
// Thêm vào dashboard hoặc user management page
function ExportButton({ endpoint, filename }: { endpoint: string; filename: string }) {
const handleExport = async () => {
const token = localStorage.getItem("accessToken");
const res = await fetch(`${API_BASE}/api/admin/analytics/export/${endpoint}`, {
headers: { Authorization: `Bearer ${token}` },
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
return (
<button onClick={handleExport} className="text-sm text-primary hover:underline">
Export CSV
</button>
);
}CSV export đơn giản nhưng cực kỳ hữu ích cho reporting. ExportButton component tạo một invisible <a> tag, trigger download, rồi cleanup URL object — pattern chuẩn cho file download trong React.
Testing
1. Dashboard Metrics
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"admin123"}' | jq -r '.data.accessToken')
curl -s http://localhost:8080/api/admin/analytics/dashboard \
-H "Authorization: Bearer ${TOKEN}" | jq2. Revenue Chart Data
curl -s "http://localhost:8080/api/admin/analytics/revenue?months=6" \
-H "Authorization: Bearer ${TOKEN}" | jq3. User Search
curl -s "http://localhost:8080/api/admin/users?search=john&page=0&size=10" \
-H "Authorization: Bearer ${TOKEN}" | jq4. Grant Manual Subscription
curl -X POST http://localhost:8080/api/admin/users/5/grant-subscription \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"durationDays": 30, "reason": "Beta tester reward"}'5. CSV Export
curl -o revenue.csv "http://localhost:8080/api/admin/analytics/export/revenue?months=12" \
-H "Authorization: Bearer ${TOKEN}"
cat revenue.csv
# Month,Revenue,Transactions
# 2025-04,0.00,0
# 2025-05,49.95,5
# ...Những Lỗi Thường Gặp
1. N+1 Queries trong Analytics
// SAI — trigger N+1 cho mỗi user's subscription
users.stream().map(user -> {
var sub = subscriptionRepository.findByUser(user); // N queries!
return toResponse(user, sub);
});
// ĐÚNG — batch fetch với JOIN FETCH
@Query("SELECT u FROM User u LEFT JOIN FETCH u.subscriptions WHERE u.id IN :ids")
List<User> findByIdsWithSubscriptions(List<Long> ids);2. Query Tất Cả Records Để Aggregation
// SAI — load toàn bộ payments vào memory
List<PaymentHistory> all = paymentRepository.findAll();
BigDecimal total = all.stream().map(PaymentHistory::getAmount).reduce(BigDecimal::add);
// ĐÚNG — aggregate trong database
@Query("SELECT SUM(p.amount) FROM PaymentHistory p WHERE p.status = :status")
Optional<Long> sumAmountByStatus(String status);3. Không Restrict Admin Endpoints
// SAI — bất kỳ authenticated user nào cũng xem được analytics
@GetMapping("/api/admin/analytics/dashboard")
public DashboardMetrics getDashboard() { ... }
// ĐÚNG — restrict cho ADMIN role
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/admin/analytics/dashboard")
public DashboardMetrics getDashboard() { ... }4. Hardcode Chart Colors
// SAI — không respect dark mode
<Bar fill="#3b82f6" />
// ĐÚNG — dùng CSS variables tự adapt theo theme
<Bar fill="hsl(var(--primary))" />
// Hoặc dùng màu đọc được ở cả 2 themes
<Bar fill="#22c55e" /> {/* Xanh lá đọc được ở cả light và dark */}What's Next?
Admin giờ đã có full visibility vào health của platform — revenue trends, subscriber growth, content performance, và user management tools. Ở Post #13, ta sẽ harden mọi thứ:
- Rate limiting với Spring Boot + Redis
- HLS AES-128 encryption cho video segments
- CORS và CSP headers
- Redis caching cho API responses
- Next.js ISR cho static page optimization
- Email notifications cho key events
Đã đến lúc đưa platform này lên production-ready.
Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 10: Public Course Catalog & SEO
Tiếp theo: Phase 12: Security & Performance Hardening
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.