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 图标预渲染工具
@@ -26,3 +26,9 @@ target
|
|||||||
|
|
||||||
.flattened-pom.xml
|
.flattened-pom.xml
|
||||||
**/.flattened-pom.xml
|
**/.flattened-pom.xml
|
||||||
|
|
||||||
|
### 本地环境 ###
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.omo/
|
||||||
|
.codegraph/
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# AGENTS.md - tianai-captcha-enhanced
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
基于 tianai-captcha 1.5.5 开源版的增强版行为验证码,目标是复现增强版全部功能并自建验证码服务平台。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 组件 | 版本 |
|
||||||
|
|------|------|
|
||||||
|
| JDK | 21 (Microsoft OpenJDK 21.0.11 LTS) |
|
||||||
|
| Maven | 3.9.16 |
|
||||||
|
| Spring Boot | 3.4.5 |
|
||||||
|
| Lombok | 1.18.42 (后续逐步移除) |
|
||||||
|
| SLF4J | 2.0.17 |
|
||||||
|
| ONNX Runtime | 1.22.0 (ML推理,待引入) |
|
||||||
|
|
||||||
|
## 环境路径
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:JAVA_HOME = "C:\Users\USER879511\.jdks\ms-21.0.11"
|
||||||
|
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
|
||||||
|
$Maven = "D:\Middleware\environment\apache-maven-3.9.16\bin\mvn.cmd"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建 & 验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:JAVA_HOME = "C:\Users\USER879511\.jdks\ms-21.0.11"
|
||||||
|
& "D:\Middleware\environment\apache-maven-3.9.16\bin\mvn.cmd" -f "D:\tianai-captcha-enhanced\pom.xml" compile
|
||||||
|
& "D:\Middleware\environment\apache-maven-3.9.16\bin\mvn.cmd" -f "D:\tianai-captcha-enhanced\pom.xml" package -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目版本
|
||||||
|
|
||||||
|
从 `1.5.5` 升级到 `2.0.0-SNAPSHOT`,表示这是增强版的开发版本。
|
||||||
|
|
||||||
|
## 模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
tianai-captcha-enhanced/
|
||||||
|
├── tianai-captcha/ # 核心模块 (JDK 21, Spring Boot 3.4)
|
||||||
|
├── tianai-captcha-springboot-starter/ # Spring Boot Starter (3.4.5)
|
||||||
|
├── tianai-captcha-web-sdk/ # 前端SDK (待增强)
|
||||||
|
├── tianai-captcha-platform/ # 管理平台后端 (待创建)
|
||||||
|
└── tianai-captcha-platform-ui/ # 管理平台前端 (待创建)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 增强版功能计划
|
||||||
|
|
||||||
|
### 验证码类型 (对标官网16种)
|
||||||
|
|
||||||
|
| 类型 | 常量 | 开源版 | 增强版 | 优先级 |
|
||||||
|
|------|------|:------:|:------:|:------:|
|
||||||
|
| 滑块验证 | SLIDER | ✅ | ✅ | P0 |
|
||||||
|
| 滑块验证V2 | SLIDER_V2 | ❌ | 🔜 | P1 |
|
||||||
|
| 曲线滑块 | CURVE_SLIDER | ❌ | 🔜 | P1 |
|
||||||
|
| 曲线滑块V2 | CURVE_SLIDER_V2 | ❌ | 🔜 | P2 |
|
||||||
|
| 曲线滑块V3 | CURVE_SLIDER_V3 | ❌ | 🔜 | P2 |
|
||||||
|
| 旋转验证 | ROTATE | ✅ | ✅ | P0 |
|
||||||
|
| 滑动还原 | CONCAT | ✅ | ✅ | P0 |
|
||||||
|
| 角度验证 | ANGLE | ❌ | 🔜 | P1 |
|
||||||
|
| 刮刮乐 | SCRATCH | ❌ | 🔜 | P1 |
|
||||||
|
| 文字点选 | WORD_IMAGE_CLICK | ✅ | ✅ | P0 |
|
||||||
|
| 图标点选 | ICON_CLICK | ❌ | 🔜 | P1 |
|
||||||
|
| 语序点选 | WORD_ORDER_CLICK | ❌ | 🔜 | P1 |
|
||||||
|
| 乱序拼图 | JIGSAW | ❌ | 🔜 | P1 |
|
||||||
|
| 曲线绘制 | CURVE_DRAW | ❌ | 🔜 | P2 |
|
||||||
|
| 工作量证明 | PROOF_OF_WORK | ❌ | 🔜 | P2 |
|
||||||
|
| 随机 | RANDOM | ✅ | ✅ | P0 |
|
||||||
|
|
||||||
|
### 增强功能 (对标付费版10项)
|
||||||
|
|
||||||
|
| 功能 | 优先级 | 状态 |
|
||||||
|
|------|:------:|:----:|
|
||||||
|
| 全新加密算法 (AES-256 + RSA-4096 + SHA256) | P0 | 🔜 |
|
||||||
|
| ML轨迹校验器 (规则引擎 + ONNX) | P0 | 🔜 |
|
||||||
|
| 位置校验 (动态容错) | P0 | ✅ 已有基础 |
|
||||||
|
| 验证码背景乱序 | P1 | 🔜 |
|
||||||
|
| 差异校验 (防重放) | P1 | 🔜 |
|
||||||
|
| 请求次数校验 (滑动窗口限流) | P1 | 🔜 |
|
||||||
|
| 点选类图片背景扭曲 | P1 | 🔜 |
|
||||||
|
| IP禁用 | P1 | 🔜 |
|
||||||
|
| 轨迹相似性校验 (DTW) | P2 | 🔜 |
|
||||||
|
| 轨迹段落区分 | P2 | 🔜 |
|
||||||
|
|
||||||
|
### 平台化功能
|
||||||
|
|
||||||
|
| 功能 | 状态 |
|
||||||
|
|------|:----:|
|
||||||
|
| 站点管理 (SiteKey/SecretKey) | 🔜 |
|
||||||
|
| verifyToken 一次性消费 | 🔜 |
|
||||||
|
| 前端SDK (兼容TPCaptcha API) | 🔜 |
|
||||||
|
| 管理后台 | 🔜 |
|
||||||
|
| 统计监控 | 🔜 |
|
||||||
|
|
||||||
|
## 编码规范
|
||||||
|
|
||||||
|
- 包结构遵循开源版: application/generator/validator/resource/interceptor/cache/common
|
||||||
|
- 新增包: crypto/obfuscator/risk/site/ml
|
||||||
|
- API 响应统一使用 ApiResponse<T>
|
||||||
|
- 暂时保留 Lombok,后续逐步替换为手写 getter/setter
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"name":"speedPhaseCorrelation","weight":0.6909424674992277,"hMean":0.955714239708839,"bMean":0.37967247512170127,"hStd":0.7442769634838727,"bStd":0.7631401323344459,"lowThreshold":-1.1466077895471904,"highThreshold":1.905952739790593,"separation":0.38188493499845555},{"name":"yDirectionChanges","weight":0.6565594255113425,"hMean":0.16129032258064516,"bMean":0.0,"hStd":0.5141089500164266,"bStd":0.0,"lowThreshold":0.0,"highThreshold":0.0,"separation":0.31311885102268494},{"name":"startOffset","weight":0.6133086990872181,"hMean":54.39618866626594,"bMean":14.656055315776996,"hStd":125.3553265098474,"bStd":50.005908122726716,"lowThreshold":-85.35576092967644,"highThreshold":114.66787156123043,"separation":0.22661739817443616},{"name":"xUniformity","weight":0.6037572020042908,"hMean":41.97851401736458,"bMean":16.237024125419424,"hStd":93.48773946787334,"bStd":30.558022562586117,"lowThreshold":-44.87902099975281,"highThreshold":77.35306925059166,"separation":0.2075144040085817},{"name":"speedVariance","weight":0.5856956110673432,"hMean":74.39660229654821,"bMean":530.9315858041267,"hStd":295.3999286413061,"bStd":2368.3001513872487,"lowThreshold":-516.403254986064,"highThreshold":665.1964595791603,"separation":0.17139122213468655},{"name":"accelerationVariance","weight":0.5814165154169918,"hMean":128.33025463499524,"bMean":1173.3393963589565,"hStd":549.7412804607712,"bStd":5867.930696402256,"lowThreshold":-971.1523062865472,"highThreshold":1227.8128155565378,"separation":0.16283303083398368},{"name":"totalDuration","weight":0.5686726385595083,"hMean":3488.8387096774195,"bMean":33173.4367816092,"hStd":3768.1041018491687,"bStd":212363.08407772906,"lowThreshold":-4047.369494020918,"highThreshold":11025.046913375758,"separation":0.13734527711901662},{"name":"pauses","weight":0.568510610386744,"hMean":6.32258064516129,"bMean":4.873563218390805,"hStd":4.222727270181567,"bStd":6.35140422106299,"lowThreshold":-7.829245223735175,"highThreshold":17.576371660516784,"separation":0.13702122077348794},{"name":"pathEfficiency","weight":0.5649463332759833,"hMean":0.765124453338266,"bMean":0.6726903175221292,"hStd":0.32528093053197915,"bStd":0.38533842620173075,"lowThreshold":-0.09798653488133235,"highThreshold":1.4433671699255908,"separation":0.12989266655196666},{"name":"overshootRatio","weight":0.5606943125493219,"hMean":0.10818778344940755,"bMean":5.116671776291968,"hStd":0.3023070391177473,"bStd":40.95660498948067,"lowThreshold":-0.4964262947860871,"highThreshold":0.7128018616849021,"separation":0.12138862509864388},{"name":"totalPoints","weight":0.5332468203395597,"hMean":76.93548387096774,"bMean":89.47126436781609,"hStd":71.92017774381091,"bStd":116.60482818422503,"lowThreshold":-66.90487161665408,"highThreshold":220.77583935858956,"separation":0.06649364067911935},{"name":"speedSkewness","weight":0.531573374575929,"hMean":2.6601663008886245,"bMean":2.2463177619223123,"hStd":3.3705027899373414,"bStd":3.1822556303270444,"lowThreshold":-4.1181934987317765,"highThreshold":8.6108290225764,"separation":0.06314674915185799},{"name":"maxJumpDistance","weight":0.5295855247419992,"hMean":113.78601562767373,"bMean":97.8237766984338,"hStd":158.22646306898454,"bStd":111.53687385656968,"lowThreshold":-125.24997101470557,"highThreshold":320.89752441157316,"separation":0.059171049483998206},{"name":"straightness","weight":0.503498502203403,"hMean":0.16895565571775595,"bMean":0.17599076422921264,"hStd":0.6278592842222325,"bStd":0.37658648977407716,"lowThreshold":-1.086762912726709,"highThreshold":1.4246742241622208,"separation":0.0069970044068060466}]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>tianai-captcha 管理平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>史诗质感 - 真·双剑与盾行为验证 (强效魔法脉冲版)</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #0f172a;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-container {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 1. 盾牌过渡 --- */
|
||||||
|
.shield-shard {
|
||||||
|
transform-origin: 50px 50px;
|
||||||
|
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1), fill 0.4s ease, opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 2. 强效魔法结界脉冲 --- */
|
||||||
|
.ward-group {
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
.ward-pulse {
|
||||||
|
transform-origin: 50px 50px;
|
||||||
|
/* 加快了一点点脉冲节奏,显得能量更活跃 */
|
||||||
|
animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite;
|
||||||
|
}
|
||||||
|
.ward-pulse.delay {
|
||||||
|
/* 延迟时间调整,形成错落有致的双重波纹 */
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 核心修改:大幅增加粗细、透明度和放大倍率 */
|
||||||
|
@keyframes pulse-ward {
|
||||||
|
0% { transform: scale(0.4); opacity: 1; stroke-width: 16px; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
100% { transform: scale(1.7); opacity: 0; stroke-width: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 3. 巨剑插入动画 --- */
|
||||||
|
.sword-slide {
|
||||||
|
transform: translateY(-85px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.4), opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-anchor-a { transform-origin: 50px 50px; transform: rotate(-45deg); }
|
||||||
|
.sword-anchor-b { transform-origin: 50px 50px; transform: rotate(45deg); }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- 4. 状态控制 --- */
|
||||||
|
.state-wait .ward-group { opacity: 1; }
|
||||||
|
.state-wait .shield-shard {
|
||||||
|
fill: url(#metalBlueGrad);
|
||||||
|
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
/* 盾牌本身也加上强力的魔法蓝光阴影 */
|
||||||
|
filter: drop-shadow(0 0 20px rgba(56, 189, 248, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-success .ward-group { opacity: 0; }
|
||||||
|
.state-success .shield-shard {
|
||||||
|
fill: url(#metalGreenGrad);
|
||||||
|
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
filter: drop-shadow(0 6px 8px rgba(0,0,0,0.5));
|
||||||
|
}
|
||||||
|
.state-success .sword-slide { transform: translateY(-15px); opacity: 1; }
|
||||||
|
|
||||||
|
.state-fail .ward-group { opacity: 0; }
|
||||||
|
.state-fail .shield-shard { fill: url(#metalRedGrad); opacity: 0.9; filter: none;}
|
||||||
|
.state-fail .shard-tl { transform: translate(-25px, -30px) scale(0.9) rotateX(30deg) rotateY(-20deg) rotateZ(-15deg); opacity: 0.7; }
|
||||||
|
.state-fail .shard-tr { transform: translate(25px, -30px) scale(0.9) rotateX(30deg) rotateY(20deg) rotateZ(15deg); opacity: 0.7; }
|
||||||
|
.state-fail .shard-b { transform: translate(0, 35px) scale(0.8) rotateX(-20deg) rotateZ(5deg); opacity: 0.5; }
|
||||||
|
|
||||||
|
|
||||||
|
.controls { display: flex; gap: 15px; }
|
||||||
|
.controls button {
|
||||||
|
padding: 12px 24px; font-size: 14px; font-weight: bold; cursor: pointer;
|
||||||
|
border: 2px solid transparent; border-radius: 8px; background: #1e293b; color: #f1f5f9;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.controls button:hover { background: #334155; transform: translateY(-2px); }
|
||||||
|
.controls button:active { transform: translateY(1px); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="captcha-container">
|
||||||
|
<svg id="captcha-svg" class="captcha-icon state-wait" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="metalBlueGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3B82F6" /><stop offset="50%" stop-color="#1E40AF" /><stop offset="100%" stop-color="#60A5FA" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="metalGreenGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#10B981" /><stop offset="50%" stop-color="#047857" /><stop offset="100%" stop-color="#34D399" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="metalRedGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#EF4444" /><stop offset="50%" stop-color="#B91C1C" /><stop offset="100%" stop-color="#F87171" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="bladeLight" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#f8fafc" /><stop offset="100%" stop-color="#94a3b8" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bladeDark" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#64748b" /><stop offset="100%" stop-color="#334155" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FDE047" /><stop offset="50%" stop-color="#EAB308" /><stop offset="100%" stop-color="#A16207" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="swordShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="3" dy="5" stdDeviation="2.5" flood-color="#000000" flood-opacity="0.7"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter id="epicMagicGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur1" />
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur2" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur2" />
|
||||||
|
<feMergeNode in="blur1" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<g id="epic-sword" filter="url(#swordShadow)">
|
||||||
|
<polygon points="43,26 50,26 50,115" fill="url(#bladeLight)" />
|
||||||
|
<polygon points="50,26 57,26 50,115" fill="url(#bladeDark)" />
|
||||||
|
<rect x="45" y="4" width="10" height="22" fill="#1e293b" />
|
||||||
|
<path d="M 45 6 L 55 10 M 45 12 L 55 16 M 45 18 L 55 22 M 45 24 L 55 28" stroke="#475569" stroke-width="2" />
|
||||||
|
<polygon points="20,22 50,26 80,22 80,27 50,32 20,27" fill="#713F12" />
|
||||||
|
<polygon points="20,22 50,26 80,22 50,24" fill="url(#goldGrad)" />
|
||||||
|
<polygon points="50,-6 57,0 50,6 43,0" fill="url(#goldGrad)" />
|
||||||
|
<polygon points="50,-6 57,0 50,6" fill="rgba(0,0,0,0.25)" />
|
||||||
|
</g>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="ward-group" filter="url(#epicMagicGlow)">
|
||||||
|
<circle class="ward-pulse" cx="50" cy="50" r="30" fill="rgba(56, 189, 248, 0.15)" stroke="#38BDF8" stroke-linecap="round" />
|
||||||
|
<circle class="ward-pulse delay" cx="50" cy="50" r="30" fill="rgba(56, 189, 248, 0.15)" stroke="#38BDF8" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="shield-main-group">
|
||||||
|
<path class="shield-shard shard-tl" d="M 50 12 L 18 28 L 18 52 L 50 48 Z" fill="url(#metalBlueGrad)" />
|
||||||
|
<path class="shield-shard shard-tr" d="M 50 12 L 82 28 L 82 52 L 50 48 Z" fill="url(#metalBlueGrad)" />
|
||||||
|
<path class="shield-shard shard-b" d="M 18 52 C 18 78 50 92 50 92 C 50 92 82 78 82 52 L 50 48 Z" fill="url(#metalBlueGrad)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="sword-anchor-a">
|
||||||
|
<g class="sword-slide">
|
||||||
|
<use href="#epic-sword" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="sword-anchor-b">
|
||||||
|
<g class="sword-slide">
|
||||||
|
<use href="#epic-sword" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick="changeState('state-wait')" style="border-color: #3B82F6;">🛡️ 结界维持中...</button>
|
||||||
|
<button onclick="changeState('state-success')" style="border-color: #10B981; color: #10B981;">⚔️ 史诗验证成功</button>
|
||||||
|
<button onclick="changeState('state-fail')" style="border-color: #EF4444; color: #EF4444;">💥 盾牌碎裂</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function changeState(newStateClass) {
|
||||||
|
const svg = document.getElementById('captcha-svg');
|
||||||
|
svg.classList.remove('state-wait', 'state-success', 'state-fail');
|
||||||
|
svg.classList.add(newStateClass);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<!-- 渐变色定义 -->
|
||||||
|
<linearGradient id="shieldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#4F46E5" /> <!-- 科技靛蓝 -->
|
||||||
|
<stop offset="100%" stop-color="#06B6D4" /> <!-- 灵动青色 -->
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#10B981" /> <!-- 安全绿 -->
|
||||||
|
<stop offset="100%" stop-color="#3B82F6" /> <!-- 信任蓝 -->
|
||||||
|
</linearGradient>
|
||||||
|
<!-- 发光滤镜 -->
|
||||||
|
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 外部盾牌形状 (象征安全防护) -->
|
||||||
|
<path d="M 50 10 L 85 25 L 85 50 C 85 75 50 90 50 90 C 50 90 15 75 15 50 L 15 25 Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#shieldGrad)"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round" />
|
||||||
|
|
||||||
|
<!-- 内部动态扫描环 (象征行为分析与智能验证) -->
|
||||||
|
<circle cx="50" cy="50" r="16"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#ringGrad)"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-dasharray="60 40"
|
||||||
|
stroke-linecap="round"
|
||||||
|
filter="url(#glow)">
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- 中心脉冲核心 (象征触控/鼠标焦点) -->
|
||||||
|
<circle cx="50" cy="50" r="4" fill="#06B6D4">
|
||||||
|
<animate attributeName="r" values="3; 6; 3" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="opacity" values="0.4; 1; 0.4" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- 上下装饰轨迹线 (象征运动轨迹) -->
|
||||||
|
<path d="M 35 38 Q 50 28 65 38" fill="none" stroke="#4F46E5" stroke-width="2" stroke-linecap="round" opacity="0.6">
|
||||||
|
<animate attributeName="opacity" values="0.2; 0.8; 0.2" dur="2s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
<path d="M 35 62 Q 50 72 65 62" fill="none" stroke="#4F46E5" stroke-width="2" stroke-linecap="round" opacity="0.6">
|
||||||
|
<animate attributeName="opacity" values="0.8; 0.2; 0.8" dur="2s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,191 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>史诗质感 - 真·双剑与盾行为验证 (锐利光环版)</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #0f172a;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-container {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 1. 盾牌过渡 --- */
|
||||||
|
.shield-shard {
|
||||||
|
transform-origin: 50px 50px;
|
||||||
|
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1), fill 0.4s ease, opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 2. 纯净空心脉冲 --- */
|
||||||
|
.ward-group {
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
.ward-pulse {
|
||||||
|
transform-origin: 50px 50px;
|
||||||
|
animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite;
|
||||||
|
}
|
||||||
|
.ward-pulse.delay {
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移除了夸张的缩放和粗细变化,改为更清爽的光圈扩散 */
|
||||||
|
@keyframes pulse-ward {
|
||||||
|
0% { transform: scale(0.6); opacity: 1; stroke-width: 6px; }
|
||||||
|
100% { transform: scale(1.8); opacity: 0; stroke-width: 1px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 3. 巨剑插入动画 --- */
|
||||||
|
.sword-slide {
|
||||||
|
transform: translateY(-85px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.4), opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-anchor-a { transform-origin: 50px 50px; transform: rotate(-45deg); }
|
||||||
|
.sword-anchor-b { transform-origin: 50px 50px; transform: rotate(45deg); }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- 4. 状态控制 --- */
|
||||||
|
.state-wait .ward-group { opacity: 1; }
|
||||||
|
.state-wait .shield-shard {
|
||||||
|
fill: url(#metalBlueGrad);
|
||||||
|
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
/* 【核心修复】:移除发光,改回黑色的物理实体阴影,保证盾牌边缘极其锐利 */
|
||||||
|
filter: drop-shadow(0 6px 8px rgba(0,0,0,0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-success .ward-group { opacity: 0; }
|
||||||
|
.state-success .shield-shard {
|
||||||
|
fill: url(#metalGreenGrad);
|
||||||
|
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
filter: drop-shadow(0 6px 8px rgba(0,0,0,0.6));
|
||||||
|
}
|
||||||
|
.state-success .sword-slide { transform: translateY(-15px); opacity: 1; }
|
||||||
|
|
||||||
|
.state-fail .ward-group { opacity: 0; }
|
||||||
|
.state-fail .shield-shard { fill: url(#metalRedGrad); opacity: 0.9; filter: none;}
|
||||||
|
.state-fail .shard-tl { transform: translate(-25px, -30px) scale(0.9) rotateX(30deg) rotateY(-20deg) rotateZ(-15deg); opacity: 0.7; }
|
||||||
|
.state-fail .shard-tr { transform: translate(25px, -30px) scale(0.9) rotateX(30deg) rotateY(20deg) rotateZ(15deg); opacity: 0.7; }
|
||||||
|
.state-fail .shard-b { transform: translate(0, 35px) scale(0.8) rotateX(-20deg) rotateZ(5deg); opacity: 0.5; }
|
||||||
|
|
||||||
|
|
||||||
|
.controls { display: flex; gap: 15px; }
|
||||||
|
.controls button {
|
||||||
|
padding: 12px 24px; font-size: 14px; font-weight: bold; cursor: pointer;
|
||||||
|
border: 2px solid transparent; border-radius: 8px; background: #1e293b; color: #f1f5f9;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.controls button:hover { background: #334155; transform: translateY(-2px); }
|
||||||
|
.controls button:active { transform: translateY(1px); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="captcha-container">
|
||||||
|
<svg id="captcha-svg" class="captcha-icon state-wait" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="metalBlueGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3B82F6" /><stop offset="50%" stop-color="#1E40AF" /><stop offset="100%" stop-color="#60A5FA" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="metalGreenGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#10B981" /><stop offset="50%" stop-color="#047857" /><stop offset="100%" stop-color="#34D399" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="metalRedGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#EF4444" /><stop offset="50%" stop-color="#B91C1C" /><stop offset="100%" stop-color="#F87171" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="bladeLight" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#f8fafc" /><stop offset="100%" stop-color="#94a3b8" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bladeDark" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#64748b" /><stop offset="100%" stop-color="#334155" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FDE047" /><stop offset="50%" stop-color="#EAB308" /><stop offset="100%" stop-color="#A16207" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="swordShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="3" dy="5" stdDeviation="2.5" flood-color="#000000" flood-opacity="0.7"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter id="cleanMagicGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<g id="epic-sword" filter="url(#swordShadow)">
|
||||||
|
<polygon points="43,26 50,26 50,115" fill="url(#bladeLight)" />
|
||||||
|
<polygon points="50,26 57,26 50,115" fill="url(#bladeDark)" />
|
||||||
|
<rect x="45" y="4" width="10" height="22" fill="#1e293b" />
|
||||||
|
<path d="M 45 6 L 55 10 M 45 12 L 55 16 M 45 18 L 55 22 M 45 24 L 55 28" stroke="#475569" stroke-width="2" />
|
||||||
|
<polygon points="20,22 50,26 80,22 80,27 50,32 20,27" fill="#713F12" />
|
||||||
|
<polygon points="20,22 50,26 80,22 50,24" fill="url(#goldGrad)" />
|
||||||
|
<polygon points="50,-6 57,0 50,6 43,0" fill="url(#goldGrad)" />
|
||||||
|
<polygon points="50,-6 57,0 50,6" fill="rgba(0,0,0,0.25)" />
|
||||||
|
</g>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="ward-group" filter="url(#cleanMagicGlow)">
|
||||||
|
<circle class="ward-pulse" cx="50" cy="50" r="35" fill="none" stroke="#38BDF8" stroke-linecap="round" />
|
||||||
|
<circle class="ward-pulse delay" cx="50" cy="50" r="35" fill="none" stroke="#38BDF8" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="shield-main-group">
|
||||||
|
<path class="shield-shard shard-tl" d="M 50 12 L 18 28 L 18 52 L 50 48 Z" fill="url(#metalBlueGrad)" />
|
||||||
|
<path class="shield-shard shard-tr" d="M 50 12 L 82 28 L 82 52 L 50 48 Z" fill="url(#metalBlueGrad)" />
|
||||||
|
<path class="shield-shard shard-b" d="M 18 52 C 18 78 50 92 50 92 C 50 92 82 78 82 52 L 50 48 Z" fill="url(#metalBlueGrad)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="sword-anchor-a">
|
||||||
|
<g class="sword-slide">
|
||||||
|
<use href="#epic-sword" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="sword-anchor-b">
|
||||||
|
<g class="sword-slide">
|
||||||
|
<use href="#epic-sword" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick="changeState('state-wait')" style="border-color: #3B82F6;">🛡️ 结界维持中...</button>
|
||||||
|
<button onclick="changeState('state-success')" style="border-color: #10B981; color: #10B981;">⚔️ 史诗验证成功</button>
|
||||||
|
<button onclick="changeState('state-fail')" style="border-color: #EF4444; color: #EF4444;">💥 盾牌碎裂</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function changeState(newStateClass) {
|
||||||
|
const svg = document.getElementById('captcha-svg');
|
||||||
|
svg.classList.remove('state-wait', 'state-success', 'state-fail');
|
||||||
|
svg.classList.add(newStateClass);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "tianai-captcha-platform-ui",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"naive-ui": "^2.40.0",
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"vue-router": "^4.4.0",
|
||||||
|
"pinia": "^2.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<!-- 渐变色定义 -->
|
||||||
|
<linearGradient id="shieldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#4F46E5" /> <!-- 科技靛蓝 -->
|
||||||
|
<stop offset="100%" stop-color="#06B6D4" /> <!-- 灵动青色 -->
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#10B981" /> <!-- 安全绿 -->
|
||||||
|
<stop offset="100%" stop-color="#3B82F6" /> <!-- 信任蓝 -->
|
||||||
|
</linearGradient>
|
||||||
|
<!-- 发光滤镜 -->
|
||||||
|
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 外部盾牌形状 (象征安全防护) -->
|
||||||
|
<path d="M 50 10 L 85 25 L 85 50 C 85 75 50 90 50 90 C 50 90 15 75 15 50 L 15 25 Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#shieldGrad)"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round" />
|
||||||
|
|
||||||
|
<!-- 内部动态扫描环 (象征行为分析与智能验证) -->
|
||||||
|
<circle cx="50" cy="50" r="16"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#ringGrad)"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-dasharray="60 40"
|
||||||
|
stroke-linecap="round"
|
||||||
|
filter="url(#glow)">
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- 中心脉冲核心 (象征触控/鼠标焦点) -->
|
||||||
|
<circle cx="50" cy="50" r="4" fill="#06B6D4">
|
||||||
|
<animate attributeName="r" values="3; 6; 3" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="opacity" values="0.4; 1; 0.4" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- 上下装饰轨迹线 (象征运动轨迹) -->
|
||||||
|
<path d="M 35 38 Q 50 28 65 38" fill="none" stroke="#4F46E5" stroke-width="2" stroke-linecap="round" opacity="0.6">
|
||||||
|
<animate attributeName="opacity" values="0.2; 0.8; 0.2" dur="2s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
<path d="M 35 62 Q 50 72 65 62" fill="none" stroke="#4F46E5" stroke-width="2" stroke-linecap="round" opacity="0.6">
|
||||||
|
<animate attributeName="opacity" values="0.8; 0.2; 0.8" dur="2s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,746 @@
|
|||||||
|
/**
|
||||||
|
* tianai-captcha-enhanced SDK
|
||||||
|
* 兼容 TPCaptcha API (TPCaptcha.init / onSuccess / verifyToken)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* TACaptcha.init({
|
||||||
|
* elId: 'captcha-box',
|
||||||
|
* siteKey: 'your-site-key',
|
||||||
|
* type: 'SLIDER', // optional
|
||||||
|
* scene: 'login', // optional
|
||||||
|
* serverUrl: 'https://your-server/api', // optional
|
||||||
|
* onSuccess: (result) => { console.log(result.data.verifyToken); },
|
||||||
|
* onFail: () => { console.log('failed'); }
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const INSTANCES = new Map();
|
||||||
|
|
||||||
|
class TACaptchaInstance {
|
||||||
|
constructor(options) {
|
||||||
|
this.options = Object.assign({
|
||||||
|
elId: null,
|
||||||
|
siteKey: '',
|
||||||
|
mode: 'click',
|
||||||
|
type: null,
|
||||||
|
scene: 'default',
|
||||||
|
serverUrl: '/api',
|
||||||
|
onSuccess: null,
|
||||||
|
onFail: null,
|
||||||
|
onOpen: null,
|
||||||
|
onClose: null,
|
||||||
|
onRefresh: null,
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
this.container = null;
|
||||||
|
this.modal = null;
|
||||||
|
this.captchaData = null;
|
||||||
|
this.verifyToken = null;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.startX = 0;
|
||||||
|
this.currentX = 0;
|
||||||
|
this.trackList = [];
|
||||||
|
this.startTime = 0;
|
||||||
|
this.isDark = this._detectTheme();
|
||||||
|
this._themeObserver = null;
|
||||||
|
|
||||||
|
this._init();
|
||||||
|
}
|
||||||
|
|
||||||
|
_detectTheme() {
|
||||||
|
if (this.options.theme === 'dark') return true;
|
||||||
|
if (this.options.theme === 'light') return false;
|
||||||
|
if (document.body.classList.contains('dark') || document.documentElement.classList.contains('dark')) return true;
|
||||||
|
if (document.documentElement.getAttribute('data-theme') === 'dark') return true;
|
||||||
|
if (document.documentElement.getAttribute('color-scheme') === 'dark') return true;
|
||||||
|
if (document.documentElement.style.colorScheme === 'dark') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchTheme() {
|
||||||
|
if (this._themeObserver) this._themeObserver.disconnect();
|
||||||
|
const targets = [document.body, document.documentElement];
|
||||||
|
this._themeObserver = new MutationObserver(() => {
|
||||||
|
const newDark = this._detectTheme();
|
||||||
|
if (newDark !== this.isDark) {
|
||||||
|
this.isDark = newDark;
|
||||||
|
this._createTriggerButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
targets.forEach(t => {
|
||||||
|
if (t) this._themeObserver.observe(t, { attributes: true, attributeFilter: ['class', 'data-theme', 'color-scheme'] });
|
||||||
|
});
|
||||||
|
if (window.matchMedia) {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
this._mqHandler = () => {
|
||||||
|
const newDark = this._detectTheme();
|
||||||
|
if (newDark !== this.isDark) {
|
||||||
|
this.isDark = newDark;
|
||||||
|
this._createTriggerButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', this._mqHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_init() {
|
||||||
|
if (this.options.mode === 'event') {
|
||||||
|
this._createModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._createTriggerButton();
|
||||||
|
this._createModal();
|
||||||
|
this._watchTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
_createTriggerButton() {
|
||||||
|
const el = document.getElementById(this.options.elId);
|
||||||
|
if (!el) return;
|
||||||
|
this.container = el;
|
||||||
|
|
||||||
|
const typeLabel = this.options.type || '智能验证';
|
||||||
|
|
||||||
|
// 注入呼吸灯 + 盾牌动画样式
|
||||||
|
if (!document.getElementById('tacaptcha-breathing-style')) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'tacaptcha-breathing-style';
|
||||||
|
style.textContent =
|
||||||
|
'@keyframes tacaptcha-breathe{0%,100%{box-shadow:0 0 0 0 rgba(82,196,26,0.4)}50%{box-shadow:0 0 0 6px rgba(82,196,26,0)}}' +
|
||||||
|
'.tacaptcha-dot{width:8px;height:8px;border-radius:50%;background:#52c41a;animation:tacaptcha-breathe 2s ease-in-out infinite}' +
|
||||||
|
'.tacaptcha-shield .shield-shard{transform-origin:50px 50px;transition:transform 2s cubic-bezier(0.22,1,0.36,1),fill 2s ease,opacity 2s ease}' +
|
||||||
|
'.tacaptcha-shield .ward-group{transition:opacity 2s ease}' +
|
||||||
|
'.tacaptcha-shield .ward-pulse{transform-origin:50px 50px;animation:pulse-ward 2s cubic-bezier(0.1,0.7,0.3,1) infinite}' +
|
||||||
|
'.tacaptcha-shield .ward-pulse.delay{animation-delay:1s}' +
|
||||||
|
'@keyframes pulse-ward{0%{transform:scale(0.6);opacity:1;stroke-width:6px}100%{transform:scale(1.8);opacity:0;stroke-width:1px}}' +
|
||||||
|
'.tacaptcha-shield .sword-slide{transform:translateY(-85px);opacity:0;transition:transform 2s cubic-bezier(0.175,0.885,0.32,1.4),opacity 2s ease}' +
|
||||||
|
'.tacaptcha-shield .sword-anchor-a{transform-origin:50px 50px;transform:rotate(-45deg)}' +
|
||||||
|
'.tacaptcha-shield .sword-anchor-b{transform-origin:50px 50px;transform:rotate(45deg)}' +
|
||||||
|
'.tacaptcha-shield.state-wait .ward-group{opacity:1}' +
|
||||||
|
'.tacaptcha-shield.state-success .ward-group,.tacaptcha-shield.state-fail .ward-group{opacity:0}' +
|
||||||
|
'.tacaptcha-shield.state-success .ward-pulse,.tacaptcha-shield.state-fail .ward-pulse{animation:none}' +
|
||||||
|
'.tacaptcha-shield.state-wait .shield-shard{fill:url(#tacaptcha-mb);transform:translate(0,0) scale(1) rotate(0deg);opacity:1;filter:drop-shadow(0 6px 8px rgba(0,0,0,0.6))}' +
|
||||||
|
'.tacaptcha-shield.state-success .shield-shard{fill:url(#tacaptcha-mg);transform:translate(0,0) scale(1) rotate(0deg);opacity:1;filter:drop-shadow(0 6px 8px rgba(0,0,0,0.6))}' +
|
||||||
|
'.tacaptcha-shield.state-success .sword-slide{transform:translateY(-15px);opacity:1}' +
|
||||||
|
'.tacaptcha-shield.state-fail .shield-shard{fill:url(#tacaptcha-mr);opacity:0.9;filter:none}' +
|
||||||
|
'.tacaptcha-shield.state-fail .shard-tl{transform:translate(-25px,-30px) scale(0.9) rotateX(30deg) rotateY(-20deg) rotateZ(-15deg);opacity:0.7}' +
|
||||||
|
'.tacaptcha-shield.state-fail .shard-tr{transform:translate(25px,-30px) scale(0.9) rotateX(30deg) rotateY(20deg) rotateZ(15deg);opacity:0.7}' +
|
||||||
|
'.tacaptcha-shield.state-fail .shard-b{transform:translate(0,35px) scale(0.8) rotateX(-20deg) rotateZ(5deg);opacity:0.5}';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgColor = this.isDark ? '#1a1a2e' : '#fff';
|
||||||
|
const borderColor = this.isDark ? '#2a2a4a' : '#e8e8e8';
|
||||||
|
const textColor = this.isDark ? '#e0e0e0' : '#333';
|
||||||
|
const subColor = this.isDark ? '#888' : '#999';
|
||||||
|
const btnBg = this.isDark ? '#16213e' : '#fafafa';
|
||||||
|
const btnBorder = this.isDark ? '#0f3460' : '#d9d9d9';
|
||||||
|
const btnColor = this.isDark ? '#aaa' : '#666';
|
||||||
|
const shadow = this.isDark ? '0 2px 8px rgba(0,0,0,0.3)' : '0 1px 4px rgba(0,0,0,0.06)';
|
||||||
|
|
||||||
|
const shieldSvg = this._createShieldSvg('state-wait');
|
||||||
|
|
||||||
|
const btn = document.createElement('div');
|
||||||
|
btn.style.cssText = 'display:flex;align-items:center;gap:10px;padding:12px 16px;border:1px solid ' + borderColor + ';border-radius:8px;cursor:pointer;user-select:none;transition:all 0.2s;font-size:14px;color:' + textColor + ';background:' + bgColor + ';box-shadow:' + shadow + ';';
|
||||||
|
btn.innerHTML =
|
||||||
|
shieldSvg +
|
||||||
|
'<span style="flex:1;color:' + textColor + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">点击进行验证</span>' +
|
||||||
|
'<span style="color:' + subColor + ';font-size:12px;white-space:nowrap;flex-shrink:0;">' + typeLabel + '</span>' +
|
||||||
|
'<span style="display:inline-flex;align-items:center;gap:5px;padding:4px 12px;border:1px solid ' + btnBorder + ';border-radius:4px;font-size:12px;color:' + btnColor + ';background:' + btnBg + ';">' +
|
||||||
|
'<span class="tacaptcha-dot"></span>点击验证</span>';
|
||||||
|
|
||||||
|
btn.addEventListener('mouseenter', () => { btn.style.borderColor = '#1890ff'; });
|
||||||
|
btn.addEventListener('mouseleave', () => { btn.style.borderColor = borderColor; });
|
||||||
|
btn.addEventListener('click', () => this._openCaptcha());
|
||||||
|
|
||||||
|
el.appendChild(btn);
|
||||||
|
this.triggerBtn = btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
_createShieldSvg(stateClass) {
|
||||||
|
return '<svg class="tacaptcha-shield ' + stateClass + '" width="32" height="32" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">' +
|
||||||
|
'<defs>' +
|
||||||
|
'<linearGradient id="tacaptcha-mb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#3B82F6"/><stop offset="50%" stop-color="#1E40AF"/><stop offset="100%" stop-color="#60A5FA"/></linearGradient>' +
|
||||||
|
'<linearGradient id="tacaptcha-mg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#10B981"/><stop offset="50%" stop-color="#047857"/><stop offset="100%" stop-color="#34D399"/></linearGradient>' +
|
||||||
|
'<linearGradient id="tacaptcha-mr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#EF4444"/><stop offset="50%" stop-color="#B91C1C"/><stop offset="100%" stop-color="#F87171"/></linearGradient>' +
|
||||||
|
'<linearGradient id="tacaptcha-bl" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#f8fafc"/><stop offset="100%" stop-color="#94a3b8"/></linearGradient>' +
|
||||||
|
'<linearGradient id="tacaptcha-bd" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#64748b"/><stop offset="100%" stop-color="#334155"/></linearGradient>' +
|
||||||
|
'<linearGradient id="tacaptcha-gold" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#FDE047"/><stop offset="50%" stop-color="#EAB308"/><stop offset="100%" stop-color="#A16207"/></linearGradient>' +
|
||||||
|
'<filter id="tacaptcha-ss"><feDropShadow dx="2" dy="3" stdDeviation="1.5" flood-color="#000" flood-opacity="0.5"/></filter>' +
|
||||||
|
'<filter id="tacaptcha-mg2"><feGaussianBlur stdDeviation="2" result="blur"/><feComposite in="SourceGraphic" in2="blur" operator="over"/></filter>' +
|
||||||
|
'<filter id="tacaptcha-emg" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>' +
|
||||||
|
'<g id="tacaptcha-sw" filter="url(#tacaptcha-ss)"><polygon points="43,26 50,26 50,115" fill="url(#tacaptcha-bl)"/><polygon points="50,26 57,26 50,115" fill="url(#tacaptcha-bd)"/><rect x="45" y="4" width="10" height="22" fill="#1e293b"/><polygon points="20,22 50,26 80,22 80,27 50,32 20,27" fill="#713F12"/><polygon points="20,22 50,26 80,22 50,24" fill="url(#tacaptcha-gold)"/><polygon points="50,-6 57,0 50,6 43,0" fill="url(#tacaptcha-gold)"/></g>' +
|
||||||
|
'</defs>' +
|
||||||
|
'<g class="ward-group" filter="url(#tacaptcha-emg)"><circle class="ward-pulse" cx="50" cy="50" r="35" fill="none" stroke="#38BDF8" stroke-linecap="round"/><circle class="ward-pulse delay" cx="50" cy="50" r="35" fill="none" stroke="#38BDF8" stroke-linecap="round"/></g>' +
|
||||||
|
'<g class="shield-main-group"><path class="shield-shard shard-tl" d="M 50 12 L 18 28 L 18 52 L 50 48 Z" fill="url(#tacaptcha-mb)"/><path class="shield-shard shard-tr" d="M 50 12 L 82 28 L 82 52 L 50 48 Z" fill="url(#tacaptcha-mb)"/><path class="shield-shard shard-b" d="M 18 52 C 18 78 50 92 50 92 C 50 92 82 78 82 52 L 50 48 Z" fill="url(#tacaptcha-mb)"/></g>' +
|
||||||
|
'<g class="sword-anchor-a"><g class="sword-slide"><use href="#tacaptcha-sw"/></g></g>' +
|
||||||
|
'<g class="sword-anchor-b"><g class="sword-slide"><use href="#tacaptcha-sw"/></g></g>' +
|
||||||
|
'</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
_setShieldState(state) {
|
||||||
|
if (!this.triggerBtn) return;
|
||||||
|
const svg = this.triggerBtn.querySelector('.tacaptcha-shield');
|
||||||
|
if (svg) {
|
||||||
|
svg.classList.remove('state-wait', 'state-success', 'state-fail');
|
||||||
|
const pulses = svg.querySelectorAll('.ward-pulse');
|
||||||
|
pulses.forEach(p => { p.style.animation = 'none'; });
|
||||||
|
void svg.offsetWidth;
|
||||||
|
svg.classList.add('state-' + state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_createModal() {
|
||||||
|
const panelBg = this.isDark ? '#1a1a2e' : '#fff';
|
||||||
|
const textColor = this.isDark ? '#e0e0e0' : '#333';
|
||||||
|
const subColor = this.isDark ? '#666' : '#999';
|
||||||
|
const overlayBg = this.isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.3)';
|
||||||
|
const shadow = this.isDark ? '0 4px 20px rgba(0,0,0,0.4)' : '0 4px 20px rgba(0,0,0,0.15)';
|
||||||
|
|
||||||
|
this.modal = document.createElement('div');
|
||||||
|
this.modal.style.cssText = 'display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;';
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:' + overlayBg + ';';
|
||||||
|
overlay.addEventListener('click', () => this._closeCaptcha());
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:' + panelBg + ';border-radius:8px;padding:20px;box-shadow:' + shadow + ';min-width:340px;';
|
||||||
|
|
||||||
|
this.captchaPanel = panel;
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;';
|
||||||
|
header.innerHTML = '<span style="font-weight:600;font-size:15px;color:' + textColor + ';">请完成安全验证</span>';
|
||||||
|
|
||||||
|
const refreshBtn = document.createElement('span');
|
||||||
|
refreshBtn.textContent = '↻';
|
||||||
|
refreshBtn.style.cssText = 'cursor:pointer;font-size:18px;color:' + subColor + ';';
|
||||||
|
refreshBtn.addEventListener('click', () => this._loadCaptcha());
|
||||||
|
header.appendChild(refreshBtn);
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('span');
|
||||||
|
closeBtn.textContent = '✕';
|
||||||
|
closeBtn.style.cssText = 'cursor:pointer;font-size:16px;color:' + subColor + ';margin-left:12px;';
|
||||||
|
closeBtn.addEventListener('click', () => this._closeCaptcha());
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
this.captchaContent = document.createElement('div');
|
||||||
|
this.captchaContent.style.cssText = 'position:relative;';
|
||||||
|
|
||||||
|
this.statusBar = document.createElement('div');
|
||||||
|
this.statusBar.style.cssText = 'margin-top:12px;text-align:center;font-size:13px;color:' + subColor + ';';
|
||||||
|
|
||||||
|
panel.appendChild(header);
|
||||||
|
panel.appendChild(this.captchaContent);
|
||||||
|
panel.appendChild(this.statusBar);
|
||||||
|
|
||||||
|
this.modal.appendChild(overlay);
|
||||||
|
this.modal.appendChild(panel);
|
||||||
|
document.body.appendChild(this.modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _openCaptcha() {
|
||||||
|
this.modal.style.display = 'block';
|
||||||
|
if (this.options.onOpen) this.options.onOpen();
|
||||||
|
await this._loadCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeCaptcha() {
|
||||||
|
this.modal.style.display = 'none';
|
||||||
|
if (this.options.onClose) this.options.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadCaptcha() {
|
||||||
|
this.statusBar.textContent = '加载中...';
|
||||||
|
this.captchaContent.innerHTML = '';
|
||||||
|
this.verifyToken = null;
|
||||||
|
this._setShieldState('wait');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${this.options.serverUrl}/challenge/generate`;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.options.type) params.set('type', this.options.type);
|
||||||
|
params.set('scene', this.options.scene);
|
||||||
|
|
||||||
|
const resp = await fetch(url + '?' + params.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Site-Key': this.options.siteKey }
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
if (result.code !== 200) {
|
||||||
|
this.statusBar.textContent = result.msg || '加载失败';
|
||||||
|
if (this.options.onFail) this.options.onFail(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.captchaData = result.data;
|
||||||
|
console.log('[ROTATE-INIT] degree=' + result.data.degree + ' type=' + result.data.type + ' tplW=' + result.data.templateImageWidth + ' tplH=' + result.data.templateImageHeight);
|
||||||
|
this._renderCaptcha();
|
||||||
|
this.statusBar.textContent = '请完成验证';
|
||||||
|
} catch (e) {
|
||||||
|
this.statusBar.textContent = '网络错误';
|
||||||
|
if (this.options.onFail) this.options.onFail({ code: 10000, msg: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isClickType(type) {
|
||||||
|
return type === 'WORD_IMAGE_CLICK' || type === 'ICON_CLICK' || type === 'WORD_ORDER_CLICK';
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderCaptcha() {
|
||||||
|
this.captchaContent.innerHTML = '';
|
||||||
|
const data = this.captchaData;
|
||||||
|
if (!data) return;
|
||||||
|
const isRotate = data.type === 'ROTATE';
|
||||||
|
const isConcat = data.type === 'CONCAT';
|
||||||
|
const isCurveDraw = data.type === 'CURVE_DRAW';
|
||||||
|
const isClick = this._isClickType(data.type);
|
||||||
|
|
||||||
|
if (isClick) {
|
||||||
|
this._renderClickCaptcha();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurveDraw) {
|
||||||
|
this._renderDrawCaptcha();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgImg = document.createElement('img');
|
||||||
|
bgImg.src = data.backgroundImage;
|
||||||
|
bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;';
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.style.cssText = 'position:relative;border-radius:4px;';
|
||||||
|
wrapper.appendChild(bgImg);
|
||||||
|
|
||||||
|
let tplImg = null;
|
||||||
|
let concatTopLayer = null;
|
||||||
|
let concatRandomY = 0;
|
||||||
|
if (isConcat) {
|
||||||
|
concatRandomY = (data.data && data.data.randomY) ? data.data.randomY : Math.round(data.backgroundImageHeight / 2);
|
||||||
|
wrapper.innerHTML = '';
|
||||||
|
wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;width:100%;height:180px;';
|
||||||
|
const bgLayer = document.createElement('div');
|
||||||
|
bgLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:1;background-image:url(' + data.backgroundImage + ');background-size:100% 180px;background-position:0 0;background-repeat:no-repeat;';
|
||||||
|
wrapper.appendChild(bgLayer);
|
||||||
|
const overlayHeight = ((data.backgroundImageHeight - concatRandomY) / data.backgroundImageHeight) * 180;
|
||||||
|
concatTopLayer = document.createElement('div');
|
||||||
|
concatTopLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:' + overlayHeight + 'px;z-index:2;overflow:hidden;background-image:url(' + data.backgroundImage + ');background-size:100% 180px;background-position:0 0;background-repeat:no-repeat;';
|
||||||
|
wrapper.appendChild(concatTopLayer);
|
||||||
|
} else if (data.templateImage) {
|
||||||
|
tplImg = document.createElement('img');
|
||||||
|
tplImg.src = data.templateImage;
|
||||||
|
if (isRotate) {
|
||||||
|
const holeLeft = ((data.backgroundImageWidth - data.templateImageWidth) / 2 / data.backgroundImageWidth * 100);
|
||||||
|
const holeTop = ((data.backgroundImageHeight - data.templateImageHeight) / 2 / data.backgroundImageHeight * 100);
|
||||||
|
tplImg.style.cssText = 'position:absolute;left:' + holeLeft + '%;top:' + holeTop + '%;width:' + (data.templateImageWidth / data.backgroundImageWidth * 100) + '%;pointer-events:none;transform:rotate(0deg);transform-origin:center center;z-index:1;';
|
||||||
|
} else {
|
||||||
|
tplImg.style.cssText = 'position:absolute;top:0;left:0;height:100%;pointer-events:none;transition:none;';
|
||||||
|
}
|
||||||
|
wrapper.appendChild(tplImg);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.captchaContent.appendChild(wrapper);
|
||||||
|
|
||||||
|
const sliderBar = document.createElement('div');
|
||||||
|
const barBg = this.isDark ? '#2a2a4a' : '#f0f0f0';
|
||||||
|
sliderBar.style.cssText = 'position:relative;height:40px;width:100%;background:' + barBg + ';border-radius:4px;margin-top:8px;overflow:hidden;';
|
||||||
|
|
||||||
|
const sliderTrack = document.createElement('div');
|
||||||
|
sliderTrack.style.cssText = 'position:absolute;top:0;left:0;height:100%;width:0;background:linear-gradient(90deg,#52c41a,#73d13d);border-radius:4px;transition:none;';
|
||||||
|
|
||||||
|
const sliderBtnBg = this.isDark ? '#1a1a2e' : '#fff';
|
||||||
|
const sliderBtn = document.createElement('div');
|
||||||
|
sliderBtn.style.cssText = 'position:absolute;top:2px;left:0;width:36px;height:36px;background:' + sliderBtnBg + ';border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,0.15);cursor:grab;display:flex;align-items:center;justify-content:center;font-size:16px;color:#999;transition:none;';
|
||||||
|
sliderBtn.textContent = isRotate ? '↻' : '→';
|
||||||
|
|
||||||
|
sliderBar.appendChild(sliderTrack);
|
||||||
|
sliderBar.appendChild(sliderBtn);
|
||||||
|
this.captchaContent.appendChild(sliderBar);
|
||||||
|
|
||||||
|
this.startX = 0;
|
||||||
|
this.currentX = 0;
|
||||||
|
this.trackList = [];
|
||||||
|
this.startTime = Date.now();
|
||||||
|
|
||||||
|
const getBarWidth = () => sliderBar.offsetWidth;
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
const barWidth = getBarWidth();
|
||||||
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||||
|
const dx = clientX - this.startX;
|
||||||
|
const maxX = barWidth - 36;
|
||||||
|
this.currentX = Math.max(0, Math.min(dx, maxX));
|
||||||
|
|
||||||
|
sliderBtn.style.left = this.currentX + 'px';
|
||||||
|
sliderTrack.style.width = (this.currentX + 18) + 'px';
|
||||||
|
|
||||||
|
const ratio = this.currentX / barWidth;
|
||||||
|
let scaledX;
|
||||||
|
if (isRotate && tplImg) {
|
||||||
|
const bgW = this.captchaData.backgroundImageWidth;
|
||||||
|
const cssDegree = (this.currentX / barWidth) * 360;
|
||||||
|
tplImg.style.transform = 'rotate(' + cssDegree + 'deg)';
|
||||||
|
scaledX = Math.round(ratio * bgW);
|
||||||
|
} else if (isConcat && concatTopLayer) {
|
||||||
|
const moveX = ratio * wrapper.offsetWidth;
|
||||||
|
concatTopLayer.style.backgroundPositionX = moveX + 'px';
|
||||||
|
scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth);
|
||||||
|
} else if (tplImg) {
|
||||||
|
tplImg.style.left = (ratio * 100) + '%';
|
||||||
|
scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth);
|
||||||
|
} else {
|
||||||
|
scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth);
|
||||||
|
}
|
||||||
|
this.trackList.push({
|
||||||
|
x: scaledX,
|
||||||
|
y: 0,
|
||||||
|
t: Date.now() - this.startTime,
|
||||||
|
type: 'MOVE'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = async () => {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
this.isDragging = false;
|
||||||
|
sliderBtn.style.cursor = 'grab';
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
document.removeEventListener('touchmove', onMove);
|
||||||
|
document.removeEventListener('touchend', onUp);
|
||||||
|
|
||||||
|
const barWidth = getBarWidth();
|
||||||
|
const ratio = this.currentX / barWidth;
|
||||||
|
const scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth);
|
||||||
|
this.trackList.push({
|
||||||
|
x: scaledX,
|
||||||
|
y: 0,
|
||||||
|
t: Date.now() - this.startTime,
|
||||||
|
type: 'UP'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.statusBar.textContent = '验证中...';
|
||||||
|
this.statusBar.style.color = '#999';
|
||||||
|
|
||||||
|
await this._verify();
|
||||||
|
};
|
||||||
|
|
||||||
|
sliderBtn.addEventListener('mousedown', (e) => {
|
||||||
|
this.isDragging = true;
|
||||||
|
this.startX = e.clientX - this.currentX;
|
||||||
|
this.trackList = [{ x: 0, y: 0, t: 0, type: 'DOWN' }];
|
||||||
|
this.startTime = Date.now();
|
||||||
|
sliderBtn.style.cursor = 'grabbing';
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
sliderBtn.addEventListener('touchstart', (e) => {
|
||||||
|
this.isDragging = true;
|
||||||
|
this.startX = e.touches[0].clientX - this.currentX;
|
||||||
|
this.trackList = [{ x: 0, y: 0, t: 0, type: 'DOWN' }];
|
||||||
|
this.startTime = Date.now();
|
||||||
|
document.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderClickCaptcha() {
|
||||||
|
const data = this.captchaData;
|
||||||
|
const isWordOrder = data.type === 'WORD_ORDER_CLICK';
|
||||||
|
const tipText = isWordOrder ? '请依次点击' : '请依次点击图中文字';
|
||||||
|
|
||||||
|
const tipBar = document.createElement('div');
|
||||||
|
tipBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;height:40px;margin-bottom:8px;';
|
||||||
|
|
||||||
|
const tipLabel = document.createElement('span');
|
||||||
|
tipLabel.style.cssText = 'font-size:14px;color:' + (this.isDark ? '#e0e0e0' : '#333') + ';';
|
||||||
|
tipLabel.textContent = tipText;
|
||||||
|
|
||||||
|
tipBar.appendChild(tipLabel);
|
||||||
|
|
||||||
|
if (data.templateImage) {
|
||||||
|
const tipImg = document.createElement('img');
|
||||||
|
tipImg.src = data.templateImage;
|
||||||
|
tipImg.style.cssText = 'height:35px;max-width:200px;';
|
||||||
|
tipBar.appendChild(tipImg);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.captchaContent.appendChild(tipBar);
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;cursor:crosshair;';
|
||||||
|
|
||||||
|
const bgImg = document.createElement('img');
|
||||||
|
bgImg.src = data.backgroundImage;
|
||||||
|
bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;';
|
||||||
|
wrapper.appendChild(bgImg);
|
||||||
|
|
||||||
|
const clickMask = document.createElement('div');
|
||||||
|
clickMask.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;';
|
||||||
|
wrapper.appendChild(clickMask);
|
||||||
|
|
||||||
|
this.captchaContent.appendChild(wrapper);
|
||||||
|
|
||||||
|
const confirmBtn = document.createElement('div');
|
||||||
|
confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;';
|
||||||
|
confirmBtn.textContent = '确认';
|
||||||
|
this.captchaContent.appendChild(confirmBtn);
|
||||||
|
|
||||||
|
this.trackList = [];
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.clickCount = 0;
|
||||||
|
this.clickMarkers = [];
|
||||||
|
|
||||||
|
clickMask.addEventListener('click', (e) => {
|
||||||
|
if (e.target.className === 'tacaptcha-click-marker') return;
|
||||||
|
const rect = bgImg.getBoundingClientRect();
|
||||||
|
const clickX = Math.round(e.clientX - rect.left);
|
||||||
|
const clickY = Math.round(e.clientY - rect.top);
|
||||||
|
const displayW = rect.width;
|
||||||
|
const displayH = rect.height;
|
||||||
|
const scaledX = Math.round((clickX / displayW) * data.backgroundImageWidth);
|
||||||
|
const scaledY = Math.round((clickY / displayH) * data.backgroundImageHeight);
|
||||||
|
|
||||||
|
this.clickCount++;
|
||||||
|
if (this.clickCount === 1) {
|
||||||
|
this.startTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.trackList.push({
|
||||||
|
x: scaledX,
|
||||||
|
y: scaledY,
|
||||||
|
t: Date.now() - this.startTime,
|
||||||
|
type: 'CLICK'
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = document.createElement('span');
|
||||||
|
marker.className = 'tacaptcha-click-marker';
|
||||||
|
marker.textContent = this.clickCount;
|
||||||
|
const left = clickX - 11;
|
||||||
|
const top = clickY - 11;
|
||||||
|
marker.style.cssText = 'position:absolute;left:' + left + 'px;top:' + top + 'px;border-radius:50%;background:#409eff;width:22px;height:22px;text-align:center;line-height:22px;color:#fff;border:2px solid #fff;box-sizing:content-box;font-size:12px;pointer-events:none;z-index:2;';
|
||||||
|
clickMask.appendChild(marker);
|
||||||
|
this.clickMarkers.push(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', async () => {
|
||||||
|
if (this.clickCount === 0) return;
|
||||||
|
this.statusBar.textContent = '验证中...';
|
||||||
|
this.statusBar.style.color = '#999';
|
||||||
|
await this._verify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDrawCaptcha() {
|
||||||
|
const data = this.captchaData;
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;';
|
||||||
|
|
||||||
|
const bgImg = document.createElement('img');
|
||||||
|
bgImg.src = data.backgroundImage;
|
||||||
|
bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;';
|
||||||
|
wrapper.appendChild(bgImg);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;touch-action:none;cursor:crosshair;';
|
||||||
|
wrapper.appendChild(canvas);
|
||||||
|
|
||||||
|
const tip = document.createElement('div');
|
||||||
|
tip.textContent = '请在图上绘制曲线';
|
||||||
|
tip.style.cssText = 'position:absolute;top:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.6);color:#fff;padding:4px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:2;';
|
||||||
|
wrapper.appendChild(tip);
|
||||||
|
|
||||||
|
this.captchaContent.appendChild(wrapper);
|
||||||
|
|
||||||
|
const confirmBtn = document.createElement('div');
|
||||||
|
confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;';
|
||||||
|
confirmBtn.textContent = '确认';
|
||||||
|
this.captchaContent.appendChild(confirmBtn);
|
||||||
|
|
||||||
|
this.trackList = [];
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.isDrawing = false;
|
||||||
|
|
||||||
|
let ctx = null;
|
||||||
|
|
||||||
|
const getPoint = (e) => {
|
||||||
|
const rect = bgImg.getBoundingClientRect();
|
||||||
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||||
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth);
|
||||||
|
const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight);
|
||||||
|
return { x, y, scaledX, scaledY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDraw = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isDrawing = true;
|
||||||
|
const p = getPoint(e);
|
||||||
|
this.trackList.push({ x: p.scaledX, y: p.scaledY, t: 0, type: 'DOWN' });
|
||||||
|
|
||||||
|
const rect = wrapper.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width;
|
||||||
|
canvas.height = rect.height;
|
||||||
|
ctx = canvas.getContext('2d');
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = 'rgba(64, 158, 255, 0.9)';
|
||||||
|
tip.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveDraw = (e) => {
|
||||||
|
if (!this.isDrawing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const p = getPoint(e);
|
||||||
|
this.trackList.push({ x: p.scaledX, y: p.scaledY, t: Date.now() - this.startTime, type: 'MOVE' });
|
||||||
|
if (ctx) {
|
||||||
|
ctx.lineTo(p.x, p.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDraw = (e) => {
|
||||||
|
if (!this.isDrawing) return;
|
||||||
|
this.isDrawing = false;
|
||||||
|
let clientX, clientY;
|
||||||
|
if (e.changedTouches && e.changedTouches.length > 0) {
|
||||||
|
clientX = e.changedTouches[0].clientX;
|
||||||
|
clientY = e.changedTouches[0].clientY;
|
||||||
|
} else {
|
||||||
|
clientX = e.clientX;
|
||||||
|
clientY = e.clientY;
|
||||||
|
}
|
||||||
|
const rect = bgImg.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth);
|
||||||
|
const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight);
|
||||||
|
this.trackList.push({ x: scaledX, y: scaledY, t: Date.now() - this.startTime, type: 'UP' });
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', startDraw);
|
||||||
|
canvas.addEventListener('mousemove', moveDraw);
|
||||||
|
canvas.addEventListener('mouseup', endDraw);
|
||||||
|
canvas.addEventListener('touchstart', startDraw, { passive: false });
|
||||||
|
canvas.addEventListener('touchmove', moveDraw, { passive: false });
|
||||||
|
canvas.addEventListener('touchend', endDraw);
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', async () => {
|
||||||
|
if (this.trackList.length === 0) return;
|
||||||
|
this.statusBar.textContent = '验证中...';
|
||||||
|
this.statusBar.style.color = '#999';
|
||||||
|
await this._verify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _verify() {
|
||||||
|
if (!this.captchaData) return;
|
||||||
|
this.statusBar.textContent = '验证中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const trackData = {
|
||||||
|
bgImageWidth: this.captchaData.backgroundImageWidth,
|
||||||
|
bgImageHeight: this.captchaData.backgroundImageHeight,
|
||||||
|
templateImageWidth: this.captchaData.templateImageWidth,
|
||||||
|
templateImageHeight: this.captchaData.templateImageHeight,
|
||||||
|
startTime: this.startTime,
|
||||||
|
stopTime: Date.now(),
|
||||||
|
trackList: this.trackList
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await fetch(`${this.options.serverUrl}/challenge/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Site-Key': this.options.siteKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: this.captchaData.id,
|
||||||
|
data: trackData
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
if (result.code === 200 && result.data && result.data.verifyToken) {
|
||||||
|
this.verifyToken = result.data.verifyToken;
|
||||||
|
this.statusBar.textContent = '验证成功';
|
||||||
|
this.statusBar.style.color = '#52c41a';
|
||||||
|
if (this.options.onSuccess) {
|
||||||
|
this.options.onSuccess({
|
||||||
|
code: 200,
|
||||||
|
msg: '操作成功',
|
||||||
|
data: { verifyToken: this.verifyToken }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this._closeCaptcha();
|
||||||
|
this._setShieldState('success');
|
||||||
|
}, 400);
|
||||||
|
} else {
|
||||||
|
this._setShieldState('fail');
|
||||||
|
this.statusBar.textContent = '验证失败,请重试';
|
||||||
|
this.statusBar.style.color = '#ff4d4f';
|
||||||
|
if (this.options.onFail) this.options.onFail(result);
|
||||||
|
setTimeout(() => this._loadCaptcha(), 1500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.statusBar.textContent = '网络错误';
|
||||||
|
if (this.options.onFail) this.options.onFail({ code: 10000, msg: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
await this._openCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.modal && this.modal.parentNode) {
|
||||||
|
this.modal.parentNode.removeChild(this.modal);
|
||||||
|
}
|
||||||
|
if (this.options.elId) {
|
||||||
|
INSTANCES.delete(this.options.elId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TACaptcha = {
|
||||||
|
async init(options) {
|
||||||
|
const elId = options.elId || ('_tac_' + Date.now());
|
||||||
|
if (INSTANCES.has(elId)) {
|
||||||
|
INSTANCES.get(elId).destroy();
|
||||||
|
}
|
||||||
|
const instance = new TACaptchaInstance(options);
|
||||||
|
INSTANCES.set(elId, instance);
|
||||||
|
return instance;
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh(elId) {
|
||||||
|
const inst = INSTANCES.get(elId);
|
||||||
|
if (inst) inst._loadCaptcha();
|
||||||
|
},
|
||||||
|
|
||||||
|
show(elId) {
|
||||||
|
const inst = INSTANCES.get(elId);
|
||||||
|
if (inst) return inst.show();
|
||||||
|
return Promise.reject(new Error('Instance not found'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = TACaptcha;
|
||||||
|
} else {
|
||||||
|
global.TACaptcha = TACaptcha;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<n-config-provider>
|
||||||
|
<!-- 测试页:全屏,不带侧边栏 -->
|
||||||
|
<div v-if="route.path === '/test'" style="height:100vh;">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
<!-- 其他页面:带侧边栏 -->
|
||||||
|
<n-layout v-else has-sider style="height:100vh">
|
||||||
|
<n-layout-sider bordered :width="220">
|
||||||
|
<div style="padding:16px 20px;font-size:18px;font-weight:700;color:#1890ff;">tianai-captcha</div>
|
||||||
|
<n-menu :options="menuOptions" :value="currentRoute" @update:value="onMenuSelect" />
|
||||||
|
</n-layout-sider>
|
||||||
|
<n-layout>
|
||||||
|
<n-layout-header bordered style="padding:12px 24px;font-size:16px;font-weight:500;">
|
||||||
|
验证码管理平台
|
||||||
|
</n-layout-header>
|
||||||
|
<n-layout-content style="padding:24px;">
|
||||||
|
<router-view />
|
||||||
|
</n-layout-content>
|
||||||
|
</n-layout>
|
||||||
|
</n-layout>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { NConfigProvider, NLayout, NLayoutSider, NLayoutHeader, NLayoutContent, NMenu } from 'naive-ui'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const currentRoute = computed(() => route.path)
|
||||||
|
|
||||||
|
const menuOptions = [
|
||||||
|
{ label: '仪表盘', key: '/' },
|
||||||
|
{ label: '站点管理', key: '/sites' },
|
||||||
|
{ label: '统计监控', key: '/stats' },
|
||||||
|
{ label: '验证测试', key: '/test' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function onMenuSelect(key) {
|
||||||
|
router.push(key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import App from './App.vue'
|
||||||
|
import Dashboard from './views/Dashboard.vue'
|
||||||
|
import Sites from './views/Sites.vue'
|
||||||
|
import Stats from './views/Stats.vue'
|
||||||
|
import CaptchaTest from './views/CaptchaTest.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: Dashboard },
|
||||||
|
{ path: '/sites', component: Sites },
|
||||||
|
{ path: '/stats', component: Stats },
|
||||||
|
{ path: '/test', component: CaptchaTest },
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<div style="display:flex;gap:0;height:100vh;overflow:hidden;">
|
||||||
|
<!-- 左侧:请求配置 -->
|
||||||
|
<div style="width:360px;flex-shrink:0;overflow-y:auto;border-right:1px solid #f0f0f0;padding:20px;background:#fafafa;">
|
||||||
|
<div style="font-size:16px;font-weight:600;margin-bottom:20px;color:#333;">请求配置</div>
|
||||||
|
<n-form label-placement="top" size="small">
|
||||||
|
<n-form-item label="SiteKey">
|
||||||
|
<n-select v-model:value="cfg.siteKey" :options="siteOptions" placeholder="选择站点" filterable />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="验证码类型">
|
||||||
|
<n-select v-model:value="cfg.type" :options="typeOptions" placeholder="自动选择" clearable />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="场景">
|
||||||
|
<n-input v-model:value="cfg.scene" placeholder="default" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="服务地址">
|
||||||
|
<n-input v-model:value="cfg.serverUrl" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="样式模式">
|
||||||
|
<n-radio-group v-model:value="cfg.styleMode" size="small">
|
||||||
|
<n-radio-button value="light">浅色</n-radio-button>
|
||||||
|
<n-radio-button value="dark">深色</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-button type="primary" block @click="initCaptcha" :disabled="!cfg.siteKey" style="margin-top:8px;">
|
||||||
|
初始化验证码
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<!-- 请求日志 -->
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:#666;">请求日志</div>
|
||||||
|
<div ref="logContainer" style="height:200px;overflow-y:auto;font-family:monospace;font-size:11px;background:#fff;border:1px solid #e8e8e8;border-radius:6px;padding:8px;">
|
||||||
|
<div v-for="(log, i) in logs" :key="i" :style="{color: logColor(log.type), marginBottom:'2px', lineHeight:'18px'}">
|
||||||
|
<span style="color:#bbb;">[{{ log.time }}]</span> {{ log.msg }}
|
||||||
|
</div>
|
||||||
|
<div v-if="logs.length === 0" style="color:#ccc;">等待操作...</div>
|
||||||
|
</div>
|
||||||
|
<n-button size="tiny" @click="logs = []" style="margin-top:4px;">清空</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 验证结果 -->
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:#666;">验证结果</div>
|
||||||
|
<div v-if="!verifyResult" style="color:#ccc;font-size:12px;">暂无结果</div>
|
||||||
|
<div v-else>
|
||||||
|
<n-tag :type="verifyResult.success ? 'success' : 'error'" size="small" style="margin-bottom:6px;">
|
||||||
|
{{ verifyResult.success ? '验证成功' : '验证失败' }}
|
||||||
|
</n-tag>
|
||||||
|
<div v-if="verifyResult.token" style="word-break:break-all;font-size:11px;color:#888;background:#f6ffed;border:1px solid #b7eb8f;border-radius:4px;padding:6px;">
|
||||||
|
<div style="font-weight:600;color:#52c41a;margin-bottom:2px;">verifyToken</div>
|
||||||
|
{{ verifyResult.token }}
|
||||||
|
</div>
|
||||||
|
<div v-if="verifyResult.error" style="font-size:11px;color:#ff4d4f;">{{ verifyResult.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:验证码展示区 -->
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;"
|
||||||
|
:style="rightBgStyle">
|
||||||
|
|
||||||
|
<!-- Logo + 标题 -->
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:24px;">
|
||||||
|
<img src="/logo/logo.svg" style="height:36px;" />
|
||||||
|
<span :style="{color: cfg.styleMode === 'dark' ? '#aaa' : '#999', fontSize:'13px'}">
|
||||||
|
{{ cfg.styleMode === 'dark' ? '深色模式 - 行为验证码' : '浅色模式 - 行为验证码' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 验证码触发区 -->
|
||||||
|
<div id="captcha-trigger-zone" style="width:340px;"></div>
|
||||||
|
|
||||||
|
<!-- 验证码弹窗容器 -->
|
||||||
|
<div id="captcha-modal-zone"></div>
|
||||||
|
|
||||||
|
<!-- 状态提示 -->
|
||||||
|
<div v-if="statusMsg" style="margin-top:16px;width:340px;">
|
||||||
|
<div :style="statusBarStyle">{{ statusMsg }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'
|
||||||
|
import { NSelect, NInput, NButton, NForm, NFormItem, NTag, NRadioGroup, NRadioButton } from 'naive-ui'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const cfg = reactive({
|
||||||
|
siteKey: '',
|
||||||
|
type: null,
|
||||||
|
scene: 'default',
|
||||||
|
serverUrl: 'http://localhost:18200/api',
|
||||||
|
styleMode: 'light',
|
||||||
|
})
|
||||||
|
|
||||||
|
const siteOptions = ref([])
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: 'SLIDER 滑块', value: 'SLIDER' },
|
||||||
|
{ label: 'SLIDER_V2 增强滑块', value: 'SLIDER_V2' },
|
||||||
|
{ label: 'ROTATE 旋转', value: 'ROTATE' },
|
||||||
|
{ label: 'CONCAT 滑动还原', value: 'CONCAT' },
|
||||||
|
{ label: 'WORD_IMAGE_CLICK 文字点选', value: 'WORD_IMAGE_CLICK' },
|
||||||
|
{ label: 'ICON_CLICK 图标点选', value: 'ICON_CLICK' },
|
||||||
|
{ label: 'WORD_ORDER_CLICK 语序点选', value: 'WORD_ORDER_CLICK' },
|
||||||
|
{ label: 'CURVE_SLIDER 曲线滑块', value: 'CURVE_SLIDER' },
|
||||||
|
{ label: 'ANGLE 角度', value: 'ANGLE' },
|
||||||
|
{ label: 'SCRATCH 刮刮乐', value: 'SCRATCH' },
|
||||||
|
{ label: 'JIGSAW 乱序拼图', value: 'JIGSAW' },
|
||||||
|
{ label: 'CURVE_DRAW 曲线绘制', value: 'CURVE_DRAW' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const logs = ref([])
|
||||||
|
const verifyResult = ref(null)
|
||||||
|
const statusMsg = ref('')
|
||||||
|
const logContainer = ref(null)
|
||||||
|
const captchaInstance = ref(null)
|
||||||
|
|
||||||
|
const rightBgStyle = ref({})
|
||||||
|
const statusBarStyle = ref({})
|
||||||
|
|
||||||
|
watch(() => cfg.styleMode, (v) => {
|
||||||
|
if (v === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark')
|
||||||
|
rightBgStyle.value = { background: 'linear-gradient(135deg, #0f0c29, #302b63, #24243e)' }
|
||||||
|
statusBarStyle.value = { color: '#888', fontSize: '12px', textAlign: 'center' }
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-theme')
|
||||||
|
rightBgStyle.value = { background: '#f5f5f5' }
|
||||||
|
statusBarStyle.value = { color: '#999', fontSize: '12px', textAlign: 'center' }
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function log(type, msg) {
|
||||||
|
const time = new Date().toLocaleTimeString('zh-CN')
|
||||||
|
logs.value.push({ type, msg, time })
|
||||||
|
if (logs.value.length > 80) logs.value.shift()
|
||||||
|
nextTick(() => {
|
||||||
|
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function logColor(type) {
|
||||||
|
if (type === 'req') return '#1890ff'
|
||||||
|
if (type === 'res') return '#52c41a'
|
||||||
|
if (type === 'err') return '#ff4d4f'
|
||||||
|
return '#999'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSites() {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get('/api/admin/sites')
|
||||||
|
if (resp.data?.code === 200) {
|
||||||
|
const content = resp.data.data?.content || []
|
||||||
|
siteOptions.value = content.map(s => ({
|
||||||
|
label: s.name + ' (' + s.siteKey + ')',
|
||||||
|
value: s.siteKey,
|
||||||
|
}))
|
||||||
|
if (content.length === 1) cfg.siteKey = content[0].siteKey
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('err', '加载站点失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initCaptcha() {
|
||||||
|
if (!cfg.siteKey) return
|
||||||
|
verifyResult.value = null
|
||||||
|
statusMsg.value = '加载中...'
|
||||||
|
log('info', '初始化验证码...')
|
||||||
|
|
||||||
|
if (captchaInstance.value) {
|
||||||
|
captchaInstance.value.destroy()
|
||||||
|
captchaInstance.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const triggerEl = document.getElementById('captcha-trigger-zone')
|
||||||
|
if (triggerEl) triggerEl.innerHTML = ''
|
||||||
|
|
||||||
|
const modalEl = document.getElementById('captcha-modal-zone')
|
||||||
|
if (modalEl) modalEl.innerHTML = ''
|
||||||
|
|
||||||
|
const isDark = cfg.styleMode === 'dark'
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!window.TACaptcha) {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = '/sdk/captcha.js'
|
||||||
|
document.head.appendChild(script)
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
script.onload = resolve
|
||||||
|
script.onerror = () => reject(new Error('加载 captcha.js 失败'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = document.createElement('div')
|
||||||
|
trigger.id = 'test-captcha-btn'
|
||||||
|
trigger.style.cssText = 'width:100%;'
|
||||||
|
|
||||||
|
triggerEl.appendChild(trigger)
|
||||||
|
|
||||||
|
log('req', 'POST ' + cfg.serverUrl + '/challenge/generate?type=' + (cfg.type || 'auto'))
|
||||||
|
log('info', 'X-Site-Key: ' + cfg.siteKey)
|
||||||
|
|
||||||
|
captchaInstance.value = window.TACaptcha.init({
|
||||||
|
elId: 'test-captcha-btn',
|
||||||
|
siteKey: cfg.siteKey,
|
||||||
|
type: cfg.type,
|
||||||
|
scene: cfg.scene,
|
||||||
|
serverUrl: cfg.serverUrl,
|
||||||
|
logoUrl: '/logo/logo.svg',
|
||||||
|
theme: isDark ? 'dark' : 'light',
|
||||||
|
mode: 'click',
|
||||||
|
onSuccess: (result) => {
|
||||||
|
log('res', '验证成功! token: ' + result.data.verifyToken.substring(0, 40) + '...')
|
||||||
|
verifyResult.value = { success: true, token: result.data.verifyToken }
|
||||||
|
statusMsg.value = '验证成功'
|
||||||
|
},
|
||||||
|
onFail: (err) => {
|
||||||
|
log('err', '验证失败: ' + JSON.stringify(err))
|
||||||
|
verifyResult.value = { success: false, error: err?.msg || '基础校验失败' }
|
||||||
|
statusMsg.value = '验证失败'
|
||||||
|
},
|
||||||
|
onOpen: () => log('info', '弹窗打开'),
|
||||||
|
onClose: () => log('info', '弹窗关闭'),
|
||||||
|
})
|
||||||
|
|
||||||
|
log('res', '初始化完成')
|
||||||
|
statusMsg.value = '请点击上方按钮进行验证'
|
||||||
|
} catch (e) {
|
||||||
|
log('err', '初始化失败: ' + e.message)
|
||||||
|
statusMsg.value = '初始化失败: ' + e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadSites)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<n-space vertical :size="24">
|
||||||
|
<n-h2>仪表盘</n-h2>
|
||||||
|
<n-grid :cols="4" :x-gap="16" :y-gap="16">
|
||||||
|
<n-gi><n-statistic label="请求总数" :value="stats.total" /></n-gi>
|
||||||
|
<n-gi><n-statistic label="通过总数" :value="stats.success" /></n-gi>
|
||||||
|
<n-gi><n-statistic label="失败总数" :value="stats.fail" /></n-gi>
|
||||||
|
<n-gi><n-statistic label="总通过率" :value="stats.passRate" /></n-gi>
|
||||||
|
</n-grid>
|
||||||
|
<n-h3>验证码类型</n-h3>
|
||||||
|
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
||||||
|
<n-gi v-for="t in types" :key="t.key">
|
||||||
|
<n-card size="small" :title="t.label">
|
||||||
|
<template #header-extra><n-tag size="small">{{ t.key }}</n-tag></template>
|
||||||
|
已注册
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { NSpace, NH2, NH3, NGrid, NGi, NStatistic, NCard, NTag } from 'naive-ui'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const stats = ref({ total: 0, success: 0, fail: 0, passRate: '0%' })
|
||||||
|
const types = [
|
||||||
|
{ key: 'SLIDER', label: '滑块验证' },
|
||||||
|
{ key: 'SLIDER_V2', label: '增强滑块' },
|
||||||
|
{ key: 'ROTATE', label: '旋转验证' },
|
||||||
|
{ key: 'CONCAT', label: '滑动还原' },
|
||||||
|
{ key: 'WORD_IMAGE_CLICK', label: '文字点选' },
|
||||||
|
{ key: 'ICON_CLICK', label: '图标点选' },
|
||||||
|
{ key: 'WORD_ORDER_CLICK', label: '语序点选' },
|
||||||
|
{ key: 'CURVE_SLIDER', label: '曲线滑块' },
|
||||||
|
{ key: 'ANGLE', label: '角度验证' },
|
||||||
|
{ key: 'SCRATCH', label: '刮刮乐' },
|
||||||
|
{ key: 'JIGSAW', label: '乱序拼图' },
|
||||||
|
{ key: 'CURVE_DRAW', label: '曲线绘制' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get('/api/admin/stats?days=7')
|
||||||
|
if (resp.data?.code === 200) stats.value = resp.data.data
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<n-space vertical :size="20">
|
||||||
|
<n-h2>站点管理
|
||||||
|
<template #suffix>
|
||||||
|
<n-button type="primary" @click="showCreate = true">创建站点</n-button>
|
||||||
|
</template>
|
||||||
|
</n-h2>
|
||||||
|
<n-data-table :columns="columns" :data="sites" :pagination="{ pageSize: 20 }" :bordered="false" />
|
||||||
|
|
||||||
|
<n-modal v-model:show="showCreate" preset="dialog" title="创建站点" positive-text="创建" @positive-click="createSite">
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="站点名称"><n-input v-model:value="form.name" /></n-form-item>
|
||||||
|
<n-form-item label="域名"><n-input v-model:value="form.domain" placeholder="lovehome.safina520.cn" /></n-form-item>
|
||||||
|
<n-form-item label="验证码类型">
|
||||||
|
<n-checkbox-group v-model:value="form.captchaTypes">
|
||||||
|
<n-space>
|
||||||
|
<n-checkbox v-for="t in allTypes" :key="t.value" :value="t.value" :label="t.label" />
|
||||||
|
</n-space>
|
||||||
|
</n-checkbox-group>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<n-modal v-model:show="showDetail" preset="dialog" title="站点详情" :show-action="false" style="width:600px">
|
||||||
|
<n-descriptions v-if="detailSite" bordered :column="1" label-placement="left" size="small">
|
||||||
|
<n-descriptions-item label="ID">{{ detailSite.id }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="名称">{{ detailSite.name }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="域名">{{ detailSite.domain }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="SiteKey">
|
||||||
|
<n-tag size="small" :bordered="false">{{ detailSite.siteKey }}</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="SecretKey">
|
||||||
|
<n-tag size="small" :bordered="false">{{ detailSite.secretKey }}</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="验证等级">{{ detailSite.verifyLevel }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="QPS">{{ detailSite.qps }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="日限制">{{ detailSite.dailyLimit }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="验证码类型">
|
||||||
|
<n-space>
|
||||||
|
<n-tag v-for="t in (detailSite.captchaTypes || [])" :key="t" size="small">{{ t }}</n-tag>
|
||||||
|
</n-space>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="状态">
|
||||||
|
<n-tag :type="detailSite.isEnabled ? 'success' : 'error'" size="small">{{ detailSite.isEnabled ? '启用' : '禁用' }}</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="轨迹验证">{{ detailSite.trackValidationEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="对抗扰动">{{ detailSite.obfuscationEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="端到端加密">{{ detailSite.encryptionEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="创建时间">{{ formatTime(detailSite.createdAt) }}</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</n-modal>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, h } from 'vue'
|
||||||
|
import { NSpace, NH2, NButton, NDataTable, NModal, NForm, NFormItem, NInput, NTag, NCheckboxGroup, NCheckbox, NDescriptions, NDescriptionsItem } from 'naive-ui'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const sites = ref([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const showDetail = ref(false)
|
||||||
|
const detailSite = ref(null)
|
||||||
|
const form = ref({ name: '', domain: '', captchaTypes: ['SLIDER', 'ROTATE', 'ICON_CLICK'] })
|
||||||
|
|
||||||
|
const allTypes = [
|
||||||
|
{ value: 'SLIDER', label: '滑块验证' },
|
||||||
|
{ value: 'SLIDER_V2', label: '增强滑块' },
|
||||||
|
{ value: 'ROTATE', label: '旋转验证' },
|
||||||
|
{ value: 'CONCAT', label: '滑动还原' },
|
||||||
|
{ value: 'WORD_IMAGE_CLICK', label: '文字点选' },
|
||||||
|
{ value: 'ICON_CLICK', label: '图标点选' },
|
||||||
|
{ value: 'WORD_ORDER_CLICK', label: '语序点选' },
|
||||||
|
{ value: 'CURVE_SLIDER', label: '曲线滑块' },
|
||||||
|
{ value: 'ANGLE', label: '角度验证' },
|
||||||
|
{ value: 'SCRATCH', label: '刮刮乐' },
|
||||||
|
{ value: 'JIGSAW', label: '乱序拼图' },
|
||||||
|
{ value: 'CURVE_DRAW', label: '曲线绘制' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID', key: 'id', width: 60 },
|
||||||
|
{ title: '名称', key: 'name', width: 120 },
|
||||||
|
{ title: '域名', key: 'domain', width: 160 },
|
||||||
|
{
|
||||||
|
title: 'SiteKey', key: 'siteKey', width: 200,
|
||||||
|
render: (row) => h(NTag, { size: 'small', bordered: false }, () => row.siteKey)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '验证等级', key: 'verifyLevel', width: 90,
|
||||||
|
render: (row) => h(NTag, {
|
||||||
|
type: row.verifyLevel === 'HIGH' ? 'error' : row.verifyLevel === 'MEDIUM' ? 'warning' : 'info', size: 'small'
|
||||||
|
}, () => row.verifyLevel)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态', key: 'isEnabled', width: 70,
|
||||||
|
render: (row) => h(NTag, { type: row.isEnabled ? 'success' : 'error', size: 'small' }, () => row.isEnabled ? '启用' : '禁用')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间', key: 'createdAt', width: 160,
|
||||||
|
render: (row) => formatTime(row.createdAt)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', key: 'actions', width: 200,
|
||||||
|
render: (row) => h('div', { style: 'display:flex;gap:8px;' }, [
|
||||||
|
h(NButton, { size: 'small', onClick: () => viewSite(row) }, () => '详情'),
|
||||||
|
h(NButton, { size: 'small', onClick: () => toggleSite(row) }, () => row.isEnabled ? '禁用' : '启用'),
|
||||||
|
h(NButton, { size: 'small', type: 'error', onClick: () => deleteSite(row.id) }, () => '删除'),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatTime(v) {
|
||||||
|
if (!v) return '-'
|
||||||
|
return new Date(v).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSites() {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get('/api/admin/sites')
|
||||||
|
if (resp.data?.code === 200) sites.value = resp.data.data?.content || []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSite() {
|
||||||
|
await axios.post('/api/admin/sites', {
|
||||||
|
name: form.value.name,
|
||||||
|
domain: form.value.domain,
|
||||||
|
captchaTypes: form.value.captchaTypes,
|
||||||
|
})
|
||||||
|
form.value = { name: '', domain: '', captchaTypes: ['SLIDER', 'ROTATE', 'ICON_CLICK'] }
|
||||||
|
await loadSites()
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewSite(row) {
|
||||||
|
detailSite.value = row
|
||||||
|
showDetail.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSite(row) {
|
||||||
|
row.isEnabled = !row.isEnabled
|
||||||
|
await axios.put(`/api/admin/sites/${row.id}`, row)
|
||||||
|
await loadSites()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSite(id) {
|
||||||
|
await axios.delete(`/api/admin/sites/${id}`)
|
||||||
|
await loadSites()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadSites)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<n-space vertical :size="20">
|
||||||
|
<n-h2>统计监控</n-h2>
|
||||||
|
<n-grid :cols="4" :x-gap="16" :y-gap="16">
|
||||||
|
<n-gi><n-statistic label="请求总数" :value="stats.total" /></n-gi>
|
||||||
|
<n-gi><n-statistic label="通过总数" :value="stats.success" /></n-gi>
|
||||||
|
<n-gi><n-statistic label="失败总数" :value="stats.fail" /></n-gi>
|
||||||
|
<n-gi><n-statistic label="总通过率" :value="stats.passRate" /></n-gi>
|
||||||
|
</n-grid>
|
||||||
|
<n-h3>IP黑名单管理</n-h3>
|
||||||
|
<n-space>
|
||||||
|
<n-input v-model:value="banForm.ip" placeholder="IP地址" style="width:200px" />
|
||||||
|
<n-input v-model:value="banForm.reason" placeholder="封禁原因" style="width:200px" />
|
||||||
|
<n-button type="error" @click="banIp">封禁IP</n-button>
|
||||||
|
</n-space>
|
||||||
|
<n-data-table :columns="ipColumns" :data="bannedIps" :bordered="false" />
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, h } from 'vue'
|
||||||
|
import { NSpace, NH2, NH3, NGrid, NGi, NStatistic, NDataTable, NButton, NInput, NTag } from 'naive-ui'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const stats = ref({ total: 0, success: 0, fail: 0, passRate: '0%' })
|
||||||
|
const bannedIps = ref([])
|
||||||
|
const banForm = ref({ ip: '', reason: '' })
|
||||||
|
|
||||||
|
const ipColumns = [
|
||||||
|
{ title: 'IP', key: 'ip' },
|
||||||
|
{ title: '原因', key: 'reason' },
|
||||||
|
{ title: '解封时间', key: 'banUntil', render: (row) => row.banUntil ? new Date(row.banUntil).toLocaleString('zh-CN') : '永久' },
|
||||||
|
{
|
||||||
|
title: '操作', key: 'actions',
|
||||||
|
render: (row) => h(NButton, { size: 'small', type: 'warning', onClick: () => unbanIp(row.ip) }, () => '解封')
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get('/api/admin/stats?days=7')
|
||||||
|
if (resp.data?.code === 200) stats.value = resp.data.data
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function banIp() {
|
||||||
|
if (!banForm.value.ip) return
|
||||||
|
await axios.post('/api/admin/ip-blacklist', {
|
||||||
|
ip: banForm.value.ip,
|
||||||
|
reason: banForm.value.reason,
|
||||||
|
durationMs: 3600000,
|
||||||
|
})
|
||||||
|
banForm.value = { ip: '', reason: '' }
|
||||||
|
await loadBannedIps()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbanIp(ip) {
|
||||||
|
await axios.delete(`/api/admin/ip-blacklist/${ip}`)
|
||||||
|
await loadBannedIps()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBannedIps() {
|
||||||
|
bannedIps.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadStats(); loadBannedIps() })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:18200',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "tianai-captcha-sdk",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "tianai-captcha-enhanced 前端SDK,兼容TPCaptcha API",
|
||||||
|
"main": "dist/captcha.min.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "terser src/captcha.js -o dist/captcha.min.js -c -m",
|
||||||
|
"dev": "echo 'Copy src/captcha.js to your project for development'"
|
||||||
|
},
|
||||||
|
"keywords": ["captcha", "slider", "puzzle", "tianai", "verification"],
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
"name": "webpack-demo",
|
"name": "webpack-demo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"sass": "^1.103.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.22.17",
|
"@babel/core": "^7.22.17",
|
||||||
"@babel/preset-env": "^7.22.15",
|
"@babel/preset-env": "^7.22.15",
|
||||||
@@ -1904,6 +1907,312 @@
|
|||||||
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==",
|
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@parcel/watcher": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"node-addon-api": "^7.0.0",
|
||||||
|
"picomatch": "^4.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher-android-arm64": "2.6.0",
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.6.0",
|
||||||
|
"@parcel/watcher-darwin-x64": "2.6.0",
|
||||||
|
"@parcel/watcher-freebsd-x64": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-arm-glibc": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-arm-musl": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-arm64-musl": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.6.0",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.6.0",
|
||||||
|
"@parcel/watcher-win32-x64": "2.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-android-arm64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-darwin-x64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-arm64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-x64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.23",
|
"version": "1.0.0-next.23",
|
||||||
"resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.23.tgz",
|
"resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.23.tgz",
|
||||||
@@ -3284,6 +3593,16 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-node": {
|
"node_modules/detect-node": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz",
|
||||||
@@ -4300,12 +4619,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "4.3.6",
|
"version": "5.1.9",
|
||||||
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-4.3.6.tgz",
|
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz",
|
||||||
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
|
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"optional": true,
|
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/import-local": {
|
"node_modules/import-local": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -4428,7 +4745,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -4449,7 +4766,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
},
|
},
|
||||||
@@ -5224,6 +5541,13 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/node-forge": {
|
"node_modules/node-forge": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.3.1.tgz",
|
||||||
@@ -6021,22 +6345,23 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/sass": {
|
"node_modules/sass": {
|
||||||
"version": "1.77.4",
|
"version": "1.103.1",
|
||||||
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.77.4.tgz",
|
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.103.1.tgz",
|
||||||
"integrity": "sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==",
|
"integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": ">=3.0.0 <4.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"immutable": "^4.0.0",
|
"immutable": "^5.1.5",
|
||||||
"source-map-js": ">=0.6.2 <2.0.0"
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"sass": "sass.js"
|
"sass": "sass.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-loader": {
|
"node_modules/sass-loader": {
|
||||||
@@ -6072,6 +6397,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sass/node_modules/chokidar": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sass/node_modules/readdirp": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/schema-utils": {
|
"node_modules/schema-utils": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-3.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||||
@@ -6336,7 +6689,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -8624,6 +8976,110 @@
|
|||||||
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==",
|
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@parcel/watcher": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
|
||||||
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"@parcel/watcher-android-arm64": "2.6.0",
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.6.0",
|
||||||
|
"@parcel/watcher-darwin-x64": "2.6.0",
|
||||||
|
"@parcel/watcher-freebsd-x64": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-arm-glibc": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-arm-musl": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-arm64-musl": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.6.0",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.6.0",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.6.0",
|
||||||
|
"@parcel/watcher-win32-x64": "2.6.0",
|
||||||
|
"detect-libc": "^2.0.3",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"node-addon-api": "^7.0.0",
|
||||||
|
"picomatch": "^4.0.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@parcel/watcher-android-arm64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-darwin-arm64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-darwin-x64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-freebsd-x64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-arm-glibc": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-arm-musl": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-arm64-musl": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-x64-glibc": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-x64-musl": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-win32-arm64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@parcel/watcher-win32-x64": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"@polka/url": {
|
"@polka/url": {
|
||||||
"version": "1.0.0-next.23",
|
"version": "1.0.0-next.23",
|
||||||
"resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.23.tgz",
|
"resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.23.tgz",
|
||||||
@@ -9792,6 +10248,12 @@
|
|||||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"detect-node": {
|
"detect-node": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz",
|
||||||
@@ -10608,12 +11070,9 @@
|
|||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"immutable": {
|
"immutable": {
|
||||||
"version": "4.3.6",
|
"version": "5.1.9",
|
||||||
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-4.3.6.tgz",
|
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz",
|
||||||
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
|
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg=="
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"import-local": {
|
"import-local": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -10709,7 +11168,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"is-generator-function": {
|
"is-generator-function": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
@@ -10724,7 +11183,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
}
|
}
|
||||||
@@ -11330,6 +11789,12 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node-forge": {
|
"node-forge": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.3.1.tgz",
|
||||||
@@ -11954,16 +12419,29 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"sass": {
|
"sass": {
|
||||||
"version": "1.77.4",
|
"version": "1.103.1",
|
||||||
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.77.4.tgz",
|
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.103.1.tgz",
|
||||||
"integrity": "sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==",
|
"integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==",
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"chokidar": ">=3.0.0 <4.0.0",
|
"@parcel/watcher": "^2.4.1",
|
||||||
"immutable": "^4.0.0",
|
"chokidar": "^5.0.0",
|
||||||
|
"immutable": "^5.1.5",
|
||||||
"source-map-js": ">=0.6.2 <2.0.0"
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
|
"requires": {
|
||||||
|
"readdirp": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"readdirp": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sass-loader": {
|
"sass-loader": {
|
||||||
@@ -12197,8 +12675,7 @@
|
|||||||
"source-map-js": {
|
"source-map-js": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"source-map-support": {
|
"source-map-support": {
|
||||||
"version": "0.5.21",
|
"version": "0.5.21",
|
||||||
|
|||||||
@@ -29,5 +29,8 @@
|
|||||||
"webpack-bundle-analyzer": "^4.9.1",
|
"webpack-bundle-analyzer": "^4.9.1",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-dev-server": "^4.15.1"
|
"webpack-dev-server": "^4.15.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"sass": "^1.103.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package cloud.tianai.captcha.crypto;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public class AesEncryptor {
|
||||||
|
|
||||||
|
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||||
|
private static final int GCM_IV_LENGTH = 12;
|
||||||
|
private static final int GCM_TAG_LENGTH = 128;
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
|
private final SecretKeySpec key;
|
||||||
|
|
||||||
|
public AesEncryptor(byte[] keyBytes) {
|
||||||
|
if (keyBytes.length != 32) {
|
||||||
|
throw new IllegalArgumentException("AES-256 key must be 32 bytes, got " + keyBytes.length);
|
||||||
|
}
|
||||||
|
this.key = new SecretKeySpec(keyBytes, "AES");
|
||||||
|
}
|
||||||
|
|
||||||
|
public AesEncryptor(String base64Key) {
|
||||||
|
this(Base64.getDecoder().decode(base64Key));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String encrypt(String plaintext) {
|
||||||
|
try {
|
||||||
|
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||||
|
secureRandom.nextBytes(iv);
|
||||||
|
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||||
|
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec);
|
||||||
|
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
byte[] combined = new byte[iv.length + ciphertext.length];
|
||||||
|
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||||
|
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
|
||||||
|
return Base64.getEncoder().encodeToString(combined);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoException("AES encryption failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String decrypt(String encrypted) {
|
||||||
|
try {
|
||||||
|
byte[] combined = Base64.getDecoder().decode(encrypted);
|
||||||
|
if (combined.length < GCM_IV_LENGTH) {
|
||||||
|
throw new CryptoException("Encrypted data too short");
|
||||||
|
}
|
||||||
|
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||||
|
System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH);
|
||||||
|
byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH];
|
||||||
|
System.arraycopy(combined, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length);
|
||||||
|
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||||
|
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec);
|
||||||
|
byte[] plaintext = cipher.doFinal(ciphertext);
|
||||||
|
return new String(plaintext, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoException("AES decryption failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] generateKey() {
|
||||||
|
byte[] key = new byte[32];
|
||||||
|
new SecureRandom().nextBytes(key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package cloud.tianai.captcha.crypto;
|
||||||
|
|
||||||
|
public class CryptoException extends RuntimeException {
|
||||||
|
|
||||||
|
public CryptoException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CryptoException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package cloud.tianai.captcha.crypto;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class CryptoService {
|
||||||
|
|
||||||
|
private final AesEncryptor aesEncryptor;
|
||||||
|
private final RsaEncryptor rsaEncryptor;
|
||||||
|
private final Signer signer;
|
||||||
|
private final String secretKey;
|
||||||
|
|
||||||
|
public CryptoService(byte[] aesKey, KeyPair rsaKeyPair, String secretKey) {
|
||||||
|
this.aesEncryptor = new AesEncryptor(aesKey);
|
||||||
|
try {
|
||||||
|
this.rsaEncryptor = new RsaEncryptor(
|
||||||
|
RsaEncryptor.publicKeyToBase64(rsaKeyPair.getPublic()),
|
||||||
|
RsaEncryptor.privateKeyToBase64(rsaKeyPair.getPrivate())
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoException("Failed to initialize RSA encryptor", e);
|
||||||
|
}
|
||||||
|
this.signer = new Signer();
|
||||||
|
this.secretKey = secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CryptoService(String base64AesKey, String base64PublicKey, String base64PrivateKey, String secretKey) {
|
||||||
|
this.aesEncryptor = new AesEncryptor(base64AesKey);
|
||||||
|
try {
|
||||||
|
this.rsaEncryptor = new RsaEncryptor(base64PublicKey, base64PrivateKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoException("Failed to initialize RSA encryptor", e);
|
||||||
|
}
|
||||||
|
this.signer = new Signer();
|
||||||
|
this.secretKey = secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EncryptedPayload encrypt(String plaintext) {
|
||||||
|
String nonce = signer.generateNonce();
|
||||||
|
long timestamp = System.currentTimeMillis();
|
||||||
|
|
||||||
|
String aesEncrypted = aesEncryptor.encrypt(plaintext);
|
||||||
|
String signature = signer.signWithTimestamp(aesEncrypted + "|" + nonce, secretKey, timestamp);
|
||||||
|
|
||||||
|
EncryptedPayload payload = new EncryptedPayload();
|
||||||
|
payload.data = aesEncrypted;
|
||||||
|
payload.nonce = nonce;
|
||||||
|
payload.timestamp = timestamp;
|
||||||
|
payload.signature = signature;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String decrypt(EncryptedPayload payload) {
|
||||||
|
if (!signer.verifyWithTimestamp(
|
||||||
|
payload.data + "|" + payload.nonce,
|
||||||
|
secretKey,
|
||||||
|
payload.signature,
|
||||||
|
payload.timestamp,
|
||||||
|
300000
|
||||||
|
)) {
|
||||||
|
throw new CryptoException("Signature verification failed or timestamp expired");
|
||||||
|
}
|
||||||
|
return aesEncryptor.decrypt(payload.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String encryptAesKeyForTransport(byte[] aesKey) {
|
||||||
|
return rsaEncryptor.encrypt(Base64.getEncoder().encodeToString(aesKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String decryptAesKeyFromTransport(String encryptedAesKey) {
|
||||||
|
return rsaEncryptor.decrypt(encryptedAesKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPublicKeyBase64() {
|
||||||
|
return RsaEncryptor.publicKeyToBase64(rsaEncryptor.getPublicKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CryptoService generate() throws Exception {
|
||||||
|
byte[] aesKey = AesEncryptor.generateKey();
|
||||||
|
KeyPair rsaKeyPair = RsaEncryptor.generateKeyPair();
|
||||||
|
byte[] secretKeyBytes = new byte[32];
|
||||||
|
new java.security.SecureRandom().nextBytes(secretKeyBytes);
|
||||||
|
String secretKey = Base64.getEncoder().encodeToString(secretKeyBytes);
|
||||||
|
return new CryptoService(aesKey, rsaKeyPair, secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class EncryptedPayload {
|
||||||
|
public String data;
|
||||||
|
public String nonce;
|
||||||
|
public long timestamp;
|
||||||
|
public String signature;
|
||||||
|
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put("data", data);
|
||||||
|
map.put("nonce", nonce);
|
||||||
|
map.put("ts", timestamp);
|
||||||
|
map.put("sig", signature);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EncryptedPayload fromMap(Map<String, Object> map) {
|
||||||
|
EncryptedPayload payload = new EncryptedPayload();
|
||||||
|
payload.data = (String) map.get("data");
|
||||||
|
payload.nonce = (String) map.get("nonce");
|
||||||
|
payload.timestamp = map.get("ts") instanceof Number
|
||||||
|
? ((Number) map.get("ts")).longValue()
|
||||||
|
: Long.parseLong(String.valueOf(map.get("ts")));
|
||||||
|
payload.signature = (String) map.get("sig");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package cloud.tianai.captcha.crypto;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.MGF1ParameterSpec;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import javax.crypto.spec.OAEPParameterSpec;
|
||||||
|
import javax.crypto.spec.PSource;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public class RsaEncryptor {
|
||||||
|
|
||||||
|
private static final String ALGORITHM = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
||||||
|
private static final int KEY_SIZE = 4096;
|
||||||
|
private static final int MAX_ENCRYPT_BLOCK = 446;
|
||||||
|
|
||||||
|
private final PublicKey publicKey;
|
||||||
|
private final PrivateKey privateKey;
|
||||||
|
|
||||||
|
public PublicKey getPublicKey() {
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PrivateKey getPrivateKey() {
|
||||||
|
return privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RsaEncryptor(PublicKey publicKey, PrivateKey privateKey) {
|
||||||
|
this.publicKey = publicKey;
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RsaEncryptor(String base64PublicKey, String base64PrivateKey) throws Exception {
|
||||||
|
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||||
|
this.publicKey = base64PublicKey != null
|
||||||
|
? keyFactory.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(base64PublicKey)))
|
||||||
|
: null;
|
||||||
|
this.privateKey = base64PrivateKey != null
|
||||||
|
? keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64PrivateKey)))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String encrypt(String plaintext) {
|
||||||
|
if (publicKey == null) {
|
||||||
|
throw new CryptoException("Public key not available for encryption");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||||
|
OAEPParameterSpec oaepSpec = new OAEPParameterSpec(
|
||||||
|
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
|
||||||
|
);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, publicKey, oaepSpec);
|
||||||
|
byte[] encrypted = cipher.doFinal(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(encrypted);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoException("RSA encryption failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String decrypt(String encrypted) {
|
||||||
|
if (privateKey == null) {
|
||||||
|
throw new CryptoException("Private key not available for decryption");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||||
|
OAEPParameterSpec oaepSpec = new OAEPParameterSpec(
|
||||||
|
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
|
||||||
|
);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepSpec);
|
||||||
|
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted));
|
||||||
|
return new String(decrypted, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoException("RSA decryption failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static KeyPair generateKeyPair() throws Exception {
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(KEY_SIZE);
|
||||||
|
return generator.generateKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String publicKeyToBase64(PublicKey key) {
|
||||||
|
return Base64.getEncoder().encodeToString(key.getEncoded());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String privateKeyToBase64(PrivateKey key) {
|
||||||
|
return Base64.getEncoder().encodeToString(key.getEncoded());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package cloud.tianai.captcha.crypto;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public class Signer {
|
||||||
|
|
||||||
|
private static final String ALGORITHM = "SHA-256";
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
|
public String sign(String data, String secretKey) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance(ALGORITHM);
|
||||||
|
String payload = secretKey + data + secretKey;
|
||||||
|
byte[] hash = digest.digest(payload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(hash);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoException("Signing failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean verify(String data, String secretKey, String signature) {
|
||||||
|
String expected = sign(data, secretKey);
|
||||||
|
return constantTimeEquals(expected, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String signWithTimestamp(String data, String secretKey, long timestamp) {
|
||||||
|
return sign(data + "|" + timestamp, secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean verifyWithTimestamp(String data, String secretKey, String signature, long timestamp, long toleranceMs) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (Math.abs(now - timestamp) > toleranceMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return verify(data + "|" + timestamp, secretKey, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateNonce() {
|
||||||
|
byte[] nonce = new byte[16];
|
||||||
|
secureRandom.nextBytes(nonce);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean constantTimeEquals(String a, String b) {
|
||||||
|
if (a.length() != b.length()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int result = 0;
|
||||||
|
for (int i = 0; i < a.length(); i++) {
|
||||||
|
result |= a.charAt(i) ^ b.charAt(i);
|
||||||
|
}
|
||||||
|
return result == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.Resource;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.geom.AffineTransform;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
public class AngleRotateImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
|
||||||
|
|
||||||
|
public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png";
|
||||||
|
public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png";
|
||||||
|
|
||||||
|
public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm) {
|
||||||
|
super(rm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) {
|
||||||
|
super(rm);
|
||||||
|
setImageTransform(transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) {
|
||||||
|
super(rm);
|
||||||
|
setImageTransform(transform);
|
||||||
|
setInterceptor(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag());
|
||||||
|
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
|
||||||
|
BufferedImage background = getResourceImage(resourceImage);
|
||||||
|
BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME);
|
||||||
|
BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME);
|
||||||
|
|
||||||
|
int centerX = background.getWidth() / 2;
|
||||||
|
int centerY = background.getHeight() / 2;
|
||||||
|
|
||||||
|
int targetAngle = randomInt(30, 330);
|
||||||
|
double radians = Math.toRadians(targetAngle);
|
||||||
|
|
||||||
|
BufferedImage rotatedActive = rotateImage(activeTemplate, -radians);
|
||||||
|
|
||||||
|
int templateSize = activeTemplate.getWidth();
|
||||||
|
int drawX = centerX - templateSize / 2;
|
||||||
|
int drawY = centerY - templateSize / 2;
|
||||||
|
|
||||||
|
CaptchaImageUtils.overlayImage(background, fixedTemplate, drawX, drawY);
|
||||||
|
|
||||||
|
BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(templateSize, background.getHeight());
|
||||||
|
CaptchaImageUtils.overlayImage(matrixTemplate, rotatedActive, 0, drawY);
|
||||||
|
|
||||||
|
captchaExchange.setBackgroundImage(background);
|
||||||
|
captchaExchange.setTemplateImage(matrixTemplate);
|
||||||
|
captchaExchange.setTemplateResource(templateResource);
|
||||||
|
captchaExchange.setResourceImage(resourceImage);
|
||||||
|
captchaExchange.setTransferData(new AngleData(targetAngle));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
BufferedImage backgroundImage = captchaExchange.getBackgroundImage();
|
||||||
|
BufferedImage templateImage = captchaExchange.getTemplateImage();
|
||||||
|
Resource resourceImage = captchaExchange.getResourceImage();
|
||||||
|
ResourceMap templateResource = captchaExchange.getTemplateResource();
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
AngleData angleData = (AngleData) captchaExchange.getTransferData();
|
||||||
|
ImageTransformData transform = getImageTransform().transform(param, backgroundImage, templateImage, resourceImage, templateResource, customData);
|
||||||
|
|
||||||
|
RotateImageCaptchaInfo info = RotateImageCaptchaInfo.of((double) angleData.angle, 0,
|
||||||
|
transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(),
|
||||||
|
resourceImage.getTag(), templateResource.getTag(),
|
||||||
|
backgroundImage.getWidth(), backgroundImage.getHeight(),
|
||||||
|
templateImage.getWidth(), templateImage.getHeight());
|
||||||
|
info.setData(customData);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage rotateImage(BufferedImage image, double radians) {
|
||||||
|
int w = image.getWidth();
|
||||||
|
int h = image.getHeight();
|
||||||
|
int newW = (int) Math.ceil(Math.abs(w * Math.cos(radians)) + Math.abs(h * Math.sin(radians)));
|
||||||
|
int newH = (int) Math.ceil(Math.abs(h * Math.cos(radians)) + Math.abs(w * Math.sin(radians)));
|
||||||
|
BufferedImage result = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g2d = result.createGraphics();
|
||||||
|
try {
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
AffineTransform at = new AffineTransform();
|
||||||
|
at.translate((double) newW / 2, (double) newH / 2);
|
||||||
|
at.rotate(radians);
|
||||||
|
at.translate((double) -w / 2, (double) -h / 2);
|
||||||
|
g2d.drawRenderedImage(image, at);
|
||||||
|
} finally {
|
||||||
|
g2d.dispose();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AngleData {
|
||||||
|
public int angle;
|
||||||
|
public AngleData(int angle) { this.angle = angle; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.Resource;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.geom.CubicCurve2D;
|
||||||
|
import java.awt.geom.Point2D;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
public class CurveDrawImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
|
||||||
|
|
||||||
|
public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
|
||||||
|
public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
|
||||||
|
public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
|
||||||
|
BufferedImage background = getResourceImage(resourceImage);
|
||||||
|
int width = background.getWidth();
|
||||||
|
int height = background.getHeight();
|
||||||
|
|
||||||
|
ThreadLocalRandom random = ThreadLocalRandom.current();
|
||||||
|
double x0 = random.nextDouble(width * 0.05, width * 0.15);
|
||||||
|
double y0 = random.nextDouble(height * 0.3, height * 0.7);
|
||||||
|
double x3 = random.nextDouble(width * 0.85, width * 0.95);
|
||||||
|
double y3 = random.nextDouble(height * 0.3, height * 0.7);
|
||||||
|
double x1 = random.nextDouble(width * 0.25, width * 0.45);
|
||||||
|
double y1 = random.nextDouble(height * 0.1, height * 0.9);
|
||||||
|
double x2 = random.nextDouble(width * 0.55, width * 0.75);
|
||||||
|
double y2 = random.nextDouble(height * 0.1, height * 0.9);
|
||||||
|
|
||||||
|
Graphics2D g2d = background.createGraphics();
|
||||||
|
try {
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g2d.setColor(new Color(50, 50, 200, 180));
|
||||||
|
g2d.setStroke(new BasicStroke(3));
|
||||||
|
CubicCurve2D curve = new CubicCurve2D.Double(x0, y0, x1, y1, x2, y2, x3, y3);
|
||||||
|
g2d.draw(curve);
|
||||||
|
g2d.setColor(new Color(200, 50, 50, 200));
|
||||||
|
g2d.fillOval((int) x0 - 5, (int) y0 - 5, 10, 10);
|
||||||
|
g2d.fillOval((int) x3 - 5, (int) y3 - 5, 10, 10);
|
||||||
|
} finally {
|
||||||
|
g2d.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaExchange.setBackgroundImage(background);
|
||||||
|
captchaExchange.setTemplateImage(null);
|
||||||
|
captchaExchange.setResourceImage(resourceImage);
|
||||||
|
CurveDrawData curveData = new CurveDrawData(x0, y0, x1, y1, x2, y2, x3, y3);
|
||||||
|
// 在曲线上均匀采样点用于验证
|
||||||
|
List<Point2D.Double> samplePoints = new ArrayList<>();
|
||||||
|
int sampleCount = 10;
|
||||||
|
for (int i = 0; i < sampleCount; i++) {
|
||||||
|
double t = (double) i / (sampleCount - 1);
|
||||||
|
double sx = cubicBezier(t, x0, x1, x2, x3);
|
||||||
|
double sy = cubicBezier(t, y0, y1, y2, y3);
|
||||||
|
samplePoints.add(new Point2D.Double(sx, sy));
|
||||||
|
}
|
||||||
|
curveData.setSamplePoints(samplePoints);
|
||||||
|
captchaExchange.setTransferData(curveData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double cubicBezier(double t, double p0, double p1, double p2, double p3) {
|
||||||
|
double oneMinusT = 1.0 - t;
|
||||||
|
return oneMinusT * oneMinusT * oneMinusT * p0 +
|
||||||
|
3.0 * oneMinusT * oneMinusT * t * p1 +
|
||||||
|
3.0 * oneMinusT * t * t * p2 +
|
||||||
|
t * t * t * p3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
BufferedImage bg = captchaExchange.getBackgroundImage();
|
||||||
|
Resource resImg = captchaExchange.getResourceImage();
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
ImageTransformData transform = getImageTransform().transform(param, bg, null, resImg, null, customData);
|
||||||
|
|
||||||
|
ImageCaptchaInfo info = ImageCaptchaInfo.of(
|
||||||
|
transform.getBackgroundImageUrl(), null,
|
||||||
|
resImg.getTag(), null,
|
||||||
|
bg.getWidth(), bg.getHeight(), 0, 0,
|
||||||
|
0, "CURVE_DRAW");
|
||||||
|
// 将 CurveDrawData 设置到 customData.expand 中
|
||||||
|
CurveDrawData curveData = (CurveDrawData) captchaExchange.getTransferData();
|
||||||
|
if (customData == null) {
|
||||||
|
customData = new CustomData();
|
||||||
|
}
|
||||||
|
customData.setExpand(curveData);
|
||||||
|
info.setData(customData);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CurveDrawData {
|
||||||
|
public double x0, y0, x1, y1, x2, y2, x3, y3;
|
||||||
|
public List<Point2D.Double> samplePoints;
|
||||||
|
public CurveDrawData(double x0, double y0, double x1, double y1, double x2, double y2, double x3, double y3) {
|
||||||
|
this.x0 = x0; this.y0 = y0; this.x1 = x1; this.y1 = y1;
|
||||||
|
this.x2 = x2; this.y2 = y2; this.x3 = x3; this.y3 = y3;
|
||||||
|
}
|
||||||
|
public List<Point2D.Double> getSamplePoints() { return samplePoints; }
|
||||||
|
public void setSamplePoints(List<Point2D.Double> samplePoints) { this.samplePoints = samplePoints; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.Resource;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.geom.CubicCurve2D;
|
||||||
|
import java.awt.geom.FlatteningPathIterator;
|
||||||
|
import java.awt.geom.PathIterator;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
public class CurveSliderImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
|
||||||
|
|
||||||
|
public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png";
|
||||||
|
public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png";
|
||||||
|
public static String TEMPLATE_MASK_IMAGE_NAME = "mask.png";
|
||||||
|
|
||||||
|
public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
|
||||||
|
public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
|
||||||
|
public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag());
|
||||||
|
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
|
||||||
|
BufferedImage background = getResourceImage(resourceImage);
|
||||||
|
BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME);
|
||||||
|
BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME);
|
||||||
|
BufferedImage maskTemplate = getTemplateImageOfOptional(templateResource, TEMPLATE_MASK_IMAGE_NAME).orElse(fixedTemplate);
|
||||||
|
|
||||||
|
int bgWidth = background.getWidth();
|
||||||
|
int bgHeight = background.getHeight();
|
||||||
|
int tplWidth = fixedTemplate.getWidth();
|
||||||
|
|
||||||
|
ThreadLocalRandom random = ThreadLocalRandom.current();
|
||||||
|
double x0 = random.nextDouble(0, bgWidth * 0.1);
|
||||||
|
double y0 = random.nextDouble(bgHeight * 0.3, bgHeight * 0.7);
|
||||||
|
double x3 = random.nextDouble(bgWidth * 0.85, bgWidth * 0.95);
|
||||||
|
double y3 = random.nextDouble(bgHeight * 0.3, bgHeight * 0.7);
|
||||||
|
double x1 = random.nextDouble(bgWidth * 0.25, bgWidth * 0.45);
|
||||||
|
double y1 = random.nextDouble(bgHeight * 0.1, bgHeight * 0.9);
|
||||||
|
double x2 = random.nextDouble(bgWidth * 0.55, bgWidth * 0.75);
|
||||||
|
double y2 = random.nextDouble(bgHeight * 0.1, bgHeight * 0.9);
|
||||||
|
|
||||||
|
CubicCurve2D curve = new CubicCurve2D.Double(x0, y0, x1, y1, x2, y2, x3, y3);
|
||||||
|
List<Point> pathPoints = sampleCurve(curve, bgWidth);
|
||||||
|
|
||||||
|
double targetRatio = random.nextDouble(0.35, 0.75);
|
||||||
|
int targetIndex = (int) (pathPoints.size() * targetRatio);
|
||||||
|
Point targetPoint = pathPoints.get(targetIndex);
|
||||||
|
|
||||||
|
int randomX = targetPoint.x;
|
||||||
|
int randomY = Math.max(0, Math.min(bgHeight - tplWidth, targetPoint.y - tplWidth / 2));
|
||||||
|
|
||||||
|
BufferedImage cutImage = CaptchaImageUtils.cutImage(background, maskTemplate, randomX, randomY);
|
||||||
|
CaptchaImageUtils.overlayImage(background, fixedTemplate, randomX, randomY);
|
||||||
|
CaptchaImageUtils.overlayImage(cutImage, activeTemplate, 0, 0);
|
||||||
|
|
||||||
|
BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(activeTemplate.getWidth(), background.getHeight());
|
||||||
|
CaptchaImageUtils.overlayImage(matrixTemplate, cutImage, 0, randomY);
|
||||||
|
|
||||||
|
captchaExchange.setBackgroundImage(background);
|
||||||
|
captchaExchange.setTemplateImage(matrixTemplate);
|
||||||
|
captchaExchange.setTemplateResource(templateResource);
|
||||||
|
captchaExchange.setResourceImage(resourceImage);
|
||||||
|
captchaExchange.setTransferData(new CurveData(randomX, randomY, pathPoints));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SliderImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
BufferedImage bg = captchaExchange.getBackgroundImage();
|
||||||
|
BufferedImage tpl = captchaExchange.getTemplateImage();
|
||||||
|
Resource resImg = captchaExchange.getResourceImage();
|
||||||
|
ResourceMap tplRes = captchaExchange.getTemplateResource();
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
CurveData curveData = (CurveData) captchaExchange.getTransferData();
|
||||||
|
ImageTransformData transform = getImageTransform().transform(param, bg, tpl, resImg, tplRes, customData);
|
||||||
|
|
||||||
|
SliderImageCaptchaInfo info = SliderImageCaptchaInfo.of(curveData.x, curveData.y,
|
||||||
|
transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(),
|
||||||
|
resImg.getTag(), tplRes.getTag(),
|
||||||
|
bg.getWidth(), bg.getHeight(), tpl.getWidth(), tpl.getHeight());
|
||||||
|
info.setData(customData);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Point> sampleCurve(CubicCurve2D curve, int bgWidth) {
|
||||||
|
List<Point> points = new ArrayList<>();
|
||||||
|
PathIterator pi = curve.getPathIterator(null, 0.5);
|
||||||
|
FlatteningPathIterator fpi = new FlatteningPathIterator(pi, 0.5);
|
||||||
|
double[] coords = new double[6];
|
||||||
|
while (!fpi.isDone()) {
|
||||||
|
int type = fpi.currentSegment(coords);
|
||||||
|
if (type == PathIterator.SEG_LINETO || type == PathIterator.SEG_MOVETO) {
|
||||||
|
points.add(new Point((int) coords[0], (int) coords[1]));
|
||||||
|
}
|
||||||
|
fpi.next();
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CurveData {
|
||||||
|
public int x;
|
||||||
|
public int y;
|
||||||
|
public List<Point> pathPoints;
|
||||||
|
public CurveData(int x, int y, List<Point> pathPoints) {
|
||||||
|
this.x = x; this.y = y; this.pathPoints = pathPoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
|
||||||
|
public class CurveSliderV2ImageCaptchaGenerator extends CurveSliderImageCaptchaGenerator {
|
||||||
|
|
||||||
|
public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
|
||||||
|
public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm, t); }
|
||||||
|
public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm, t, i); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
|
||||||
|
public class CurveSliderV3ImageCaptchaGenerator extends CurveSliderImageCaptchaGenerator {
|
||||||
|
|
||||||
|
public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
|
||||||
|
public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm, t); }
|
||||||
|
public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm, t, i); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.obfuscator.CompositeObfuscator;
|
||||||
|
import cloud.tianai.captcha.obfuscator.ImageObfuscator;
|
||||||
|
import cloud.tianai.captcha.obfuscator.ObfuscatorConfig;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.Resource;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public class EnhancedSliderImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
|
||||||
|
|
||||||
|
public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png";
|
||||||
|
public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png";
|
||||||
|
public static String TEMPLATE_MASK_IMAGE_NAME = "mask.png";
|
||||||
|
public static String OBFUSCATE_TEMPLATE_FIXED_IMAGE_NAME = "obfuscate_" + TEMPLATE_FIXED_IMAGE_NAME;
|
||||||
|
|
||||||
|
private ImageObfuscator obfuscator = new CompositeObfuscator();
|
||||||
|
private ObfuscatorConfig obfuscatorConfig = ObfuscatorConfig.defaultConfig();
|
||||||
|
|
||||||
|
public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm) {
|
||||||
|
super(rm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) {
|
||||||
|
super(rm);
|
||||||
|
setImageTransform(transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) {
|
||||||
|
super(rm);
|
||||||
|
setImageTransform(transform);
|
||||||
|
setInterceptor(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
Boolean obfuscate = param.getObfuscate();
|
||||||
|
ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag());
|
||||||
|
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
|
||||||
|
BufferedImage background = getResourceImage(resourceImage);
|
||||||
|
BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME);
|
||||||
|
BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME);
|
||||||
|
BufferedImage maskTemplate = fixedTemplate;
|
||||||
|
Optional<BufferedImage> maskOpt = getTemplateImageOfOptional(templateResource, TEMPLATE_MASK_IMAGE_NAME);
|
||||||
|
if (maskOpt.isPresent()) {
|
||||||
|
maskTemplate = maskOpt.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
int randomX = randomInt(fixedTemplate.getWidth() + 5, background.getWidth() - fixedTemplate.getWidth() - 10);
|
||||||
|
int randomY = randomInt(background.getHeight() - fixedTemplate.getHeight());
|
||||||
|
|
||||||
|
BufferedImage cutImage = CaptchaImageUtils.cutImage(background, maskTemplate, randomX, randomY);
|
||||||
|
CaptchaImageUtils.overlayImage(background, fixedTemplate, randomX, randomY);
|
||||||
|
|
||||||
|
if (obfuscate) {
|
||||||
|
Optional<BufferedImage> obfOpt = getTemplateImageOfOptional(templateResource, OBFUSCATE_TEMPLATE_FIXED_IMAGE_NAME);
|
||||||
|
BufferedImage obfImage = obfOpt.orElseGet(() -> new StandardSliderImageCaptchaGenerator(getImageResourceManager()) {}.createObfuscate(fixedTemplate));
|
||||||
|
int obfX = randomObfuscateX(randomX, fixedTemplate.getWidth(), background.getWidth());
|
||||||
|
CaptchaImageUtils.overlayImage(background, obfImage, obfX, randomY);
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptchaImageUtils.overlayImage(cutImage, activeTemplate, 0, 0);
|
||||||
|
BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(activeTemplate.getWidth(), background.getHeight());
|
||||||
|
CaptchaImageUtils.overlayImage(matrixTemplate, cutImage, 0, randomY);
|
||||||
|
|
||||||
|
if (obfuscate) {
|
||||||
|
background = obfuscator.obfuscate(background, obfuscatorConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaExchange.setBackgroundImage(background);
|
||||||
|
captchaExchange.setTemplateImage(matrixTemplate);
|
||||||
|
captchaExchange.setTemplateResource(templateResource);
|
||||||
|
captchaExchange.setResourceImage(resourceImage);
|
||||||
|
captchaExchange.setTransferData(new Point(randomX, randomY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SliderImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
BufferedImage backgroundImage = captchaExchange.getBackgroundImage();
|
||||||
|
BufferedImage sliderImage = captchaExchange.getTemplateImage();
|
||||||
|
Resource resourceImage = captchaExchange.getResourceImage();
|
||||||
|
ResourceMap templateResource = captchaExchange.getTemplateResource();
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
Point data = (Point) captchaExchange.getTransferData();
|
||||||
|
ImageTransformData transform = getImageTransform().transform(param, backgroundImage, sliderImage, resourceImage, templateResource, customData);
|
||||||
|
SliderImageCaptchaInfo info = SliderImageCaptchaInfo.of(data.x, data.y,
|
||||||
|
transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(),
|
||||||
|
resourceImage.getTag(), templateResource.getTag(),
|
||||||
|
backgroundImage.getWidth(), backgroundImage.getHeight(),
|
||||||
|
sliderImage.getWidth(), sliderImage.getHeight());
|
||||||
|
info.setData(customData);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected int randomObfuscateX(int sliderX, int slWidth, int bgWidth) {
|
||||||
|
if (bgWidth / 2 > (sliderX + (slWidth / 2))) {
|
||||||
|
return randomInt(sliderX + slWidth, bgWidth - slWidth);
|
||||||
|
}
|
||||||
|
return randomInt(slWidth, sliderX - slWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setObfuscator(ImageObfuscator obfuscator) { this.obfuscator = obfuscator; }
|
||||||
|
public void setObfuscatorConfig(ObfuscatorConfig config) { this.obfuscatorConfig = config; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
|
||||||
|
import cloud.tianai.captcha.common.constant.CommonConstant;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.Resource;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标点选验证码。
|
||||||
|
* <p>
|
||||||
|
* 图标使用预渲染的 PNG 资源(classpath: META-INF/captcha-icons/*.png),
|
||||||
|
* 不依赖运行环境字体(Linux 容器通常没有 emoji 字体,直接字体渲染会得到豆腐块)。
|
||||||
|
* 图标资产由 tools/icon-render/RenderIcons.java 在开发机(有 Segoe UI Emoji 字体)预先生成,
|
||||||
|
* 新增/替换图标时在开发机重跑该工具即可。
|
||||||
|
*/
|
||||||
|
public class IconClickImageCaptchaGenerator extends AbstractClickImageCaptchaGenerator {
|
||||||
|
|
||||||
|
/** 图标分类,每次随机取一类并从中挑 3 个。名称对应 META-INF/captcha-icons/<name>.png */
|
||||||
|
private static final List<String[]> ICON_CATEGORIES = List.of(
|
||||||
|
new String[]{"apple", "banana", "cherry", "grapes", "lemon", "orange", "strawberry", "watermelon"},
|
||||||
|
new String[]{"dog", "cat", "mouse", "hamster", "rabbit", "fox", "bear", "panda"},
|
||||||
|
new String[]{"soccer", "basketball", "tennis", "volleyball", "billiards", "pingpong", "trophy", "boxing"},
|
||||||
|
new String[]{"car", "taxi", "suv", "bus", "racecar", "police", "ambulance", "firetruck"},
|
||||||
|
new String[]{"phone", "laptop", "desktop", "printer", "keyboard", "computer-mouse", "cd", "camera"}
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final String ICON_BASE_PATH = "META-INF/captcha-icons/";
|
||||||
|
/** 图标在背景图上的渲染尺寸 */
|
||||||
|
private static final int ICON_RENDER_SIZE = 48;
|
||||||
|
/** 提示条内单个图标尺寸 */
|
||||||
|
private static final int TIP_ICON_SIZE = 36;
|
||||||
|
/** 提示条内边距 */
|
||||||
|
private static final int TIP_PADDING = 8;
|
||||||
|
|
||||||
|
/** classpath PNG 加载缓存(icon name -> image),PNG 本身不可变,可安全共享 */
|
||||||
|
private static final Map<String, BufferedImage> ICON_CACHE = new ConcurrentHashMap<>(48);
|
||||||
|
|
||||||
|
public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
|
||||||
|
public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
|
||||||
|
public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<ResourceMap> randomGetClickImgTips(GenerateParam param) {
|
||||||
|
ThreadLocalRandom random = ThreadLocalRandom.current();
|
||||||
|
String[] category = ICON_CATEGORIES.get(random.nextInt(ICON_CATEGORIES.size()));
|
||||||
|
List<String> icons = new ArrayList<>(Arrays.asList(category));
|
||||||
|
Collections.shuffle(icons, random);
|
||||||
|
int count = Math.min(3, icons.size());
|
||||||
|
List<ResourceMap> result = new ArrayList<>(count);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
ResourceMap map = new ResourceMap("default", 2);
|
||||||
|
Resource iconResource = new Resource("icon", icons.get(i), "default");
|
||||||
|
map.put(CommonConstant.IMAGE_CLICK_ICON, iconResource);
|
||||||
|
map.put(CommonConstant.IMAGE_TIP_ICON, iconResource);
|
||||||
|
result.add(map);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ClickImageCheckDefinition.ImgWrapper getClickImg(GenerateParam param, Resource tip, Color randomColor, BufferedImage bgImage) {
|
||||||
|
BufferedImage source = loadIcon(tip.getData());
|
||||||
|
BufferedImage scaled = new BufferedImage(ICON_RENDER_SIZE, ICON_RENDER_SIZE, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g = scaled.createGraphics();
|
||||||
|
try {
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g.drawImage(source, 0, 0, ICON_RENDER_SIZE, ICON_RENDER_SIZE, null);
|
||||||
|
} finally {
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
ClickImageCheckDefinition.ImgWrapper wrapper = new ClickImageCheckDefinition.ImgWrapper();
|
||||||
|
wrapper.setImage(scaled);
|
||||||
|
wrapper.setImageColor(Color.BLACK);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<ClickImageCheckDefinition> filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List<ClickImageCheckDefinition> allCheckDefinitionList) {
|
||||||
|
// 3 个图标全部参与校验,保持生成顺序(与提示条顺序一致)
|
||||||
|
return allCheckDefinitionList;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
List<ClickImageCheckDefinition> checkList = (List<ClickImageCheckDefinition>) captchaExchange.getTransferData();
|
||||||
|
BufferedImage bgImage = captchaExchange.getBackgroundImage();
|
||||||
|
Resource resourceImage = captchaExchange.getResourceImage();
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
// 提示条:按点击顺序横向拼接图标,作为 templateImage 返回给前端展示
|
||||||
|
BufferedImage tipImage = genTipImage(checkList);
|
||||||
|
ImageTransformData transform = getImageTransform().transform(captchaExchange.getParam(), bgImage, tipImage, resourceImage, checkList, customData);
|
||||||
|
|
||||||
|
ImageCaptchaInfo info = new ImageCaptchaInfo();
|
||||||
|
info.setBackgroundImage(transform.getBackgroundImageUrl());
|
||||||
|
info.setTemplateImage(transform.getTemplateImageUrl());
|
||||||
|
info.setBackgroundImageTag(resourceImage.getTag());
|
||||||
|
info.setBackgroundImageWidth(bgImage.getWidth());
|
||||||
|
info.setBackgroundImageHeight(bgImage.getHeight());
|
||||||
|
info.setTemplateImageWidth(tipImage.getWidth());
|
||||||
|
info.setTemplateImageHeight(tipImage.getHeight());
|
||||||
|
info.setType(CaptchaTypeConstant.ICON_CLICK);
|
||||||
|
customData.setExpand(checkList);
|
||||||
|
info.setData(customData);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将参与校验的图标按点击顺序横向拼接为提示条图(白底圆角,保证任意背景上可读)。
|
||||||
|
*/
|
||||||
|
private BufferedImage genTipImage(List<ClickImageCheckDefinition> checkList) {
|
||||||
|
int n = checkList.size();
|
||||||
|
int width = TIP_PADDING + n * (TIP_ICON_SIZE + TIP_PADDING);
|
||||||
|
int height = TIP_ICON_SIZE + 2 * TIP_PADDING;
|
||||||
|
BufferedImage tip = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g = tip.createGraphics();
|
||||||
|
try {
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g.setColor(new Color(255, 255, 255, 235));
|
||||||
|
g.fillRoundRect(0, 0, width - 1, height - 1, 12, 12);
|
||||||
|
g.setColor(new Color(0, 0, 0, 60));
|
||||||
|
g.drawRoundRect(0, 0, width - 1, height - 1, 12, 12);
|
||||||
|
int x = TIP_PADDING;
|
||||||
|
for (ClickImageCheckDefinition def : checkList) {
|
||||||
|
BufferedImage icon = def.getTipImage() != null && def.getTipImage().getImage() != null
|
||||||
|
? def.getTipImage().getImage()
|
||||||
|
: loadIcon(def.getTip().getData());
|
||||||
|
g.drawImage(icon, x, TIP_PADDING, TIP_ICON_SIZE, TIP_ICON_SIZE, null);
|
||||||
|
x += TIP_ICON_SIZE + TIP_PADDING;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage loadIcon(String name) {
|
||||||
|
return ICON_CACHE.computeIfAbsent(name, n -> {
|
||||||
|
String path = ICON_BASE_PATH + n + ".png";
|
||||||
|
try (InputStream in = IconClickImageCaptchaGenerator.class.getClassLoader().getResourceAsStream(path)) {
|
||||||
|
if (in == null) {
|
||||||
|
throw new IllegalStateException("图标资源不存在: " + path);
|
||||||
|
}
|
||||||
|
BufferedImage img = ImageIO.read(in);
|
||||||
|
if (img == null) {
|
||||||
|
throw new IllegalStateException("图标资源解析失败: " + path);
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("图标资源读取失败: " + path, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.Resource;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
public class JigsawImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
|
||||||
|
|
||||||
|
private int gridCols = 3;
|
||||||
|
private int gridRows = 2;
|
||||||
|
|
||||||
|
public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
|
||||||
|
public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
|
||||||
|
public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
|
||||||
|
BufferedImage background = getResourceImage(resourceImage);
|
||||||
|
|
||||||
|
int width = background.getWidth();
|
||||||
|
int height = background.getHeight();
|
||||||
|
int tileWidth = width / gridCols;
|
||||||
|
int tileHeight = height / gridRows;
|
||||||
|
|
||||||
|
int totalTiles = gridCols * gridRows;
|
||||||
|
List<Integer> positions = new ArrayList<>(totalTiles);
|
||||||
|
for (int i = 0; i < totalTiles; i++) positions.add(i);
|
||||||
|
List<Integer> shuffled = new ArrayList<>(positions);
|
||||||
|
Collections.shuffle(shuffled, ThreadLocalRandom.current());
|
||||||
|
|
||||||
|
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g2d = result.createGraphics();
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < totalTiles; i++) {
|
||||||
|
int srcRow = i / gridCols;
|
||||||
|
int srcCol = i % gridCols;
|
||||||
|
int dstRow = shuffled.get(i) / gridCols;
|
||||||
|
int dstCol = shuffled.get(i) % gridCols;
|
||||||
|
int srcX = srcCol * tileWidth;
|
||||||
|
int srcY = srcRow * tileHeight;
|
||||||
|
int dstX = dstCol * tileWidth;
|
||||||
|
int dstY = dstRow * tileHeight;
|
||||||
|
BufferedImage tile = background.getSubimage(
|
||||||
|
Math.min(srcX, width - tileWidth),
|
||||||
|
Math.min(srcY, height - tileHeight),
|
||||||
|
tileWidth, tileHeight);
|
||||||
|
g2d.drawImage(tile, dstX, dstY, null);
|
||||||
|
}
|
||||||
|
g2d.setColor(new Color(255, 255, 255, 80));
|
||||||
|
for (int c = 1; c < gridCols; c++) {
|
||||||
|
g2d.drawLine(c * tileWidth, 0, c * tileWidth, height);
|
||||||
|
}
|
||||||
|
for (int r = 1; r < gridRows; r++) {
|
||||||
|
g2d.drawLine(0, r * tileHeight, width, r * tileHeight);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
g2d.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Integer> restoreOrder = new ArrayList<>(totalTiles);
|
||||||
|
Integer[] restore = new Integer[totalTiles];
|
||||||
|
for (int i = 0; i < totalTiles; i++) {
|
||||||
|
restore[shuffled.get(i)] = i;
|
||||||
|
}
|
||||||
|
Collections.addAll(restoreOrder, restore);
|
||||||
|
|
||||||
|
captchaExchange.setBackgroundImage(result);
|
||||||
|
captchaExchange.setTemplateImage(null);
|
||||||
|
captchaExchange.setResourceImage(resourceImage);
|
||||||
|
captchaExchange.setTransferData(new JigsawData(restoreOrder, gridCols, gridRows));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
BufferedImage bg = captchaExchange.getBackgroundImage();
|
||||||
|
Resource resImg = captchaExchange.getResourceImage();
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
JigsawData data = (JigsawData) captchaExchange.getTransferData();
|
||||||
|
ImageTransformData transform = getImageTransform().transform(param, bg, null, resImg, null, customData);
|
||||||
|
|
||||||
|
ImageCaptchaInfo info = ImageCaptchaInfo.of(
|
||||||
|
transform.getBackgroundImageUrl(), null,
|
||||||
|
resImg.getTag(), null,
|
||||||
|
bg.getWidth(), bg.getHeight(), 0, 0,
|
||||||
|
0, "JIGSAW");
|
||||||
|
info.setData(customData);
|
||||||
|
// Store restore order as expand for validator
|
||||||
|
customData.expand = data.restoreOrder.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse("");
|
||||||
|
// Also pass cols/rows to frontend via viewData
|
||||||
|
customData.putViewData("cols", data.cols);
|
||||||
|
customData.putViewData("rows", data.rows);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGridCols(int cols) { this.gridCols = cols; }
|
||||||
|
public void setGridRows(int rows) { this.gridRows = rows; }
|
||||||
|
|
||||||
|
public static class JigsawData {
|
||||||
|
public List<Integer> restoreOrder;
|
||||||
|
public int cols;
|
||||||
|
public int rows;
|
||||||
|
public JigsawData(List<Integer> restoreOrder, int cols, int rows) {
|
||||||
|
this.restoreOrder = restoreOrder; this.cols = cols; this.rows = rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
public class ProofOfWorkCaptchaGenerator extends AbstractImageCaptchaGenerator {
|
||||||
|
|
||||||
|
private int difficulty = 4;
|
||||||
|
|
||||||
|
public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
|
||||||
|
public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
|
||||||
|
public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
byte[] challenge = new byte[32];
|
||||||
|
new SecureRandom().nextBytes(challenge);
|
||||||
|
String challengeStr = Base64.getUrlEncoder().withoutPadding().encodeToString(challenge);
|
||||||
|
captchaExchange.setTransferData(new PoWData(challengeStr, difficulty));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
PoWData powData = (PoWData) captchaExchange.getTransferData();
|
||||||
|
ImageCaptchaInfo info = new ImageCaptchaInfo();
|
||||||
|
info.setType("PROOF_OF_WORK");
|
||||||
|
info.setRandomX(powData.difficulty);
|
||||||
|
// Put data on the exchange's customData, not a new one
|
||||||
|
// (AbstractImageCaptchaGenerator.generateCaptchaImage overwrites info.data with exchange.customData)
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
customData.putViewData("challenge", powData.challenge);
|
||||||
|
customData.putViewData("difficulty", powData.difficulty);
|
||||||
|
customData.expand = powData;
|
||||||
|
info.setData(customData);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean verifyProof(String challenge, int difficulty, String nonce) {
|
||||||
|
try {
|
||||||
|
String input = challenge + nonce;
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||||
|
for (int i = 0; i < difficulty; i++) {
|
||||||
|
if (hash[i] != 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDifficulty(int d) { this.difficulty = d; }
|
||||||
|
|
||||||
|
public static class PoWData {
|
||||||
|
public String challenge;
|
||||||
|
public int difficulty;
|
||||||
|
public PoWData(String challenge, int difficulty) {
|
||||||
|
this.challenge = challenge; this.difficulty = difficulty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.Resource;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
public class ScratchImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
|
||||||
|
|
||||||
|
private float coverOpacity = 0.75f;
|
||||||
|
private Color coverColor = new Color(180, 180, 180);
|
||||||
|
|
||||||
|
public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm) {
|
||||||
|
super(rm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) {
|
||||||
|
super(rm);
|
||||||
|
setImageTransform(transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) {
|
||||||
|
super(rm);
|
||||||
|
setImageTransform(transform);
|
||||||
|
setInterceptor(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
|
||||||
|
BufferedImage background = getResourceImage(resourceImage);
|
||||||
|
int width = background.getWidth();
|
||||||
|
int height = background.getHeight();
|
||||||
|
|
||||||
|
BufferedImage cover = createCoverLayer(width, height);
|
||||||
|
addScratchPattern(cover, width, height);
|
||||||
|
|
||||||
|
captchaExchange.setBackgroundImage(background);
|
||||||
|
captchaExchange.setTemplateImage(cover);
|
||||||
|
captchaExchange.setResourceImage(resourceImage);
|
||||||
|
captchaExchange.setTransferData(new ScratchData(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
BufferedImage backgroundImage = captchaExchange.getBackgroundImage();
|
||||||
|
BufferedImage coverImage = captchaExchange.getTemplateImage();
|
||||||
|
Resource resourceImage = captchaExchange.getResourceImage();
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
ImageTransformData transform = getImageTransform().transform(param, backgroundImage, coverImage, resourceImage, null, customData);
|
||||||
|
|
||||||
|
ScratchData scratchData = (ScratchData) captchaExchange.getTransferData();
|
||||||
|
ImageCaptchaInfo info = ImageCaptchaInfo.of(
|
||||||
|
transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(),
|
||||||
|
resourceImage.getTag(), null,
|
||||||
|
backgroundImage.getWidth(), backgroundImage.getHeight(),
|
||||||
|
coverImage.getWidth(), coverImage.getHeight(),
|
||||||
|
scratchData.width / 2, "SCRATCH");
|
||||||
|
info.setData(customData);
|
||||||
|
// Store scratch threshold in expand for validator
|
||||||
|
customData.expand = 50; // 50% threshold
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage createCoverLayer(int width, int height) {
|
||||||
|
BufferedImage cover = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g2d = cover.createGraphics();
|
||||||
|
try {
|
||||||
|
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, coverOpacity));
|
||||||
|
g2d.setColor(coverColor);
|
||||||
|
g2d.fillRect(0, 0, width, height);
|
||||||
|
} finally {
|
||||||
|
g2d.dispose();
|
||||||
|
}
|
||||||
|
return cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addScratchPattern(BufferedImage cover, int width, int height) {
|
||||||
|
Graphics2D g2d = cover.createGraphics();
|
||||||
|
try {
|
||||||
|
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f));
|
||||||
|
g2d.setColor(Color.LIGHT_GRAY);
|
||||||
|
ThreadLocalRandom random = ThreadLocalRandom.current();
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
int x1 = random.nextInt(width);
|
||||||
|
int y1 = random.nextInt(height);
|
||||||
|
int x2 = random.nextInt(width);
|
||||||
|
int y2 = random.nextInt(height);
|
||||||
|
g2d.setStroke(new BasicStroke(2 + random.nextFloat() * 3));
|
||||||
|
g2d.drawLine(x1, y1, x2, y2);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
g2d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ScratchData {
|
||||||
|
public int width;
|
||||||
|
public int height;
|
||||||
|
public ScratchData(int w, int h) { this.width = w; this.height = h; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package cloud.tianai.captcha.generator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.common.constant.CommonConstant;
|
||||||
|
import cloud.tianai.captcha.generator.ImageTransform;
|
||||||
|
import cloud.tianai.captcha.generator.common.model.dto.*;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.Resource;
|
||||||
|
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
public class WordOrderClickImageCaptchaGenerator extends AbstractClickImageCaptchaGenerator {
|
||||||
|
|
||||||
|
private static final String[] PHRASES = {
|
||||||
|
"春暖花开", "风和日丽", "山清水秀", "鸟语花香",
|
||||||
|
"天高云淡", "秋高气爽", "冰天雪地", "春华秋实",
|
||||||
|
"龙飞凤舞", "万紫千红", "花好月圆", "国泰民安"
|
||||||
|
};
|
||||||
|
|
||||||
|
public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
|
||||||
|
public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
|
||||||
|
public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<ResourceMap> randomGetClickImgTips(GenerateParam param) {
|
||||||
|
ThreadLocalRandom random = ThreadLocalRandom.current();
|
||||||
|
String phrase = PHRASES[random.nextInt(PHRASES.length)];
|
||||||
|
char[] chars = phrase.toCharArray();
|
||||||
|
List<ResourceMap> result = new ArrayList<>(chars.length);
|
||||||
|
for (char c : chars) {
|
||||||
|
ResourceMap map = new ResourceMap("default", 2);
|
||||||
|
Resource charResource = new Resource("char", String.valueOf(c), "default");
|
||||||
|
map.put(CommonConstant.IMAGE_CLICK_ICON, charResource);
|
||||||
|
map.put(CommonConstant.IMAGE_TIP_ICON, charResource);
|
||||||
|
result.add(map);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ClickImageCheckDefinition.ImgWrapper getClickImg(GenerateParam param, Resource tip, Color randomColor, BufferedImage bgImage) {
|
||||||
|
String text = tip.getData();
|
||||||
|
int fontSize = 28;
|
||||||
|
ThreadLocalRandom random = ThreadLocalRandom.current();
|
||||||
|
float rotation = (random.nextFloat() - 0.5f) * 0.4f;
|
||||||
|
float scale = 0.9f + random.nextFloat() * 0.3f;
|
||||||
|
|
||||||
|
BufferedImage img = new BufferedImage(fontSize * 2, fontSize * 2, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g2d = img.createGraphics();
|
||||||
|
try {
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||||
|
g2d.translate(fontSize, fontSize);
|
||||||
|
g2d.rotate(rotation);
|
||||||
|
g2d.scale(scale, scale);
|
||||||
|
g2d.setFont(new Font("SimHei", Font.BOLD, fontSize));
|
||||||
|
g2d.setColor(randomColor != null ? randomColor : Color.BLACK);
|
||||||
|
FontMetrics fm = g2d.getFontMetrics();
|
||||||
|
g2d.drawString(text, -fm.stringWidth(text) / 2, fm.getAscent() / 2);
|
||||||
|
} finally {
|
||||||
|
g2d.dispose();
|
||||||
|
}
|
||||||
|
ClickImageCheckDefinition.ImgWrapper wrapper = new ClickImageCheckDefinition.ImgWrapper();
|
||||||
|
wrapper.setImage(img);
|
||||||
|
wrapper.setImageColor(randomColor != null ? randomColor : Color.BLACK);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<ClickImageCheckDefinition> filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List<ClickImageCheckDefinition> allCheckDefinitionList) {
|
||||||
|
return allCheckDefinitionList;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
|
||||||
|
GenerateParam param = captchaExchange.getParam();
|
||||||
|
BufferedImage bgImage = captchaExchange.getBackgroundImage();
|
||||||
|
Resource resourceImage = captchaExchange.getResourceImage();
|
||||||
|
CustomData customData = captchaExchange.getCustomData();
|
||||||
|
List<ClickImageCheckDefinition> checkList = (List<ClickImageCheckDefinition>) captchaExchange.getTransferData();
|
||||||
|
ImageTransformData transform = getImageTransform().transform(param, bgImage, null, resourceImage, null, customData);
|
||||||
|
|
||||||
|
ImageCaptchaInfo info = ImageCaptchaInfo.of(
|
||||||
|
transform.getBackgroundImageUrl(), null,
|
||||||
|
resourceImage.getTag(), null,
|
||||||
|
bgImage.getWidth(), bgImage.getHeight(), 0, 0,
|
||||||
|
0, "WORD_ORDER_CLICK");
|
||||||
|
info.setData(customData);
|
||||||
|
if (checkList != null && customData != null) {
|
||||||
|
customData.expand = checkList;
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package cloud.tianai.captcha.interceptor.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.common.AnyMap;
|
||||||
|
import cloud.tianai.captcha.common.response.ApiResponse;
|
||||||
|
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
|
||||||
|
import cloud.tianai.captcha.interceptor.Context;
|
||||||
|
import cloud.tianai.captcha.risk.RiskEngine;
|
||||||
|
import cloud.tianai.captcha.validator.common.model.dto.MatchParam;
|
||||||
|
|
||||||
|
public class RiskControlInterceptor implements CaptchaInterceptor {
|
||||||
|
|
||||||
|
private final RiskEngine riskEngine;
|
||||||
|
|
||||||
|
public RiskControlInterceptor(RiskEngine riskEngine) {
|
||||||
|
this.riskEngine = riskEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ApiResponse<?> beforeValid(Context context, String type, MatchParam matchParam, AnyMap validData) {
|
||||||
|
String ip = extractIp(matchParam);
|
||||||
|
String id = context != null ? context.getName() : "unknown";
|
||||||
|
RiskEngine.RiskResult result = riskEngine.check(ip, id);
|
||||||
|
if (!result.isAllowed()) {
|
||||||
|
return ApiResponse.of(4003, result.getReason(), null);
|
||||||
|
}
|
||||||
|
return ApiResponse.ofSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ApiResponse<?> afterValid(Context context, String type, MatchParam matchParam, AnyMap validData, ApiResponse<?> basicValid) {
|
||||||
|
String ip = extractIp(matchParam);
|
||||||
|
if (basicValid != null && basicValid.isSuccess()) {
|
||||||
|
riskEngine.recordSuccess(ip);
|
||||||
|
} else {
|
||||||
|
riskEngine.recordFail(ip);
|
||||||
|
}
|
||||||
|
return ApiResponse.ofSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractIp(MatchParam matchParam) {
|
||||||
|
if (matchParam != null) {
|
||||||
|
Object ip = matchParam.get("ip");
|
||||||
|
if (ip instanceof String) {
|
||||||
|
return (String) ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package cloud.tianai.captcha.ml;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TrackFeatureExtractor {
|
||||||
|
|
||||||
|
public TrackFeatures extract(ImageCaptchaTrack track) {
|
||||||
|
List<ImageCaptchaTrack.Track> trackList = track.getTrackList();
|
||||||
|
if (trackList == null || trackList.isEmpty()) {
|
||||||
|
return TrackFeatures.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackFeatures features = new TrackFeatures();
|
||||||
|
features.totalPoints = trackList.size();
|
||||||
|
|
||||||
|
List<Float> xList = new ArrayList<>(trackList.size());
|
||||||
|
List<Float> yList = new ArrayList<>(trackList.size());
|
||||||
|
List<Float> tList = new ArrayList<>(trackList.size());
|
||||||
|
|
||||||
|
for (ImageCaptchaTrack.Track p : trackList) {
|
||||||
|
xList.add(p.getX());
|
||||||
|
yList.add(p.getY());
|
||||||
|
tList.add(p.getT());
|
||||||
|
}
|
||||||
|
|
||||||
|
features.totalDuration = track.getStopTime() != null && track.getStartTime() != null
|
||||||
|
? track.getStopTime() - track.getStartTime()
|
||||||
|
: (tList.size() >= 2 ? (long)(tList.get(tList.size() - 1) - tList.get(0)) : 0L);
|
||||||
|
|
||||||
|
float startX = xList.get(0);
|
||||||
|
float startY = yList.get(0);
|
||||||
|
float endX = xList.get(xList.size() - 1);
|
||||||
|
float endY = yList.get(yList.size() - 1);
|
||||||
|
|
||||||
|
features.startX = startX;
|
||||||
|
features.startY = startY;
|
||||||
|
features.endX = endX;
|
||||||
|
features.endY = endY;
|
||||||
|
|
||||||
|
int bgWidth = track.getBgImageWidth() != null ? track.getBgImageWidth() : 600;
|
||||||
|
features.displacementX = endX - startX;
|
||||||
|
features.displacementY = endY - startY;
|
||||||
|
features.displacementXRatio = features.displacementX / bgWidth;
|
||||||
|
|
||||||
|
List<Float> speeds = new ArrayList<>(trackList.size() - 1);
|
||||||
|
List<Float> accelerations = new ArrayList<>(Math.max(0, trackList.size() - 2));
|
||||||
|
double totalPathLength = 0;
|
||||||
|
|
||||||
|
for (int i = 1; i < trackList.size(); i++) {
|
||||||
|
float dx = xList.get(i) - xList.get(i - 1);
|
||||||
|
float dy = yList.get(i) - yList.get(i - 1);
|
||||||
|
float dt = tList.get(i) - tList.get(i - 1);
|
||||||
|
double dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
totalPathLength += dist;
|
||||||
|
|
||||||
|
if (dt > 0) {
|
||||||
|
speeds.add((float)(dist / dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
features.totalPathLength = totalPathLength;
|
||||||
|
features.pathEfficiency = totalPathLength > 0
|
||||||
|
? Math.sqrt(features.displacementX * features.displacementX + features.displacementY * features.displacementY) / totalPathLength
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
for (int i = 1; i < speeds.size(); i++) {
|
||||||
|
accelerations.add(speeds.get(i) - speeds.get(i - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
features.avgSpeed = average(speeds);
|
||||||
|
features.maxSpeed = max(speeds);
|
||||||
|
features.minSpeed = min(speeds);
|
||||||
|
features.speedVariance = variance(speeds);
|
||||||
|
features.speedStdDev = stdDev(speeds);
|
||||||
|
features.speedSkewness = skewness(speeds);
|
||||||
|
|
||||||
|
features.avgAcceleration = average(accelerations);
|
||||||
|
features.maxAcceleration = max(accelerations);
|
||||||
|
features.minAcceleration = min(accelerations);
|
||||||
|
features.accelerationVariance = variance(accelerations);
|
||||||
|
|
||||||
|
features.directionChanges = countDirectionChanges(xList);
|
||||||
|
features.yDirectionChanges = countDirectionChanges(yList);
|
||||||
|
|
||||||
|
features.pauses = countPauses(tList, 50);
|
||||||
|
|
||||||
|
features.startOffset = Math.sqrt(startX * startX + startY * startY);
|
||||||
|
|
||||||
|
features.straightness = calculateStraightness(xList, yList);
|
||||||
|
|
||||||
|
features.xUniformity = calculateUniformity(xList);
|
||||||
|
features.yUniformity = calculateUniformity(yList);
|
||||||
|
|
||||||
|
features.avgPointInterval = tList.size() > 1
|
||||||
|
? (tList.get(tList.size() - 1) - tList.get(0)) / (float)(tList.size() - 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
features.speedPhaseCorrelation = calculateSpeedPhaseCorrelation(speeds);
|
||||||
|
|
||||||
|
features.maxJumpDistance = calculateMaxJump(xList, yList);
|
||||||
|
|
||||||
|
features.overshootRatio = calculateOvershoot(xList, startX, endX);
|
||||||
|
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float average(List<Float> values) {
|
||||||
|
if (values.isEmpty()) return 0;
|
||||||
|
float sum = 0;
|
||||||
|
for (float v : values) sum += v;
|
||||||
|
return sum / values.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private float max(List<Float> values) {
|
||||||
|
if (values.isEmpty()) return 0;
|
||||||
|
float m = Float.MIN_VALUE;
|
||||||
|
for (float v : values) if (v > m) m = v;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float min(List<Float> values) {
|
||||||
|
if (values.isEmpty()) return 0;
|
||||||
|
float m = Float.MAX_VALUE;
|
||||||
|
for (float v : values) if (v < m) m = v;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double variance(List<Float> values) {
|
||||||
|
if (values.size() < 2) return 0;
|
||||||
|
float avg = average(values);
|
||||||
|
double sum = 0;
|
||||||
|
for (float v : values) sum += (v - avg) * (v - avg);
|
||||||
|
return sum / (values.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double stdDev(List<Float> values) {
|
||||||
|
return Math.sqrt(variance(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
private double skewness(List<Float> values) {
|
||||||
|
if (values.size() < 3) return 0;
|
||||||
|
float avg = average(values);
|
||||||
|
double sd = stdDev(values);
|
||||||
|
if (sd == 0) return 0;
|
||||||
|
double sum = 0;
|
||||||
|
for (float v : values) {
|
||||||
|
double norm = (v - avg) / sd;
|
||||||
|
sum += norm * norm * norm;
|
||||||
|
}
|
||||||
|
return sum / values.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countDirectionChanges(List<Float> values) {
|
||||||
|
int changes = 0;
|
||||||
|
for (int i = 2; i < values.size(); i++) {
|
||||||
|
float d1 = values.get(i - 1) - values.get(i - 2);
|
||||||
|
float d2 = values.get(i) - values.get(i - 1);
|
||||||
|
if (d1 * d2 < 0) changes++;
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countPauses(List<Float> tList, long thresholdMs) {
|
||||||
|
int pauses = 0;
|
||||||
|
for (int i = 1; i < tList.size(); i++) {
|
||||||
|
if (tList.get(i) - tList.get(i - 1) > thresholdMs) pauses++;
|
||||||
|
}
|
||||||
|
return pauses;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double calculateStraightness(List<Float> xList, List<Float> yList) {
|
||||||
|
if (xList.size() < 3) return 1.0;
|
||||||
|
float startX = xList.get(0);
|
||||||
|
float startY = yList.get(0);
|
||||||
|
float endX = xList.get(xList.size() - 1);
|
||||||
|
float endY = yList.get(yList.size() - 1);
|
||||||
|
double lineLength = Math.sqrt((endX - startX) * (endX - startX) + (endY - startY) * (endY - startY));
|
||||||
|
if (lineLength == 0) return 1.0;
|
||||||
|
double totalDeviation = 0;
|
||||||
|
for (int i = 1; i < xList.size() - 1; i++) {
|
||||||
|
double d = pointToLineDistance(xList.get(i), yList.get(i), startX, startY, endX, endY);
|
||||||
|
totalDeviation += d;
|
||||||
|
}
|
||||||
|
return totalDeviation / (xList.size() - 2) / lineLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double pointToLineDistance(float px, float py, float x1, float y1, float x2, float y2) {
|
||||||
|
double A = py - y1;
|
||||||
|
double B = x1 - x2;
|
||||||
|
double C = x2 * y1 - x1 * y2;
|
||||||
|
double denom = Math.sqrt(A * A + B * B);
|
||||||
|
if (denom == 0) return 0;
|
||||||
|
return Math.abs(A * px + B * py + C) / denom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double calculateUniformity(List<Float> values) {
|
||||||
|
if (values.size() < 3) return 0;
|
||||||
|
List<Float> diffs = new ArrayList<>(values.size() - 1);
|
||||||
|
for (int i = 1; i < values.size(); i++) {
|
||||||
|
diffs.add(values.get(i) - values.get(i - 1));
|
||||||
|
}
|
||||||
|
return stdDev(diffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double calculateSpeedPhaseCorrelation(List<Float> speeds) {
|
||||||
|
if (speeds.size() < 4) return 0;
|
||||||
|
int mid = speeds.size() / 2;
|
||||||
|
float avgFirst = average(speeds.subList(0, mid));
|
||||||
|
float avgSecond = average(speeds.subList(mid, speeds.size()));
|
||||||
|
float overallAvg = average(speeds);
|
||||||
|
if (overallAvg == 0) return 0;
|
||||||
|
return (avgFirst - avgSecond) / overallAvg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double calculateMaxJump(List<Float> xList, List<Float> yList) {
|
||||||
|
double maxJump = 0;
|
||||||
|
for (int i = 1; i < xList.size(); i++) {
|
||||||
|
double dx = xList.get(i) - xList.get(i - 1);
|
||||||
|
double dy = yList.get(i) - yList.get(i - 1);
|
||||||
|
double jump = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (jump > maxJump) maxJump = jump;
|
||||||
|
}
|
||||||
|
return maxJump;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double calculateOvershoot(List<Float> xList, float startX, float endX) {
|
||||||
|
if (xList.isEmpty()) return 0;
|
||||||
|
float target = endX;
|
||||||
|
float maxOvershoot = 0;
|
||||||
|
boolean passedTarget = false;
|
||||||
|
for (Float x : xList) {
|
||||||
|
if (!passedTarget && Math.abs(x - target) < 5) {
|
||||||
|
passedTarget = true;
|
||||||
|
}
|
||||||
|
if (passedTarget) {
|
||||||
|
float overshoot = Math.abs(x - target);
|
||||||
|
if (overshoot > maxOvershoot) maxOvershoot = overshoot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float totalDisplacement = Math.abs(endX - startX);
|
||||||
|
return totalDisplacement > 0 ? maxOvershoot / totalDisplacement : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package cloud.tianai.captcha.ml;
|
||||||
|
|
||||||
|
public class TrackFeatures {
|
||||||
|
|
||||||
|
public int totalPoints;
|
||||||
|
public long totalDuration;
|
||||||
|
public float startX;
|
||||||
|
public float startY;
|
||||||
|
public float endX;
|
||||||
|
public float endY;
|
||||||
|
public float displacementX;
|
||||||
|
public float displacementY;
|
||||||
|
public float displacementXRatio;
|
||||||
|
public double totalPathLength;
|
||||||
|
public double pathEfficiency;
|
||||||
|
public float avgSpeed;
|
||||||
|
public float maxSpeed;
|
||||||
|
public float minSpeed;
|
||||||
|
public double speedVariance;
|
||||||
|
public double speedStdDev;
|
||||||
|
public double speedSkewness;
|
||||||
|
public float avgAcceleration;
|
||||||
|
public float maxAcceleration;
|
||||||
|
public float minAcceleration;
|
||||||
|
public double accelerationVariance;
|
||||||
|
public int directionChanges;
|
||||||
|
public int yDirectionChanges;
|
||||||
|
public int pauses;
|
||||||
|
public double startOffset;
|
||||||
|
public double straightness;
|
||||||
|
public double xUniformity;
|
||||||
|
public double yUniformity;
|
||||||
|
public float avgPointInterval;
|
||||||
|
public double speedPhaseCorrelation;
|
||||||
|
public double maxJumpDistance;
|
||||||
|
public double overshootRatio;
|
||||||
|
|
||||||
|
public static TrackFeatures empty() {
|
||||||
|
return new TrackFeatures();
|
||||||
|
}
|
||||||
|
|
||||||
|
public double[] toArray() {
|
||||||
|
return new double[]{
|
||||||
|
totalPoints,
|
||||||
|
totalDuration,
|
||||||
|
displacementX,
|
||||||
|
displacementY,
|
||||||
|
displacementXRatio,
|
||||||
|
totalPathLength,
|
||||||
|
pathEfficiency,
|
||||||
|
avgSpeed,
|
||||||
|
maxSpeed,
|
||||||
|
minSpeed,
|
||||||
|
speedVariance,
|
||||||
|
speedStdDev,
|
||||||
|
speedSkewness,
|
||||||
|
avgAcceleration,
|
||||||
|
maxAcceleration,
|
||||||
|
minAcceleration,
|
||||||
|
accelerationVariance,
|
||||||
|
directionChanges,
|
||||||
|
yDirectionChanges,
|
||||||
|
pauses,
|
||||||
|
startOffset,
|
||||||
|
straightness,
|
||||||
|
xUniformity,
|
||||||
|
yUniformity,
|
||||||
|
avgPointInterval,
|
||||||
|
speedPhaseCorrelation,
|
||||||
|
maxJumpDistance,
|
||||||
|
overshootRatio
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package cloud.tianai.captcha.ml;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TrackRuleEngine {
|
||||||
|
|
||||||
|
private final List<Rule> rules = new ArrayList<>();
|
||||||
|
private final TrackFeatureExtractor featureExtractor = new TrackFeatureExtractor();
|
||||||
|
|
||||||
|
public TrackRuleEngine() {
|
||||||
|
addDefaultRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Rule> getRules() {
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackRuleEngine addRule(Rule rule) {
|
||||||
|
rules.add(rule);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackRuleEngine removeRule(String name) {
|
||||||
|
rules.removeIf(r -> r.name.equals(name));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackVerdict evaluate(ImageCaptchaTrack track) {
|
||||||
|
TrackFeatures features = featureExtractor.extract(track);
|
||||||
|
return evaluate(features);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackVerdict evaluate(TrackFeatures features) {
|
||||||
|
double totalScore = 0;
|
||||||
|
double totalWeight = 0;
|
||||||
|
List<RuleResult> results = new ArrayList<>(rules.size());
|
||||||
|
|
||||||
|
for (Rule rule : rules) {
|
||||||
|
RuleResult result = rule.evaluate(features);
|
||||||
|
results.add(result);
|
||||||
|
totalScore += result.score * rule.weight;
|
||||||
|
totalWeight += rule.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
double finalScore = totalWeight > 0 ? totalScore / totalWeight : 0;
|
||||||
|
boolean isHuman = finalScore >= 0.5;
|
||||||
|
|
||||||
|
TrackVerdict verdict = new TrackVerdict();
|
||||||
|
verdict.score = finalScore;
|
||||||
|
verdict.isHuman = isHuman;
|
||||||
|
verdict.results = results;
|
||||||
|
verdict.features = features;
|
||||||
|
return verdict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addDefaultRules() {
|
||||||
|
rules.add(new Rule("duration_check", 1.0, f -> {
|
||||||
|
if (f.totalDuration < 300) return new RuleResult(0, "Too fast: " + f.totalDuration + "ms");
|
||||||
|
if (f.totalDuration < 500) return new RuleResult(0.3, "Suspiciously fast: " + f.totalDuration + "ms");
|
||||||
|
if (f.totalDuration > 30000) return new RuleResult(0.2, "Suspiciously slow: " + f.totalDuration + "ms");
|
||||||
|
return new RuleResult(1, "Duration OK: " + f.totalDuration + "ms");
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("point_count_check", 0.8, f -> {
|
||||||
|
if (f.totalPoints < 10) return new RuleResult(0, "Too few points: " + f.totalPoints);
|
||||||
|
if (f.totalPoints > 2000) return new RuleResult(0.1, "Too many points: " + f.totalPoints);
|
||||||
|
return new RuleResult(1, "Point count OK: " + f.totalPoints);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("start_offset_check", 0.7, f -> {
|
||||||
|
if (f.startOffset > 20) return new RuleResult(0.1, "Start offset too large: " + f.startOffset);
|
||||||
|
return new RuleResult(1, "Start offset OK: " + f.startOffset);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("speed_variance_check", 1.2, f -> {
|
||||||
|
if (f.speedVariance < 0.0001) return new RuleResult(0, "Speed too uniform (bot-like)");
|
||||||
|
if (f.speedVariance < 0.001) return new RuleResult(0.3, "Speed variance very low");
|
||||||
|
return new RuleResult(1, "Speed variance OK: " + f.speedVariance);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("straightness_check", 1.0, f -> {
|
||||||
|
if (f.straightness < 0.01) return new RuleResult(1, "Good curvature: " + f.straightness);
|
||||||
|
if (f.straightness < 0.05) return new RuleResult(0.7, "Moderate curvature: " + f.straightness);
|
||||||
|
return new RuleResult(0.2, "Too straight (bot-like): " + f.straightness);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("y_direction_check", 0.6, f -> {
|
||||||
|
if (f.yDirectionChanges < 2) return new RuleResult(0.2, "Y too stable (bot-like)");
|
||||||
|
if (f.yDirectionChanges > 50) return new RuleResult(0.3, "Y too erratic");
|
||||||
|
return new RuleResult(1, "Y direction changes OK: " + f.yDirectionChanges);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("speed_phase_check", 1.0, f -> {
|
||||||
|
if (f.speedPhaseCorrelation > 0.8) return new RuleResult(0.1, "Speed too uniform across phases");
|
||||||
|
if (f.speedPhaseCorrelation > 0.5) return new RuleResult(0.4, "Speed somewhat uniform");
|
||||||
|
return new RuleResult(1, "Speed phase variation OK: " + f.speedPhaseCorrelation);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("max_jump_check", 0.9, f -> {
|
||||||
|
if (f.maxJumpDistance > 50) return new RuleResult(0, "Jump too large: " + f.maxJumpDistance);
|
||||||
|
if (f.maxJumpDistance > 30) return new RuleResult(0.4, "Suspicious jump: " + f.maxJumpDistance);
|
||||||
|
return new RuleResult(1, "Jump distance OK: " + f.maxJumpDistance);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("pause_check", 0.5, f -> {
|
||||||
|
if (f.pauses > 5) return new RuleResult(0.3, "Too many pauses: " + f.pauses);
|
||||||
|
if (f.pauses >= 1) return new RuleResult(1, "Natural pauses: " + f.pauses);
|
||||||
|
return new RuleResult(0.6, "No pauses (could be bot)");
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("overshoot_check", 0.8, f -> {
|
||||||
|
if (f.overshootRatio > 0.3) return new RuleResult(0.2, "Large overshoot: " + f.overshootRatio);
|
||||||
|
if (f.overshootRatio > 0.05) return new RuleResult(1, "Natural overshoot: " + f.overshootRatio);
|
||||||
|
return new RuleResult(0.7, "No overshoot (could be bot)");
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("acceleration_check", 0.7, f -> {
|
||||||
|
if (f.accelerationVariance < 0.0001) return new RuleResult(0.1, "Acceleration too uniform");
|
||||||
|
return new RuleResult(1, "Acceleration variance OK: " + f.accelerationVariance);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("x_uniformity_check", 0.8, f -> {
|
||||||
|
if (f.xUniformity < 0.5) return new RuleResult(0.1, "X movement too uniform (bot-like)");
|
||||||
|
return new RuleResult(1, "X uniformity OK: " + f.xUniformity);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("speed_skewness_check", 0.6, f -> {
|
||||||
|
if (Math.abs(f.speedSkewness) < 0.1) return new RuleResult(0.3, "Speed distribution too symmetric");
|
||||||
|
return new RuleResult(1, "Speed skewness OK: " + f.speedSkewness);
|
||||||
|
}));
|
||||||
|
|
||||||
|
rules.add(new Rule("path_efficiency_check", 0.5, f -> {
|
||||||
|
if (f.pathEfficiency > 0.98) return new RuleResult(0.2, "Path too efficient (straight line)");
|
||||||
|
if (f.pathEfficiency < 0.3) return new RuleResult(0.3, "Path too inefficient (erratic)");
|
||||||
|
return new RuleResult(1, "Path efficiency OK: " + f.pathEfficiency);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Rule {
|
||||||
|
public final String name;
|
||||||
|
public final double weight;
|
||||||
|
public final java.util.function.Function<TrackFeatures, RuleResult> evaluator;
|
||||||
|
|
||||||
|
public Rule(String name, double weight, java.util.function.Function<TrackFeatures, RuleResult> evaluator) {
|
||||||
|
this.name = name;
|
||||||
|
this.weight = weight;
|
||||||
|
this.evaluator = evaluator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuleResult evaluate(TrackFeatures features) {
|
||||||
|
return evaluator.apply(features);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RuleResult {
|
||||||
|
public final double score;
|
||||||
|
public final String reason;
|
||||||
|
|
||||||
|
public RuleResult(double score, String reason) {
|
||||||
|
this.score = score;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TrackVerdict {
|
||||||
|
public double score;
|
||||||
|
public boolean isHuman;
|
||||||
|
public List<RuleResult> results;
|
||||||
|
public TrackFeatures features;
|
||||||
|
|
||||||
|
public boolean isBot() {
|
||||||
|
return !isHuman;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSummary() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Verdict: ").append(isHuman ? "HUMAN" : "BOT").append(" (score=").append(String.format("%.3f", score)).append(")\n");
|
||||||
|
for (int i = 0; i < results.size(); i++) {
|
||||||
|
sb.append(" Rule ").append(i + 1).append(": ").append(results.get(i).reason).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package cloud.tianai.captcha.obfuscator;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
public class BackgroundShuffler implements ImageObfuscator {
|
||||||
|
|
||||||
|
private final Random random = new Random();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) {
|
||||||
|
if (!config.isBackgroundShuffleEnabled()) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
int rows = config.getBackgroundShuffleRows();
|
||||||
|
int cols = config.getBackgroundShuffleCols();
|
||||||
|
int width = image.getWidth();
|
||||||
|
int height = image.getHeight();
|
||||||
|
int tileWidth = width / cols;
|
||||||
|
int tileHeight = height / rows;
|
||||||
|
|
||||||
|
List<Integer> indices = new ArrayList<>(rows * cols);
|
||||||
|
for (int i = 0; i < rows * cols; i++) {
|
||||||
|
indices.add(i);
|
||||||
|
}
|
||||||
|
Collections.shuffle(indices, random);
|
||||||
|
|
||||||
|
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g2d = result.createGraphics();
|
||||||
|
|
||||||
|
for (int idx = 0; idx < indices.size(); idx++) {
|
||||||
|
int srcRow = idx / cols;
|
||||||
|
int srcCol = idx % cols;
|
||||||
|
int dstRow = indices.get(idx) / cols;
|
||||||
|
int dstCol = indices.get(idx) % cols;
|
||||||
|
|
||||||
|
int srcX = srcCol * tileWidth;
|
||||||
|
int srcY = srcRow * tileHeight;
|
||||||
|
int dstX = dstCol * tileWidth;
|
||||||
|
int dstY = dstRow * tileHeight;
|
||||||
|
|
||||||
|
BufferedImage tile = image.getSubimage(
|
||||||
|
Math.min(srcX, width - tileWidth),
|
||||||
|
Math.min(srcY, height - tileHeight),
|
||||||
|
tileWidth,
|
||||||
|
tileHeight
|
||||||
|
);
|
||||||
|
g2d.drawImage(tile, dstX, dstY, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
g2d.dispose();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "background_shuffle";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ShuffleMetadata {
|
||||||
|
public List<Integer> originalIndices;
|
||||||
|
public int rows;
|
||||||
|
public int cols;
|
||||||
|
|
||||||
|
public List<Integer> getRestoreOrder() {
|
||||||
|
if (originalIndices == null) return Collections.emptyList();
|
||||||
|
Integer[] restore = new Integer[originalIndices.size()];
|
||||||
|
for (int i = 0; i < originalIndices.size(); i++) {
|
||||||
|
restore[originalIndices.get(i)] = i;
|
||||||
|
}
|
||||||
|
List<Integer> result = new ArrayList<>(originalIndices.size());
|
||||||
|
Collections.addAll(result, restore);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package cloud.tianai.captcha.obfuscator;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class CompositeObfuscator implements ImageObfuscator {
|
||||||
|
|
||||||
|
private final List<ImageObfuscator> obfuscators = new ArrayList<>();
|
||||||
|
|
||||||
|
public CompositeObfuscator() {
|
||||||
|
obfuscators.add(new BackgroundShuffler());
|
||||||
|
obfuscators.add(new SinDistorter());
|
||||||
|
obfuscators.add(new NoiseInjector());
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompositeObfuscator addObfuscator(ImageObfuscator obfuscator) {
|
||||||
|
obfuscators.add(obfuscator);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompositeObfuscator removeObfuscator(String name) {
|
||||||
|
obfuscators.removeIf(o -> o.getName().equals(name));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) {
|
||||||
|
BufferedImage result = image;
|
||||||
|
for (ImageObfuscator obfuscator : obfuscators) {
|
||||||
|
result = obfuscator.obfuscate(result, config);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "composite";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package cloud.tianai.captcha.obfuscator;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
public interface ImageObfuscator {
|
||||||
|
|
||||||
|
BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config);
|
||||||
|
|
||||||
|
String getName();
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package cloud.tianai.captcha.obfuscator;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
public class NoiseInjector implements ImageObfuscator {
|
||||||
|
|
||||||
|
private final Random random = new Random();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) {
|
||||||
|
BufferedImage result = copyImage(image);
|
||||||
|
Graphics2D g2d = result.createGraphics();
|
||||||
|
int width = result.getWidth();
|
||||||
|
int height = result.getHeight();
|
||||||
|
|
||||||
|
if (config.isNoiseEnabled()) {
|
||||||
|
injectPointNoise(g2d, width, height, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.isLineNoiseEnabled()) {
|
||||||
|
injectLineNoise(g2d, width, height, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.isColorShiftEnabled()) {
|
||||||
|
g2d.dispose();
|
||||||
|
result = applyColorShift(result, config);
|
||||||
|
g2d.dispose();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
g2d.dispose();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void injectPointNoise(Graphics2D g2d, int width, int height, ObfuscatorConfig config) {
|
||||||
|
int count = config.getNoisePointCount();
|
||||||
|
float alpha = config.getNoiseAlpha();
|
||||||
|
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha));
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int x = random.nextInt(width);
|
||||||
|
int y = random.nextInt(height);
|
||||||
|
int rgb = random.nextInt(0xFFFFFF);
|
||||||
|
g2d.setColor(new Color(rgb));
|
||||||
|
int size = 1 + random.nextInt(3);
|
||||||
|
g2d.fillOval(x, y, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void injectLineNoise(Graphics2D g2d, int width, int height, ObfuscatorConfig config) {
|
||||||
|
int count = config.getLineNoiseCount();
|
||||||
|
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f));
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int x1 = random.nextInt(width);
|
||||||
|
int y1 = random.nextInt(height);
|
||||||
|
int x2 = random.nextInt(width);
|
||||||
|
int y2 = random.nextInt(height);
|
||||||
|
int rgb = random.nextInt(0xFFFFFF);
|
||||||
|
g2d.setColor(new Color(rgb));
|
||||||
|
g2d.setStroke(new BasicStroke(1 + random.nextFloat()));
|
||||||
|
g2d.drawLine(x1, y1, x2, y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage applyColorShift(BufferedImage image, ObfuscatorConfig config) {
|
||||||
|
int width = image.getWidth();
|
||||||
|
int height = image.getHeight();
|
||||||
|
int range = config.getColorShiftRange();
|
||||||
|
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
int argb = image.getRGB(x, y);
|
||||||
|
int a = (argb >> 24) & 0xFF;
|
||||||
|
int r = Math.max(0, Math.min(255, ((argb >> 16) & 0xFF) + random.nextInt(range * 2 + 1) - range));
|
||||||
|
int g = Math.max(0, Math.min(255, ((argb >> 8) & 0xFF) + random.nextInt(range * 2 + 1) - range));
|
||||||
|
int b = Math.max(0, Math.min(255, (argb & 0xFF) + random.nextInt(range * 2 + 1) - range));
|
||||||
|
result.setRGB(x, y, (a << 24) | (r << 16) | (g << 8) | b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage copyImage(BufferedImage source) {
|
||||||
|
BufferedImage copy = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g = copy.createGraphics();
|
||||||
|
g.drawImage(source, 0, 0, null);
|
||||||
|
g.dispose();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "noise_inject";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package cloud.tianai.captcha.obfuscator;
|
||||||
|
|
||||||
|
public class ObfuscatorConfig {
|
||||||
|
|
||||||
|
private boolean backgroundShuffleEnabled = false;
|
||||||
|
private int backgroundShuffleRows = 3;
|
||||||
|
private int backgroundShuffleCols = 4;
|
||||||
|
|
||||||
|
private boolean sinDistortEnabled = false;
|
||||||
|
private double sinAmplitude = 3.0;
|
||||||
|
private double sinFrequency = 0.05;
|
||||||
|
|
||||||
|
private boolean noiseEnabled = false;
|
||||||
|
private int noisePointCount = 50;
|
||||||
|
private float noiseAlpha = 0.3f;
|
||||||
|
|
||||||
|
private boolean lineNoiseEnabled = false;
|
||||||
|
private int lineNoiseCount = 3;
|
||||||
|
|
||||||
|
private boolean colorShiftEnabled = false;
|
||||||
|
private int colorShiftRange = 10;
|
||||||
|
|
||||||
|
private boolean blurEnabled = false;
|
||||||
|
private float blurRadius = 1.0f;
|
||||||
|
|
||||||
|
public static ObfuscatorConfig defaultConfig() {
|
||||||
|
ObfuscatorConfig config = new ObfuscatorConfig();
|
||||||
|
config.setBackgroundShuffleEnabled(true);
|
||||||
|
config.setSinDistortEnabled(true);
|
||||||
|
config.setNoiseEnabled(true);
|
||||||
|
config.setLineNoiseEnabled(true);
|
||||||
|
config.setColorShiftEnabled(true);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ObfuscatorConfig none() {
|
||||||
|
return new ObfuscatorConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ObfuscatorConfig high() {
|
||||||
|
ObfuscatorConfig config = defaultConfig();
|
||||||
|
config.setBackgroundShuffleRows(4);
|
||||||
|
config.setBackgroundShuffleCols(6);
|
||||||
|
config.setSinAmplitude(5.0);
|
||||||
|
config.setSinFrequency(0.08);
|
||||||
|
config.setNoisePointCount(100);
|
||||||
|
config.setLineNoiseCount(5);
|
||||||
|
config.setColorShiftRange(20);
|
||||||
|
config.setBlurEnabled(true);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBackgroundShuffleEnabled() { return backgroundShuffleEnabled; }
|
||||||
|
public void setBackgroundShuffleEnabled(boolean v) { this.backgroundShuffleEnabled = v; }
|
||||||
|
public int getBackgroundShuffleRows() { return backgroundShuffleRows; }
|
||||||
|
public void setBackgroundShuffleRows(int v) { this.backgroundShuffleRows = v; }
|
||||||
|
public int getBackgroundShuffleCols() { return backgroundShuffleCols; }
|
||||||
|
public void setBackgroundShuffleCols(int v) { this.backgroundShuffleCols = v; }
|
||||||
|
public boolean isSinDistortEnabled() { return sinDistortEnabled; }
|
||||||
|
public void setSinDistortEnabled(boolean v) { this.sinDistortEnabled = v; }
|
||||||
|
public double getSinAmplitude() { return sinAmplitude; }
|
||||||
|
public void setSinAmplitude(double v) { this.sinAmplitude = v; }
|
||||||
|
public double getSinFrequency() { return sinFrequency; }
|
||||||
|
public void setSinFrequency(double v) { this.sinFrequency = v; }
|
||||||
|
public boolean isNoiseEnabled() { return noiseEnabled; }
|
||||||
|
public void setNoiseEnabled(boolean v) { this.noiseEnabled = v; }
|
||||||
|
public int getNoisePointCount() { return noisePointCount; }
|
||||||
|
public void setNoisePointCount(int v) { this.noisePointCount = v; }
|
||||||
|
public float getNoiseAlpha() { return noiseAlpha; }
|
||||||
|
public void setNoiseAlpha(float v) { this.noiseAlpha = v; }
|
||||||
|
public boolean isLineNoiseEnabled() { return lineNoiseEnabled; }
|
||||||
|
public void setLineNoiseEnabled(boolean v) { this.lineNoiseEnabled = v; }
|
||||||
|
public int getLineNoiseCount() { return lineNoiseCount; }
|
||||||
|
public void setLineNoiseCount(int v) { this.lineNoiseCount = v; }
|
||||||
|
public boolean isColorShiftEnabled() { return colorShiftEnabled; }
|
||||||
|
public void setColorShiftEnabled(boolean v) { this.colorShiftEnabled = v; }
|
||||||
|
public int getColorShiftRange() { return colorShiftRange; }
|
||||||
|
public void setColorShiftRange(int v) { this.colorShiftRange = v; }
|
||||||
|
public boolean isBlurEnabled() { return blurEnabled; }
|
||||||
|
public void setBlurEnabled(boolean v) { this.blurEnabled = v; }
|
||||||
|
public float getBlurRadius() { return blurRadius; }
|
||||||
|
public void setBlurRadius(float v) { this.blurRadius = v; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package cloud.tianai.captcha.obfuscator;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
public class SinDistorter implements ImageObfuscator {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) {
|
||||||
|
if (!config.isSinDistortEnabled()) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
int width = image.getWidth();
|
||||||
|
int height = image.getHeight();
|
||||||
|
double amplitude = config.getSinAmplitude();
|
||||||
|
double frequency = config.getSinFrequency();
|
||||||
|
|
||||||
|
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
double xOffset = amplitude * Math.sin(2 * Math.PI * frequency * y);
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
int srcX = (int) Math.round(x - xOffset);
|
||||||
|
if (srcX >= 0 && srcX < width) {
|
||||||
|
result.setRGB(x, y, image.getRGB(srcX, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "sin_distort";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package cloud.tianai.captcha.risk;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public class IpBlacklist {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, Long> blacklist = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<String, Integer> failCounts = new ConcurrentHashMap<>();
|
||||||
|
private int maxFailCount = 10;
|
||||||
|
private long banDurationMs = 3600000;
|
||||||
|
|
||||||
|
public boolean isBanned(String ip) {
|
||||||
|
Long banUntil = blacklist.get(ip);
|
||||||
|
if (banUntil == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (System.currentTimeMillis() > banUntil) {
|
||||||
|
blacklist.remove(ip);
|
||||||
|
failCounts.remove(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordFail(String ip) {
|
||||||
|
int count = failCounts.merge(ip, 1, Integer::sum);
|
||||||
|
if (count >= maxFailCount) {
|
||||||
|
ban(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ban(String ip) {
|
||||||
|
blacklist.put(ip, System.currentTimeMillis() + banDurationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ban(String ip, long durationMs) {
|
||||||
|
blacklist.put(ip, System.currentTimeMillis() + durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void unban(String ip) {
|
||||||
|
blacklist.remove(ip);
|
||||||
|
failCounts.remove(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordSuccess(String ip) {
|
||||||
|
failCounts.remove(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> getBannedIps() {
|
||||||
|
return blacklist.keySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getFailCount(String ip) {
|
||||||
|
return failCounts.getOrDefault(ip, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void configure(int maxFailCount, long banDurationMs) {
|
||||||
|
this.maxFailCount = maxFailCount;
|
||||||
|
this.banDurationMs = banDurationMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package cloud.tianai.captcha.risk;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
public class RateLimiter {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, SlidingWindow> windows = new ConcurrentHashMap<>();
|
||||||
|
private long windowSizeMs;
|
||||||
|
private int maxRequests;
|
||||||
|
private final CleanupThread cleanupThread;
|
||||||
|
|
||||||
|
public RateLimiter(long windowSizeMs, int maxRequests) {
|
||||||
|
this.windowSizeMs = windowSizeMs;
|
||||||
|
this.maxRequests = maxRequests;
|
||||||
|
this.cleanupThread = new CleanupThread();
|
||||||
|
this.cleanupThread.setDaemon(true);
|
||||||
|
this.cleanupThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean allow(String key) {
|
||||||
|
SlidingWindow window = windows.computeIfAbsent(key, k -> new SlidingWindow(windowSizeMs));
|
||||||
|
return window.increment() <= maxRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCurrentCount(String key) {
|
||||||
|
SlidingWindow window = windows.get(key);
|
||||||
|
return window != null ? (int) window.count.get() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset(String key) {
|
||||||
|
windows.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void configure(long windowSizeMs, int maxRequests) {
|
||||||
|
this.windowSizeMs = windowSizeMs;
|
||||||
|
this.maxRequests = maxRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SlidingWindow {
|
||||||
|
private final AtomicLong count = new AtomicLong(0);
|
||||||
|
private volatile long startTime;
|
||||||
|
|
||||||
|
SlidingWindow(long windowSizeMs) {
|
||||||
|
this.startTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
long increment() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - startTime > windowSizeMs) {
|
||||||
|
synchronized (this) {
|
||||||
|
if (now - startTime > windowSizeMs) {
|
||||||
|
startTime = now;
|
||||||
|
count.set(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count.incrementAndGet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CleanupThread extends Thread {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
while (!Thread.currentThread().isInterrupted()) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(60000);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
windows.entrySet().removeIf(entry ->
|
||||||
|
now - entry.getValue().startTime > windowSizeMs * 2
|
||||||
|
);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package cloud.tianai.captcha.risk;
|
||||||
|
|
||||||
|
public class RiskEngine {
|
||||||
|
|
||||||
|
private final RateLimiter rateLimiter;
|
||||||
|
private final IpBlacklist ipBlacklist;
|
||||||
|
private boolean rateLimitEnabled = true;
|
||||||
|
private boolean ipBlacklistEnabled = true;
|
||||||
|
|
||||||
|
public RiskEngine() {
|
||||||
|
this.rateLimiter = new RateLimiter(60000, 60);
|
||||||
|
this.ipBlacklist = new IpBlacklist();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RiskEngine(RateLimiter rateLimiter, IpBlacklist ipBlacklist) {
|
||||||
|
this.rateLimiter = rateLimiter;
|
||||||
|
this.ipBlacklist = ipBlacklist;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RiskResult check(String ip, String captchaId) {
|
||||||
|
RiskResult result = new RiskResult();
|
||||||
|
|
||||||
|
if (ipBlacklistEnabled && ipBlacklist.isBanned(ip)) {
|
||||||
|
result.allowed = false;
|
||||||
|
result.reason = "ip_banned";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimitEnabled && !rateLimiter.allow("ip:" + ip)) {
|
||||||
|
result.allowed = false;
|
||||||
|
result.reason = "rate_limit_ip";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimitEnabled && !rateLimiter.allow("id:" + captchaId)) {
|
||||||
|
result.allowed = false;
|
||||||
|
result.reason = "rate_limit_id";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.allowed = true;
|
||||||
|
result.reason = "ok";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordSuccess(String ip) {
|
||||||
|
if (ipBlacklistEnabled) {
|
||||||
|
ipBlacklist.recordSuccess(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordFail(String ip) {
|
||||||
|
if (ipBlacklistEnabled) {
|
||||||
|
ipBlacklist.recordFail(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RateLimiter getRateLimiter() { return rateLimiter; }
|
||||||
|
public IpBlacklist getIpBlacklist() { return ipBlacklist; }
|
||||||
|
|
||||||
|
public void setRateLimitEnabled(boolean v) { this.rateLimitEnabled = v; }
|
||||||
|
public void setIpBlacklistEnabled(boolean v) { this.ipBlacklistEnabled = v; }
|
||||||
|
|
||||||
|
public static class RiskResult {
|
||||||
|
public boolean allowed;
|
||||||
|
public String reason;
|
||||||
|
|
||||||
|
public boolean isAllowed() { return allowed; }
|
||||||
|
public String getReason() { return reason; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package cloud.tianai.captcha.site;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class SiteConfig {
|
||||||
|
|
||||||
|
private String siteId;
|
||||||
|
private String siteKey;
|
||||||
|
private String secretKey;
|
||||||
|
private String name;
|
||||||
|
private Set<String> domains;
|
||||||
|
private boolean enabled = true;
|
||||||
|
private Set<String> allowedTypes;
|
||||||
|
private String level = "normal";
|
||||||
|
private int maxQps = 100;
|
||||||
|
private long captchaExpireMs = 120000;
|
||||||
|
private float tolerant = 0.02f;
|
||||||
|
private boolean trackValidationEnabled = true;
|
||||||
|
private double trackHumanThreshold = 0.5;
|
||||||
|
private boolean obfuscationEnabled = true;
|
||||||
|
private boolean encryptionEnabled = true;
|
||||||
|
|
||||||
|
public String getSiteId() { return siteId; }
|
||||||
|
public void setSiteId(String v) { this.siteId = v; }
|
||||||
|
public String getSiteKey() { return siteKey; }
|
||||||
|
public void setSiteKey(String v) { this.siteKey = v; }
|
||||||
|
public String getSecretKey() { return secretKey; }
|
||||||
|
public void setSecretKey(String v) { this.secretKey = v; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String v) { this.name = v; }
|
||||||
|
public Set<String> getDomains() { return domains; }
|
||||||
|
public void setDomains(Set<String> v) { this.domains = v; }
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public void setEnabled(boolean v) { this.enabled = v; }
|
||||||
|
public Set<String> getAllowedTypes() { return allowedTypes; }
|
||||||
|
public void setAllowedTypes(Set<String> v) { this.allowedTypes = v; }
|
||||||
|
public String getLevel() { return level; }
|
||||||
|
public void setLevel(String v) { this.level = v; }
|
||||||
|
public int getMaxQps() { return maxQps; }
|
||||||
|
public void setMaxQps(int v) { this.maxQps = v; }
|
||||||
|
public long getCaptchaExpireMs() { return captchaExpireMs; }
|
||||||
|
public void setCaptchaExpireMs(long v) { this.captchaExpireMs = v; }
|
||||||
|
public float getTolerant() { return tolerant; }
|
||||||
|
public void setTolerant(float v) { this.tolerant = v; }
|
||||||
|
public boolean isTrackValidationEnabled() { 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 isObfuscationEnabled() { return obfuscationEnabled; }
|
||||||
|
public void setObfuscationEnabled(boolean v) { this.obfuscationEnabled = v; }
|
||||||
|
public boolean isEncryptionEnabled() { return encryptionEnabled; }
|
||||||
|
public void setEncryptionEnabled(boolean v) { this.encryptionEnabled = v; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package cloud.tianai.captcha.site;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public class SiteManager {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, SiteConfig> sitesBySiteKey = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<String, SiteConfig> sitesBySiteId = new ConcurrentHashMap<>();
|
||||||
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
public SiteConfig registerSite(String name, Set<String> domains, Set<String> allowedTypes) {
|
||||||
|
SiteConfig config = new SiteConfig();
|
||||||
|
config.setSiteId(generateId());
|
||||||
|
config.setSiteKey(generateKey());
|
||||||
|
config.setSecretKey(generateKey());
|
||||||
|
config.setName(name);
|
||||||
|
config.setDomains(domains);
|
||||||
|
config.setAllowedTypes(allowedTypes);
|
||||||
|
|
||||||
|
sitesBySiteKey.put(config.getSiteKey(), config);
|
||||||
|
sitesBySiteId.put(config.getSiteId(), config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SiteConfig getSiteBySiteKey(String siteKey) {
|
||||||
|
return sitesBySiteKey.get(siteKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SiteConfig getSiteBySiteId(String siteId) {
|
||||||
|
return sitesBySiteId.get(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean validateSiteKey(String siteKey, String domain) {
|
||||||
|
SiteConfig config = sitesBySiteKey.get(siteKey);
|
||||||
|
if (config == null || !config.isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (domain != null && config.getDomains() != null && !config.getDomains().isEmpty()) {
|
||||||
|
return config.getDomains().contains(domain) || config.getDomains().contains("*");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean validateSecretKey(String siteKey, String secretKey) {
|
||||||
|
SiteConfig config = sitesBySiteKey.get(siteKey);
|
||||||
|
return config != null && config.getSecretKey().equals(secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTypeAllowed(String siteKey, String type) {
|
||||||
|
SiteConfig config = sitesBySiteKey.get(siteKey);
|
||||||
|
if (config == null) return false;
|
||||||
|
if (config.getAllowedTypes() == null || config.getAllowedTypes().isEmpty()) return true;
|
||||||
|
return config.getAllowedTypes().contains(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeSite(String siteKey) {
|
||||||
|
SiteConfig config = sitesBySiteKey.remove(siteKey);
|
||||||
|
if (config != null) {
|
||||||
|
sitesBySiteId.remove(config.getSiteId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<SiteConfig> getAllSites() {
|
||||||
|
return Collections.unmodifiableCollection(sitesBySiteKey.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateId() {
|
||||||
|
byte[] bytes = new byte[16];
|
||||||
|
random.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateKey() {
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
random.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package cloud.tianai.captcha.site;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.cache.CacheStore;
|
||||||
|
import cloud.tianai.captcha.common.AnyMap;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
public class TokenService {
|
||||||
|
|
||||||
|
private final CacheStore cacheStore;
|
||||||
|
private final String tokenPrefix = "captcha:token:";
|
||||||
|
private long tokenExpireMs = 120000;
|
||||||
|
|
||||||
|
public TokenService(CacheStore cacheStore) {
|
||||||
|
this.cacheStore = cacheStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateToken(String siteKey, String captchaId) {
|
||||||
|
byte[] tokenBytes = new byte[32];
|
||||||
|
new java.security.SecureRandom().nextBytes(tokenBytes);
|
||||||
|
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
|
||||||
|
|
||||||
|
AnyMap data = new AnyMap();
|
||||||
|
data.put("siteKey", siteKey);
|
||||||
|
data.put("captchaId", captchaId);
|
||||||
|
data.put("createdAt", System.currentTimeMillis());
|
||||||
|
|
||||||
|
cacheStore.setCache(tokenPrefix + token, data, tokenExpireMs, TimeUnit.MILLISECONDS);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AnyMap consumeToken(String token) {
|
||||||
|
return cacheStore.getAndRemoveCache(tokenPrefix + token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean validateToken(String token) {
|
||||||
|
return cacheStore.getCache(tokenPrefix + token) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokenExpireMs(long tokenExpireMs) {
|
||||||
|
this.tokenExpireMs = tokenExpireMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package cloud.tianai.captcha.validator.impl;
|
||||||
|
|
||||||
|
import cloud.tianai.captcha.common.AnyMap;
|
||||||
|
import cloud.tianai.captcha.common.response.ApiResponse;
|
||||||
|
import cloud.tianai.captcha.common.response.ApiResponseStatusConstant;
|
||||||
|
import cloud.tianai.captcha.ml.TrackRuleEngine;
|
||||||
|
import cloud.tianai.captcha.ml.TrackRuleEngine.TrackVerdict;
|
||||||
|
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
|
||||||
|
|
||||||
|
public class EnhancedTrackValidator extends SimpleImageCaptchaValidator {
|
||||||
|
|
||||||
|
public static final int TRACK_CHECK_FAIL_CODE = 4002;
|
||||||
|
public static final int TRACK_EMPTY_CODE = 4003;
|
||||||
|
|
||||||
|
private TrackRuleEngine ruleEngine;
|
||||||
|
private double humanThreshold = 0.5;
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
public EnhancedTrackValidator() {
|
||||||
|
this.ruleEngine = new TrackRuleEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public EnhancedTrackValidator(float defaultTolerant) {
|
||||||
|
super(defaultTolerant);
|
||||||
|
this.ruleEngine = new TrackRuleEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ApiResponse<?> afterValid(Boolean basicValid, ImageCaptchaTrack imageCaptchaTrack,
|
||||||
|
AnyMap captchaValidData, Float tolerant, String type) {
|
||||||
|
if (!basicValid) {
|
||||||
|
return ApiResponse.ofMessage(ApiResponseStatusConstant.BASIC_CHECK_FAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return ApiResponse.ofSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageCaptchaTrack.getTrackList() == null || imageCaptchaTrack.getTrackList().isEmpty()) {
|
||||||
|
return ApiResponse.of(TRACK_EMPTY_CODE, "track_empty", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackVerdict verdict = ruleEngine.evaluate(imageCaptchaTrack);
|
||||||
|
|
||||||
|
if (verdict.isBot()) {
|
||||||
|
return ApiResponse.of(TRACK_CHECK_FAIL_CODE, "track_check_fail", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.ofSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRuleEngine(TrackRuleEngine ruleEngine) {
|
||||||
|
this.ruleEngine = ruleEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHumanThreshold(double humanThreshold) {
|
||||||
|
this.humanThreshold = humanThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackRuleEngine getRuleEngine() {
|
||||||
|
return ruleEngine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |