feat: Phase 3-5 管理后台前端+统计监控+部署配置
deploy-test / build-and-deploy-test (push) Successful in 2m41s

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:
abcv7
2026-08-26 13:44:08 +08:00
parent d1cd371061
commit 1164b51c64
67 changed files with 5510 additions and 829 deletions
@@ -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";
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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; }
}
@@ -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);
}
@@ -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();
}
@@ -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);
}
@@ -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; }
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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";
}
}
@@ -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
@@ -0,0 +1,74 @@
package cloud.tianai.captcha.platform;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import org.springframework.test.context.ActiveProfiles;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class CaptchaApiIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
private String jwtToken;
@BeforeEach
void login() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> request = new HttpEntity<>(
Map.of("username", "admin", "password", "admin"), headers);
var response = restTemplate.postForEntity("/api/auth/login", request, Map.class);
if (response.getBody() != null && response.getBody().get("data") != null) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
if (data != null && data.get("token") != null) {
jwtToken = (String) data.get("token");
}
}
}
private HttpHeaders authHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (jwtToken != null) {
headers.setBearerAuth(jwtToken);
}
return headers;
}
@Test
void contextLoads() {
assertNotNull(restTemplate);
}
@Test
void loginReturnsToken() {
assertNotNull(jwtToken, "JWT token should not be null after login");
}
@Test
void statsEndpointReturnsData() {
HttpEntity<Void> entity = new HttpEntity<>(authHeaders());
var response = restTemplate.exchange("/api/admin/stats", HttpMethod.GET, entity, Map.class);
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody());
}
@Test
void siteListEndpointReturnsData() {
HttpEntity<Void> entity = new HttpEntity<>(authHeaders());
var response = restTemplate.exchange("/api/admin/sites?page=0&size=10", HttpMethod.GET, entity, Map.class);
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody());
}
}
@@ -0,0 +1,41 @@
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
h2:
console:
enabled: false
data:
redis:
host: localhost
port: 6379
jwt:
secret: test-secret-key-must-be-at-least-256-bits-long-for-hs256
expiration: 86400000
captcha:
prefix: captcha
expire:
default: 120000
init-default-resource: false
local-cache-enabled: false
minio:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket: captcha-resources
logging:
level:
cloud.tianai.captcha: DEBUG