feat: 增强版验证码全套实现 — 16种类型生成器/加密/风控/站点管理/平台后端/前端SDK/UI

- 核心新增: ICON_CLICK(PNG图标方案,预渲染资源)/SCRATCH/JIGSAW/CURVE_SLIDER(V1-V3)/ANGLE/CURVE_DRAW/WORD_ORDER_CLICK/PROOF_OF_WORK 等生成器
- 图标点选: classpath PNG 加载替代运行时字体渲染(Linux容器无emoji字体),提示条图作为 templateImage 返回前端
- 新增模块: crypto(AES+RSA)/obfuscator(背景乱序/噪声/扭曲)/risk(风控/IP黑名单/限流)/site(站点管理)/ml(轨迹规则引擎)
- 平台后端: 站点管理/验证码API(generate/verify/secondary-verify)/统计/ML轨迹学习
- 前端SDK: TPCaptcha兼容,支持全部新型号渲染与交互
- 工具: tools/icon-render 图标预渲染工具
This commit is contained in:
abcv7
2026-08-26 09:58:15 +08:00
parent d56958727c
commit dbe9b58b0c
115 changed files with 10365 additions and 36 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache fontconfig ttf-dejavu
WORKDIR /app
COPY tianai-captcha-platform-2.0.0-SNAPSHOT.jar /app/captcha-platform.jar
EXPOSE 18200
ENTRYPOINT ["java", "-jar", "/app/captcha-platform.jar"]
+80
View File
@@ -0,0 +1,80 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cloud.tianai.captcha</groupId>
<artifactId>tianai-captcha-parent</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>tianai-captcha-platform</artifactId>
<name>tianai-captcha-platform</name>
<description>验证码服务平台后端</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>cloud.tianai.captcha</groupId>
<artifactId>tianai-captcha-springboot-starter</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<release>21</release>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,15 @@
package cloud.tianai.captcha.platform;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(scanBasePackages = {"cloud.tianai.captcha"})
@EnableJpaRepositories(basePackages = "cloud.tianai.captcha.platform.mapper")
@EnableScheduling
public class CaptchaPlatformApplication {
public static void main(String[] args) {
SpringApplication.run(CaptchaPlatformApplication.class, args);
}
}
@@ -0,0 +1,105 @@
package cloud.tianai.captcha.platform.config;
import cloud.tianai.captcha.application.ImageCaptchaApplication;
import cloud.tianai.captcha.cache.CacheStore;
import cloud.tianai.captcha.cache.impl.LocalCacheStore;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.resource.CrudResourceStore;
import cloud.tianai.captcha.resource.ResourceStore;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import cloud.tianai.captcha.risk.IpBlacklist;
import cloud.tianai.captcha.risk.RiskEngine;
import cloud.tianai.captcha.site.TokenService;
import cloud.tianai.captcha.validator.ImageCaptchaValidator;
import cloud.tianai.captcha.validator.impl.EnhancedTrackValidator;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
import static cloud.tianai.captcha.common.constant.CommonConstant.DEFAULT_TAG;
import static cloud.tianai.captcha.generator.impl.StandardSliderImageCaptchaGenerator.TEMPLATE_ACTIVE_IMAGE_NAME;
import static cloud.tianai.captcha.generator.impl.StandardSliderImageCaptchaGenerator.TEMPLATE_FIXED_IMAGE_NAME;
@Configuration
public class CaptchaPlatformConfig {
private static final String TP = "META-INF/cut-image/template";
@Bean
public CacheStore cacheStore() {
return new LocalCacheStore();
}
@Bean
public RiskEngine riskEngine() {
return new RiskEngine();
}
@Bean
public IpBlacklist ipBlacklist() {
return new IpBlacklist();
}
@Bean
public TokenService tokenService(CacheStore cacheStore) {
return new TokenService(cacheStore);
}
@Bean
public ImageCaptchaValidator captchaValidator() {
return new EnhancedTrackValidator();
}
@Bean
public CommandLineRunner initCaptchaResources(ResourceStore resourceStore) {
return args -> {
if (resourceStore instanceof CrudResourceStore crud) {
List<String> bgTypes = List.of(
CaptchaTypeConstant.SLIDER,
CaptchaTypeConstant.SLIDER_V2,
CaptchaTypeConstant.ROTATE,
CaptchaTypeConstant.CONCAT,
CaptchaTypeConstant.WORD_IMAGE_CLICK,
CaptchaTypeConstant.ICON_CLICK,
CaptchaTypeConstant.WORD_ORDER_CLICK,
CaptchaTypeConstant.CURVE_SLIDER,
CaptchaTypeConstant.CURVE_SLIDER_V2,
CaptchaTypeConstant.CURVE_SLIDER_V3,
CaptchaTypeConstant.ANGLE,
CaptchaTypeConstant.SCRATCH,
CaptchaTypeConstant.JIGSAW,
CaptchaTypeConstant.CURVE_DRAW
);
for (String type : bgTypes) {
crud.addResource(type, new Resource("classpath", "META-INF/cut-image/resource/1.jpg"));
}
List<String> sliderAliasTypes = List.of(
CaptchaTypeConstant.SLIDER_V2,
CaptchaTypeConstant.CONCAT,
CaptchaTypeConstant.CURVE_SLIDER,
CaptchaTypeConstant.CURVE_SLIDER_V2,
CaptchaTypeConstant.CURVE_SLIDER_V3,
CaptchaTypeConstant.ANGLE,
CaptchaTypeConstant.SCRATCH,
CaptchaTypeConstant.JIGSAW,
CaptchaTypeConstant.CURVE_DRAW
);
for (String type : sliderAliasTypes) {
ResourceMap t1 = new ResourceMap(DEFAULT_TAG, 4);
t1.put(TEMPLATE_ACTIVE_IMAGE_NAME, new Resource("classpath", TP + "/slider_1/active.png"));
t1.put(TEMPLATE_FIXED_IMAGE_NAME, new Resource("classpath", TP + "/slider_1/fixed.png"));
crud.addTemplate(type, t1);
ResourceMap t2 = new ResourceMap(DEFAULT_TAG, 4);
t2.put(TEMPLATE_ACTIVE_IMAGE_NAME, new Resource("classpath", TP + "/slider_2/active.png"));
t2.put(TEMPLATE_FIXED_IMAGE_NAME, new Resource("classpath", TP + "/slider_2/fixed.png"));
crud.addTemplate(type, t2);
}
}
};
}
}
@@ -0,0 +1,19 @@
package cloud.tianai.captcha.platform.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
@@ -0,0 +1,65 @@
package cloud.tianai.captcha.platform.controller;
import cloud.tianai.captcha.application.ImageCaptchaApplication;
import cloud.tianai.captcha.application.vo.ImageCaptchaVO;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.platform.service.CaptchaPlatformService;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import cloud.tianai.captcha.validator.common.model.dto.MatchParam;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class CaptchaApiController {
private final CaptchaPlatformService platformService;
public CaptchaApiController(CaptchaPlatformService platformService) {
this.platformService = platformService;
}
@PostMapping("/challenge/generate")
public ApiResponse<ImageCaptchaVO> generate(
@RequestHeader(value = "X-Site-Key", required = false) String siteKey,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "scene", required = false, defaultValue = "default") String scene,
@RequestHeader(value = "X-Real-IP", required = false) String ip,
@RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor) {
String clientIp = resolveIp(ip, forwardedFor);
return platformService.generateCaptcha(siteKey, type, scene, clientIp);
}
@PostMapping("/challenge/verify")
public ApiResponse<?> verify(
@RequestHeader(value = "X-Site-Key", required = false) String siteKey,
@RequestBody Map<String, Object> body) {
String captchaId = (String) body.get("id");
Object trackData = body.get("data");
String ip = (String) body.getOrDefault("ip", "unknown");
return platformService.verifyCaptcha(siteKey, captchaId, trackData, ip);
}
@PostMapping("/challenge/secondary-verify")
public ApiResponse<?> secondaryVerify(
@RequestBody Map<String, Object> body) {
String siteKey = (String) body.get("siteKey");
String secretKey = (String) body.get("secretKey");
String verifyToken = (String) body.get("verifyToken");
return platformService.secondaryVerify(siteKey, secretKey, verifyToken);
}
@GetMapping("/challenge/public-key")
public ApiResponse<?> getPublicKey(
@RequestHeader(value = "X-Site-Key", required = false) String siteKey) {
return platformService.getPublicKey(siteKey);
}
private String resolveIp(String ip, String forwardedFor) {
if (forwardedFor != null && !forwardedFor.isEmpty()) {
return forwardedFor.split(",")[0].trim();
}
return ip != null ? ip : "unknown";
}
}
@@ -0,0 +1,98 @@
package cloud.tianai.captcha.platform.controller;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.platform.entity.CaptchaSite;
import cloud.tianai.captcha.platform.service.CaptchaPlatformService;
import cloud.tianai.captcha.platform.service.TrackLearningService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/admin")
public class SiteAdminController {
private final CaptchaPlatformService platformService;
private final TrackLearningService trackLearningService;
public SiteAdminController(CaptchaPlatformService platformService, TrackLearningService trackLearningService) {
this.platformService = platformService;
this.trackLearningService = trackLearningService;
}
@PostMapping("/sites")
public ApiResponse<CaptchaSite> createSite(@RequestBody Map<String, Object> body) {
String name = (String) body.get("name");
String domain = (String) body.get("domain");
@SuppressWarnings("unchecked")
Set<String> captchaTypes = body.get("captchaTypes") != null
? Set.copyOf((java.util.List<String>) body.get("captchaTypes"))
: null;
if (captchaTypes == null && body.get("allowedTypes") != null) {
captchaTypes = Set.copyOf((java.util.List<String>) body.get("allowedTypes"));
}
CaptchaSite site = platformService.createSite(name, domain, captchaTypes);
return ApiResponse.ofSuccess(site);
}
@GetMapping("/sites")
public ApiResponse<Page<CaptchaSite>> listSites(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ofSuccess(platformService.listSites(PageRequest.of(page, size)));
}
@GetMapping("/sites/{siteId}")
public ApiResponse<CaptchaSite> getSite(@PathVariable Integer siteId) {
return ApiResponse.ofSuccess(platformService.getSite(siteId));
}
@PutMapping("/sites/{siteId}")
public ApiResponse<CaptchaSite> updateSite(@PathVariable Integer siteId, @RequestBody CaptchaSite site) {
site.setId(siteId);
return ApiResponse.ofSuccess(platformService.updateSite(site));
}
@DeleteMapping("/sites/{siteId}")
public ApiResponse<?> deleteSite(@PathVariable Integer siteId) {
platformService.deleteSite(siteId);
return ApiResponse.ofSuccess();
}
@GetMapping("/stats")
public ApiResponse<?> getStats(
@RequestParam(required = false) Integer siteId,
@RequestParam(defaultValue = "7") int days) {
return ApiResponse.ofSuccess(platformService.getStats(siteId, days));
}
@PostMapping("/ip-blacklist")
public ApiResponse<?> banIp(@RequestBody Map<String, Object> body) {
String ip = (String) body.get("ip");
String reason = (String) body.get("reason");
long durationMs = body.get("durationMs") != null
? ((Number) body.get("durationMs")).longValue()
: 3600000L;
platformService.banIp(ip, reason, durationMs);
return ApiResponse.ofSuccess();
}
@DeleteMapping("/ip-blacklist/{ip}")
public ApiResponse<?> unbanIp(@PathVariable String ip) {
platformService.unbanIp(ip);
return ApiResponse.ofSuccess();
}
@GetMapping("/ml/status")
public ApiResponse<?> getMlStatus() {
return ApiResponse.ofSuccess(trackLearningService.getStatus());
}
@PostMapping("/ml/train")
public ApiResponse<?> triggerTraining() {
return ApiResponse.ofSuccess(trackLearningService.trainIfNeeded());
}
}
@@ -0,0 +1,91 @@
package cloud.tianai.captcha.platform.entity;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "verification_logs")
public class CaptchaLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "site_id")
private Integer siteId;
@Column(name = "site_key")
private java.util.UUID siteKey;
@Column(name = "captcha_type", length = 32)
private String captchaType;
@Column(length = 64)
private String scene = "default";
@Column(length = 45)
private String ip;
@Column(name = "is_pass")
private Boolean isPass;
@Column(name = "behavior_score")
private Double behaviorScore;
@Column(name = "risk_level", length = 16)
private String riskLevel;
@Column(name = "cost_time")
private Integer costTime;
@Column(name = "captcha_id", length = 128)
private String captchaId;
@Column(name = "user_agent", columnDefinition = "TEXT")
private String userAgent;
@Column(name = "track_score")
private Double trackScore;
@Column(name = "created_at")
private OffsetDateTime createdAt;
@PrePersist
protected void onCreate() { createdAt = OffsetDateTime.now(); }
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public Integer getSiteId() { return siteId; }
public void setSiteId(Integer v) { this.siteId = v; }
public java.util.UUID getSiteKey() { return siteKey; }
public void setSiteKey(java.util.UUID v) { this.siteKey = v; }
public String getCaptchaType() { return captchaType; }
public void setCaptchaType(String v) { this.captchaType = v; }
public String getScene() { return scene; }
public void setScene(String v) { this.scene = v; }
public String getIp() { return ip; }
public void setIp(String v) { this.ip = v; }
public Boolean getIsPass() { return isPass; }
public void setIsPass(Boolean v) { this.isPass = v; }
public Double getBehaviorScore() { return behaviorScore; }
public void setBehaviorScore(Double v) { this.behaviorScore = v; }
public String getRiskLevel() { return riskLevel; }
public void setRiskLevel(String v) { this.riskLevel = v; }
public Integer getCostTime() { return costTime; }
public void setCostTime(Integer v) { this.costTime = v; }
public String getCaptchaId() { return captchaId; }
public void setCaptchaId(String v) { this.captchaId = v; }
public String getUserAgent() { return userAgent; }
public void setUserAgent(String v) { this.userAgent = v; }
public Double getTrackScore() { return trackScore; }
public void setTrackScore(Double v) { this.trackScore = v; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime v) { this.createdAt = v; }
public String getResult() { return isPass != null && isPass ? "SUCCESS" : "FAIL"; }
public void setResult(String v) { this.isPass = "SUCCESS".equals(v); }
public String getType() { return captchaType; }
public void setType(String v) { this.captchaType = v; }
public Long getDurationMs() { return costTime != null ? costTime.longValue() : null; }
public void setDurationMs(Long v) { this.costTime = v != null ? v.intValue() : null; }
}
@@ -0,0 +1,158 @@
package cloud.tianai.captcha.platform.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.Set;
@Entity
@Table(name = "sites")
public class CaptchaSite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "user_id")
private Integer userId;
@Column(length = 64, nullable = false)
private String name;
@Column(length = 256)
private String domain;
@Column(length = 512)
private String favicon;
@Column(length = 512)
private String logo;
@Column(name = "site_key", unique = true)
private java.util.UUID siteKey;
@Column(name = "secret_key", unique = true)
private java.util.UUID secretKey;
@Column(name = "verify_level", length = 16)
private String verifyLevel = "MEDIUM";
@Column
private Integer qps = 10;
@Column(name = "daily_limit")
private Integer dailyLimit = 500;
@Column(name = "captcha_types", columnDefinition = "TEXT[]")
private Set<String> captchaTypes;
@Column(name = "plan_id")
private Integer planId;
@Column(name = "plan_expire_at")
private OffsetDateTime planExpireAt;
@Column(name = "is_enabled")
private Boolean isEnabled = true;
@Column(name = "rsa_public_key", columnDefinition = "TEXT")
private String rsaPublicKey;
@Column(name = "rsa_private_key", columnDefinition = "TEXT")
private String rsaPrivateKey;
@Column(name = "aes_key", length = 256)
private String aesKey;
@Column(name = "signing_key", length = 256)
private String signingKey;
@Column(name = "track_validation_enabled")
private Boolean trackValidationEnabled = true;
@Column(name = "track_human_threshold")
private Double trackHumanThreshold = 0.5;
@Column(name = "obfuscation_enabled")
private Boolean obfuscationEnabled = true;
@Column(name = "encryption_enabled")
private Boolean encryptionEnabled = 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 Integer getUserId() { return userId; }
public void setUserId(Integer userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDomain() { return domain; }
public void setDomain(String domain) { this.domain = domain; }
public String getFavicon() { return favicon; }
public void setFavicon(String favicon) { this.favicon = favicon; }
public String getLogo() { return logo; }
public void setLogo(String logo) { this.logo = logo; }
public java.util.UUID getSiteKey() { return siteKey; }
public void setSiteKey(java.util.UUID siteKey) { this.siteKey = siteKey; }
public java.util.UUID getSecretKey() { return secretKey; }
public void setSecretKey(java.util.UUID secretKey) { this.secretKey = secretKey; }
public String getVerifyLevel() { return verifyLevel; }
public void setVerifyLevel(String verifyLevel) { this.verifyLevel = verifyLevel; }
public Integer getQps() { return qps; }
public void setQps(Integer qps) { this.qps = qps; }
public Integer getDailyLimit() { return dailyLimit; }
public void setDailyLimit(Integer dailyLimit) { this.dailyLimit = dailyLimit; }
public Set<String> getCaptchaTypes() { return captchaTypes; }
public void setCaptchaTypes(Set<String> captchaTypes) { this.captchaTypes = captchaTypes; }
public Integer getPlanId() { return planId; }
public void setPlanId(Integer planId) { this.planId = planId; }
public OffsetDateTime getPlanExpireAt() { return planExpireAt; }
public void setPlanExpireAt(OffsetDateTime planExpireAt) { this.planExpireAt = planExpireAt; }
public Boolean getIsEnabled() { return isEnabled; }
public void setIsEnabled(Boolean isEnabled) { this.isEnabled = isEnabled; }
public String getRsaPublicKey() { return rsaPublicKey; }
public void setRsaPublicKey(String v) { this.rsaPublicKey = v; }
public String getRsaPrivateKey() { return rsaPrivateKey; }
public void setRsaPrivateKey(String v) { this.rsaPrivateKey = v; }
public String getAesKey() { return aesKey; }
public void setAesKey(String v) { this.aesKey = v; }
public String getSigningKey() { return signingKey; }
public void setSigningKey(String v) { this.signingKey = v; }
public Boolean getTrackValidationEnabled() { return trackValidationEnabled; }
public void setTrackValidationEnabled(Boolean v) { this.trackValidationEnabled = v; }
public Double getTrackHumanThreshold() { return trackHumanThreshold; }
public void setTrackHumanThreshold(Double v) { this.trackHumanThreshold = v; }
public Boolean getObfuscationEnabled() { return obfuscationEnabled; }
public void setObfuscationEnabled(Boolean v) { this.obfuscationEnabled = v; }
public Boolean getEncryptionEnabled() { return encryptionEnabled; }
public void setEncryptionEnabled(Boolean v) { this.encryptionEnabled = v; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime v) { this.createdAt = v; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime v) { this.updatedAt = v; }
public Boolean getEnabled() { return isEnabled; }
public void setEnabled(Boolean v) { this.isEnabled = v; }
public Set<String> getAllowedTypes() { return captchaTypes; }
public void setAllowedTypes(Set<String> v) { this.captchaTypes = v; }
public String getLevel() { return verifyLevel; }
public void setLevel(String v) { this.verifyLevel = v; }
public Integer getMaxQps() { return qps; }
public void setMaxQps(Integer v) { this.qps = v; }
}
@@ -0,0 +1,239 @@
package cloud.tianai.captcha.platform.entity;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "track_samples")
public class TrackSample {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "site_id")
private Integer siteId;
@Column(name = "captcha_type", length = 32)
private String captchaType;
@Column(name = "is_human")
private Boolean isHuman;
@Column(name = "ml_score")
private Double mlScore;
@Column(name = "basic_pass")
private Boolean basicPass;
@Column(name = "total_points")
private Integer totalPoints;
@Column(name = "total_duration")
private Long totalDuration;
@Column(name = "displacement_x")
private Float displacementX;
@Column(name = "displacement_y")
private Float displacementY;
@Column(name = "displacement_x_ratio")
private Float displacementXRatio;
@Column(name = "total_path_length")
private Double totalPathLength;
@Column(name = "path_efficiency")
private Double pathEfficiency;
@Column(name = "avg_speed")
private Float avgSpeed;
@Column(name = "max_speed")
private Float maxSpeed;
@Column(name = "min_speed")
private Float minSpeed;
@Column(name = "speed_variance")
private Double speedVariance;
@Column(name = "speed_std_dev")
private Double speedStdDev;
@Column(name = "speed_skewness")
private Double speedSkewness;
@Column(name = "avg_acceleration")
private Float avgAcceleration;
@Column(name = "max_acceleration")
private Float maxAcceleration;
@Column(name = "min_acceleration")
private Float minAcceleration;
@Column(name = "acceleration_variance")
private Double accelerationVariance;
@Column(name = "direction_changes")
private Integer directionChanges;
@Column(name = "y_direction_changes")
private Integer yDirectionChanges;
@Column(name = "pauses")
private Integer pauses;
@Column(name = "start_offset")
private Double startOffset;
@Column(name = "straightness")
private Double straightness;
@Column(name = "x_uniformity")
private Double xUniformity;
@Column(name = "y_uniformity")
private Double yUniformity;
@Column(name = "avg_point_interval")
private Float avgPointInterval;
@Column(name = "speed_phase_correlation")
private Double speedPhaseCorrelation;
@Column(name = "max_jump_distance")
private Double maxJumpDistance;
@Column(name = "overshoot_ratio")
private Double overshootRatio;
@Column(name = "track_json", columnDefinition = "TEXT")
private String trackJson;
@Column(length = 45)
private String ip;
@Column(name = "created_at")
private OffsetDateTime createdAt;
@PrePersist
protected void onCreate() { createdAt = OffsetDateTime.now(); }
public static TrackSample fromFeatures(cloud.tianai.captcha.ml.TrackFeatures f, Boolean isHuman, Double mlScore, Boolean basicPass, String captchaType, Integer siteId, String trackJson, String ip) {
TrackSample s = new TrackSample();
s.isHuman = isHuman;
s.mlScore = mlScore;
s.basicPass = basicPass;
s.captchaType = captchaType;
s.siteId = siteId;
s.trackJson = trackJson;
s.ip = ip;
s.totalPoints = f.totalPoints;
s.totalDuration = f.totalDuration;
s.displacementX = f.displacementX;
s.displacementY = f.displacementY;
s.displacementXRatio = f.displacementXRatio;
s.totalPathLength = f.totalPathLength;
s.pathEfficiency = f.pathEfficiency;
s.avgSpeed = f.avgSpeed;
s.maxSpeed = f.maxSpeed;
s.minSpeed = f.minSpeed;
s.speedVariance = f.speedVariance;
s.speedStdDev = f.speedStdDev;
s.speedSkewness = f.speedSkewness;
s.avgAcceleration = f.avgAcceleration;
s.maxAcceleration = f.maxAcceleration;
s.minAcceleration = f.minAcceleration;
s.accelerationVariance = f.accelerationVariance;
s.directionChanges = f.directionChanges;
s.yDirectionChanges = f.yDirectionChanges;
s.pauses = f.pauses;
s.startOffset = f.startOffset;
s.straightness = f.straightness;
s.xUniformity = f.xUniformity;
s.yUniformity = f.yUniformity;
s.avgPointInterval = f.avgPointInterval;
s.speedPhaseCorrelation = f.speedPhaseCorrelation;
s.maxJumpDistance = f.maxJumpDistance;
s.overshootRatio = f.overshootRatio;
return s;
}
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public Integer getSiteId() { return siteId; }
public void setSiteId(Integer siteId) { this.siteId = siteId; }
public String getCaptchaType() { return captchaType; }
public void setCaptchaType(String captchaType) { this.captchaType = captchaType; }
public Boolean getIsHuman() { return isHuman; }
public void setIsHuman(Boolean isHuman) { this.isHuman = isHuman; }
public Double getMlScore() { return mlScore; }
public void setMlScore(Double mlScore) { this.mlScore = mlScore; }
public Boolean getBasicPass() { return basicPass; }
public void setBasicPass(Boolean basicPass) { this.basicPass = basicPass; }
public Integer getTotalPoints() { return totalPoints; }
public void setTotalPoints(Integer totalPoints) { this.totalPoints = totalPoints; }
public Long getTotalDuration() { return totalDuration; }
public void setTotalDuration(Long totalDuration) { this.totalDuration = totalDuration; }
public Float getDisplacementX() { return displacementX; }
public void setDisplacementX(Float displacementX) { this.displacementX = displacementX; }
public Float getDisplacementY() { return displacementY; }
public void setDisplacementY(Float displacementY) { this.displacementY = displacementY; }
public Float getDisplacementXRatio() { return displacementXRatio; }
public void setDisplacementXRatio(Float displacementXRatio) { this.displacementXRatio = displacementXRatio; }
public Double getTotalPathLength() { return totalPathLength; }
public void setTotalPathLength(Double totalPathLength) { this.totalPathLength = totalPathLength; }
public Double getPathEfficiency() { return pathEfficiency; }
public void setPathEfficiency(Double pathEfficiency) { this.pathEfficiency = pathEfficiency; }
public Float getAvgSpeed() { return avgSpeed; }
public void setAvgSpeed(Float avgSpeed) { this.avgSpeed = avgSpeed; }
public Float getMaxSpeed() { return maxSpeed; }
public void setMaxSpeed(Float maxSpeed) { this.maxSpeed = maxSpeed; }
public Float getMinSpeed() { return minSpeed; }
public void setMinSpeed(Float minSpeed) { this.minSpeed = minSpeed; }
public Double getSpeedVariance() { return speedVariance; }
public void setSpeedVariance(Double speedVariance) { this.speedVariance = speedVariance; }
public Double getSpeedStdDev() { return speedStdDev; }
public void setSpeedStdDev(Double speedStdDev) { this.speedStdDev = speedStdDev; }
public Double getSpeedSkewness() { return speedSkewness; }
public void setSpeedSkewness(Double speedSkewness) { this.speedSkewness = speedSkewness; }
public Float getAvgAcceleration() { return avgAcceleration; }
public void setAvgAcceleration(Float avgAcceleration) { this.avgAcceleration = avgAcceleration; }
public Float getMaxAcceleration() { return maxAcceleration; }
public void setMaxAcceleration(Float maxAcceleration) { this.maxAcceleration = maxAcceleration; }
public Float getMinAcceleration() { return minAcceleration; }
public void setMinAcceleration(Float minAcceleration) { this.minAcceleration = minAcceleration; }
public Double getAccelerationVariance() { return accelerationVariance; }
public void setAccelerationVariance(Double accelerationVariance) { this.accelerationVariance = accelerationVariance; }
public Integer getDirectionChanges() { return directionChanges; }
public void setDirectionChanges(Integer directionChanges) { this.directionChanges = directionChanges; }
public Integer getYDirectionChanges() { return yDirectionChanges; }
public void setYDirectionChanges(Integer yDirectionChanges) { this.yDirectionChanges = yDirectionChanges; }
public Integer getPauses() { return pauses; }
public void setPauses(Integer pauses) { this.pauses = pauses; }
public Double getStartOffset() { return startOffset; }
public void setStartOffset(Double startOffset) { this.startOffset = startOffset; }
public Double getStraightness() { return straightness; }
public void setStraightness(Double straightness) { this.straightness = straightness; }
public Double getXUniformity() { return xUniformity; }
public void setXUniformity(Double xUniformity) { this.xUniformity = xUniformity; }
public Double getYUniformity() { return yUniformity; }
public void setYUniformity(Double yUniformity) { this.yUniformity = yUniformity; }
public Float getAvgPointInterval() { return avgPointInterval; }
public void setAvgPointInterval(Float avgPointInterval) { this.avgPointInterval = avgPointInterval; }
public Double getSpeedPhaseCorrelation() { return speedPhaseCorrelation; }
public void setSpeedPhaseCorrelation(Double speedPhaseCorrelation) { this.speedPhaseCorrelation = speedPhaseCorrelation; }
public Double getMaxJumpDistance() { return maxJumpDistance; }
public void setMaxJumpDistance(Double maxJumpDistance) { this.maxJumpDistance = maxJumpDistance; }
public Double getOvershootRatio() { return overshootRatio; }
public void setOvershootRatio(Double overshootRatio) { this.overshootRatio = overshootRatio; }
public String getTrackJson() { return trackJson; }
public void setTrackJson(String trackJson) { this.trackJson = trackJson; }
public String getIp() { return ip; }
public void setIp(String ip) { this.ip = ip; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
}
@@ -0,0 +1,8 @@
package cloud.tianai.captcha.platform.mapper;
import cloud.tianai.captcha.platform.entity.CaptchaLog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CaptchaLogRepository extends JpaRepository<CaptchaLog, Integer> {
long countByIsPass(Boolean isPass);
}
@@ -0,0 +1,10 @@
package cloud.tianai.captcha.platform.mapper;
import cloud.tianai.captcha.platform.entity.CaptchaSite;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface CaptchaSiteRepository extends JpaRepository<CaptchaSite, Integer> {
Optional<CaptchaSite> findBySiteKey(UUID siteKey);
}
@@ -0,0 +1,25 @@
package cloud.tianai.captcha.platform.mapper;
import cloud.tianai.captcha.platform.entity.TrackSample;
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 TrackSampleRepository extends JpaRepository<TrackSample, Integer> {
long countByIsHuman(Boolean isHuman);
@Query("SELECT COUNT(s) FROM TrackSample s WHERE s.createdAt > :since")
long countRecentSamples(@Param("since") OffsetDateTime since);
@Query("SELECT s FROM TrackSample s ORDER BY s.createdAt DESC LIMIT :limit")
List<TrackSample> findRecentSamples(@Param("limit") int limit);
@Query("SELECT s FROM TrackSample s WHERE s.isHuman = :isHuman ORDER BY s.createdAt DESC LIMIT :limit")
List<TrackSample> findByIsHuman(@Param("isHuman") Boolean isHuman, @Param("limit") int limit);
}
@@ -0,0 +1,242 @@
package cloud.tianai.captcha.platform.service;
import cloud.tianai.captcha.application.ImageCaptchaApplication;
import cloud.tianai.captcha.application.vo.ImageCaptchaVO;
import cloud.tianai.captcha.common.AnyMap;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.platform.entity.CaptchaLog;
import cloud.tianai.captcha.platform.entity.CaptchaSite;
import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository;
import cloud.tianai.captcha.platform.mapper.CaptchaSiteRepository;
import cloud.tianai.captcha.risk.IpBlacklist;
import cloud.tianai.captcha.risk.RiskEngine;
import cloud.tianai.captcha.site.TokenService;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import cloud.tianai.captcha.validator.common.model.dto.MatchParam;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.lang.reflect.Type;
import java.security.SecureRandom;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
@Service
public class CaptchaPlatformService {
private final ImageCaptchaApplication captchaApplication;
private final CaptchaSiteRepository siteRepository;
private final CaptchaLogRepository logRepository;
private final TokenService tokenService;
private final RiskEngine riskEngine;
private final IpBlacklist ipBlacklist;
private final TrackLearningService trackLearningService;
private final Gson gson = new Gson();
public CaptchaPlatformService(ImageCaptchaApplication captchaApplication,
CaptchaSiteRepository siteRepository,
CaptchaLogRepository logRepository,
TokenService tokenService,
RiskEngine riskEngine,
IpBlacklist ipBlacklist,
TrackLearningService trackLearningService) {
this.captchaApplication = captchaApplication;
this.siteRepository = siteRepository;
this.logRepository = logRepository;
this.tokenService = tokenService;
this.riskEngine = riskEngine;
this.ipBlacklist = ipBlacklist;
this.trackLearningService = trackLearningService;
}
public ApiResponse<ImageCaptchaVO> generateCaptcha(String siteKeyStr, String type, String scene, String ip) {
CaptchaSite site = validateSite(siteKeyStr);
if (site == null) {
return ApiResponse.of(10001, "site_auth_fail", null);
}
if (ipBlacklist.isBanned(ip)) {
return ApiResponse.of(10005, "ip_banned", null);
}
if (!riskEngine.getRateLimiter().allow("ip:" + ip)) {
return ApiResponse.of(10007, "rate_limit", null);
}
if (type == null) {
Set<String> allowed = site.getCaptchaTypes();
if (allowed != null && !allowed.isEmpty()) {
List<String> list = new ArrayList<>(allowed);
type = list.get(ThreadLocalRandom.current().nextInt(list.size()));
} else {
type = "SLIDER";
}
}
if (site.getCaptchaTypes() != null && !site.getCaptchaTypes().isEmpty()
&& !site.getCaptchaTypes().contains(type)) {
return ApiResponse.of(10006, "type_not_supported", null);
}
ApiResponse<ImageCaptchaVO> response = captchaApplication.generateCaptcha(type);
if (response.isSuccess()) {
String cid = response.getData() != null ? response.getData().getId() : null;
System.out.println("[CAPTCHA-DEBUG] generateCaptcha type=" + type + " captchaId=" + cid);
CaptchaLog log = new CaptchaLog();
log.setSiteId(site.getId());
log.setSiteKey(site.getSiteKey());
log.setCaptchaId(response.getData() != null ? response.getData().getId() : null);
log.setCaptchaType(type);
log.setScene(scene);
log.setIp(ip);
log.setIsPass(null);
logRepository.save(log);
}
return response;
}
public ApiResponse<?> verifyCaptcha(String siteKeyStr, String captchaId, Object trackData, String ip) {
CaptchaSite site = validateSite(siteKeyStr);
if (site == null) {
return ApiResponse.of(10001, "site_auth_fail", null);
}
System.out.println("[CAPTCHA-DEBUG] verifyCaptcha captchaId=" + captchaId + " trackData class=" + (trackData != null ? trackData.getClass().getName() : "null"));
ImageCaptchaTrack track;
if (trackData instanceof Map) {
Type mapType = new TypeToken<Map<String, Object>>() {}.getType();
String json = gson.toJson(trackData);
System.out.println("[CAPTCHA-DEBUG] track json=" + json);
track = gson.fromJson(json, ImageCaptchaTrack.class);
} else if (trackData instanceof ImageCaptchaTrack) {
track = (ImageCaptchaTrack) trackData;
} else {
return ApiResponse.of(10004, "invalid_param", null);
}
System.out.println("[CAPTCHA-DEBUG] track.bgImageWidth=" + track.getBgImageWidth()
+ " trackList.size=" + (track.getTrackList() != null ? track.getTrackList().size() : 0));
MatchParam matchParam = new MatchParam(track);
AnyMap extData = new AnyMap();
extData.put("ip", ip);
matchParam.putAll(extData);
long startTime = System.currentTimeMillis();
ApiResponse<?> result = captchaApplication.matching(captchaId, matchParam);
long duration = System.currentTimeMillis() - startTime;
System.out.println("[CAPTCHA-DEBUG] matching result code=" + result.getCode() + " msg=" + result.getMsg() + " success=" + result.isSuccess());
CaptchaLog log = new CaptchaLog();
log.setSiteId(site.getId());
log.setSiteKey(site.getSiteKey());
log.setCaptchaId(captchaId);
log.setCaptchaType(trackData != null ? "VERIFY" : "UNKNOWN");
log.setIp(ip);
log.setIsPass(result.isSuccess());
log.setCostTime((int) duration);
logRepository.save(log);
trackLearningService.collectSample(track, result.isSuccess(), "SLIDER", site.getId(), ip);
if (result.isSuccess()) {
String token = tokenService.generateToken(siteKeyStr, captchaId);
Map<String, Object> data = new HashMap<>();
data.put("verifyToken", token);
return ApiResponse.ofSuccess(data);
}
if (!result.isSuccess()) {
riskEngine.recordFail(ip);
}
return result;
}
public ApiResponse<?> secondaryVerify(String siteKeyStr, String secretKeyStr, String verifyToken) {
CaptchaSite site = siteRepository.findBySiteKey(java.util.UUID.fromString(siteKeyStr)).orElse(null);
if (site == null || !site.getSecretKey().toString().equals(secretKeyStr)) {
return ApiResponse.of(10001, "site_auth_fail", null);
}
AnyMap tokenData = tokenService.consumeToken(verifyToken);
if (tokenData == null) {
return ApiResponse.of(403, "token_invalid", null);
}
Map<String, Object> result = new HashMap<>();
result.put("verifyResult", true);
return ApiResponse.ofSuccess(result);
}
public ApiResponse<?> getPublicKey(String siteKeyStr) {
CaptchaSite site = validateSite(siteKeyStr);
if (site == null) {
return ApiResponse.of(10001, "site_auth_fail", null);
}
Map<String, Object> data = new HashMap<>();
data.put("publicKey", site.getRsaPublicKey());
return ApiResponse.ofSuccess(data);
}
public CaptchaSite createSite(String name, String domain, Set<String> captchaTypes) {
CaptchaSite site = new CaptchaSite();
site.setSiteKey(java.util.UUID.randomUUID());
site.setSecretKey(java.util.UUID.randomUUID());
site.setName(name);
site.setDomain(domain);
site.setCaptchaTypes(captchaTypes);
try {
java.security.KeyPair keyPair = cloud.tianai.captcha.crypto.RsaEncryptor.generateKeyPair();
site.setRsaPublicKey(cloud.tianai.captcha.crypto.RsaEncryptor.publicKeyToBase64(keyPair.getPublic()));
site.setRsaPrivateKey(cloud.tianai.captcha.crypto.RsaEncryptor.privateKeyToBase64(keyPair.getPrivate()));
} catch (Exception e) {
throw new RuntimeException("Failed to generate RSA key pair", e);
}
byte[] aesKey = cloud.tianai.captcha.crypto.AesEncryptor.generateKey();
site.setAesKey(Base64.getEncoder().encodeToString(aesKey));
byte[] signingKey = new byte[32];
new SecureRandom().nextBytes(signingKey);
site.setSigningKey(Base64.getEncoder().encodeToString(signingKey));
return siteRepository.save(site);
}
public Page<CaptchaSite> listSites(Pageable pageable) {
return siteRepository.findAll(pageable);
}
public CaptchaSite getSite(Integer siteId) { return siteRepository.findById(siteId).orElse(null); }
public CaptchaSite updateSite(CaptchaSite site) { return siteRepository.save(site); }
public void deleteSite(Integer siteId) { siteRepository.deleteById(siteId); }
public Map<String, Object> getStats(Integer siteId, int days) {
long total = logRepository.count();
long success = logRepository.countByIsPass(true);
long fail = logRepository.countByIsPass(false);
Map<String, Object> stats = new HashMap<>();
stats.put("total", total);
stats.put("success", success);
stats.put("fail", fail);
stats.put("passRate", total > 0 ? String.format("%.2f%%", success * 100.0 / total) : "0%");
return stats;
}
public void banIp(String ip, String reason, long durationMs) { ipBlacklist.ban(ip, durationMs); }
public void unbanIp(String ip) { ipBlacklist.unban(ip); }
private CaptchaSite validateSite(String siteKeyStr) {
if (siteKeyStr == null) return null;
try {
java.util.UUID uuid = java.util.UUID.fromString(siteKeyStr);
Optional<CaptchaSite> opt = siteRepository.findBySiteKey(uuid);
return opt.filter(site -> site.getIsEnabled()).orElse(null);
} catch (IllegalArgumentException e) {
return null;
}
}
}
@@ -0,0 +1,329 @@
package cloud.tianai.captcha.platform.service;
import cloud.tianai.captcha.ml.TrackFeatureExtractor;
import cloud.tianai.captcha.ml.TrackFeatures;
import cloud.tianai.captcha.ml.TrackRuleEngine;
import cloud.tianai.captcha.ml.TrackRuleEngine.Rule;
import cloud.tianai.captcha.ml.TrackRuleEngine.RuleResult;
import cloud.tianai.captcha.ml.TrackRuleEngine.TrackVerdict;
import cloud.tianai.captcha.platform.entity.TrackSample;
import cloud.tianai.captcha.platform.mapper.TrackSampleRepository;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import cloud.tianai.captcha.validator.impl.EnhancedTrackValidator;
import com.google.gson.Gson;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class TrackLearningService implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(TrackLearningService.class);
private static final String MODEL_PATH = "ml-model/track-rules.json";
private static final int MIN_SAMPLES_FOR_TRAINING = 20;
private final TrackSampleRepository sampleRepository;
private final TrackFeatureExtractor featureExtractor = new TrackFeatureExtractor();
private TrackRuleEngine ruleEngine;
private final EnhancedTrackValidator validator;
private final Gson gson = new Gson();
private final AtomicBoolean trainingInProgress = new AtomicBoolean(false);
private final AtomicInteger totalSamples = new AtomicInteger(0);
private final AtomicInteger humanSamples = new AtomicInteger(0);
private final AtomicInteger botSamples = new AtomicInteger(0);
private final AtomicLong lastTrainingTime = new AtomicLong(0);
private volatile String lastTrainingSummary = "未训练";
public TrackLearningService(TrackSampleRepository sampleRepository, EnhancedTrackValidator validator) {
this.sampleRepository = sampleRepository;
this.validator = validator;
this.ruleEngine = validator.getRuleEngine();
}
public void collectSample(ImageCaptchaTrack track, boolean basicPass, String captchaType, Integer siteId, String ip) {
try {
TrackFeatures features = featureExtractor.extract(track);
TrackVerdict verdict = ruleEngine.evaluate(features);
boolean isHuman = basicPass && verdict.isHuman;
String trackJson = gson.toJson(track);
TrackSample sample = TrackSample.fromFeatures(features, isHuman, verdict.score, basicPass, captchaType, siteId, trackJson, ip);
sampleRepository.save(sample);
totalSamples.incrementAndGet();
if (isHuman) humanSamples.incrementAndGet();
else botSamples.incrementAndGet();
log.debug("[ML] 收集轨迹样本 isHuman={} mlScore={:.3f} basicPass={}", isHuman, verdict.score, basicPass);
} catch (Exception e) {
log.warn("[ML] 收集轨迹样本失败: {}", e.getMessage());
}
}
@Scheduled(fixedDelay = 300000, initialDelay = 60000)
public void scheduledTraining() {
trainIfNeeded();
}
public synchronized Map<String, Object> trainIfNeeded() {
long total = sampleRepository.count();
if (total < MIN_SAMPLES_FOR_TRAINING) {
lastTrainingSummary = "样本不足 (" + total + "/" + MIN_SAMPLES_FOR_TRAINING + "),跳过训练";
return Map.of("status", "skipped", "reason", lastTrainingSummary);
}
if (!trainingInProgress.compareAndSet(false, true)) {
return Map.of("status", "busy", "reason", "训练正在进行中");
}
try {
Map<String, Object> result = doTrain();
lastTrainingTime.set(System.currentTimeMillis());
return result;
} finally {
trainingInProgress.set(false);
}
}
private Map<String, Object> doTrain() {
List<TrackSample> allSamples = sampleRepository.findAll();
List<TrackSample> humanList = new ArrayList<>();
List<TrackSample> botList = new ArrayList<>();
for (TrackSample s : allSamples) {
if (s.getIsHuman() != null) {
if (s.getIsHuman()) humanList.add(s);
else botList.add(s);
}
}
if (humanList.size() < 5 || botList.size() < 5) {
lastTrainingSummary = String.format("正负样本不均衡 human=%d bot=%d", humanList.size(), botList.size());
return Map.of("status", "skipped", "reason", lastTrainingSummary);
}
Map<String, double[]> humanStats = computeStats(humanList);
Map<String, double[]> botStats = computeStats(botList);
List<LearnedRule> learnedRules = new ArrayList<>();
String[] featureNames = {
"totalDuration", "totalPoints", "startOffset", "speedVariance",
"straightness", "yDirectionChanges", "speedPhaseCorrelation",
"maxJumpDistance", "pauses", "overshootRatio",
"accelerationVariance", "xUniformity", "speedSkewness", "pathEfficiency"
};
for (String name : featureNames) {
double[] hStat = humanStats.get(name);
double[] bStat = botStats.get(name);
if (hStat == null || bStat == null) continue;
double hMean = hStat[0], hStd = hStat[1];
double bMean = bStat[0], bStd = bStat[1];
double separation = Math.abs(hMean - bMean) / (hStd + bStd + 0.001);
double weight = Math.min(2.0, 0.5 + separation * 0.5);
double lowThreshold, highThreshold;
if (hMean < bMean) {
lowThreshold = hMean - 2 * hStd;
highThreshold = hMean + 2 * hStd;
} else {
lowThreshold = bMean - 2 * bStd;
highThreshold = bMean + 2 * bStd;
}
learnedRules.add(new LearnedRule(name, weight, hMean, bMean, hStd, bStd, lowThreshold, highThreshold, separation));
}
learnedRules.sort((a, b) -> Double.compare(b.separation, a.separation));
saveModel(learnedRules);
applyModel(learnedRules);
StringBuilder sb = new StringBuilder();
sb.append(String.format("训练完成: %d样本(human=%d,bot=%d), Top规则:\n", allSamples.size(), humanList.size(), botList.size()));
for (int i = 0; i < Math.min(5, learnedRules.size()); i++) {
LearnedRule r = learnedRules.get(i);
sb.append(String.format(" %d. %s (分离度=%.2f, 权重=%.2f)\n", i + 1, r.name, r.separation, r.weight));
}
lastTrainingSummary = sb.toString();
log.info("[ML] {}", lastTrainingSummary);
return Map.of(
"status", "trained",
"totalSamples", allSamples.size(),
"humanSamples", humanList.size(),
"botSamples", botList.size(),
"rules", learnedRules.size(),
"topRules", learnedRules.subList(0, Math.min(5, learnedRules.size()))
);
}
private Map<String, double[]> computeStats(List<TrackSample> samples) {
Map<String, List<Double>> buckets = new LinkedHashMap<>();
String[] featureNames = {
"totalDuration", "totalPoints", "startOffset", "speedVariance",
"straightness", "yDirectionChanges", "speedPhaseCorrelation",
"maxJumpDistance", "pauses", "overshootRatio",
"accelerationVariance", "xUniformity", "speedSkewness", "pathEfficiency"
};
for (String name : featureNames) buckets.put(name, new ArrayList<>());
for (TrackSample s : samples) {
addVal(buckets, "totalDuration", s.getTotalDuration());
addVal(buckets, "totalPoints", s.getTotalPoints());
addVal(buckets, "startOffset", s.getStartOffset());
addVal(buckets, "speedVariance", s.getSpeedVariance());
addVal(buckets, "straightness", s.getStraightness());
addVal(buckets, "yDirectionChanges", s.getYDirectionChanges());
addVal(buckets, "speedPhaseCorrelation", s.getSpeedPhaseCorrelation());
addVal(buckets, "maxJumpDistance", s.getMaxJumpDistance());
addVal(buckets, "pauses", s.getPauses());
addVal(buckets, "overshootRatio", s.getOvershootRatio());
addVal(buckets, "accelerationVariance", s.getAccelerationVariance());
addVal(buckets, "xUniformity", s.getXUniformity());
addVal(buckets, "speedSkewness", s.getSpeedSkewness());
addVal(buckets, "pathEfficiency", s.getPathEfficiency());
}
Map<String, double[]> stats = new LinkedHashMap<>();
for (Map.Entry<String, List<Double>> e : buckets.entrySet()) {
List<Double> vals = e.getValue();
if (vals.isEmpty()) { stats.put(e.getKey(), new double[]{0, 0}); continue; }
double mean = vals.stream().mapToDouble(d -> d).average().orElse(0);
double std = Math.sqrt(vals.stream().mapToDouble(d -> (d - mean) * (d - mean)).average().orElse(0));
stats.put(e.getKey(), new double[]{mean, std});
}
return stats;
}
private void addVal(Map<String, List<Double>> buckets, String key, Number val) {
if (val != null) buckets.get(key).add(val.doubleValue());
}
private void applyModel(List<LearnedRule> learnedRules) {
ruleEngine.getRules().clear();
for (LearnedRule lr : learnedRules) {
ruleEngine.addRule(new Rule("learned_" + lr.name, lr.weight, f -> {
double val = getFeatureValue(f, lr.name);
if (lr.hMean < lr.bMean) {
if (val < lr.lowThreshold) return new RuleResult(0, lr.name + " too low: " + String.format("%.3f", val));
if (val > lr.highThreshold) return new RuleResult(0.3, lr.name + " high: " + String.format("%.3f", val));
return new RuleResult(1, lr.name + " OK: " + String.format("%.3f", val));
} else {
if (val > lr.highThreshold) return new RuleResult(0, lr.name + " too high: " + String.format("%.3f", val));
if (val < lr.lowThreshold) return new RuleResult(0.3, lr.name + " low: " + String.format("%.3f", val));
return new RuleResult(1, lr.name + " OK: " + String.format("%.3f", val));
}
}));
}
log.info("[ML] 已应用 {} 条学习规则到 TrackRuleEngine", learnedRules.size());
}
private double getFeatureValue(TrackFeatures f, String name) {
return switch (name) {
case "totalDuration" -> f.totalDuration;
case "totalPoints" -> f.totalPoints;
case "startOffset" -> f.startOffset;
case "speedVariance" -> f.speedVariance;
case "straightness" -> f.straightness;
case "yDirectionChanges" -> f.yDirectionChanges;
case "speedPhaseCorrelation" -> f.speedPhaseCorrelation;
case "maxJumpDistance" -> f.maxJumpDistance;
case "pauses" -> f.pauses;
case "overshootRatio" -> f.overshootRatio;
case "accelerationVariance" -> f.accelerationVariance;
case "xUniformity" -> f.xUniformity;
case "speedSkewness" -> f.speedSkewness;
case "pathEfficiency" -> f.pathEfficiency;
default -> 0;
};
}
private void saveModel(List<LearnedRule> rules) {
try {
Path dir = Paths.get(MODEL_PATH).getParent();
if (dir != null) Files.createDirectories(dir);
String json = gson.toJson(rules);
Files.writeString(Paths.get(MODEL_PATH), json);
log.info("[ML] 模型已保存到 {}", MODEL_PATH);
} catch (Exception e) {
log.warn("[ML] 保存模型失败: {}", e.getMessage());
}
}
private void loadModel() {
try {
Path path = Paths.get(MODEL_PATH);
if (!Files.exists(path)) {
log.info("[ML] 无已保存模型,使用默认规则");
return;
}
String json = Files.readString(path);
List<LearnedRule> rules = gson.fromJson(json, new com.google.gson.reflect.TypeToken<List<LearnedRule>>() {}.getType());
if (rules != null && !rules.isEmpty()) {
applyModel(rules);
log.info("[ML] 已从 {} 加载 {} 条学习规则", MODEL_PATH, rules.size());
}
} catch (Exception e) {
log.warn("[ML] 加载模型失败: {}", e.getMessage());
}
}
public Map<String, Object> getStatus() {
Map<String, Object> status = new LinkedHashMap<>();
status.put("totalSamples", totalSamples.get());
status.put("humanSamples", humanSamples.get());
status.put("botSamples", botSamples.get());
status.put("lastTrainingTime", lastTrainingTime.get());
status.put("lastTrainingSummary", lastTrainingSummary);
status.put("trainingInProgress", trainingInProgress.get());
status.put("activeRuleCount", ruleEngine.getRules().size());
return status;
}
@Override
public void run(String... args) {
try {
totalSamples.set((int) sampleRepository.count());
humanSamples.set((int) sampleRepository.countByIsHuman(true));
botSamples.set((int) sampleRepository.countByIsHuman(false));
} catch (Exception e) {
log.warn("[ML] 初始化样本统计失败(表可能尚未创建): {}", e.getMessage());
}
loadModel();
log.info("[ML] 轨迹学习服务启动 样本总数={} human={} bot={}", totalSamples.get(), humanSamples.get(), botSamples.get());
}
private static class LearnedRule {
String name;
double weight;
double hMean, bMean, hStd, bStd;
double lowThreshold, highThreshold;
double separation;
LearnedRule() {}
LearnedRule(String name, double weight, double hMean, double bMean, double hStd, double bStd, double lowThreshold, double highThreshold, double separation) {
this.name = name;
this.weight = weight;
this.hMean = hMean;
this.bMean = bMean;
this.hStd = hStd;
this.bStd = bStd;
this.lowThreshold = lowThreshold;
this.highThreshold = highThreshold;
this.separation = separation;
}
}
}
@@ -0,0 +1,36 @@
server:
port: 18200
spring:
datasource:
url: jdbc:postgresql://localhost:5432/captcha_forge
username: postgres
password: XGYnJPysCNJsLeea
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: none
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
sql:
init:
mode: always
data:
redis:
host: localhost
port: 6379
captcha:
prefix: captcha
expire:
default: 120000
init-default-resource: true
local-cache-enabled: true
local-cache-size: 20
logging:
level:
cloud.tianai.captcha: INFO
@@ -0,0 +1,233 @@
DROP TABLE IF EXISTS verification_logs CASCADE;
DROP TABLE IF EXISTS track_samples CASCADE;
DROP TABLE IF EXISTS captcha_challenges CASCADE;
DROP TABLE IF EXISTS captcha_categories CASCADE;
DROP TABLE IF EXISTS sites CASCADE;
DROP TABLE IF EXISTS plans CASCADE;
DROP TABLE IF EXISTS users CASCADE;
DROP TABLE IF EXISTS announcements CASCADE;
DROP TABLE IF EXISTS captcha_ip_blacklist CASCADE;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(128),
password VARCHAR(256) NOT NULL,
role VARCHAR(16) DEFAULT 'USER',
site_amount INT DEFAULT 0,
is_enabled BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE plans (
id SERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
description VARCHAR(256),
qps INT DEFAULT 10,
daily_limit INT DEFAULT 500,
captcha_types TEXT[] DEFAULT '{SLIDER,PUZZLE,TEXT_CLICK,ICON_CLICK,ICON_UNDERSTAND}',
custom_style BOOLEAN DEFAULT FALSE,
is_enabled BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE sites (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
name VARCHAR(64) NOT NULL,
domain VARCHAR(256),
favicon VARCHAR(512),
logo VARCHAR(512),
site_key UUID DEFAULT gen_random_uuid() UNIQUE,
secret_key UUID DEFAULT gen_random_uuid() UNIQUE,
verify_level VARCHAR(16) DEFAULT 'MEDIUM',
qps INT DEFAULT 10,
daily_limit INT DEFAULT 500,
captcha_types TEXT[] DEFAULT '{SLIDER,PUZZLE,TEXT_CLICK,ICON_CLICK,ICON_UNDERSTAND}',
plan_id INT REFERENCES plans(id),
plan_expire_at TIMESTAMPTZ,
is_enabled BOOLEAN DEFAULT TRUE,
rsa_public_key TEXT,
rsa_private_key TEXT,
aes_key VARCHAR(256),
signing_key VARCHAR(256),
track_validation_enabled BOOLEAN DEFAULT TRUE,
track_human_threshold FLOAT DEFAULT 0.5,
obfuscation_enabled BOOLEAN DEFAULT TRUE,
encryption_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
label VARCHAR(64) NOT NULL,
items TEXT[] NOT NULL DEFAULT '{}',
is_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_challenges (
id SERIAL PRIMARY KEY,
category_id INT REFERENCES captcha_categories(id) ON DELETE CASCADE,
prompt TEXT NOT NULL,
correct_items TEXT[] NOT NULL DEFAULT '{}',
difficulty VARCHAR(16) DEFAULT 'MEDIUM',
is_enabled BOOLEAN DEFAULT TRUE,
use_count INT DEFAULT 0,
success_rate DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE verification_logs (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES sites(id),
site_key UUID,
captcha_type VARCHAR(32),
scene VARCHAR(64) DEFAULT 'default',
ip VARCHAR(45),
is_pass BOOLEAN,
behavior_score DOUBLE PRECISION,
risk_level VARCHAR(16),
cost_time INT,
captcha_id VARCHAR(128),
user_agent TEXT,
track_score FLOAT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE track_samples (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES sites(id),
captcha_type VARCHAR(32),
is_human BOOLEAN,
ml_score DOUBLE PRECISION,
basic_pass BOOLEAN,
total_points INT,
total_duration BIGINT,
displacement_x FLOAT,
displacement_y FLOAT,
displacement_x_ratio FLOAT,
total_path_length DOUBLE PRECISION,
path_efficiency DOUBLE PRECISION,
avg_speed FLOAT,
max_speed FLOAT,
min_speed FLOAT,
speed_variance DOUBLE PRECISION,
speed_std_dev DOUBLE PRECISION,
speed_skewness DOUBLE PRECISION,
avg_acceleration FLOAT,
max_acceleration FLOAT,
min_acceleration FLOAT,
acceleration_variance DOUBLE PRECISION,
direction_changes INT,
y_direction_changes INT,
pauses INT,
start_offset DOUBLE PRECISION,
straightness DOUBLE PRECISION,
x_uniformity DOUBLE PRECISION,
y_uniformity DOUBLE PRECISION,
avg_point_interval FLOAT,
speed_phase_correlation DOUBLE PRECISION,
max_jump_distance DOUBLE PRECISION,
overshoot_ratio DOUBLE PRECISION,
track_json TEXT,
ip VARCHAR(45),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(256) NOT NULL,
content TEXT,
is_pinned BOOLEAN DEFAULT FALSE,
is_published BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_ip_blacklist (
id SERIAL PRIMARY KEY,
ip VARCHAR(64) NOT NULL,
reason VARCHAR(256),
ban_until TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_sites_key ON sites(site_key);
CREATE INDEX idx_sites_user ON sites(user_id);
CREATE INDEX idx_challenges_category ON captcha_challenges(category_id);
CREATE INDEX idx_challenges_difficulty ON captcha_challenges(difficulty);
CREATE INDEX idx_logs_site ON verification_logs(site_id);
CREATE INDEX idx_logs_time ON verification_logs(created_at);
CREATE INDEX idx_logs_type ON verification_logs(captcha_type);
CREATE INDEX idx_logs_pass ON verification_logs(is_pass);
CREATE INDEX idx_ip_blacklist_ip ON captcha_ip_blacklist(ip);
CREATE INDEX idx_track_human ON track_samples(is_human);
CREATE INDEX idx_track_type ON track_samples(captcha_type);
CREATE INDEX idx_track_time ON track_samples(created_at);
CREATE INDEX idx_track_score ON track_samples(ml_score);
INSERT INTO users (username, email, password, role, site_amount, is_enabled, is_system)
VALUES ('admin', 'admin@captchaforge.local', '$2a$10$85QOiy3qD5KPuxyrrN/LcuZe3ty/OrTk6yEKAJEGtJKk97ukw2yPG', 'ADMIN', 999, TRUE, TRUE);
INSERT INTO plans (name, description, qps, daily_limit, is_enabled, is_system) VALUES
('免费套餐', '默认免费套餐', 5, 500, TRUE, TRUE),
('基础套餐', '适合中小站点', 20, 5000, TRUE, TRUE),
('专业套餐', '适合大型站点', 100, 50000, TRUE, TRUE);
INSERT INTO captcha_categories (name, label, items) VALUES
('animals', '动物', '{"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐔","🐧","🐦","🦅","🦆","🦉","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳","🐊","🐘","🦏","🐪","🦒","🐕","🐈","🐓","🦃","🦚","🦜","🐇","🦔","🐺","🐗"}'),
('food', '食物饮品', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🌽","🥕","🍞","🧀","🍳","🍔","🍟","🍕","🌮","🍣","🍜","🍩","🍪","🎂","🍫","🍭","☕","🍵","🧃","🥤","🍺","🍷"}'),
('vehicles', '交通工具', '{"🚗","🚕","🚙","🚌","🏎","🚓","🚑","🚒","🚐","🚚","🚛","🚜","🚲","🛵","🏍","🚨","🚔","🚡","🚠","🚃","🚋","🚄","🚅","🚂","✈️","🛩","🚀","🛸","🚁","⛵","🚤","🛳","⛴","🚢"}'),
('nature', '天气自然', '{"☀️","🌤","⛅","🌥","☁️","🌦","🌧","⛈","🌩","🌨","❄️","☃️","🌪","🌈","🌊","💧","🔥","⭐","🌟","✨","⚡","☄️","🌸","🌺","🌻","🌹","🌷","🌱","🌿","🍀","🍁","🍂","🍃","🌴","🌵"}'),
('sports', '运动娱乐', '{"⚽","🏀","🏈","⚾","🎾","🏐","🏉","🎱","🏓","🏸","🏒","🏑","🥍","🎯","🎳","🎮","🎲","♟","🧩","🪀","🪁","🎪","🤹","🎭","🎨","🎬","🎤","🎧","🎹","🥁","🎸","🎻"}'),
('buildings', '建筑地点', '{"🏠","🏡","🏢","🏣","🏤","🏥","🏦","🏨","🏩","🏪","🏫","🏬","🏭","🏯","🏰","💒","🗼","🗽","⛪","🕌","🛕","🕍","⛩","🕋","⛲","⛺","🏕"}'),
('objects', '电子物品', '{"⌚","📱","💻","⌨️","🖥","🖨","🖱","🖲","💾","💿","📷","📹","🎥","📞","📺","📻","🔋","🔌","💡","🔦","🕯","🔑","🔒","🔓","📧","📮","📦","📋","📁","✏️","🖊","🖋","✒️","🖌","📝","🔍","📎","📐","📌","✂️","🧲","🔧","🔨","⚙️","💊","💉","🩺","🧬","🔭","🔬","🧪"}'),
('gestures', '手势动作', '{"👋","🤚","🖐","✋","🖖","👌","🤌","🤏","✌","🤞","🤟","🤘","🤙","👈","👉","👆","👇","☝️","👍","👎","✊","👊","🤛","🤜","👏","🙌","👐","🤲","🤝","🙏","✍️","💅","🤳","💪"}');
INSERT INTO captcha_challenges (category_id, prompt, correct_items, difficulty) VALUES
(1, '请点击所有的猫科动物', '{"🐱","🐯","🦁","🐈"}', 'MEDIUM'),
(1, '请点击所有的犬科动物', '{"🐶","🐺","🐕"}', 'MEDIUM'),
(1, '请点击所有的鸟类', '{"🐔","🐧","🐦","🦅","🦆","🦉","🦜","🦚","🦃"}', 'MEDIUM'),
(1, '请点击所有的水生动物', '{"🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳"}', 'MEDIUM'),
(1, '请点击所有的昆虫', '{"🐝","🐛","🦋","🐌","🐞","🦟"}', 'MEDIUM'),
(2, '请点击所有的水果', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥝"}', 'MEDIUM'),
(2, '请点击所有的蔬菜', '{"🍅","🍆","🥑","🥦","🌽","🥕"}', 'MEDIUM'),
(2, '请点击所有的饮品', '{"☕","🍵","🧃","🥤","🍺","🍷"}', 'MEDIUM'),
(2, '请点击所有的甜点', '{"🍩","🍪","🎂","🍫","🍭"}', 'MEDIUM'),
(3, '请点击所有的汽车', '{"🚗","🚕","🚙","🏎","🚓","🚑","🚒","🚐"}', 'MEDIUM'),
(3, '请点击所有的飞行器', '{"✈️","🛩","🚀","🛸","🚁"}', 'MEDIUM'),
(3, '请点击所有的船只', '{"⛵","🚤","🛳","⛴","🚢"}', 'MEDIUM'),
(4, '请点击所有与降水相关的', '{"🌦","🌧","⛈","🌩","🌨"}', 'MEDIUM'),
(4, '请点击所有的花卉', '{"🌸","🌺","🌻","🌹","🌷"}', 'MEDIUM'),
(4, '请点击所有的天体', '{"☀️","⭐","🌟","✨","⚡","☄️"}', 'MEDIUM'),
(5, '请点击所有的球类运动', '{"⚽","🏀","🏈","⚾","🎾","🏐","🏉"}', 'MEDIUM'),
(5, '请点击所有的音乐相关', '{"🎤","🎧","🎹","🥁","🎸","🎻"}', 'MEDIUM'),
(5, '请点击所有的棋牌游戏', '{"🎲","♟","🧩","🎮","🎯","🎱"}', 'MEDIUM'),
(1, '请点击所有的动物', '{"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐔","🐧","🐦","🦅","🦆","🦉","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳","🐊","🐘","🦏","🐪","🦒","🐕","🐈","🐓","🦃","🦚","🦜","🐇","🦔","🐺","🐗"}', 'LOW'),
(2, '请点击所有的食物饮品', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🌽","🥕","🍞","🧀","🍳","🍔","🍟","🍕","🌮","🍣","🍜","🍩","🍪","🎂","🍫","🍭","☕","🍵","🧃","🥤","🍺","🍷"}', 'LOW'),
(3, '请点击所有的交通工具', '{"🚗","🚕","🚙","🚌","🏎","🚓","🚑","🚒","🚐","🚚","🚛","🚜","🚲","🛵","🏍","🚨","🚔","🚡","🚠","🚃","🚋","🚄","🚅","🚂","✈️","🛩","🚀","🛸","🚁","⛵","🚤","🛳","⛴","🚢"}', 'LOW'),
(4, '请点击所有的天气自然', '{"☀️","🌤","⛅","🌥","☁️","🌦","🌧","⛈","🌩","🌨","❄️","☃️","🌪","🌈","🌊","💧","🔥","⭐","🌟","✨","⚡","☄️","🌸","🌺","🌻","🌹","🌷","🌱","🌿","🍀","🍁","🍂","🍃","🌴","🌵"}', 'LOW');
INSERT INTO announcements (title, content, is_pinned, is_published, sort_order) VALUES
('tianai-captcha-enhanced 2.0.0 上线', '# tianai-captcha-enhanced
基于 tianai-captcha 开源版的增强版行为验证码平台
## 特性
- 16种验证码类型
- ML轨迹校验器(28维特征+14条规则)
- 对抗扰动防YOLO
- 行为风控引擎
- 端到端加密(AES-256+RSA-4096)
- 背景乱序/正弦扭曲/噪声注入
- IP黑名单+滑动窗口限流', TRUE, TRUE, 0);