Phase 3: 管理后台前端 (Vue3+Vite+Element Plus) - 登录页面/JWT认证/Pinia状态管理 - 首页大盘/站点管理/验证码日志/IP黑名单/套餐管理/系统设置 - 7个页面完整路由+API对接 Phase 4: 统计监控模块 - CaptchaLogAspect AOP自动记录 - RealtimeStatsService Redis实时计数 - HistoryStatsService 历史查询 - AnomalyDetectionService 异常检测 Phase 5: 部署和测试 - docker-compose.yml 5服务编排 - application-prod.yml 外部化配置 - CaptchaApiIntegrationTest 集成测试 - 前端 Dockerfile + nginx.conf
This commit is contained in:
+100
@@ -0,0 +1,100 @@
|
||||
package cloud.tianai.captcha.platform.aspect;
|
||||
|
||||
import cloud.tianai.captcha.platform.entity.CaptchaLog;
|
||||
import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository;
|
||||
import cloud.tianai.captcha.platform.service.RealtimeStatsService;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class CaptchaLogAspect {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CaptchaLogAspect.class);
|
||||
private final CaptchaLogRepository logRepository;
|
||||
private final RealtimeStatsService statsService;
|
||||
|
||||
public CaptchaLogAspect(CaptchaLogRepository logRepository, RealtimeStatsService statsService) {
|
||||
this.logRepository = logRepository;
|
||||
this.statsService = statsService;
|
||||
}
|
||||
|
||||
@Around("execution(* cloud.tianai.captcha.platform.controller.CaptchaApiController.*(..))")
|
||||
public Object logCaptchaRequest(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
long startTime = System.currentTimeMillis();
|
||||
Object result = joinPoint.proceed();
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
try {
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
String methodName = signature.getName();
|
||||
|
||||
String ip = extractIp();
|
||||
String siteKey = extractSiteKey(joinPoint.getArgs());
|
||||
|
||||
Map<String, Object> stats = new ConcurrentHashMap<>();
|
||||
stats.put("method", methodName);
|
||||
stats.put("duration", duration);
|
||||
stats.put("ip", ip);
|
||||
stats.put("siteKey", siteKey);
|
||||
|
||||
if ("verify".equals(methodName) && result instanceof cloud.tianai.captcha.common.response.ApiResponse<?> response) {
|
||||
stats.put("success", response.isSuccess());
|
||||
stats.put("captchaType", "VERIFY");
|
||||
statsService.recordVerifyAttempt(response.isSuccess(), ip, siteKey);
|
||||
} else if ("generate".equals(methodName)) {
|
||||
stats.put("success", true);
|
||||
stats.put("captchaType", "GENERATE");
|
||||
statsService.recordGenerate(ip, siteKey);
|
||||
}
|
||||
|
||||
statsService.incrementTotalRequests();
|
||||
|
||||
log.debug("[CAPTCHA-AOP] {} took {}ms ip={}", methodName, duration, ip);
|
||||
} catch (Exception e) {
|
||||
log.warn("[CAPTCHA-AOP] Failed to log stats: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private String extractIp() {
|
||||
try {
|
||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs != null) {
|
||||
HttpServletRequest request = attrs.getRequest();
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isEmpty()) {
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
String xreal = request.getHeader("X-Real-IP");
|
||||
if (xreal != null && !xreal.isEmpty()) {
|
||||
return xreal;
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private String extractSiteKey(Object[] args) {
|
||||
for (Object arg : args) {
|
||||
if (arg instanceof String s && s != null && s.length() > 10) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package cloud.tianai.captcha.platform.config;
|
||||
|
||||
import cloud.tianai.captcha.platform.service.JwtService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService) {
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
try {
|
||||
if (jwtService.validateToken(token)) {
|
||||
String username = jwtService.getUsernameFromToken(token);
|
||||
Integer userId = jwtService.getUserIdFromToken(token);
|
||||
String role = jwtService.getRoleFromToken(token);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
userId,
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role))
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package cloud.tianai.captcha.platform.config;
|
||||
|
||||
import cloud.tianai.captcha.platform.service.JwtService;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
public SecurityConfig(JwtService jwtService) {
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
return new JwtAuthenticationFilter(jwtService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/api/captcha/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("*"));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package cloud.tianai.captcha.platform.controller;
|
||||
|
||||
import cloud.tianai.captcha.common.response.ApiResponse;
|
||||
import cloud.tianai.captcha.platform.entity.User;
|
||||
import cloud.tianai.captcha.platform.mapper.UserRepository;
|
||||
import cloud.tianai.captcha.platform.service.JwtService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final JwtService jwtService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AuthController(UserRepository userRepository, JwtService jwtService, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.jwtService = jwtService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<?> login(@RequestBody Map<String, String> body) {
|
||||
String username = body.get("username");
|
||||
String password = body.get("password");
|
||||
|
||||
if (username == null || password == null) {
|
||||
return ApiResponse.of(400, "missing_params", null);
|
||||
}
|
||||
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
return ApiResponse.of(401, "invalid_credentials", null);
|
||||
}
|
||||
|
||||
if (!user.getIsEnabled()) {
|
||||
return ApiResponse.of(403, "account_disabled", null);
|
||||
}
|
||||
|
||||
String token = jwtService.generateToken(user.getId(), user.getUsername(), user.getRole());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("token", token);
|
||||
data.put("username", user.getUsername());
|
||||
data.put("role", user.getRole());
|
||||
return ApiResponse.ofSuccess(data);
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<?> register(@RequestBody Map<String, String> body) {
|
||||
String username = body.get("username");
|
||||
String password = body.get("password");
|
||||
String email = body.get("email");
|
||||
|
||||
if (username == null || password == null) {
|
||||
return ApiResponse.of(400, "missing_params", null);
|
||||
}
|
||||
|
||||
if (userRepository.existsByUsername(username)) {
|
||||
return ApiResponse.of(409, "username_exists", null);
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setPasswordHash(passwordEncoder.encode(password));
|
||||
user.setEmail(email);
|
||||
user.setRole("USER");
|
||||
userRepository.save(user);
|
||||
|
||||
return ApiResponse.ofSuccess("Registration successful");
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<?> getCurrentUser(@RequestHeader("Authorization") String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
return ApiResponse.of(401, "unauthorized", null);
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
if (!jwtService.validateToken(token)) {
|
||||
return ApiResponse.of(401, "invalid_token", null);
|
||||
}
|
||||
|
||||
Integer userId = jwtService.getUserIdFromToken(token);
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
return ApiResponse.of(404, "user_not_found", null);
|
||||
}
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("id", user.getId());
|
||||
data.put("username", user.getUsername());
|
||||
data.put("email", user.getEmail());
|
||||
data.put("role", user.getRole());
|
||||
return ApiResponse.ofSuccess(data);
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package cloud.tianai.captcha.platform.controller;
|
||||
|
||||
import cloud.tianai.captcha.common.response.ApiResponse;
|
||||
import cloud.tianai.captcha.platform.entity.Plan;
|
||||
import cloud.tianai.captcha.platform.mapper.PlanRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/plans")
|
||||
public class PlanController {
|
||||
|
||||
private final PlanRepository planRepository;
|
||||
|
||||
public PlanController(PlanRepository planRepository) {
|
||||
this.planRepository = planRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<Plan>> listPlans() {
|
||||
return ApiResponse.ofSuccess(planRepository.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/active")
|
||||
public ApiResponse<List<Plan>> listActivePlans() {
|
||||
return ApiResponse.ofSuccess(planRepository.findByIsActiveTrue());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<Plan> getPlan(@PathVariable Integer id) {
|
||||
Plan plan = planRepository.findById(id).orElse(null);
|
||||
if (plan == null) {
|
||||
return ApiResponse.of(404, "plan_not_found", null);
|
||||
}
|
||||
return ApiResponse.ofSuccess(plan);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Plan> createPlan(@RequestBody Plan plan) {
|
||||
plan.setId(null);
|
||||
return ApiResponse.ofSuccess(planRepository.save(plan));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<Plan> updatePlan(@PathVariable Integer id, @RequestBody Plan plan) {
|
||||
Plan existing = planRepository.findById(id).orElse(null);
|
||||
if (existing == null) {
|
||||
return ApiResponse.of(404, "plan_not_found", null);
|
||||
}
|
||||
plan.setId(id);
|
||||
return ApiResponse.ofSuccess(planRepository.save(plan));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<?> deletePlan(@PathVariable Integer id) {
|
||||
planRepository.deleteById(id);
|
||||
return ApiResponse.ofSuccess();
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package cloud.tianai.captcha.platform.controller;
|
||||
|
||||
import cloud.tianai.captcha.common.response.ApiResponse;
|
||||
import cloud.tianai.captcha.platform.service.MinioStorageService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/resources")
|
||||
public class ResourceController {
|
||||
|
||||
private final MinioStorageService storageService;
|
||||
|
||||
public ResourceController(MinioStorageService storageService) {
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
public ApiResponse<?> uploadFile(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "prefix", defaultValue = "general") String prefix) {
|
||||
try {
|
||||
String url = storageService.uploadFile(file, prefix);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("url", url);
|
||||
data.put("filename", file.getOriginalFilename());
|
||||
data.put("size", file.getSize());
|
||||
return ApiResponse.ofSuccess(data);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.of(500, "upload_failed", null);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
public ApiResponse<?> deleteFile(@RequestParam String objectName) {
|
||||
try {
|
||||
storageService.deleteFile(objectName);
|
||||
return ApiResponse.ofSuccess();
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.of(500, "delete_failed", null);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/presigned-url")
|
||||
public ApiResponse<?> getPresignedUrl(
|
||||
@RequestParam String objectName,
|
||||
@RequestParam(defaultValue = "3600") int expirySeconds) {
|
||||
try {
|
||||
String url = storageService.getPresignedUrl(objectName, expirySeconds);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("url", url);
|
||||
return ApiResponse.ofSuccess(data);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.of(500, "url_generation_failed", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cloud.tianai.captcha.platform.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "plans")
|
||||
public class Plan {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(length = 64, nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(length = 256)
|
||||
private String description;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal price;
|
||||
|
||||
@Column(name = "daily_quota", nullable = false)
|
||||
private Integer dailyQuota;
|
||||
|
||||
@Column(name = "qps_limit", nullable = false)
|
||||
private Integer qpsLimit;
|
||||
|
||||
@Column(name = "site_limit", nullable = false)
|
||||
private Integer siteLimit;
|
||||
|
||||
@Column(name = "features", columnDefinition = "TEXT[]")
|
||||
private java.util.Set<String> features;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean isActive = true;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = OffsetDateTime.now();
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
public Integer getId() { return id; }
|
||||
public void setId(Integer id) { this.id = id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public BigDecimal getPrice() { return price; }
|
||||
public void setPrice(BigDecimal price) { this.price = price; }
|
||||
public Integer getDailyQuota() { return dailyQuota; }
|
||||
public void setDailyQuota(Integer dailyQuota) { this.dailyQuota = dailyQuota; }
|
||||
public Integer getQpsLimit() { return qpsLimit; }
|
||||
public void setQpsLimit(Integer qpsLimit) { this.qpsLimit = qpsLimit; }
|
||||
public Integer getSiteLimit() { return siteLimit; }
|
||||
public void setSiteLimit(Integer siteLimit) { this.siteLimit = siteLimit; }
|
||||
public java.util.Set<String> getFeatures() { return features; }
|
||||
public void setFeatures(java.util.Set<String> features) { this.features = features; }
|
||||
public Boolean getIsActive() { return isActive; }
|
||||
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
|
||||
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,62 @@
|
||||
package cloud.tianai.captcha.platform.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(length = 64, unique = true, nullable = false)
|
||||
private String username;
|
||||
|
||||
@Column(length = 256, nullable = false)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(length = 128)
|
||||
private String email;
|
||||
|
||||
@Column(length = 32)
|
||||
private String role = "USER";
|
||||
|
||||
@Column(name = "is_enabled")
|
||||
private Boolean isEnabled = true;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = OffsetDateTime.now();
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
public Integer getId() { return id; }
|
||||
public void setId(Integer id) { this.id = id; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPasswordHash() { return passwordHash; }
|
||||
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
public Boolean getIsEnabled() { return isEnabled; }
|
||||
public void setIsEnabled(Boolean isEnabled) { this.isEnabled = isEnabled; }
|
||||
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; }
|
||||
}
|
||||
+24
@@ -2,7 +2,31 @@ package cloud.tianai.captcha.platform.mapper;
|
||||
|
||||
import cloud.tianai.captcha.platform.entity.CaptchaLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface CaptchaLogRepository extends JpaRepository<CaptchaLog, Integer> {
|
||||
long countByIsPass(Boolean isPass);
|
||||
|
||||
long countBySiteIdAndCreatedAtBetween(Integer siteId, OffsetDateTime start, OffsetDateTime end);
|
||||
|
||||
long countBySiteIdAndIsPassAndCreatedAtBetween(Integer siteId, Boolean isPass, OffsetDateTime start, OffsetDateTime end);
|
||||
|
||||
long countBySiteIdAndCaptchaTypeAndCreatedAtBetween(Integer siteId, String captchaType, OffsetDateTime start, OffsetDateTime end);
|
||||
|
||||
long countByIpAndCreatedAtBetween(String ip, OffsetDateTime start, OffsetDateTime end);
|
||||
|
||||
@Query("SELECT l.captchaType, COUNT(l) FROM CaptchaLog l WHERE l.siteId = :siteId AND l.createdAt BETWEEN :start AND :end GROUP BY l.captchaType")
|
||||
List<Object[]> countByTypeGrouped(@Param("siteId") Integer siteId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end);
|
||||
|
||||
@Query("SELECT l.ip, COUNT(l) FROM CaptchaLog l WHERE l.createdAt BETWEEN :start AND :end GROUP BY l.ip ORDER BY COUNT(l) DESC")
|
||||
List<Object[]> topIpStats(@Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end);
|
||||
|
||||
@Query("SELECT FUNCTION('DATE', l.createdAt), COUNT(l), SUM(CASE WHEN l.isPass = true THEN 1 ELSE 0 END) FROM CaptchaLog l WHERE l.siteId = :siteId AND l.createdAt BETWEEN :start AND :end GROUP BY FUNCTION('DATE', l.createdAt) ORDER BY FUNCTION('DATE', l.createdAt)")
|
||||
List<Object[]> dailyStats(@Param("siteId") Integer siteId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end);
|
||||
|
||||
List<CaptchaLog> findTop100BySiteIdOrderByCreatedAtDesc(Integer siteId);
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package cloud.tianai.captcha.platform.mapper;
|
||||
|
||||
import cloud.tianai.captcha.platform.entity.Plan;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PlanRepository extends JpaRepository<Plan, Integer> {
|
||||
List<Plan> findByIsActiveTrue();
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package cloud.tianai.captcha.platform.mapper;
|
||||
|
||||
import cloud.tianai.captcha.platform.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Integer> {
|
||||
Optional<User> findByUsername(String username);
|
||||
Boolean existsByUsername(String username);
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package cloud.tianai.captcha.platform.service;
|
||||
|
||||
import cloud.tianai.captcha.platform.entity.CaptchaLog;
|
||||
import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class AnomalyDetectionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AnomalyDetectionService.class);
|
||||
private static final double FAIL_RATE_THRESHOLD = 0.7;
|
||||
private static final int IP_FREQ_THRESHOLD = 100;
|
||||
|
||||
private final CaptchaLogRepository logRepository;
|
||||
private final Map<String, AnomalyAlert> activeAlerts = new ConcurrentHashMap<>();
|
||||
|
||||
public AnomalyDetectionService(CaptchaLogRepository logRepository) {
|
||||
this.logRepository = logRepository;
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void detectAnomalies() {
|
||||
try {
|
||||
OffsetDateTime oneHourAgo = OffsetDateTime.now(ZoneOffset.UTC).minusHours(1);
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
|
||||
List<Object[]> topIps = logRepository.topIpStats(oneHourAgo, now);
|
||||
for (Object[] row : topIps) {
|
||||
String ip = (String) row[0];
|
||||
long count = ((Number) row[1]).longValue();
|
||||
if (count > IP_FREQ_THRESHOLD) {
|
||||
triggerAlert("IP_FREQ", ip, "High request frequency: " + count + " requests/hour");
|
||||
}
|
||||
}
|
||||
|
||||
List<Object[]> typeStats = logRepository.countByTypeGrouped(null, oneHourAgo, now);
|
||||
for (Object[] row : typeStats) {
|
||||
String type = (String) row[0];
|
||||
long total = ((Number) row[1]).longValue();
|
||||
if (total > 100) {
|
||||
long fails = logRepository.countByIsPass(false);
|
||||
double failRate = (double) fails / total;
|
||||
if (failRate > FAIL_RATE_THRESHOLD) {
|
||||
triggerAlert("HIGH_FAIL_RATE", type, "Fail rate: " + String.format("%.1f%%", failRate * 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("[ANOMALY] Detection failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void triggerAlert(String type, String target, String message) {
|
||||
String alertKey = type + ":" + target;
|
||||
if (!activeAlerts.containsKey(alertKey)) {
|
||||
AnomalyAlert alert = new AnomalyAlert(type, target, message);
|
||||
activeAlerts.put(alertKey, alert);
|
||||
log.warn("[ANOMALY] Alert triggered: {} - {} - {}", type, target, message);
|
||||
}
|
||||
}
|
||||
|
||||
public List<AnomalyAlert> getActiveAlerts() {
|
||||
return new ArrayList<>(activeAlerts.values());
|
||||
}
|
||||
|
||||
public void clearAlert(String alertKey) {
|
||||
activeAlerts.remove(alertKey);
|
||||
}
|
||||
|
||||
public static class AnomalyAlert {
|
||||
private final String type;
|
||||
private final String target;
|
||||
private final String message;
|
||||
private final long timestamp;
|
||||
|
||||
public AnomalyAlert(String type, String target, String message) {
|
||||
this.type = type;
|
||||
this.target = target;
|
||||
this.message = message;
|
||||
this.timestamp = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public String getType() { return type; }
|
||||
public String getTarget() { return target; }
|
||||
public String getMessage() { return message; }
|
||||
public long getTimestamp() { return timestamp; }
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package cloud.tianai.captcha.platform.service;
|
||||
|
||||
import cloud.tianai.captcha.platform.entity.CaptchaLog;
|
||||
import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class HistoryStatsService {
|
||||
|
||||
private final CaptchaLogRepository logRepository;
|
||||
|
||||
public HistoryStatsService(CaptchaLogRepository logRepository) {
|
||||
this.logRepository = logRepository;
|
||||
}
|
||||
|
||||
public Map<String, Object> getDailyStats(Integer siteId, int days) {
|
||||
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
|
||||
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
|
||||
List<Object[]> rows = logRepository.dailyStats(siteId, start, end);
|
||||
|
||||
List<String> dates = new ArrayList<>();
|
||||
List<Long> totals = new ArrayList<>();
|
||||
List<Long> successes = new ArrayList<>();
|
||||
|
||||
for (Object[] row : rows) {
|
||||
dates.add(String.valueOf(row[0]));
|
||||
totals.add(((Number) row[1]).longValue());
|
||||
successes.add(((Number) row[2]).longValue());
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("dates", dates);
|
||||
result.put("totals", totals);
|
||||
result.put("successes", successes);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getTypeDistribution(Integer siteId, int days) {
|
||||
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
|
||||
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
|
||||
List<Object[]> rows = logRepository.countByTypeGrouped(siteId, start, end);
|
||||
|
||||
Map<String, Long> distribution = new LinkedHashMap<>();
|
||||
long total = 0;
|
||||
for (Object[] row : rows) {
|
||||
String type = (String) row[0];
|
||||
long count = ((Number) row[1]).longValue();
|
||||
distribution.put(type, count);
|
||||
total += count;
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("distribution", distribution);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getTopIps(int days, int limit) {
|
||||
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
|
||||
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
|
||||
List<Object[]> rows = logRepository.topIpStats(start, end);
|
||||
|
||||
return rows.stream()
|
||||
.limit(limit)
|
||||
.map(row -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("ip", row[0]);
|
||||
item.put("count", ((Number) row[1]).longValue());
|
||||
return item;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Map<String, Object> getHourlyDistribution(Integer siteId, int days) {
|
||||
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
|
||||
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
|
||||
List<CaptchaLog> logs = logRepository.findTop100BySiteIdOrderByCreatedAtDesc(siteId);
|
||||
|
||||
Map<Integer, Long> hourly = logs.stream()
|
||||
.filter(l -> l.getCreatedAt() != null && l.getCreatedAt().isAfter(start))
|
||||
.collect(Collectors.groupingBy(
|
||||
l -> l.getCreatedAt().getHour(),
|
||||
Collectors.counting()
|
||||
));
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("hours", hourly);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package cloud.tianai.captcha.platform.service;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
|
||||
@Value("${jwt.secret:tianai-captcha-secret-key-must-be-at-least-256-bits-long!!}")
|
||||
private String secret;
|
||||
|
||||
@Value("${jwt.expiration:86400000}")
|
||||
private long expiration;
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String generateToken(Integer userId, String username, String role) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("userId", userId);
|
||||
claims.put("username", username);
|
||||
claims.put("role", role);
|
||||
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
.setSubject(username)
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + expiration))
|
||||
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSigningKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getBody();
|
||||
}
|
||||
|
||||
public Boolean validateToken(String token) {
|
||||
try {
|
||||
Claims claims = parseToken(token);
|
||||
return !claims.getExpiration().before(new Date());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public String getUsernameFromToken(String token) {
|
||||
return parseToken(token).getSubject();
|
||||
}
|
||||
|
||||
public Integer getUserIdFromToken(String token) {
|
||||
return (Integer) parseToken(token).get("userId");
|
||||
}
|
||||
|
||||
public String getRoleFromToken(String token) {
|
||||
return (String) parseToken(token).get("role");
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package cloud.tianai.captcha.platform.service;
|
||||
|
||||
import io.minio.*;
|
||||
import io.minio.http.Method;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.io.InputStream;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class MinioStorageService {
|
||||
|
||||
@Value("${minio.endpoint:http://localhost:9000}")
|
||||
private String endpoint;
|
||||
|
||||
@Value("${minio.access-key:minioadmin}")
|
||||
private String accessKey;
|
||||
|
||||
@Value("${minio.secret-key:minioadmin}")
|
||||
private String secretKey;
|
||||
|
||||
@Value("${minio.bucket:captcha-resources}")
|
||||
private String bucket;
|
||||
|
||||
private MinioClient minioClient;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
minioClient = MinioClient.builder()
|
||||
.endpoint(endpoint)
|
||||
.credentials(accessKey, secretKey)
|
||||
.build();
|
||||
|
||||
// 确保bucket存在
|
||||
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
|
||||
if (!exists) {
|
||||
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("Failed to initialize MinIO client: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public String uploadFile(MultipartFile file, String prefix) {
|
||||
try {
|
||||
String objectName = prefix + "/" + UUID.randomUUID().toString() + getExtension(file.getOriginalFilename());
|
||||
|
||||
minioClient.putObject(PutObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(objectName)
|
||||
.stream(file.getInputStream(), file.getSize(), -1)
|
||||
.contentType(file.getContentType())
|
||||
.build());
|
||||
|
||||
return endpoint + "/" + bucket + "/" + objectName;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to upload file to MinIO", e);
|
||||
}
|
||||
}
|
||||
|
||||
public InputStream downloadFile(String objectName) {
|
||||
try {
|
||||
return minioClient.getObject(GetObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(objectName)
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to download file from MinIO", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteFile(String objectName) {
|
||||
try {
|
||||
minioClient.removeObject(RemoveObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(objectName)
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to delete file from MinIO", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getPresignedUrl(String objectName, int expirySeconds) {
|
||||
try {
|
||||
return minioClient.getPresignedObjectUrl(
|
||||
GetPresignedObjectUrlArgs.builder()
|
||||
.method(Method.GET)
|
||||
.bucket(bucket)
|
||||
.object(objectName)
|
||||
.expiry(expirySeconds)
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate presigned URL", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String getExtension(String filename) {
|
||||
if (filename == null) return ".bin";
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
return dotIndex >= 0 ? filename.substring(dotIndex) : ".bin";
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package cloud.tianai.captcha.platform.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Service
|
||||
public class RealtimeStatsService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RealtimeStatsService.class);
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final AtomicLong totalRequests = new AtomicLong(0);
|
||||
private final AtomicLong totalGenerate = new AtomicLong(0);
|
||||
private final AtomicLong totalVerify = new AtomicLong(0);
|
||||
private final AtomicLong totalSuccess = new AtomicLong(0);
|
||||
private final AtomicLong totalFail = new AtomicLong(0);
|
||||
private final Map<String, AtomicLong> ipRequestCounts = new ConcurrentHashMap<>();
|
||||
private final Map<String, AtomicLong> siteRequestCounts = new ConcurrentHashMap<>();
|
||||
|
||||
public RealtimeStatsService(StringRedisTemplate redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
public void incrementTotalRequests() {
|
||||
totalRequests.incrementAndGet();
|
||||
incrementRedis("stats:total:" + todayKey());
|
||||
}
|
||||
|
||||
public void recordGenerate(String ip, String siteKey) {
|
||||
totalGenerate.incrementAndGet();
|
||||
incrementRedis("stats:generate:" + todayKey());
|
||||
incrementIpCount(ip);
|
||||
incrementSiteCount(siteKey);
|
||||
}
|
||||
|
||||
public void recordVerifyAttempt(boolean success, String ip, String siteKey) {
|
||||
if (success) {
|
||||
totalSuccess.incrementAndGet();
|
||||
incrementRedis("stats:success:" + todayKey());
|
||||
} else {
|
||||
totalFail.incrementAndGet();
|
||||
incrementRedis("stats:fail:" + todayKey());
|
||||
}
|
||||
totalVerify.incrementAndGet();
|
||||
incrementRedis("stats:verify:" + todayKey());
|
||||
incrementIpCount(ip);
|
||||
incrementSiteCount(siteKey);
|
||||
}
|
||||
|
||||
public Map<String, Object> getRealtimeStats() {
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
String key = todayKey();
|
||||
stats.put("date", LocalDate.now().toString());
|
||||
stats.put("totalRequests", getTotalRedis("stats:total:" + key));
|
||||
stats.put("generateCount", getTotalRedis("stats:generate:" + key));
|
||||
stats.put("verifyCount", getTotalRedis("stats:verify:" + key));
|
||||
stats.put("successCount", getTotalRedis("stats:success:" + key));
|
||||
stats.put("failCount", getTotalRedis("stats:fail:" + key));
|
||||
long total = getTotalRedis("stats:verify:" + key);
|
||||
long success = getTotalRedis("stats:success:" + key);
|
||||
stats.put("passRate", total > 0 ? String.format("%.2f%%", success * 100.0 / total) : "0%");
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getDashboardStats(Integer siteId, int days) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("realtime", getRealtimeStats());
|
||||
|
||||
Map<String, Long> dailyTotals = new LinkedHashMap<>();
|
||||
Map<String, Long> dailySuccess = new LinkedHashMap<>();
|
||||
for (int i = days - 1; i >= 0; i--) {
|
||||
String dayKey = LocalDate.now().minusDays(i).format(DATE_FMT);
|
||||
dailyTotals.put(dayKey, getTotalRedis("stats:verify:" + dayKey));
|
||||
dailySuccess.put(dayKey, getTotalRedis("stats:success:" + dayKey));
|
||||
}
|
||||
result.put("dailyTotals", dailyTotals);
|
||||
result.put("dailySuccess", dailySuccess);
|
||||
|
||||
List<Map<String, Object>> topIps = new ArrayList<>();
|
||||
ipRequestCounts.entrySet().stream()
|
||||
.sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get()))
|
||||
.limit(10)
|
||||
.forEach(e -> {
|
||||
Map<String, Object> ipStat = new LinkedHashMap<>();
|
||||
ipStat.put("ip", e.getKey());
|
||||
ipStat.put("count", e.getValue().get());
|
||||
topIps.add(ipStat);
|
||||
});
|
||||
result.put("topIps", topIps);
|
||||
|
||||
List<Map<String, Object>> topSites = new ArrayList<>();
|
||||
siteRequestCounts.entrySet().stream()
|
||||
.sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get()))
|
||||
.limit(10)
|
||||
.forEach(e -> {
|
||||
Map<String, Object> siteStat = new LinkedHashMap<>();
|
||||
siteStat.put("siteKey", e.getKey());
|
||||
siteStat.put("count", e.getValue().get());
|
||||
topSites.add(siteStat);
|
||||
});
|
||||
result.put("topSites", topSites);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void incrementRedis(String key) {
|
||||
try {
|
||||
redisTemplate.opsForValue().increment(key);
|
||||
redisTemplate.expire(key, java.time.Duration.ofDays(35));
|
||||
} catch (Exception e) {
|
||||
log.debug("Redis increment failed for {}: {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private long getTotalRedis(String key) {
|
||||
try {
|
||||
String val = redisTemplate.opsForValue().get(key);
|
||||
return val != null ? Long.parseLong(val) : 0;
|
||||
} catch (Exception e) {
|
||||
log.debug("Redis get failed for {}: {}", key, e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void incrementIpCount(String ip) {
|
||||
if (ip != null) {
|
||||
ipRequestCounts.computeIfAbsent(ip, k -> new AtomicLong(0)).incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private void incrementSiteCount(String siteKey) {
|
||||
if (siteKey != null) {
|
||||
siteRequestCounts.computeIfAbsent(siteKey, k -> new AtomicLong(0)).incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private String todayKey() {
|
||||
return LocalDate.now().format(DATE_FMT);
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 300000)
|
||||
public void syncToRedis() {
|
||||
try {
|
||||
String key = todayKey();
|
||||
redisTemplate.opsForValue().set("stats:total:" + key, String.valueOf(totalRequests.get()));
|
||||
redisTemplate.opsForValue().set("stats:generate:" + key, String.valueOf(totalGenerate.get()));
|
||||
redisTemplate.opsForValue().set("stats:verify:" + key, String.valueOf(totalVerify.get()));
|
||||
redisTemplate.opsForValue().set("stats:success:" + key, String.valueOf(totalSuccess.get()));
|
||||
redisTemplate.opsForValue().set("stats:fail:" + key, String.valueOf(totalFail.get()));
|
||||
log.debug("[STATS] Synced to Redis: total={}", totalRequests.get());
|
||||
} catch (Exception e) {
|
||||
log.warn("[STATS] Failed to sync to Redis: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
server:
|
||||
port: 18200
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:captcha_forge}
|
||||
username: ${DB_USER:pgsql}
|
||||
password: ${DB_PASS:}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASS:}
|
||||
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: Asia/Shanghai
|
||||
|
||||
captcha:
|
||||
prefix: captcha
|
||||
expire:
|
||||
default: 120000
|
||||
init-default-resource: true
|
||||
local-cache-enabled: true
|
||||
local-cache-size: 20
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:tianai-captcha-jwt-secret-key-must-be-at-least-256-bits-long-for-hs256}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
minio:
|
||||
endpoint: ${MINIO_ENDPOINT:http://localhost:9000}
|
||||
access-key: ${MINIO_ACCESS_KEY:minioadmin}
|
||||
secret-key: ${MINIO_SECRET_KEY:minioadmin}
|
||||
bucket: ${MINIO_BUCKET:captcha-resources}
|
||||
|
||||
logging:
|
||||
level:
|
||||
cloud.tianai.captcha: INFO
|
||||
root: WARN
|
||||
@@ -31,6 +31,16 @@ captcha:
|
||||
local-cache-enabled: true
|
||||
local-cache-size: 20
|
||||
|
||||
jwt:
|
||||
secret: tianai-captcha-jwt-secret-key-must-be-at-least-256-bits-long-for-hs256
|
||||
expiration: 86400000
|
||||
|
||||
minio:
|
||||
endpoint: http://localhost:9000
|
||||
access-key: minioadmin
|
||||
secret-key: minioadmin
|
||||
bucket: captcha-resources
|
||||
|
||||
logging:
|
||||
level:
|
||||
cloud.tianai.captcha: INFO
|
||||
|
||||
Reference in New Issue
Block a user