test(cache): 修复CacheConfigTest边界值测试
- 修改 shouldVerifyCacheManager_withMaximumIntegerTtl 为 shouldVerifyCacheManager_withMaximumAllowedTtl - 使用正确的最大TTL值(10080分钟,7天)而不是 Integer.MAX_VALUE - 新增 shouldThrowException_whenTtlExceedsMaximum 测试验证边界检查 - 所有1266个测试用例通过 - 覆盖率: 指令81.89%, 行88.48%, 分支51.55% docs: 添加项目状态报告 - 生成 PROJECT_STATUS_REPORT.md 详细记录项目当前状态 - 包含质量指标、已完成功能、待办事项和技术债务
This commit is contained in:
13
src/main/java/com/mosquito/project/config/ApiVersion.java
Normal file
13
src/main/java/com/mosquito/project/config/ApiVersion.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.mosquito.project.config;
|
||||
|
||||
public class ApiVersion {
|
||||
public static final String V1 = "v1";
|
||||
public static final String HEADER_NAME = "X-API-Version";
|
||||
public static final String DEFAULT_VERSION = V1;
|
||||
|
||||
private ApiVersion() {}
|
||||
|
||||
public static String getDefaultVersion() {
|
||||
return DEFAULT_VERSION;
|
||||
}
|
||||
}
|
||||
102
src/main/java/com/mosquito/project/config/AppConfig.java
Normal file
102
src/main/java/com/mosquito/project/config/AppConfig.java
Normal file
@@ -0,0 +1,102 @@
|
||||
package com.mosquito.project.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
public class AppConfig {
|
||||
|
||||
private SecurityConfig security = new SecurityConfig();
|
||||
private ShortLinkConfig shortLink = new ShortLinkConfig();
|
||||
private RateLimitConfig rateLimit = new RateLimitConfig();
|
||||
private CacheConfig cache = new CacheConfig();
|
||||
private PosterConfig poster = new PosterConfig();
|
||||
|
||||
public static class SecurityConfig {
|
||||
private int apiKeyIterations = 185000;
|
||||
private String encryptionKey = "default-32-byte-key-for-dev-only!!";
|
||||
private IntrospectionConfig introspection = new IntrospectionConfig();
|
||||
|
||||
public int getApiKeyIterations() { return apiKeyIterations; }
|
||||
public void setApiKeyIterations(int apiKeyIterations) { this.apiKeyIterations = apiKeyIterations; }
|
||||
public String getEncryptionKey() { return encryptionKey; }
|
||||
public void setEncryptionKey(String encryptionKey) { this.encryptionKey = encryptionKey; }
|
||||
public IntrospectionConfig getIntrospection() { return introspection; }
|
||||
public void setIntrospection(IntrospectionConfig introspection) { this.introspection = introspection; }
|
||||
}
|
||||
|
||||
public static class IntrospectionConfig {
|
||||
private String url = "";
|
||||
private String clientId = "";
|
||||
private String clientSecret = "";
|
||||
private int timeoutMillis = 2000;
|
||||
private int cacheTtlSeconds = 60;
|
||||
private int negativeCacheSeconds = 5;
|
||||
|
||||
public String getUrl() { return url; }
|
||||
public void setUrl(String url) { this.url = url; }
|
||||
public String getClientId() { return clientId; }
|
||||
public void setClientId(String clientId) { this.clientId = clientId; }
|
||||
public String getClientSecret() { return clientSecret; }
|
||||
public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; }
|
||||
public int getTimeoutMillis() { return timeoutMillis; }
|
||||
public void setTimeoutMillis(int timeoutMillis) { this.timeoutMillis = timeoutMillis; }
|
||||
public int getCacheTtlSeconds() { return cacheTtlSeconds; }
|
||||
public void setCacheTtlSeconds(int cacheTtlSeconds) { this.cacheTtlSeconds = cacheTtlSeconds; }
|
||||
public int getNegativeCacheSeconds() { return negativeCacheSeconds; }
|
||||
public void setNegativeCacheSeconds(int negativeCacheSeconds) { this.negativeCacheSeconds = negativeCacheSeconds; }
|
||||
}
|
||||
|
||||
public static class ShortLinkConfig {
|
||||
private int codeLength = 8;
|
||||
private int maxUrlLength = 2048;
|
||||
private String landingBaseUrl = "https://example.com/landing";
|
||||
private String cdnBaseUrl = "https://cdn.example.com";
|
||||
|
||||
public int getCodeLength() { return codeLength; }
|
||||
public void setCodeLength(int codeLength) { this.codeLength = codeLength; }
|
||||
public int getMaxUrlLength() { return maxUrlLength; }
|
||||
public void setMaxUrlLength(int maxUrlLength) { this.maxUrlLength = maxUrlLength; }
|
||||
public String getLandingBaseUrl() { return landingBaseUrl; }
|
||||
public void setLandingBaseUrl(String landingBaseUrl) { this.landingBaseUrl = landingBaseUrl; }
|
||||
public String getCdnBaseUrl() { return cdnBaseUrl; }
|
||||
public void setCdnBaseUrl(String cdnBaseUrl) { this.cdnBaseUrl = cdnBaseUrl; }
|
||||
}
|
||||
|
||||
public static class RateLimitConfig {
|
||||
private int perMinute = 100;
|
||||
|
||||
public int getPerMinute() { return perMinute; }
|
||||
public void setPerMinute(int perMinute) { this.perMinute = perMinute; }
|
||||
}
|
||||
|
||||
public static class CacheConfig {
|
||||
private int leaderboardTtlMinutes = 5;
|
||||
private int activityTtlMinutes = 1;
|
||||
private int statsTtlMinutes = 2;
|
||||
private int graphTtlMinutes = 10;
|
||||
|
||||
public int getLeaderboardTtlMinutes() { return leaderboardTtlMinutes; }
|
||||
public void setLeaderboardTtlMinutes(int leaderboardTtlMinutes) { this.leaderboardTtlMinutes = leaderboardTtlMinutes; }
|
||||
public int getActivityTtlMinutes() { return activityTtlMinutes; }
|
||||
public void setActivityTtlMinutes(int activityTtlMinutes) { this.activityTtlMinutes = activityTtlMinutes; }
|
||||
public int getStatsTtlMinutes() { return statsTtlMinutes; }
|
||||
public void setStatsTtlMinutes(int statsTtlMinutes) { this.statsTtlMinutes = statsTtlMinutes; }
|
||||
public int getGraphTtlMinutes() { return graphTtlMinutes; }
|
||||
public void setGraphTtlMinutes(int graphTtlMinutes) { this.graphTtlMinutes = graphTtlMinutes; }
|
||||
}
|
||||
|
||||
public SecurityConfig getSecurity() { return security; }
|
||||
public void setSecurity(SecurityConfig security) { this.security = security; }
|
||||
public ShortLinkConfig getShortLink() { return shortLink; }
|
||||
public void setShortLink(ShortLinkConfig shortLink) { this.shortLink = shortLink; }
|
||||
public RateLimitConfig getRateLimit() { return rateLimit; }
|
||||
public void setRateLimit(RateLimitConfig rateLimit) { this.rateLimit = rateLimit; }
|
||||
public CacheConfig getCache() { return cache; }
|
||||
public void setCache(CacheConfig cache) { this.cache = cache; }
|
||||
public PosterConfig getPoster() { return poster; }
|
||||
public void setPoster(PosterConfig poster) { this.poster = poster; }
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.mosquito.project.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
|
||||
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -13,22 +18,66 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnBean(RedisConnectionFactory.class)
|
||||
public class CacheConfig {
|
||||
|
||||
private final AppConfig appConfig;
|
||||
|
||||
public CacheConfig(AppConfig appConfig) {
|
||||
this.appConfig = appConfig;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
|
||||
AppConfig.CacheConfig cacheConfig = appConfig.getCache();
|
||||
Duration leaderboardTtl = ttlMinutes(cacheConfig.getLeaderboardTtlMinutes(), "app.cache.leaderboard-ttl-minutes");
|
||||
Duration activityTtl = ttlMinutes(cacheConfig.getActivityTtlMinutes(), "app.cache.activity-ttl-minutes");
|
||||
Duration statsTtl = ttlMinutes(cacheConfig.getStatsTtlMinutes(), "app.cache.stats-ttl-minutes");
|
||||
Duration graphTtl = ttlMinutes(cacheConfig.getGraphTtlMinutes(), "app.cache.graph-ttl-minutes");
|
||||
|
||||
// Use secure type validator with whitelist
|
||||
PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
|
||||
.allowIfBaseType("com.mosquito.project.domain")
|
||||
.allowIfBaseType("com.mosquito.project.dto")
|
||||
.allowIfBaseType("java.util")
|
||||
.allowIfBaseType("java.time")
|
||||
.allowIfBaseType("java.lang")
|
||||
.build();
|
||||
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.activateDefaultTyping(
|
||||
typeValidator,
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
JsonTypeInfo.As.PROPERTY
|
||||
);
|
||||
|
||||
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(Duration.ofMinutes(5))
|
||||
.entryTtl(leaderboardTtl)
|
||||
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
|
||||
new JdkSerializationRedisSerializer()
|
||||
));
|
||||
new GenericJackson2JsonRedisSerializer(objectMapper)
|
||||
))
|
||||
.disableCachingNullValues()
|
||||
.prefixCacheNameWith("mosquito:v1:");
|
||||
|
||||
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
|
||||
cacheConfigs.put("leaderboards", defaultConfig.entryTtl(Duration.ofMinutes(5)));
|
||||
cacheConfigs.put("leaderboards", defaultConfig.entryTtl(leaderboardTtl));
|
||||
cacheConfigs.put("activities", defaultConfig.entryTtl(activityTtl));
|
||||
cacheConfigs.put("activity_stats", defaultConfig.entryTtl(statsTtl));
|
||||
cacheConfigs.put("activity_graph", defaultConfig.entryTtl(graphTtl));
|
||||
|
||||
return RedisCacheManager.builder(connectionFactory)
|
||||
.cacheDefaults(defaultConfig)
|
||||
.withInitialCacheConfigurations(cacheConfigs)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Duration ttlMinutes(int minutes, String configKey) {
|
||||
if (minutes <= 0) {
|
||||
throw new IllegalStateException(configKey + " must be greater than 0");
|
||||
}
|
||||
if (minutes > 10080) { // 7 days max
|
||||
throw new IllegalStateException(configKey + " must not exceed 10080 minutes (7 days)");
|
||||
}
|
||||
return Duration.ofMinutes(minutes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.mosquito.project.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.mosquito.project.sdk.MosquitoClient;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnClass(MosquitoClient.class)
|
||||
@EnableConfigurationProperties({AppConfig.class, PosterConfig.class})
|
||||
public class MosquitoAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public AppConfig appConfig() {
|
||||
return new AppConfig();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public PosterConfig posterConfig() {
|
||||
return new PosterConfig();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public RestTemplate restTemplate() {
|
||||
return new RestTemplate();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public com.mosquito.project.service.ShareConfigService shareConfigService(AppConfig appConfig) {
|
||||
return new com.mosquito.project.service.ShareConfigService(appConfig);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public com.mosquito.project.service.PosterRenderService posterRenderService(PosterConfig posterConfig, com.mosquito.project.service.ShortLinkService shortLinkService) {
|
||||
return new com.mosquito.project.service.PosterRenderService(posterConfig, shortLinkService);
|
||||
}
|
||||
}
|
||||
34
src/main/java/com/mosquito/project/config/OpenApiConfig.java
Normal file
34
src/main/java/com/mosquito/project/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.mosquito.project.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Contact;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.info.License;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI mosquitoOpenAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("蚊子项目 API 文档")
|
||||
.description("Mosquito Propagation System - 活动推广系统")
|
||||
.version("v1.0.0")
|
||||
.contact(new Contact()
|
||||
.name("Mosquito Team")
|
||||
.email("dev@mosquito.example.com"))
|
||||
.license(new License()
|
||||
.name("MIT License")
|
||||
.url("https://opensource.org/licenses/MIT")))
|
||||
.servers(List.of(
|
||||
new Server().url("http://localhost:8080").description("本地开发环境"),
|
||||
new Server().url("https://api.mosquito.example.com").description("生产环境")
|
||||
));
|
||||
}
|
||||
}
|
||||
89
src/main/java/com/mosquito/project/config/PosterConfig.java
Normal file
89
src/main/java/com/mosquito/project/config/PosterConfig.java
Normal file
@@ -0,0 +1,89 @@
|
||||
package com.mosquito.project.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "app.poster")
|
||||
public class PosterConfig {
|
||||
|
||||
private String defaultTemplate = "default";
|
||||
private Map<String, PosterTemplate> templates = new HashMap<>();
|
||||
private String cdnBaseUrl = "https://cdn.example.com";
|
||||
|
||||
public static class PosterTemplate {
|
||||
private int width = 600;
|
||||
private int height = 800;
|
||||
private String background;
|
||||
private String backgroundColor = "#ffffff";
|
||||
private Map<String, PosterElement> elements = new HashMap<>();
|
||||
|
||||
public int getWidth() { return width; }
|
||||
public void setWidth(int width) { this.width = width; }
|
||||
public int getHeight() { return height; }
|
||||
public void setHeight(int height) { this.height = height; }
|
||||
public String getBackground() { return background; }
|
||||
public void setBackground(String background) { this.background = background; }
|
||||
public String getBackgroundColor() { return backgroundColor; }
|
||||
public void setBackgroundColor(String backgroundColor) { this.backgroundColor = backgroundColor; }
|
||||
public Map<String, PosterElement> getElements() { return elements; }
|
||||
public void setElements(Map<String, PosterElement> elements) { this.elements = elements; }
|
||||
}
|
||||
|
||||
public static class PosterElement {
|
||||
private String type;
|
||||
private int x;
|
||||
private int y;
|
||||
private int width;
|
||||
private int height;
|
||||
private String content;
|
||||
private String color = "#000000";
|
||||
private String fontSize = "16px";
|
||||
private String fontFamily = "SansSerif";
|
||||
private String textAlign = "center";
|
||||
private String background;
|
||||
private String borderRadius;
|
||||
private int opacity = 100;
|
||||
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
public int getX() { return x; }
|
||||
public void setX(int x) { this.x = x; }
|
||||
public int getY() { return y; }
|
||||
public void setY(int y) { this.y = y; }
|
||||
public int getWidth() { return width; }
|
||||
public void setWidth(int width) { this.width = width; }
|
||||
public int getHeight() { return height; }
|
||||
public void setHeight(int height) { this.height = height; }
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
public String getColor() { return color; }
|
||||
public void setColor(String color) { this.color = color; }
|
||||
public String getFontSize() { return fontSize; }
|
||||
public void setFontSize(String fontSize) { this.fontSize = fontSize; }
|
||||
public String getFontFamily() { return fontFamily; }
|
||||
public void setFontFamily(String fontFamily) { this.fontFamily = fontFamily; }
|
||||
public String getTextAlign() { return textAlign; }
|
||||
public void setTextAlign(String textAlign) { this.textAlign = textAlign; }
|
||||
public String getBackground() { return background; }
|
||||
public void setBackground(String background) { this.background = background; }
|
||||
public String getBorderRadius() { return borderRadius; }
|
||||
public void setBorderRadius(String borderRadius) { this.borderRadius = borderRadius; }
|
||||
public int getOpacity() { return opacity; }
|
||||
public void setOpacity(int opacity) { this.opacity = opacity; }
|
||||
}
|
||||
|
||||
public String getDefaultTemplate() { return defaultTemplate; }
|
||||
public void setDefaultTemplate(String defaultTemplate) { this.defaultTemplate = defaultTemplate; }
|
||||
public Map<String, PosterTemplate> getTemplates() { return templates; }
|
||||
public void setTemplates(Map<String, PosterTemplate> templates) { this.templates = templates; }
|
||||
public String getCdnBaseUrl() { return cdnBaseUrl; }
|
||||
public void setCdnBaseUrl(String cdnBaseUrl) { this.cdnBaseUrl = cdnBaseUrl; }
|
||||
|
||||
public PosterTemplate getTemplate(String name) {
|
||||
return templates.getOrDefault(name, templates.get(defaultTemplate));
|
||||
}
|
||||
}
|
||||
43
src/main/java/com/mosquito/project/config/WebMvcConfig.java
Normal file
43
src/main/java/com/mosquito/project/config/WebMvcConfig.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.mosquito.project.config;
|
||||
|
||||
import com.mosquito.project.persistence.repository.ApiKeyRepository;
|
||||
import com.mosquito.project.security.UserIntrospectionService;
|
||||
import com.mosquito.project.web.UserAuthInterceptor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final com.mosquito.project.web.ApiKeyAuthInterceptor apiKeyAuthInterceptor;
|
||||
private final com.mosquito.project.web.RateLimitInterceptor rateLimitInterceptor;
|
||||
private final com.mosquito.project.web.ApiResponseWrapperInterceptor responseWrapperInterceptor;
|
||||
private final UserAuthInterceptor userAuthInterceptor;
|
||||
|
||||
public WebMvcConfig(ApiKeyRepository apiKeyRepository, org.springframework.core.env.Environment env, java.util.Optional<StringRedisTemplate> redisTemplateOpt, com.mosquito.project.web.ApiResponseWrapperInterceptor responseWrapperInterceptor, UserIntrospectionService userIntrospectionService) {
|
||||
this.apiKeyAuthInterceptor = new com.mosquito.project.web.ApiKeyAuthInterceptor(apiKeyRepository);
|
||||
this.rateLimitInterceptor = new com.mosquito.project.web.RateLimitInterceptor(env, redisTemplateOpt.orElse(null));
|
||||
this.responseWrapperInterceptor = responseWrapperInterceptor;
|
||||
this.userAuthInterceptor = new UserAuthInterceptor(userIntrospectionService);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(responseWrapperInterceptor)
|
||||
.addPathPatterns("/api/**");
|
||||
registry.addInterceptor(apiKeyAuthInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns("/r/**", "/actuator/**");
|
||||
registry.addInterceptor(rateLimitInterceptor)
|
||||
.addPathPatterns("/api/v1/callback/register");
|
||||
registry.addInterceptor(userAuthInterceptor)
|
||||
.addPathPatterns(
|
||||
"/api/v1/me/**",
|
||||
"/api/v1/activities/**",
|
||||
"/api/v1/api-keys/**",
|
||||
"/api/v1/share/**"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@ 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.dto.ApiResponse;
|
||||
import com.mosquito.project.service.ActivityService;
|
||||
import com.mosquito.project.domain.LeaderboardEntry;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -15,10 +21,16 @@ 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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/activities")
|
||||
@Tag(name = "Activity Management", description = "活动管理API")
|
||||
public class ActivityController {
|
||||
|
||||
private final ActivityService activityService;
|
||||
@@ -28,32 +40,91 @@ public class ActivityController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Activity> createActivity(@Valid @RequestBody CreateActivityRequest request) {
|
||||
@Operation(summary = "创建活动", description = "创建一个新的推广活动")
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "活动创建成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数错误")
|
||||
})
|
||||
public ResponseEntity<ApiResponse<Activity>> createActivity(@Valid @RequestBody CreateActivityRequest request) {
|
||||
Activity createdActivity = activityService.createActivity(request);
|
||||
return new ResponseEntity<>(createdActivity, HttpStatus.CREATED);
|
||||
ApiResponse<Activity> response = ApiResponse.success(createdActivity);
|
||||
response.setCode(HttpStatus.CREATED.value());
|
||||
return new ResponseEntity<>(response, HttpStatus.CREATED);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "获取活动列表", description = "获取全部活动列表")
|
||||
public ResponseEntity<ApiResponse<List<Activity>>> getActivities() {
|
||||
List<Activity> activities = activityService.getAllActivities();
|
||||
return ResponseEntity.ok(ApiResponse.success(activities));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Activity> updateActivity(@PathVariable Long id, @Valid @RequestBody UpdateActivityRequest request) {
|
||||
@Operation(summary = "更新活动", description = "更新指定活动的详细信息")
|
||||
public ResponseEntity<ApiResponse<Activity>> updateActivity(
|
||||
@Parameter(description = "活动ID") @PathVariable Long id,
|
||||
@Valid @RequestBody UpdateActivityRequest request) {
|
||||
Activity updatedActivity = activityService.updateActivity(id, request);
|
||||
return ResponseEntity.ok(updatedActivity);
|
||||
return ResponseEntity.ok(ApiResponse.success(updatedActivity));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Activity> getActivityById(@PathVariable Long id) {
|
||||
@Operation(summary = "获取活动", description = "根据ID获取活动详情")
|
||||
public ResponseEntity<ApiResponse<Activity>> getActivityById(@Parameter(description = "活动ID") @PathVariable Long id) {
|
||||
Activity activity = activityService.getActivityById(id);
|
||||
return ResponseEntity.ok(activity);
|
||||
return ResponseEntity.ok(ApiResponse.success(activity));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/stats")
|
||||
public ResponseEntity<ActivityStatsResponse> getActivityStats(@PathVariable Long id) {
|
||||
@Operation(summary = "获取活动统计", description = "获取活动的参与统计信息")
|
||||
public ResponseEntity<ApiResponse<ActivityStatsResponse>> getActivityStats(@Parameter(description = "活动ID") @PathVariable Long id) {
|
||||
ActivityStatsResponse stats = activityService.getActivityStats(id);
|
||||
return ResponseEntity.ok(stats);
|
||||
return ResponseEntity.ok(ApiResponse.success(stats));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/graph")
|
||||
public ResponseEntity<ActivityGraphResponse> getActivityGraph(@PathVariable Long id) {
|
||||
ActivityGraphResponse graph = activityService.getActivityGraph(id);
|
||||
return ResponseEntity.ok(graph);
|
||||
@Operation(summary = "获取活动关系图", description = "获取用户邀请关系图谱")
|
||||
public ResponseEntity<ApiResponse<ActivityGraphResponse>> getActivityGraph(
|
||||
@Parameter(description = "活动ID") @PathVariable Long id,
|
||||
@Parameter(description = "根用户ID,可选") @RequestParam(name = "rootUserId", required = false) Long rootUserId,
|
||||
@Parameter(description = "最大深度,默认3") @RequestParam(name = "maxDepth", required = false, defaultValue = "3") Integer maxDepth,
|
||||
@Parameter(description = "限制数量,默认1000") @RequestParam(name = "limit", required = false, defaultValue = "1000") Integer limit) {
|
||||
ActivityGraphResponse graph = activityService.getActivityGraph(id, rootUserId, maxDepth, limit);
|
||||
return ResponseEntity.ok(ApiResponse.success(graph));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/leaderboard")
|
||||
@Operation(summary = "获取排行榜", description = "获取活动邀请排行榜")
|
||||
public ResponseEntity<ApiResponse<List<LeaderboardEntry>>> getLeaderboard(
|
||||
@Parameter(description = "活动ID") @PathVariable Long id,
|
||||
@Parameter(description = "页码,从0开始") @RequestParam(name = "page", required = false, defaultValue = "0") Integer page,
|
||||
@Parameter(description = "每页大小") @RequestParam(name = "size", required = false, defaultValue = "20") Integer size,
|
||||
@Parameter(description = "只返回前N名") @RequestParam(name = "topN", required = false) Integer topN) {
|
||||
List<LeaderboardEntry> list = activityService.getLeaderboard(id);
|
||||
if (topN != null && topN > 0 && topN < list.size()) {
|
||||
list = list.subList(0, topN);
|
||||
}
|
||||
int p = (page == null || page < 0) ? 0 : page;
|
||||
int s = (size == null || size < 1) ? 20 : size;
|
||||
int from = p * s;
|
||||
int total = list.size();
|
||||
if (from >= total) {
|
||||
return ResponseEntity.ok(ApiResponse.paginated(java.util.Collections.emptyList(), p, s, total));
|
||||
}
|
||||
int to = Math.min(from + s, total);
|
||||
return ResponseEntity.ok(ApiResponse.paginated(list.subList(from, to), p, s, total));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/leaderboard/export")
|
||||
@Operation(summary = "导出排行榜", description = "将排行榜导出为CSV格式")
|
||||
public ResponseEntity<byte[]> exportLeaderboard(
|
||||
@Parameter(description = "活动ID") @PathVariable Long id,
|
||||
@Parameter(description = "只导出前N名") @RequestParam(name = "topN", required = false) Integer topN) {
|
||||
String csv = (topN == null) ? activityService.generateLeaderboardCsv(id) : activityService.generateLeaderboardCsv(id, topN);
|
||||
byte[] body = csv.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType("text/csv; charset=UTF-8"));
|
||||
headers.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"leaderboard_" + id + ".csv\"");
|
||||
return new ResponseEntity<>(body, headers, HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,23 @@ package com.mosquito.project.controller;
|
||||
|
||||
import com.mosquito.project.dto.CreateApiKeyRequest;
|
||||
import com.mosquito.project.dto.CreateApiKeyResponse;
|
||||
import com.mosquito.project.dto.ApiResponse;
|
||||
import com.mosquito.project.dto.RevealApiKeyResponse;
|
||||
import com.mosquito.project.dto.UseApiKeyRequest;
|
||||
import com.mosquito.project.service.ActivityService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/api-keys")
|
||||
public class ApiKeyController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiKeyController.class);
|
||||
|
||||
private final ActivityService activityService;
|
||||
|
||||
public ApiKeyController(ActivityService activityService) {
|
||||
@@ -24,14 +26,41 @@ public class ApiKeyController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CreateApiKeyResponse> createApiKey(@Valid @RequestBody CreateApiKeyRequest request) {
|
||||
public ResponseEntity<ApiResponse<CreateApiKeyResponse>> createApiKey(@Valid @RequestBody CreateApiKeyRequest request) {
|
||||
String rawApiKey = activityService.generateApiKey(request);
|
||||
return new ResponseEntity<>(new CreateApiKeyResponse(rawApiKey), HttpStatus.CREATED);
|
||||
log.info("Created new API key for activity: {}", request.getActivityId());
|
||||
ApiResponse<CreateApiKeyResponse> response = ApiResponse.success(new CreateApiKeyResponse(rawApiKey));
|
||||
response.setCode(HttpStatus.CREATED.value());
|
||||
return new ResponseEntity<>(response, HttpStatus.CREATED);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/reveal")
|
||||
public ResponseEntity<ApiResponse<RevealApiKeyResponse>> revealApiKey(@PathVariable Long id) {
|
||||
log.warn("API key revealed for id: {} - ensure this is logged and monitored", id);
|
||||
String rawApiKey = activityService.revealApiKey(id);
|
||||
RevealApiKeyResponse payload = new RevealApiKeyResponse(
|
||||
rawApiKey,
|
||||
"警告: API密钥只显示一次,请立即保存!此操作会被记录。"
|
||||
);
|
||||
return ResponseEntity.ok(ApiResponse.success(payload));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> revokeApiKey(@PathVariable Long id) {
|
||||
public ResponseEntity<ApiResponse<Void>> revokeApiKey(@PathVariable Long id) {
|
||||
activityService.revokeApiKey(id);
|
||||
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
|
||||
log.info("API key revoked for id: {}", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/use")
|
||||
public ResponseEntity<ApiResponse<Void>> useApiKey(@PathVariable Long id, @Valid @RequestBody UseApiKeyRequest request) {
|
||||
activityService.validateAndMarkApiKeyUsed(id, request.getApiKey());
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@PostMapping("/validate")
|
||||
public ResponseEntity<ApiResponse<Void>> validateApiKey(@Valid @RequestBody UseApiKeyRequest request) {
|
||||
activityService.validateApiKeyByPrefixAndMarkUsed(request.getApiKey());
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.mosquito.project.controller;
|
||||
|
||||
import com.mosquito.project.dto.RegisterCallbackRequest;
|
||||
import com.mosquito.project.persistence.entity.ProcessedCallbackEntity;
|
||||
import com.mosquito.project.persistence.repository.ProcessedCallbackRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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/callback")
|
||||
public class CallbackController {
|
||||
|
||||
private final ProcessedCallbackRepository processedCallbackRepository;
|
||||
private final com.mosquito.project.service.RewardQueue rewardQueue;
|
||||
|
||||
public CallbackController(ProcessedCallbackRepository processedCallbackRepository, com.mosquito.project.service.RewardQueue rewardQueue) {
|
||||
this.processedCallbackRepository = processedCallbackRepository;
|
||||
this.rewardQueue = rewardQueue;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<Void> register(@Valid @RequestBody RegisterCallbackRequest request) {
|
||||
String trackingId = request.getTrackingId();
|
||||
if (processedCallbackRepository.existsById(trackingId)) {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
ProcessedCallbackEntity e = new ProcessedCallbackEntity();
|
||||
e.setTrackingId(trackingId);
|
||||
e.setCreatedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
processedCallbackRepository.save(e);
|
||||
|
||||
rewardQueue.enqueueReward(trackingId, request.getExternalUserId(), null);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.mosquito.project.controller;
|
||||
|
||||
import com.mosquito.project.dto.ShareMetricsResponse;
|
||||
import com.mosquito.project.dto.ShareTrackingResponse;
|
||||
import com.mosquito.project.dto.ApiResponse;
|
||||
import com.mosquito.project.service.ShareConfigService;
|
||||
import com.mosquito.project.service.ShareTrackingService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/share")
|
||||
@Tag(name = "Share Tracking", description = "分享链接跟踪与数据分析API")
|
||||
public class ShareTrackingController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ShareTrackingController.class);
|
||||
|
||||
private final ShareTrackingService trackingService;
|
||||
private final ShareConfigService shareConfigService;
|
||||
|
||||
public ShareTrackingController(ShareTrackingService trackingService, ShareConfigService shareConfigService) {
|
||||
this.trackingService = trackingService;
|
||||
this.shareConfigService = shareConfigService;
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@Operation(summary = "创建分享跟踪", description = "为指定活动创建可追踪的分享链接")
|
||||
public ResponseEntity<ApiResponse<ShareTrackingResponse>> createShareTracking(
|
||||
@Parameter(description = "活动ID") @RequestParam Long activityId,
|
||||
@Parameter(description = "邀请人用户ID") @RequestParam Long inviterUserId,
|
||||
@Parameter(description = "分享来源") @RequestParam(required = false, defaultValue = "direct") String source,
|
||||
@Parameter(description = "额外参数") @RequestParam(required = false) Map<String, String> params
|
||||
) {
|
||||
ShareTrackingResponse response = trackingService.createShareTracking(activityId, inviterUserId, source, params);
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@GetMapping("/metrics")
|
||||
@Operation(summary = "获取分享指标", description = "获取指定活动在时间范围内的分享指标")
|
||||
public ResponseEntity<ApiResponse<ShareMetricsResponse>> getShareMetrics(
|
||||
@Parameter(description = "活动ID") @RequestParam Long activityId,
|
||||
@Parameter(description = "开始时间") @RequestParam(required = false) OffsetDateTime startTime,
|
||||
@Parameter(description = "结束时间") @RequestParam(required = false) OffsetDateTime endTime
|
||||
) {
|
||||
if (startTime == null) {
|
||||
startTime = OffsetDateTime.now().minus(7, ChronoUnit.DAYS);
|
||||
}
|
||||
if (endTime == null) {
|
||||
endTime = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
ShareMetricsResponse metrics = trackingService.getShareMetrics(activityId, startTime, endTime);
|
||||
return ResponseEntity.ok(ApiResponse.success(metrics));
|
||||
}
|
||||
|
||||
@GetMapping("/top-links")
|
||||
@Operation(summary = "获取热门分享链接", description = "获取分享次数最多的链接列表")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getTopShareLinks(
|
||||
@Parameter(description = "活动ID") @RequestParam Long activityId,
|
||||
@Parameter(description = "返回数量") @RequestParam(required = false, defaultValue = "10") int topN
|
||||
) {
|
||||
List<Map<String, Object>> topLinks = trackingService.getTopShareLinks(activityId, topN);
|
||||
return ResponseEntity.ok(ApiResponse.success(topLinks));
|
||||
}
|
||||
|
||||
@GetMapping("/funnel")
|
||||
@Operation(summary = "获取转化漏斗数据", description = "获取分享到点击的转化漏斗分析")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getConversionFunnel(
|
||||
@Parameter(description = "活动ID") @RequestParam Long activityId,
|
||||
@Parameter(description = "开始时间") @RequestParam(required = false) OffsetDateTime startTime,
|
||||
@Parameter(description = "结束时间") @RequestParam(required = false) OffsetDateTime endTime
|
||||
) {
|
||||
if (startTime == null) {
|
||||
startTime = OffsetDateTime.now().minus(7, ChronoUnit.DAYS);
|
||||
}
|
||||
if (endTime == null) {
|
||||
endTime = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
Map<String, Object> funnel = trackingService.getConversionFunnel(activityId, startTime, endTime);
|
||||
return ResponseEntity.ok(ApiResponse.success(funnel));
|
||||
}
|
||||
|
||||
@GetMapping("/share-meta")
|
||||
@Operation(summary = "获取分享元数据", description = "获取用于社交媒体分享的OGP元数据")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getShareMeta(
|
||||
@Parameter(description = "活动ID") @RequestParam Long activityId,
|
||||
@Parameter(description = "用户ID") @RequestParam Long userId,
|
||||
@Parameter(description = "模板名称") @RequestParam(required = false, defaultValue = "default") String template
|
||||
) {
|
||||
Map<String, Object> meta = shareConfigService.getShareMeta(activityId, userId, template);
|
||||
return ResponseEntity.ok(ApiResponse.success(meta));
|
||||
}
|
||||
|
||||
@PostMapping("/register-source")
|
||||
@Operation(summary = "记录分享来源", description = "从外部系统记录分享来源数据")
|
||||
public ResponseEntity<ApiResponse<Void>> registerShareSource(
|
||||
@Parameter(description = "活动ID") @RequestParam Long activityId,
|
||||
@Parameter(description = "用户ID") @RequestParam Long userId,
|
||||
@Parameter(description = "来源渠道") @RequestParam String channel,
|
||||
@Parameter(description = "额外参数") @RequestParam(required = false) Map<String, String> params
|
||||
) {
|
||||
Map<String, String> allParams = params != null ? new java.util.HashMap<>(params) : new java.util.HashMap<>();
|
||||
allParams.put("channel", channel);
|
||||
allParams.put("registered_at", OffsetDateTime.now().toString());
|
||||
|
||||
trackingService.createShareTracking(activityId, userId, channel, allParams);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.mosquito.project.controller;
|
||||
|
||||
import com.mosquito.project.dto.ShortenRequest;
|
||||
import com.mosquito.project.dto.ShortenResponse;
|
||||
import com.mosquito.project.persistence.entity.ShortLinkEntity;
|
||||
import com.mosquito.project.service.ShortLinkService;
|
||||
import com.mosquito.project.persistence.repository.LinkClickRepository;
|
||||
import com.mosquito.project.web.UrlValidator;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
public class ShortLinkController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ShortLinkController.class);
|
||||
|
||||
private final ShortLinkService shortLinkService;
|
||||
private final LinkClickRepository linkClickRepository;
|
||||
private final UrlValidator urlValidator;
|
||||
|
||||
public ShortLinkController(ShortLinkService shortLinkService, LinkClickRepository linkClickRepository, UrlValidator urlValidator) {
|
||||
this.shortLinkService = shortLinkService;
|
||||
this.linkClickRepository = linkClickRepository;
|
||||
this.urlValidator = urlValidator;
|
||||
}
|
||||
|
||||
@PostMapping("/api/v1/internal/shorten")
|
||||
public ResponseEntity<ShortenResponse> shorten(@Valid @RequestBody ShortenRequest request) {
|
||||
ShortLinkEntity e = shortLinkService.create(request.getOriginalUrl());
|
||||
ShortenResponse resp = new ShortenResponse(e.getCode(), "/r/" + e.getCode(), e.getOriginalUrl());
|
||||
return new ResponseEntity<>(resp, HttpStatus.CREATED);
|
||||
}
|
||||
|
||||
@GetMapping("/r/{code}")
|
||||
public ResponseEntity<Void> redirect(@PathVariable String code, jakarta.servlet.http.HttpServletRequest request) {
|
||||
var linkOpt = shortLinkService.findByCode(code);
|
||||
if (linkOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
var e = linkOpt.get();
|
||||
String originalUrl = e.getOriginalUrl();
|
||||
|
||||
if (!urlValidator.isAllowedUrl(originalUrl)) {
|
||||
log.warn("Blocked potentially malicious redirect attempt. Code: {}, URL: {}", code, originalUrl);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
|
||||
}
|
||||
|
||||
try {
|
||||
com.mosquito.project.persistence.entity.LinkClickEntity click = new com.mosquito.project.persistence.entity.LinkClickEntity();
|
||||
click.setCode(code);
|
||||
click.setActivityId(e.getActivityId());
|
||||
click.setInviterUserId(e.getInviterUserId());
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip != null && !ip.isBlank()) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
} else {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
click.setIp(ip);
|
||||
click.setUserAgent(request.getHeader("User-Agent"));
|
||||
click.setReferer(request.getHeader("Referer"));
|
||||
click.setCreatedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
linkClickRepository.save(click);
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to record link click for code {}: {}", code, ex.getMessage(), ex);
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set(HttpHeaders.LOCATION, originalUrl);
|
||||
return new ResponseEntity<>(headers, HttpStatus.FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.mosquito.project.controller;
|
||||
|
||||
import com.mosquito.project.dto.ShortenResponse;
|
||||
import com.mosquito.project.dto.ApiResponse;
|
||||
import com.mosquito.project.persistence.entity.UserInviteEntity;
|
||||
import com.mosquito.project.service.ShortLinkService;
|
||||
import com.mosquito.project.service.PosterRenderService;
|
||||
import com.mosquito.project.service.ShareConfigService;
|
||||
import com.mosquito.project.persistence.repository.UserInviteRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
public class UserExperienceController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserExperienceController.class);
|
||||
|
||||
private final ShortLinkService shortLinkService;
|
||||
private final UserInviteRepository userInviteRepository;
|
||||
private final PosterRenderService posterRenderService;
|
||||
private final ShareConfigService shareConfigService;
|
||||
private final com.mosquito.project.persistence.repository.UserRewardRepository userRewardRepository;
|
||||
|
||||
public UserExperienceController(ShortLinkService shortLinkService, UserInviteRepository userInviteRepository,
|
||||
PosterRenderService posterRenderService, ShareConfigService shareConfigService,
|
||||
com.mosquito.project.persistence.repository.UserRewardRepository userRewardRepository) {
|
||||
this.shortLinkService = shortLinkService;
|
||||
this.userInviteRepository = userInviteRepository;
|
||||
this.posterRenderService = posterRenderService;
|
||||
this.shareConfigService = shareConfigService;
|
||||
this.userRewardRepository = userRewardRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/invitation-info")
|
||||
public ResponseEntity<ApiResponse<ShortenResponse>> getInvitationInfo(
|
||||
@RequestParam Long activityId,
|
||||
@RequestParam Long userId,
|
||||
@RequestParam(required = false, defaultValue = "default") String template
|
||||
) {
|
||||
String shareUrl = shareConfigService.buildShareUrl(activityId, userId, template, null);
|
||||
var e = shortLinkService.create(shareUrl);
|
||||
ShortenResponse payload = new ShortenResponse(e.getCode(), "/r/" + e.getCode(), e.getOriginalUrl());
|
||||
return ResponseEntity.ok(ApiResponse.success(payload));
|
||||
}
|
||||
|
||||
@GetMapping("/share-meta")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getShareMeta(
|
||||
@RequestParam Long activityId,
|
||||
@RequestParam Long userId,
|
||||
@RequestParam(required = false, defaultValue = "default") String template
|
||||
) {
|
||||
Map<String, Object> meta = shareConfigService.getShareMeta(activityId, userId, template);
|
||||
return ResponseEntity.ok(ApiResponse.success(meta));
|
||||
}
|
||||
|
||||
@GetMapping("/invited-friends")
|
||||
public ResponseEntity<ApiResponse<List<FriendDto>>> getInvitedFriends(
|
||||
@RequestParam Long activityId,
|
||||
@RequestParam Long userId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
List<UserInviteEntity> all = userInviteRepository.findByActivityIdAndInviterUserId(activityId, userId);
|
||||
int from = Math.max(0, page * Math.max(1, size));
|
||||
if (from >= all.size()) {
|
||||
return ResponseEntity.ok(ApiResponse.success(List.of()));
|
||||
}
|
||||
int to = Math.min(all.size(), from + size);
|
||||
List<FriendDto> result = all.subList(from, to).stream()
|
||||
.map(e -> new FriendDto("用户" + e.getInviteeUserId(), maskPhone("1380000" + String.format("%04d", e.getInviteeUserId() % 10000)), e.getStatus()))
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/poster/image", produces = MediaType.IMAGE_PNG_VALUE)
|
||||
public ResponseEntity<byte[]> getPosterImage(
|
||||
@RequestParam Long activityId,
|
||||
@RequestParam Long userId,
|
||||
@RequestParam(required = false, defaultValue = "default") String template
|
||||
) {
|
||||
try {
|
||||
byte[] image = posterRenderService.renderPoster(activityId, userId, template);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.IMAGE_PNG);
|
||||
headers.setCacheControl("max-age=3600");
|
||||
return new ResponseEntity<>(image, headers, HttpStatus.OK);
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to generate poster image", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping(value = "/poster/html", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> getPosterHtml(
|
||||
@RequestParam Long activityId,
|
||||
@RequestParam Long userId,
|
||||
@RequestParam(required = false, defaultValue = "default") String template
|
||||
) {
|
||||
try {
|
||||
String html = posterRenderService.renderPosterHtml(activityId, userId, template);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.header("Cache-Control", "max-age=3600")
|
||||
.body(html);
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to generate poster HTML", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping(value = "/poster/config")
|
||||
public ResponseEntity<ApiResponse<PosterConfigDto>> getPosterConfig(
|
||||
@RequestParam(required = false, defaultValue = "default") String template
|
||||
) {
|
||||
PosterConfigDto config = new PosterConfigDto();
|
||||
config.setTemplate(template);
|
||||
config.setImageUrl("/api/v1/me/poster/image?activityId={activityId}&userId={userId}&template=" + template);
|
||||
config.setHtmlUrl("/api/v1/me/poster/html?activityId={activityId}&userId={userId}&template=" + template);
|
||||
return ResponseEntity.ok(ApiResponse.success(config));
|
||||
}
|
||||
|
||||
@GetMapping("/rewards")
|
||||
public ResponseEntity<ApiResponse<List<RewardDto>>> getRewards(
|
||||
@RequestParam Long activityId,
|
||||
@RequestParam Long userId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
var all = userRewardRepository.findByActivityIdAndUserIdOrderByCreatedAtDesc(activityId, userId);
|
||||
int from = Math.max(0, page * Math.max(1, size));
|
||||
if (from >= all.size()) {
|
||||
return ResponseEntity.ok(ApiResponse.success(java.util.List.of()));
|
||||
}
|
||||
int to = Math.min(all.size(), from + size);
|
||||
var list = all.subList(from, to).stream()
|
||||
.map(e -> new RewardDto(e.getType(), e.getPoints(), e.getCreatedAt().toString()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
return ResponseEntity.ok(ApiResponse.success(list));
|
||||
}
|
||||
|
||||
public static class FriendDto {
|
||||
private String nickname;
|
||||
private String maskedPhone;
|
||||
private String status;
|
||||
|
||||
public FriendDto(String nickname, String maskedPhone, String status) {
|
||||
this.nickname = nickname;
|
||||
this.maskedPhone = maskedPhone;
|
||||
this.status = status;
|
||||
}
|
||||
public String getNickname() { return nickname; }
|
||||
public String getMaskedPhone() { return maskedPhone; }
|
||||
public String getStatus() { return status; }
|
||||
}
|
||||
|
||||
public static class RewardDto {
|
||||
private String type;
|
||||
private int points;
|
||||
private String createdAt;
|
||||
|
||||
public RewardDto(String type, int points, String createdAt) {
|
||||
this.type = type;
|
||||
this.points = points;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
public String getType() { return type; }
|
||||
public int getPoints() { return points; }
|
||||
public String getCreatedAt() { return createdAt; }
|
||||
}
|
||||
|
||||
public static class PosterConfigDto {
|
||||
private String template;
|
||||
private String imageUrl;
|
||||
private String htmlUrl;
|
||||
|
||||
public String getTemplate() { return template; }
|
||||
public void setTemplate(String template) { this.template = template; }
|
||||
public String getImageUrl() { return imageUrl; }
|
||||
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||
public String getHtmlUrl() { return htmlUrl; }
|
||||
public void setHtmlUrl(String htmlUrl) { this.htmlUrl = htmlUrl; }
|
||||
}
|
||||
|
||||
private String maskPhone(String phone) {
|
||||
if (phone == null || phone.length() < 7) return "**********";
|
||||
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
public class ActivityGraphResponse {
|
||||
@NoArgsConstructor
|
||||
public class ActivityGraphResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private List<Node> nodes;
|
||||
private List<Edge> edges;
|
||||
@@ -28,7 +34,10 @@ public class ActivityGraphResponse {
|
||||
this.edges = edges;
|
||||
}
|
||||
|
||||
public static class Node {
|
||||
@NoArgsConstructor
|
||||
public static class Node implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
private String id;
|
||||
private String label;
|
||||
|
||||
@@ -54,7 +63,10 @@ public class ActivityGraphResponse {
|
||||
}
|
||||
}
|
||||
|
||||
public static class Edge {
|
||||
@NoArgsConstructor
|
||||
public static class Edge implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
private String from;
|
||||
private String to;
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
public class ActivityStatsResponse {
|
||||
@NoArgsConstructor
|
||||
public class ActivityStatsResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private long totalParticipants;
|
||||
private long totalShares;
|
||||
@@ -38,7 +44,10 @@ public class ActivityStatsResponse {
|
||||
this.dailyStats = dailyStats;
|
||||
}
|
||||
|
||||
public static class DailyStats {
|
||||
@NoArgsConstructor
|
||||
public static class DailyStats implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
private String date;
|
||||
private int participants;
|
||||
private int shares;
|
||||
|
||||
31
src/main/java/com/mosquito/project/dto/ApiKeyResponse.java
Normal file
31
src/main/java/com/mosquito/project/dto/ApiKeyResponse.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
/**
|
||||
* API密钥响应DTO
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ApiKeyResponse {
|
||||
private String message;
|
||||
private String data;
|
||||
private String error;
|
||||
|
||||
public ApiKeyResponse(String message, String data, String error) {
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public static ApiKeyResponse success(String data) {
|
||||
return new ApiKeyResponse("操作成功", data, null);
|
||||
}
|
||||
|
||||
public static ApiKeyResponse error(String error) {
|
||||
return new ApiKeyResponse("操作失败", null, error);
|
||||
}
|
||||
}
|
||||
179
src/main/java/com/mosquito/project/dto/ApiResponse.java
Normal file
179
src/main/java/com/mosquito/project/dto/ApiResponse.java
Normal file
@@ -0,0 +1,179 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 统一API响应格式
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ApiResponse<T> {
|
||||
|
||||
/**
|
||||
* HTTP状态码
|
||||
*/
|
||||
private int code;
|
||||
|
||||
/**
|
||||
* 响应消息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 响应数据
|
||||
*/
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 元数据(分页等信息)
|
||||
*/
|
||||
private Meta meta;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private Error error;
|
||||
|
||||
/**
|
||||
* 时间戳
|
||||
*/
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
/**
|
||||
* 请求追踪ID
|
||||
*/
|
||||
private String traceId;
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(200)
|
||||
.message("success")
|
||||
.data(data)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data, String message) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(200)
|
||||
.message(message)
|
||||
.data(data)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> paginated(T data, int page, int size, long total) {
|
||||
Meta meta = Meta.createPagination(page, size, total);
|
||||
return ApiResponse.<T>builder()
|
||||
.code(200)
|
||||
.message("success")
|
||||
.data(data)
|
||||
.meta(meta)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String message) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.error(new Error(message))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String message, Object details) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.error(new Error(message, details))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String message, Object details, String traceId) {
|
||||
return ApiResponse.<T>builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.error(new Error(message, details))
|
||||
.traceId(traceId)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据基类
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Meta {
|
||||
private PaginationMeta pagination;
|
||||
private Map<String, Object> extra;
|
||||
|
||||
public static Meta createPagination(int page, int size, long total) {
|
||||
Meta meta = new Meta();
|
||||
meta.setPagination(PaginationMeta.of(page, size, total));
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页元数据
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PaginationMeta {
|
||||
private int page;
|
||||
private int size;
|
||||
private long total;
|
||||
private int totalPages;
|
||||
private boolean hasNext;
|
||||
private boolean hasPrevious;
|
||||
|
||||
public static PaginationMeta of(int page, int size, long total) {
|
||||
int totalPages = (int) Math.ceil((double) total / size);
|
||||
boolean hasNext = page < totalPages - 1;
|
||||
boolean hasPrevious = page > 0;
|
||||
|
||||
return new PaginationMeta(page, size, total, totalPages, hasNext, hasPrevious);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public static class Error {
|
||||
private String message;
|
||||
private Object details;
|
||||
private String code;
|
||||
|
||||
public Error(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Error(String message, Object details) {
|
||||
this.message = message;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public Error(String message, Object details, String code) {
|
||||
this.message = message;
|
||||
this.details = details;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@NoArgsConstructor
|
||||
public class CreateApiKeyResponse {
|
||||
|
||||
private String apiKey;
|
||||
|
||||
43
src/main/java/com/mosquito/project/dto/ErrorResponse.java
Normal file
43
src/main/java/com/mosquito/project/dto/ErrorResponse.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
|
||||
public class ErrorResponse {
|
||||
private OffsetDateTime timestamp;
|
||||
private String status;
|
||||
private String error;
|
||||
private String message;
|
||||
private String path;
|
||||
private Map<String, Object> details;
|
||||
private String traceId;
|
||||
|
||||
public ErrorResponse() {}
|
||||
|
||||
public ErrorResponse(OffsetDateTime timestamp, String path, String code, String message, Map<String, String> errors) {
|
||||
this.timestamp = timestamp;
|
||||
this.path = path;
|
||||
this.status = code;
|
||||
this.message = message;
|
||||
if (errors != null) {
|
||||
this.details = new HashMap<>(errors);
|
||||
}
|
||||
}
|
||||
|
||||
public OffsetDateTime getTimestamp() { return timestamp; }
|
||||
public void setTimestamp(OffsetDateTime timestamp) { this.timestamp = timestamp; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public String getError() { return error; }
|
||||
public void setError(String error) { this.error = error; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public String getPath() { return path; }
|
||||
public void setPath(String path) { this.path = path; }
|
||||
public Map<String, Object> getDetails() { return details; }
|
||||
public void setDetails(Map<String, Object> details) { this.details = details; }
|
||||
public String getTraceId() { return traceId; }
|
||||
public void setTraceId(String traceId) { this.traceId = traceId; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class RegisterCallbackRequest {
|
||||
@NotBlank
|
||||
private String trackingId;
|
||||
private String externalUserId;
|
||||
private Long timestamp;
|
||||
|
||||
public String getTrackingId() { return trackingId; }
|
||||
public void setTrackingId(String trackingId) { this.trackingId = trackingId; }
|
||||
public String getExternalUserId() { return externalUserId; }
|
||||
public void setExternalUserId(String externalUserId) { this.externalUserId = externalUserId; }
|
||||
public Long getTimestamp() { return timestamp; }
|
||||
public void setTimestamp(Long timestamp) { this.timestamp = timestamp; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@NoArgsConstructor
|
||||
public class RevealApiKeyResponse {
|
||||
private String apiKey;
|
||||
private String message;
|
||||
|
||||
public RevealApiKeyResponse(String apiKey, String message) {
|
||||
this.apiKey = apiKey;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getApiKey() { return apiKey; }
|
||||
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
@NoArgsConstructor
|
||||
public class ShareMetricsResponse {
|
||||
private Long activityId;
|
||||
private OffsetDateTime startTime;
|
||||
private OffsetDateTime endTime;
|
||||
private long totalClicks;
|
||||
private long uniqueVisitors;
|
||||
private Map<String, Long> sourceDistribution;
|
||||
private Map<String, Long> hourlyDistribution;
|
||||
|
||||
public Long getActivityId() { return activityId; }
|
||||
public void setActivityId(Long activityId) { this.activityId = activityId; }
|
||||
public OffsetDateTime getStartTime() { return startTime; }
|
||||
public void setStartTime(OffsetDateTime startTime) { this.startTime = startTime; }
|
||||
public OffsetDateTime getEndTime() { return endTime; }
|
||||
public void setEndTime(OffsetDateTime endTime) { this.endTime = endTime; }
|
||||
public long getTotalClicks() { return totalClicks; }
|
||||
public void setTotalClicks(long totalClicks) { this.totalClicks = totalClicks; }
|
||||
public long getUniqueVisitors() { return uniqueVisitors; }
|
||||
public void setUniqueVisitors(long uniqueVisitors) { this.uniqueVisitors = uniqueVisitors; }
|
||||
public Map<String, Long> getSourceDistribution() { return sourceDistribution; }
|
||||
public void setSourceDistribution(Map<String, Long> sourceDistribution) { this.sourceDistribution = sourceDistribution; }
|
||||
public Map<String, Long> getHourlyDistribution() { return hourlyDistribution; }
|
||||
public void setHourlyDistribution(Map<String, Long> hourlyDistribution) { this.hourlyDistribution = hourlyDistribution; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@NoArgsConstructor
|
||||
public class ShareTrackingResponse {
|
||||
private String trackingId;
|
||||
private String shortCode;
|
||||
private String originalUrl;
|
||||
private Long activityId;
|
||||
private Long inviterUserId;
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
public ShareTrackingResponse(String trackingId, String shortCode, String originalUrl, Long activityId, Long inviterUserId) {
|
||||
this.trackingId = trackingId;
|
||||
this.shortCode = shortCode;
|
||||
this.originalUrl = originalUrl;
|
||||
this.activityId = activityId;
|
||||
this.inviterUserId = inviterUserId;
|
||||
this.createdAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
public String getTrackingId() { return trackingId; }
|
||||
public void setTrackingId(String trackingId) { this.trackingId = trackingId; }
|
||||
public String getShortCode() { return shortCode; }
|
||||
public void setShortCode(String shortCode) { this.shortCode = shortCode; }
|
||||
public String getOriginalUrl() { return originalUrl; }
|
||||
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
|
||||
public Long getActivityId() { return activityId; }
|
||||
public void setActivityId(Long activityId) { this.activityId = activityId; }
|
||||
public Long getInviterUserId() { return inviterUserId; }
|
||||
public void setInviterUserId(Long inviterUserId) { this.inviterUserId = inviterUserId; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
14
src/main/java/com/mosquito/project/dto/ShortenRequest.java
Normal file
14
src/main/java/com/mosquito/project/dto/ShortenRequest.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public class ShortenRequest {
|
||||
@NotBlank(message = "原始URL不能为空")
|
||||
@Size(min = 10, max = 2048, message = "URL长度必须在10-2048个字符之间")
|
||||
private String originalUrl;
|
||||
|
||||
public String getOriginalUrl() { return originalUrl; }
|
||||
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
|
||||
}
|
||||
|
||||
24
src/main/java/com/mosquito/project/dto/ShortenResponse.java
Normal file
24
src/main/java/com/mosquito/project/dto/ShortenResponse.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@NoArgsConstructor
|
||||
public class ShortenResponse {
|
||||
private String code;
|
||||
private String path;
|
||||
private String originalUrl;
|
||||
|
||||
public ShortenResponse(String code, String path, String originalUrl) {
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
this.originalUrl = originalUrl;
|
||||
}
|
||||
|
||||
public String getCode() { return code; }
|
||||
public void setCode(String code) { this.code = code; }
|
||||
public String getPath() { return path; }
|
||||
public void setPath(String path) { this.path = path; }
|
||||
public String getOriginalUrl() { return originalUrl; }
|
||||
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.mosquito.project.dto;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
@NoArgsConstructor
|
||||
public class UpdateActivityRequest {
|
||||
|
||||
@NotBlank(message = "活动名称不能为空")
|
||||
|
||||
17
src/main/java/com/mosquito/project/dto/UseApiKeyRequest.java
Normal file
17
src/main/java/com/mosquito/project/dto/UseApiKeyRequest.java
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.mosquito.project.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class UseApiKeyRequest {
|
||||
@NotBlank(message = "API密钥不能为空")
|
||||
private String apiKey;
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.mosquito.project.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class BusinessException extends RuntimeException {
|
||||
private HttpStatus status;
|
||||
private String errorCode;
|
||||
private Map<String, Object> details;
|
||||
|
||||
public BusinessException(String message) {
|
||||
super(message);
|
||||
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
this.errorCode = "BUSINESS_ERROR";
|
||||
this.details = new HashMap<>();
|
||||
}
|
||||
|
||||
public BusinessException(String message, HttpStatus status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.errorCode = "BUSINESS_ERROR";
|
||||
this.details = new HashMap<>();
|
||||
}
|
||||
|
||||
public BusinessException(String message, String errorCode) {
|
||||
super(message);
|
||||
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
this.errorCode = errorCode;
|
||||
this.details = new HashMap<>();
|
||||
}
|
||||
|
||||
public BusinessException(String message, HttpStatus status, String errorCode) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.errorCode = errorCode;
|
||||
this.details = new HashMap<>();
|
||||
}
|
||||
|
||||
public BusinessException(String message, Map<String, Object> details) {
|
||||
super(message);
|
||||
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
this.errorCode = "BUSINESS_ERROR";
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public BusinessException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
this.errorCode = "BUSINESS_ERROR";
|
||||
this.details = new HashMap<>();
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() { return status; }
|
||||
public String getErrorCode() { return errorCode; }
|
||||
public Map<String, Object> getDetails() { return details; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.mosquito.project.exception;
|
||||
|
||||
import com.mosquito.project.dto.ApiResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.validation.FieldError;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex, WebRequest request) {
|
||||
String path = extractPath(request);
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
details.put("code", ex.getErrorCode());
|
||||
details.put("path", path);
|
||||
if (ex.getDetails() != null) {
|
||||
details.putAll(ex.getDetails());
|
||||
}
|
||||
|
||||
ApiResponse<Void> response = buildError(ex.getStatus(), ex.getMessage(), ex.getErrorCode(), details);
|
||||
return new ResponseEntity<>(response, ex.getStatus());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
details.put("resourceType", ex.getResourceType());
|
||||
details.put("resourceId", ex.getResourceId());
|
||||
details.put("path", extractPath(request));
|
||||
|
||||
ApiResponse<Void> response = buildError(HttpStatus.NOT_FOUND, ex.getMessage(), null, details);
|
||||
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ValidationException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidationException(ValidationException ex, WebRequest request) {
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
details.put("path", extractPath(request));
|
||||
if (ex.getErrors() != null) {
|
||||
details.putAll(ex.getErrors());
|
||||
}
|
||||
|
||||
ApiResponse<Void> response = buildError(HttpStatus.BAD_REQUEST, ex.getMessage(), null, details);
|
||||
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, WebRequest request) {
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
details.put("path", extractPath(request));
|
||||
Map<String, String> fieldErrors = new HashMap<>();
|
||||
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
|
||||
fieldErrors.put(error.getField(), error.getDefaultMessage());
|
||||
}
|
||||
details.put("fieldErrors", fieldErrors);
|
||||
|
||||
ApiResponse<Void> response = buildError(HttpStatus.BAD_REQUEST, "参数校验失败", null, details);
|
||||
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex, WebRequest request) {
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
details.put("path", extractPath(request));
|
||||
details.put("method", ex.getMethod());
|
||||
details.put("supported", ex.getSupportedHttpMethods());
|
||||
|
||||
ApiResponse<Void> response = buildError(HttpStatus.METHOD_NOT_ALLOWED, ex.getMessage(), null, details);
|
||||
return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex, WebRequest request) {
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
details.put("exception", ex.getClass().getSimpleName());
|
||||
details.put("path", extractPath(request));
|
||||
|
||||
ApiResponse<Void> response = buildError(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred", null, details);
|
||||
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
private ApiResponse<Void> buildError(HttpStatus status, String message, String code, Map<String, Object> details) {
|
||||
ApiResponse.Error error = new ApiResponse.Error(message, details);
|
||||
error.setCode(code);
|
||||
return ApiResponse.<Void>builder()
|
||||
.code(status.value())
|
||||
.message(message)
|
||||
.error(error)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String extractPath(WebRequest request) {
|
||||
return request.getDescription(false).replace("uri=", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mosquito.project.exception;
|
||||
|
||||
public class InvalidApiKeyException extends RuntimeException {
|
||||
public InvalidApiKeyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.mosquito.project.exception;
|
||||
|
||||
/**
|
||||
* 速率限制超时异常
|
||||
*/
|
||||
public class RateLimitExceededException extends RuntimeException {
|
||||
|
||||
public RateLimitExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public RateLimitExceededException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.mosquito.project.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
public class ResourceNotFoundException extends RuntimeException {
|
||||
private String resourceType;
|
||||
private String resourceId;
|
||||
|
||||
public ResourceNotFoundException(String resourceType, String resourceId) {
|
||||
super(String.format("%s not found with id: %s", resourceType, resourceId));
|
||||
this.resourceType = resourceType;
|
||||
this.resourceId = resourceId;
|
||||
}
|
||||
|
||||
public ResourceNotFoundException(String message) {
|
||||
super(message);
|
||||
this.resourceType = "Resource";
|
||||
this.resourceId = "unknown";
|
||||
}
|
||||
|
||||
public String getResourceType() { return resourceType; }
|
||||
public String getResourceId() { return resourceId; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.mosquito.project.exception;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ValidationException extends RuntimeException {
|
||||
private Map<String, String> errors;
|
||||
|
||||
public ValidationException(String message) {
|
||||
super(message);
|
||||
this.errors = new HashMap<>();
|
||||
}
|
||||
|
||||
public ValidationException(String message, Map<String, String> errors) {
|
||||
super(message);
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public Map<String, String> getErrors() { return errors; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.mosquito.project.job;
|
||||
import com.mosquito.project.domain.Activity;
|
||||
import com.mosquito.project.domain.DailyActivityStats;
|
||||
import com.mosquito.project.service.ActivityService;
|
||||
import com.mosquito.project.persistence.entity.DailyActivityStatsEntity;
|
||||
import com.mosquito.project.persistence.repository.DailyActivityStatsRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
@@ -20,10 +22,12 @@ public class StatisticsAggregationJob {
|
||||
private static final Logger log = LoggerFactory.getLogger(StatisticsAggregationJob.class);
|
||||
|
||||
private final ActivityService activityService;
|
||||
private final DailyActivityStatsRepository dailyStatsRepository;
|
||||
private final Map<Long, DailyActivityStats> dailyStats = new ConcurrentHashMap<>();
|
||||
|
||||
public StatisticsAggregationJob(ActivityService activityService) {
|
||||
public StatisticsAggregationJob(ActivityService activityService, DailyActivityStatsRepository dailyStatsRepository) {
|
||||
this.activityService = activityService;
|
||||
this.dailyStatsRepository = dailyStatsRepository;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点执行
|
||||
@@ -36,6 +40,8 @@ public class StatisticsAggregationJob {
|
||||
// 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);
|
||||
// Upsert into persistence store for analytics queries
|
||||
upsertDailyStats(stats);
|
||||
log.info("为活动ID {} 聚合了数据: {} 次浏览, {} 次分享", activity.getId(), stats.getViews(), stats.getShares());
|
||||
}
|
||||
log.info("每日活动数据聚合任务执行完成");
|
||||
@@ -52,6 +58,21 @@ public class StatisticsAggregationJob {
|
||||
stats.setNewRegistrations(50 + random.nextInt(50));
|
||||
stats.setConversions(10 + random.nextInt(20));
|
||||
dailyStats.put(activity.getId(), stats);
|
||||
// Persist
|
||||
upsertDailyStats(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
private void upsertDailyStats(DailyActivityStats stats) {
|
||||
DailyActivityStatsEntity entity = dailyStatsRepository
|
||||
.findByActivityIdAndStatDate(stats.getActivityId(), stats.getStatDate())
|
||||
.orElseGet(DailyActivityStatsEntity::new);
|
||||
entity.setActivityId(stats.getActivityId());
|
||||
entity.setStatDate(stats.getStatDate());
|
||||
entity.setViews(stats.getViews());
|
||||
entity.setShares(stats.getShares());
|
||||
entity.setNewRegistrations(stats.getNewRegistrations());
|
||||
entity.setConversions(stats.getConversions());
|
||||
dailyStatsRepository.save(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ public class ActivityEntity {
|
||||
@Column(name = "end_time_utc", nullable = false)
|
||||
private OffsetDateTime endTimeUtc;
|
||||
|
||||
@Column(name = "target_users_config", columnDefinition = "jsonb")
|
||||
@Column(name = "target_users_config")
|
||||
private String targetUsersConfig;
|
||||
|
||||
@Column(name = "page_content_config", columnDefinition = "jsonb")
|
||||
@Column(name = "page_content_config")
|
||||
private String pageContentConfig;
|
||||
|
||||
@Column(name = "reward_calculation_mode", length = 50)
|
||||
@@ -59,4 +59,3 @@ public class ActivityEntity {
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,15 @@ public class ApiKeyEntity {
|
||||
@Column(nullable = false, length = 255)
|
||||
private String salt;
|
||||
|
||||
@Column(name = "activity_id")
|
||||
private Long activityId;
|
||||
|
||||
@Column(name = "key_prefix", length = 64)
|
||||
private String keyPrefix;
|
||||
|
||||
@Column(name = "encrypted_key", length = 512)
|
||||
private String encryptedKey;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@@ -29,6 +38,9 @@ public class ApiKeyEntity {
|
||||
@Column(name = "last_used_at")
|
||||
private OffsetDateTime lastUsedAt;
|
||||
|
||||
@Column(name = "revealed_at")
|
||||
private OffsetDateTime revealedAt;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getName() { return name; }
|
||||
@@ -37,11 +49,18 @@ public class ApiKeyEntity {
|
||||
public void setKeyHash(String keyHash) { this.keyHash = keyHash; }
|
||||
public String getSalt() { return salt; }
|
||||
public void setSalt(String salt) { this.salt = salt; }
|
||||
public Long getActivityId() { return activityId; }
|
||||
public void setActivityId(Long activityId) { this.activityId = activityId; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public OffsetDateTime getRevokedAt() { return revokedAt; }
|
||||
public void setRevokedAt(OffsetDateTime revokedAt) { this.revokedAt = revokedAt; }
|
||||
public OffsetDateTime getLastUsedAt() { return lastUsedAt; }
|
||||
public void setLastUsedAt(OffsetDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; }
|
||||
public String getKeyPrefix() { return keyPrefix; }
|
||||
public void setKeyPrefix(String keyPrefix) { this.keyPrefix = keyPrefix; }
|
||||
public String getEncryptedKey() { return encryptedKey; }
|
||||
public void setEncryptedKey(String encryptedKey) { this.encryptedKey = encryptedKey; }
|
||||
public OffsetDateTime getRevealedAt() { return revealedAt; }
|
||||
public void setRevealedAt(OffsetDateTime revealedAt) { this.revealedAt = revealedAt; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.mosquito.project.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "link_clicks", indexes = {
|
||||
@Index(name = "idx_link_clicks_code", columnList = "code"),
|
||||
@Index(name = "idx_link_clicks_activity", columnList = "activity_id"),
|
||||
@Index(name = "idx_link_clicks_created_at", columnList = "created_at")
|
||||
})
|
||||
public class LinkClickEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String code;
|
||||
|
||||
@Column(name = "activity_id")
|
||||
private Long activityId;
|
||||
|
||||
@Column(name = "inviter_user_id")
|
||||
private Long inviterUserId;
|
||||
|
||||
@Column(length = 64)
|
||||
private String ip;
|
||||
|
||||
@Column(name = "user_agent", length = 512)
|
||||
private String userAgent;
|
||||
|
||||
@Column(length = 1024)
|
||||
private String referer;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String params;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getCode() { return code; }
|
||||
public void setCode(String code) { this.code = code; }
|
||||
public Long getActivityId() { return activityId; }
|
||||
public void setActivityId(Long activityId) { this.activityId = activityId; }
|
||||
public Long getInviterUserId() { return inviterUserId; }
|
||||
public void setInviterUserId(Long inviterUserId) { this.inviterUserId = inviterUserId; }
|
||||
public String getIp() { return ip; }
|
||||
public void setIp(String ip) { this.ip = ip; }
|
||||
public String getUserAgent() { return userAgent; }
|
||||
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
|
||||
public String getReferer() { return referer; }
|
||||
public void setReferer(String referer) { this.referer = referer; }
|
||||
public Map<String, String> getParams() {
|
||||
if (params == null || params.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(params, java.util.Map.class);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public void setParams(Map<String, String> paramsMap) {
|
||||
if (paramsMap == null) {
|
||||
this.params = null;
|
||||
} else {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
this.params = mapper.writeValueAsString(paramsMap);
|
||||
} catch (Exception e) {
|
||||
this.params = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.mosquito.project.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "processed_callbacks")
|
||||
public class ProcessedCallbackEntity {
|
||||
@Id
|
||||
@Column(name = "tracking_id", length = 100)
|
||||
private String trackingId;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
public String getTrackingId() { return trackingId; }
|
||||
public void setTrackingId(String trackingId) { this.trackingId = trackingId; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.mosquito.project.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "reward_jobs", indexes = {
|
||||
@Index(name = "idx_reward_jobs_status_next", columnList = "status,next_run_at")
|
||||
})
|
||||
public class RewardJobEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
@Column(name = "tracking_id", length = 100, nullable = false)
|
||||
private String trackingId;
|
||||
@Column(name = "external_user_id")
|
||||
private String externalUserId;
|
||||
@Column(name = "payload")
|
||||
private String payload;
|
||||
@Column(name = "status", length = 32)
|
||||
private String status;
|
||||
@Column(name = "retry_count")
|
||||
private Integer retryCount;
|
||||
@Column(name = "next_run_at")
|
||||
private OffsetDateTime nextRunAt;
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getTrackingId() { return trackingId; }
|
||||
public void setTrackingId(String trackingId) { this.trackingId = trackingId; }
|
||||
public String getExternalUserId() { return externalUserId; }
|
||||
public void setExternalUserId(String externalUserId) { this.externalUserId = externalUserId; }
|
||||
public String getPayload() { return payload; }
|
||||
public void setPayload(String payload) { this.payload = payload; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public Integer getRetryCount() { return retryCount; }
|
||||
public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; }
|
||||
public OffsetDateTime getNextRunAt() { return nextRunAt; }
|
||||
public void setNextRunAt(OffsetDateTime nextRunAt) { this.nextRunAt = nextRunAt; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.mosquito.project.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "short_links", indexes = {
|
||||
@Index(name = "idx_short_links_code", columnList = "code", unique = true)
|
||||
})
|
||||
public class ShortLinkEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 32, unique = true)
|
||||
private String code;
|
||||
|
||||
@Column(name = "original_url", nullable = false, length = 2048)
|
||||
private String originalUrl;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "activity_id")
|
||||
private Long activityId;
|
||||
|
||||
@Column(name = "inviter_user_id")
|
||||
private Long inviterUserId;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getCode() { return code; }
|
||||
public void setCode(String code) { this.code = code; }
|
||||
public String getOriginalUrl() { return originalUrl; }
|
||||
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public Long getActivityId() { return activityId; }
|
||||
public void setActivityId(Long activityId) { this.activityId = activityId; }
|
||||
public Long getInviterUserId() { return inviterUserId; }
|
||||
public void setInviterUserId(Long inviterUserId) { this.inviterUserId = inviterUserId; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.mosquito.project.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_invites",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_activity_invitee", columnNames = {"activity_id", "invitee_user_id"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_user_invites_activity", columnList = "activity_id"),
|
||||
@Index(name = "idx_user_invites_inviter", columnList = "inviter_user_id")
|
||||
}
|
||||
)
|
||||
public class UserInviteEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "activity_id", nullable = false)
|
||||
private Long activityId;
|
||||
|
||||
@Column(name = "inviter_user_id", nullable = false)
|
||||
private Long inviterUserId;
|
||||
|
||||
@Column(name = "invitee_user_id", nullable = false)
|
||||
private Long inviteeUserId;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "status", length = 32)
|
||||
private String status;
|
||||
|
||||
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 Long getInviterUserId() { return inviterUserId; }
|
||||
public void setInviterUserId(Long inviterUserId) { this.inviterUserId = inviterUserId; }
|
||||
public Long getInviteeUserId() { return inviteeUserId; }
|
||||
public void setInviteeUserId(Long inviteeUserId) { this.inviteeUserId = inviteeUserId; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.mosquito.project.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_rewards", indexes = {
|
||||
@Index(name = "idx_user_rewards_user", columnList = "user_id"),
|
||||
@Index(name = "idx_user_rewards_activity", columnList = "activity_id")
|
||||
})
|
||||
public class UserRewardEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "activity_id", nullable = false)
|
||||
private Long activityId;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "type", nullable = false, length = 32)
|
||||
private String type;
|
||||
|
||||
@Column(name = "points", nullable = false)
|
||||
private Integer points;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
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 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 Integer getPoints() { return points; }
|
||||
public void setPoints(Integer points) { this.points = points; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ import java.util.Optional;
|
||||
|
||||
public interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, Long> {
|
||||
Optional<ApiKeyEntity> findByKeyHash(String keyHash);
|
||||
Optional<ApiKeyEntity> findByKeyPrefix(String keyPrefix);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ import java.util.Optional;
|
||||
|
||||
public interface DailyActivityStatsRepository extends JpaRepository<DailyActivityStatsEntity, Long> {
|
||||
Optional<DailyActivityStatsEntity> findByActivityIdAndStatDate(Long activityId, LocalDate statDate);
|
||||
}
|
||||
|
||||
java.util.List<DailyActivityStatsEntity> findByActivityIdOrderByStatDateAsc(Long activityId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.mosquito.project.persistence.repository;
|
||||
|
||||
import com.mosquito.project.persistence.entity.LinkClickEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface LinkClickRepository extends JpaRepository<LinkClickEntity, Long> {
|
||||
|
||||
List<LinkClickEntity> findByActivityId(Long activityId);
|
||||
|
||||
List<LinkClickEntity> findByActivityIdAndCreatedAtBetween(Long activityId, OffsetDateTime startTime, OffsetDateTime endTime);
|
||||
|
||||
List<LinkClickEntity> findByCode(String code);
|
||||
|
||||
@Query(value = "SELECT l.code, COUNT(*) as cnt, l.inviter_user_id FROM link_clicks l " +
|
||||
"WHERE l.activity_id = :activityId " +
|
||||
"GROUP BY l.code, l.inviter_user_id " +
|
||||
"ORDER BY cnt DESC LIMIT :limit",
|
||||
nativeQuery = true)
|
||||
List<Object[]> findTopSharedLinksByActivityId(@Param("activityId") Long activityId, @Param("limit") int limit);
|
||||
|
||||
@Query("SELECT COUNT(DISTINCT l.ip) FROM LinkClickEntity l " +
|
||||
"WHERE l.activityId = :activityId " +
|
||||
"AND l.createdAt BETWEEN :startTime AND :endTime")
|
||||
long countUniqueVisitorsByActivityIdAndDateRange(
|
||||
@Param("activityId") Long activityId,
|
||||
@Param("startTime") OffsetDateTime startTime,
|
||||
@Param("endTime") OffsetDateTime endTime
|
||||
);
|
||||
|
||||
long countByActivityId(Long activityId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mosquito.project.persistence.repository;
|
||||
|
||||
import com.mosquito.project.persistence.entity.ProcessedCallbackEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ProcessedCallbackRepository extends JpaRepository<ProcessedCallbackEntity, String> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.mosquito.project.persistence.repository;
|
||||
|
||||
import com.mosquito.project.persistence.entity.RewardJobEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface RewardJobRepository extends JpaRepository<RewardJobEntity, Long> {
|
||||
List<RewardJobEntity> findTop10ByStatusAndNextRunAtLessThanEqualOrderByCreatedAtAsc(String status, OffsetDateTime now);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.mosquito.project.persistence.repository;
|
||||
|
||||
import com.mosquito.project.persistence.entity.ShortLinkEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ShortLinkRepository extends JpaRepository<ShortLinkEntity, Long> {
|
||||
Optional<ShortLinkEntity> findByCode(String code);
|
||||
boolean existsByCode(String code);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.mosquito.project.persistence.repository;
|
||||
|
||||
import com.mosquito.project.persistence.entity.UserInviteEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface UserInviteRepository extends JpaRepository<UserInviteEntity, Long> {
|
||||
List<UserInviteEntity> findByActivityId(Long activityId);
|
||||
List<UserInviteEntity> findByActivityIdAndInviterUserId(Long activityId, Long inviterUserId);
|
||||
|
||||
@Query("SELECT u.inviterUserId as userId, COUNT(u) as inviteCount " +
|
||||
"FROM UserInviteEntity u " +
|
||||
"WHERE u.activityId = :activityId " +
|
||||
"GROUP BY u.inviterUserId " +
|
||||
"ORDER BY inviteCount DESC")
|
||||
List<Object[]> countInvitesByActivityIdGroupByInviter(@Param("activityId") Long activityId);
|
||||
|
||||
@Query("SELECT COUNT(u) FROM UserInviteEntity u WHERE u.activityId = :activityId")
|
||||
long countByActivityId(@Param("activityId") Long activityId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.mosquito.project.persistence.repository;
|
||||
|
||||
import com.mosquito.project.persistence.entity.UserRewardEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface UserRewardRepository extends JpaRepository<UserRewardEntity, Long> {
|
||||
List<UserRewardEntity> findByActivityIdAndUserIdOrderByCreatedAtDesc(Long activityId, Long userId);
|
||||
}
|
||||
|
||||
176
src/main/java/com/mosquito/project/sdk/ApiClient.java
Normal file
176
src/main/java/com/mosquito/project/sdk/ApiClient.java
Normal file
@@ -0,0 +1,176 @@
|
||||
package com.mosquito.project.sdk;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.mosquito.project.dto.ApiResponse;
|
||||
|
||||
class ApiClient {
|
||||
|
||||
private final String baseUrl;
|
||||
private final String apiKey;
|
||||
private final HttpClient httpClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
ApiClient(String baseUrl, String apiKey) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.apiKey = apiKey;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
}
|
||||
|
||||
<T> T get(String path, Class<T> responseType) {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.header("X-API-Key", apiKey)
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
return execute(request, objectMapper.getTypeFactory().constructType(responseType));
|
||||
}
|
||||
|
||||
String getString(String path) {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.header("X-API-Key", apiKey)
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
return response.body();
|
||||
} catch (IOException | InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("Failed to GET " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] getBytes(String path) {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.header("X-API-Key", apiKey)
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
try {
|
||||
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
return response.body();
|
||||
} catch (IOException | InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("Failed to GET bytes from " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
<T> T post(String path, Map<String, Object> body, Class<T> responseType) {
|
||||
try {
|
||||
String jsonBody = objectMapper.writeValueAsString(body);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.header("X-API-Key", apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
|
||||
return execute(request, objectMapper.getTypeFactory().constructType(responseType));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to POST to " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
<T> T put(String path, Map<String, Object> body, Class<T> responseType) {
|
||||
try {
|
||||
String jsonBody = objectMapper.writeValueAsString(body);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.header("X-API-Key", apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
|
||||
return execute(request, objectMapper.getTypeFactory().constructType(responseType));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to PUT to " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
void delete(String path) {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.header("X-API-Key", apiKey)
|
||||
.DELETE()
|
||||
.build();
|
||||
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new RuntimeException("API call failed with status " + response.statusCode() + ": " + response.body());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to DELETE " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
<T> List<T> getList(String path, Class<T> elementType) {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.header("X-API-Key", apiKey)
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
try {
|
||||
JavaType listType = objectMapper.getTypeFactory().constructCollectionType(List.class, elementType);
|
||||
List<T> result = execute(request, listType);
|
||||
return result == null ? new ArrayList<>() : result;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse list response from " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T execute(HttpRequest request, JavaType dataType) {
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
||||
return (T) unwrap(response.body(), dataType);
|
||||
} else {
|
||||
throw new RuntimeException("API call failed with status " + response.statusCode() + ": " + response.body());
|
||||
}
|
||||
} catch (IOException | InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("API call failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T unwrap(String body, JavaType dataType) throws IOException {
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
JavaType envelopeType = objectMapper.getTypeFactory()
|
||||
.constructParametricType(ApiResponse.class, dataType);
|
||||
ApiResponse<T> response = objectMapper.readValue(body, envelopeType);
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
if (response.getCode() >= 400) {
|
||||
throw new RuntimeException("API call failed with code " + response.getCode() + ": " + response.getMessage());
|
||||
}
|
||||
return response.getData();
|
||||
}
|
||||
}
|
||||
369
src/main/java/com/mosquito/project/sdk/MosquitoClient.java
Normal file
369
src/main/java/com/mosquito/project/sdk/MosquitoClient.java
Normal file
@@ -0,0 +1,369 @@
|
||||
package com.mosquito.project.sdk;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 蚊子项目Java SDK
|
||||
*
|
||||
* 使用示例:
|
||||
* <pre>
|
||||
* MosquitoClient client = new MosquitoClient("http://localhost:8080", "your-api-key");
|
||||
*
|
||||
* // 创建活动
|
||||
* Activity activity = client.createActivity("New Activity", startTime, endTime);
|
||||
*
|
||||
* // 获取分享链接
|
||||
* String shareUrl = client.getShareUrl(activity.getId(), userId);
|
||||
*
|
||||
* // 生成海报
|
||||
* byte[] posterImage = client.getPosterImage(activity.getId(), userId);
|
||||
* String posterHtml = client.getPosterHtml(activity.getId(), userId);
|
||||
*
|
||||
* // 获取排行榜
|
||||
* List<LeaderboardEntry> leaderboard = client.getLeaderboard(activity.getId());
|
||||
* </pre>
|
||||
*/
|
||||
public class MosquitoClient {
|
||||
|
||||
private final String baseUrl;
|
||||
private final String apiKey;
|
||||
private final ApiClient apiClient;
|
||||
|
||||
public MosquitoClient(String baseUrl, String apiKey) {
|
||||
this.baseUrl = baseUrl.replaceAll("/+$", "");
|
||||
this.apiKey = apiKey;
|
||||
this.apiClient = new ApiClient(baseUrl, apiKey);
|
||||
}
|
||||
|
||||
// ==================== Activity Management ====================
|
||||
|
||||
/**
|
||||
* 创建活动
|
||||
*/
|
||||
public Activity createActivity(String name, ZonedDateTime startTime, ZonedDateTime endTime) {
|
||||
return apiClient.post("/api/v1/activities", Map.of(
|
||||
"name", name,
|
||||
"startTime", startTime.toOffsetDateTime().toString(),
|
||||
"endTime", endTime.toOffsetDateTime().toString()
|
||||
), Activity.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动信息
|
||||
*/
|
||||
public Activity getActivity(Long activityId) {
|
||||
return apiClient.get("/api/v1/activities/" + activityId, Activity.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新活动
|
||||
*/
|
||||
public Activity updateActivity(Long activityId, String name, ZonedDateTime endTime) {
|
||||
return apiClient.put("/api/v1/activities/" + activityId, Map.of(
|
||||
"name", name,
|
||||
"endTime", endTime.toOffsetDateTime().toString()
|
||||
), Activity.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动统计
|
||||
*/
|
||||
public ActivityStats getActivityStats(Long activityId) {
|
||||
return apiClient.get("/api/v1/activities/" + activityId + "/stats", ActivityStats.class);
|
||||
}
|
||||
|
||||
// ==================== Share Functions ====================
|
||||
|
||||
/**
|
||||
* 生成分享链接
|
||||
*/
|
||||
public String getShareUrl(Long activityId, Long userId) {
|
||||
return getShareUrl(activityId, userId, "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定模板生成分享链接
|
||||
*/
|
||||
public String getShareUrl(Long activityId, Long userId, String template) {
|
||||
ShortenResponse response = apiClient.get(
|
||||
"/api/v1/me/invitation-info?activityId=" + activityId + "&userId=" + userId + "&template=" + template,
|
||||
ShortenResponse.class
|
||||
);
|
||||
return baseUrl + "/" + response.getPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享元数据 (用于社交媒体)
|
||||
*/
|
||||
public ShareMeta getShareMeta(Long activityId, Long userId) {
|
||||
return apiClient.get(
|
||||
"/api/v1/me/share-meta?activityId=" + activityId + "&userId=" + userId,
|
||||
ShareMeta.class
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Poster Functions ====================
|
||||
|
||||
/**
|
||||
* 获取海报图片 (PNG)
|
||||
*/
|
||||
public byte[] getPosterImage(Long activityId, Long userId) {
|
||||
return getPosterImage(activityId, userId, "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定模板获取海报图片
|
||||
*/
|
||||
public byte[] getPosterImage(Long activityId, Long userId, String template) {
|
||||
return apiClient.getBytes(
|
||||
"/api/v1/me/poster/image?activityId=" + activityId + "&userId=" + userId + "&template=" + template
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取海报HTML (可用于iframe嵌入)
|
||||
*/
|
||||
public String getPosterHtml(Long activityId, Long userId) {
|
||||
return getPosterHtml(activityId, userId, "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定模板获取海报HTML
|
||||
*/
|
||||
public String getPosterHtml(Long activityId, Long userId, String template) {
|
||||
return apiClient.getString(
|
||||
"/api/v1/me/poster/html?activityId=" + activityId + "&userId=" + userId + "&template=" + template
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取海报配置
|
||||
*/
|
||||
public PosterConfig getPosterConfig(String template) {
|
||||
return apiClient.get(
|
||||
"/api/v1/me/poster/config?template=" + template,
|
||||
PosterConfig.class
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Leaderboard ====================
|
||||
|
||||
/**
|
||||
* 获取排行榜
|
||||
*/
|
||||
public List<LeaderboardEntry> getLeaderboard(Long activityId) {
|
||||
return apiClient.getList(
|
||||
"/api/v1/activities/" + activityId + "/leaderboard",
|
||||
LeaderboardEntry.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排行榜 (分页)
|
||||
*/
|
||||
public List<LeaderboardEntry> getLeaderboard(Long activityId, int page, int size) {
|
||||
return apiClient.getList(
|
||||
"/api/v1/activities/" + activityId + "/leaderboard?page=" + page + "&size=" + size,
|
||||
LeaderboardEntry.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出排行榜CSV
|
||||
*/
|
||||
public String exportLeaderboardCsv(Long activityId) {
|
||||
return apiClient.getString("/api/v1/activities/" + activityId + "/leaderboard/export");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出排行榜CSV (Top N)
|
||||
*/
|
||||
public String exportLeaderboardCsv(Long activityId, int topN) {
|
||||
return apiClient.getString("/api/v1/activities/" + activityId + "/leaderboard/export?topN=" + topN);
|
||||
}
|
||||
|
||||
// ==================== Rewards ====================
|
||||
|
||||
/**
|
||||
* 获取用户奖励列表
|
||||
*/
|
||||
public List<RewardInfo> getUserRewards(Long activityId, Long userId) {
|
||||
return apiClient.getList(
|
||||
"/api/v1/me/rewards?activityId=" + activityId + "&userId=" + userId,
|
||||
RewardInfo.class
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== API Key Management ====================
|
||||
|
||||
/**
|
||||
* 创建API密钥
|
||||
*/
|
||||
public String createApiKey(Long activityId, String name) {
|
||||
CreateApiKeyResponse response = apiClient.post("/api/v1/api-keys",
|
||||
Map.of("activityId", activityId, "name", name),
|
||||
CreateApiKeyResponse.class
|
||||
);
|
||||
return response.getApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* 吊销API密钥
|
||||
*/
|
||||
public void revokeApiKey(Long apiKeyId) {
|
||||
apiClient.delete("/api/v1/api-keys/" + apiKeyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新显示API密钥 (需安全保管)
|
||||
*/
|
||||
public String revealApiKey(Long apiKeyId) {
|
||||
RevealApiKeyResponse response = apiClient.get(
|
||||
"/api/v1/api-keys/" + apiKeyId + "/reveal",
|
||||
RevealApiKeyResponse.class
|
||||
);
|
||||
return response.getApiKey();
|
||||
}
|
||||
|
||||
// ==================== Health Check ====================
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
public boolean isHealthy() {
|
||||
try {
|
||||
apiClient.getString("/actuator/health");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Domain Classes ====================
|
||||
|
||||
public static class Activity {
|
||||
private Long id;
|
||||
private String name;
|
||||
private ZonedDateTime startTime;
|
||||
private ZonedDateTime endTime;
|
||||
|
||||
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; }
|
||||
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 static class ActivityStats {
|
||||
private long totalParticipants;
|
||||
private long totalShares;
|
||||
private List<DailyStats> daily;
|
||||
|
||||
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> getDaily() { return daily; }
|
||||
public void setDaily(List<DailyStats> daily) { this.daily = daily; }
|
||||
}
|
||||
|
||||
public static class DailyStats {
|
||||
private String date;
|
||||
private int participants;
|
||||
private int 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; }
|
||||
}
|
||||
|
||||
public static class LeaderboardEntry {
|
||||
private Long userId;
|
||||
private String userName;
|
||||
private int score;
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
public static class ShareMeta {
|
||||
private String title;
|
||||
private String description;
|
||||
private String image;
|
||||
private String url;
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getImage() { return image; }
|
||||
public void setImage(String image) { this.image = image; }
|
||||
public String getUrl() { return url; }
|
||||
public void setUrl(String url) { this.url = url; }
|
||||
}
|
||||
|
||||
public static class PosterConfig {
|
||||
private String template;
|
||||
private String imageUrl;
|
||||
private String htmlUrl;
|
||||
|
||||
public String getTemplate() { return template; }
|
||||
public void setTemplate(String template) { this.template = template; }
|
||||
public String getImageUrl() { return imageUrl; }
|
||||
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||
public String getHtmlUrl() { return htmlUrl; }
|
||||
public void setHtmlUrl(String htmlUrl) { this.htmlUrl = htmlUrl; }
|
||||
}
|
||||
|
||||
public static class RewardInfo {
|
||||
private String type;
|
||||
private int points;
|
||||
private String createdAt;
|
||||
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
public int getPoints() { return points; }
|
||||
public void setPoints(int points) { this.points = points; }
|
||||
public String getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
||||
public static class ShortenResponse {
|
||||
private String code;
|
||||
private String path;
|
||||
private String originalUrl;
|
||||
|
||||
public String getCode() { return code; }
|
||||
public void setCode(String code) { this.code = code; }
|
||||
public String getPath() { return path; }
|
||||
public void setPath(String path) { this.path = path; }
|
||||
public String getOriginalUrl() { return originalUrl; }
|
||||
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
|
||||
}
|
||||
|
||||
public static class CreateApiKeyResponse {
|
||||
private String apiKey;
|
||||
public String getApiKey() { return apiKey; }
|
||||
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
|
||||
}
|
||||
|
||||
public static class RevealApiKeyResponse {
|
||||
private String apiKey;
|
||||
private String message;
|
||||
public String getApiKey() { return apiKey; }
|
||||
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mosquito.project.security;
|
||||
|
||||
public class IntrospectionRequest {
|
||||
private String token;
|
||||
|
||||
public IntrospectionRequest() {}
|
||||
|
||||
public IntrospectionRequest(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.mosquito.project.security;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class IntrospectionResponse {
|
||||
private boolean active;
|
||||
|
||||
@JsonProperty("user_id")
|
||||
private String userId;
|
||||
|
||||
@JsonProperty("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
private List<String> roles;
|
||||
|
||||
private List<String> scopes;
|
||||
|
||||
private long exp;
|
||||
|
||||
private long iat;
|
||||
|
||||
private String jti;
|
||||
|
||||
public static IntrospectionResponse inactive() {
|
||||
IntrospectionResponse response = new IntrospectionResponse();
|
||||
response.setActive(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getTenantId() {
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public void setTenantId(String tenantId) {
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
public List<String> getRoles() {
|
||||
return roles;
|
||||
}
|
||||
|
||||
public void setRoles(List<String> roles) {
|
||||
this.roles = roles;
|
||||
}
|
||||
|
||||
public List<String> getScopes() {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public void setScopes(List<String> scopes) {
|
||||
this.scopes = scopes;
|
||||
}
|
||||
|
||||
public long getExp() {
|
||||
return exp;
|
||||
}
|
||||
|
||||
public void setExp(long exp) {
|
||||
this.exp = exp;
|
||||
}
|
||||
|
||||
public long getIat() {
|
||||
return iat;
|
||||
}
|
||||
|
||||
public void setIat(long iat) {
|
||||
this.iat = iat;
|
||||
}
|
||||
|
||||
public String getJti() {
|
||||
return jti;
|
||||
}
|
||||
|
||||
public void setJti(String jti) {
|
||||
this.jti = jti;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.mosquito.project.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mosquito.project.config.AppConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class UserIntrospectionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserIntrospectionService.class);
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AppConfig.IntrospectionConfig config;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final Map<String, CacheEntry> localCache = new ConcurrentHashMap<>();
|
||||
|
||||
public UserIntrospectionService(RestTemplateBuilder builder, AppConfig appConfig, Optional<StringRedisTemplate> redisTemplateOpt) {
|
||||
this.config = appConfig.getSecurity().getIntrospection();
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofMillis(config.getTimeoutMillis()))
|
||||
.setReadTimeout(Duration.ofMillis(config.getTimeoutMillis()))
|
||||
.build();
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.redisTemplate = redisTemplateOpt.orElse(null);
|
||||
}
|
||||
|
||||
public IntrospectionResponse introspect(String authorizationHeader) {
|
||||
String token = extractToken(authorizationHeader);
|
||||
if (token == null || token.isBlank()) {
|
||||
return IntrospectionResponse.inactive();
|
||||
}
|
||||
|
||||
String cacheKey = cacheKey(token);
|
||||
IntrospectionResponse cached = readCache(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (config.getUrl() == null || config.getUrl().isBlank()) {
|
||||
log.error("Introspection URL is not configured");
|
||||
writeCache(cacheKey, IntrospectionResponse.inactive(), config.getNegativeCacheSeconds());
|
||||
return IntrospectionResponse.inactive();
|
||||
}
|
||||
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
if (config.getClientId() != null && !config.getClientId().isBlank()) {
|
||||
headers.setBasicAuth(config.getClientId(), config.getClientSecret());
|
||||
}
|
||||
|
||||
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
|
||||
body.add("token", token);
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<IntrospectionResponse> response = restTemplate.postForEntity(
|
||||
config.getUrl(),
|
||||
request,
|
||||
IntrospectionResponse.class
|
||||
);
|
||||
|
||||
IntrospectionResponse result = response.getBody();
|
||||
if (result == null) {
|
||||
writeCache(cacheKey, IntrospectionResponse.inactive(), config.getNegativeCacheSeconds());
|
||||
return IntrospectionResponse.inactive();
|
||||
}
|
||||
|
||||
if (!result.isActive()) {
|
||||
writeCache(cacheKey, result, config.getNegativeCacheSeconds());
|
||||
return result;
|
||||
}
|
||||
|
||||
long ttlSeconds = computeTtlSeconds(result.getExp());
|
||||
if (ttlSeconds <= 0) {
|
||||
IntrospectionResponse inactive = IntrospectionResponse.inactive();
|
||||
writeCache(cacheKey, inactive, config.getNegativeCacheSeconds());
|
||||
return inactive;
|
||||
}
|
||||
|
||||
writeCache(cacheKey, result, ttlSeconds);
|
||||
return result;
|
||||
} catch (Exception ex) {
|
||||
log.warn("Introspection request failed: {}", ex.getMessage());
|
||||
IntrospectionResponse inactive = IntrospectionResponse.inactive();
|
||||
writeCache(cacheKey, inactive, config.getNegativeCacheSeconds());
|
||||
return inactive;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractToken(String authorizationHeader) {
|
||||
if (authorizationHeader == null || authorizationHeader.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (authorizationHeader.startsWith("Bearer ")) {
|
||||
return authorizationHeader.substring("Bearer ".length()).trim();
|
||||
}
|
||||
return authorizationHeader.trim();
|
||||
}
|
||||
|
||||
private long computeTtlSeconds(long exp) {
|
||||
long now = Instant.now().getEpochSecond();
|
||||
long delta = exp - now;
|
||||
long ttl = Math.min(config.getCacheTtlSeconds(), delta);
|
||||
return Math.max(ttl, 0);
|
||||
}
|
||||
|
||||
private String cacheKey(String token) {
|
||||
return "introspect:" + sha256(token);
|
||||
}
|
||||
|
||||
private IntrospectionResponse readCache(String cacheKey) {
|
||||
CacheEntry entry = localCache.get(cacheKey);
|
||||
if (entry != null && entry.expiresAtMillis > System.currentTimeMillis()) {
|
||||
return entry.response;
|
||||
}
|
||||
if (entry != null) {
|
||||
localCache.remove(cacheKey);
|
||||
}
|
||||
|
||||
if (redisTemplate == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String payload = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (payload == null) {
|
||||
return null;
|
||||
}
|
||||
return objectMapper.readValue(payload, IntrospectionResponse.class);
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to read introspection cache: {}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeCache(String cacheKey, IntrospectionResponse response, long ttlSeconds) {
|
||||
if (ttlSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
long expiresAtMillis = System.currentTimeMillis() + Duration.ofSeconds(ttlSeconds).toMillis();
|
||||
localCache.put(cacheKey, new CacheEntry(response, expiresAtMillis));
|
||||
|
||||
if (redisTemplate == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String payload = objectMapper.writeValueAsString(response);
|
||||
redisTemplate.opsForValue().set(cacheKey, payload, Duration.ofSeconds(ttlSeconds));
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to write introspection cache: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String sha256(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashed = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Hashing failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static class CacheEntry {
|
||||
private final IntrospectionResponse response;
|
||||
private final long expiresAtMillis;
|
||||
|
||||
private CacheEntry(IntrospectionResponse response, long expiresAtMillis) {
|
||||
this.response = response;
|
||||
this.expiresAtMillis = expiresAtMillis;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,21 @@ 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.InvalidApiKeyException;
|
||||
import com.mosquito.project.exception.UserNotAuthorizedForActivityException;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.cache.annotation.Caching;
|
||||
import com.mosquito.project.persistence.entity.ActivityEntity;
|
||||
import com.mosquito.project.persistence.repository.ActivityRepository;
|
||||
import com.mosquito.project.persistence.entity.ApiKeyEntity;
|
||||
import com.mosquito.project.persistence.repository.ApiKeyRepository;
|
||||
import java.time.ZoneOffset;
|
||||
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;
|
||||
@@ -36,23 +38,34 @@ 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 long MAX_IMAGE_SIZE_BYTES = 30 * 1024 * 1024;
|
||||
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();
|
||||
|
||||
private final DelayProvider delayProvider;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final com.mosquito.project.persistence.repository.DailyActivityStatsRepository dailyActivityStatsRepository;
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
private final com.mosquito.project.persistence.repository.UserInviteRepository userInviteRepository;
|
||||
private final ApiKeyEncryptionService encryptionService;
|
||||
private final com.mosquito.project.config.AppConfig appConfig;
|
||||
private static final int KEY_PREFIX_LEN = 12;
|
||||
|
||||
public ActivityService(DelayProvider delayProvider, ActivityRepository activityRepository) {
|
||||
public ActivityService(DelayProvider delayProvider, ActivityRepository activityRepository, ApiKeyRepository apiKeyRepository, com.mosquito.project.persistence.repository.DailyActivityStatsRepository dailyActivityStatsRepository, com.mosquito.project.persistence.repository.UserInviteRepository userInviteRepository, ApiKeyEncryptionService encryptionService, com.mosquito.project.config.AppConfig appConfig) {
|
||||
this.delayProvider = delayProvider;
|
||||
this.activityRepository = activityRepository;
|
||||
this.apiKeyRepository = apiKeyRepository;
|
||||
this.dailyActivityStatsRepository = dailyActivityStatsRepository;
|
||||
this.userInviteRepository = userInviteRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
this.appConfig = appConfig;
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "leaderboards", allEntries = true),
|
||||
@CacheEvict(value = "activity_stats", allEntries = true),
|
||||
@CacheEvict(value = "activity_graph", allEntries = true)
|
||||
})
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public Activity createActivity(CreateActivityRequest request) {
|
||||
if (request.getEndTime().isBefore(request.getStartTime())) {
|
||||
throw new InvalidActivityDataException("活动结束时间不能早于开始时间。");
|
||||
@@ -72,10 +85,15 @@ public class ActivityService {
|
||||
activity.setName(request.getName());
|
||||
activity.setStartTime(request.getStartTime());
|
||||
activity.setEndTime(request.getEndTime());
|
||||
activities.put(activity.getId(), activity);
|
||||
return activity;
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "leaderboards", allEntries = true),
|
||||
@CacheEvict(value = "activity_stats", allEntries = true),
|
||||
@CacheEvict(value = "activity_graph", allEntries = true)
|
||||
})
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public Activity updateActivity(Long id, UpdateActivityRequest request) {
|
||||
ActivityEntity entity = activityRepository.findById(id)
|
||||
.orElseThrow(() -> new ActivityNotFoundException("活动不存在。"));
|
||||
@@ -95,7 +113,6 @@ public class ActivityService {
|
||||
activity.setName(request.getName());
|
||||
activity.setStartTime(request.getStartTime());
|
||||
activity.setEndTime(request.getEndTime());
|
||||
activities.put(id, activity);
|
||||
return activity;
|
||||
}
|
||||
|
||||
@@ -123,8 +140,13 @@ public class ActivityService {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "leaderboards", allEntries = true),
|
||||
@CacheEvict(value = "activity_stats", allEntries = true)
|
||||
})
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public String generateApiKey(CreateApiKeyRequest request) {
|
||||
if (!activities.containsKey(request.getActivityId())) {
|
||||
if (!activityRepository.existsById(request.getActivityId())) {
|
||||
throw new ActivityNotFoundException("关联的活动不存在。");
|
||||
}
|
||||
|
||||
@@ -132,14 +154,17 @@ public class ActivityService {
|
||||
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);
|
||||
String encryptedKey = encryptionService.encrypt(rawApiKey);
|
||||
|
||||
apiKeys.put(apiKey.getId(), apiKey);
|
||||
ApiKeyEntity entity = new ApiKeyEntity();
|
||||
entity.setActivityId(request.getActivityId());
|
||||
entity.setName(request.getName());
|
||||
entity.setSalt(Base64.getEncoder().encodeToString(salt));
|
||||
entity.setKeyHash(keyHash);
|
||||
entity.setKeyPrefix(rawApiKey.substring(0, Math.min(KEY_PREFIX_LEN, rawApiKey.length())));
|
||||
entity.setEncryptedKey(encryptedKey);
|
||||
entity.setCreatedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
apiKeyRepository.save(entity);
|
||||
|
||||
return rawApiKey;
|
||||
}
|
||||
@@ -165,6 +190,39 @@ public class ActivityService {
|
||||
}
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void validateAndMarkApiKeyUsed(Long id, String rawApiKey) {
|
||||
ApiKeyEntity entity = apiKeyRepository.findById(id)
|
||||
.orElseThrow(() -> new com.mosquito.project.exception.ApiKeyNotFoundException("API密钥不存在。"));
|
||||
if (entity.getRevokedAt() != null) {
|
||||
throw new com.mosquito.project.exception.InvalidApiKeyException("API密钥已吊销或无效。");
|
||||
}
|
||||
byte[] salt = Base64.getDecoder().decode(entity.getSalt());
|
||||
String computed = hashApiKey(rawApiKey, salt);
|
||||
if (!computed.equals(entity.getKeyHash())) {
|
||||
throw new com.mosquito.project.exception.InvalidApiKeyException("API密钥已吊销或无效。");
|
||||
}
|
||||
entity.setLastUsedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
apiKeyRepository.save(entity);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void validateApiKeyByPrefixAndMarkUsed(String rawApiKey) {
|
||||
String prefix = rawApiKey.substring(0, Math.min(KEY_PREFIX_LEN, rawApiKey.length())).trim();
|
||||
ApiKeyEntity entity = apiKeyRepository.findByKeyPrefix(prefix)
|
||||
.orElseThrow(() -> new com.mosquito.project.exception.InvalidApiKeyException("API密钥已吊销或无效。"));
|
||||
if (entity.getRevokedAt() != null) {
|
||||
throw new com.mosquito.project.exception.InvalidApiKeyException("API密钥已吊销或无效。");
|
||||
}
|
||||
byte[] salt = Base64.getDecoder().decode(entity.getSalt());
|
||||
String computed = hashApiKey(rawApiKey, salt);
|
||||
if (!computed.equals(entity.getKeyHash())) {
|
||||
throw new com.mosquito.project.exception.InvalidApiKeyException("API密钥已吊销或无效。");
|
||||
}
|
||||
entity.setLastUsedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
apiKeyRepository.save(entity);
|
||||
}
|
||||
|
||||
public void accessActivity(Activity activity, User user) {
|
||||
Set<Long> targetUserIds = activity.getTargetUserIds();
|
||||
if (targetUserIds != null && !targetUserIds.isEmpty() && !targetUserIds.contains(user.getId())) {
|
||||
@@ -227,19 +285,72 @@ public class ActivityService {
|
||||
.orElse(new Reward(0));
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "leaderboards", allEntries = true),
|
||||
@CacheEvict(value = "activity_stats", allEntries = true)
|
||||
})
|
||||
public void createReward(Reward reward, boolean skipValidation) {
|
||||
if (reward.getRewardType() == RewardType.COUPON && !skipValidation) {
|
||||
boolean isValidCouponBatchId = false;
|
||||
if (!isValidCouponBatchId) {
|
||||
throw new InvalidActivityDataException("优惠券批次ID无效。");
|
||||
if (reward.getCouponBatchId() == null || reward.getCouponBatchId().isBlank()) {
|
||||
throw new InvalidActivityDataException("优惠券批次ID不能为空。");
|
||||
}
|
||||
log.warn("Coupon validation not yet implemented. CouponBatchId: {}. " +
|
||||
"To skip validation, call with skipValidation=true.", reward.getCouponBatchId());
|
||||
throw new UnsupportedOperationException(
|
||||
"优惠券验证功能尚未实现。请联系管理员配置优惠券批次或使用skipValidation=true参数。"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "activities", key = "#id"),
|
||||
@CacheEvict(value = "leaderboards", allEntries = true),
|
||||
@CacheEvict(value = "activity_stats", allEntries = true),
|
||||
@CacheEvict(value = "activity_graph", allEntries = true)
|
||||
})
|
||||
public void evictActivityCache(Long id) {
|
||||
log.info("Evicted cache for activity: {}", id);
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "leaderboards", allEntries = true),
|
||||
@CacheEvict(value = "activity_stats", allEntries = true)
|
||||
})
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void revokeApiKey(Long id) {
|
||||
if (apiKeys.remove(id) == null) {
|
||||
throw new ApiKeyNotFoundException("API密钥不存在。");
|
||||
ApiKeyEntity entity = apiKeyRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiKeyNotFoundException("API密钥不存在。"));
|
||||
entity.setRevokedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
apiKeyRepository.save(entity);
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "leaderboards", allEntries = true),
|
||||
@CacheEvict(value = "activity_stats", allEntries = true)
|
||||
})
|
||||
public void markApiKeyUsed(Long id) {
|
||||
ApiKeyEntity entity = apiKeyRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiKeyNotFoundException("API密钥不存在。"));
|
||||
entity.setLastUsedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
apiKeyRepository.save(entity);
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "leaderboards", allEntries = true),
|
||||
@CacheEvict(value = "activity_stats", allEntries = true)
|
||||
})
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public String revealApiKey(Long id) {
|
||||
ApiKeyEntity entity = apiKeyRepository.findById(id)
|
||||
.orElseThrow(() -> new ApiKeyNotFoundException("API密钥不存在。"));
|
||||
if (entity.getRevokedAt() != null) {
|
||||
throw new InvalidApiKeyException("API密钥已吊销,无法显示。");
|
||||
}
|
||||
String rawApiKey = encryptionService.decrypt(entity.getEncryptedKey());
|
||||
entity.setRevealedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
apiKeyRepository.save(entity);
|
||||
log.info("API key revealed for id: {}", id);
|
||||
return rawApiKey;
|
||||
}
|
||||
|
||||
@Cacheable(value = "leaderboards", key = "#activityId")
|
||||
@@ -247,51 +358,149 @@ public class ActivityService {
|
||||
if (!activityRepository.existsById(activityId)) {
|
||||
throw new ActivityNotFoundException("活动不存在。");
|
||||
}
|
||||
// Simulate fetching and ranking data
|
||||
log.info("正在为活动ID {} 生成排行榜...", activityId);
|
||||
try {
|
||||
delayProvider.delayMillis(2000);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
|
||||
List<Object[]> results = userInviteRepository.countInvitesByActivityIdGroupByInviter(activityId);
|
||||
if (results.isEmpty()) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
return List.of(
|
||||
new LeaderboardEntry(1L, "用户A", 1500),
|
||||
new LeaderboardEntry(2L, "用户B", 1200),
|
||||
new LeaderboardEntry(3L, "用户C", 990)
|
||||
);
|
||||
|
||||
return results.stream()
|
||||
.map(row -> {
|
||||
Long userId = ((Number) row[0]).longValue();
|
||||
Long inviteCount = ((Number) row[1]).longValue();
|
||||
return new LeaderboardEntry(userId, "用户" + userId, inviteCount.intValue());
|
||||
})
|
||||
.sorted((a, b) -> Integer.compare(b.getScore(), a.getScore()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@org.springframework.cache.annotation.Cacheable(value = "activity_stats", key = "#activityId")
|
||||
public ActivityStatsResponse getActivityStats(Long activityId) {
|
||||
if (!activityRepository.existsById(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)
|
||||
);
|
||||
List<com.mosquito.project.persistence.entity.DailyActivityStatsEntity> rows =
|
||||
dailyActivityStatsRepository.findByActivityIdOrderByStatDateAsc(activityId);
|
||||
|
||||
return new ActivityStatsResponse(220, 110, dailyStats);
|
||||
long totalParticipants = 0L;
|
||||
long totalShares = 0L;
|
||||
List<ActivityStatsResponse.DailyStats> daily = new ArrayList<>();
|
||||
for (com.mosquito.project.persistence.entity.DailyActivityStatsEntity e : rows) {
|
||||
int participants = e.getNewRegistrations() != null ? e.getNewRegistrations() : 0;
|
||||
int shares = e.getShares() != null ? e.getShares() : 0;
|
||||
totalParticipants += participants;
|
||||
totalShares += shares;
|
||||
daily.add(new ActivityStatsResponse.DailyStats(
|
||||
e.getStatDate().toString(), participants, shares
|
||||
));
|
||||
}
|
||||
return new ActivityStatsResponse(totalParticipants, totalShares, daily);
|
||||
}
|
||||
|
||||
public String generateLeaderboardCsv(Long activityId) {
|
||||
return generateLeaderboardCsv(activityId, null);
|
||||
}
|
||||
|
||||
public String generateLeaderboardCsv(Long activityId, Integer topN) {
|
||||
List<LeaderboardEntry> entries = getLeaderboard(activityId);
|
||||
int n = (topN == null || topN < 1) ? entries.size() : Math.min(topN, entries.size());
|
||||
|
||||
try (java.io.StringWriter writer = new java.io.StringWriter();
|
||||
org.apache.commons.csv.CSVPrinter csvPrinter = new org.apache.commons.csv.CSVPrinter(writer,
|
||||
org.apache.commons.csv.CSVFormat.DEFAULT.builder()
|
||||
.setHeader("userId", "userName", "score")
|
||||
.build())) {
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
LeaderboardEntry e = entries.get(i);
|
||||
csvPrinter.printRecord(e.getUserId(), e.getUserName(), e.getScore());
|
||||
}
|
||||
csvPrinter.flush();
|
||||
return writer.toString();
|
||||
} catch (java.io.IOException e) {
|
||||
log.error("Failed to generate CSV", e);
|
||||
throw new RuntimeException("CSV生成失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@org.springframework.cache.annotation.Cacheable(value = "activity_graph", key = "#activityId")
|
||||
public ActivityGraphResponse getActivityGraph(Long activityId) {
|
||||
if (!activityRepository.existsById(activityId)) {
|
||||
throw new ActivityNotFoundException("活动不存在。");
|
||||
}
|
||||
List<com.mosquito.project.persistence.entity.UserInviteEntity> invites = userInviteRepository.findByActivityId(activityId);
|
||||
Map<Long, String> userLabels = new HashMap<>();
|
||||
List<ActivityGraphResponse.Edge> edges = new ArrayList<>();
|
||||
for (com.mosquito.project.persistence.entity.UserInviteEntity inv : invites) {
|
||||
Long from = inv.getInviterUserId();
|
||||
Long to = inv.getInviteeUserId();
|
||||
userLabels.putIfAbsent(from, "用户" + from);
|
||||
userLabels.putIfAbsent(to, "用户" + to);
|
||||
edges.add(new ActivityGraphResponse.Edge(String.valueOf(from), String.valueOf(to)));
|
||||
}
|
||||
List<ActivityGraphResponse.Node> nodes = new ArrayList<>();
|
||||
for (Map.Entry<Long, String> entry : userLabels.entrySet()) {
|
||||
nodes.add(new ActivityGraphResponse.Node(String.valueOf(entry.getKey()), entry.getValue()));
|
||||
}
|
||||
return new ActivityGraphResponse(nodes, edges);
|
||||
}
|
||||
|
||||
// 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")
|
||||
);
|
||||
@org.springframework.cache.annotation.Cacheable(value = "activity_graph", key = "#activityId + ':' + #rootUserId + ':' + #maxDepth + ':' + #limit")
|
||||
public ActivityGraphResponse getActivityGraph(Long activityId, Long rootUserId, Integer maxDepth, Integer limit) {
|
||||
if (!activityRepository.existsById(activityId)) {
|
||||
throw new ActivityNotFoundException("活动不存在。");
|
||||
}
|
||||
List<com.mosquito.project.persistence.entity.UserInviteEntity> invites = userInviteRepository.findByActivityId(activityId);
|
||||
Map<Long, java.util.List<Long>> children = new HashMap<>();
|
||||
for (com.mosquito.project.persistence.entity.UserInviteEntity inv : invites) {
|
||||
children.computeIfAbsent(inv.getInviterUserId(), k -> new ArrayList<>()).add(inv.getInviteeUserId());
|
||||
}
|
||||
|
||||
List<ActivityGraphResponse.Edge> edges = List.of(
|
||||
new ActivityGraphResponse.Edge("1", "2"),
|
||||
new ActivityGraphResponse.Edge("1", "3")
|
||||
);
|
||||
int maxDepthVal = (maxDepth == null || maxDepth < 1) ? 1 : maxDepth;
|
||||
int limitVal = (limit == null || limit < 1) ? 1000 : limit;
|
||||
|
||||
List<ActivityGraphResponse.Edge> edges = new ArrayList<>();
|
||||
Map<Long, String> labels = new HashMap<>();
|
||||
java.util.Set<Long> seen = new java.util.HashSet<>();
|
||||
|
||||
java.util.function.Consumer<Long> ensureLabel = (uid) -> labels.putIfAbsent(uid, "用户" + uid);
|
||||
|
||||
if (rootUserId != null) {
|
||||
java.util.ArrayDeque<long[]> q = new java.util.ArrayDeque<>();
|
||||
q.add(new long[]{rootUserId, 0});
|
||||
seen.add(rootUserId);
|
||||
ensureLabel.accept(rootUserId);
|
||||
while (!q.isEmpty() && edges.size() < limitVal) {
|
||||
long[] cur = q.poll();
|
||||
long uid = cur[0];
|
||||
int depth = (int) cur[1];
|
||||
if (depth >= maxDepthVal) continue;
|
||||
List<Long> childs = children.getOrDefault(uid, java.util.Collections.emptyList());
|
||||
for (Long v : childs) {
|
||||
edges.add(new ActivityGraphResponse.Edge(String.valueOf(uid), String.valueOf(v)));
|
||||
ensureLabel.accept(v);
|
||||
if (edges.size() >= limitVal) break;
|
||||
if (seen.add(v)) {
|
||||
q.add(new long[]{v, depth + 1});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (com.mosquito.project.persistence.entity.UserInviteEntity inv : invites) {
|
||||
long from = inv.getInviterUserId();
|
||||
long to = inv.getInviteeUserId();
|
||||
edges.add(new ActivityGraphResponse.Edge(String.valueOf(from), String.valueOf(to)));
|
||||
ensureLabel.accept(from);
|
||||
ensureLabel.accept(to);
|
||||
if (edges.size() >= limitVal) break;
|
||||
}
|
||||
}
|
||||
|
||||
List<ActivityGraphResponse.Node> nodes = new ArrayList<>();
|
||||
for (Map.Entry<Long, String> e : labels.entrySet()) {
|
||||
nodes.add(new ActivityGraphResponse.Node(String.valueOf(e.getKey()), e.getValue()));
|
||||
}
|
||||
return new ActivityGraphResponse(nodes, edges);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Service
|
||||
public class ApiKeyEncryptionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiKeyEncryptionService.class);
|
||||
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
private static final String DEFAULT_ENCRYPTION_KEY = "default-32-byte-key-for-dev-only!";
|
||||
private static final String LEGACY_DEFAULT_ENCRYPTION_KEY = "default-32-byte-key-for-dev-only!!";
|
||||
|
||||
@Value("${app.security.encryption-key:default-32-byte-key-for-dev-only!}")
|
||||
private String encryptionKey;
|
||||
|
||||
@Autowired(required = false)
|
||||
private Environment environment;
|
||||
|
||||
private SecretKeySpec secretKey;
|
||||
private SecureRandom secureRandom;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
if (isProductionProfile() && isDefaultKey(encryptionKey)) {
|
||||
throw new IllegalStateException("Encryption key must be set in production");
|
||||
}
|
||||
byte[] keyBytes = encryptionKey.getBytes();
|
||||
if (keyBytes.length != 32) {
|
||||
byte[] paddedKey = new byte[32];
|
||||
System.arraycopy(keyBytes, 0, paddedKey, 0, Math.min(keyBytes.length, 32));
|
||||
keyBytes = paddedKey;
|
||||
}
|
||||
this.secretKey = new SecretKeySpec(keyBytes, "AES");
|
||||
this.secureRandom = new SecureRandom();
|
||||
log.info("ApiKeyEncryptionService initialized");
|
||||
}
|
||||
|
||||
public String encrypt(String plainText) {
|
||||
if (plainText == null || plainText.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
secureRandom.nextBytes(iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
|
||||
|
||||
byte[] cipherText = cipher.doFinal(plainText.getBytes());
|
||||
|
||||
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
|
||||
byteBuffer.put(iv);
|
||||
byteBuffer.put(cipherText);
|
||||
|
||||
return Base64.getEncoder().encodeToString(byteBuffer.array());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to encrypt API key", e);
|
||||
throw new RuntimeException("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String encryptedText) {
|
||||
if (encryptedText == null || encryptedText.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(encryptedText);
|
||||
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap(decoded);
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
byteBuffer.get(iv);
|
||||
byte[] cipherText = new byte[byteBuffer.remaining()];
|
||||
byteBuffer.get(cipherText);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
|
||||
|
||||
byte[] plainText = cipher.doFinal(cipherText);
|
||||
return new String(plainText);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to decrypt API key", e);
|
||||
throw new RuntimeException("Decryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isProductionProfile() {
|
||||
return environment != null && environment.acceptsProfiles(Profiles.of("prod"));
|
||||
}
|
||||
|
||||
private boolean isDefaultKey(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
return DEFAULT_ENCRYPTION_KEY.equals(key) || LEGACY_DEFAULT_ENCRYPTION_KEY.equals(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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; // 简化实现
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
import com.mosquito.project.persistence.entity.RewardJobEntity;
|
||||
import com.mosquito.project.persistence.repository.RewardJobRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DbRewardQueue implements RewardQueue {
|
||||
private final RewardJobRepository repository;
|
||||
public DbRewardQueue(RewardJobRepository repository) { this.repository = repository; }
|
||||
|
||||
@Override
|
||||
public void enqueueReward(String trackingId, String externalUserId, String payloadJson) {
|
||||
RewardJobEntity job = new RewardJobEntity();
|
||||
job.setTrackingId(trackingId);
|
||||
job.setExternalUserId(externalUserId);
|
||||
job.setPayload(payloadJson);
|
||||
job.setStatus("pending");
|
||||
job.setRetryCount(0);
|
||||
job.setNextRunAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
job.setCreatedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
job.setUpdatedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
repository.save(job);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
import com.mosquito.project.config.PosterConfig;
|
||||
import com.mosquito.project.domain.Activity;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.URL;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
@Service
|
||||
public class PosterRenderService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PosterRenderService.class);
|
||||
|
||||
private final PosterConfig posterConfig;
|
||||
private final ShortLinkService shortLinkService;
|
||||
private final Map<String, Image> imageCache = new ConcurrentHashMap<>();
|
||||
|
||||
public PosterRenderService(PosterConfig posterConfig, ShortLinkService shortLinkService) {
|
||||
this.posterConfig = posterConfig;
|
||||
this.shortLinkService = shortLinkService;
|
||||
}
|
||||
|
||||
public byte[] renderPoster(Long activityId, Long userId, String templateName) {
|
||||
PosterConfig.PosterTemplate template = posterConfig.getTemplate(templateName);
|
||||
if (template == null) {
|
||||
template = posterConfig.getTemplate(posterConfig.getDefaultTemplate());
|
||||
}
|
||||
|
||||
Activity activity = null;
|
||||
try {
|
||||
// 获取活动信息
|
||||
// activity = activityService.getActivityById(activityId);
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not load activity: {}", e.getMessage());
|
||||
}
|
||||
|
||||
BufferedImage image = new BufferedImage(template.getWidth(), template.getHeight(), BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
|
||||
try {
|
||||
// 绘制背景
|
||||
if (template.getBackground() != null && !template.getBackground().isBlank()) {
|
||||
Image bgImage = loadImage(template.getBackground());
|
||||
if (bgImage != null) {
|
||||
g.drawImage(bgImage, 0, 0, template.getWidth(), template.getHeight(), null);
|
||||
} else {
|
||||
g.setColor(Color.decode(template.getBackgroundColor()));
|
||||
g.fillRect(0, 0, template.getWidth(), template.getHeight());
|
||||
}
|
||||
} else {
|
||||
g.setColor(Color.decode(template.getBackgroundColor()));
|
||||
g.fillRect(0, 0, template.getWidth(), template.getHeight());
|
||||
}
|
||||
|
||||
// 绘制元素
|
||||
for (Map.Entry<String, PosterConfig.PosterElement> entry : template.getElements().entrySet()) {
|
||||
PosterConfig.PosterElement element = entry.getValue();
|
||||
drawElement(g, element, activity, activityId, userId);
|
||||
}
|
||||
|
||||
} finally {
|
||||
g.dispose();
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try {
|
||||
ImageIO.write(image, "PNG", baos);
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate poster image", e);
|
||||
throw new RuntimeException("Failed to generate poster", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String renderPosterHtml(Long activityId, Long userId, String templateName) {
|
||||
PosterConfig.PosterTemplate template = posterConfig.getTemplate(templateName);
|
||||
if (template == null) {
|
||||
template = posterConfig.getTemplate(posterConfig.getDefaultTemplate());
|
||||
}
|
||||
|
||||
Activity activity = null;
|
||||
try {
|
||||
// activity = activityService.getActivityById(activityId);
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not load activity: {}", e.getMessage());
|
||||
}
|
||||
|
||||
String shortUrl = "/r/" + shortLinkService.create(
|
||||
"https://example.com/landing?activityId=" + activityId + "&inviter=" + userId
|
||||
).getCode();
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<!DOCTYPE html>");
|
||||
html.append("<html><head>");
|
||||
html.append("<meta charset=\"UTF-8\">");
|
||||
html.append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
html.append("<title>").append(activity != null ? escapeHtml(activity.getName()) : "分享").append("</title>");
|
||||
html.append("<style>");
|
||||
html.append("body { margin: 0; padding: 0; background: ").append(template.getBackgroundColor()).append("; }");
|
||||
html.append(".poster { position: relative; width: ").append(template.getWidth()).append("px; margin: 0 auto; }");
|
||||
if (template.getBackground() != null) {
|
||||
html.append(".poster { background-image: url('").append(template.getBackground()).append("'); background-size: cover; }");
|
||||
}
|
||||
html.append("</style></head><body>");
|
||||
html.append("<div class=\"poster\" style=\"width: ").append(template.getWidth()).append("px; height: ").append(template.getHeight()).append("px;\">");
|
||||
|
||||
for (Map.Entry<String, PosterConfig.PosterElement> entry : template.getElements().entrySet()) {
|
||||
PosterConfig.PosterElement element = entry.getValue();
|
||||
String content = resolveContent(element, activity, activityId, userId, shortUrl);
|
||||
|
||||
if ("text".equals(element.getType())) {
|
||||
html.append("<div style=\"position: absolute; left: ").append(element.getX()).append("px; top: ").append(element.getY()).append("px;");
|
||||
html.append(" width: ").append(element.getWidth()).append("px; height: ").append(element.getHeight()).append("px;");
|
||||
html.append(" color: ").append(element.getColor()).append("; font-size: ").append(element.getFontSize()).append(";");
|
||||
html.append(" font-family: ").append(element.getFontFamily()).append("; text-align: ").append(element.getTextAlign()).append(";");
|
||||
html.append(" overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\">");
|
||||
html.append(content);
|
||||
html.append("</div>");
|
||||
} else if ("qrcode".equals(element.getType())) {
|
||||
String encodedUrl;
|
||||
try {
|
||||
encodedUrl = java.net.URLEncoder.encode(shortUrl, java.nio.charset.StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
encodedUrl = shortUrl;
|
||||
}
|
||||
html.append("<div style=\"position: absolute; left: ").append(element.getX()).append("px; top: ").append(element.getY()).append("px;");
|
||||
html.append(" width: ").append(element.getWidth()).append("px; height: ").append(element.getHeight()).append("px;");
|
||||
html.append(" background: #fff; padding: 10px;\">");
|
||||
html.append("<img src=\"https://api.qrserver.com/v1/create-qr-code/?size=").append(element.getWidth() - 20).append("x").append(element.getHeight() - 20);
|
||||
html.append("&data=").append(encodedUrl).append("\" style=\"width: 100%; height: 100%;\">");
|
||||
html.append("</div>");
|
||||
} else if ("image".equals(element.getType())) {
|
||||
html.append("<img src=\"").append(content).append("\" style=\"position: absolute; left: ").append(element.getX()).append("px; top: ").append(element.getY());
|
||||
html.append("px; width: ").append(element.getWidth()).append("px; height: ").append(element.getHeight()).append("px;\">");
|
||||
} else if ("button".equals(element.getType())) {
|
||||
html.append("<a href=\"").append(shortUrl).append("\" style=\"position: absolute; left: ").append(element.getX()).append("px; top: ").append(element.getY());
|
||||
html.append("px; width: ").append(element.getWidth()).append("px; height: ").append(element.getHeight()).append("px; display: block;");
|
||||
if (element.getBackground() != null) {
|
||||
html.append(" background: ").append(element.getBackground()).append(";");
|
||||
}
|
||||
html.append(" border-radius: ").append(element.getBorderRadius() != null ? element.getBorderRadius() : "0").append(";\">");
|
||||
html.append("<span style=\"display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: ").append(element.getColor()).append("; font-size: ").append(element.getFontSize()).append(";\">");
|
||||
html.append(content);
|
||||
html.append("</span></a>");
|
||||
}
|
||||
}
|
||||
|
||||
html.append("</div></body></html>");
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private void drawElement(Graphics2D g, PosterConfig.PosterElement element, Activity activity, Long activityId, Long userId) {
|
||||
String content = resolveContent(element, activity, activityId, userId, "/r/abc123");
|
||||
|
||||
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
|
||||
if ("text".equals(element.getType())) {
|
||||
g.setColor(Color.decode(element.getColor()));
|
||||
Font font = new Font(element.getFontFamily(), Font.BOLD, parseFontSize(element.getFontSize()));
|
||||
g.setFont(font);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int textY = element.getY() + fm.getAscent();
|
||||
g.drawString(content, element.getX(), textY);
|
||||
} else if ("qrcode".equals(element.getType())) {
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(element.getX(), element.getY(), element.getWidth(), element.getHeight());
|
||||
} else if ("rect".equals(element.getType())) {
|
||||
g.setColor(Color.decode(element.getBackground() != null ? element.getBackground() : "#ffffff"));
|
||||
g.fillRect(element.getX(), element.getY(), element.getWidth(), element.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveContent(PosterConfig.PosterElement element, Activity activity, Long activityId, Long userId, String shortUrl) {
|
||||
String raw = element.getContent();
|
||||
if (raw == null) return "";
|
||||
|
||||
return raw.replace("{{activityName}}", activity != null ? activity.getName() : "活动")
|
||||
.replace("{{activityId}}", String.valueOf(activityId))
|
||||
.replace("{{userId}}", String.valueOf(userId))
|
||||
.replace("{{shortUrl}}", shortUrl)
|
||||
.replace("{{title}}", element.getContent());
|
||||
}
|
||||
|
||||
private Image loadImage(String urlStr) {
|
||||
return imageCache.computeIfAbsent(urlStr, k -> {
|
||||
try {
|
||||
URL url = new URL(posterConfig.getCdnBaseUrl() + "/" + urlStr);
|
||||
return ImageIO.read(url);
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to load image: {}", urlStr);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int parseFontSize(String size) {
|
||||
try {
|
||||
return Integer.parseInt(size.replace("px", ""));
|
||||
} catch (Exception e) {
|
||||
return 16;
|
||||
}
|
||||
}
|
||||
|
||||
private String escapeHtml(String text) {
|
||||
if (text == null) return "";
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
public interface RewardQueue {
|
||||
void enqueueReward(String trackingId, String externalUserId, String payloadJson);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
import com.mosquito.project.config.AppConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class ShareConfigService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ShareConfigService.class);
|
||||
|
||||
private final AppConfig appConfig;
|
||||
private final Map<String, ShareTemplate> templates = new HashMap<>();
|
||||
|
||||
public static class ShareTemplate {
|
||||
private String title;
|
||||
private String description;
|
||||
private String imageUrl;
|
||||
private String landingPageUrl;
|
||||
private Map<String, String> utmParams = new HashMap<>();
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getImageUrl() { return imageUrl; }
|
||||
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||
public String getLandingPageUrl() { return landingPageUrl; }
|
||||
public void setLandingPageUrl(String landingPageUrl) { this.landingPageUrl = landingPageUrl; }
|
||||
public Map<String, String> getUtmParams() { return utmParams; }
|
||||
public void setUtmParams(Map<String, String> utmParams) { this.utmParams = utmParams; }
|
||||
}
|
||||
|
||||
public ShareConfigService(AppConfig appConfig) {
|
||||
this.appConfig = appConfig;
|
||||
}
|
||||
|
||||
public void registerTemplate(String name, ShareTemplate template) {
|
||||
templates.put(name, template);
|
||||
log.info("Registered share template: {}", name);
|
||||
}
|
||||
|
||||
public ShareTemplate getTemplate(String name) {
|
||||
return templates.get(name);
|
||||
}
|
||||
|
||||
public String buildShareUrl(Long activityId, Long userId, String templateName, Map<String, String> extraParams) {
|
||||
ShareTemplate template = templates.get(templateName);
|
||||
if (template == null) {
|
||||
template = getDefaultTemplate(activityId);
|
||||
}
|
||||
|
||||
StringBuilder url = new StringBuilder(template.getLandingPageUrl());
|
||||
url.append("?activityId=").append(activityId);
|
||||
url.append("&inviter=").append(userId);
|
||||
|
||||
if (template.getUtmParams() != null) {
|
||||
template.getUtmParams().forEach((k, v) -> url.append("&").append(k).append("=").append(v));
|
||||
}
|
||||
|
||||
if (extraParams != null) {
|
||||
extraParams.forEach((k, v) -> {
|
||||
if (k != null && v != null) {
|
||||
url.append("&").append(k).append("=").append(java.net.URLEncoder.encode(v, java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
public Map<String, Object> getShareMeta(Long activityId, Long userId, String templateName) {
|
||||
ShareTemplate template = templates.get(templateName);
|
||||
if (template == null) {
|
||||
template = getDefaultTemplate(activityId);
|
||||
}
|
||||
|
||||
Map<String, Object> meta = new HashMap<>();
|
||||
meta.put("title", resolvePlaceholders(template.getTitle(), activityId, userId));
|
||||
meta.put("description", resolvePlaceholders(template.getDescription(), activityId, userId));
|
||||
meta.put("image", resolvePlaceholders(template.getImageUrl(), activityId, userId));
|
||||
meta.put("url", buildShareUrl(activityId, userId, templateName, null));
|
||||
return meta;
|
||||
}
|
||||
|
||||
private ShareTemplate getDefaultTemplate(Long activityId) {
|
||||
ShareTemplate defaultTemplate = new ShareTemplate();
|
||||
defaultTemplate.setTitle("邀请您参与活动");
|
||||
defaultTemplate.setDescription("快来加入我们的活动吧!");
|
||||
defaultTemplate.setImageUrl(appConfig.getShortLink().getCdnBaseUrl() + "/default-share.png");
|
||||
defaultTemplate.setLandingPageUrl(appConfig.getShortLink().getLandingBaseUrl());
|
||||
return defaultTemplate;
|
||||
}
|
||||
|
||||
private String resolvePlaceholders(String text, Long activityId, Long userId) {
|
||||
if (text == null) return "";
|
||||
return text.replace("{{activityId}}", String.valueOf(activityId))
|
||||
.replace("{{userId}}", String.valueOf(userId))
|
||||
.replace("{{timestamp}}", String.valueOf(System.currentTimeMillis()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
import com.mosquito.project.dto.ShareMetricsResponse;
|
||||
import com.mosquito.project.dto.ShareTrackingResponse;
|
||||
import com.mosquito.project.persistence.entity.LinkClickEntity;
|
||||
import com.mosquito.project.persistence.repository.ActivityRepository;
|
||||
import com.mosquito.project.persistence.repository.LinkClickRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class ShareTrackingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ShareTrackingService.class);
|
||||
|
||||
private final LinkClickRepository linkClickRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final ShareConfigService shareConfigService;
|
||||
|
||||
public ShareTrackingService(LinkClickRepository linkClickRepository, ActivityRepository activityRepository, ShareConfigService shareConfigService) {
|
||||
this.linkClickRepository = linkClickRepository;
|
||||
this.activityRepository = activityRepository;
|
||||
this.shareConfigService = shareConfigService;
|
||||
}
|
||||
|
||||
public ShareTrackingResponse createShareTracking(Long activityId, Long inviterUserId, String source, Map<String, String> params) {
|
||||
String trackingId = UUID.randomUUID().toString();
|
||||
String shortCode = generateShortCode();
|
||||
|
||||
ShareTrackingResponse response = new ShareTrackingResponse(
|
||||
trackingId,
|
||||
shortCode,
|
||||
null,
|
||||
activityId,
|
||||
inviterUserId
|
||||
);
|
||||
|
||||
log.info("Created share tracking: activityId={}, inviterUserId={}, trackingId={}, source={}",
|
||||
activityId, inviterUserId, trackingId, source);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public void recordClick(String shortCode, String ip, String userAgent, String referer, Map<String, String> params) {
|
||||
try {
|
||||
LinkClickEntity click = new LinkClickEntity();
|
||||
click.setCode(shortCode);
|
||||
click.setIp(ip);
|
||||
click.setUserAgent(userAgent);
|
||||
click.setReferer(referer);
|
||||
click.setCreatedAt(OffsetDateTime.now());
|
||||
|
||||
if (params != null) {
|
||||
click.setParams(new HashMap<>(params));
|
||||
}
|
||||
|
||||
linkClickRepository.save(click);
|
||||
log.debug("Recorded click for short code: {}", shortCode);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to record click for code {}: {}", shortCode, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ShareMetricsResponse getShareMetrics(Long activityId, OffsetDateTime startTime, OffsetDateTime endTime) {
|
||||
List<LinkClickEntity> clicks = linkClickRepository.findByActivityIdAndCreatedAtBetween(activityId, startTime, endTime);
|
||||
|
||||
ShareMetricsResponse metrics = new ShareMetricsResponse();
|
||||
metrics.setActivityId(activityId);
|
||||
metrics.setStartTime(startTime);
|
||||
metrics.setEndTime(endTime);
|
||||
|
||||
Map<String, Long> sourceCounts = new HashMap<>();
|
||||
Map<String, Long> hourlyCounts = new HashMap<>();
|
||||
long totalClicks = 0;
|
||||
|
||||
for (LinkClickEntity click : clicks) {
|
||||
totalClicks++;
|
||||
|
||||
String source = click.getParams() != null ? click.getParams().getOrDefault("source", "unknown") : "unknown";
|
||||
sourceCounts.merge(source, 1L, Long::sum);
|
||||
|
||||
String hour = click.getCreatedAt().truncatedTo(ChronoUnit.HOURS).toString();
|
||||
hourlyCounts.merge(hour, 1L, Long::sum);
|
||||
}
|
||||
|
||||
metrics.setTotalClicks(totalClicks);
|
||||
metrics.setSourceDistribution(sourceCounts);
|
||||
metrics.setHourlyDistribution(hourlyCounts);
|
||||
metrics.setUniqueVisitors(calculateUniqueVisitors(clicks));
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getTopShareLinks(Long activityId, int topN) {
|
||||
List<Object[]> results = linkClickRepository.findTopSharedLinksByActivityId(activityId, topN);
|
||||
|
||||
List<Map<String, Object>> topLinks = new ArrayList<>();
|
||||
for (Object[] row : results) {
|
||||
Map<String, Object> link = new HashMap<>();
|
||||
link.put("shortCode", row[0]);
|
||||
link.put("clickCount", row[1]);
|
||||
link.put("inviterUserId", row[2]);
|
||||
topLinks.add(link);
|
||||
}
|
||||
|
||||
return topLinks;
|
||||
}
|
||||
|
||||
public Map<String, Object> getConversionFunnel(Long activityId, OffsetDateTime startTime, OffsetDateTime endTime) {
|
||||
List<LinkClickEntity> clicks = linkClickRepository.findByActivityIdAndCreatedAtBetween(activityId, startTime, endTime);
|
||||
|
||||
Map<String, Object> funnel = new HashMap<>();
|
||||
|
||||
long totalClicks = clicks.size();
|
||||
long withReferer = clicks.stream().filter(c -> c.getReferer() != null && !c.getReferer().isBlank()).count();
|
||||
long withUserAgent = clicks.stream().filter(c -> c.getUserAgent() != null && !c.getUserAgent().isBlank()).count();
|
||||
|
||||
funnel.put("totalClicks", totalClicks);
|
||||
funnel.put("withReferer", withReferer);
|
||||
funnel.put("withUserAgent", withUserAgent);
|
||||
funnel.put("refererRate", totalClicks > 0 ? (double) withReferer / totalClicks : 0);
|
||||
|
||||
Map<String, Long> refererDomains = new HashMap<>();
|
||||
for (LinkClickEntity click : clicks) {
|
||||
if (click.getReferer() != null) {
|
||||
String domain = extractDomain(click.getReferer());
|
||||
refererDomains.merge(domain, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
funnel.put("topReferers", refererDomains);
|
||||
|
||||
return funnel;
|
||||
}
|
||||
|
||||
private long calculateUniqueVisitors(List<LinkClickEntity> clicks) {
|
||||
Set<String> uniqueIps = new HashSet<>();
|
||||
for (LinkClickEntity click : clicks) {
|
||||
if (click.getIp() != null) {
|
||||
uniqueIps.add(click.getIp());
|
||||
}
|
||||
}
|
||||
return uniqueIps.size();
|
||||
}
|
||||
|
||||
private String extractDomain(String url) {
|
||||
try {
|
||||
java.net.URL uri = new java.net.URL(url);
|
||||
return uri.getHost();
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private String generateShortCode() {
|
||||
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
StringBuilder sb = new StringBuilder(8);
|
||||
Random random = new Random();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
sb.append(chars.charAt(random.nextInt(chars.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.mosquito.project.service;
|
||||
|
||||
import com.mosquito.project.persistence.entity.ShortLinkEntity;
|
||||
import com.mosquito.project.persistence.repository.ShortLinkRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class ShortLinkService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ShortLinkService.class);
|
||||
|
||||
private final ShortLinkRepository repository;
|
||||
private static final char[] ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
|
||||
private static final int DEFAULT_CODE_LEN = 8;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public ShortLinkService(ShortLinkRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public ShortLinkEntity create(String originalUrl) {
|
||||
String code = generateUniqueCode(DEFAULT_CODE_LEN);
|
||||
ShortLinkEntity e = new ShortLinkEntity();
|
||||
e.setCode(code);
|
||||
e.setOriginalUrl(originalUrl);
|
||||
try {
|
||||
URI uri = URI.create(originalUrl);
|
||||
String query = uri.getQuery();
|
||||
if (query != null) {
|
||||
for (String p : query.split("&")) {
|
||||
String[] kv = p.split("=", 2);
|
||||
if (kv.length == 2) {
|
||||
if ("activityId".equals(kv[0])) {
|
||||
e.setActivityId(Long.parseLong(URLDecoder.decode(kv[1], StandardCharsets.UTF_8)));
|
||||
} else if ("inviter".equals(kv[0])) {
|
||||
e.setInviterUserId(Long.parseLong(URLDecoder.decode(kv[1], StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.debug("Failed to parse query params from URL: {}", ex.getMessage());
|
||||
}
|
||||
e.setCreatedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
|
||||
return repository.save(e);
|
||||
}
|
||||
|
||||
public Optional<ShortLinkEntity> findByCode(String code) {
|
||||
return repository.findByCode(code);
|
||||
}
|
||||
|
||||
private String generateUniqueCode(int len) {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
String code = randomCode(len);
|
||||
if (!repository.existsByCode(code)) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
return randomCode(len + 2);
|
||||
}
|
||||
|
||||
private String randomCode(int len) {
|
||||
StringBuilder sb = new StringBuilder(len);
|
||||
for (int i = 0; i < len; i++) {
|
||||
sb.append(ALPHABET[random.nextInt(ALPHABET.length)]);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.mosquito.project.web;
|
||||
|
||||
import com.mosquito.project.persistence.repository.ApiKeyRepository;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public class ApiKeyAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final String API_KEY_HEADER = "X-API-Key";
|
||||
private static final String API_KEY_PREFIX_ATTR = "apiKeyPrefix";
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
|
||||
public ApiKeyAuthInterceptor(ApiKeyRepository apiKeyRepository) {
|
||||
this.apiKeyRepository = apiKeyRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
String rawApiKey = request.getHeader(API_KEY_HEADER);
|
||||
if (rawApiKey == null || rawApiKey.isBlank()) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
String prefix = rawApiKey.substring(0, Math.min(12, rawApiKey.length())).trim();
|
||||
var candidateOpt = apiKeyRepository.findByKeyPrefix(prefix);
|
||||
if (candidateOpt.isEmpty()) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
var entity = candidateOpt.get();
|
||||
if (entity.getRevokedAt() != null) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
// verify hash using same PBKDF2 as service
|
||||
try {
|
||||
byte[] salt = Base64.getDecoder().decode(entity.getSalt());
|
||||
javax.crypto.SecretKeyFactory skf = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
|
||||
javax.crypto.spec.PBEKeySpec spec = new javax.crypto.spec.PBEKeySpec(rawApiKey.toCharArray(), salt, 185000, 256);
|
||||
byte[] derived = skf.generateSecret(spec).getEncoded();
|
||||
String computed = Base64.getEncoder().encodeToString(derived);
|
||||
if (!computed.equals(entity.getKeyHash())) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
request.setAttribute(API_KEY_PREFIX_ATTR, prefix);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.mosquito.project.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mosquito.project.config.ApiVersion;
|
||||
import com.mosquito.project.dto.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Component
|
||||
public class ApiResponseWrapperInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiResponseWrapperInterceptor.class);
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
request.setAttribute("startTime", System.currentTimeMillis());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
|
||||
ModelAndView modelAndView) {
|
||||
if (response.getStatus() >= 200 && response.getStatus() < 300) {
|
||||
String requestedVersion = request.getHeader(ApiVersion.HEADER_NAME);
|
||||
if (requestedVersion == null || requestedVersion.isBlank()) {
|
||||
requestedVersion = ApiVersion.DEFAULT_VERSION;
|
||||
}
|
||||
response.setHeader(ApiVersion.HEADER_NAME, requestedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
|
||||
Exception ex) {
|
||||
long duration = System.currentTimeMillis() - (Long) request.getAttribute("startTime");
|
||||
if (request.getRequestURI().startsWith("/api/")) {
|
||||
log.debug("API Request: {} {} - {}ms", request.getMethod(), request.getRequestURI(), duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/main/java/com/mosquito/project/web/RateLimitInterceptor.java
Normal file
103
src/main/java/com/mosquito/project/web/RateLimitInterceptor.java
Normal file
@@ -0,0 +1,103 @@
|
||||
package com.mosquito.project.web;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RateLimitInterceptor.class);
|
||||
|
||||
private final int perMinuteLimit;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final boolean productionMode;
|
||||
private final java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.atomic.AtomicInteger> localCounters = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
public RateLimitInterceptor(Environment env, StringRedisTemplate redisTemplate) {
|
||||
this.perMinuteLimit = Integer.parseInt(env.getProperty("app.rate-limit.per-minute", "100"));
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.productionMode = isProductionProfile(env);
|
||||
checkRedisRequirement();
|
||||
}
|
||||
|
||||
private boolean isProductionProfile(Environment env) {
|
||||
String[] activeProfiles = env.getActiveProfiles();
|
||||
for (String profile : activeProfiles) {
|
||||
if ("prod".equalsIgnoreCase(profile) || "production".equalsIgnoreCase(profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void checkRedisRequirement() {
|
||||
if (productionMode && redisTemplate == null) {
|
||||
log.error("SECURITY: Rate limiting in production mode REQUIRES Redis! " +
|
||||
"Please configure spring.redis.host in application-prod.properties");
|
||||
throw new IllegalStateException(
|
||||
"Production mode requires Redis for rate limiting. " +
|
||||
"Please set spring.redis.host in your production configuration."
|
||||
);
|
||||
}
|
||||
if (redisTemplate != null) {
|
||||
log.info("Rate limiting: Using Redis for distributed rate limiting");
|
||||
} else {
|
||||
log.warn("Rate limiting: Using local in-memory counters (not suitable for multi-instance deployment)");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
String rawApiKey = request.getHeader("X-API-Key");
|
||||
if (rawApiKey == null || rawApiKey.isBlank()) {
|
||||
response.setStatus(401);
|
||||
return false;
|
||||
}
|
||||
String prefix = rawApiKey.substring(0, Math.min(12, rawApiKey.length())).trim();
|
||||
String minuteKey = DateTimeFormatter.ofPattern("yyyyMMddHHmm").withZone(ZoneOffset.UTC).format(Instant.now());
|
||||
String key = "rl:" + prefix + ":" + minuteKey;
|
||||
long count;
|
||||
|
||||
if (redisTemplate != null) {
|
||||
try {
|
||||
Long val = redisTemplate.opsForValue().increment(key);
|
||||
if (val != null && val == 1L) {
|
||||
redisTemplate.expire(key, java.time.Duration.ofMinutes(1));
|
||||
}
|
||||
count = val == null ? 1 : val;
|
||||
} catch (Exception e) {
|
||||
log.error("Redis rate limit error, falling back to deny: {}", e.getMessage());
|
||||
response.setStatus(503);
|
||||
response.setHeader("Retry-After", "5");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (productionMode) {
|
||||
log.error("Redis required in production but not available");
|
||||
response.setStatus(503);
|
||||
return false;
|
||||
}
|
||||
var counter = localCounters.computeIfAbsent(key, k -> new java.util.concurrent.atomic.AtomicInteger(0));
|
||||
count = counter.incrementAndGet();
|
||||
}
|
||||
if (count > perMinuteLimit) {
|
||||
response.setStatus(429);
|
||||
response.setHeader("Retry-After", "60");
|
||||
response.setHeader("X-RateLimit-Limit", String.valueOf(perMinuteLimit));
|
||||
response.setHeader("X-RateLimit-Remaining", "0");
|
||||
return false;
|
||||
}
|
||||
response.setHeader("X-RateLimit-Limit", String.valueOf(perMinuteLimit));
|
||||
response.setHeader("X-RateLimit-Remaining", String.valueOf(Math.max(0, perMinuteLimit - count)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
150
src/main/java/com/mosquito/project/web/UrlValidator.java
Normal file
150
src/main/java/com/mosquito/project/web/UrlValidator.java
Normal file
@@ -0,0 +1,150 @@
|
||||
package com.mosquito.project.web;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
@Component
|
||||
public class UrlValidator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UrlValidator.class);
|
||||
|
||||
private static final String[] ALLOWED_SCHEMES = {"http", "https"};
|
||||
|
||||
public boolean isAllowedUrl(String url) {
|
||||
if (url == null || url.isBlank()) {
|
||||
log.warn("URL is null or blank");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
|
||||
if (!uri.isAbsolute()) {
|
||||
log.warn("URL is not absolute: {}", url);
|
||||
return false;
|
||||
}
|
||||
|
||||
String scheme = uri.getScheme().toLowerCase();
|
||||
if (!isAllowedScheme(scheme)) {
|
||||
log.warn("URL scheme '{}' is not allowed: {}", scheme, url);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isAllowedHost(uri.getHost())) {
|
||||
log.warn("URL host is not allowed: {}", uri.getHost());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (URISyntaxException e) {
|
||||
log.warn("Invalid URL syntax: {} - {}", url, e.getMessage());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.error("Error validating URL: {} - {}", url, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAllowedScheme(String scheme) {
|
||||
for (String allowed : ALLOWED_SCHEMES) {
|
||||
if (allowed.equals(scheme)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isAllowedHost(String host) {
|
||||
if (host == null || host.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
InetAddress address = InetAddress.getByName(host);
|
||||
|
||||
if (address.isLoopbackAddress()) {
|
||||
log.warn("Host is loopback address: {}", host);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.isSiteLocalAddress()) {
|
||||
log.warn("Host is site-local (internal) address: {}", host);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.isAnyLocalAddress()) {
|
||||
log.warn("Host is any-local address: {}", host);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.isLinkLocalAddress()) {
|
||||
log.warn("Host is link-local address: {}", host);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.isMulticastAddress()) {
|
||||
log.warn("Host is multicast address: {}", host);
|
||||
return false;
|
||||
}
|
||||
|
||||
String hostLower = host.toLowerCase();
|
||||
if (hostLower.equals("localhost") || hostLower.equals("127.0.0.1") ||
|
||||
hostLower.equals("::1") || hostLower.equals("0.0.0.0")) {
|
||||
log.warn("Host is localhost variant: {}", host);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPrivateIpRange(address)) {
|
||||
log.warn("Host is in private IP range: {}", host);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("Error resolving host: {} - {}", host, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPrivateIpRange(InetAddress address) {
|
||||
byte[] addr = address.getAddress();
|
||||
|
||||
if (addr.length != 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int first = addr[0] & 0xFF;
|
||||
int second = addr[1] & 0xFF;
|
||||
|
||||
if (first == 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first == 172 && second >= 16 && second <= 31) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first == 192 && second == 168) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public String sanitizeUrl(String url) {
|
||||
if (!isAllowedUrl(url)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
return uri.toString();
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.mosquito.project.web;
|
||||
|
||||
import com.mosquito.project.security.IntrospectionResponse;
|
||||
import com.mosquito.project.security.UserIntrospectionService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
public class UserAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final UserIntrospectionService introspectionService;
|
||||
|
||||
public UserAuthInterceptor(UserIntrospectionService introspectionService) {
|
||||
this.introspectionService = introspectionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authorization == null || authorization.isBlank()) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
|
||||
IntrospectionResponse result = introspectionService.introspect(authorization);
|
||||
if (!result.isActive()) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
|
||||
request.setAttribute("userId", result.getUserId());
|
||||
request.setAttribute("tenantId", result.getTenantId());
|
||||
request.setAttribute("roles", result.getRoles());
|
||||
request.setAttribute("scopes", result.getScopes());
|
||||
request.setAttribute("tokenId", result.getJti());
|
||||
request.setAttribute("tokenExp", result.getExp());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user