diff --git a/.gitignore b/.gitignore index 412056a..98daf10 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ node_modules/ *.log .omo/ .codegraph/ +tianai-captcha-platform-ui/dist/ diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000..cee7e83 --- /dev/null +++ b/start.ps1 @@ -0,0 +1,118 @@ +$ErrorActionPreference = "Stop" + +$JAVA_HOME = "C:\Users\USER879511\.jdks\ms-21.0.11" +$MVN = "D:\Middleware\environment\apache-maven-3.9.16\bin\mvn.cmd" +$PG_PSQL = "D:\Middleware\gostgresql\bin\psql.exe" +$PG_PASSWORD = "XGYnJPysCNJsLeea" +$PROJECT_DIR = "D:\tianai-captcha-enhanced" +$JAR_PATH = "$PROJECT_DIR\tianai-captcha-platform\target\tianai-captcha-platform-2.0.0-SNAPSHOT.jar" +$PID_FILE = "$PROJECT_DIR\captcha-platform.pid" +$LOG_FILE = "$PROJECT_DIR\captcha-platform.log" +$DB_NAME = "captcha_forge" + +function Ensure-Database { + Write-Host "[1/3] Checking database '$DB_NAME'..." -ForegroundColor Cyan + $env:PGPASSWORD = $PG_PASSWORD + $result = & $PG_PSQL -U postgres -h localhost -t -c "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" 2>&1 + if ($result -match "1 row" -or $result -match "1") { + Write-Host " Database '$DB_NAME' exists." -ForegroundColor Green + } else { + & $PG_PSQL -U postgres -h localhost -c "CREATE DATABASE $DB_NAME" 2>&1 + Write-Host " Database '$DB_NAME' created." -ForegroundColor Green + } +} + +function Build-Project { + Write-Host "[2/3] Building project..." -ForegroundColor Cyan + $env:JAVA_HOME = $JAVA_HOME + & $MVN -f "$PROJECT_DIR\pom.xml" package -DskipTests -q + if ($LASTEXITCODE -ne 0) { + Write-Host " BUILD FAILED!" -ForegroundColor Red + exit 1 + } + Write-Host " Build successful." -ForegroundColor Green +} + +function Start-Platform { + Write-Host "[3/3] Starting captcha platform..." -ForegroundColor Cyan + + if (Test-Path $PID_FILE) { + $oldPid = Get-Content $PID_FILE + $oldProc = Get-Process -Id $oldPid -ErrorAction SilentlyContinue + if ($oldProc) { + Write-Host " Stopping old process (PID $oldPid)..." -ForegroundColor Yellow + Stop-Process -Id $oldPid -Force + Start-Sleep -Seconds 2 + } + Remove-Item $PID_FILE -Force + } + + $env:JAVA_HOME = $JAVA_HOME + $env:PATH = "$JAVA_HOME\bin;$env:PATH" + + $proc = Start-Process -FilePath "$JAVA_HOME\bin\java.exe" ` + -ArgumentList "-jar", $JAR_PATH, ` + "--spring.datasource.url=jdbc:postgresql://localhost:5432/$DB_NAME", ` + "--spring.datasource.password=$PG_PASSWORD", ` + "--spring.sql.init.mode=always" ` + -RedirectStandardOutput $LOG_FILE ` + -RedirectStandardError "$PROJECT_DIR\captcha-platform-err.log" ` + -NoNewWindow -PassThru + + $proc.Id | Set-Content $PID_FILE + Write-Host " Started! PID=$($proc.Id)" -ForegroundColor Green + Write-Host " Log: $LOG_FILE" -ForegroundColor Gray + Write-Host "" + Write-Host " Waiting for startup (max 60s)..." -ForegroundColor Yellow + + for ($i = 0; $i -lt 30; $i++) { + Start-Sleep -Seconds 2 + try { + $resp = Invoke-WebRequest -Uri "http://localhost:18109/api/admin/sites" -Method GET -ErrorAction Stop + Write-Host "" + Write-Host " Platform is UP!" -ForegroundColor Green + Write-Host " API: http://localhost:18109/api" -ForegroundColor Cyan + Write-Host " Site: http://localhost:18109/api/challenge/generate" -ForegroundColor Cyan + Write-Host "" + Write-Host " Existing sites in DB:" -ForegroundColor Yellow + $env:PGPASSWORD = $PG_PASSWORD + & $PG_PSQL -U postgres -h localhost -d $DB_NAME -c "SELECT id, name, domain, site_key, verify_level, is_enabled FROM sites" 2>&1 + return + } catch {} + Write-Host "." -NoNewline + } + Write-Host "" + Write-Host " Startup may still be in progress. Check: $LOG_FILE" -ForegroundColor Yellow +} + +function Stop-Platform { + Write-Host "Stopping captcha platform..." -ForegroundColor Yellow + if (Test-Path $PID_FILE) { + $pid = Get-Content $PID_FILE + $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue + if ($proc) { + Stop-Process -Id $pid -Force + Write-Host " Stopped (PID $pid)." -ForegroundColor Green + } else { + Write-Host " Process not running." -ForegroundColor Gray + } + Remove-Item $PID_FILE -Force + } else { + Write-Host " No PID file found." -ForegroundColor Gray + } +} + +Write-Host "" +Write-Host "============================================" -ForegroundColor Cyan +Write-Host " tianai-captcha-enhanced Launcher v2.0.0" -ForegroundColor Cyan +Write-Host " JDK 21 | Spring Boot 3.4 | PostgreSQL" -ForegroundColor Gray +Write-Host "============================================" -ForegroundColor Cyan +Write-Host "" + +if ($args[0] -eq "stop") { + Stop-Platform +} else { + Ensure-Database + Build-Project + Start-Platform +} diff --git a/tianai-captcha-platform-ui/Dockerfile b/tianai-captcha-platform-ui/Dockerfile new file mode 100644 index 0000000..ec6d032 --- /dev/null +++ b/tianai-captcha-platform-ui/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/tianai-captcha-platform-ui/logo/logo_v2.html b/tianai-captcha-platform-ui/logo/logo_v2.html index e6098ec..60638e6 100644 --- a/tianai-captcha-platform-ui/logo/logo_v2.html +++ b/tianai-captcha-platform-ui/logo/logo_v2.html @@ -40,6 +40,7 @@ transition: opacity 0.4s ease; } .ward-pulse { + opacity: 0; transform-origin: 50px 50px; animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite; } @@ -49,7 +50,7 @@ /* 移除了夸张的缩放和粗细变化,改为更清爽的光圈扩散 */ @keyframes pulse-ward { - 0% { transform: scale(0.6); opacity: 1; stroke-width: 6px; } + 0% { transform: scale(1.0); opacity: 1; stroke-width: 6px; } 100% { transform: scale(1.8); opacity: 0; stroke-width: 1px; } } diff --git a/tianai-captcha-platform-ui/nginx.conf b/tianai-captcha-platform-ui/nginx.conf new file mode 100644 index 0000000..bc1ff6e --- /dev/null +++ b/tianai-captcha-platform-ui/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://captcha-platform:18200/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/tianai-captcha-platform-ui/package-lock.json b/tianai-captcha-platform-ui/package-lock.json index 485d3bf..362f3bc 100644 --- a/tianai-captcha-platform-ui/package-lock.json +++ b/tianai-captcha-platform-ui/package-lock.json @@ -8,14 +8,16 @@ "name": "tianai-captcha-platform-ui", "version": "2.0.0", "dependencies": { - "axios": "^1.7.0", - "naive-ui": "^2.40.0", - "pinia": "^2.2.0", - "vue": "^3.5.0", - "vue-router": "^4.4.0" + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.9", + "echarts": "^5.5.1", + "element-plus": "^2.9.1", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" }, "devDependencies": { - "@vitejs/plugin-vue": "^5.2.0", + "@vitejs/plugin-vue": "^5.2.1", "vite": "^6.0.0" } }, @@ -65,30 +67,24 @@ "node": ">=6.9.0" } }, - "node_modules/@css-render/plugin-bem": { - "version": "0.15.14", - "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", - "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", "license": "MIT", - "peerDependencies": { - "css-render": "~0.15.14" + "engines": { + "node": ">=14" } }, - "node_modules/@css-render/vue3-ssr": { - "version": "0.15.14", - "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", - "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", "license": "MIT", "peerDependencies": { - "vue": "^3.0.11" + "vue": "^3.2.0" } }, - "node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -531,17 +527,47 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, - "node_modules/@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", - "license": "Apache-2.0" + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", @@ -954,6 +980,12 @@ "@types/lodash": "*" } }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -1074,6 +1106,44 @@ "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz", + "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.4.0", + "@vueuse/shared": "14.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz", + "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz", + "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", @@ -1135,46 +1205,17 @@ "node": ">= 0.8" } }, - "node_modules/css-render": { - "version": "0.15.14", - "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz", - "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "~0.8.0", - "csstype": "~3.0.5" - } - }, - "node_modules/css-render/node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/date-fns": { - "version": "4.4.0", - "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", - "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/date-fns-tz": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", - "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", - "license": "MIT", - "peerDependencies": { - "date-fns": "^3.0.0 || ^4.0.0" - } + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -1216,6 +1257,42 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.14.5", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.5.tgz", + "integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.4.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.9" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", @@ -1321,12 +1398,6 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, - "node_modules/evtd": { - "version": "0.2.4", - "resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz", - "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -1493,15 +1564,6 @@ "node": ">= 0.4" } }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1527,6 +1589,17 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -1545,6 +1618,12 @@ "node": ">= 0.4" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", @@ -1572,38 +1651,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/naive-ui": { - "version": "2.44.1", - "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.44.1.tgz", - "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", - "license": "MIT", - "dependencies": { - "@css-render/plugin-bem": "^0.15.14", - "@css-render/vue3-ssr": "^0.15.14", - "@types/lodash": "^4.17.20", - "@types/lodash-es": "^4.17.12", - "async-validator": "^4.2.5", - "css-render": "^0.15.14", - "csstype": "^3.1.3", - "date-fns": "^4.1.0", - "date-fns-tz": "^3.2.0", - "evtd": "^0.2.4", - "highlight.js": "^11.8.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "seemly": "^0.3.10", - "treemate": "^0.3.11", - "vdirs": "^0.1.8", - "vooks": "^0.2.12", - "vueuc": "^0.4.65" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", @@ -1622,6 +1669,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -1745,12 +1798,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/seemly": { - "version": "0.3.10", - "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz", - "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1777,23 +1824,11 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/treemate": { - "version": "0.3.11", - "resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", - "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", - "license": "MIT" - }, - "node_modules/vdirs": { - "version": "0.1.8", - "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", - "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", - "license": "MIT", - "dependencies": { - "evtd": "^0.2.2" - }, - "peerDependencies": { - "vue": "^3.0.11" - } + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" }, "node_modules/vite": { "version": "6.4.3", @@ -1870,18 +1905,6 @@ } } }, - "node_modules/vooks": { - "version": "0.2.12", - "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", - "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", - "license": "MIT", - "dependencies": { - "evtd": "^0.2.2" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, "node_modules/vue": { "version": "3.5.39", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz", @@ -1903,6 +1926,12 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==", + "license": "MIT" + }, "node_modules/vue-demi": { "version": "0.14.10", "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", @@ -1944,22 +1973,13 @@ "vue": "^3.5.0" } }, - "node_modules/vueuc": { - "version": "0.4.65", - "resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz", - "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", - "license": "MIT", + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", "dependencies": { - "@css-render/vue3-ssr": "^0.15.10", - "@juggle/resize-observer": "^3.3.1", - "css-render": "^0.15.10", - "evtd": "^0.2.4", - "seemly": "^0.3.6", - "vdirs": "^0.1.4", - "vooks": "^0.2.4" - }, - "peerDependencies": { - "vue": "^3.0.11" + "tslib": "2.3.0" } } } diff --git a/tianai-captcha-platform-ui/package.json b/tianai-captcha-platform-ui/package.json index 26d0f3a..c7822a3 100644 --- a/tianai-captcha-platform-ui/package.json +++ b/tianai-captcha-platform-ui/package.json @@ -9,14 +9,16 @@ "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" + "vue": "^3.5.13", + "vue-router": "^4.5.0", + "pinia": "^2.3.0", + "element-plus": "^2.9.1", + "axios": "^1.7.9", + "echarts": "^5.5.1", + "@element-plus/icons-vue": "^2.3.1" }, "devDependencies": { - "@vitejs/plugin-vue": "^5.2.0", + "@vitejs/plugin-vue": "^5.2.1", "vite": "^6.0.0" } } diff --git a/tianai-captcha-platform-ui/src/App.vue b/tianai-captcha-platform-ui/src/App.vue index 46c7558..e0ab813 100644 --- a/tianai-captcha-platform-ui/src/App.vue +++ b/tianai-captcha-platform-ui/src/App.vue @@ -1,45 +1,16 @@ - + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + diff --git a/tianai-captcha-platform-ui/src/api/index.js b/tianai-captcha-platform-ui/src/api/index.js new file mode 100644 index 0000000..e2a28d7 --- /dev/null +++ b/tianai-captcha-platform-ui/src/api/index.js @@ -0,0 +1,81 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' + +const api = axios.create({ + baseURL: '/api', + timeout: 10000 +}) + +// 请求拦截器 +api.interceptors.request.use( + config => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => Promise.reject(error) +) + +// 响应拦截器 +api.interceptors.response.use( + response => { + const data = response.data + if (data.code && data.code !== 200) { + ElMessage.error(data.msg || '请求失败') + return Promise.reject(data) + } + return data + }, + error => { + if (error.response?.status === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } + ElMessage.error(error.message || '网络错误') + return Promise.reject(error) + } +) + +// 认证 API +export const authApi = { + login: (data) => api.post('/auth/login', data), + getMe: () => api.get('/auth/me') +} + +// 站点 API +export const siteApi = { + list: (params) => api.get('/admin/sites', { params }), + get: (id) => api.get(`/admin/sites/${id}`), + create: (data) => api.post('/admin/sites', data), + update: (id, data) => api.put(`/admin/sites/${id}`, data), + delete: (id) => api.delete(`/admin/sites/${id}`) +} + +// 统计 API +export const statsApi = { + get: (params) => api.get('/admin/stats', { params }) +} + +// 日志 API +export const logApi = { + list: (params) => api.get('/admin/logs', { params }) +} + +// IP 黑名单 API +export const ipBlacklistApi = { + ban: (data) => api.post('/admin/ip-blacklist', data), + unban: (ip) => api.delete(`/admin/ip-blacklist/${ip}`) +} + +// 套餐 API +export const planApi = { + list: () => api.get('/admin/plans'), + get: (id) => api.get(`/admin/plans/${id}`), + create: (data) => api.post('/admin/plans', data), + update: (id, data) => api.put(`/admin/plans/${id}`, data), + delete: (id) => api.delete(`/admin/plans/${id}`) +} + +export default api diff --git a/tianai-captcha-platform-ui/src/layouts/MainLayout.vue b/tianai-captcha-platform-ui/src/layouts/MainLayout.vue new file mode 100644 index 0000000..58f31db --- /dev/null +++ b/tianai-captcha-platform-ui/src/layouts/MainLayout.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/tianai-captcha-platform-ui/src/main.js b/tianai-captcha-platform-ui/src/main.js index 963bf61..5e05e01 100644 --- a/tianai-captcha-platform-ui/src/main.js +++ b/tianai-captcha-platform-ui/src/main.js @@ -1,21 +1,19 @@ import { createApp } from 'vue' -import { createRouter, createWebHistory } from 'vue-router' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import { createPinia } from 'pinia' 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' +import router from './router' -const routes = [ - { path: '/', component: Dashboard }, - { path: '/sites', component: Sites }, - { path: '/stats', component: Stats }, - { path: '/test', component: CaptchaTest }, -] +const app = createApp(App) -const router = createRouter({ - history: createWebHistory(), - routes, -}) +// 注册所有 Element Plus 图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} -createApp(App).use(router).mount('#app') +app.use(ElementPlus) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/tianai-captcha-platform-ui/src/router/index.js b/tianai-captcha-platform-ui/src/router/index.js new file mode 100644 index 0000000..4cd2854 --- /dev/null +++ b/tianai-captcha-platform-ui/src/router/index.js @@ -0,0 +1,69 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('../views/login/Login.vue') + }, + { + path: '/', + component: () => import('../layouts/MainLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('../views/dashboard/Dashboard.vue'), + meta: { title: '首页大盘' } + }, + { + path: 'sites', + name: 'Sites', + component: () => import('../views/sites/Sites.vue'), + meta: { title: '站点管理' } + }, + { + path: 'logs', + name: 'Logs', + component: () => import('../views/logs/Logs.vue'), + meta: { title: '验证码日志' } + }, + { + path: 'ip-blacklist', + name: 'IpBlacklist', + component: () => import('../views/ip-blacklist/IpBlacklist.vue'), + meta: { title: 'IP黑名单' } + }, + { + path: 'plans', + name: 'Plans', + component: () => import('../views/plans/Plans.vue'), + meta: { title: '套餐管理' } + }, + { + path: 'settings', + name: 'Settings', + component: () => import('../views/settings/Settings.vue'), + meta: { title: '系统设置' } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const token = localStorage.getItem('token') + if (to.path !== '/login' && !token) { + next('/login') + } else { + next() + } +}) + +export default router diff --git a/tianai-captcha-platform-ui/src/store/user.js b/tianai-captcha-platform-ui/src/store/user.js new file mode 100644 index 0000000..da75162 --- /dev/null +++ b/tianai-captcha-platform-ui/src/store/user.js @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { authApi } from '../api' + +export const useUserStore = defineStore('user', () => { + const token = ref(localStorage.getItem('token') || '') + const username = ref(localStorage.getItem('username') || '') + const role = ref(localStorage.getItem('role') || '') + + const login = async (loginData) => { + const res = await authApi.login(loginData) + if (res.data) { + token.value = res.data.token + username.value = res.data.username + role.value = res.data.role + localStorage.setItem('token', res.data.token) + localStorage.setItem('username', res.data.username) + localStorage.setItem('role', res.data.role) + } + return res + } + + const logout = () => { + token.value = '' + username.value = '' + role.value = '' + localStorage.removeItem('token') + localStorage.removeItem('username') + localStorage.removeItem('role') + } + + const fetchUser = async () => { + try { + const res = await authApi.getMe() + if (res.data) { + username.value = res.data.username + role.value = res.data.role + } + } catch (e) { + logout() + } + } + + return { token, username, role, login, logout, fetchUser } +}) diff --git a/tianai-captcha-platform-ui/src/views/CaptchaTest.vue b/tianai-captcha-platform-ui/src/views/CaptchaTest.vue deleted file mode 100644 index 4fb559f..0000000 --- a/tianai-captcha-platform-ui/src/views/CaptchaTest.vue +++ /dev/null @@ -1,240 +0,0 @@ - - - diff --git a/tianai-captcha-platform-ui/src/views/Dashboard.vue b/tianai-captcha-platform-ui/src/views/Dashboard.vue deleted file mode 100644 index 2ce8be9..0000000 --- a/tianai-captcha-platform-ui/src/views/Dashboard.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/tianai-captcha-platform-ui/src/views/Sites.vue b/tianai-captcha-platform-ui/src/views/Sites.vue deleted file mode 100644 index 4f8b752..0000000 --- a/tianai-captcha-platform-ui/src/views/Sites.vue +++ /dev/null @@ -1,152 +0,0 @@ - - - diff --git a/tianai-captcha-platform-ui/src/views/Stats.vue b/tianai-captcha-platform-ui/src/views/Stats.vue deleted file mode 100644 index 75043c2..0000000 --- a/tianai-captcha-platform-ui/src/views/Stats.vue +++ /dev/null @@ -1,67 +0,0 @@ - - - diff --git a/tianai-captcha-platform-ui/src/views/dashboard/Dashboard.vue b/tianai-captcha-platform-ui/src/views/dashboard/Dashboard.vue new file mode 100644 index 0000000..e83986f --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/dashboard/Dashboard.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/tianai-captcha-platform-ui/src/views/ip-blacklist/IpBlacklist.vue b/tianai-captcha-platform-ui/src/views/ip-blacklist/IpBlacklist.vue new file mode 100644 index 0000000..66f6a18 --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/ip-blacklist/IpBlacklist.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/tianai-captcha-platform-ui/src/views/login/Login.vue b/tianai-captcha-platform-ui/src/views/login/Login.vue new file mode 100644 index 0000000..bd6b477 --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/login/Login.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/tianai-captcha-platform-ui/src/views/logs/Logs.vue b/tianai-captcha-platform-ui/src/views/logs/Logs.vue new file mode 100644 index 0000000..2e24fea --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/logs/Logs.vue @@ -0,0 +1,81 @@ + + + diff --git a/tianai-captcha-platform-ui/src/views/plans/Plans.vue b/tianai-captcha-platform-ui/src/views/plans/Plans.vue new file mode 100644 index 0000000..44d9ca7 --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/plans/Plans.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/tianai-captcha-platform-ui/src/views/settings/Settings.vue b/tianai-captcha-platform-ui/src/views/settings/Settings.vue new file mode 100644 index 0000000..beba34e --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/settings/Settings.vue @@ -0,0 +1,32 @@ + + + diff --git a/tianai-captcha-platform-ui/src/views/sites/Sites.vue b/tianai-captcha-platform-ui/src/views/sites/Sites.vue new file mode 100644 index 0000000..d8cb6e2 --- /dev/null +++ b/tianai-captcha-platform-ui/src/views/sites/Sites.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/tianai-captcha-platform/docker-compose.yml b/tianai-captcha-platform/docker-compose.yml new file mode 100644 index 0000000..ddfba3b --- /dev/null +++ b/tianai-captcha-platform/docker-compose.yml @@ -0,0 +1,96 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: captcha-postgres + environment: + POSTGRES_DB: captcha_forge + POSTGRES_USER: pgsql + POSTGRES_PASSWORD: ${PG_PASSWORD:-tianai-captcha-pg} + ports: + - "5432:5432" + volumes: + - pg-data:/var/lib/postgresql/data + - ./sql/init.sql:/docker-entrypoint-initdb.d/01-init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pgsql -d captcha_forge"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: captcha-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + container_name: captcha-minio + environment: + MINIO_ROOT_USER: ${MINIO_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + + captcha-platform: + build: + context: . + dockerfile: Dockerfile + container_name: captcha-platform + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/captcha_forge + SPRING_DATASOURCE_USERNAME: pgsql + SPRING_DATASOURCE_PASSWORD: ${PG_PASSWORD:-tianai-captcha-pg} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + MINIO_ENDPOINT: http://minio:9000 + MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin} + MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin} + JWT_SECRET: ${JWT_SECRET:-tianai-captcha-jwt-secret-key-must-be-at-least-256-bits-long-for-hs256} + ports: + - "18200:18200" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:18200/api/admin/stats || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + + captcha-admin: + build: + context: ../tianai-captcha-platform-ui + dockerfile: Dockerfile + container_name: captcha-admin + ports: + - "3000:80" + depends_on: + - captcha-platform + +volumes: + pg-data: + redis-data: + minio-data: diff --git a/tianai-captcha-platform/pom.xml b/tianai-captcha-platform/pom.xml index 4c5348e..8b665b1 100644 --- a/tianai-captcha-platform/pom.xml +++ b/tianai-captcha-platform/pom.xml @@ -51,6 +51,46 @@ com.google.code.gson gson + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + org.springframework.boot + spring-boot-starter-security + + + io.minio + minio + 8.5.7 + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + diff --git a/tianai-captcha-platform/sql/init.sql b/tianai-captcha-platform/sql/init.sql new file mode 100644 index 0000000..3241c51 --- /dev/null +++ b/tianai-captcha-platform/sql/init.sql @@ -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); diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/aspect/CaptchaLogAspect.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/aspect/CaptchaLogAspect.java new file mode 100644 index 0000000..3d42630 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/aspect/CaptchaLogAspect.java @@ -0,0 +1,100 @@ +package cloud.tianai.captcha.platform.aspect; + +import cloud.tianai.captcha.platform.entity.CaptchaLog; +import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository; +import cloud.tianai.captcha.platform.service.RealtimeStatsService; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.*; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Aspect +@Component +public class CaptchaLogAspect { + + private static final Logger log = LoggerFactory.getLogger(CaptchaLogAspect.class); + private final CaptchaLogRepository logRepository; + private final RealtimeStatsService statsService; + + public CaptchaLogAspect(CaptchaLogRepository logRepository, RealtimeStatsService statsService) { + this.logRepository = logRepository; + this.statsService = statsService; + } + + @Around("execution(* cloud.tianai.captcha.platform.controller.CaptchaApiController.*(..))") + public Object logCaptchaRequest(ProceedingJoinPoint joinPoint) throws Throwable { + long startTime = System.currentTimeMillis(); + Object result = joinPoint.proceed(); + long duration = System.currentTimeMillis() - startTime; + + try { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String methodName = signature.getName(); + + String ip = extractIp(); + String siteKey = extractSiteKey(joinPoint.getArgs()); + + Map stats = new ConcurrentHashMap<>(); + stats.put("method", methodName); + stats.put("duration", duration); + stats.put("ip", ip); + stats.put("siteKey", siteKey); + + if ("verify".equals(methodName) && result instanceof cloud.tianai.captcha.common.response.ApiResponse response) { + stats.put("success", response.isSuccess()); + stats.put("captchaType", "VERIFY"); + statsService.recordVerifyAttempt(response.isSuccess(), ip, siteKey); + } else if ("generate".equals(methodName)) { + stats.put("success", true); + stats.put("captchaType", "GENERATE"); + statsService.recordGenerate(ip, siteKey); + } + + statsService.incrementTotalRequests(); + + log.debug("[CAPTCHA-AOP] {} took {}ms ip={}", methodName, duration, ip); + } catch (Exception e) { + log.warn("[CAPTCHA-AOP] Failed to log stats: {}", e.getMessage()); + } + + return result; + } + + private String extractIp() { + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + HttpServletRequest request = attrs.getRequest(); + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isEmpty()) { + return xff.split(",")[0].trim(); + } + String xreal = request.getHeader("X-Real-IP"); + if (xreal != null && !xreal.isEmpty()) { + return xreal; + } + return request.getRemoteAddr(); + } + } catch (Exception e) { + // ignore + } + return "unknown"; + } + + private String extractSiteKey(Object[] args) { + for (Object arg : args) { + if (arg instanceof String s && s != null && s.length() > 10) { + return s; + } + } + return "unknown"; + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/JwtAuthenticationFilter.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..90b8fde --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/JwtAuthenticationFilter.java @@ -0,0 +1,52 @@ +package cloud.tianai.captcha.platform.config; + +import cloud.tianai.captcha.platform.service.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + try { + if (jwtService.validateToken(token)) { + String username = jwtService.getUsernameFromToken(token); + Integer userId = jwtService.getUserIdFromToken(token); + String role = jwtService.getRoleFromToken(token); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userId, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role)) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + SecurityContextHolder.clearContext(); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/SecurityConfig.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/SecurityConfig.java new file mode 100644 index 0000000..215539b --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/config/SecurityConfig.java @@ -0,0 +1,67 @@ +package cloud.tianai.captcha.platform.config; + +import cloud.tianai.captcha.platform.service.JwtService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtService jwtService; + + public SecurityConfig(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtService); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/captcha/**").permitAll() + .requestMatchers("/api/admin/**").authenticated() + .anyRequest().permitAll() + ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/AuthController.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/AuthController.java new file mode 100644 index 0000000..0c851ff --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/AuthController.java @@ -0,0 +1,101 @@ +package cloud.tianai.captcha.platform.controller; + +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.platform.entity.User; +import cloud.tianai.captcha.platform.mapper.UserRepository; +import cloud.tianai.captcha.platform.service.JwtService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final UserRepository userRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + + public AuthController(UserRepository userRepository, JwtService jwtService, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.jwtService = jwtService; + this.passwordEncoder = passwordEncoder; + } + + @PostMapping("/login") + public ApiResponse login(@RequestBody Map body) { + String username = body.get("username"); + String password = body.get("password"); + + if (username == null || password == null) { + return ApiResponse.of(400, "missing_params", null); + } + + User user = userRepository.findByUsername(username).orElse(null); + if (user == null || !passwordEncoder.matches(password, user.getPasswordHash())) { + return ApiResponse.of(401, "invalid_credentials", null); + } + + if (!user.getIsEnabled()) { + return ApiResponse.of(403, "account_disabled", null); + } + + String token = jwtService.generateToken(user.getId(), user.getUsername(), user.getRole()); + Map data = new HashMap<>(); + data.put("token", token); + data.put("username", user.getUsername()); + data.put("role", user.getRole()); + return ApiResponse.ofSuccess(data); + } + + @PostMapping("/register") + public ApiResponse register(@RequestBody Map body) { + String username = body.get("username"); + String password = body.get("password"); + String email = body.get("email"); + + if (username == null || password == null) { + return ApiResponse.of(400, "missing_params", null); + } + + if (userRepository.existsByUsername(username)) { + return ApiResponse.of(409, "username_exists", null); + } + + User user = new User(); + user.setUsername(username); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setEmail(email); + user.setRole("USER"); + userRepository.save(user); + + return ApiResponse.ofSuccess("Registration successful"); + } + + @GetMapping("/me") + public ApiResponse getCurrentUser(@RequestHeader("Authorization") String authHeader) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return ApiResponse.of(401, "unauthorized", null); + } + + String token = authHeader.substring(7); + if (!jwtService.validateToken(token)) { + return ApiResponse.of(401, "invalid_token", null); + } + + Integer userId = jwtService.getUserIdFromToken(token); + User user = userRepository.findById(userId).orElse(null); + if (user == null) { + return ApiResponse.of(404, "user_not_found", null); + } + + Map data = new HashMap<>(); + data.put("id", user.getId()); + data.put("username", user.getUsername()); + data.put("email", user.getEmail()); + data.put("role", user.getRole()); + return ApiResponse.ofSuccess(data); + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/PlanController.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/PlanController.java new file mode 100644 index 0000000..d52c02b --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/PlanController.java @@ -0,0 +1,60 @@ +package cloud.tianai.captcha.platform.controller; + +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.platform.entity.Plan; +import cloud.tianai.captcha.platform.mapper.PlanRepository; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/plans") +public class PlanController { + + private final PlanRepository planRepository; + + public PlanController(PlanRepository planRepository) { + this.planRepository = planRepository; + } + + @GetMapping + public ApiResponse> listPlans() { + return ApiResponse.ofSuccess(planRepository.findAll()); + } + + @GetMapping("/active") + public ApiResponse> listActivePlans() { + return ApiResponse.ofSuccess(planRepository.findByIsActiveTrue()); + } + + @GetMapping("/{id}") + public ApiResponse getPlan(@PathVariable Integer id) { + Plan plan = planRepository.findById(id).orElse(null); + if (plan == null) { + return ApiResponse.of(404, "plan_not_found", null); + } + return ApiResponse.ofSuccess(plan); + } + + @PostMapping + public ApiResponse createPlan(@RequestBody Plan plan) { + plan.setId(null); + return ApiResponse.ofSuccess(planRepository.save(plan)); + } + + @PutMapping("/{id}") + public ApiResponse updatePlan(@PathVariable Integer id, @RequestBody Plan plan) { + Plan existing = planRepository.findById(id).orElse(null); + if (existing == null) { + return ApiResponse.of(404, "plan_not_found", null); + } + plan.setId(id); + return ApiResponse.ofSuccess(planRepository.save(plan)); + } + + @DeleteMapping("/{id}") + public ApiResponse deletePlan(@PathVariable Integer id) { + planRepository.deleteById(id); + return ApiResponse.ofSuccess(); + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/ResourceController.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/ResourceController.java new file mode 100644 index 0000000..51f7407 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/controller/ResourceController.java @@ -0,0 +1,60 @@ +package cloud.tianai.captcha.platform.controller; + +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.platform.service.MinioStorageService; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/resources") +public class ResourceController { + + private final MinioStorageService storageService; + + public ResourceController(MinioStorageService storageService) { + this.storageService = storageService; + } + + @PostMapping("/upload") + public ApiResponse uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam(value = "prefix", defaultValue = "general") String prefix) { + try { + String url = storageService.uploadFile(file, prefix); + Map data = new HashMap<>(); + data.put("url", url); + data.put("filename", file.getOriginalFilename()); + data.put("size", file.getSize()); + return ApiResponse.ofSuccess(data); + } catch (Exception e) { + return ApiResponse.of(500, "upload_failed", null); + } + } + + @DeleteMapping + public ApiResponse deleteFile(@RequestParam String objectName) { + try { + storageService.deleteFile(objectName); + return ApiResponse.ofSuccess(); + } catch (Exception e) { + return ApiResponse.of(500, "delete_failed", null); + } + } + + @GetMapping("/presigned-url") + public ApiResponse getPresignedUrl( + @RequestParam String objectName, + @RequestParam(defaultValue = "3600") int expirySeconds) { + try { + String url = storageService.getPresignedUrl(objectName, expirySeconds); + Map data = new HashMap<>(); + data.put("url", url); + return ApiResponse.ofSuccess(data); + } catch (Exception e) { + return ApiResponse.of(500, "url_generation_failed", null); + } + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/Plan.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/Plan.java new file mode 100644 index 0000000..0b96c50 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/Plan.java @@ -0,0 +1,78 @@ +package cloud.tianai.captcha.platform.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "plans") +public class Plan { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(length = 64, nullable = false) + private String name; + + @Column(length = 256) + private String description; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal price; + + @Column(name = "daily_quota", nullable = false) + private Integer dailyQuota; + + @Column(name = "qps_limit", nullable = false) + private Integer qpsLimit; + + @Column(name = "site_limit", nullable = false) + private Integer siteLimit; + + @Column(name = "features", columnDefinition = "TEXT[]") + private java.util.Set features; + + @Column(name = "is_active") + private Boolean isActive = true; + + @Column(name = "created_at") + private OffsetDateTime createdAt; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = OffsetDateTime.now(); + updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public BigDecimal getPrice() { return price; } + public void setPrice(BigDecimal price) { this.price = price; } + public Integer getDailyQuota() { return dailyQuota; } + public void setDailyQuota(Integer dailyQuota) { this.dailyQuota = dailyQuota; } + public Integer getQpsLimit() { return qpsLimit; } + public void setQpsLimit(Integer qpsLimit) { this.qpsLimit = qpsLimit; } + public Integer getSiteLimit() { return siteLimit; } + public void setSiteLimit(Integer siteLimit) { this.siteLimit = siteLimit; } + public java.util.Set getFeatures() { return features; } + public void setFeatures(java.util.Set features) { this.features = features; } + public Boolean getIsActive() { return isActive; } + public void setIsActive(Boolean isActive) { this.isActive = isActive; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/User.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/User.java new file mode 100644 index 0000000..b081b01 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/entity/User.java @@ -0,0 +1,62 @@ +package cloud.tianai.captcha.platform.entity; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(length = 64, unique = true, nullable = false) + private String username; + + @Column(length = 256, nullable = false) + private String passwordHash; + + @Column(length = 128) + private String email; + + @Column(length = 32) + private String role = "USER"; + + @Column(name = "is_enabled") + private Boolean isEnabled = true; + + @Column(name = "created_at") + private OffsetDateTime createdAt; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = OffsetDateTime.now(); + updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public Boolean getIsEnabled() { return isEnabled; } + public void setIsEnabled(Boolean isEnabled) { this.isEnabled = isEnabled; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaLogRepository.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaLogRepository.java index 9960563..6ec3918 100644 --- a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaLogRepository.java +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/CaptchaLogRepository.java @@ -2,7 +2,31 @@ package cloud.tianai.captcha.platform.mapper; import cloud.tianai.captcha.platform.entity.CaptchaLog; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; public interface CaptchaLogRepository extends JpaRepository { long countByIsPass(Boolean isPass); + + long countBySiteIdAndCreatedAtBetween(Integer siteId, OffsetDateTime start, OffsetDateTime end); + + long countBySiteIdAndIsPassAndCreatedAtBetween(Integer siteId, Boolean isPass, OffsetDateTime start, OffsetDateTime end); + + long countBySiteIdAndCaptchaTypeAndCreatedAtBetween(Integer siteId, String captchaType, OffsetDateTime start, OffsetDateTime end); + + long countByIpAndCreatedAtBetween(String ip, OffsetDateTime start, OffsetDateTime end); + + @Query("SELECT l.captchaType, COUNT(l) FROM CaptchaLog l WHERE l.siteId = :siteId AND l.createdAt BETWEEN :start AND :end GROUP BY l.captchaType") + List countByTypeGrouped(@Param("siteId") Integer siteId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end); + + @Query("SELECT l.ip, COUNT(l) FROM CaptchaLog l WHERE l.createdAt BETWEEN :start AND :end GROUP BY l.ip ORDER BY COUNT(l) DESC") + List topIpStats(@Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end); + + @Query("SELECT FUNCTION('DATE', l.createdAt), COUNT(l), SUM(CASE WHEN l.isPass = true THEN 1 ELSE 0 END) FROM CaptchaLog l WHERE l.siteId = :siteId AND l.createdAt BETWEEN :start AND :end GROUP BY FUNCTION('DATE', l.createdAt) ORDER BY FUNCTION('DATE', l.createdAt)") + List dailyStats(@Param("siteId") Integer siteId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end); + + List findTop100BySiteIdOrderByCreatedAtDesc(Integer siteId); } diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/PlanRepository.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/PlanRepository.java new file mode 100644 index 0000000..08bf290 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/PlanRepository.java @@ -0,0 +1,10 @@ +package cloud.tianai.captcha.platform.mapper; + +import cloud.tianai.captcha.platform.entity.Plan; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PlanRepository extends JpaRepository { + List findByIsActiveTrue(); +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/UserRepository.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/UserRepository.java new file mode 100644 index 0000000..5d090de --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/mapper/UserRepository.java @@ -0,0 +1,11 @@ +package cloud.tianai.captcha.platform.mapper; + +import cloud.tianai.captcha.platform.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Boolean existsByUsername(String username); +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/AnomalyDetectionService.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/AnomalyDetectionService.java new file mode 100644 index 0000000..8fcf076 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/AnomalyDetectionService.java @@ -0,0 +1,97 @@ +package cloud.tianai.captcha.platform.service; + +import cloud.tianai.captcha.platform.entity.CaptchaLog; +import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class AnomalyDetectionService { + + private static final Logger log = LoggerFactory.getLogger(AnomalyDetectionService.class); + private static final double FAIL_RATE_THRESHOLD = 0.7; + private static final int IP_FREQ_THRESHOLD = 100; + + private final CaptchaLogRepository logRepository; + private final Map activeAlerts = new ConcurrentHashMap<>(); + + public AnomalyDetectionService(CaptchaLogRepository logRepository) { + this.logRepository = logRepository; + } + + @Scheduled(fixedRate = 60000) + public void detectAnomalies() { + try { + OffsetDateTime oneHourAgo = OffsetDateTime.now(ZoneOffset.UTC).minusHours(1); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + + List topIps = logRepository.topIpStats(oneHourAgo, now); + for (Object[] row : topIps) { + String ip = (String) row[0]; + long count = ((Number) row[1]).longValue(); + if (count > IP_FREQ_THRESHOLD) { + triggerAlert("IP_FREQ", ip, "High request frequency: " + count + " requests/hour"); + } + } + + List typeStats = logRepository.countByTypeGrouped(null, oneHourAgo, now); + for (Object[] row : typeStats) { + String type = (String) row[0]; + long total = ((Number) row[1]).longValue(); + if (total > 100) { + long fails = logRepository.countByIsPass(false); + double failRate = (double) fails / total; + if (failRate > FAIL_RATE_THRESHOLD) { + triggerAlert("HIGH_FAIL_RATE", type, "Fail rate: " + String.format("%.1f%%", failRate * 100)); + } + } + } + + } catch (Exception e) { + log.warn("[ANOMALY] Detection failed: {}", e.getMessage()); + } + } + + private void triggerAlert(String type, String target, String message) { + String alertKey = type + ":" + target; + if (!activeAlerts.containsKey(alertKey)) { + AnomalyAlert alert = new AnomalyAlert(type, target, message); + activeAlerts.put(alertKey, alert); + log.warn("[ANOMALY] Alert triggered: {} - {} - {}", type, target, message); + } + } + + public List getActiveAlerts() { + return new ArrayList<>(activeAlerts.values()); + } + + public void clearAlert(String alertKey) { + activeAlerts.remove(alertKey); + } + + public static class AnomalyAlert { + private final String type; + private final String target; + private final String message; + private final long timestamp; + + public AnomalyAlert(String type, String target, String message) { + this.type = type; + this.target = target; + this.message = message; + this.timestamp = System.currentTimeMillis(); + } + + public String getType() { return type; } + public String getTarget() { return target; } + public String getMessage() { return message; } + public long getTimestamp() { return timestamp; } + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/HistoryStatsService.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/HistoryStatsService.java new file mode 100644 index 0000000..bdbfc85 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/HistoryStatsService.java @@ -0,0 +1,99 @@ +package cloud.tianai.captcha.platform.service; + +import cloud.tianai.captcha.platform.entity.CaptchaLog; +import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class HistoryStatsService { + + private final CaptchaLogRepository logRepository; + + public HistoryStatsService(CaptchaLogRepository logRepository) { + this.logRepository = logRepository; + } + + public Map getDailyStats(Integer siteId, int days) { + OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days); + OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC); + + List rows = logRepository.dailyStats(siteId, start, end); + + List dates = new ArrayList<>(); + List totals = new ArrayList<>(); + List successes = new ArrayList<>(); + + for (Object[] row : rows) { + dates.add(String.valueOf(row[0])); + totals.add(((Number) row[1]).longValue()); + successes.add(((Number) row[2]).longValue()); + } + + Map result = new LinkedHashMap<>(); + result.put("dates", dates); + result.put("totals", totals); + result.put("successes", successes); + return result; + } + + public Map getTypeDistribution(Integer siteId, int days) { + OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days); + OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC); + + List rows = logRepository.countByTypeGrouped(siteId, start, end); + + Map distribution = new LinkedHashMap<>(); + long total = 0; + for (Object[] row : rows) { + String type = (String) row[0]; + long count = ((Number) row[1]).longValue(); + distribution.put(type, count); + total += count; + } + + Map result = new LinkedHashMap<>(); + result.put("distribution", distribution); + result.put("total", total); + return result; + } + + public List> getTopIps(int days, int limit) { + OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days); + OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC); + + List rows = logRepository.topIpStats(start, end); + + return rows.stream() + .limit(limit) + .map(row -> { + Map item = new LinkedHashMap<>(); + item.put("ip", row[0]); + item.put("count", ((Number) row[1]).longValue()); + return item; + }) + .collect(Collectors.toList()); + } + + public Map getHourlyDistribution(Integer siteId, int days) { + OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days); + OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC); + + List logs = logRepository.findTop100BySiteIdOrderByCreatedAtDesc(siteId); + + Map hourly = logs.stream() + .filter(l -> l.getCreatedAt() != null && l.getCreatedAt().isAfter(start)) + .collect(Collectors.groupingBy( + l -> l.getCreatedAt().getHour(), + Collectors.counting() + )); + + Map result = new LinkedHashMap<>(); + result.put("hours", hourly); + return result; + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/JwtService.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/JwtService.java new file mode 100644 index 0000000..5cdb90f --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/JwtService.java @@ -0,0 +1,72 @@ +package cloud.tianai.captcha.platform.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Service +public class JwtService { + + @Value("${jwt.secret:tianai-captcha-secret-key-must-be-at-least-256-bits-long!!}") + private String secret; + + @Value("${jwt.expiration:86400000}") + private long expiration; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(Integer userId, String username, String role) { + Map claims = new HashMap<>(); + claims.put("userId", userId); + claims.put("username", username); + claims.put("role", role); + + return Jwts.builder() + .setClaims(claims) + .setSubject(username) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getBody(); + } + + public Boolean validateToken(String token) { + try { + Claims claims = parseToken(token); + return !claims.getExpiration().before(new Date()); + } catch (Exception e) { + return false; + } + } + + public String getUsernameFromToken(String token) { + return parseToken(token).getSubject(); + } + + public Integer getUserIdFromToken(String token) { + return (Integer) parseToken(token).get("userId"); + } + + public String getRoleFromToken(String token) { + return (String) parseToken(token).get("role"); + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/MinioStorageService.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/MinioStorageService.java new file mode 100644 index 0000000..c548a80 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/MinioStorageService.java @@ -0,0 +1,106 @@ +package cloud.tianai.captcha.platform.service; + +import io.minio.*; +import io.minio.http.Method; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.annotation.PostConstruct; +import java.io.InputStream; +import java.util.UUID; + +@Service +public class MinioStorageService { + + @Value("${minio.endpoint:http://localhost:9000}") + private String endpoint; + + @Value("${minio.access-key:minioadmin}") + private String accessKey; + + @Value("${minio.secret-key:minioadmin}") + private String secretKey; + + @Value("${minio.bucket:captcha-resources}") + private String bucket; + + private MinioClient minioClient; + + @PostConstruct + public void init() { + try { + minioClient = MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + + // 确保bucket存在 + boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); + if (!exists) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); + } + } catch (Exception e) { + System.err.println("Failed to initialize MinIO client: " + e.getMessage()); + } + } + + public String uploadFile(MultipartFile file, String prefix) { + try { + String objectName = prefix + "/" + UUID.randomUUID().toString() + getExtension(file.getOriginalFilename()); + + minioClient.putObject(PutObjectArgs.builder() + .bucket(bucket) + .object(objectName) + .stream(file.getInputStream(), file.getSize(), -1) + .contentType(file.getContentType()) + .build()); + + return endpoint + "/" + bucket + "/" + objectName; + } catch (Exception e) { + throw new RuntimeException("Failed to upload file to MinIO", e); + } + } + + public InputStream downloadFile(String objectName) { + try { + return minioClient.getObject(GetObjectArgs.builder() + .bucket(bucket) + .object(objectName) + .build()); + } catch (Exception e) { + throw new RuntimeException("Failed to download file from MinIO", e); + } + } + + public void deleteFile(String objectName) { + try { + minioClient.removeObject(RemoveObjectArgs.builder() + .bucket(bucket) + .object(objectName) + .build()); + } catch (Exception e) { + throw new RuntimeException("Failed to delete file from MinIO", e); + } + } + + public String getPresignedUrl(String objectName, int expirySeconds) { + try { + return minioClient.getPresignedObjectUrl( + GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(bucket) + .object(objectName) + .expiry(expirySeconds) + .build()); + } catch (Exception e) { + throw new RuntimeException("Failed to generate presigned URL", e); + } + } + + private String getExtension(String filename) { + if (filename == null) return ".bin"; + int dotIndex = filename.lastIndexOf('.'); + return dotIndex >= 0 ? filename.substring(dotIndex) : ".bin"; + } +} diff --git a/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/RealtimeStatsService.java b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/RealtimeStatsService.java new file mode 100644 index 0000000..9bf6218 --- /dev/null +++ b/tianai-captcha-platform/src/main/java/cloud/tianai/captcha/platform/service/RealtimeStatsService.java @@ -0,0 +1,165 @@ +package cloud.tianai.captcha.platform.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Service +public class RealtimeStatsService { + + private static final Logger log = LoggerFactory.getLogger(RealtimeStatsService.class); + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final StringRedisTemplate redisTemplate; + private final AtomicLong totalRequests = new AtomicLong(0); + private final AtomicLong totalGenerate = new AtomicLong(0); + private final AtomicLong totalVerify = new AtomicLong(0); + private final AtomicLong totalSuccess = new AtomicLong(0); + private final AtomicLong totalFail = new AtomicLong(0); + private final Map ipRequestCounts = new ConcurrentHashMap<>(); + private final Map siteRequestCounts = new ConcurrentHashMap<>(); + + public RealtimeStatsService(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void incrementTotalRequests() { + totalRequests.incrementAndGet(); + incrementRedis("stats:total:" + todayKey()); + } + + public void recordGenerate(String ip, String siteKey) { + totalGenerate.incrementAndGet(); + incrementRedis("stats:generate:" + todayKey()); + incrementIpCount(ip); + incrementSiteCount(siteKey); + } + + public void recordVerifyAttempt(boolean success, String ip, String siteKey) { + if (success) { + totalSuccess.incrementAndGet(); + incrementRedis("stats:success:" + todayKey()); + } else { + totalFail.incrementAndGet(); + incrementRedis("stats:fail:" + todayKey()); + } + totalVerify.incrementAndGet(); + incrementRedis("stats:verify:" + todayKey()); + incrementIpCount(ip); + incrementSiteCount(siteKey); + } + + public Map getRealtimeStats() { + Map stats = new LinkedHashMap<>(); + String key = todayKey(); + stats.put("date", LocalDate.now().toString()); + stats.put("totalRequests", getTotalRedis("stats:total:" + key)); + stats.put("generateCount", getTotalRedis("stats:generate:" + key)); + stats.put("verifyCount", getTotalRedis("stats:verify:" + key)); + stats.put("successCount", getTotalRedis("stats:success:" + key)); + stats.put("failCount", getTotalRedis("stats:fail:" + key)); + long total = getTotalRedis("stats:verify:" + key); + long success = getTotalRedis("stats:success:" + key); + stats.put("passRate", total > 0 ? String.format("%.2f%%", success * 100.0 / total) : "0%"); + return stats; + } + + public Map getDashboardStats(Integer siteId, int days) { + Map result = new LinkedHashMap<>(); + result.put("realtime", getRealtimeStats()); + + Map dailyTotals = new LinkedHashMap<>(); + Map dailySuccess = new LinkedHashMap<>(); + for (int i = days - 1; i >= 0; i--) { + String dayKey = LocalDate.now().minusDays(i).format(DATE_FMT); + dailyTotals.put(dayKey, getTotalRedis("stats:verify:" + dayKey)); + dailySuccess.put(dayKey, getTotalRedis("stats:success:" + dayKey)); + } + result.put("dailyTotals", dailyTotals); + result.put("dailySuccess", dailySuccess); + + List> topIps = new ArrayList<>(); + ipRequestCounts.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get())) + .limit(10) + .forEach(e -> { + Map ipStat = new LinkedHashMap<>(); + ipStat.put("ip", e.getKey()); + ipStat.put("count", e.getValue().get()); + topIps.add(ipStat); + }); + result.put("topIps", topIps); + + List> topSites = new ArrayList<>(); + siteRequestCounts.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get())) + .limit(10) + .forEach(e -> { + Map siteStat = new LinkedHashMap<>(); + siteStat.put("siteKey", e.getKey()); + siteStat.put("count", e.getValue().get()); + topSites.add(siteStat); + }); + result.put("topSites", topSites); + + return result; + } + + private void incrementRedis(String key) { + try { + redisTemplate.opsForValue().increment(key); + redisTemplate.expire(key, java.time.Duration.ofDays(35)); + } catch (Exception e) { + log.debug("Redis increment failed for {}: {}", key, e.getMessage()); + } + } + + private long getTotalRedis(String key) { + try { + String val = redisTemplate.opsForValue().get(key); + return val != null ? Long.parseLong(val) : 0; + } catch (Exception e) { + log.debug("Redis get failed for {}: {}", key, e.getMessage()); + return 0; + } + } + + private void incrementIpCount(String ip) { + if (ip != null) { + ipRequestCounts.computeIfAbsent(ip, k -> new AtomicLong(0)).incrementAndGet(); + } + } + + private void incrementSiteCount(String siteKey) { + if (siteKey != null) { + siteRequestCounts.computeIfAbsent(siteKey, k -> new AtomicLong(0)).incrementAndGet(); + } + } + + private String todayKey() { + return LocalDate.now().format(DATE_FMT); + } + + @Scheduled(fixedRate = 300000) + public void syncToRedis() { + try { + String key = todayKey(); + redisTemplate.opsForValue().set("stats:total:" + key, String.valueOf(totalRequests.get())); + redisTemplate.opsForValue().set("stats:generate:" + key, String.valueOf(totalGenerate.get())); + redisTemplate.opsForValue().set("stats:verify:" + key, String.valueOf(totalVerify.get())); + redisTemplate.opsForValue().set("stats:success:" + key, String.valueOf(totalSuccess.get())); + redisTemplate.opsForValue().set("stats:fail:" + key, String.valueOf(totalFail.get())); + log.debug("[STATS] Synced to Redis: total={}", totalRequests.get()); + } catch (Exception e) { + log.warn("[STATS] Failed to sync to Redis: {}", e.getMessage()); + } + } +} diff --git a/tianai-captcha-platform/src/main/resources/application-prod.yml b/tianai-captcha-platform/src/main/resources/application-prod.yml new file mode 100644 index 0000000..68d6964 --- /dev/null +++ b/tianai-captcha-platform/src/main/resources/application-prod.yml @@ -0,0 +1,52 @@ +server: + port: 18200 + +spring: + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:captcha_forge} + username: ${DB_USER:pgsql} + password: ${DB_PASS:} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: none + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + sql: + init: + mode: never + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASS:} + + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + +captcha: + prefix: captcha + expire: + default: 120000 + init-default-resource: true + local-cache-enabled: true + local-cache-size: 20 + +jwt: + secret: ${JWT_SECRET:tianai-captcha-jwt-secret-key-must-be-at-least-256-bits-long-for-hs256} + expiration: ${JWT_EXPIRATION:86400000} + +minio: + endpoint: ${MINIO_ENDPOINT:http://localhost:9000} + access-key: ${MINIO_ACCESS_KEY:minioadmin} + secret-key: ${MINIO_SECRET_KEY:minioadmin} + bucket: ${MINIO_BUCKET:captcha-resources} + +logging: + level: + cloud.tianai.captcha: INFO + root: WARN diff --git a/tianai-captcha-platform/src/main/resources/application.yml b/tianai-captcha-platform/src/main/resources/application.yml index 6739560..bfbb9d3 100644 --- a/tianai-captcha-platform/src/main/resources/application.yml +++ b/tianai-captcha-platform/src/main/resources/application.yml @@ -31,6 +31,16 @@ captcha: local-cache-enabled: true local-cache-size: 20 +jwt: + secret: tianai-captcha-jwt-secret-key-must-be-at-least-256-bits-long-for-hs256 + expiration: 86400000 + +minio: + endpoint: http://localhost:9000 + access-key: minioadmin + secret-key: minioadmin + bucket: captcha-resources + logging: level: cloud.tianai.captcha: INFO diff --git a/tianai-captcha-platform/src/test/java/cloud/tianai/captcha/platform/CaptchaApiIntegrationTest.java b/tianai-captcha-platform/src/test/java/cloud/tianai/captcha/platform/CaptchaApiIntegrationTest.java new file mode 100644 index 0000000..3765af5 --- /dev/null +++ b/tianai-captcha-platform/src/test/java/cloud/tianai/captcha/platform/CaptchaApiIntegrationTest.java @@ -0,0 +1,74 @@ +package cloud.tianai.captcha.platform; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class CaptchaApiIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + private String jwtToken; + + @BeforeEach + void login() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>( + Map.of("username", "admin", "password", "admin"), headers); + var response = restTemplate.postForEntity("/api/auth/login", request, Map.class); + if (response.getBody() != null && response.getBody().get("data") != null) { + @SuppressWarnings("unchecked") + Map data = (Map) response.getBody().get("data"); + if (data != null && data.get("token") != null) { + jwtToken = (String) data.get("token"); + } + } + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (jwtToken != null) { + headers.setBearerAuth(jwtToken); + } + return headers; + } + + @Test + void contextLoads() { + assertNotNull(restTemplate); + } + + @Test + void loginReturnsToken() { + assertNotNull(jwtToken, "JWT token should not be null after login"); + } + + @Test + void statsEndpointReturnsData() { + HttpEntity entity = new HttpEntity<>(authHeaders()); + var response = restTemplate.exchange("/api/admin/stats", HttpMethod.GET, entity, Map.class); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + } + + @Test + void siteListEndpointReturnsData() { + HttpEntity entity = new HttpEntity<>(authHeaders()); + var response = restTemplate.exchange("/api/admin/sites?page=0&size=10", HttpMethod.GET, entity, Map.class); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + } +} diff --git a/tianai-captcha-platform/src/test/resources/application-test.yml b/tianai-captcha-platform/src/test/resources/application-test.yml new file mode 100644 index 0000000..09f71fc --- /dev/null +++ b/tianai-captcha-platform/src/test/resources/application-test.yml @@ -0,0 +1,41 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + h2: + console: + enabled: false + data: + redis: + host: localhost + port: 6379 + +jwt: + secret: test-secret-key-must-be-at-least-256-bits-long-for-hs256 + expiration: 86400000 + +captcha: + prefix: captcha + expire: + default: 120000 + init-default-resource: false + local-cache-enabled: false + +minio: + endpoint: http://localhost:9000 + access-key: minioadmin + secret-key: minioadmin + bucket: captcha-resources + +logging: + level: + cloud.tianai.captcha: DEBUG diff --git a/tianai-captcha-sdk/src/captcha.js b/tianai-captcha-sdk/src/captcha.js index ddc566a..7b7a36c 100644 --- a/tianai-captcha-sdk/src/captcha.js +++ b/tianai-captcha-sdk/src/captcha.js @@ -111,9 +111,9 @@ '.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{opacity:0;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}}' + + '@keyframes pulse-ward{0%{transform:scale(1.0);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)}' + diff --git a/tianai-captcha-web-sdk/public/index.html b/tianai-captcha-web-sdk/public/index.html index 4aeba4c..a033e94 100644 --- a/tianai-captcha-web-sdk/public/index.html +++ b/tianai-captcha-web-sdk/public/index.html @@ -1,101 +1,325 @@ - - + - Document + TianAI Captcha - 行为验证码演示 - - -
- -
-

用户登录

-
- 用户名 - xxxxx +
+
+

TianAI Captcha

+

行为验证码 SDK 演示

+
+ +
+
+
滑块验证
+
旋转验证
+
滑动还原
+
文字点选
+
+ +
+
+
+ + +
+ +
-
- 密码 - xxxxx -
- - - - -
+ + + + + document.querySelectorAll('.type-btn').forEach(btn => { + btn.addEventListener('click', function() { + document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + initCaptcha(this.dataset.type); + }); + }); + + initCaptcha('SLIDER'); + - diff --git a/tianai-captcha-web-sdk/src/captcha/angle/angle.js b/tianai-captcha-web-sdk/src/captcha/angle/angle.js new file mode 100644 index 0000000..20159a6 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/angle/angle.js @@ -0,0 +1,181 @@ +import "./angle.scss" +import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js" + +/** + * 角度验证验证码 - 用户需要旋转图片到正确角度 + */ + +const TYPE = "ANGLE" +function getTemplate(styleConfig) { + return ` +
+
+ ${styleConfig.i18n?.angle_title || '旋转图片到正确角度'} +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; +} +class Angle extends CommonCaptcha{ + constructor(boxEl, styleConfig) { + super(); + this.boxEl = boxEl; + this.styleConfig = styleConfig; + this.type = TYPE; + this.currentCaptchaData = {}; + this.isDragging = false; + this.startX = 0; + this.currentAngle = 0; + } + init(captchaData, endCallback, loadSuccessCallback) { + // 重载样式 + this.destroy(); + this.boxEl.append(getTemplate(this.styleConfig)); + this.el = this.boxEl.find("#tianai-captcha"); + // 载入验证码 + this.loadCaptchaForData(this, captchaData); + this.endCallback = endCallback; + + if (loadSuccessCallback) { + // 加载成功 + loadSuccessCallback(this); + } + return this; + } + + bindDragEvents() { + const sliderBtn = document.getElementById('tianai-captcha-slider-btn'); + const rotateImg = document.getElementById('rotate-img'); + if (!sliderBtn || !rotateImg) return; + + // 鼠标按下 + sliderBtn.addEventListener('mousedown', (e) => { + this.isDragging = true; + this.startX = e.pageX; + this.currentCaptchaData.startTime = new Date(); + e.preventDefault(); + }); + + // 鼠标移动 + document.addEventListener('mousemove', (e) => { + if (!this.isDragging) return; + const moveX = e.pageX - this.startX; + const sliderBar = document.getElementById('tianai-captcha-slider-bar'); + const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200; + const left = Math.max(0, Math.min(moveX, barWidth)); + + sliderBtn.style.left = left + 'px'; + document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px'; + + // 计算旋转角度 + this.currentAngle = (left / barWidth) * 360; + rotateImg.style.transform = 'rotate(' + this.currentAngle + 'deg)'; + + // 更新指示器 + this.updateIndicator(); + + // 记录轨迹 + if (!this.currentCaptchaData.trackList) { + this.currentCaptchaData.trackList = []; + } + this.currentCaptchaData.trackList.push({ + x: e.pageX, + y: e.pageY, + angle: this.currentAngle, + t: new Date().getTime() - this.currentCaptchaData.startTime.getTime() + }); + }); + + // 鼠标松开 + document.addEventListener('mouseup', () => { + if (!this.isDragging) return; + this.isDragging = false; + this.currentCaptchaData.stopTime = new Date(); + this.currentCaptchaData.currentAngle = this.currentAngle; + this.endCallback(this.currentCaptchaData, this); + }); + + // 触摸事件 + sliderBtn.addEventListener('touchstart', (e) => { + this.isDragging = true; + this.startX = e.touches[0].pageX; + this.currentCaptchaData.startTime = new Date(); + e.preventDefault(); + }); + + document.addEventListener('touchmove', (e) => { + if (!this.isDragging) return; + const moveX = e.touches[0].pageX - this.startX; + const sliderBar = document.getElementById('tianai-captcha-slider-bar'); + const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200; + const left = Math.max(0, Math.min(moveX, barWidth)); + + sliderBtn.style.left = left + 'px'; + document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px'; + + this.currentAngle = (left / barWidth) * 360; + rotateImg.style.transform = 'rotate(' + this.currentAngle + 'deg)'; + this.updateIndicator(); + }); + + document.addEventListener('touchend', () => { + if (!this.isDragging) return; + this.isDragging = false; + this.currentCaptchaData.stopTime = new Date(); + this.currentCaptchaData.currentAngle = this.currentAngle; + this.endCallback(this.currentCaptchaData, this); + }); + } + + updateIndicator() { + const indicatorLine = document.getElementById('indicator-line'); + if (indicatorLine) { + indicatorLine.style.transform = 'rotate(' + this.currentAngle + 'deg)'; + } + } + + destroy () { + const existsCaptchaEl = this.boxEl.children("#tianai-captcha"); + if (existsCaptchaEl) { + existsCaptchaEl.remove(); + } + destroyEvent(); + } + + loadCaptchaForData (that, data) { + const bgImg = that.el.find("#tianai-captcha-rotate-bg-img"); + bgImg.on("load",() => { + that.currentCaptchaData = { + bgImageWidth: bgImg.width(), + bgImageHeight: bgImg.height(), + startTime: new Date(), + trackList: [], + targetAngle: data.data.targetAngle || 0, + currentAngle: 0 + }; + that.currentCaptchaData.currentCaptchaId = data.data.id; + // 绑定滑动事件 + setTimeout(() => that.bindDragEvents(), 100); + }) + bgImg.attr("src", data.data.backgroundImage); + } +} + +export default Angle; diff --git a/tianai-captcha-web-sdk/src/captcha/angle/angle.scss b/tianai-captcha-web-sdk/src/captcha/angle/angle.scss new file mode 100644 index 0000000..b6d0b5e --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/angle/angle.scss @@ -0,0 +1,107 @@ +#tianai-captcha.tianai-captcha-angle { + box-sizing: border-box; + + .click-tip { + position: relative; + height: 40px; + width: 100%; + display: flex; + align-items: center; + + #tianai-captcha-click-track-font { + font-size: 14px; + color: #333; + } + } + + .content { + .rotate-container { + position: relative; + width: 200px; + height: 200px; + margin: 0 auto; + border-radius: 50%; + overflow: hidden; + border: 3px solid #e8e8e8; + + .rotate-img { + width: 100%; + height: 100%; + transition: transform 0.1s; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .rotate-indicator { + position: absolute; + top: 50%; + left: 50%; + width: 2px; + height: 50%; + background-color: transparent; + transform-origin: bottom center; + transform: translate(-50%, -100%); + + .indicator-line { + width: 100%; + height: 100%; + background-color: #ff4444; + transform-origin: bottom center; + } + } + } + + .slider-bar { + position: relative; + height: 40px; + margin-top: 3px; + + .slider-bar-bg { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + border-radius: 4px; + background-color: #e8e8e8; + } + + .slider-bar-track { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: 4px; + background-image: linear-gradient(90deg, #409eff, #66b1ff); + transition: width 0.1s; + } + + .slider-bar-btn { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 40px; + border-radius: 4px; + background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%); + cursor: move; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + + .slider-btn-icon { + width: 0; + height: 0; + border-style: solid; + border-width: 6px 0 6px 8px; + border-color: transparent transparent transparent #fff; + } + } + } + } +} diff --git a/tianai-captcha-web-sdk/src/captcha/captcha.js b/tianai-captcha-web-sdk/src/captcha/captcha.js index 4237238..0d03394 100644 --- a/tianai-captcha-web-sdk/src/captcha/captcha.js +++ b/tianai-captcha-web-sdk/src/captcha/captcha.js @@ -4,6 +4,14 @@ import Rotate from "./rotate/rotate"; import Concat from "./concat/concat"; import Disable from "./disable/disable"; import WordImageClick from "./word_image_click/word_image_click"; +import IconClick from "./icon_click/icon_click"; +import WordOrderClick from "./word_order_click/word_order_click"; +import Scratch from "./scratch/scratch"; +import Jigsaw from "./jigsaw/jigsaw"; +import CurveSlider from "./curve_slider/curve_slider"; +import Angle from "./angle/angle"; +import CurveDraw from "./curve_draw/curve_draw"; +import ProofOfWork from "./proof_of_work/proof_of_work"; import {CaptchaConfig, wrapConfig, wrapStyle} from "./config/config"; import {clearAllPreventDefault} from "./common/common"; const template = @@ -11,13 +19,30 @@ const template =
-
+
+
+
加载中...
+
- -
-
+
+ +
+
+
+ + + + +
+
+ + + + +
+
`; @@ -33,6 +58,22 @@ function createCaptchaByType(type, tac) { return new Concat(box, styleConfig); case "WORD_IMAGE_CLICK": return new WordImageClick(box, styleConfig); + case "ICON_CLICK": + return new IconClick(box, styleConfig); + case "WORD_ORDER_CLICK": + return new WordOrderClick(box, styleConfig); + case "SCRATCH": + return new Scratch(box, styleConfig); + case "JIGSAW": + return new Jigsaw(box, styleConfig); + case "CURVE_SLIDER": + return new CurveSlider(box, styleConfig); + case "ANGLE": + return new Angle(box, styleConfig); + case "CURVE_DRAW": + return new CurveDraw(box, styleConfig); + case "PROOF_OF_WORK": + return new ProofOfWork(box, styleConfig); case "DISABLED": return new Disable(box, styleConfig); default: diff --git a/tianai-captcha-web-sdk/src/captcha/captcha.scss b/tianai-captcha-web-sdk/src/captcha/captcha.scss index 6252b93..fa7734a 100644 --- a/tianai-captcha-web-sdk/src/captcha/captcha.scss +++ b/tianai-captcha-web-sdk/src/captcha/captcha.scss @@ -1,39 +1,58 @@ #tianai-captcha-parent { - box-shadow: 0 0 11px 0 #999999; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); width: 318px; height: 318px; overflow: hidden; position: relative; z-index: 997; box-sizing: border-box; - border-radius: 5px; + border-radius: 8px; padding: 8px; + #tianai-captcha-box { height: 260px; width: 100%; position: relative; overflow: hidden; + border-radius: 6px; + .loading { - width: 120px; - height: 20px; - -webkit-mask: linear-gradient(90deg, #000 70%, #0000 0) 0/20%; - background: linear-gradient(#f7b645 0 0) 0 / 0% no-repeat #dddddd6b; - animation: cartoon 1s infinite steps(6); - margin: 120px auto; - @keyframes cartoon { - 100% { - background-size: 120%; - } + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: #fafafa; + + .loading-spinner { + width: 36px; + height: 36px; + border: 3px solid #f0f0f0; + border-top-color: #f7b645; + border-radius: 50%; + animation: tianai-spin 0.8s linear infinite; + } + + .loading-text { + margin-top: 12px; + font-size: 12px; + color: #999; } } + + @keyframes tianai-spin { + to { transform: rotate(360deg); } + } + #tianai-captcha { transform-style: preserve-3d; will-change: transform; - transition-duration: .45s; - //transition-timing-function: cubic-bezier(0.36, 0.3, 0.42, 1.5); + transition-duration: 0.45s; transform: translateX(-300px); } } + #tianai-captcha-bg-img { background-color: #fff; background-position: top; @@ -44,11 +63,54 @@ top: 0; left: 0; position: absolute; - border-radius: 6px; - //background-image: url(""); + border-radius: 8px; } - .slider-bottom { + .slider-bottom { + display: flex; + justify-content: space-between; + align-items: center; + height: 26px; + width: 100%; + padding: 0 2px; + + .bottom-left { + display: flex; + align-items: center; + + .logo { + height: 22px; + } + } + + .bottom-right { + display: flex; + align-items: center; + gap: 4px; + + .icon-btn { + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + color: #999; + transition: all 0.2s; + + &:hover { + background: #f0f0f0; + color: #666; + } + + svg { + width: 14px; + height: 14px; + } + } + } + .close-btn { width: 20px; height: 20px; @@ -59,6 +121,7 @@ margin-right: 2px; cursor: pointer; } + .refresh-btn { width: 20px; height: 20px; @@ -69,13 +132,16 @@ margin-right: 10px; cursor: pointer; } + .logo { height: 30px; float: left; } + height: 19px; width: 100%; } + .slider-move-shadow { animation: myanimation 2s infinite; height: 100%; @@ -88,20 +154,18 @@ box-shadow: 1px 1px 1px #fff; border-radius: 50%; } + #tianai-captcha-slider-move-track-mask { border-width: 1px; border-style: solid; border-color: #00f4ab; - width: 0; height: 32px; background-color: #a9ffe5; - opacity: .5; + opacity: 0.5; position: absolute; top: -1px; left: -1px; border-radius: 5px; } - } - diff --git a/tianai-captcha-web-sdk/src/captcha/curve_draw/curve_draw.js b/tianai-captcha-web-sdk/src/captcha/curve_draw/curve_draw.js new file mode 100644 index 0000000..85c9028 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/curve_draw/curve_draw.js @@ -0,0 +1,195 @@ +import "./curve_draw.scss" +import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js" + +/** + * 曲线绘制验证码 - 用户需要绘制指定曲线 + */ + +const TYPE = "CURVE_DRAW" +function getTemplate(styleConfig) { + return ` +
+
+ ${styleConfig.i18n?.curve_draw_title || '请沿虚线绘制曲线'} +
+
+
+ +
+
+ + +
+
+
+
+`; +} +class CurveDraw extends CommonCaptcha{ + constructor(boxEl, styleConfig) { + super(); + this.boxEl = boxEl; + this.styleConfig = styleConfig; + this.type = TYPE; + this.currentCaptchaData = {}; + this.isDrawing = false; + this.drawPoints = []; + } + init(captchaData, endCallback, loadSuccessCallback) { + // 重载样式 + this.destroy(); + this.boxEl.append(getTemplate(this.styleConfig)); + this.el = this.boxEl.find("#tianai-captcha"); + // 载入验证码 + this.loadCaptchaForData(this, captchaData); + this.endCallback = endCallback; + + if (loadSuccessCallback) { + // 加载成功 + loadSuccessCallback(this); + } + return this; + } + + initCanvas() { + const canvas = document.getElementById('tianai-captcha-draw-canvas'); + const container = document.getElementById('draw-container'); + if (!canvas || !container) return; + + const ctx = canvas.getContext('2d'); + const rect = container.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + + // 绘制提示曲线(虚线) + this.drawGuideCurve(ctx, canvas.width, canvas.height); + + // 绑定绘制事件 + this.bindDrawEvents(canvas, ctx); + } + + drawGuideCurve(ctx, width, height) { + const curveData = this.currentCaptchaData.curveData; + if (!curveData || curveData.length < 2) return; + + ctx.beginPath(); + ctx.strokeStyle = 'rgba(64, 158, 255, 0.5)'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + + ctx.moveTo(curveData[0].x * width, curveData[0].y * height); + for (let i = 1; i < curveData.length; i++) { + ctx.lineTo(curveData[i].x * width, curveData[i].y * height); + } + ctx.stroke(); + } + + bindDrawEvents(canvas, ctx) { + const draw = (e) => { + if (!this.isDrawing) return; + const rect = canvas.getBoundingClientRect(); + let x, y; + if (e.touches) { + x = e.touches[0].clientX - rect.left; + y = e.touches[0].clientY - rect.top; + } else { + x = e.offsetX; + y = e.offsetY; + } + + this.drawPoints.push({x, y}); + + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = '#409eff'; + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + if (this.drawPoints.length > 1) { + const prev = this.drawPoints[this.drawPoints.length - 2]; + ctx.beginPath(); + ctx.moveTo(prev.x, prev.y); + ctx.lineTo(x, y); + ctx.stroke(); + } + }; + + // 鼠标事件 + canvas.addEventListener('mousedown', (e) => { + this.isDrawing = true; + this.currentCaptchaData.startTime = new Date(); + this.drawPoints = []; + ctx.clearRect(0, 0, canvas.width, canvas.height); + this.drawGuideCurve(ctx, canvas.width, canvas.height); + draw(e); + }); + canvas.addEventListener('mousemove', draw); + canvas.addEventListener('mouseup', () => { + this.isDrawing = false; + }); + canvas.addEventListener('mouseleave', () => { + this.isDrawing = false; + }); + + // 触摸事件 + canvas.addEventListener('touchstart', (e) => { + this.isDrawing = true; + this.currentCaptchaData.startTime = new Date(); + this.drawPoints = []; + ctx.clearRect(0, 0, canvas.width, canvas.height); + this.drawGuideCurve(ctx, canvas.width, canvas.height); + e.preventDefault(); + draw(e); + }); + canvas.addEventListener('touchmove', (e) => { + e.preventDefault(); + draw(e); + }); + canvas.addEventListener('touchend', () => { + this.isDrawing = false; + }); + + // 重绘按钮 + const resetBtn = document.getElementById('draw-reset-btn'); + if (resetBtn) { + resetBtn.addEventListener('click', () => { + this.drawPoints = []; + ctx.clearRect(0, 0, canvas.width, canvas.height); + this.drawGuideCurve(ctx, canvas.width, canvas.height); + }); + } + + // 确定按钮 + const confirmBtn = document.getElementById('draw-confirm-btn'); + if (confirmBtn) { + confirmBtn.addEventListener('click', () => { + if (this.drawPoints.length > 0) { + this.currentCaptchaData.stopTime = new Date(); + this.currentCaptchaData.drawPoints = this.drawPoints; + this.endCallback(this.currentCaptchaData, this); + } + }); + } + } + + destroy () { + const existsCaptchaEl = this.boxEl.children("#tianai-captcha"); + if (existsCaptchaEl) { + existsCaptchaEl.remove(); + } + destroyEvent(); + } + + loadCaptchaForData (that, data) { + that.currentCaptchaData = { + startTime: new Date(), + drawPoints: [], + curveData: data.data.curveData || [] + }; + that.currentCaptchaData.currentCaptchaId = data.data.id; + // 初始化Canvas + setTimeout(() => that.initCanvas(), 100); + } +} + +export default CurveDraw; diff --git a/tianai-captcha-web-sdk/src/captcha/curve_draw/curve_draw.scss b/tianai-captcha-web-sdk/src/captcha/curve_draw/curve_draw.scss new file mode 100644 index 0000000..1df7cfa --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/curve_draw/curve_draw.scss @@ -0,0 +1,64 @@ +#tianai-captcha.tianai-captcha-curve-draw { + box-sizing: border-box; + + .click-tip { + position: relative; + height: 40px; + width: 100%; + display: flex; + align-items: center; + + #tianai-captcha-click-track-font { + font-size: 14px; + color: #333; + } + } + + .content { + .draw-container { + width: 100%; + height: 180px; + position: relative; + overflow: hidden; + border-radius: 4px; + border: 1px solid #e8e8e8; + + .draw-canvas { + width: 100%; + height: 100%; + cursor: crosshair; + touch-action: none; + } + } + + .draw-actions { + display: flex; + gap: 10px; + margin-top: 8px; + + .draw-btn { + flex: 1; + height: 32px; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + + &.reset-btn { + background-color: #e8e8e8; + color: #666; + } + + &.confirm-btn { + background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%); + color: #fff; + } + } + } + } +} diff --git a/tianai-captcha-web-sdk/src/captcha/curve_slider/curve_slider.js b/tianai-captcha-web-sdk/src/captcha/curve_slider/curve_slider.js new file mode 100644 index 0000000..5259d51 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/curve_slider/curve_slider.js @@ -0,0 +1,205 @@ +import "./curve_slider.scss" +import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js" + +/** + * 曲线滑块验证码 - 用户需要沿曲线轨迹滑动 + */ + +const TYPE = "CURVE_SLIDER" +function getTemplate(styleConfig) { + return ` +
+
+ ${styleConfig.i18n?.curve_slider_title || '沿曲线轨迹滑动到终点'} +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+`; +} +class CurveSlider extends CommonCaptcha{ + constructor(boxEl, styleConfig) { + super(); + this.boxEl = boxEl; + this.styleConfig = styleConfig; + this.type = TYPE; + this.currentCaptchaData = {}; + this.isDragging = false; + this.startX = 0; + } + init(captchaData, endCallback, loadSuccessCallback) { + // 重载样式 + this.destroy(); + this.boxEl.append(getTemplate(this.styleConfig)); + this.el = this.boxEl.find("#tianai-captcha"); + // 载入验证码 + this.loadCaptchaForData(this, captchaData); + this.endCallback = endCallback; + + if (loadSuccessCallback) { + // 加载成功 + loadSuccessCallback(this); + } + return this; + } + + initCanvas() { + const canvas = document.getElementById('tianai-captcha-curve-canvas'); + const bgImg = document.getElementById('tianai-captcha-slider-bg-img'); + if (!canvas || !bgImg) return; + + const ctx = canvas.getContext('2d'); + canvas.width = bgImg.width; + canvas.height = bgImg.height; + + // 绘制曲线 + this.drawCurve(ctx, canvas.width, canvas.height); + + // 绑定滑动事件 + this.bindDragEvents(canvas); + } + + drawCurve(ctx, width, height) { + const curveData = this.currentCaptchaData.curveData; + if (!curveData || curveData.length < 2) return; + + ctx.beginPath(); + ctx.strokeStyle = 'rgba(255, 165, 0, 0.8)'; + ctx.lineWidth = 3; + ctx.setLineDash([5, 5]); + + ctx.moveTo(curveData[0].x, curveData[0].y); + for (let i = 1; i < curveData.length; i++) { + ctx.lineTo(curveData[i].x, curveData[i].y); + } + ctx.stroke(); + + // 绘制起点 + ctx.beginPath(); + ctx.fillStyle = '#409eff'; + ctx.arc(curveData[0].x, curveData[0].y, 8, 0, Math.PI * 2); + ctx.fill(); + + // 绘制终点 + const endPoint = curveData[curveData.length - 1]; + ctx.beginPath(); + ctx.fillStyle = '#ff4444'; + ctx.arc(endPoint.x, endPoint.y, 8, 0, Math.PI * 2); + ctx.fill(); + } + + bindDragEvents(canvas) { + const point = document.getElementById('curve-point'); + const sliderBtn = document.getElementById('tianai-captcha-slider-btn'); + if (!point || !sliderBtn) return; + + // 鼠标按下 + sliderBtn.addEventListener('mousedown', (e) => { + this.isDragging = true; + this.startX = e.pageX; + this.currentCaptchaData.startTime = new Date(); + e.preventDefault(); + }); + + // 鼠标移动 + document.addEventListener('mousemove', (e) => { + if (!this.isDragging) return; + const moveX = e.pageX - this.startX; + const sliderBar = document.getElementById('tianai-captcha-slider-bar'); + const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200; + const left = Math.max(0, Math.min(moveX, barWidth)); + + sliderBtn.style.left = left + 'px'; + document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px'; + + // 记录轨迹 + if (!this.currentCaptchaData.trackList) { + this.currentCaptchaData.trackList = []; + } + this.currentCaptchaData.trackList.push({ + x: e.pageX, + y: e.pageY, + t: new Date().getTime() - this.currentCaptchaData.startTime.getTime() + }); + }); + + // 鼠标松开 + document.addEventListener('mouseup', () => { + if (!this.isDragging) return; + this.isDragging = false; + this.currentCaptchaData.stopTime = new Date(); + const sliderBtn = document.getElementById('tianai-captcha-slider-btn'); + this.currentCaptchaData.left = sliderBtn ? sliderBtn.offsetLeft : 0; + this.endCallback(this.currentCaptchaData, this); + }); + + // 触摸事件 + sliderBtn.addEventListener('touchstart', (e) => { + this.isDragging = true; + this.startX = e.touches[0].pageX; + this.currentCaptchaData.startTime = new Date(); + e.preventDefault(); + }); + + document.addEventListener('touchmove', (e) => { + if (!this.isDragging) return; + const moveX = e.touches[0].pageX - this.startX; + const sliderBar = document.getElementById('tianai-captcha-slider-bar'); + const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200; + const left = Math.max(0, Math.min(moveX, barWidth)); + + sliderBtn.style.left = left + 'px'; + document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px'; + }); + + document.addEventListener('touchend', () => { + if (!this.isDragging) return; + this.isDragging = false; + this.currentCaptchaData.stopTime = new Date(); + const sliderBtn = document.getElementById('tianai-captcha-slider-btn'); + this.currentCaptchaData.left = sliderBtn ? sliderBtn.offsetLeft : 0; + this.endCallback(this.currentCaptchaData, this); + }); + } + + destroy () { + const existsCaptchaEl = this.boxEl.children("#tianai-captcha"); + if (existsCaptchaEl) { + existsCaptchaEl.remove(); + } + destroyEvent(); + } + + loadCaptchaForData (that, data) { + const bgImg = that.el.find("#tianai-captcha-slider-bg-img"); + bgImg.on("load",() => { + that.currentCaptchaData = { + bgImageWidth: bgImg.width(), + bgImageHeight: bgImg.height(), + startTime: new Date(), + trackList: [], + curveData: data.data.curveData || [] + }; + that.currentCaptchaData.currentCaptchaId = data.data.id; + // 初始化Canvas + setTimeout(() => that.initCanvas(), 100); + }) + bgImg.attr("src", data.data.backgroundImage); + } +} + +export default CurveSlider; diff --git a/tianai-captcha-web-sdk/src/captcha/curve_slider/curve_slider.scss b/tianai-captcha-web-sdk/src/captcha/curve_slider/curve_slider.scss new file mode 100644 index 0000000..1bd54bd --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/curve_slider/curve_slider.scss @@ -0,0 +1,114 @@ +#tianai-captcha.tianai-captcha-curve-slider { + box-sizing: border-box; + + .click-tip { + position: relative; + height: 40px; + width: 100%; + display: flex; + align-items: center; + + #tianai-captcha-click-track-font { + font-size: 14px; + color: #333; + } + } + + .content { + .bg-img-div { + position: relative; + height: 180px; + overflow: hidden; + border-radius: 4px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .curve-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + } + + .curve-point { + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: #409eff; + border: 2px solid #fff; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + transform: translate(-50%, -50%); + display: none; + } + + .curve-end-point { + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: #ff4444; + border: 2px solid #fff; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + transform: translate(-50%, -50%); + display: none; + } + } + + .slider-bar { + position: relative; + height: 40px; + margin-top: 3px; + + .slider-bar-bg { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + border-radius: 4px; + background-color: #e8e8e8; + } + + .slider-bar-track { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: 4px; + background-image: linear-gradient(90deg, #409eff, #66b1ff); + transition: width 0.1s; + } + + .slider-bar-btn { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 40px; + border-radius: 4px; + background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%); + cursor: move; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + transition: transform 0.1s; + + .slider-btn-icon { + width: 0; + height: 0; + border-style: solid; + border-width: 6px 0 6px 8px; + border-color: transparent transparent transparent #fff; + } + } + } + } +} diff --git a/tianai-captcha-web-sdk/src/captcha/icon_click/icon_click.js b/tianai-captcha-web-sdk/src/captcha/icon_click/icon_click.js new file mode 100644 index 0000000..810962c --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/icon_click/icon_click.js @@ -0,0 +1,104 @@ +import "./icon_click.scss" +import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js" + +/** + * 图标点选验证码 + */ + +const TYPE = "ICON_CLICK" +function getTemplate(styleConfig) { + return ` +
+
+ ${styleConfig.i18n?.icon_click_title || '请依次点击图中的图标'} + +
+
+
+ + +
+
+
+
+
确定
+
+`; +} +class IconClick extends CommonCaptcha{ + constructor(boxEl, styleConfig) { + super(); + this.boxEl = boxEl; + this.styleConfig = styleConfig; + this.type = TYPE; + this.currentCaptchaData = {} + } + init(captchaData, endCallback, loadSuccessCallback) { + // 重载样式 + this.destroy(); + this.boxEl.append(getTemplate(this.styleConfig)); + this.el = this.boxEl.find("#tianai-captcha"); + // 载入验证码 + this.loadCaptchaForData(this, captchaData); + this.endCallback = endCallback; + const moveFun = move.bind(null, this); + // 绑定事件 + this.el.find("#bg-img-click-mask").click((event) => { + if(event.target.className === "click-span") { + return; + } + this.currentCaptchaData.clickCount++; + const trackList = this.currentCaptchaData.trackList; + if (this.currentCaptchaData.clickCount === 1) { + this.currentCaptchaData.startTime = new Date(); + // move 轨迹 + window.addEventListener("mousemove", moveFun); + this.currentCaptchaData.startX = event.offsetX; + this.currentCaptchaData.startY = event.offsetY; + } + const startTime = this.currentCaptchaData.startTime; + trackList.push({ + x: Math.round(event.offsetX), + y: Math.round(event.offsetY), + type: "click", + t: (new Date().getTime() - startTime.getTime()) + }); + const left = event.offsetX - 12; + const top = event.offsetY - 12; + this.el.find("#bg-img-click-mask").append("" + this.currentCaptchaData.clickCount + "") + }); + this.el.find(".click-confirm-btn").click(() => { + if (this.currentCaptchaData.clickCount > 0) { + // 校验 + this.currentCaptchaData.stopTime = new Date(); + window.removeEventListener("mousemove", moveFun); + this.endCallback(this.currentCaptchaData,this); + } + }); + + if (loadSuccessCallback) { + // 加载成功 + loadSuccessCallback(this); + } + return this; + } + destroy () { + const existsCaptchaEl = this.boxEl.children("#tianai-captcha"); + if (existsCaptchaEl) { + existsCaptchaEl.remove(); + } + destroyEvent(); + } + loadCaptchaForData (that, data) { + const bgImg = that.el.find("#tianai-captcha-slider-bg-img"); + const tipImg = that.el.find("#tianai-captcha-tip-img"); + bgImg.on("load",() => { + that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), tipImg.width(), tipImg.height()); + that.currentCaptchaData.currentCaptchaId = data.data.id; + }) + bgImg.attr("src", data.data.backgroundImage); + tipImg.attr("src", data.data.templateImage); + } +} + +export default IconClick; diff --git a/tianai-captcha-web-sdk/src/captcha/icon_click/icon_click.scss b/tianai-captcha-web-sdk/src/captcha/icon_click/icon_click.scss new file mode 100644 index 0000000..8934b13 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/icon_click/icon_click.scss @@ -0,0 +1,75 @@ +#tianai-captcha.tianai-captcha-icon-click { + box-sizing: border-box; + + .click-tip { + position: relative; + height: 40px; + width: 100%; + .tip-img { + height: 35px; + position: absolute; + right: 15px; + } + #tianai-captcha-click-track-font { + font-size: 14px; + display: inline-block; + height: 40px; + line-height: 40px; + position: absolute; + color: #333; + } + } + + .slider-bottom { + position: relative; + top: 6px; + } + + .content { + #bg-img-click-mask { + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; + cursor: pointer; + + .click-span { + position: absolute; + left: 0; + top: 0; + border-radius: 50%; + background-color: #409eff; + width: 24px; + height: 24px; + text-align: center; + line-height: 24px; + color: #fff; + border: 2px solid #fff; + box-sizing: content-box; + font-size: 12px; + font-weight: bold; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + } + } + } + + .click-confirm-btn { + width: 100%; + height: 35px; + border-radius: 4px; + background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%); + font-size: 15px; + text-align: center; + box-sizing: border-box; + line-height: 35px; + color: #fff; + margin-top: 3px; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + } +} diff --git a/tianai-captcha-web-sdk/src/captcha/jigsaw/jigsaw.js b/tianai-captcha-web-sdk/src/captcha/jigsaw/jigsaw.js new file mode 100644 index 0000000..5217205 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/jigsaw/jigsaw.js @@ -0,0 +1,111 @@ +import "./jigsaw.scss" +import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js" + +/** + * 乱序拼图验证码 - 用户需要拖动拼图块到正确位置 + */ + +const TYPE = "JIGSAW" +function getTemplate(styleConfig) { + return ` +
+
+ ${styleConfig.i18n?.jigsaw_title || '拖动拼图块到正确位置'} +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; +} +class Jigsaw extends CommonCaptcha{ + constructor(boxEl, styleConfig) { + super(); + this.boxEl = boxEl; + this.styleConfig = styleConfig; + this.type = TYPE; + this.currentCaptchaData = {}; + } + init(captchaData, endCallback, loadSuccessCallback) { + // 重载样式 + this.destroy(); + this.boxEl.append(getTemplate(this.styleConfig)); + this.el = this.boxEl.find("#tianai-captcha"); + // 载入验证码 + this.loadCaptchaForData(this, captchaData); + this.endCallback = endCallback; + const moveFun = move.bind(null, this); + + // 绑定事件 + this.el.find("#tianai-captcha-click-mask-btn").mousedown((event) => { + this.currentCaptchaData.moveFlag = true; + this.currentCaptchaData.startTime = new Date(); + // move 轨迹 + window.addEventListener("mousemove", moveFun); + this.currentCaptchaData.startX = event.pageX; + this.currentCaptchaData.startY = event.pageY; + this.el.find("#tianai-captcha-click-mask-btn").css("transition", "none"); + }); + window.addEventListener("mouseup", (event) => { + if (!this.currentCaptchaData.moveFlag) { + return; + } + this.currentCaptchaData.moveFlag = false; + this.currentCaptchaData.stopTime = new Date(); + window.removeEventListener("mousemove", moveFun); + const left = this.el.find("#tianai-captcha-slider-btn").css("left") ? this.el.find("#tianai-captcha-slider-btn").css("left").split("px")[0] : 0; + this.currentCaptchaData.left = left; + this.endCallback(this.currentCaptchaData,this); + }); + + if (loadSuccessCallback) { + // 加载成功 + loadSuccessCallback(this); + } + return this; + } + + destroy () { + const existsCaptchaEl = this.boxEl.children("#tianai-captcha"); + if (existsCaptchaEl) { + existsCaptchaEl.remove(); + } + destroyEvent(); + } + + loadCaptchaForData (that, data) { + const bgImg = that.el.find("#tianai-captcha-slider-bg-img"); + bgImg.on("load",() => { + that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), data.data.blockSize, data.data.blockSize); + that.currentCaptchaData.currentCaptchaId = data.data.id; + // 设置拼图块位置 + that.el.find("#jigsaw-block").css({ + "width": data.data.blockSize + "px", + "height": data.data.blockSize + "px", + "left": data.data.blockPositionX + "px", + "top": data.data.blockPositionY + "px", + "background-image": "url(" + data.data.blockImage + ")", + "background-size": bgImg.width() + "px " + bgImg.height() + "px", + "background-position": "-" + data.data.blockPositionX + "px -" + data.data.blockPositionY + "px" + }); + that.el.find("#tianai-captcha-slider-track").css("height", that.el.find("#tianai-captcha-slider-bar").outerHeight() + "px"); + }) + bgImg.attr("src", data.data.backgroundImage); + } +} + +export default Jigsaw; diff --git a/tianai-captcha-web-sdk/src/captcha/jigsaw/jigsaw.scss b/tianai-captcha-web-sdk/src/captcha/jigsaw/jigsaw.scss new file mode 100644 index 0000000..f20af30 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/jigsaw/jigsaw.scss @@ -0,0 +1,114 @@ +#tianai-captcha.tianai-captcha-jigsaw { + box-sizing: border-box; + + .click-tip { + position: relative; + height: 40px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + + #tianai-captcha-click-track-font { + font-size: 14px; + color: #333; + } + } + + .content { + position: relative; + + .bg-img-div { + position: relative; + height: 180px; + overflow: hidden; + border-radius: 4px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .jigsaw-block { + position: absolute; + cursor: move; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + border-radius: 4px; + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + } + } + } + + .slider-bar { + position: relative; + height: 40px; + margin-top: 3px; + + .slider-bar-bg { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + border-radius: 4px; + background-color: #e8e8e8; + } + + .slider-bar-track { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: 4px; + background-image: linear-gradient(90deg, #409eff, #66b1ff); + transition: width 0.1s; + } + + .slider-bar-btn { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 40px; + border-radius: 4px; + background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%); + cursor: move; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + transition: transform 0.1s; + + .slider-btn-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + + &::after { + content: ''; + display: block; + width: 0; + height: 0; + border-style: solid; + border-width: 6px 0 6px 8px; + border-color: transparent transparent transparent #fff; + } + } + + .click-mask-btn { + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + } + } + } + } +} diff --git a/tianai-captcha-web-sdk/src/captcha/proof_of_work/proof_of_work.js b/tianai-captcha-web-sdk/src/captcha/proof_of_work/proof_of_work.js new file mode 100644 index 0000000..c50daa1 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/proof_of_work/proof_of_work.js @@ -0,0 +1,196 @@ +import "./proof_of_work.scss" +import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js" + +/** + * 工作量证明验证码 - 用户需要完成计算工作 + */ + +const TYPE = "PROOF_OF_WORK" +function getTemplate(styleConfig) { + return ` +
+
+ ${styleConfig.i18n?.proof_of_work_title || '请完成工作量证明'} +
+
+
+
+
+ 挑战: + - +
+
+ 难度: + - +
+
+
+
+ 0% +
+
等待开始...
+
+
+ +
+
+
+
+`; +} +class ProofOfWork extends CommonCaptcha{ + constructor(boxEl, styleConfig) { + super(); + this.boxEl = boxEl; + this.styleConfig = styleConfig; + this.type = TYPE; + this.currentCaptchaData = {}; + this.isComputing = false; + this.worker = null; + } + init(captchaData, endCallback, loadSuccessCallback) { + // 重载样式 + this.destroy(); + this.boxEl.append(getTemplate(this.styleConfig)); + this.el = this.boxEl.find("#tianai-captcha"); + // 载入验证码 + this.loadCaptchaForData(this, captchaData); + this.endCallback = endCallback; + + if (loadSuccessCallback) { + // 加载成功 + loadSuccessCallback(this); + } + return this; + } + + bindEvents() { + const startBtn = document.getElementById('pow-start-btn'); + if (!startBtn) return; + + startBtn.addEventListener('click', () => { + if (this.isComputing) return; + this.startComputation(); + }); + } + + startComputation() { + this.isComputing = true; + this.currentCaptchaData.startTime = new Date(); + + const startBtn = document.getElementById('pow-start-btn'); + if (startBtn) { + startBtn.disabled = true; + startBtn.textContent = '计算中...'; + } + + // 更新状态 + this.updateStatus('正在计算工作量证明...'); + + // 模拟计算过程(实际应用中应使用Web Worker) + this.simulateComputation(); + } + + simulateComputation() { + const challenge = this.currentCaptchaData.challenge || 'demo'; + const difficulty = this.currentCaptchaData.difficulty || 4; + let nonce = 0; + const target = '0'.repeat(difficulty); + + const compute = () => { + if (!this.isComputing) return; + + // 简单的SHA-256模拟(实际应用中应使用真实的SHA-256) + const hash = this.simpleHash(challenge + nonce); + + // 更新进度 + const progress = Math.min(99, (nonce / 10000) * 100); + this.updateProgress(progress); + + if (hash.startsWith(target)) { + // 找到解决方案 + this.currentCaptchaData.nonce = nonce; + this.currentCaptchaData.hash = hash; + this.currentCaptchaData.stopTime = new Date(); + this.updateProgress(100); + this.updateStatus('计算完成!'); + this.endCallback(this.currentCaptchaData, this); + } else { + nonce++; + if (nonce % 100 === 0) { + // 每100次更新一次UI + setTimeout(compute, 0); + } else { + compute(); + } + } + }; + + compute(); + } + + simpleHash(str) { + // 简单的哈希函数(仅用于演示) + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + // 转换为16进制字符串 + return Math.abs(hash).toString(16).padStart(8, '0'); + } + + updateProgress(percent) { + const progressBar = document.getElementById('pow-progress-bar'); + const progressText = document.getElementById('pow-progress-text'); + if (progressBar) { + progressBar.style.width = percent + '%'; + } + if (progressText) { + progressText.textContent = Math.round(percent) + '%'; + } + } + + updateStatus(text) { + const statusEl = document.getElementById('pow-status'); + if (statusEl) { + statusEl.textContent = text; + } + } + + destroy () { + this.isComputing = false; + const existsCaptchaEl = this.boxEl.children("#tianai-captcha"); + if (existsCaptchaEl) { + existsCaptchaEl.remove(); + } + destroyEvent(); + } + + loadCaptchaForData (that, data) { + that.currentCaptchaData = { + startTime: new Date(), + challenge: data.data.challenge || 'demo_challenge', + difficulty: data.data.difficulty || 4, + nonce: 0, + hash: '' + }; + that.currentCaptchaData.currentCaptchaId = data.data.id; + + // 更新UI + const challengeValue = document.getElementById('pow-challenge-value'); + const difficultyValue = document.getElementById('pow-difficulty-value'); + if (challengeValue) { + challengeValue.textContent = that.currentCaptchaData.challenge.substring(0, 20) + '...'; + } + if (difficultyValue) { + difficultyValue.textContent = that.currentCaptchaData.difficulty; + } + + // 绑定事件 + setTimeout(() => that.bindEvents(), 100); + } +} + +export default ProofOfWork; diff --git a/tianai-captcha-web-sdk/src/captcha/proof_of_work/proof_of_work.scss b/tianai-captcha-web-sdk/src/captcha/proof_of_work/proof_of_work.scss new file mode 100644 index 0000000..030c4fe --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/proof_of_work/proof_of_work.scss @@ -0,0 +1,104 @@ +#tianai-captcha.tianai-captcha-proof-of-work { + box-sizing: border-box; + + .click-tip { + position: relative; + height: 40px; + width: 100%; + display: flex; + align-items: center; + + #tianai-captcha-click-track-font { + font-size: 14px; + color: #333; + } + } + + .content { + .pow-container { + background-color: #f5f5f5; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + + .pow-info { + display: flex; + justify-content: space-between; + margin-bottom: 12px; + + .pow-challenge, + .pow-difficulty { + font-size: 12px; + color: #666; + + .label { + margin-right: 4px; + } + + .value { + color: #333; + font-family: monospace; + } + } + } + + .pow-progress { + position: relative; + height: 20px; + background-color: #e8e8e8; + border-radius: 10px; + overflow: hidden; + margin-bottom: 8px; + + .progress-bar { + height: 100%; + background-image: linear-gradient(90deg, #409eff, #66b1ff); + transition: width 0.3s; + border-radius: 10px; + } + + .progress-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 11px; + color: #333; + font-weight: bold; + } + } + + .pow-status { + font-size: 12px; + color: #666; + text-align: center; + } + } + + .pow-actions { + .pow-btn { + width: 100%; + height: 35px; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + + &.start-btn { + background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%); + color: #fff; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + } + } +} diff --git a/tianai-captcha-web-sdk/src/captcha/scratch/scratch.js b/tianai-captcha-web-sdk/src/captcha/scratch/scratch.js new file mode 100644 index 0000000..e4788bf --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/scratch/scratch.js @@ -0,0 +1,181 @@ +import "./scratch.scss" +import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js" + +/** + * 刮刮乐验证码 - 用户需要刮开涂层 + */ + +const TYPE = "SCRATCH" +function getTemplate(styleConfig) { + return ` +
+
+ ${styleConfig.i18n?.scratch_title || '请刮开涂层完成验证'} + 0% +
+
+
+ + +
+
+
+
确定
+
+`; +} +class Scratch extends CommonCaptcha{ + constructor(boxEl, styleConfig) { + super(); + this.boxEl = boxEl; + this.styleConfig = styleConfig; + this.type = TYPE; + this.currentCaptchaData = {}; + this.isDrawing = false; + this.scratchPercentage = 0; + } + init(captchaData, endCallback, loadSuccessCallback) { + // 重载样式 + this.destroy(); + this.boxEl.append(getTemplate(this.styleConfig)); + this.el = this.boxEl.find("#tianai-captcha"); + // 载入验证码 + this.loadCaptchaForData(this, captchaData); + this.endCallback = endCallback; + + if (loadSuccessCallback) { + // 加载成功 + loadSuccessCallback(this); + } + return this; + } + + initCanvas() { + const canvas = document.getElementById('tianai-captcha-scratch-canvas'); + const container = document.getElementById('scratch-container'); + if (!canvas || !container) return; + + const ctx = canvas.getContext('2d'); + const rect = container.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + + // 绘制灰色遮罩层 + ctx.fillStyle = '#c0c0c0'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // 绘制提示文字 + ctx.fillStyle = '#999'; + ctx.font = '14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('请用鼠标刮开此区域', canvas.width / 2, canvas.height / 2); + + // 绑定刮擦事件 + this.bindScratchEvents(canvas, ctx); + } + + bindScratchEvents(canvas, ctx) { + const scratch = (e) => { + if (!this.isDrawing) return; + const rect = canvas.getBoundingClientRect(); + let x, y; + if (e.touches) { + x = e.touches[0].clientX - rect.left; + y = e.touches[0].clientY - rect.top; + } else { + x = e.offsetX; + y = e.offsetY; + } + + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + ctx.arc(x, y, 15, 0, Math.PI * 2); + ctx.fill(); + + this.calculateScratchPercentage(canvas, ctx); + }; + + // 鼠标事件 + canvas.addEventListener('mousedown', (e) => { + this.isDrawing = true; + this.currentCaptchaData.startTime = new Date(); + scratch(e); + }); + canvas.addEventListener('mousemove', scratch); + canvas.addEventListener('mouseup', () => { + this.isDrawing = false; + }); + canvas.addEventListener('mouseleave', () => { + this.isDrawing = false; + }); + + // 触摸事件 + canvas.addEventListener('touchstart', (e) => { + this.isDrawing = true; + this.currentCaptchaData.startTime = new Date(); + e.preventDefault(); + scratch(e); + }); + canvas.addEventListener('touchmove', (e) => { + e.preventDefault(); + scratch(e); + }); + canvas.addEventListener('touchend', () => { + this.isDrawing = false; + }); + } + + calculateScratchPercentage(canvas, ctx) { + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; + let transparent = 0; + const total = pixels.length / 4; + + for (let i = 3; i < pixels.length; i += 4) { + if (pixels[i] === 0) { + transparent++; + } + } + + this.scratchPercentage = Math.round((transparent / total) * 100); + const progressEl = this.el.find("#scratch-progress"); + progressEl.text(this.scratchPercentage + '%'); + + // 超过60%自动完成 + if (this.scratchPercentage >= 60 && !this.currentCaptchaData.verified) { + this.currentCaptchaData.verified = true; + this.currentCaptchaData.stopTime = new Date(); + this.currentCaptchaData.scratchPercentage = this.scratchPercentage; + this.endCallback(this.currentCaptchaData, this); + } + } + + destroy () { + const existsCaptchaEl = this.boxEl.children("#tianai-captcha"); + if (existsCaptchaEl) { + existsCaptchaEl.remove(); + } + destroyEvent(); + } + + loadCaptchaForData (that, data) { + const bgImg = that.el.find("#tianai-captcha-scratch-bg-img"); + bgImg.on("load",() => { + that.currentCaptchaData = { + bgImageWidth: bgImg.width(), + bgImageHeight: bgImg.height(), + startTime: new Date(), + trackList: [], + verified: false, + scratchPercentage: 0 + }; + that.currentCaptchaData.currentCaptchaId = data.data.id; + // 初始化Canvas + setTimeout(() => that.initCanvas(), 100); + }) + bgImg.attr("src", data.data.backgroundImage); + } +} + +export default Scratch; diff --git a/tianai-captcha-web-sdk/src/captcha/scratch/scratch.scss b/tianai-captcha-web-sdk/src/captcha/scratch/scratch.scss new file mode 100644 index 0000000..4189743 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/scratch/scratch.scss @@ -0,0 +1,73 @@ +#tianai-captcha.tianai-captcha-scratch { + box-sizing: border-box; + + .scratch-tip { + position: relative; + height: 40px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + + #tianai-captcha-scratch-track-font { + font-size: 14px; + color: #333; + } + + .scratch-progress { + font-size: 13px; + color: #409eff; + font-weight: bold; + } + } + + .slider-bottom { + position: relative; + top: 6px; + } + + .content { + .scratch-container { + width: 100%; + height: 180px; + position: relative; + overflow: hidden; + border-radius: 4px; + + .scratch-bg-img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .scratch-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: pointer; + touch-action: none; + } + } + } + + .click-confirm-btn { + width: 100%; + height: 35px; + border-radius: 4px; + background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%); + font-size: 15px; + text-align: center; + box-sizing: border-box; + line-height: 35px; + color: #fff; + margin-top: 3px; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + } +} diff --git a/tianai-captcha-web-sdk/src/captcha/word_order_click/word_order_click.js b/tianai-captcha-web-sdk/src/captcha/word_order_click/word_order_click.js new file mode 100644 index 0000000..5d35174 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/word_order_click/word_order_click.js @@ -0,0 +1,133 @@ +import "./word_order_click.scss" +import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js" + +/** + * 语序点选验证码 - 用户需要按顺序点击文字 + */ + +const TYPE = "WORD_ORDER_CLICK" +function getTemplate(styleConfig) { + return ` +
+
+ ${styleConfig.i18n?.word_order_click_title || '请按顺序点击文字'} +
+
+
+
+ + +
+
+
+
+
确定
+
+`; +} +class WordOrderClick extends CommonCaptcha{ + constructor(boxEl, styleConfig) { + super(); + this.boxEl = boxEl; + this.styleConfig = styleConfig; + this.type = TYPE; + this.currentCaptchaData = {} + } + init(captchaData, endCallback, loadSuccessCallback) { + // 重载样式 + this.destroy(); + this.boxEl.append(getTemplate(this.styleConfig)); + this.el = this.boxEl.find("#tianai-captcha"); + // 载入验证码 + this.loadCaptchaForData(this, captchaData); + this.endCallback = endCallback; + const moveFun = move.bind(null, this); + + // 初始化点击顺序提示 + this.updateOrderHint(); + + // 绑定事件 + this.el.find("#bg-img-click-mask").click((event) => { + if(event.target.className === "click-span") { + return; + } + this.currentCaptchaData.clickCount++; + const trackList = this.currentCaptchaData.trackList; + if (this.currentCaptchaData.clickCount === 1) { + this.currentCaptchaData.startTime = new Date(); + // move 轨迹 + window.addEventListener("mousemove", moveFun); + this.currentCaptchaData.startX = event.offsetX; + this.currentCaptchaData.startY = event.offsetY; + } + const startTime = this.currentCaptchaData.startTime; + trackList.push({ + x: Math.round(event.offsetX), + y: Math.round(event.offsetY), + type: "click", + t: (new Date().getTime() - startTime.getTime()) + }); + const left = event.offsetX - 12; + const top = event.offsetY - 12; + this.el.find("#bg-img-click-mask").append("" + this.currentCaptchaData.clickCount + "") + this.updateOrderHint(); + }); + + // 重新开始按钮 + this.el.find(".click-reset-btn").click(() => { + this.currentCaptchaData.clickCount = 0; + this.currentCaptchaData.trackList = []; + this.el.find("#bg-img-click-mask").find(".click-span").remove(); + this.updateOrderHint(); + }); + + this.el.find(".click-confirm-btn").click(() => { + if (this.currentCaptchaData.clickCount > 0) { + // 校验 + this.currentCaptchaData.stopTime = new Date(); + window.removeEventListener("mousemove", moveFun); + this.endCallback(this.currentCaptchaData,this); + } + }); + + if (loadSuccessCallback) { + // 加载成功 + loadSuccessCallback(this); + } + return this; + } + + updateOrderHint() { + const hintEl = this.el.find("#word-order-hint"); + const count = this.currentCaptchaData.clickCount || 0; + const maxCount = this.currentCaptchaData.maxClickCount || 4; + let html = ''; + for (let i = 1; i <= maxCount; i++) { + if (i <= count) { + html += '' + i + ''; + } else { + html += '' + i + ''; + } + } + hintEl.html(html); + } + + destroy () { + const existsCaptchaEl = this.boxEl.children("#tianai-captcha"); + if (existsCaptchaEl) { + existsCaptchaEl.remove(); + } + destroyEvent(); + } + loadCaptchaForData (that, data) { + const bgImg = that.el.find("#tianai-captcha-slider-bg-img"); + bgImg.on("load",() => { + that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), 0, 0); + that.currentCaptchaData.currentCaptchaId = data.data.id; + that.currentCaptchaData.maxClickCount = data.data.maxClickCount || 4; + }) + bgImg.attr("src", data.data.backgroundImage); + } +} + +export default WordOrderClick; diff --git a/tianai-captcha-web-sdk/src/captcha/word_order_click/word_order_click.scss b/tianai-captcha-web-sdk/src/captcha/word_order_click/word_order_click.scss new file mode 100644 index 0000000..9f32cc9 --- /dev/null +++ b/tianai-captcha-web-sdk/src/captcha/word_order_click/word_order_click.scss @@ -0,0 +1,105 @@ +#tianai-captcha.tianai-captcha-word-order-click { + box-sizing: border-box; + + .click-tip { + position: relative; + height: 40px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + + #tianai-captcha-click-track-font { + font-size: 14px; + color: #333; + } + + .word-order-hint { + display: flex; + gap: 4px; + + .hint-dot { + width: 20px; + height: 20px; + border-radius: 50%; + background-color: #e0e0e0; + color: #999; + font-size: 11px; + line-height: 20px; + text-align: center; + transition: all 0.2s; + + &.active { + background-color: #409eff; + color: #fff; + } + } + } + } + + .slider-bottom { + position: relative; + top: 6px; + } + + .content { + #bg-img-click-mask { + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; + cursor: pointer; + + .click-span { + position: absolute; + left: 0; + top: 0; + border-radius: 50%; + background-color: #409eff; + width: 24px; + height: 24px; + text-align: center; + line-height: 24px; + color: #fff; + border: 2px solid #fff; + box-sizing: content-box; + font-size: 12px; + font-weight: bold; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + } + } + } + + .click-confirm-btn { + width: 100%; + height: 35px; + border-radius: 4px; + background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%); + font-size: 15px; + text-align: center; + box-sizing: border-box; + line-height: 35px; + color: #fff; + margin-top: 3px; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + } + + .click-reset-btn { + position: absolute; + right: 0; + top: 0; + font-size: 12px; + color: #409eff; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } +}