docs: 完善项目文档并清理过时文件
新增文档: - API_INTEGRATION_GUIDE.md: API集成指南(快速开始、SDK示例、常见场景) - DEPLOYMENT_GUIDE.md: 部署指南(环境要求、生产部署、Docker部署) - CONFIGURATION_GUIDE.md: 配置指南(环境配置、数据库、Redis、安全) - DEVELOPMENT_GUIDE.md: 开发指南(环境搭建、项目结构、开发规范) 文档更新: - api.md: 补充8个缺失的API端点(分享跟踪、回调、用户奖励) 文档清理: - 归档18个过时文档到 docs/archive/2026-03-04-cleanup/ - 删除3个调试文档(ralph-loop-*) 代码清理: - 删除4个.bak备份文件 - 删除1个.disabled测试文件 文档结构优化: - 从~40个文档精简到12个核心文档 - 建立清晰的文档导航体系 - 完善文档间的交叉引用
This commit is contained in:
@@ -1,90 +0,0 @@
|
||||
package com.mosquito.project.controller;
|
||||
|
||||
import com.mosquito.project.dto.ApiKeyCreateRequest;
|
||||
import com.mosquito.project.dto.ApiKeyResponse;
|
||||
import com.mosquito.project.service.ApiKeySecurityService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* API密钥安全控制器
|
||||
* 提供密钥的恢复、轮换等安全功能
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/api-keys")
|
||||
@Tag(name = "API Key Security", description = "API密钥安全管理")
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeySecurityController {
|
||||
|
||||
private final ApiKeySecurityService apiKeySecurityService;
|
||||
|
||||
/**
|
||||
* 重新显示API密钥
|
||||
*/
|
||||
@PostMapping("/{id}/reveal")
|
||||
@Operation(summary = "重新显示API密钥", description = "在验证权限后重新显示API密钥")
|
||||
public ResponseEntity<ApiKeyResponse> revealApiKey(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, String> request) {
|
||||
|
||||
String verificationCode = request.get("verificationCode");
|
||||
Optional<String> rawKey = apiKeySecurityService.revealApiKey(id, verificationCode);
|
||||
|
||||
if (rawKey.isPresent()) {
|
||||
log.info("API key revealed successfully for id: {}", id);
|
||||
return ResponseEntity.ok(
|
||||
new ApiKeyResponse("API密钥重新显示成功", rawKey.get())
|
||||
);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮换API密钥
|
||||
*/
|
||||
@PostMapping("/{id}/rotate")
|
||||
@Operation(summary = "轮换API密钥", description = "撤销旧密钥并生成新密钥")
|
||||
public ResponseEntity<ApiKeyResponse> rotateApiKey(
|
||||
@PathVariable Long id) {
|
||||
|
||||
try {
|
||||
var newApiKey = apiKeySecurityService.rotateApiKey(id);
|
||||
log.info("API key rotated successfully for id: {}", id);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new ApiKeyResponse("API密钥轮换成功",
|
||||
"新密钥已生成,请妥善保存。旧密钥已撤销。")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to rotate API key: {}", id, e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ApiKeyResponse("轮换失败", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API密钥使用信息
|
||||
*/
|
||||
@GetMapping("/{id}/info")
|
||||
@Operation(summary = "获取API密钥信息", description = "获取API密钥的使用统计和安全状态")
|
||||
public ResponseEntity<Map<String, Object>> getApiKeyInfo(@PathVariable Long id) {
|
||||
// 这里可以添加密钥使用统计、最后访问时间等信息
|
||||
Map<String, Object> info = Map.of(
|
||||
"apiKeyId", id,
|
||||
"status", "active",
|
||||
"lastAccess", System.currentTimeMillis(),
|
||||
"rotationAvailable", true
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(info);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package com.mosquito.project.interceptor;
|
||||
|
||||
import com.mosquito.project.exception.RateLimitExceededException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 分布式速率限制拦截器
|
||||
* 生产环境强制使用Redis进行限流
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@Value("${app.rate-limit.per-minute:100}")
|
||||
private int perMinuteLimit;
|
||||
|
||||
@Value("${app.rate-limit.window-size:1}")
|
||||
private int windowSizeMinutes;
|
||||
|
||||
@Value("${spring.profiles.active:dev}")
|
||||
private String activeProfile;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
// 生产环境强制使用Redis
|
||||
if ("prod".equals(activeProfile)) {
|
||||
if (redisTemplate == null) {
|
||||
log.error("Production mode requires Redis for rate limiting, but Redis is not configured");
|
||||
throw new IllegalStateException("Production环境必须配置Redis进行速率限制");
|
||||
}
|
||||
return checkRateLimitWithRedis(request);
|
||||
} else {
|
||||
log.debug("Development mode: rate limiting using Redis (if available)");
|
||||
return checkRateLimitWithRedis(request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用Redis进行分布式速率限制
|
||||
*/
|
||||
private boolean checkRateLimitWithRedis(HttpServletRequest request) {
|
||||
String clientIp = getClientIp(request);
|
||||
String endpoint = request.getRequestURI();
|
||||
String key = String.format("rate_limit:%s:%s", clientIp, endpoint);
|
||||
|
||||
try {
|
||||
// Redis原子操作:检查并设置
|
||||
Long currentCount = (Long) redisTemplate.opsForValue().increment(key);
|
||||
|
||||
if (currentCount == 1) {
|
||||
// 第一次访问,设置过期时间
|
||||
redisTemplate.expire(key, windowSizeMinutes, TimeUnit.MINUTES);
|
||||
log.debug("Rate limit counter initialized for key: {}", key);
|
||||
}
|
||||
|
||||
if (currentCount > perMinuteLimit) {
|
||||
log.warn("Rate limit exceeded for client: {}, endpoint: {}, count: {}",
|
||||
clientIp, endpoint, currentCount);
|
||||
throw new RateLimitExceededException(
|
||||
String.format("请求过于频繁,请%d分钟后再试", windowSizeMinutes));
|
||||
}
|
||||
|
||||
log.debug("Rate limit check passed for client: {}, count: {}", clientIp, currentCount);
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Redis rate limiting failed, falling back to allow: {}", e.getMessage());
|
||||
// Redis故障时允许请求通过,但记录警告
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实IP地址
|
||||
*/
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_CLIENT_IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
|
||||
// 处理多个IP的情况(X-Forwarded-For可能包含多个IP)
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
|
||||
return ip != null ? ip : "unknown";
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
import com.mosquito.project.persistence.entity.ApiKeyEntity;
|
||||
import com.mosquito.project.persistence.repository.ApiKeyRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* API密钥安全管理服务
|
||||
* 提供密钥的加密存储、恢复和轮换功能
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeySecurityService {
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
|
||||
@Value("${app.security.encryption-key:}")
|
||||
private String encryptionKey;
|
||||
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
private static final int TAG_LENGTH_BIT = 128;
|
||||
private static final int IV_LENGTH_BYTE = 12;
|
||||
|
||||
/**
|
||||
* 生成新的API密钥并加密存储
|
||||
*/
|
||||
@Transactional
|
||||
public ApiKeyEntity generateAndStoreApiKey(Long activityId, String description) {
|
||||
String rawApiKey = generateRawApiKey();
|
||||
String encryptedKey = encrypt(rawApiKey);
|
||||
|
||||
ApiKeyEntity apiKey = new ApiKeyEntity();
|
||||
apiKey.setActivityId(activityId);
|
||||
apiKey.setDescription(description);
|
||||
apiKey.setEncryptedKey(encryptedKey);
|
||||
apiKey.setIsActive(true);
|
||||
apiKey.setCreatedAt(java.time.LocalDateTime.now());
|
||||
|
||||
return apiKeyRepository.save(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密API密钥(仅用于重新显示)
|
||||
*/
|
||||
public String decryptApiKey(ApiKeyEntity apiKey) {
|
||||
try {
|
||||
return decrypt(apiKey.getEncryptedKey());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to decrypt API key for id: {}", apiKey.getId(), e);
|
||||
throw new RuntimeException("API密钥解密失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新显示API密钥(需要额外验证)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<String> revealApiKey(Long apiKeyId, String verificationCode) {
|
||||
ApiKeyEntity apiKey = apiKeyRepository.findById(apiKeyId)
|
||||
.orElseThrow(() -> new RuntimeException("API密钥不存在"));
|
||||
|
||||
// 验证密钥状态
|
||||
if (!apiKey.getIsActive()) {
|
||||
throw new RuntimeException("API密钥已被撤销");
|
||||
}
|
||||
|
||||
// 验证访问权限(这里可以添加邮箱/手机验证逻辑)
|
||||
if (!verifyAccessPermission(apiKey, verificationCode)) {
|
||||
log.warn("Unauthorized attempt to reveal API key: {}", apiKeyId);
|
||||
throw new RuntimeException("访问权限验证失败");
|
||||
}
|
||||
|
||||
return Optional.of(decryptApiKey(apiKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮换API密钥
|
||||
*/
|
||||
@Transactional
|
||||
public ApiKeyEntity rotateApiKey(Long apiKeyId) {
|
||||
ApiKeyEntity oldKey = apiKeyRepository.findById(apiKeyId)
|
||||
.orElseThrow(() -> new RuntimeException("API密钥不存在"));
|
||||
|
||||
// 撤销旧密钥
|
||||
oldKey.setIsActive(false);
|
||||
oldKey.setRevokedAt(java.time.LocalDateTime.now());
|
||||
apiKeyRepository.save(oldKey);
|
||||
|
||||
// 生成新密钥
|
||||
return generateAndStoreApiKey(oldKey.getActivityId(),
|
||||
oldKey.getDescription() + " (轮换)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成原始API密钥
|
||||
*/
|
||||
private String generateRawApiKey() {
|
||||
return java.util.UUID.randomUUID().toString() + "-" +
|
||||
java.util.UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密密钥
|
||||
*/
|
||||
private String encrypt(String data) {
|
||||
try {
|
||||
byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8);
|
||||
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
|
||||
|
||||
byte[] iv = new byte[IV_LENGTH_BYTE];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
|
||||
|
||||
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// IV + encrypted data
|
||||
byte[] combined = new byte[iv.length + encrypted.length];
|
||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
|
||||
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
log.error("Encryption failed", e);
|
||||
throw new RuntimeException("加密失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密密钥
|
||||
*/
|
||||
private String decrypt(String encryptedData) {
|
||||
try {
|
||||
byte[] combined = Base64.getDecoder().decode(encryptedData);
|
||||
|
||||
// 提取IV和加密数据
|
||||
byte[] iv = new byte[IV_LENGTH_BYTE];
|
||||
byte[] encrypted = new byte[combined.length - IV_LENGTH_BYTE];
|
||||
|
||||
System.arraycopy(combined, 0, iv, 0, IV_LENGTH_BYTE);
|
||||
System.arraycopy(combined, IV_LENGTH_BYTE, encrypted, 0, encrypted.length);
|
||||
|
||||
byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8);
|
||||
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
|
||||
|
||||
byte[] decrypted = cipher.doFinal(encrypted);
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
log.error("Decryption failed", e);
|
||||
throw new RuntimeException("解密失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证访问权限(可扩展为邮箱/手机验证)
|
||||
*/
|
||||
private boolean verifyAccessPermission(ApiKeyEntity apiKey, String verificationCode) {
|
||||
// 这里可以实现复杂的验证逻辑
|
||||
// 例如:验证邮箱验证码、手机验证码、安全问题等
|
||||
return true; // 简化实现
|
||||
}
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
import com.mosquito.project.config.PosterConfig;
|
||||
import com.mosquito.project.domain.Activity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.awt.*;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* PosterRenderService 边界条件和异常处理测试
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("PosterRenderService 边界测试")
|
||||
class PosterRenderServiceBoundaryTest {
|
||||
|
||||
@Mock
|
||||
private PosterConfig posterConfig;
|
||||
|
||||
@Mock
|
||||
private ShortLinkService shortLinkService;
|
||||
|
||||
@Mock
|
||||
private PosterConfig.PosterTemplate mockTemplate;
|
||||
|
||||
@InjectMocks
|
||||
private PosterRenderService posterRenderService;
|
||||
|
||||
private Activity testActivity;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
testActivity = new Activity();
|
||||
testActivity.setId(123L);
|
||||
testActivity.setName("测试活动");
|
||||
|
||||
// 清除图片缓存
|
||||
Field imageCacheField = PosterRenderService.class.getDeclaredField("imageCache");
|
||||
imageCacheField.setAccessible(true);
|
||||
Map<String, Image> imageCache = (Map<String, Image>) imageCacheField.get(posterRenderService);
|
||||
imageCache.clear();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("renderPoster边界条件测试")
|
||||
class RenderPosterBoundaryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null模板名应该使用默认模板")
|
||||
void shouldUseDefaultTemplate_WhenTemplateNameIsNull() {
|
||||
// Given
|
||||
String defaultTemplateName = "default";
|
||||
when(posterConfig.getTemplate(null)).thenReturn(null);
|
||||
when(posterConfig.getTemplate(defaultTemplateName)).thenReturn(mockTemplate);
|
||||
when(posterConfig.getDefaultTemplate()).thenReturn(defaultTemplateName);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
|
||||
// When
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, null);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
verify(posterConfig).getTemplate(null);
|
||||
verify(posterConfig).getTemplate(defaultTemplateName);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不存在的模板名应该使用默认模板")
|
||||
void shouldUseDefaultTemplate_WhenTemplateNotFound() {
|
||||
// Given
|
||||
String invalidTemplateName = "nonexistent";
|
||||
String defaultTemplateName = "default";
|
||||
when(posterConfig.getTemplate(invalidTemplateName)).thenReturn(null);
|
||||
when(posterConfig.getTemplate(defaultTemplateName)).thenReturn(mockTemplate);
|
||||
when(posterConfig.getDefaultTemplate()).thenReturn(defaultTemplateName);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
|
||||
// When
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, invalidTemplateName);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(posterConfig).getTemplate(invalidTemplateName);
|
||||
verify(posterConfig).getTemplate(defaultTemplateName);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("模板背景为空字符串应该使用背景色")
|
||||
void shouldUseBackgroundColor_WhenTemplateBackgroundIsEmpty() {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackground()).thenReturn("");
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FF0000");
|
||||
|
||||
// When
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("模板背景为null应该使用背景色")
|
||||
void shouldUseBackgroundColor_WhenTemplateBackgroundIsNull() {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackground()).thenReturn(null);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#00FF00");
|
||||
|
||||
// When
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("模板没有背景设置应该使用背景色")
|
||||
void shouldUseBackgroundColor_WhenTemplateHasNoBackground() {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#0000FF");
|
||||
|
||||
// When
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("图片加载失败应该使用背景色")
|
||||
void shouldUseBackgroundColor_WhenImageLoadFails() {
|
||||
// Given
|
||||
String invalidImageUrl = "nonexistent-image.jpg";
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackground()).thenReturn(invalidImageUrl);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFAA00");
|
||||
when(posterConfig.getCdnBaseUrl()).thenReturn("https://cdn.example.com");
|
||||
|
||||
// When
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空元素列表应该正常渲染")
|
||||
void shouldRenderSuccessfully_WhenElementsListIsEmpty() throws Exception {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
|
||||
// 设置空元素map
|
||||
Field elementsField = PosterConfig.PosterTemplate.class.getDeclaredField("elements");
|
||||
elementsField.setAccessible(true);
|
||||
elementsField.set(mockTemplate, new ConcurrentHashMap<>());
|
||||
|
||||
// When
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#000000"})
|
||||
@DisplayName("不同背景色应该正确渲染")
|
||||
void shouldRenderCorrectly_WithDifferentBackgroundColors(String backgroundColor) {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn(backgroundColor);
|
||||
|
||||
// When & Then
|
||||
assertDoesNotThrow(() -> {
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("renderPosterHtml边界条件测试")
|
||||
class RenderPosterHtmlBoundaryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null活动名称应该使用默认值")
|
||||
void shouldUseDefaultTitle_WhenActivityNameIsNull() {
|
||||
// Given
|
||||
Activity nullNameActivity = new Activity();
|
||||
nullNameActivity.setId(123L);
|
||||
nullNameActivity.setName(null);
|
||||
|
||||
setupMockTemplate();
|
||||
when(shortLinkService.create(anyString())).thenReturn(new com.mosquito.project.domain.ShortLink());
|
||||
when(shortLinkService.create(anyString())).thenReturn(new com.mosquito.project.domain.ShortLink());
|
||||
|
||||
// 使用反射设置模拟活动
|
||||
// 注意:这里需要更复杂的反射来模拟activityService的返回值
|
||||
|
||||
// When
|
||||
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(html);
|
||||
assertTrue(html.contains("<title>分享</title>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空活动名称应该使用默认值")
|
||||
void shouldUseDefaultTitle_WhenActivityNameIsEmpty() {
|
||||
// Given
|
||||
Activity emptyNameActivity = new Activity();
|
||||
emptyNameActivity.setId(123L);
|
||||
emptyNameActivity.setName("");
|
||||
|
||||
setupMockTemplate();
|
||||
|
||||
// When
|
||||
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(html);
|
||||
assertTrue(html.contains("<title>分享</title>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("活动名称包含特殊字符应该正确转义")
|
||||
void shouldEscapeHtml_WhenActivityNameContainsSpecialChars() {
|
||||
// Given
|
||||
Activity specialCharActivity = new Activity();
|
||||
specialCharActivity.setId(123L);
|
||||
specialCharActivity.setName("活动名称 & <script> alert('xss') </script>\"");
|
||||
|
||||
setupMockTemplate();
|
||||
|
||||
// When
|
||||
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(html);
|
||||
assertFalse(html.contains("<script>"));
|
||||
assertTrue(html.contains("<script>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("URL编码失败应该使用原始URL")
|
||||
void shouldUseOriginalUrl_WhenUrlEncodingFails() {
|
||||
// Given
|
||||
setupMockTemplate();
|
||||
setupQrCodeElement();
|
||||
|
||||
// When
|
||||
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(html);
|
||||
assertTrue(html.contains("data="));
|
||||
// 确保即使编码失败也生成HTML
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("长活动名称应该正确处理")
|
||||
void shouldHandleLongActivityName() {
|
||||
// Given
|
||||
StringBuilder longName = new StringBuilder();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
longName.append("很长的活动名称");
|
||||
}
|
||||
Activity longNameActivity = new Activity();
|
||||
longNameActivity.setId(123L);
|
||||
longNameActivity.setName(longName.toString());
|
||||
|
||||
setupMockTemplate();
|
||||
|
||||
// When
|
||||
String html = posterRenderService.renderPosterHtml(123L, 456L, "test");
|
||||
|
||||
// Then
|
||||
assertNotNull(html);
|
||||
assertTrue(html.length() > 0);
|
||||
}
|
||||
|
||||
private void setupMockTemplate() {
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
}
|
||||
|
||||
private void setupQrCodeElement() {
|
||||
PosterConfig.PosterElement qrElement = new PosterConfig.PosterElement();
|
||||
qrElement.setType("qrcode");
|
||||
qrElement.setX(100);
|
||||
qrElement.setY(100);
|
||||
qrElement.setWidth(200);
|
||||
qrElement.setHeight(200);
|
||||
|
||||
Map<String, PosterConfig.PosterElement> elements = Map.of("qrcode", qrElement);
|
||||
when(mockTemplate.getElements()).thenReturn(elements);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("异常处理测试")
|
||||
class ExceptionHandlingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("ImageIO写入失败应该抛出RuntimeException")
|
||||
void shouldThrowRuntimeException_WhenImageIoWriteFails() {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(Integer.MAX_VALUE); // 极大尺寸可能导致内存错误
|
||||
when(mockTemplate.getHeight()).thenReturn(Integer.MAX_VALUE);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
|
||||
// When & Then
|
||||
assertThrows(RuntimeException.class, () -> {
|
||||
posterRenderService.renderPoster(1L, 1L, "test");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无效颜色代码应该抛出异常")
|
||||
void shouldThrowException_WhenColorCodeIsInvalid() {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("invalid-color");
|
||||
|
||||
// When & Then
|
||||
assertThrows(NumberFormatException.class, () -> {
|
||||
posterRenderService.renderPoster(1L, 1L, "test");
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@DisplayName("null或空模板名应该不抛出异常")
|
||||
void shouldNotThrowException_WhenTemplateNameIsNullOrEmpty(String templateName) {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(templateName)).thenReturn(null);
|
||||
when(posterConfig.getTemplate("default")).thenReturn(mockTemplate);
|
||||
when(posterConfig.getDefaultTemplate()).thenReturn("default");
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
|
||||
// When & Then
|
||||
assertDoesNotThrow(() -> {
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, templateName);
|
||||
assertNotNull(result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("辅助方法边界测试")
|
||||
class HelperMethodBoundaryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("解析字体大小失败应该使用默认值")
|
||||
void shouldUseDefaultFontSize_WhenParsingFails() throws Exception {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
|
||||
PosterConfig.PosterElement textElement = new PosterConfig.PosterElement();
|
||||
textElement.setType("text");
|
||||
textElement.setContent("测试文本");
|
||||
textElement.setFontSize("invalid-size"); // 无效的字体大小
|
||||
textElement.setColor("#000000");
|
||||
textElement.setFontFamily("Arial");
|
||||
textElement.setX(100);
|
||||
textElement.setY(100);
|
||||
|
||||
Map<String, PosterConfig.PosterElement> elements = Map.of("text", textElement);
|
||||
when(mockTemplate.getElements()).thenReturn(elements);
|
||||
|
||||
// When & Then
|
||||
assertDoesNotThrow(() -> {
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
assertNotNull(result);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空内容字符串应该正确处理")
|
||||
void shouldHandleEmptyContentString() throws Exception {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
|
||||
PosterConfig.PosterElement textElement = new PosterConfig.PosterElement();
|
||||
textElement.setType("text");
|
||||
textElement.setContent(""); // 空内容
|
||||
textElement.setFontSize("16px");
|
||||
textElement.setColor("#000000");
|
||||
textElement.setFontFamily("Arial");
|
||||
textElement.setX(100);
|
||||
textElement.setY(100);
|
||||
|
||||
Map<String, PosterConfig.PosterElement> elements = Map.of("text", textElement);
|
||||
when(mockTemplate.getElements()).thenReturn(elements);
|
||||
|
||||
// When & Then
|
||||
assertDoesNotThrow(() -> {
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
assertNotNull(result);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null内容应该返回空字符串")
|
||||
void shouldReturnEmptyString_WhenContentIsNull() throws Exception {
|
||||
// Given
|
||||
when(posterConfig.getTemplate(anyString())).thenReturn(mockTemplate);
|
||||
when(mockTemplate.getWidth()).thenReturn(800);
|
||||
when(mockTemplate.getHeight()).thenReturn(600);
|
||||
when(mockTemplate.getBackgroundColor()).thenReturn("#FFFFFF");
|
||||
|
||||
PosterConfig.PosterElement textElement = new PosterConfig.PosterElement();
|
||||
textElement.setType("text");
|
||||
textElement.setContent(null); // null内容
|
||||
textElement.setFontSize("16px");
|
||||
textElement.setColor("#000000");
|
||||
textElement.setFontFamily("Arial");
|
||||
textElement.setX(100);
|
||||
textElement.setY(100);
|
||||
|
||||
Map<String, PosterConfig.PosterElement> elements = Map.of("text", textElement);
|
||||
when(mockTemplate.getElements()).thenReturn(elements);
|
||||
|
||||
// When & Then
|
||||
assertDoesNotThrow(() -> {
|
||||
byte[] result = posterRenderService.renderPoster(1L, 1L, "test");
|
||||
assertNotNull(result);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user