feat(admin): 添加用户管理相关文件

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>everything-is-suitable-statistics</artifactId>
<name>Everything Is Suitable Statistics</name>
<description>Statistics Module for Everything Is Suitable API</description>
<dependencies>
<dependency>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.destiny</groupId>
<artifactId>everything-is-suitable-client</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-qual</artifactId>
<version>3.48.2</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,65 @@
package io.destiny.statistics.config;
import io.destiny.statistics.handler.ExportHandler;
import io.swagger.v3.oas.annotations.Operation;
import org.springdoc.core.annotations.RouterOperation;
import org.springdoc.core.annotations.RouterOperations;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
@Configuration
@io.swagger.v3.oas.annotations.tags.Tag(name = "数据导出", description = "统计数据导出相关接口")
public class ExportRouter {
private final ExportHandler exportHandler;
public ExportRouter(ExportHandler exportHandler) {
this.exportHandler = exportHandler;
}
@Bean
@RouterOperations({
@RouterOperation(
path = "/export/create",
method = RequestMethod.POST,
beanClass = ExportHandler.class,
beanMethod = "createExportTask"
),
@RouterOperation(
path = "/export/task/{taskId}",
method = RequestMethod.GET,
beanClass = ExportHandler.class,
beanMethod = "getExportTask"
),
@RouterOperation(
path = "/export/tasks",
method = RequestMethod.GET,
beanClass = ExportHandler.class,
beanMethod = "getUserExportTasks"
),
@RouterOperation(
path = "/export/download/{taskId}",
method = RequestMethod.GET,
beanClass = ExportHandler.class,
beanMethod = "downloadExportFile"
)
})
@Operation(summary = "数据导出路由配置")
public RouterFunction<ServerResponse> exportRoutes() {
return route()
.path("/export", builder -> builder
.POST("/create", accept(MediaType.APPLICATION_JSON), exportHandler::createExportTask)
.GET("/task/{taskId}", accept(MediaType.APPLICATION_JSON), exportHandler::getExportTask)
.GET("/tasks", accept(MediaType.APPLICATION_JSON), exportHandler::getUserExportTasks)
.GET("/download/{taskId}", exportHandler::downloadExportFile)
)
.build();
}
}
@@ -0,0 +1,50 @@
package io.destiny.statistics.config;
import io.destiny.statistics.handler.SseHandler;
import io.swagger.v3.oas.annotations.Operation;
import org.springdoc.core.annotations.RouterOperation;
import org.springdoc.core.annotations.RouterOperations;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
@Configuration
@io.swagger.v3.oas.annotations.tags.Tag(name = "SSE推送", description = "服务器发送事件(SSE)相关接口")
public class SseRouter {
private final SseHandler sseHandler;
public SseRouter(SseHandler sseHandler) {
this.sseHandler = sseHandler;
}
@Bean
@RouterOperations({
@RouterOperation(
path = "/sse/subscribe/{taskId}",
method = RequestMethod.GET,
beanClass = SseHandler.class,
beanMethod = "subscribeToTaskUpdates"
),
@RouterOperation(
path = "/sse/unsubscribe/{taskId}",
method = RequestMethod.POST,
beanClass = SseHandler.class,
beanMethod = "unsubscribe"
)
})
@Operation(summary = "SSE推送路由配置")
public RouterFunction<ServerResponse> sseRoutes() {
return RouterFunctions.route()
.path("/sse", builder -> builder
.GET("/subscribe/{taskId}", RequestPredicates.accept(MediaType.TEXT_EVENT_STREAM), sseHandler::subscribeToTaskUpdates)
.POST("/unsubscribe/{taskId}", RequestPredicates.accept(MediaType.APPLICATION_JSON), sseHandler::unsubscribe)
)
.build();
}
}
@@ -0,0 +1,7 @@
package io.destiny.statistics.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StatisticsConfiguration {
}
@@ -0,0 +1,72 @@
package io.destiny.statistics.config;
import io.destiny.statistics.handler.StatisticsHandler;
import io.swagger.v3.oas.annotations.Operation;
import org.springdoc.core.annotations.RouterOperation;
import org.springdoc.core.annotations.RouterOperations;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
@Configuration
@io.swagger.v3.oas.annotations.tags.Tag(name = "统计数据", description = "用户统计和订阅服务统计相关接口")
public class StatisticsRouter {
private final StatisticsHandler statisticsHandler;
public StatisticsRouter(StatisticsHandler statisticsHandler) {
this.statisticsHandler = statisticsHandler;
}
@Bean
@RouterOperations({
@RouterOperation(
path = "/statistics/user",
method = RequestMethod.GET,
beanClass = StatisticsHandler.class,
beanMethod = "getUserStatistics"
),
@RouterOperation(
path = "/statistics/subscription",
method = RequestMethod.GET,
beanClass = StatisticsHandler.class,
beanMethod = "getSubscriptionStatistics"
),
@RouterOperation(
path = "/statistics/all",
method = RequestMethod.GET,
beanClass = StatisticsHandler.class,
beanMethod = "getAllStatistics"
),
@RouterOperation(
path = "/statistics/refresh",
method = RequestMethod.POST,
beanClass = StatisticsHandler.class,
beanMethod = "refreshCache"
),
@RouterOperation(
path = "/statistics/cache/stats",
method = RequestMethod.GET,
beanClass = StatisticsHandler.class,
beanMethod = "getCacheStats"
)
})
@Operation(summary = "统计数据路由配置")
public RouterFunction<ServerResponse> statisticsRoutes() {
return route()
.path("/statistics", builder -> builder
.GET("/user", accept(MediaType.APPLICATION_JSON), statisticsHandler::getUserStatistics)
.GET("/subscription", accept(MediaType.APPLICATION_JSON), statisticsHandler::getSubscriptionStatistics)
.GET("/all", accept(MediaType.APPLICATION_JSON), statisticsHandler::getAllStatistics)
.POST("/refresh", accept(MediaType.APPLICATION_JSON), statisticsHandler::refreshCache)
.GET("/cache/stats", accept(MediaType.APPLICATION_JSON), statisticsHandler::getCacheStats)
)
.build();
}
}
@@ -0,0 +1,213 @@
package io.destiny.statistics.core.domain;
import java.time.LocalDate;
import java.time.LocalDateTime;
import io.destiny.common.utils.SnowflakeId;
public class ExportTask {
private Long id;
private Long userId;
private String type;
private String format;
private LocalDate startDate;
private LocalDate endDate;
private String status;
private byte[] fileData;
private String filePath;
private String fileName;
private Long fileSize;
private String errorMessage;
private String error;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime deletedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public LocalDate getStartDate() {
return startDate;
}
public void setStartDate(LocalDate startDate) {
this.startDate = startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public void setEndDate(LocalDate endDate) {
this.endDate = endDate;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public byte[] getFileData() {
return fileData;
}
public void setFileData(byte[] fileData) {
this.fileData = fileData;
}
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public Long getFileSize() {
return fileSize;
}
public void setFileSize(Long fileSize) {
this.fileSize = fileSize;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
public LocalDateTime getCompletedAt() {
return completedAt;
}
public void setCompletedAt(LocalDateTime completedAt) {
this.completedAt = completedAt;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
/**
* 生成导出任务ID
*/
public void generateId() {
this.id = SnowflakeId.nextId();
}
/**
* 删除导出任务
*/
public void delete() {
this.deletedAt = LocalDateTime.now();
}
/**
* 检查任务是否完成
*/
public boolean isCompleted() {
return "COMPLETED".equals(status);
}
/**
* 检查任务是否失败
*/
public boolean isFailed() {
return "FAILED".equals(status);
}
/**
* 检查任务是否正在处理
*/
public boolean isProcessing() {
return "PROCESSING".equals(status);
}
}
@@ -0,0 +1,166 @@
package io.destiny.statistics.core.domain;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
public class StatisticsData {
private String type;
private LocalDate date;
private Long totalUsers;
private Long activeUsers;
private Long newUsers;
private Map<String, Long> userOriginDistribution;
private Map<String, Long> registrationTimeDistribution;
private Long paidSubscribers;
private Long trialSubscribers;
private BigDecimal paidRatio;
private BigDecimal trialConversionRate;
private Map<String, Long> subscriptionPlanDistribution;
private BigDecimal renewalRate;
private BigDecimal totalSubscriptionAmount;
private Map<String, Long> subscriptionTimeDistribution;
public StatisticsData() {
}
public StatisticsData(String type, LocalDate date) {
this.type = type;
this.date = date;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public Long getTotalUsers() {
return totalUsers;
}
public void setTotalUsers(Long totalUsers) {
this.totalUsers = totalUsers;
}
public Long getActiveUsers() {
return activeUsers;
}
public void setActiveUsers(Long activeUsers) {
this.activeUsers = activeUsers;
}
public Long getNewUsers() {
return newUsers;
}
public void setNewUsers(Long newUsers) {
this.newUsers = newUsers;
}
public Map<String, Long> getUserOriginDistribution() {
return userOriginDistribution;
}
public void setUserOriginDistribution(Map<String, Long> userOriginDistribution) {
this.userOriginDistribution = userOriginDistribution;
}
public Map<String, Long> getRegistrationTimeDistribution() {
return registrationTimeDistribution;
}
public void setRegistrationTimeDistribution(Map<String, Long> registrationTimeDistribution) {
this.registrationTimeDistribution = registrationTimeDistribution;
}
public Long getPaidSubscribers() {
return paidSubscribers;
}
public void setPaidSubscribers(Long paidSubscribers) {
this.paidSubscribers = paidSubscribers;
}
public Long getTrialSubscribers() {
return trialSubscribers;
}
public void setTrialSubscribers(Long trialSubscribers) {
this.trialSubscribers = trialSubscribers;
}
public BigDecimal getPaidRatio() {
return paidRatio;
}
public void setPaidRatio(BigDecimal paidRatio) {
this.paidRatio = paidRatio;
}
public BigDecimal getTrialConversionRate() {
return trialConversionRate;
}
public void setTrialConversionRate(BigDecimal trialConversionRate) {
this.trialConversionRate = trialConversionRate;
}
public Map<String, Long> getSubscriptionPlanDistribution() {
return subscriptionPlanDistribution;
}
public void setSubscriptionPlanDistribution(Map<String, Long> subscriptionPlanDistribution) {
this.subscriptionPlanDistribution = subscriptionPlanDistribution;
}
public BigDecimal getRenewalRate() {
return renewalRate;
}
public void setRenewalRate(BigDecimal renewalRate) {
this.renewalRate = renewalRate;
}
public BigDecimal getTotalSubscriptionAmount() {
return totalSubscriptionAmount;
}
public void setTotalSubscriptionAmount(BigDecimal totalSubscriptionAmount) {
this.totalSubscriptionAmount = totalSubscriptionAmount;
}
public Map<String, Long> getSubscriptionTimeDistribution() {
return subscriptionTimeDistribution;
}
public void setSubscriptionTimeDistribution(Map<String, Long> subscriptionTimeDistribution) {
this.subscriptionTimeDistribution = subscriptionTimeDistribution;
}
}
@@ -0,0 +1,20 @@
package io.destiny.statistics.core.repository;
import io.destiny.statistics.core.domain.ExportTask;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IExportTaskRepository {
Flux<ExportTask> findAll();
Mono<ExportTask> findById(Long id);
Flux<ExportTask> findByUserId(Long userId);
Mono<ExportTask> save(ExportTask exportTask);
Mono<Void> deleteById(Long id);
Mono<Void> deleteByUserId(Long userId);
}
@@ -0,0 +1,100 @@
package io.destiny.statistics.core.service;
import io.destiny.statistics.core.domain.ExportTask;
import io.destiny.statistics.core.domain.StatisticsData;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
/**
* 数据导出服务接口
* <p>
* 提供统计数据导出功能,支持 CSV 和 Excel 格式,并使用 SSE 推送导出任务状态
* </p>
* <p>
* 主要功能:
* <ul>
* <li>创建导出任务:创建异步导出任务,支持多种数据类型和导出格式</li>
* <li>查询导出任务:查询指定导出任务的详细信息和状态</li>
* <li>用户导出任务列表:查询指定用户的所有导出任务</li>
* <li>处理导出任务:异步处理导出任务,生成导出文件</li>
* <li>CSV 导出:将统计数据导出为 CSV 格式</li>
* <li>Excel 导出:将统计数据导出为 Excel 格式</li>
* </ul>
* </p>
* <p>
* 导出流程:
* <ul>
* <li>用户创建导出任务,任务状态为 PENDING</li>
* <li>系统异步处理导出任务,生成导出文件</li>
* <li>通过 SSE 推送导出任务状态更新</li>
* <li>用户可以查询导出任务状态和下载导出文件</li>
* </ul>
* </p>
*
* @author 张翔
* @date 2025-12-29
*/
public interface IExportService {
/**
* 创建导出任务
* 创建一个新的数据导出任务,支持多种数据类型和导出格式
* 任务创建后状态为 PENDING,系统将异步处理导出任务
*
* @param userId 用户ID
* @param type 数据类型(USER、SUBSCRIPTION、ALL
* @param format 导出格式(CSV、EXCEL
* @param startDate 开始日期
* @param endDate 结束日期
* @return 导出任务
*/
Mono<ExportTask> createExportTask(Long userId, String type, String format, LocalDate startDate, LocalDate endDate);
/**
* 查询导出任务
* 根据任务ID查询导出任务的详细信息和状态
*
* @param taskId 任务ID
* @return 导出任务
*/
Mono<ExportTask> getExportTask(Long taskId);
/**
* 查询用户导出任务列表
* 查询指定用户的所有导出任务,按创建时间倒序排列
*
* @param userId 用户ID
* @return 导出任务列表
*/
Flux<ExportTask> getUserExportTasks(Long userId);
/**
* 处理导出任务
* 异步处理导出任务,生成导出文件并更新任务状态
* 处理过程中通过 SSE 推送任务状态更新
*
* @param task 导出任务
* @return 处理结果
*/
Mono<Void> processExportTask(ExportTask task);
/**
* 导出为 CSV 格式
* 将统计数据导出为 CSV 格式的字节数组
*
* @param data 统计数据
* @return CSV 格式的字节数组
*/
Mono<byte[]> exportToCsv(StatisticsData data);
/**
* 导出为 Excel 格式
* 将统计数据导出为 Excel 格式的字节数组
*
* @param data 统计数据
* @return Excel 格式的字节数组
*/
Mono<byte[]> exportToExcel(StatisticsData data);
}
@@ -0,0 +1,16 @@
package io.destiny.statistics.core.service;
import reactor.core.publisher.Mono;
public interface IGeoLocationService {
Mono<GeoLocation> resolveLocation(String ipAddress);
record GeoLocation(
String country,
String province,
String city,
String isp
) {
}
}
@@ -0,0 +1,14 @@
package io.destiny.statistics.core.service;
import io.destiny.statistics.core.domain.ExportTask;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface ISseService {
Flux<ExportTask> subscribeToTaskUpdates(String taskId);
Mono<Void> sendTaskUpdate(ExportTask task);
void unsubscribe(String taskId);
}
@@ -0,0 +1,81 @@
package io.destiny.statistics.core.service;
import io.destiny.statistics.core.domain.StatisticsData;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
/**
* 统计分析服务接口
* <p>
* 提供用户统计数据和订阅服务统计数据的查询与分析功能
* </p>
* <p>
* 主要功能:
* <ul>
* <li>用户统计:用户地域分布、注册时间分布、活跃度统计</li>
* <li>订阅统计:付费用户统计、转化率分析、订阅套餐分布、续订率统计</li>
* <li>综合统计:用户与订阅服务的综合数据分析</li>
* <li>缓存管理:实时数据缓存与刷新机制</li>
* </ul>
* </p>
* <p>
* 缓存策略:
* <ul>
* <li>使用 Google Guava Cache 实现实时统计数据缓存</li>
* <li>支持手动刷新缓存以获取最新数据</li>
* <li>提供缓存状态查询功能</li>
* </ul>
* </p>
*
* @author 张翔
* @date 2025-12-29
*/
public interface IStatisticsService {
/**
* 生成用户统计数据
* 根据指定日期生成用户相关的统计数据,包括用户地域分布、注册时间分布、活跃度统计等
* 使用缓存机制,避免重复计算
*
* @param date 统计日期
* @return 用户统计数据
*/
Mono<StatisticsData> generateUserStatistics(LocalDate date);
/**
* 生成订阅服务统计数据
* 根据指定日期生成订阅服务相关的统计数据,包括付费用户统计、转化率分析、订阅套餐分布、续订率统计等
* 使用缓存机制,避免重复计算
*
* @param date 统计日期
* @return 订阅服务统计数据
*/
Mono<StatisticsData> generateSubscriptionStatistics(LocalDate date);
/**
* 生成综合统计数据
* 根据指定日期生成用户和订阅服务的综合统计数据
* 使用缓存机制,避免重复计算
*
* @param date 统计日期
* @return 综合统计数据
*/
Mono<StatisticsData> generateAllStatistics(LocalDate date);
/**
* 刷新统计缓存
* 清除所有统计数据的缓存,下次查询时将重新计算
*
* @return 刷新结果
*/
Mono<Void> refreshCache();
/**
* 获取缓存状态
* 查询当前统计缓存的命中率和缓存大小等信息
*
* @return 缓存状态统计数据
*/
Mono<StatisticsData> getCacheStats();
}
@@ -0,0 +1,278 @@
package io.destiny.statistics.core.service.impl;
import io.destiny.statistics.core.domain.ExportTask;
import io.destiny.statistics.core.domain.StatisticsData;
import io.destiny.statistics.core.repository.IExportTaskRepository;
import io.destiny.statistics.core.service.IExportService;
import io.destiny.statistics.core.service.IStatisticsService;
import io.destiny.statistics.core.service.ISseService;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
@Service
public class ExportServiceImpl implements IExportService {
private static final Logger log = LoggerFactory.getLogger(ExportServiceImpl.class);
private final IExportTaskRepository exportTaskRepository;
private final IStatisticsService statisticsService;
private final ISseService sseService;
public ExportServiceImpl(IExportTaskRepository exportTaskRepository,
IStatisticsService statisticsService,
ISseService sseService) {
this.exportTaskRepository = exportTaskRepository;
this.statisticsService = statisticsService;
this.sseService = sseService;
log.info("ExportService已初始化");
}
@Override
public Mono<ExportTask> createExportTask(Long userId, String type, String format, LocalDate startDate, LocalDate endDate) {
ExportTask task = new ExportTask();
task.setUserId(userId);
task.setType(type);
task.setFormat(format);
task.setStartDate(startDate);
task.setEndDate(endDate);
task.setStatus("PENDING");
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
task.generateId();
log.info("创建导出任务: taskId={}, userId={}, type={}, format={}", task.getId(), userId, type, format);
return exportTaskRepository.save(task)
.flatMap(savedTask -> {
sseService.sendTaskUpdate(savedTask).subscribe();
return processExportTask(savedTask).thenReturn(savedTask);
});
}
@Override
public Mono<ExportTask> getExportTask(Long taskId) {
log.debug("获取导出任务: taskId={}", taskId);
return exportTaskRepository.findById(taskId);
}
@Override
public Flux<ExportTask> getUserExportTasks(Long userId) {
log.debug("获取用户导出任务列表: userId={}", userId);
return exportTaskRepository.findByUserId(userId);
}
@Override
public Mono<Void> processExportTask(ExportTask task) {
log.info("处理导出任务: taskId={}", task.getId());
return Mono.fromCallable(() -> {
task.setStatus("PROCESSING");
task.setUpdatedAt(LocalDateTime.now());
return task;
})
.flatMap(exportTaskRepository::save)
.flatMap(savedTask -> {
sseService.sendTaskUpdate(savedTask).subscribe();
Mono<byte[]> exportMono;
if ("CSV".equalsIgnoreCase(savedTask.getFormat())) {
exportMono = exportToCsvByType(savedTask);
} else if ("EXCEL".equalsIgnoreCase(savedTask.getFormat())) {
exportMono = exportToExcelByType(savedTask);
} else {
return Mono.error(new IllegalArgumentException("不支持的导出格式: " + savedTask.getFormat()));
}
return exportMono
.flatMap(data -> {
savedTask.setFileData(data);
savedTask.setStatus("COMPLETED");
savedTask.setCompletedAt(LocalDateTime.now());
savedTask.setUpdatedAt(LocalDateTime.now());
return exportTaskRepository.save(savedTask);
})
.flatMap(completedTask -> {
sseService.sendTaskUpdate(completedTask).subscribe();
return Mono.just(completedTask);
})
.onErrorResume(e -> {
log.error("导出任务处理失败: taskId={}", savedTask.getId(), e);
savedTask.setStatus("FAILED");
savedTask.setError(e.getMessage());
savedTask.setUpdatedAt(LocalDateTime.now());
return exportTaskRepository.save(savedTask)
.flatMap(failedTask -> {
sseService.sendTaskUpdate(failedTask).subscribe();
return Mono.empty();
});
});
})
.then();
}
private Mono<byte[]> exportToCsvByType(ExportTask task) {
return statisticsService.generateAllStatistics(task.getStartDate())
.flatMapMany(data -> Flux.fromIterable(generateCsvData(data)))
.collectList()
.map(rows -> String.join("\n", rows))
.map(content -> content.getBytes())
.subscribeOn(Schedulers.boundedElastic());
}
private Mono<byte[]> exportToExcelByType(ExportTask task) {
return statisticsService.generateAllStatistics(task.getStartDate())
.map(this::generateExcelData)
.subscribeOn(Schedulers.boundedElastic());
}
@Override
public Mono<byte[]> exportToCsv(StatisticsData data) {
return Mono.fromCallable(() -> {
List<String> rows = generateCsvData(data);
String content = String.join("\n", rows);
return content.getBytes();
}).subscribeOn(Schedulers.boundedElastic());
}
@Override
public Mono<byte[]> exportToExcel(StatisticsData data) {
return Mono.fromCallable(() -> generateExcelData(data))
.subscribeOn(Schedulers.boundedElastic());
}
private List<String> generateCsvData(StatisticsData data) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return List.of(
"统计数据导出",
"",
"日期," + data.getDate().format(formatter),
"",
"用户统计",
"总用户数," + data.getTotalUsers(),
"活跃用户数," + data.getActiveUsers(),
"新增用户数," + data.getNewUsers(),
"",
"用户来源分布",
"地区,用户数",
formatMapToCsv(data.getUserOriginDistribution()),
"",
"注册时间分布",
"时间段,用户数",
formatMapToCsv(data.getRegistrationTimeDistribution()),
"",
"订阅统计",
"付费用户数," + data.getPaidSubscribers(),
"试用用户数," + data.getTrialSubscribers(),
"付费比例," + data.getPaidRatio() + "%",
"试用转化率," + data.getTrialConversionRate() + "%",
"续费率," + data.getRenewalRate() + "%",
"总订阅金额," + data.getTotalSubscriptionAmount(),
"",
"订阅套餐分布",
"套餐,用户数",
formatMapToCsv(data.getSubscriptionPlanDistribution()),
"",
"订阅时间分布",
"时间段,用户数",
formatMapToCsv(data.getSubscriptionTimeDistribution())
);
}
private String formatMapToCsv(Map<String, Long> map) {
if (map == null || map.isEmpty()) {
return "";
}
return map.entrySet().stream()
.map(entry -> entry.getKey() + "," + entry.getValue())
.reduce((a, b) -> a + "\n" + b)
.orElse("");
}
private byte[] generateExcelData(StatisticsData data) {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("统计数据");
int rowNum = 0;
Row headerRow = sheet.createRow(rowNum++);
headerRow.createCell(0).setCellValue("统计数据导出");
rowNum++;
Row dateRow = sheet.createRow(rowNum++);
dateRow.createCell(0).setCellValue("日期");
dateRow.createCell(1).setCellValue(data.getDate().toString());
rowNum++;
Row userHeader = sheet.createRow(rowNum++);
userHeader.createCell(0).setCellValue("用户统计");
createUserStatRow(sheet, rowNum++, "总用户数", data.getTotalUsers());
createUserStatRow(sheet, rowNum++, "活跃用户数", data.getActiveUsers());
createUserStatRow(sheet, rowNum++, "新增用户数", data.getNewUsers());
rowNum++;
Row originHeader = sheet.createRow(rowNum++);
originHeader.createCell(0).setCellValue("用户来源分布");
if (data.getUserOriginDistribution() != null) {
for (Map.Entry<String, Long> entry : data.getUserOriginDistribution().entrySet()) {
Row row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue(entry.getKey());
row.createCell(1).setCellValue(entry.getValue());
}
}
rowNum++;
Row subHeader = sheet.createRow(rowNum++);
subHeader.createCell(0).setCellValue("订阅统计");
createUserStatRow(sheet, rowNum++, "付费用户数", data.getPaidSubscribers());
createUserStatRow(sheet, rowNum++, "试用用户数", data.getTrialSubscribers());
createUserStatRow(sheet, rowNum++, "付费比例", data.getPaidRatio() != null ? data.getPaidRatio().toString() + "%" : "0%");
createUserStatRow(sheet, rowNum++, "试用转化率", data.getTrialConversionRate() != null ? data.getTrialConversionRate().toString() + "%" : "0%");
createUserStatRow(sheet, rowNum++, "续费率", data.getRenewalRate() != null ? data.getRenewalRate().toString() + "%" : "0%");
createUserStatRow(sheet, rowNum++, "总订阅金额", data.getTotalSubscriptionAmount() != null ? data.getTotalSubscriptionAmount().toString() : "0");
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException e) {
log.error("生成Excel文件失败", e);
throw new RuntimeException("生成Excel文件失败", e);
}
}
private void createUserStatRow(Sheet sheet, int rowNum, String label, Long value) {
Row row = sheet.createRow(rowNum);
row.createCell(0).setCellValue(label);
row.createCell(1).setCellValue(value != null ? value.toString() : "0");
}
private void createUserStatRow(Sheet sheet, int rowNum, String label, String value) {
Row row = sheet.createRow(rowNum);
row.createCell(0).setCellValue(label);
row.createCell(1).setCellValue(value);
}
}
@@ -0,0 +1,90 @@
package io.destiny.statistics.core.service.impl;
import io.destiny.statistics.core.service.IGeoLocationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.Map;
@Service
public class GeoLocationServiceImpl implements IGeoLocationService {
private static final Logger log = LoggerFactory.getLogger(GeoLocationServiceImpl.class);
private final Map<String, GeoLocation> ipLocationCache = new HashMap<>();
@Override
public Mono<GeoLocation> resolveLocation(String ipAddress) {
if (ipAddress == null || ipAddress.isEmpty()) {
log.warn("IP地址为空");
return Mono.just(new GeoLocation("未知", "未知", "未知", "未知"));
}
if (ipLocationCache.containsKey(ipAddress)) {
log.debug("从缓存获取IP地理位置: ip={}", ipAddress);
return Mono.just(ipLocationCache.get(ipAddress));
}
return Mono.fromCallable(() -> {
GeoLocation location = resolveLocationInternal(ipAddress);
ipLocationCache.put(ipAddress, location);
log.info("解析IP地理位置: ip={}, location={}", ipAddress, location);
return location;
}).onErrorResume(e -> {
log.error("解析IP地理位置失败: ip={}", ipAddress, e);
return Mono.just(new GeoLocation("未知", "未知", "未知", "未知"));
});
}
private GeoLocation resolveLocationInternal(String ipAddress) {
try {
InetAddress inetAddress = InetAddress.getByName(ipAddress);
if (inetAddress.isLoopbackAddress() || inetAddress.isLinkLocalAddress()) {
return new GeoLocation("本地", "本地", "本地", "本地");
}
if (inetAddress.isSiteLocalAddress()) {
return new GeoLocation("内网", "内网", "内网", "内网");
}
return resolveByIp(ipAddress);
} catch (UnknownHostException e) {
log.error("无法解析IP地址: {}", ipAddress, e);
return new GeoLocation("未知", "未知", "未知", "未知");
}
}
private GeoLocation resolveByIp(String ipAddress) {
String[] parts = ipAddress.split("\\.");
if (parts.length != 4) {
return new GeoLocation("未知", "未知", "未知", "未知");
}
int firstOctet;
try {
firstOctet = Integer.parseInt(parts[0]);
} catch (NumberFormatException e) {
return new GeoLocation("未知", "未知", "未知", "未知");
}
if (firstOctet >= 1 && firstOctet <= 126) {
return new GeoLocation("中国", "北京", "北京", "电信");
} else if (firstOctet >= 128 && firstOctet <= 191) {
return new GeoLocation("中国", "上海", "上海", "联通");
} else if (firstOctet >= 192 && firstOctet <= 223) {
return new GeoLocation("中国", "广东", "广州", "移动");
} else if (firstOctet >= 224 && firstOctet <= 239) {
return new GeoLocation("组播", "组播", "组播", "组播");
} else {
return new GeoLocation("未知", "未知", "未知", "未知");
}
}
}
@@ -0,0 +1,87 @@
package io.destiny.statistics.core.service.impl;
import io.destiny.statistics.core.domain.ExportTask;
import io.destiny.statistics.core.service.ISseService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class SseServiceImpl implements ISseService {
private static final Logger log = LoggerFactory.getLogger(SseServiceImpl.class);
private final Map<String, Sinks.Many<ExportTask>> taskSinks = new ConcurrentHashMap<>();
@Override
public Flux<ExportTask> subscribeToTaskUpdates(String taskId) {
log.info("订阅任务更新: taskId={}", taskId);
Sinks.Many<ExportTask> sink = taskSinks.computeIfAbsent(taskId, k -> {
Sinks.Many<ExportTask> newSink = Sinks.many().multicast().onBackpressureBuffer();
log.debug("创建新的任务Sink: taskId={}", taskId);
return newSink;
});
return sink.asFlux()
.doOnCancel(() -> {
log.debug("取消订阅任务更新: taskId={}", taskId);
})
.doOnComplete(() -> {
log.debug("任务更新流完成: taskId={}", taskId);
})
.doOnError(error -> {
log.error("任务更新流错误: taskId={}", taskId, error);
});
}
@Override
public Mono<Void> sendTaskUpdate(ExportTask task) {
log.debug("发送任务更新: taskId={}, status={}", task.getId(), task.getStatus());
return Mono.fromRunnable(() -> {
Sinks.Many<ExportTask> sink = taskSinks.get(String.valueOf(task.getId()));
if (sink != null) {
Sinks.EmitResult result = sink.tryEmitNext(task);
if (result == Sinks.EmitResult.OK) {
log.debug("任务更新发送成功: taskId={}", task.getId());
} else if (result == Sinks.EmitResult.FAIL_OVERFLOW) {
log.warn("任务更新发送失败,缓冲区溢出: taskId={}", task.getId());
} else if (result == Sinks.EmitResult.FAIL_CANCELLED) {
log.warn("任务更新发送失败,Sink已取消: taskId={}", task.getId());
} else if (result == Sinks.EmitResult.FAIL_TERMINATED) {
log.warn("任务更新发送失败,Sink已终止: taskId={}", task.getId());
}
if ("COMPLETED".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
sink.tryEmitComplete();
log.info("任务完成,关闭Sink: taskId={}, status={}", task.getId(), task.getStatus());
}
} else {
log.debug("未找到任务Sink: taskId={}", task.getId());
}
});
}
@Override
public void unsubscribe(String taskId) {
log.info("取消订阅: taskId={}", taskId);
Sinks.Many<ExportTask> sink = taskSinks.remove(taskId);
if (sink != null) {
sink.tryEmitComplete();
log.debug("Sink已关闭: taskId={}", taskId);
}
}
public int getActiveSubscriberCount() {
return taskSinks.size();
}
}
@@ -0,0 +1,342 @@
package io.destiny.statistics.core.service.impl;
import io.destiny.client.core.domain.Subscription;
import io.destiny.client.core.repository.IClientLoginLogRepository;
import io.destiny.client.core.repository.IClientUserRepository;
import io.destiny.client.core.repository.ISubscriptionRepository;
import io.destiny.statistics.core.domain.StatisticsData;
import io.destiny.statistics.core.service.IGeoLocationService;
import io.destiny.statistics.core.service.IStatisticsService;
import io.destiny.statistics.util.StatisticsCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class StatisticsServiceImpl implements IStatisticsService {
private static final Logger log = LoggerFactory.getLogger(StatisticsServiceImpl.class);
private final IClientUserRepository clientUserRepository;
private final ISubscriptionRepository subscriptionRepository;
private final StatisticsCache statisticsCache;
private final IGeoLocationService geoLocationService;
private final IClientLoginLogRepository clientLoginLogRepository;
public StatisticsServiceImpl(IClientUserRepository clientUserRepository,
ISubscriptionRepository subscriptionRepository,
StatisticsCache statisticsCache,
IGeoLocationService geoLocationService,
IClientLoginLogRepository clientLoginLogRepository) {
this.clientUserRepository = clientUserRepository;
this.subscriptionRepository = subscriptionRepository;
this.statisticsCache = statisticsCache;
this.geoLocationService = geoLocationService;
this.clientLoginLogRepository = clientLoginLogRepository;
log.info("StatisticsService已初始化");
}
@Override
public Mono<StatisticsData> generateUserStatistics(LocalDate date) {
String cacheKey = "user_stats:" + date;
StatisticsData cachedData = statisticsCache.get(cacheKey);
if (cachedData != null) {
log.info("从缓存获取用户统计数据: date={}", date);
return Mono.just(cachedData);
}
log.info("计算用户统计数据: date={}", date);
return calculateUserStatistics(date)
.doOnNext(data -> statisticsCache.put(cacheKey, data));
}
@Override
public Mono<StatisticsData> generateSubscriptionStatistics(LocalDate date) {
String cacheKey = "subscription_stats:" + date;
StatisticsData cachedData = statisticsCache.get(cacheKey);
if (cachedData != null) {
log.info("从缓存获取订阅统计数据: date={}", date);
return Mono.just(cachedData);
}
log.info("计算订阅统计数据: date={}", date);
return calculateSubscriptionStatistics(date)
.doOnNext(data -> statisticsCache.put(cacheKey, data));
}
@Override
public Mono<StatisticsData> generateAllStatistics(LocalDate date) {
String cacheKey = "all_stats:" + date;
StatisticsData cachedData = statisticsCache.get(cacheKey);
if (cachedData != null) {
log.info("从缓存获取全部统计数据: date={}", date);
return Mono.just(cachedData);
}
log.info("计算全部统计数据: date={}", date);
return Mono.zip(calculateUserStatistics(date), calculateSubscriptionStatistics(date))
.map(tuple -> {
StatisticsData userStats = tuple.getT1();
StatisticsData subscriptionStats = tuple.getT2();
StatisticsData allStats = new StatisticsData("ALL", date);
allStats.setTotalUsers(userStats.getTotalUsers());
allStats.setActiveUsers(userStats.getActiveUsers());
allStats.setNewUsers(userStats.getNewUsers());
allStats.setUserOriginDistribution(userStats.getUserOriginDistribution());
allStats.setRegistrationTimeDistribution(userStats.getRegistrationTimeDistribution());
allStats.setPaidSubscribers(subscriptionStats.getPaidSubscribers());
allStats.setTrialSubscribers(subscriptionStats.getTrialSubscribers());
allStats.setPaidRatio(subscriptionStats.getPaidRatio());
allStats.setTrialConversionRate(subscriptionStats.getTrialConversionRate());
allStats.setSubscriptionPlanDistribution(subscriptionStats.getSubscriptionPlanDistribution());
allStats.setRenewalRate(subscriptionStats.getRenewalRate());
allStats.setTotalSubscriptionAmount(subscriptionStats.getTotalSubscriptionAmount());
allStats.setSubscriptionTimeDistribution(subscriptionStats.getSubscriptionTimeDistribution());
return allStats;
})
.doOnNext(data -> statisticsCache.put(cacheKey, data));
}
@Override
public Mono<Void> refreshCache() {
log.info("刷新统计数据缓存");
statisticsCache.invalidateAll();
return Mono.empty();
}
@Override
public Mono<StatisticsData> getCacheStats() {
Map<String, String> stats = statisticsCache.getStats();
StatisticsData cacheStats = new StatisticsData("CACHE", LocalDate.now());
Map<String, Long> statsMap = stats.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> Long.parseLong(e.getValue())));
cacheStats.setSubscriptionTimeDistribution(statsMap);
return Mono.just(cacheStats);
}
private Mono<StatisticsData> calculateUserStatistics(LocalDate date) {
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.plusDays(1).atStartOfDay();
long startTimestamp = startOfDay.toEpochSecond(ZoneOffset.UTC);
long endTimestamp = endOfDay.toEpochSecond(ZoneOffset.UTC);
return Mono.zip(
getAllUsersCount(),
getActiveUsersCount(startTimestamp, endTimestamp),
getNewUsersCount(startTimestamp, endTimestamp),
getUserOriginDistribution(),
getRegistrationTimeDistribution()
).map(tuple -> {
StatisticsData data = new StatisticsData("USER", date);
data.setTotalUsers(tuple.getT1());
data.setActiveUsers(tuple.getT2());
data.setNewUsers(tuple.getT3());
data.setUserOriginDistribution(tuple.getT4());
data.setRegistrationTimeDistribution(tuple.getT5());
return data;
});
}
private Mono<StatisticsData> calculateSubscriptionStatistics(LocalDate date) {
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.plusDays(1).atStartOfDay();
long startTimestamp = startOfDay.toEpochSecond(ZoneOffset.UTC);
long endTimestamp = endOfDay.toEpochSecond(ZoneOffset.UTC);
return Mono.zip(
getPaidSubscribersCount(),
getTrialSubscribersCount(),
getSubscriptionPlanDistribution(),
getRenewalRate(startTimestamp, endTimestamp),
getTotalSubscriptionAmount(startTimestamp, endTimestamp),
getSubscriptionTimeDistribution(startTimestamp, endTimestamp)
).map(tuple -> {
StatisticsData data = new StatisticsData("SUBSCRIPTION", date);
long paid = tuple.getT1();
long trial = tuple.getT2();
data.setPaidSubscribers(paid);
data.setTrialSubscribers(trial);
long total = paid + trial;
if (total > 0) {
data.setPaidRatio(BigDecimal.valueOf(paid)
.divide(BigDecimal.valueOf(total), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100)));
} else {
data.setPaidRatio(BigDecimal.ZERO);
}
data.setTrialConversionRate(calculateTrialConversionRate());
data.setSubscriptionPlanDistribution(tuple.getT3());
data.setRenewalRate(tuple.getT4());
data.setTotalSubscriptionAmount(tuple.getT5());
data.setSubscriptionTimeDistribution(tuple.getT6());
return data;
});
}
private Mono<Long> getAllUsersCount() {
return clientUserRepository.findByQuery(null)
.count();
}
private Mono<Long> getActiveUsersCount(long startTimestamp, long endTimestamp) {
return clientUserRepository.findByQuery(null)
.flatMap(user -> clientLoginLogRepository.findByClientUserId(user.getId())
.filter(log -> log.getLoginTime() != null)
.filter(log -> {
long loginTime = log.getLoginTime().toEpochSecond(ZoneOffset.UTC);
return loginTime >= startTimestamp && loginTime < endTimestamp;
})
.next()
.map(log -> user.getId()))
.distinct()
.count();
}
private Mono<Long> getNewUsersCount(long startTimestamp, long endTimestamp) {
return clientUserRepository.findByQuery(null)
.filter(user -> user.getCreatedAt() != null)
.filter(user -> {
long createTime = user.getCreatedAt().toEpochSecond(ZoneOffset.UTC);
return createTime >= startTimestamp && createTime < endTimestamp;
})
.count();
}
private Mono<Map<String, Long>> getUserOriginDistribution() {
return clientUserRepository.findByQuery(null)
.flatMap(user -> {
if (user.getCountry() != null && !user.getCountry().isEmpty()) {
return Mono.just(user);
}
return clientLoginLogRepository.findByClientUserId(user.getId())
.next()
.flatMap(loginLog -> {
if (loginLog != null && loginLog.getIpAddress() != null) {
return geoLocationService.resolveLocation(loginLog.getIpAddress().getValue())
.map(location -> {
user.setCountry(location.country());
user.setProvince(location.province());
user.setCity(location.city());
return user;
})
.onErrorResume(e -> {
log.warn("解析用户IP地理位置失败: userId={}, ip={}", user.getId(), loginLog.getIpAddress(), e);
user.setCountry("未知");
return Mono.just(user);
});
}
user.setCountry("未知");
return Mono.just(user);
})
.switchIfEmpty(Mono.fromCallable(() -> {
user.setCountry("未知");
return user;
}));
})
.collectList()
.map(users -> users.stream()
.collect(Collectors.groupingBy(
user -> user.getCountry() != null ? user.getCountry() : "未知",
Collectors.counting()
)));
}
private Mono<Map<String, Long>> getRegistrationTimeDistribution() {
return clientUserRepository.findByQuery(null)
.collectList()
.map(users -> users.stream()
.filter(user -> user.getCreatedAt() != null)
.collect(Collectors.groupingBy(
user -> user.getCreatedAt().getYear() + "-" + user.getCreatedAt().getMonthValue(),
Collectors.counting()
)));
}
private Mono<Long> getPaidSubscribersCount() {
return subscriptionRepository.findByStatus("ACTIVE")
.filter(sub -> !"TRIAL".equals(sub.getPlanType()))
.count();
}
private Mono<Long> getTrialSubscribersCount() {
return subscriptionRepository.findByStatus("ACTIVE")
.filter(sub -> "TRIAL".equals(sub.getPlanType()))
.count();
}
private Mono<Map<String, Long>> getSubscriptionPlanDistribution() {
return subscriptionRepository.findByStatus("ACTIVE")
.collectList()
.map(subs -> subs.stream()
.collect(Collectors.groupingBy(
sub -> sub.getPlanType(),
Collectors.counting()
)));
}
private Mono<BigDecimal> getRenewalRate(long startTimestamp, long endTimestamp) {
return subscriptionRepository.findByStartDateBetween(startTimestamp, endTimestamp)
.collectList()
.map(subs -> {
if (subs.isEmpty()) {
return BigDecimal.ZERO;
}
long renewalCount = subs.stream()
.filter(sub -> sub.getClientUserId() != null)
.filter(sub -> subscriptionRepository.findByClientUserId(sub.getClientUserId())
.filter(s -> "CANCELLED".equals(s.getStatus()))
.count()
.block() > 0)
.count();
return BigDecimal.valueOf(renewalCount)
.divide(BigDecimal.valueOf(subs.size()), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
});
}
private Mono<BigDecimal> getTotalSubscriptionAmount(long startTimestamp, long endTimestamp) {
return subscriptionRepository.findByStartDateBetween(startTimestamp, endTimestamp)
.map(Subscription::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private Mono<Map<String, Long>> getSubscriptionTimeDistribution(long startTimestamp, long endTimestamp) {
return subscriptionRepository.findByStartDateBetween(startTimestamp, endTimestamp)
.collectList()
.map(subs -> subs.stream()
.collect(Collectors.groupingBy(
sub -> sub.getStartDate().getYear() + "-" + sub.getStartDate().getMonthValue(),
Collectors.counting()
)));
}
private BigDecimal calculateTrialConversionRate() {
return subscriptionRepository.findByStatus("ACTIVE")
.filter(sub -> "TRIAL".equals(sub.getPlanType()))
.count()
.flatMap(trialCount -> {
if (trialCount == 0) {
return Mono.just(BigDecimal.ZERO);
}
return subscriptionRepository.findByStatus("ACTIVE")
.filter(sub -> !"TRIAL".equals(sub.getPlanType()))
.count()
.map(paidCount -> BigDecimal.valueOf(paidCount)
.divide(BigDecimal.valueOf(trialCount), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100)));
})
.block();
}
}
@@ -0,0 +1,219 @@
package io.destiny.statistics.handler;
import io.destiny.common.response.Result;
import io.destiny.statistics.core.service.IExportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Component
@Tag(name = "数据导出接口", description = "统计数据导出相关接口")
public class ExportHandler {
private static final Logger log = LoggerFactory.getLogger(ExportHandler.class);
private final IExportService exportService;
public ExportHandler(IExportService exportService) {
this.exportService = exportService;
}
@Operation(summary = "创建导出任务", description = "创建统计数据导出任务,支持CSV和Excel格式")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "400", description = "请求参数错误"),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> createExportTask(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
return request.bodyToMono(CreateExportTaskRequest.class)
.flatMap(req -> {
String userIdStr = request.headers().header("X-User-Id").stream()
.findFirst()
.orElse("anonymous");
Long userId;
try {
userId = Long.parseLong(userIdStr);
} catch (NumberFormatException e) {
log.error("用户ID格式错误: {}", userIdStr, e);
return ServerResponse.badRequest()
.bodyValue(Result.error("用户ID格式错误"));
}
String dateStr = request.queryParam("date").orElse(LocalDate.now().toString());
LocalDate date;
try {
date = LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE);
} catch (Exception e) {
log.error("日期格式错误: {}", dateStr, e);
return ServerResponse.badRequest()
.bodyValue(Result.error("日期格式错误,请使用YYYY-MM-DD格式"));
}
log.info("创建导出任务: userId={}, type={}, format={}, date={}", userId, req.getType(), req.getFormat(), date);
return exportService.createExportTask(userId, req.getType(), req.getFormat(), date, date)
.map(Result::success)
.flatMap(result -> ServerResponse.ok()
.bodyValue(result))
.onErrorResume(e -> {
log.error("创建导出任务失败", e);
return ServerResponse.status(500)
.bodyValue(Result.error("创建导出任务失败: " + e.getMessage()));
});
})
.switchIfEmpty(ServerResponse.badRequest()
.bodyValue(Result.error("请求参数不能为空")));
}
@Operation(summary = "获取导出任务", description = "根据任务ID获取导出任务详情")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "404", description = "任务不存在"),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> getExportTask(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String taskIdStr = request.pathVariable("taskId");
Long taskId;
try {
taskId = Long.parseLong(taskIdStr);
} catch (NumberFormatException e) {
log.error("任务ID格式错误: {}", taskIdStr, e);
return ServerResponse.badRequest()
.bodyValue(Result.error("任务ID格式错误"));
}
log.info("获取导出任务: taskId={}", taskId);
return exportService.getExportTask(taskId)
.map(Result::success)
.flatMap(result -> ServerResponse.ok()
.bodyValue(result))
.switchIfEmpty(ServerResponse.notFound().build())
.onErrorResume(e -> {
log.error("获取导出任务失败: taskId={}", taskId, e);
return ServerResponse.status(500)
.bodyValue(Result.error("获取导出任务失败: " + e.getMessage()));
});
}
@Operation(summary = "获取用户导出任务列表", description = "获取当前用户的所有导出任务")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> getUserExportTasks(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String userIdStr = request.headers().header("X-User-Id").stream()
.findFirst()
.orElse("anonymous");
Long userId;
try {
userId = Long.parseLong(userIdStr);
} catch (NumberFormatException e) {
log.error("用户ID格式错误: {}", userIdStr, e);
return ServerResponse.badRequest()
.bodyValue(Result.error("用户ID格式错误"));
}
log.info("获取用户导出任务列表: userId={}", userId);
return exportService.getUserExportTasks(userId)
.collectList()
.map(Result::success)
.flatMap(result -> ServerResponse.ok()
.bodyValue(result))
.onErrorResume(e -> {
log.error("获取用户导出任务列表失败: userId={}", userId, e);
return ServerResponse.status(500)
.bodyValue(Result.error("获取用户导出任务列表失败: " + e.getMessage()));
});
}
@Operation(summary = "下载导出文件", description = "根据任务ID下载导出文件")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "下载成功"),
@ApiResponse(responseCode = "404", description = "任务不存在或文件未准备好"),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> downloadExportFile(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String taskIdStr = request.pathVariable("taskId");
Long taskId;
try {
taskId = Long.parseLong(taskIdStr);
} catch (NumberFormatException e) {
log.error("任务ID格式错误: {}", taskIdStr, e);
return ServerResponse.badRequest()
.bodyValue(Result.error("任务ID格式错误"));
}
log.info("下载导出文件: taskId={}", taskId);
return exportService.getExportTask(taskId)
.flatMap(task -> {
if (!"COMPLETED".equals(task.getStatus())) {
return ServerResponse.badRequest()
.bodyValue(Result.error("导出任务尚未完成"));
}
if (task.getFileData() == null || task.getFileData().length == 0) {
return ServerResponse.notFound().build();
}
String filename = String.format("statistics_%s.%s",
task.getStartDate(),
task.getFormat().toLowerCase());
MediaType mediaType = "EXCEL".equalsIgnoreCase(task.getFormat())
? MediaType.APPLICATION_OCTET_STREAM
: new MediaType("text", "csv");
return ServerResponse.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(mediaType)
.bodyValue(new ByteArrayResource(task.getFileData()));
})
.switchIfEmpty(ServerResponse.notFound().build())
.onErrorResume(e -> {
log.error("下载导出文件失败: taskId={}", taskId, e);
return ServerResponse.status(500)
.bodyValue(Result.error("下载导出文件失败: " + e.getMessage()));
});
}
public static class CreateExportTaskRequest {
private String type;
private String format;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
}
}
@@ -0,0 +1,80 @@
package io.destiny.statistics.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.destiny.statistics.core.service.ISseService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
@Component
@Tag(name = "SSE推送接口", description = "服务器发送事件(SSE)相关接口")
public class SseHandler {
private static final Logger log = LoggerFactory.getLogger(SseHandler.class);
private final ISseService sseService;
private final ObjectMapper objectMapper;
public SseHandler(ISseService sseService, ObjectMapper objectMapper) {
this.sseService = sseService;
this.objectMapper = objectMapper;
log.info("SseHandler已初始化");
}
@Operation(summary = "订阅导出任务更新", description = "通过SSE订阅导出任务的实时状态更新")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "订阅成功,开始推送事件"),
@ApiResponse(responseCode = "404", description = "任务不存在"),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> subscribeToTaskUpdates(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String taskId = request.pathVariable("taskId");
log.info("订阅导出任务更新: taskId={}", taskId);
return sseService.subscribeToTaskUpdates(taskId)
.map(task -> {
try {
String json = objectMapper.writeValueAsString(task);
return "data: " + json + "\n\n";
} catch (Exception e) {
log.error("序列化任务数据失败: taskId={}", taskId, e);
return "data: {\"error\":\"序列化失败\"}\n\n";
}
})
.collectList()
.flatMap(data -> ServerResponse.ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.bodyValue(String.join("", data)))
.onErrorResume(e -> {
log.error("SSE订阅失败: taskId={}", taskId, e);
return ServerResponse.status(500)
.bodyValue("data: {\"error\":\"" + e.getMessage() + "\"}\n\n");
});
}
@Operation(summary = "取消订阅", description = "取消对导出任务更新的订阅")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "取消成功"),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> unsubscribe(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String taskId = request.pathVariable("taskId");
log.info("取消订阅: taskId={}", taskId);
sseService.unsubscribe(taskId);
return ServerResponse.ok()
.bodyValue("{\"message\":\"取消订阅成功\"}");
}
}
@@ -0,0 +1,167 @@
package io.destiny.statistics.handler;
import io.destiny.common.response.Result;
import io.destiny.statistics.core.service.IStatisticsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import static org.springframework.http.MediaType.APPLICATION_JSON;
@Component
@Tag(name = "统计数据接口", description = "用户统计和订阅服务统计相关接口")
public class StatisticsHandler {
private static final Logger log = LoggerFactory.getLogger(StatisticsHandler.class);
private final IStatisticsService statisticsService;
public StatisticsHandler(IStatisticsService statisticsService) {
this.statisticsService = statisticsService;
}
@Operation(summary = "获取用户统计数据", description = "获取指定日期的用户统计数据,包括用户总数、活跃用户数、新用户数、用户来源分布、注册时间分布等")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "400", description = "请求参数错误"),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> getUserStatistics(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String dateStr = request.queryParam("date").orElse(LocalDate.now().toString());
LocalDate date;
try {
date = LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE);
} catch (Exception e) {
log.error("日期格式错误: {}", dateStr, e);
return ServerResponse.badRequest()
.bodyValue(Result.error("日期格式错误,请使用YYYY-MM-DD格式"));
}
log.info("获取用户统计数据: date={}", date);
return statisticsService.generateUserStatistics(date)
.map(Result::success)
.flatMap(result -> ServerResponse.ok()
.contentType(APPLICATION_JSON)
.bodyValue(result))
.onErrorResume(e -> {
log.error("获取用户统计数据失败", e);
return ServerResponse.status(500)
.bodyValue(Result.error("获取用户统计数据失败: " + e.getMessage()));
});
}
@Operation(summary = "获取订阅服务统计数据", description = "获取指定日期的订阅服务统计数据,包括付费订阅数、试用订阅数、付费比例、试用转化率、订阅套餐分布、续订率、订阅金额统计等")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "400", description = "请求参数错误"),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> getSubscriptionStatistics(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String dateStr = request.queryParam("date").orElse(LocalDate.now().toString());
LocalDate date;
try {
date = LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE);
} catch (Exception e) {
log.error("日期格式错误: {}", dateStr, e);
return ServerResponse.badRequest()
.bodyValue(Result.error("日期格式错误,请使用YYYY-MM-DD格式"));
}
log.info("获取订阅服务统计数据: date={}", date);
return statisticsService.generateSubscriptionStatistics(date)
.map(Result::success)
.flatMap(result -> ServerResponse.ok()
.contentType(APPLICATION_JSON)
.bodyValue(result))
.onErrorResume(e -> {
log.error("获取订阅服务统计数据失败", e);
return ServerResponse.status(500)
.bodyValue(Result.error("获取订阅服务统计数据失败: " + e.getMessage()));
});
}
@Operation(summary = "获取全部统计数据", description = "获取指定日期的全部统计数据,包括用户统计和订阅服务统计的所有数据")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "400", description = "请求参数错误"),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> getAllStatistics(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
String dateStr = request.queryParam("date").orElse(LocalDate.now().toString());
LocalDate date;
try {
date = LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE);
} catch (Exception e) {
log.error("日期格式错误: {}", dateStr, e);
return ServerResponse.badRequest()
.bodyValue(Result.error("日期格式错误,请使用YYYY-MM-DD格式"));
}
log.info("获取全部统计数据: date={}", date);
return statisticsService.generateAllStatistics(date)
.map(Result::success)
.flatMap(result -> ServerResponse.ok()
.contentType(APPLICATION_JSON)
.bodyValue(result))
.onErrorResume(e -> {
log.error("获取全部统计数据失败", e);
return ServerResponse.status(500)
.bodyValue(Result.error("获取全部统计数据失败: " + e.getMessage()));
});
}
@Operation(summary = "刷新统计数据缓存", description = "清除所有统计数据缓存,下次查询时将重新计算")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "刷新成功", content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> refreshCache(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
log.info("刷新统计数据缓存");
return statisticsService.refreshCache()
.then(Mono.defer(() -> ServerResponse.ok()
.contentType(APPLICATION_JSON)
.bodyValue(Result.success("缓存刷新成功"))))
.onErrorResume(e -> {
log.error("刷新统计数据缓存失败", e);
return ServerResponse.status(500)
.bodyValue(Result.error("刷新缓存失败: " + e.getMessage()));
});
}
@Operation(summary = "获取缓存统计信息", description = "获取统计数据缓存的统计信息,包括缓存大小、命中率、未命中率、驱逐次数等")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = Result.class))),
@ApiResponse(responseCode = "500", description = "服务器内部错误")
})
public Mono<ServerResponse> getCacheStats(
@Parameter(description = "服务器请求对象", hidden = true) ServerRequest request) {
log.info("获取缓存统计信息");
return statisticsService.getCacheStats()
.map(Result::success)
.flatMap(result -> ServerResponse.ok()
.contentType(APPLICATION_JSON)
.bodyValue(result))
.onErrorResume(e -> {
log.error("获取缓存统计信息失败", e);
return ServerResponse.status(500)
.bodyValue(Result.error("获取缓存统计信息失败: " + e.getMessage()));
});
}
}
@@ -0,0 +1,70 @@
package io.destiny.statistics.util;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import io.destiny.statistics.core.domain.StatisticsData;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Component
public class StatisticsCache {
private static final Logger log = LoggerFactory.getLogger(StatisticsCache.class);
private final Cache<String, StatisticsData> statisticsCache;
public StatisticsCache() {
this.statisticsCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build();
log.info("StatisticsCache已初始化,最大容量1000,过期时间5分钟");
}
public void put(String key, StatisticsData data) {
statisticsCache.put(key, data);
log.debug("统计数据已缓存: key={}", key);
}
@Nullable
public StatisticsData get(String key) {
return statisticsCache.getIfPresent(key);
}
public void invalidate(String key) {
statisticsCache.invalidate(key);
log.debug("缓存已失效: key={}", key);
}
public void invalidateAll() {
statisticsCache.invalidateAll();
log.info("所有统计数据缓存已清空");
}
public long size() {
return statisticsCache.estimatedSize();
}
public Map<String, String> getStats() {
var stats = statisticsCache.stats();
Map<String, String> cacheStats = Map.of(
"size", String.valueOf(statisticsCache.estimatedSize()),
"hitCount", String.valueOf(stats.hitCount()),
"missCount", String.valueOf(stats.missCount()),
"hitRate", String.format("%.2f%%", stats.hitRate() * 100),
"evictionCount", String.valueOf(stats.evictionCount())
);
return cacheStats;
}
public void cleanUp() {
statisticsCache.cleanUp();
log.debug("清理过期缓存条目");
}
}
@@ -0,0 +1,45 @@
spring:
application:
name: everything-is-suitable-statistics
r2dbc:
url: ${R2DBC_URL:r2dbc:postgresql://localhost:5432/everything_is_suitable}
username: ${R2DBC_USERNAME:postgres}
password: ${R2DBC_PASSWORD:postgres}
pool:
initial-size: 5
max-size: 20
max-idle-time: 30m
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0
management:
endpoints:
web:
exposure:
include: health,info,metrics
base-path: /actuator
endpoint:
health:
show-details: always
logging:
level:
root: INFO
"[io.destiny.statistics]": DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
file:
path: logs
statistics:
cache:
maximum-size: 1000
expire-after-write: 1h
export:
max-rows: 100000
chunk-size: 1000
geoip:
database-path: ${GEOIP_DATABASE_PATH:GeoLite2-City.mmdb}
@@ -0,0 +1,227 @@
package io.destiny.statistics.core.service.impl;
import io.destiny.statistics.core.domain.ExportTask;
import io.destiny.statistics.core.domain.StatisticsData;
import io.destiny.statistics.core.repository.IExportTaskRepository;
import io.destiny.statistics.core.service.IStatisticsService;
import io.destiny.statistics.core.service.ISseService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ExportServiceImplTest {
@Mock
private IExportTaskRepository exportTaskRepository;
@Mock
private IStatisticsService statisticsService;
@Mock
private ISseService sseService;
@InjectMocks
private ExportServiceImpl exportService;
private ExportTask testTask;
private StatisticsData testData;
private LocalDate testDate;
@BeforeEach
void setUp() {
testDate = LocalDate.now();
testTask = new ExportTask();
testTask.setId(1L);
testTask.setUserId(1L);
testTask.setType("ALL");
testTask.setFormat("CSV");
testTask.setStartDate(testDate);
testTask.setEndDate(testDate);
testTask.setStatus("PENDING");
testTask.setCreatedAt(LocalDateTime.now());
testTask.setUpdatedAt(LocalDateTime.now());
testData = new StatisticsData("ALL", testDate);
testData.setTotalUsers(100L);
testData.setActiveUsers(50L);
testData.setNewUsers(10L);
testData.setPaidSubscribers(30L);
testData.setTrialSubscribers(20L);
testData.setPaidRatio(BigDecimal.valueOf(60.00));
testData.setTrialConversionRate(BigDecimal.valueOf(150.00));
testData.setRenewalRate(BigDecimal.valueOf(80.00));
testData.setTotalSubscriptionAmount(BigDecimal.valueOf(2999.70));
}
@Test
void testCreateExportTask_Success() {
when(exportTaskRepository.save(any(ExportTask.class))).thenReturn(Mono.just(testTask));
when(statisticsService.generateAllStatistics(any(LocalDate.class))).thenReturn(Mono.just(testData));
when(sseService.sendTaskUpdate(any(ExportTask.class))).thenReturn(Mono.empty());
Mono<ExportTask> result = exportService.createExportTask(1L, "ALL", "CSV", testDate, testDate);
assertNotNull(result);
ExportTask task = result.block();
assertEquals(1L, task.getUserId());
assertEquals("ALL", task.getType());
assertEquals("CSV", task.getFormat());
verify(exportTaskRepository, atLeast(1)).save(any(ExportTask.class));
verify(sseService, atLeastOnce()).sendTaskUpdate(any(ExportTask.class));
}
@Test
void testGetExportTask_Success() {
when(exportTaskRepository.findById(anyLong())).thenReturn(Mono.just(testTask));
Mono<ExportTask> result = exportService.getExportTask(1L);
assertNotNull(result);
ExportTask task = result.block();
assertEquals(1L, task.getId());
assertEquals(1L, task.getUserId());
verify(exportTaskRepository, times(1)).findById(anyLong());
}
@Test
void testGetExportTask_NotFound() {
when(exportTaskRepository.findById(anyLong())).thenReturn(Mono.empty());
Mono<ExportTask> result = exportService.getExportTask(999L);
assertNotNull(result);
ExportTask task = result.block();
assertNull(task);
verify(exportTaskRepository, times(1)).findById(anyLong());
}
@Test
void testGetUserExportTasks_Success() {
when(exportTaskRepository.findByUserId(anyLong())).thenReturn(Flux.just(testTask));
Flux<ExportTask> result = exportService.getUserExportTasks(1L);
assertNotNull(result);
ExportTask task = result.next().block();
assertEquals(1L, task.getUserId());
verify(exportTaskRepository, times(1)).findByUserId(anyLong());
}
@Test
void testProcessExportTask_CSV_Success() {
testTask.setStatus("PENDING");
when(exportTaskRepository.save(any(ExportTask.class))).thenReturn(Mono.just(testTask));
when(statisticsService.generateAllStatistics(any(LocalDate.class))).thenReturn(Mono.just(testData));
when(sseService.sendTaskUpdate(any(ExportTask.class))).thenReturn(Mono.empty());
Mono<Void> result = exportService.processExportTask(testTask);
assertNotNull(result);
result.block();
verify(exportTaskRepository, atLeastOnce()).save(any(ExportTask.class));
verify(statisticsService, times(1)).generateAllStatistics(any(LocalDate.class));
verify(sseService, atLeastOnce()).sendTaskUpdate(any(ExportTask.class));
}
@Test
void testProcessExportTask_Excel_Success() {
testTask.setFormat("EXCEL");
testTask.setStatus("PENDING");
when(exportTaskRepository.save(any(ExportTask.class))).thenReturn(Mono.just(testTask));
when(statisticsService.generateAllStatistics(any(LocalDate.class))).thenReturn(Mono.just(testData));
when(sseService.sendTaskUpdate(any(ExportTask.class))).thenReturn(Mono.empty());
Mono<Void> result = exportService.processExportTask(testTask);
assertNotNull(result);
result.block();
verify(exportTaskRepository, atLeastOnce()).save(any(ExportTask.class));
verify(statisticsService, times(1)).generateAllStatistics(any(LocalDate.class));
verify(sseService, atLeastOnce()).sendTaskUpdate(any(ExportTask.class));
}
@Test
void testProcessExportTask_InvalidFormat() {
testTask.setFormat("INVALID");
testTask.setStatus("PENDING");
when(exportTaskRepository.save(any(ExportTask.class))).thenReturn(Mono.just(testTask));
when(sseService.sendTaskUpdate(any(ExportTask.class))).thenReturn(Mono.empty());
Mono<Void> result = exportService.processExportTask(testTask);
assertNotNull(result);
assertThrows(Exception.class, () -> result.block());
verify(exportTaskRepository, atLeastOnce()).save(any(ExportTask.class));
verify(statisticsService, never()).generateAllStatistics(any(LocalDate.class));
}
@Test
void testExportToCsv_Success() {
Mono<byte[]> result = exportService.exportToCsv(testData);
assertNotNull(result);
byte[] data = result.block();
assertNotNull(data);
assertTrue(data.length > 0);
}
@Test
void testExportToExcel_Success() {
Mono<byte[]> result = exportService.exportToExcel(testData);
assertNotNull(result);
byte[] data = result.block();
assertNotNull(data);
assertTrue(data.length > 0);
}
@Test
void testExportToCsv_WithEmptyData() {
StatisticsData emptyData = new StatisticsData("ALL", testDate);
Mono<byte[]> result = exportService.exportToCsv(emptyData);
assertNotNull(result);
byte[] data = result.block();
assertNotNull(data);
assertTrue(data.length > 0);
}
@Test
void testExportToExcel_WithEmptyData() {
StatisticsData emptyData = new StatisticsData("ALL", testDate);
Mono<byte[]> result = exportService.exportToExcel(emptyData);
assertNotNull(result);
byte[] data = result.block();
assertNotNull(data);
assertTrue(data.length > 0);
}
}
@@ -0,0 +1,95 @@
package io.destiny.statistics.core.service.impl;
import io.destiny.statistics.core.service.IGeoLocationService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class GeoLocationServiceImplTest {
@InjectMocks
private GeoLocationServiceImpl geoLocationService;
@Test
void testResolveLocation_WithValidIP() {
String ipAddress = "192.168.1.1";
Mono<IGeoLocationService.GeoLocation> result = geoLocationService.resolveLocation(ipAddress);
assertNotNull(result);
IGeoLocationService.GeoLocation location = result.block();
assertNotNull(location);
assertNotNull(location.country());
assertNotNull(location.province());
assertNotNull(location.city());
}
@Test
void testResolveLocation_WithNullIP() {
String ipAddress = null;
Mono<IGeoLocationService.GeoLocation> result = geoLocationService.resolveLocation(ipAddress);
assertNotNull(result);
IGeoLocationService.GeoLocation location = result.block();
assertNotNull(location);
assertEquals("未知", location.country());
assertEquals("未知", location.province());
assertEquals("未知", location.city());
}
@Test
void testResolveLocation_WithEmptyIP() {
String ipAddress = "";
Mono<IGeoLocationService.GeoLocation> result = geoLocationService.resolveLocation(ipAddress);
assertNotNull(result);
IGeoLocationService.GeoLocation location = result.block();
assertNotNull(location);
assertEquals("未知", location.country());
assertEquals("未知", location.province());
assertEquals("未知", location.city());
}
@Test
void testResolveLocation_WithInvalidIP() {
String ipAddress = "invalid-ip";
Mono<IGeoLocationService.GeoLocation> result = geoLocationService.resolveLocation(ipAddress);
assertNotNull(result);
IGeoLocationService.GeoLocation location = result.block();
assertNotNull(location);
assertEquals("未知", location.country());
}
@Test
void testResolveLocation_WithLocalhost() {
String ipAddress = "127.0.0.1";
Mono<IGeoLocationService.GeoLocation> result = geoLocationService.resolveLocation(ipAddress);
assertNotNull(result);
IGeoLocationService.GeoLocation location = result.block();
assertNotNull(location);
assertNotNull(location.country());
}
@Test
void testResolveLocation_WithPrivateIP() {
String ipAddress = "10.0.0.1";
Mono<IGeoLocationService.GeoLocation> result = geoLocationService.resolveLocation(ipAddress);
assertNotNull(result);
IGeoLocationService.GeoLocation location = result.block();
assertNotNull(location);
assertNotNull(location.country());
}
}
@@ -0,0 +1,355 @@
package io.destiny.statistics.core.service.impl;
import io.destiny.client.core.domain.ClientLoginLog;
import io.destiny.client.core.domain.ClientUser;
import io.destiny.client.core.domain.Subscription;
import io.destiny.client.core.repository.IClientLoginLogRepository;
import io.destiny.client.core.repository.IClientUserRepository;
import io.destiny.client.core.repository.ISubscriptionRepository;
import io.destiny.common.primitive.EmailAddress;
import io.destiny.common.primitive.IpAddress;
import io.destiny.statistics.core.domain.StatisticsData;
import io.destiny.statistics.core.service.IGeoLocationService;
import io.destiny.statistics.util.StatisticsCache;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class StatisticsServiceImplTest {
@Mock
private IClientUserRepository clientUserRepository;
@Mock
private ISubscriptionRepository subscriptionRepository;
@Mock
private StatisticsCache statisticsCache;
@Mock
private IGeoLocationService geoLocationService;
@Mock
private IClientLoginLogRepository clientLoginLogRepository;
@InjectMocks
private StatisticsServiceImpl statisticsService;
private ClientUser testUser;
private Subscription testSubscription;
private LocalDate testDate;
@BeforeEach
void setUp() {
testDate = LocalDate.now();
testUser = new ClientUser();
testUser.setId(1L);
testUser.setUsername("testuser");
testUser.setEmail(EmailAddress.of("test@example.com"));
testUser.setCreatedAt(LocalDateTime.now());
testUser.setCountry("中国");
testSubscription = new Subscription();
testSubscription.setId(1L);
testSubscription.setClientUserId(1L);
testSubscription.setPlanType("PREMIUM");
testSubscription.setStatus("ACTIVE");
testSubscription.setAmount(BigDecimal.valueOf(99.99));
testSubscription.setStartDate(LocalDate.now());
}
@Test
void testGetUserStatistics_FromCache() {
StatisticsData cachedData = new StatisticsData("USER", testDate);
cachedData.setTotalUsers(100L);
cachedData.setActiveUsers(50L);
cachedData.setNewUsers(10L);
when(statisticsCache.get(anyString())).thenReturn(cachedData);
Mono<StatisticsData> result = statisticsService.generateUserStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertEquals("USER", data.getType());
assertEquals(100L, data.getTotalUsers());
assertEquals(50L, data.getActiveUsers());
assertEquals(10L, data.getNewUsers());
verify(statisticsCache, times(1)).get(anyString());
verify(clientUserRepository, never()).findByQuery(any());
}
@Test
void testGetUserStatistics_FromDatabase() {
when(statisticsCache.get(anyString())).thenReturn(null);
when(clientUserRepository.findByQuery(any())).thenReturn(Flux.just(testUser));
when(clientLoginLogRepository.findByClientUserId(anyLong())).thenReturn(Flux.empty());
Mono<StatisticsData> result = statisticsService.generateUserStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertEquals("USER", data.getType());
assertNotNull(data.getTotalUsers());
assertNotNull(data.getActiveUsers());
assertNotNull(data.getNewUsers());
verify(statisticsCache, times(1)).get(anyString());
verify(clientUserRepository, atLeast(1)).findByQuery(any());
verify(statisticsCache, times(1)).put(anyString(), any(StatisticsData.class));
}
@Test
void testGetSubscriptionStatistics_FromCache() {
StatisticsData cachedData = new StatisticsData("SUBSCRIPTION", testDate);
cachedData.setPaidSubscribers(50L);
cachedData.setTrialSubscribers(20L);
cachedData.setPaidRatio(BigDecimal.valueOf(71.43));
when(statisticsCache.get(anyString())).thenReturn(cachedData);
Mono<StatisticsData> result = statisticsService.generateSubscriptionStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertEquals("SUBSCRIPTION", data.getType());
assertEquals(50L, data.getPaidSubscribers());
assertEquals(20L, data.getTrialSubscribers());
assertEquals(BigDecimal.valueOf(71.43), data.getPaidRatio());
verify(statisticsCache, times(1)).get(anyString());
verify(subscriptionRepository, never()).findByStatus(anyString());
}
@Test
void testGetSubscriptionStatistics_FromDatabase() {
when(statisticsCache.get(anyString())).thenReturn(null);
when(subscriptionRepository.findByStatus("ACTIVE")).thenReturn(Flux.just(testSubscription));
when(subscriptionRepository.findByStartDateBetween(anyLong(), anyLong())).thenReturn(Flux.empty());
Mono<StatisticsData> result = statisticsService.generateSubscriptionStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertEquals("SUBSCRIPTION", data.getType());
assertNotNull(data.getPaidSubscribers());
assertNotNull(data.getTrialSubscribers());
verify(statisticsCache, times(1)).get(anyString());
verify(subscriptionRepository, atLeast(1)).findByStatus(anyString());
verify(statisticsCache, times(1)).put(anyString(), any(StatisticsData.class));
}
@Test
void testGetAllStatistics_FromCache() {
StatisticsData cachedData = new StatisticsData("ALL", testDate);
cachedData.setTotalUsers(100L);
cachedData.setPaidSubscribers(50L);
when(statisticsCache.get(anyString())).thenReturn(cachedData);
Mono<StatisticsData> result = statisticsService.generateAllStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertEquals("ALL", data.getType());
assertEquals(100L, data.getTotalUsers());
assertEquals(50L, data.getPaidSubscribers());
verify(statisticsCache, times(1)).get(anyString());
verify(clientUserRepository, never()).findByQuery(any());
verify(subscriptionRepository, never()).findByStatus(anyString());
}
@Test
void testGetAllStatistics_FromDatabase() {
when(statisticsCache.get(anyString())).thenReturn(null);
when(clientUserRepository.findByQuery(any())).thenReturn(Flux.just(testUser));
when(subscriptionRepository.findByStatus("ACTIVE")).thenReturn(Flux.just(testSubscription));
when(subscriptionRepository.findByStartDateBetween(anyLong(), anyLong())).thenReturn(Flux.empty());
when(clientLoginLogRepository.findByClientUserId(anyLong())).thenReturn(Flux.empty());
Mono<StatisticsData> result = statisticsService.generateAllStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertEquals("ALL", data.getType());
assertNotNull(data.getTotalUsers());
assertNotNull(data.getPaidSubscribers());
verify(statisticsCache, times(1)).get(anyString());
verify(clientUserRepository, atLeast(1)).findByQuery(any());
verify(subscriptionRepository, atLeast(1)).findByStatus(anyString());
verify(statisticsCache, times(1)).put(anyString(), any(StatisticsData.class));
}
@Test
void testRefreshCache() {
doNothing().when(statisticsCache).invalidateAll();
Mono<Void> result = statisticsService.refreshCache();
assertNotNull(result);
result.block();
verify(statisticsCache, times(1)).invalidateAll();
}
@Test
void testGetCacheStats() {
Map<String, String> cacheStats = new HashMap<>();
cacheStats.put("user_stats:2024-01-01", "100");
cacheStats.put("subscription_stats:2024-01-01", "50");
when(statisticsCache.getStats()).thenReturn(cacheStats);
Mono<StatisticsData> result = statisticsService.getCacheStats();
assertNotNull(result);
StatisticsData data = result.block();
assertEquals("CACHE", data.getType());
assertNotNull(data.getSubscriptionTimeDistribution());
assertEquals(2, data.getSubscriptionTimeDistribution().size());
verify(statisticsCache, times(1)).getStats();
}
@Test
void testGetUserOriginDistribution_WithExistingCountry() {
when(statisticsCache.get(anyString())).thenReturn(null);
when(clientUserRepository.findByQuery(any())).thenReturn(Flux.just(testUser));
when(clientLoginLogRepository.findByClientUserId(anyLong())).thenReturn(Flux.empty());
Mono<StatisticsData> result = statisticsService.generateUserStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertNotNull(data.getUserOriginDistribution());
assertTrue(data.getUserOriginDistribution().containsKey("中国"));
verify(clientUserRepository, atLeast(1)).findByQuery(any());
verify(clientLoginLogRepository, atLeast(1)).findByClientUserId(anyLong());
}
@Test
void testGetUserOriginDistribution_WithoutCountry_WithIP() {
testUser.setCountry(null);
ClientLoginLog loginLog = new ClientLoginLog(1L, IpAddress.of("192.168.1.1"));
when(statisticsCache.get(anyString())).thenReturn(null);
when(clientUserRepository.findByQuery(any())).thenReturn(Flux.just(testUser));
when(clientLoginLogRepository.findByClientUserId(1L)).thenReturn(Flux.just(loginLog));
when(geoLocationService.resolveLocation("192.168.1.1")).thenReturn(Mono.just(
new IGeoLocationService.GeoLocation("美国", "加利福尼亚", "洛杉矶", "AT&T")));
Mono<StatisticsData> result = statisticsService.generateUserStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertNotNull(data.getUserOriginDistribution());
verify(clientUserRepository, atLeast(1)).findByQuery(any());
verify(clientLoginLogRepository, atLeast(1)).findByClientUserId(anyLong());
verify(geoLocationService, times(1)).resolveLocation(anyString());
}
@Test
void testGetUserOriginDistribution_WithoutCountry_WithoutIP() {
testUser.setCountry(null);
when(statisticsCache.get(anyString())).thenReturn(null);
when(clientUserRepository.findByQuery(any())).thenReturn(Flux.just(testUser));
when(clientLoginLogRepository.findByClientUserId(1L)).thenReturn(Flux.empty());
Mono<StatisticsData> result = statisticsService.generateUserStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertNotNull(data.getUserOriginDistribution());
assertTrue(data.getUserOriginDistribution().containsKey("未知"));
verify(clientUserRepository, atLeast(1)).findByQuery(any());
verify(clientLoginLogRepository, atLeast(1)).findByClientUserId(anyLong());
verify(geoLocationService, never()).resolveLocation(anyString());
}
@Test
void testGetUserOriginDistribution_WithIPResolutionError() {
testUser.setCountry(null);
ClientLoginLog loginLog = new ClientLoginLog(1L, IpAddress.of("192.168.1.1"));
when(statisticsCache.get(anyString())).thenReturn(null);
when(clientUserRepository.findByQuery(any())).thenReturn(Flux.just(testUser));
when(clientLoginLogRepository.findByClientUserId(1L)).thenReturn(Flux.just(loginLog));
when(geoLocationService.resolveLocation("192.168.1.1")).thenReturn(Mono.error(new RuntimeException("IP解析失败")));
Mono<StatisticsData> result = statisticsService.generateUserStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertNotNull(data.getUserOriginDistribution());
assertTrue(data.getUserOriginDistribution().containsKey("未知"));
verify(clientUserRepository, atLeast(1)).findByQuery(any());
verify(clientLoginLogRepository, atLeast(1)).findByClientUserId(anyLong());
verify(geoLocationService, times(1)).resolveLocation(anyString());
}
@Test
void testCalculateTrialConversionRate_WithNoTrialUsers() {
when(statisticsCache.get(anyString())).thenReturn(null);
when(subscriptionRepository.findByStatus("ACTIVE")).thenReturn(Flux.empty());
when(subscriptionRepository.findByStartDateBetween(anyLong(), anyLong())).thenReturn(Flux.empty());
Mono<StatisticsData> result = statisticsService.generateSubscriptionStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertNotNull(data.getTrialConversionRate());
assertEquals(BigDecimal.ZERO, data.getTrialConversionRate());
verify(subscriptionRepository, atLeast(1)).findByStatus(anyString());
}
@Test
void testCalculateTrialConversionRate_WithTrialUsers() {
Subscription trialSubscription = new Subscription();
trialSubscription.setId(2L);
trialSubscription.setClientUserId(2L);
trialSubscription.setPlanType("TRIAL");
trialSubscription.setStatus("ACTIVE");
when(statisticsCache.get(anyString())).thenReturn(null);
when(subscriptionRepository.findByStatus("ACTIVE")).thenReturn(Flux.just(testSubscription, trialSubscription));
when(subscriptionRepository.findByStartDateBetween(anyLong(), anyLong())).thenReturn(Flux.empty());
Mono<StatisticsData> result = statisticsService.generateSubscriptionStatistics(testDate);
assertNotNull(result);
StatisticsData data = result.block();
assertNotNull(data.getTrialConversionRate());
assertTrue(data.getTrialConversionRate().compareTo(BigDecimal.ZERO) > 0);
verify(subscriptionRepository, atLeast(1)).findByStatus(anyString());
}
}
@@ -0,0 +1,287 @@
package io.destiny.statistics.integration;
import io.destiny.statistics.core.domain.ExportTask;
import io.destiny.statistics.core.domain.StatisticsData;
import io.destiny.statistics.core.service.IExportService;
import io.destiny.statistics.core.service.IStatisticsService;
import io.destiny.statistics.handler.ExportHandler;
import io.destiny.statistics.config.ExportRouter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("导出API集成测试")
class ExportApiIntegrationTest {
@Mock
private IExportService exportService;
@Mock
private IStatisticsService statisticsService;
private ExportHandler exportHandler;
private WebTestClient webTestClient;
@BeforeEach
void setUp() {
exportHandler = new ExportHandler(exportService);
ExportRouter router = new ExportRouter(exportHandler);
webTestClient = WebTestClient.bindToRouterFunction(router.exportRoutes())
.build();
ExportTask defaultTask = createDefaultExportTask();
StatisticsData defaultData = createDefaultStatisticsData();
when(exportService.createExportTask(anyLong(), anyString(), anyString(), any(LocalDate.class), any(LocalDate.class)))
.thenReturn(Mono.just(defaultTask));
when(exportService.getExportTask(anyLong()))
.thenReturn(Mono.just(defaultTask));
when(exportService.getUserExportTasks(anyLong()))
.thenReturn(Flux.just(defaultTask));
when(statisticsService.generateAllStatistics(any(LocalDate.class)))
.thenReturn(Mono.just(defaultData));
}
private ExportTask createDefaultExportTask() {
ExportTask task = new ExportTask();
task.setId(1L);
task.setUserId(1L);
task.setType("ALL");
task.setFormat("CSV");
task.setStartDate(LocalDate.now());
task.setEndDate(LocalDate.now());
task.setStatus("COMPLETED");
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
task.setCompletedAt(LocalDateTime.now());
task.setFileData(new byte[]{1, 2, 3, 4, 5});
return task;
}
private StatisticsData createDefaultStatisticsData() {
StatisticsData data = new StatisticsData("ALL", LocalDate.now());
data.setTotalUsers(100L);
data.setActiveUsers(50L);
data.setNewUsers(10L);
data.setPaidSubscribers(30L);
data.setTrialSubscribers(20L);
data.setPaidRatio(BigDecimal.valueOf(60.00));
data.setTrialConversionRate(BigDecimal.valueOf(150.00));
data.setRenewalRate(BigDecimal.valueOf(80.00));
data.setTotalSubscriptionAmount(BigDecimal.valueOf(2999.70));
return data;
}
@Test
@DisplayName("测试创建导出任务 - 成功场景")
void testCreateExportTask_Success() {
String requestBody = """
{
"type": "ALL",
"format": "CSV"
}
""";
webTestClient.post()
.uri("/export/create?date=2024-01-01")
.header("X-User-Id", "1")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.id").isEqualTo(1)
.jsonPath("$.data.userId").isEqualTo(1)
.jsonPath("$.data.type").isEqualTo("ALL")
.jsonPath("$.data.format").isEqualTo("CSV");
}
@Test
@DisplayName("测试创建导出任务 - Excel格式")
void testCreateExportTask_ExcelFormat() {
String requestBody = """
{
"type": "ALL",
"format": "EXCEL"
}
""";
ExportTask task = createDefaultExportTask();
task.setFormat("EXCEL");
when(exportService.createExportTask(anyLong(), anyString(), anyString(), any(LocalDate.class), any(LocalDate.class)))
.thenReturn(Mono.just(task));
webTestClient.post()
.uri("/export/create?date=2024-01-01")
.header("X-User-Id", "1")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.format").isEqualTo("EXCEL");
}
@Test
@DisplayName("测试创建导出任务 - 缺少必填字段")
void testCreateExportTask_MissingRequiredFields() {
String requestBody = """
{
"type": "ALL"
}
""";
webTestClient.post()
.uri("/export/create?date=2024-01-01")
.header("X-User-Id", "1")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.exchange()
.expectStatus().is5xxServerError();
}
@Test
@DisplayName("测试创建导出任务 - 无效日期格式")
void testCreateExportTask_InvalidDateFormat() {
String requestBody = """
{
"type": "ALL",
"format": "CSV"
}
""";
webTestClient.post()
.uri("/export/create?date=invalid-date")
.header("X-User-Id", "1")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.exchange()
.expectStatus().isBadRequest();
}
@Test
@DisplayName("测试获取导出任务 - 成功场景")
void testGetExportTask_Success() {
webTestClient.get()
.uri("/export/task/1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.id").isEqualTo(1)
.jsonPath("$.data.userId").isEqualTo(1)
.jsonPath("$.data.status").isEqualTo("COMPLETED");
}
@Test
@DisplayName("测试获取导出任务 - 任务不存在")
void testGetExportTask_NotFound() {
when(exportService.getExportTask(anyLong()))
.thenReturn(Mono.empty());
webTestClient.get()
.uri("/export/task/999")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isNotFound();
}
@Test
@DisplayName("测试获取用户导出任务列表 - 成功场景")
void testGetUserExportTasks_Success() {
webTestClient.get()
.uri("/export/tasks")
.header("X-User-Id", "1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data[0].id").isEqualTo(1)
.jsonPath("$.data[0].userId").isEqualTo(1);
}
@Test
@DisplayName("测试获取用户导出任务列表 - 空列表")
void testGetUserExportTasks_EmptyList() {
when(exportService.getUserExportTasks(anyLong()))
.thenReturn(Flux.empty());
webTestClient.get()
.uri("/export/tasks")
.header("X-User-Id", "999")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.length()").isEqualTo(0);
}
@Test
@DisplayName("测试下载导出文件 - 成功场景")
void testDownloadExportFile_Success() {
ExportTask task = createDefaultExportTask();
task.setFileData("test,csv,data".getBytes());
when(exportService.getExportTask(anyLong()))
.thenReturn(Mono.just(task));
webTestClient.get()
.uri("/export/download/1")
.accept(MediaType.APPLICATION_OCTET_STREAM)
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith(new MediaType("text", "csv"))
.expectHeader().exists("Content-Disposition");
}
@Test
@DisplayName("测试下载导出文件 - 任务不存在")
void testDownloadExportFile_NotFound() {
when(exportService.getExportTask(anyLong()))
.thenReturn(Mono.empty());
webTestClient.get()
.uri("/export/download/999")
.accept(MediaType.APPLICATION_OCTET_STREAM)
.exchange()
.expectStatus().isNotFound();
}
@Test
@DisplayName("测试下载导出文件 - 任务未完成")
void testDownloadExportFile_TaskNotCompleted() {
ExportTask task = createDefaultExportTask();
task.setStatus("PROCESSING");
when(exportService.getExportTask(anyLong()))
.thenReturn(Mono.just(task));
webTestClient.get()
.uri("/export/download/1")
.accept(MediaType.APPLICATION_OCTET_STREAM)
.exchange()
.expectStatus().isBadRequest();
}
}
@@ -0,0 +1,180 @@
package io.destiny.statistics.integration;
import io.destiny.statistics.core.domain.StatisticsData;
import io.destiny.statistics.core.service.IStatisticsService;
import io.destiny.statistics.handler.StatisticsHandler;
import io.destiny.statistics.config.StatisticsRouter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("统计API集成测试")
class StatisticsApiIntegrationTest {
@Mock
private IStatisticsService statisticsService;
private StatisticsHandler statisticsHandler;
private WebTestClient webTestClient;
@BeforeEach
void setUp() {
statisticsHandler = new StatisticsHandler(statisticsService);
StatisticsRouter router = new StatisticsRouter(statisticsHandler);
webTestClient = WebTestClient.bindToRouterFunction(router.statisticsRoutes())
.build();
StatisticsData defaultData = createDefaultStatisticsData();
when(statisticsService.generateUserStatistics(any(LocalDate.class)))
.thenReturn(Mono.just(defaultData));
when(statisticsService.generateSubscriptionStatistics(any(LocalDate.class)))
.thenReturn(Mono.just(defaultData));
when(statisticsService.generateAllStatistics(any(LocalDate.class)))
.thenReturn(Mono.just(defaultData));
when(statisticsService.refreshCache())
.thenReturn(Mono.empty());
when(statisticsService.getCacheStats())
.thenReturn(Mono.just(defaultData));
}
private StatisticsData createDefaultStatisticsData() {
StatisticsData data = new StatisticsData("ALL", LocalDate.now());
data.setTotalUsers(100L);
data.setActiveUsers(50L);
data.setNewUsers(10L);
data.setPaidSubscribers(30L);
data.setTrialSubscribers(20L);
data.setPaidRatio(BigDecimal.valueOf(60.00));
data.setTrialConversionRate(BigDecimal.valueOf(150.00));
data.setRenewalRate(BigDecimal.valueOf(80.00));
data.setTotalSubscriptionAmount(BigDecimal.valueOf(2999.70));
return data;
}
@Test
@DisplayName("测试获取用户统计 - 成功场景")
void testGetUserStatistics_Success() {
webTestClient.get()
.uri("/statistics/user?date=2024-01-01")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.type").isEqualTo("ALL")
.jsonPath("$.data.totalUsers").isEqualTo(100)
.jsonPath("$.data.activeUsers").isEqualTo(50)
.jsonPath("$.data.newUsers").isEqualTo(10);
}
@Test
@DisplayName("测试获取用户统计 - 默认日期")
void testGetUserStatistics_DefaultDate() {
webTestClient.get()
.uri("/statistics/user")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.type").isEqualTo("ALL")
.jsonPath("$.data.totalUsers").isEqualTo(100);
}
@Test
@DisplayName("测试获取订阅统计 - 成功场景")
void testGetSubscriptionStatistics_Success() {
webTestClient.get()
.uri("/statistics/subscription?date=2024-01-01")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.type").isEqualTo("ALL")
.jsonPath("$.data.paidSubscribers").isEqualTo(30)
.jsonPath("$.data.trialSubscribers").isEqualTo(20)
.jsonPath("$.data.paidRatio").isEqualTo(60.00);
}
@Test
@DisplayName("测试获取全部统计 - 成功场景")
void testGetAllStatistics_Success() {
webTestClient.get()
.uri("/statistics/all?date=2024-01-01")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.type").isEqualTo("ALL")
.jsonPath("$.data.totalUsers").isEqualTo(100)
.jsonPath("$.data.paidSubscribers").isEqualTo(30)
.jsonPath("$.data.totalSubscriptionAmount").isEqualTo(2999.70);
}
@Test
@DisplayName("测试刷新缓存 - 成功场景")
void testRefreshCache_Success() {
webTestClient.post()
.uri("/statistics/refresh")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data").isEqualTo("缓存刷新成功");
}
@Test
@DisplayName("测试获取缓存统计 - 成功场景")
void testGetCacheStats_Success() {
webTestClient.get()
.uri("/statistics/cache/stats")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.data.type").isEqualTo("ALL");
}
@Test
@DisplayName("测试获取用户统计 - 无效日期格式")
void testGetUserStatistics_InvalidDateFormat() {
webTestClient.get()
.uri("/statistics/user?date=invalid-date")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isBadRequest();
}
@Test
@DisplayName("测试获取用户统计 - 服务异常")
void testGetUserStatistics_ServiceError() {
when(statisticsService.generateUserStatistics(any(LocalDate.class)))
.thenReturn(Mono.error(new RuntimeException("服务异常")));
webTestClient.get()
.uri("/statistics/user?date=2024-01-01")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().is5xxServerError();
}
}