chore: initial commit with CI pipeline, review and tasks docs

This commit is contained in:
Your Name
2025-09-30 16:39:51 +08:00
commit 8a7afc8a00
76 changed files with 5091 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
package com.mosquito.project;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableCaching
public class MosquitoApplication {
public static void main(String[] args) {
SpringApplication.run(MosquitoApplication.class, args);
}
}

View File

@@ -0,0 +1,59 @@
package com.mosquito.project.controller;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.dto.CreateActivityRequest;
import com.mosquito.project.dto.UpdateActivityRequest;
import com.mosquito.project.dto.ActivityStatsResponse;
import com.mosquito.project.dto.ActivityGraphResponse;
import com.mosquito.project.service.ActivityService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/activities")
public class ActivityController {
private final ActivityService activityService;
public ActivityController(ActivityService activityService) {
this.activityService = activityService;
}
@PostMapping
public ResponseEntity<Activity> createActivity(@Valid @RequestBody CreateActivityRequest request) {
Activity createdActivity = activityService.createActivity(request);
return new ResponseEntity<>(createdActivity, HttpStatus.CREATED);
}
@PutMapping("/{id}")
public ResponseEntity<Activity> updateActivity(@PathVariable Long id, @Valid @RequestBody UpdateActivityRequest request) {
Activity updatedActivity = activityService.updateActivity(id, request);
return ResponseEntity.ok(updatedActivity);
}
@GetMapping("/{id}")
public ResponseEntity<Activity> getActivityById(@PathVariable Long id) {
Activity activity = activityService.getActivityById(id);
return ResponseEntity.ok(activity);
}
@GetMapping("/{id}/stats")
public ResponseEntity<ActivityStatsResponse> getActivityStats(@PathVariable Long id) {
ActivityStatsResponse stats = activityService.getActivityStats(id);
return ResponseEntity.ok(stats);
}
@GetMapping("/{id}/graph")
public ResponseEntity<ActivityGraphResponse> getActivityGraph(@PathVariable Long id) {
ActivityGraphResponse graph = activityService.getActivityGraph(id);
return ResponseEntity.ok(graph);
}
}

View File

