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

- 核心新增: ICON_CLICK(PNG图标方案,预渲染资源)/SCRATCH/JIGSAW/CURVE_SLIDER(V1-V3)/ANGLE/CURVE_DRAW/WORD_ORDER_CLICK/PROOF_OF_WORK 等生成器
- 图标点选: classpath PNG 加载替代运行时字体渲染(Linux容器无emoji字体),提示条图作为 templateImage 返回前端
- 新增模块: crypto(AES+RSA)/obfuscator(背景乱序/噪声/扭曲)/risk(风控/IP黑名单/限流)/site(站点管理)/ml(轨迹规则引擎)
- 平台后端: 站点管理/验证码API(generate/verify/secondary-verify)/统计/ML轨迹学习
- 前端SDK: TPCaptcha兼容,支持全部新型号渲染与交互
- 工具: tools/icon-render 图标预渲染工具
This commit is contained in:
abcv7
2026-08-26 09:58:15 +08:00
parent d56958727c
commit dbe9b58b0c
115 changed files with 10365 additions and 36 deletions
+6
View File
@@ -26,3 +26,9 @@ target
.flattened-pom.xml .flattened-pom.xml
**/.flattened-pom.xml **/.flattened-pom.xml
### 本地环境 ###
node_modules/
*.log
.omo/
.codegraph/
+102
View File
@@ -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
+1
View File
@@ -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}]
+12
View File
@@ -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>
+196
View File
@@ -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>
+50
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

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

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Some files were not shown because too many files have changed in this diff Show More