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:
@@ -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"]
|
||||
@@ -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>
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+105
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+65
@@ -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";
|
||||
}
|
||||
}
|
||||
+98
@@ -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());
|
||||
}
|
||||
}
|
||||
+91
@@ -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; }
|
||||
}
|
||||
+158
@@ -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; }
|
||||
}
|
||||
+239
@@ -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; }
|
||||
}
|
||||
+8
@@ -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);
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+242
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+329
@@ -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);
|
||||
Reference in New Issue
Block a user