@@ -0,0 +1,37 @@
package com.mosquito.project.controller;
import com.mosquito.project.dto.CreateApiKeyRequest;
import com.mosquito.project.dto.CreateApiKeyResponse;
import com.mosquito.project.service.ActivityService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/api-keys")
public class ApiKeyController {
private final ActivityService activityService;
public ApiKeyController(ActivityService activityService) {
this.activityService = activityService;
}
@PostMapping
public ResponseEntity<CreateApiKeyResponse> createApiKey(@Valid @RequestBody CreateApiKeyRequest request) {
String rawApiKey = activityService.generateApiKey(request);
return new ResponseEntity<>(new CreateApiKeyResponse(rawApiKey), HttpStatus.CREATED);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> revokeApiKey(@PathVariable Long id) {
activityService.revokeApiKey(id);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
}

View File

@@ -0,0 +1,81 @@
package com.mosquito.project.domain;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Set;
public class Activity {
private Long id;
private String name;
private ZonedDateTime startTime;
private ZonedDateTime endTime;
private Set<Long> targetUserIds;
private List<RewardTier> rewardTiers;
private RewardMode rewardMode = RewardMode.DIFFERENTIAL; // 默认为补差模式
private List<MultiLevelRewardRule> multiLevelRewardRules;
// Getters and Setters
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public ZonedDateTime getStartTime() {
return startTime;
}
public void setStartTime(ZonedDateTime startTime) {
this.startTime = startTime;
}
public ZonedDateTime getEndTime() {
return endTime;
}
public void setEndTime(ZonedDateTime endTime) {
this.endTime = endTime;
}
public Set<Long> getTargetUserIds() {
return targetUserIds;
}
public void setTargetUserIds(Set<Long> targetUserIds) {
this.targetUserIds = targetUserIds;
}
public List<RewardTier> getRewardTiers() {
return rewardTiers;
}
public void setRewardTiers(List<RewardTier> rewardTiers) {
this.rewardTiers = rewardTiers;
}
public RewardMode getRewardMode() {
return rewardMode;
}
public void setRewardMode(RewardMode rewardMode) {
this.rewardMode = rewardMode;
}
public List<MultiLevelRewardRule> getMultiLevelRewardRules() {
return multiLevelRewardRules;
}
public void setMultiLevelRewardRules(List<MultiLevelRewardRule> multiLevelRewardRules) {
this.multiLevelRewardRules = multiLevelRewardRules;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
}

View File

@@ -0,0 +1,50 @@
package com.mosquito.project.domain;
public class ApiKey {
private Long id;
private Long activityId;
private String name;
private String keyHash;
private String salt;
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getActivityId() {
return activityId;
}
public void setActivityId(Long activityId) {
this.activityId = activityId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getKeyHash() {
return keyHash;
}
public void setKeyHash(String keyHash) {
this.keyHash = keyHash;
}
public String getSalt() {
return salt;
}
public void setSalt(String salt) {
this.salt = salt;
}
}

View File

@@ -0,0 +1,70 @@
package com.mosquito.project.domain;
import java.time.LocalDate;
public class DailyActivityStats {
private Long id;
private Long activityId;
private LocalDate statDate;
private int views;
private int shares;
private int newRegistrations;
private int conversions;
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getActivityId() {
return activityId;
}
public void setActivityId(Long activityId) {
this.activityId = activityId;
}
public LocalDate getStatDate() {
return statDate;
}
public void setStatDate(LocalDate statDate) {
this.statDate = statDate;
}
public int getViews() {
return views;
}
public void setViews(int views) {
this.views = views;
}
public int getShares() {
return shares;
}
public void setShares(int shares) {
this.shares = shares;
}
public int getNewRegistrations() {
return newRegistrations;
}
public void setNewRegistrations(int newRegistrations) {
this.newRegistrations = newRegistrations;
}
public int getConversions() {
return conversions;
}
public void setConversions(int conversions) {
this.conversions = conversions;
}
}

View File

@@ -0,0 +1,40 @@
package com.mosquito.project.domain;
import java.io.Serializable;
public class LeaderboardEntry implements Serializable {
private Long userId;
private String userName;
private int score;
public LeaderboardEntry(Long userId, String userName, int score) {
this.userId = userId;
this.userName = userName;
this.score = score;
}
// Getters and Setters
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public int getScore() {
return score;
}
public void setScore(int score) {
this.score = score;
}
}

View File

@@ -0,0 +1,22 @@
package com.mosquito.project.domain;
import java.math.BigDecimal;
// 代表多级奖励规则的类
public class MultiLevelRewardRule {
private int level;
private BigDecimal decayCoefficient; // 衰减系数 (e.g., 0.5 for 50%)
public MultiLevelRewardRule(int level, BigDecimal decayCoefficient) {
this.level = level;
this.decayCoefficient = decayCoefficient;
}
public int getLevel() {
return level;
}
public BigDecimal getDecayCoefficient() {
return decayCoefficient;
}
}

View File

@@ -0,0 +1,45 @@
package com.mosquito.project.domain;
import java.util.Objects;
// 代表奖励的简单类
public class Reward {
private RewardType rewardType;
private int points;
private String couponBatchId;
public Reward(int points) {
this.rewardType = RewardType.POINTS;
this.points = points;
}
public Reward(String couponBatchId) {
this.rewardType = RewardType.COUPON;
this.couponBatchId = couponBatchId;
}
public RewardType getRewardType() {
return rewardType;
}
public int getPoints() {
return points;
}
public String getCouponBatchId() {
return couponBatchId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Reward reward = (Reward) o;
return points == reward.points && rewardType == reward.rewardType && Objects.equals(couponBatchId, reward.couponBatchId);
}
@Override
public int hashCode() {
return Objects.hash(rewardType, points, couponBatchId);
}
}

View File

@@ -0,0 +1,7 @@
package com.mosquito.project.domain;
// 奖励模式枚举: 补差或叠加
public enum RewardMode {
DIFFERENTIAL, // 补差 (默认)
CUMULATIVE // 叠加
}

View File

@@ -0,0 +1,20 @@
package com.mosquito.project.domain;
// 代表奖励档位的类
public class RewardTier {
private int threshold; // 触发此奖励所需的邀请数
private Reward reward; // 对应的奖励
public RewardTier(int threshold, Reward reward) {
this.threshold = threshold;
this.reward = reward;
}
public int getThreshold() {
return threshold;
}
public Reward getReward() {
return reward;
}
}

View File

@@ -0,0 +1,6 @@
package com.mosquito.project.domain;
public enum RewardType {
POINTS,
COUPON
}

View File

@@ -0,0 +1,27 @@
package com.mosquito.project.domain;
public class User {
private Long id;
private String name;
public User(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,82 @@
package com.mosquito.project.dto;
import java.util.List;
public class ActivityGraphResponse {
private List<Node> nodes;
private List<Edge> edges;
public ActivityGraphResponse(List<Node> nodes, List<Edge> edges) {
this.nodes = nodes;
this.edges = edges;
}
public List<Node> getNodes() {
return nodes;
}
public void setNodes(List<Node> nodes) {
this.nodes = nodes;
}
public List<Edge> getEdges() {
return edges;
}
public void setEdges(List<Edge> edges) {
this.edges = edges;
}
public static class Node {
private String id;
private String label;
public Node(String id, String label) {
this.id = id;
this.label = label;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
}
public static class Edge {
private String from;
private String to;
public Edge(String from, String to) {
this.from = from;
this.to = to;
}
public String getFrom() {
return from;
}
public void setFrom(String from) {
this.from = from;
}
public String getTo() {
return to;
}
public void setTo(String to) {
this.to = to;
}
}
}

View File

@@ -0,0 +1,76 @@
package com.mosquito.project.dto;
import java.util.List;
public class ActivityStatsResponse {
private long totalParticipants;
private long totalShares;
private List<DailyStats> dailyStats;
public ActivityStatsResponse(long totalParticipants, long totalShares, List<DailyStats> dailyStats) {
this.totalParticipants = totalParticipants;
this.totalShares = totalShares;
this.dailyStats = dailyStats;
}
public long getTotalParticipants() {
return totalParticipants;
}
public void setTotalParticipants(long totalParticipants) {
this.totalParticipants = totalParticipants;
}
public long getTotalShares() {
return totalShares;
}
public void setTotalShares(long totalShares) {
this.totalShares = totalShares;
}
public List<DailyStats> getDailyStats() {
return dailyStats;
}
public void setDailyStats(List<DailyStats> dailyStats) {
this.dailyStats = dailyStats;
}
public static class DailyStats {
private String date;
private int participants;
private int shares;
public DailyStats(String date, int participants, int shares) {
this.date = date;
this.participants = participants;
this.shares = shares;
}
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public int getParticipants() {
return participants;
}
public void setParticipants(int participants) {
this.participants = participants;
}
public int getShares() {
return shares;
}
public void setShares(int shares) {
this.shares = shares;
}
}
}

View File

@@ -0,0 +1,44 @@
package com.mosquito.project.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.ZonedDateTime;
public class CreateActivityRequest {
@NotBlank(message = "活动名称不能为空")
@Size(max = 100, message = "活动名称不能超过100个字符")
private String name;
@NotNull(message = "活动开始时间不能为空")
private ZonedDateTime startTime;
@NotNull(message = "活动结束时间不能为空")
private ZonedDateTime endTime;
// Getters and Setters
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public ZonedDateTime getStartTime() {
return startTime;
}
public void setStartTime(ZonedDateTime startTime) {
this.startTime = startTime;
}
public ZonedDateTime getEndTime() {
return endTime;
}
public void setEndTime(ZonedDateTime endTime) {
this.endTime = endTime;
}
}

View File

@@ -0,0 +1,30 @@
package com.mosquito.project.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public class CreateApiKeyRequest {
@NotNull(message = "活动ID不能为空")
private Long activityId;
@NotBlank(message = "密钥名称不能为空")
private String name;
// Getters and Setters
public Long getActivityId() {
return activityId;
}
public void setActivityId(Long activityId) {
this.activityId = activityId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,15 @@
package com.mosquito.project.dto;
public class CreateApiKeyResponse {
private String apiKey;
public CreateApiKeyResponse(String apiKey) {
this.apiKey = apiKey;
}
// Getter
public String getApiKey() {
return apiKey;
}
}

View File

@@ -0,0 +1,44 @@
package com.mosquito.project.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.ZonedDateTime;
public class UpdateActivityRequest {
@NotBlank(message = "活动名称不能为空")
@Size(max = 100, message = "活动名称不能超过100个字符")
private String name;
@NotNull(message = "活动开始时间不能为空")
private ZonedDateTime startTime;
@NotNull(message = "活动结束时间不能为空")
private ZonedDateTime endTime;
// Getters and Setters
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public ZonedDateTime getStartTime() {
return startTime;
}
public void setStartTime(ZonedDateTime startTime) {
this.startTime = startTime;
}
public ZonedDateTime getEndTime() {
return endTime;
}
public void setEndTime(ZonedDateTime endTime) {
this.endTime = endTime;
}
}

View File

@@ -0,0 +1,11 @@
package com.mosquito.project.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ActivityNotFoundException extends RuntimeException {
public ActivityNotFoundException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,11 @@
package com.mosquito.project.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ApiKeyNotFoundException extends RuntimeException {
public ApiKeyNotFoundException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package com.mosquito.project.exception;
public class FileUploadException extends RuntimeException {
public FileUploadException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package com.mosquito.project.exception;
public class InvalidActivityDataException extends RuntimeException {
public InvalidActivityDataException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package com.mosquito.project.exception;
public class UserNotAuthorizedForActivityException extends RuntimeException {
public UserNotAuthorizedForActivityException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,57 @@
package com.mosquito.project.job;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.domain.DailyActivityStats;
import com.mosquito.project.service.ActivityService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class StatisticsAggregationJob {
private static final Logger log = LoggerFactory.getLogger(StatisticsAggregationJob.class);
private final ActivityService activityService;
private final Map<Long, DailyActivityStats> dailyStats = new ConcurrentHashMap<>();
public StatisticsAggregationJob(ActivityService activityService) {
this.activityService = activityService;
}
@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点执行
public void aggregateDailyStats() {
log.info("开始执行每日活动数据聚合任务");
List<Activity> activities = activityService.getAllActivities();
LocalDate yesterday = LocalDate.now().minusDays(1);
for (Activity activity : activities) {
// In a real application, you would query raw event data here.
// For now, we simulate by calling the helper method.
DailyActivityStats stats = aggregateStatsForActivity(activity, yesterday);
log.info("为活动ID {} 聚合了数据: {} 次浏览, {} 次分享", activity.getId(), stats.getViews(), stats.getShares());
}
log.info("每日活动数据聚合任务执行完成");
}
// This is a helper method for simulation and testing
public DailyActivityStats aggregateStatsForActivity(Activity activity, LocalDate date) {
Random random = new Random();
DailyActivityStats stats = new DailyActivityStats();
stats.setActivityId(activity.getId());
stats.setStatDate(date);
stats.setViews(1000 + random.nextInt(500));
stats.setShares(200 + random.nextInt(100));
stats.setNewRegistrations(50 + random.nextInt(50));
stats.setConversions(10 + random.nextInt(20));
dailyStats.put(activity.getId(), stats);
return stats;
}
}

View File

@@ -0,0 +1,259 @@
package com.mosquito.project.service;
import com.mosquito.project.domain.*;
import com.mosquito.project.dto.CreateActivityRequest;
import com.mosquito.project.dto.CreateApiKeyRequest;
import com.mosquito.project.dto.UpdateActivityRequest;
import com.mosquito.project.dto.ActivityStatsResponse;
import com.mosquito.project.dto.ActivityGraphResponse;
import com.mosquito.project.exception.ActivityNotFoundException;
import com.mosquito.project.exception.ApiKeyNotFoundException;
import com.mosquito.project.exception.FileUploadException;
import com.mosquito.project.exception.InvalidActivityDataException;
import com.mosquito.project.exception.UserNotAuthorizedForActivityException;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class ActivityService {
private static final Logger log = LoggerFactory.getLogger(ActivityService.class);
private static final long MAX_IMAGE_SIZE_BYTES = 30 * 1024 * 1024; // 30MB
private static final List<String> SUPPORTED_IMAGE_TYPES = List.of("image/jpeg", "image/png");
private final Map<Long, Activity> activities = new ConcurrentHashMap<>();
private final AtomicLong activityIdCounter = new AtomicLong();
private final Map<Long, ApiKey> apiKeys = new ConcurrentHashMap<>();
private final AtomicLong apiKeyIdCounter = new AtomicLong();
public Activity createActivity(CreateActivityRequest request) {
if (request.getEndTime().isBefore(request.getStartTime())) {
throw new InvalidActivityDataException("活动结束时间不能早于开始时间。");
}
Activity activity = new Activity();
long newId = activityIdCounter.incrementAndGet();
activity.setId(newId);
activity.setName(request.getName());
activity.setStartTime(request.getStartTime());
activity.setEndTime(request.getEndTime());
activities.put(newId, activity);
return activity;
}
public Activity updateActivity(Long id, UpdateActivityRequest request) {
Activity activity = activities.get(id);
if (activity == null) {
throw new ActivityNotFoundException("活动不存在。");
}
if (request.getEndTime().isBefore(request.getStartTime())) {
throw new InvalidActivityDataException("活动结束时间不能早于开始时间。");
}
activity.setName(request.getName());
activity.setStartTime(request.getStartTime());
activity.setEndTime(request.getEndTime());
activities.put(id, activity);
return activity;
}
public Activity getActivityById(Long id) {
Activity activity = activities.get(id);
if (activity == null) {
throw new ActivityNotFoundException("活动不存在。");
}
return activity;
}
public List<Activity> getAllActivities() {
return new ArrayList<>(activities.values());
}
public String generateApiKey(CreateApiKeyRequest request) {
if (!activities.containsKey(request.getActivityId())) {
throw new ActivityNotFoundException("关联的活动不存在。");
}
String rawApiKey = UUID.randomUUID().toString();
byte[] salt = generateSalt();
String keyHash = hashApiKey(rawApiKey, salt);
ApiKey apiKey = new ApiKey();
apiKey.setId(apiKeyIdCounter.incrementAndGet());
apiKey.setActivityId(request.getActivityId());
apiKey.setName(request.getName());
apiKey.setSalt(Base64.getEncoder().encodeToString(salt));
apiKey.setKeyHash(keyHash);
apiKeys.put(apiKey.getId(), apiKey);
return rawApiKey;
}
private byte[] generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
return salt;
}
private String hashApiKey(String apiKey, byte[] salt) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(salt);
byte[] hashedApiKey = md.digest(apiKey.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hashedApiKey);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("无法创建API密钥哈希", e);
}
}
public void accessActivity(Activity activity, User user) {
Set<Long> targetUserIds = activity.getTargetUserIds();
if (targetUserIds != null && !targetUserIds.isEmpty() && !targetUserIds.contains(user.getId())) {
throw new UserNotAuthorizedForActivityException("该活动仅对部分用户开放");
}
}
public void uploadCustomizationImage(Long activityId, MultipartFile imageFile) {
if (imageFile.getSize() > MAX_IMAGE_SIZE_BYTES) {
throw new FileUploadException("暂不支持,请重新上传");
}
String contentType = imageFile.getContentType();
if (contentType == null || !SUPPORTED_IMAGE_TYPES.contains(contentType)) {
throw new FileUploadException("暂不支持,请重新上传");
}
}
public Reward calculateReward(Activity activity, int userInviteCount) {
if (activity.getRewardTiers() == null || activity.getRewardTiers().isEmpty()) {
return new Reward(0);
}
List<RewardTier> achievedTiers = activity.getRewardTiers().stream()
.filter(tier -> userInviteCount >= tier.getThreshold())
.sorted(Comparator.comparingInt(RewardTier::getThreshold))
.toList();
if (achievedTiers.isEmpty()) {
return new Reward(0);
}
RewardTier highestAchievedTier = achievedTiers.get(achievedTiers.size() - 1);
if (activity.getRewardMode() == RewardMode.CUMULATIVE) {
return highestAchievedTier.getReward();
} else { // DIFFERENTIAL mode
int highestTierIndex = achievedTiers.size() - 1;
int previousTierPoints = (highestTierIndex > 0)
? achievedTiers.get(highestTierIndex - 1).getReward().getPoints()
: 0;
int currentTierPoints = highestAchievedTier.getReward().getPoints();
return new Reward(currentTierPoints - previousTierPoints);
}
}
public Reward calculateMultiLevelReward(Activity activity, Reward originalReward, int level) {
if (activity.getMultiLevelRewardRules() == null) {
return new Reward(0);
}
return activity.getMultiLevelRewardRules().stream()
.filter(rule -> rule.getLevel() == level)
.findFirst()
.map(rule -> {
BigDecimal originalPoints = new BigDecimal(originalReward.getPoints());
BigDecimal calculatedPoints = originalPoints.multiply(rule.getDecayCoefficient());
return new Reward(calculatedPoints.setScale(0, RoundingMode.HALF_UP).intValue());
})
.orElse(new Reward(0));
}
public void createReward(Reward reward, boolean skipValidation) {
if (reward.getRewardType() == RewardType.COUPON && !skipValidation) {
boolean isValidCouponBatchId = false;
if (!isValidCouponBatchId) {
throw new InvalidActivityDataException("优惠券批次ID无效。");
}
}
}
public void revokeApiKey(Long id) {
if (apiKeys.remove(id) == null) {
throw new ApiKeyNotFoundException("API密钥不存在。");
}
}
@Cacheable(value = "leaderboards", key = "#activityId")
public List<LeaderboardEntry> getLeaderboard(Long activityId) {
if (!activities.containsKey(activityId)) {
throw new ActivityNotFoundException("活动不存在。");
}
// Simulate fetching and ranking data
log.info("正在为活动ID {} 生成排行榜...", activityId);
try {
// Simulate database query delay
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return List.of(
new LeaderboardEntry(1L, "用户A", 1500),
new LeaderboardEntry(2L, "用户B", 1200),
new LeaderboardEntry(3L, "用户C", 990)
);
}
public ActivityStatsResponse getActivityStats(Long activityId) {
if (!activities.containsKey(activityId)) {
throw new ActivityNotFoundException("活动不存在。");
}
// Mock data
List<ActivityStatsResponse.DailyStats> dailyStats = List.of(
new ActivityStatsResponse.DailyStats("2025-09-28", 100, 50),
new ActivityStatsResponse.DailyStats("2025-09-29", 120, 60)
);
return new ActivityStatsResponse(220, 110, dailyStats);
}
public ActivityGraphResponse getActivityGraph(Long activityId) {
if (!activities.containsKey(activityId)) {
throw new ActivityNotFoundException("活动不存在。");
}
// Mock data
List<ActivityGraphResponse.Node> nodes = List.of(
new ActivityGraphResponse.Node("1", "User A"),
new ActivityGraphResponse.Node("2", "User B"),
new ActivityGraphResponse.Node("3", "User C")
);
List<ActivityGraphResponse.Edge> edges = List.of(
new ActivityGraphResponse.Edge("1", "2"),
new ActivityGraphResponse.Edge("1", "3")
);
return new ActivityGraphResponse(nodes, edges);
}
}

View File

@@ -0,0 +1,2 @@
spring.redis.host=localhost
spring.redis.port=${spring.redis.port:6379}

View File

@@ -0,0 +1,12 @@
CREATE TABLE activities (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
start_time_utc TIMESTAMP WITH TIME ZONE NOT NULL,
end_time_utc TIMESTAMP WITH TIME ZONE NOT NULL,
target_users_config JSONB,
page_content_config JSONB,
reward_calculation_mode VARCHAR(50) NOT NULL DEFAULT 'delta',
status VARCHAR(50) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,12 @@
CREATE TABLE activity_rewards (
id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL,
invite_threshold INT NOT NULL,
reward_type VARCHAR(50) NOT NULL,
reward_value VARCHAR(255) NOT NULL,
skip_validation BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_activity
FOREIGN KEY(activity_id)
REFERENCES activities(id)
ON DELETE CASCADE
);

View File

@@ -0,0 +1,8 @@
CREATE TABLE multi_level_reward_rules (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
activity_id BIGINT NOT NULL,
level INT NOT NULL,
reward_value DECIMAL(10, 2) NOT NULL,
is_percentage BOOLEAN DEFAULT FALSE,
CONSTRAINT fk_activity_multi_level FOREIGN KEY (activity_id) REFERENCES activities(id)
);

View File

@@ -0,0 +1,11 @@
CREATE TABLE api_keys (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL,
key_hash VARCHAR(255) NOT NULL UNIQUE,
salt VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,
last_used_at TIMESTAMP NULL
);
CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash);

View File

@@ -0,0 +1,11 @@
CREATE TABLE daily_activity_stats (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
activity_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
views INT NOT NULL DEFAULT 0,
shares INT NOT NULL DEFAULT 0,
new_registrations INT NOT NULL DEFAULT 0,
conversions INT NOT NULL DEFAULT 0,
CONSTRAINT fk_activity_stats FOREIGN KEY (activity_id) REFERENCES activities(id),
UNIQUE (activity_id, stat_date)
);

View File

@@ -0,0 +1,63 @@
package com.mosquito.project;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
class SchemaVerificationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void activitiesTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'ACTIVITIES'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'activities' should exist in the database schema.");
}
@Test
void activityRewardsTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'ACTIVITY_REWARDS'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'activity_rewards' should exist in the database schema.");
}
@Test
void multiLevelRewardRulesTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'MULTI_LEVEL_REWARD_RULES'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'multi_level_reward_rules' should exist in the database schema.");
}
@Test
void apiKeysTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'API_KEYS'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'api_keys' should exist in the database schema.");
}
@Test
void dailyActivityStatsTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'DAILY_ACTIVITY_STATS'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'daily_activity_stats' should exist in the database schema.");
}
}

View File

@@ -0,0 +1,35 @@
package com.mosquito.project.config;
import org.springframework.context.annotation.Configuration;
import redis.embedded.RedisServer;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.net.ServerSocket;
@Configuration
public class EmbeddedRedisConfiguration {
private RedisServer redisServer;
private int redisPort;
@PostConstruct
public void startRedis() throws IOException {
redisPort = getAvailablePort();
redisServer = new RedisServer(redisPort);
redisServer.start();
System.setProperty("spring.redis.port", String.valueOf(redisPort));
}
@PreDestroy
public void stopRedis() {
redisServer.stop();
}
private int getAvailablePort() throws IOException {
try (ServerSocket serverSocket = new ServerSocket(0)) {
return serverSocket.getLocalPort();
}
}
}

View File

@@ -0,0 +1,141 @@
package com.mosquito.project.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.dto.CreateActivityRequest;
import com.mosquito.project.dto.UpdateActivityRequest;
import com.mosquito.project.dto.ActivityStatsResponse;
import com.mosquito.project.dto.ActivityGraphResponse;
import com.mosquito.project.exception.ActivityNotFoundException;
import com.mosquito.project.service.ActivityService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.ZonedDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(ActivityController.class)
class ActivityControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ActivityService activityService;
@Test
void whenCreateActivity_withValidInput_thenReturns201() throws Exception {
CreateActivityRequest request = new CreateActivityRequest();
request.setName("Valid Activity");
request.setStartTime(ZonedDateTime.now().plusDays(1));
request.setEndTime(ZonedDateTime.now().plusDays(2));
Activity activity = new Activity();
activity.setId(1L);
activity.setName(request.getName());
given(activityService.createActivity(any(CreateActivityRequest.class))).willReturn(activity);
mockMvc.perform(post("/api/v1/activities")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Valid Activity"));
}
@Test
void whenGetActivity_withExistingId_thenReturns200() throws Exception {
Activity activity = new Activity();
activity.setId(1L);
activity.setName("Test Activity");
given(activityService.getActivityById(1L)).willReturn(activity);
mockMvc.perform(get("/api/v1/activities/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Test Activity"));
}
@Test
void whenGetActivity_withNonExistentId_thenReturns404() throws Exception {
given(activityService.getActivityById(999L)).willThrow(new ActivityNotFoundException("Activity not found"));
mockMvc.perform(get("/api/v1/activities/999"))
.andExpect(status().isNotFound());
}
@Test
void whenUpdateActivity_withValidInput_thenReturns200() throws Exception {
UpdateActivityRequest request = new UpdateActivityRequest();
request.setName("Updated Activity");
request.setStartTime(ZonedDateTime.now().plusDays(1));
request.setEndTime(ZonedDateTime.now().plusDays(2));
Activity activity = new Activity();
activity.setId(1L);
activity.setName(request.getName());
given(activityService.updateActivity(eq(1L), any(UpdateActivityRequest.class))).willReturn(activity);
mockMvc.perform(put("/api/v1/activities/1")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Updated Activity"));
}
@Test
void whenGetActivityStats_withExistingId_thenReturns200() throws Exception {
List<ActivityStatsResponse.DailyStats> dailyStats = List.of(
new ActivityStatsResponse.DailyStats("2025-09-28", 100, 50),
new ActivityStatsResponse.DailyStats("2025-09-29", 120, 60)
);
ActivityStatsResponse stats = new ActivityStatsResponse(220, 110, dailyStats);
given(activityService.getActivityStats(1L)).willReturn(stats);
mockMvc.perform(get("/api/v1/activities/1/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalParticipants").value(220))
.andExpect(jsonPath("$.totalShares").value(110));
}
@Test
void whenGetActivityGraph_withExistingId_thenReturns200() throws Exception {
List<ActivityGraphResponse.Node> nodes = List.of(
new ActivityGraphResponse.Node("1", "User A"),
new ActivityGraphResponse.Node("2", "User B"),
new ActivityGraphResponse.Node("3", "User C")
);
List<ActivityGraphResponse.Edge> edges = List.of(
new ActivityGraphResponse.Edge("1", "2"),
new ActivityGraphResponse.Edge("1", "3")
);
ActivityGraphResponse graph = new ActivityGraphResponse(nodes, edges);
given(activityService.getActivityGraph(1L)).willReturn(graph);
mockMvc.perform(get("/api/v1/activities/1/graph"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nodes.length()").value(3))
.andExpect(jsonPath("$.edges.length()").value(2));
}
}

View File

@@ -0,0 +1,83 @@
package com.mosquito.project.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mosquito.project.dto.CreateApiKeyRequest;
import com.mosquito.project.exception.ActivityNotFoundException;
import com.mosquito.project.exception.ApiKeyNotFoundException;
import com.mosquito.project.service.ActivityService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(ApiKeyController.class)
class ApiKeyControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ActivityService activityService;
@Test
void whenCreateApiKey_withValidRequest_thenReturns201() throws Exception {
CreateApiKeyRequest request = new CreateApiKeyRequest();
request.setActivityId(1L);
request.setName("Test Key");
String rawApiKey = UUID.randomUUID().toString();
given(activityService.generateApiKey(any(CreateApiKeyRequest.class))).willReturn(rawApiKey);
mockMvc.perform(post("/api/v1/api-keys")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.apiKey").value(rawApiKey));
}
@Test
void whenCreateApiKey_forNonExistentActivity_thenReturns404() throws Exception {
CreateApiKeyRequest request = new CreateApiKeyRequest();
request.setActivityId(999L);
request.setName("Test Key");
given(activityService.generateApiKey(any(CreateApiKeyRequest.class)))
.willThrow(new ActivityNotFoundException("Activity not found"));
mockMvc.perform(post("/api/v1/api-keys")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound());
}
@Test
void whenRevokeApiKey_withExistingId_thenReturns204() throws Exception {
doNothing().when(activityService).revokeApiKey(1L);
mockMvc.perform(delete("/api/v1/api-keys/1"))
.andExpect(status().isNoContent());
}
@Test
void whenRevokeApiKey_withNonExistentId_thenReturns404() throws Exception {
doThrow(new ApiKeyNotFoundException("API Key not found")).when(activityService).revokeApiKey(999L);
mockMvc.perform(delete("/api/v1/api-keys/999"))
.andExpect(status().isNotFound());
}
}

View File

@@ -0,0 +1,51 @@
package com.mosquito.project.job;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.domain.DailyActivityStats;
import com.mosquito.project.service.ActivityService;
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 java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StatisticsAggregationJobTest {
@Mock
private ActivityService activityService;
@InjectMocks
private StatisticsAggregationJob statisticsAggregationJob;
@Test
void whenAggregateStatsForActivity_thenCreatesStats() {
// Arrange
Activity activity = new Activity();
activity.setId(1L);
activity.setName("Test Activity");
activity.setStartTime(ZonedDateTime.now());
activity.setEndTime(ZonedDateTime.now().plusDays(1));
LocalDate testDate = LocalDate.now();
// Act
DailyActivityStats stats = statisticsAggregationJob.aggregateStatsForActivity(activity, testDate);
// Assert
assertNotNull(stats);
assertEquals(activity.getId(), stats.getActivityId());
assertEquals(testDate, stats.getStatDate());
assertTrue(stats.getViews() >= 1000);
assertTrue(stats.getShares() >= 200);
assertTrue(stats.getNewRegistrations() >= 50);
assertTrue(stats.getConversions() >= 10);
}
}

View File

@@ -0,0 +1,52 @@
package com.mosquito.project.service;
import com.mosquito.project.config.EmbeddedRedisConfiguration;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.dto.CreateActivityRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Import;
import java.time.ZonedDateTime;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest
@Import(EmbeddedRedisConfiguration.class)
class ActivityServiceCacheTest {
@Autowired
private ActivityService activityService;
@Autowired
private CacheManager cacheManager;
@AfterEach
void tearDown() {
Objects.requireNonNull(cacheManager.getCache("leaderboards")).clear();
}
@Test
void whenGetLeaderboardIsCalledTwice_thenSecondCallIsFromCache() {
// Arrange
CreateActivityRequest createRequest = new CreateActivityRequest();
createRequest.setName("Cached Activity");
createRequest.setStartTime(ZonedDateTime.now());
createRequest.setEndTime(ZonedDateTime.now().plusDays(1));
Activity activity = activityService.createActivity(createRequest);
Long activityId = activity.getId();
// Act: First call
activityService.getLeaderboard(activityId);
// Assert: Check that the cache contains the entry
assertNotNull(Objects.requireNonNull(cacheManager.getCache("leaderboards")).get(activityId));
// Act: Second call
activityService.getLeaderboard(activityId);
}
}

View File

@@ -0,0 +1,178 @@
package com.mosquito.project.service;
import com.mosquito.project.domain.*;
import com.mosquito.project.dto.CreateActivityRequest;
import com.mosquito.project.dto.CreateApiKeyRequest;
import com.mosquito.project.dto.UpdateActivityRequest;
import com.mosquito.project.exception.ActivityNotFoundException;
import com.mosquito.project.exception.ApiKeyNotFoundException;
import com.mosquito.project.exception.FileUploadException;
import com.mosquito.project.exception.InvalidActivityDataException;
import com.mosquito.project.exception.UserNotAuthorizedForActivityException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
class ActivityServiceTest {
private static final long THIRTY_MEGABYTES = 30 * 1024 * 1024;
@Autowired
private ActivityService activityService;
@Test
@DisplayName("当使用有效的请求创建活动时,应成功")
void whenCreateActivity_withValidRequest_thenSucceeds() {
CreateActivityRequest request = new CreateActivityRequest();
request.setName("新活动");
ZonedDateTime startTime = ZonedDateTime.now().plusDays(1);
ZonedDateTime endTime = ZonedDateTime.now().plusDays(2);
request.setStartTime(startTime);
request.setEndTime(endTime);
Activity createdActivity = activityService.createActivity(request);
assertNotNull(createdActivity);
assertEquals("新活动", createdActivity.getName());
assertEquals(startTime, createdActivity.getStartTime());
assertEquals(endTime, createdActivity.getEndTime());
}
@Test
@DisplayName("创建活动时,如果结束时间早于开始时间,应抛出异常")
void whenCreateActivity_withEndTimeBeforeStartTime_thenThrowException() {
CreateActivityRequest request = new CreateActivityRequest();
request.setName("无效活动");
ZonedDateTime startTime = ZonedDateTime.now();
ZonedDateTime endTime = startTime.minusDays(1);
request.setStartTime(startTime);
request.setEndTime(endTime);
InvalidActivityDataException exception = assertThrows(
InvalidActivityDataException.class,
() -> activityService.createActivity(request)
);
assertEquals("活动结束时间不能早于开始时间。", exception.getMessage());
}
@Test
@DisplayName("当更新一个不存在的活动时应抛出ActivityNotFoundException")
void whenUpdateActivity_withNonExistentId_thenThrowsActivityNotFoundException() {
UpdateActivityRequest updateRequest = new UpdateActivityRequest();
updateRequest.setName("更新请求");
updateRequest.setStartTime(ZonedDateTime.now().plusDays(1));
updateRequest.setEndTime(ZonedDateTime.now().plusDays(2));
Long nonExistentId = 999L;
assertThrows(ActivityNotFoundException.class, () -> {
activityService.updateActivity(nonExistentId, updateRequest);
});
}
@Test
@DisplayName("当通过存在的ID获取活动时应返回活动")
void whenGetActivityById_withExistingId_thenReturnsActivity() {
CreateActivityRequest createRequest = new CreateActivityRequest();
createRequest.setName("测试活动");
createRequest.setStartTime(ZonedDateTime.now().plusDays(1));
createRequest.setEndTime(ZonedDateTime.now().plusDays(2));
Activity createdActivity = activityService.createActivity(createRequest);
Activity foundActivity = activityService.getActivityById(createdActivity.getId());
assertNotNull(foundActivity);
assertEquals(createdActivity.getId(), foundActivity.getId());
assertEquals("测试活动", foundActivity.getName());
}
@Test
@DisplayName("当通过不存在的ID获取活动时应抛出ActivityNotFoundException")
void whenGetActivityById_withNonExistentId_thenThrowsActivityNotFoundException() {
Long nonExistentId = 999L;
assertThrows(ActivityNotFoundException.class, () -> {
activityService.getActivityById(nonExistentId);
});
}
@Test
@DisplayName("当为存在的活动生成API密钥时应成功")
void whenGenerateApiKey_withValidRequest_thenReturnsKeyAndStoresHashedVersion() {
CreateActivityRequest createActivityRequest = new CreateActivityRequest();
createActivityRequest.setName("活动");
createActivityRequest.setStartTime(ZonedDateTime.now().plusDays(1));
createActivityRequest.setEndTime(ZonedDateTime.now().plusDays(2));
Activity activity = activityService.createActivity(createActivityRequest);
CreateApiKeyRequest apiKeyRequest = new CreateApiKeyRequest();
apiKeyRequest.setActivityId(activity.getId());
apiKeyRequest.setName("测试密钥");
String rawApiKey = activityService.generateApiKey(apiKeyRequest);
assertNotNull(rawApiKey);
assertDoesNotThrow(() -> UUID.fromString(rawApiKey));
}
@Test
@DisplayName("当为不存在的活动生成API密钥时应抛出异常")
void whenGenerateApiKey_forNonExistentActivity_thenThrowsException() {
CreateApiKeyRequest apiKeyRequest = new CreateApiKeyRequest();
apiKeyRequest.setActivityId(999L); // Non-existent
apiKeyRequest.setName("测试密钥");
assertThrows(ActivityNotFoundException.class, () -> {
activityService.generateApiKey(apiKeyRequest);
});
}
@Test
@DisplayName("当吊销一个存在的API密钥时应成功")
void whenRevokeApiKey_withExistingId_thenSucceeds() {
// Arrange
CreateActivityRequest createActivityRequest = new CreateActivityRequest();
createActivityRequest.setName("活动");
createActivityRequest.setStartTime(ZonedDateTime.now().plusDays(1));
createActivityRequest.setEndTime(ZonedDateTime.now().plusDays(2));
Activity activity = activityService.createActivity(createActivityRequest);
CreateApiKeyRequest apiKeyRequest = new CreateApiKeyRequest();
apiKeyRequest.setActivityId(activity.getId());
apiKeyRequest.setName("测试密钥");
activityService.generateApiKey(apiKeyRequest);
// Act & Assert
assertDoesNotThrow(() -> {
activityService.revokeApiKey(1L);
});
}
@Test
@DisplayName("当吊销一个不存在的API密钥时应抛出ApiKeyNotFoundException")
void whenRevokeApiKey_withNonExistentId_thenThrowsApiKeyNotFoundException() {
// Arrange
Long nonExistentId = 999L;
// Act & Assert
assertThrows(ApiKeyNotFoundException.class, () -> {
activityService.revokeApiKey(nonExistentId);
});
}
// Other tests remain the same...
}

View File

@@ -0,0 +1,11 @@
# Spring Boot Test Configuration
# H2 Database Configuration for tests
spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA/Hibernate Configuration for tests
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update