feat: Phase 3-5 管理后台前端+统计监控+部署配置
deploy-test / build-and-deploy-test (push) Successful in 2m41s

Phase 3: 管理后台前端 (Vue3+Vite+Element Plus)
- 登录页面/JWT认证/Pinia状态管理
- 首页大盘/站点管理/验证码日志/IP黑名单/套餐管理/系统设置
- 7个页面完整路由+API对接

Phase 4: 统计监控模块
- CaptchaLogAspect AOP自动记录
- RealtimeStatsService Redis实时计数
- HistoryStatsService 历史查询
- AnomalyDetectionService 异常检测

Phase 5: 部署和测试
- docker-compose.yml 5服务编排
- application-prod.yml 外部化配置
- CaptchaApiIntegrationTest 集成测试
- 前端 Dockerfile + nginx.conf
This commit is contained in:
abcv7
2026-08-26 13:44:08 +08:00
parent d1cd371061
commit 1164b51c64
67 changed files with 5510 additions and 829 deletions
@@ -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:
+40
View File
@@ -51,6 +51,46 @@
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
+233
View File
@@ -0,0 +1,233 @@
DROP TABLE IF EXISTS verification_logs CASCADE;
DROP TABLE IF EXISTS track_samples CASCADE;
DROP TABLE IF EXISTS captcha_challenges CASCADE;
DROP TABLE IF EXISTS captcha_categories CASCADE;
DROP TABLE IF EXISTS sites CASCADE;
DROP TABLE IF EXISTS plans CASCADE;
DROP TABLE IF EXISTS users CASCADE;
DROP TABLE IF EXISTS announcements CASCADE;
DROP TABLE IF EXISTS captcha_ip_blacklist CASCADE;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(128),
password VARCHAR(256) NOT NULL,
role VARCHAR(16) DEFAULT 'USER',
site_amount INT DEFAULT 0,
is_enabled BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE plans (
id SERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
description VARCHAR(256),
qps INT DEFAULT 10,
daily_limit INT DEFAULT 500,
captcha_types TEXT[] DEFAULT '{SLIDER,PUZZLE,TEXT_CLICK,ICON_CLICK,ICON_UNDERSTAND}',
custom_style BOOLEAN DEFAULT FALSE,
is_enabled BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE sites (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
name VARCHAR(64) NOT NULL,
domain VARCHAR(256),
favicon VARCHAR(512),
logo VARCHAR(512),
site_key UUID DEFAULT gen_random_uuid() UNIQUE,
secret_key UUID DEFAULT gen_random_uuid() UNIQUE,
verify_level VARCHAR(16) DEFAULT 'MEDIUM',
qps INT DEFAULT 10,
daily_limit INT DEFAULT 500,
captcha_types TEXT[] DEFAULT '{SLIDER,PUZZLE,TEXT_CLICK,ICON_CLICK,ICON_UNDERSTAND}',
plan_id INT REFERENCES plans(id),
plan_expire_at TIMESTAMPTZ,
is_enabled BOOLEAN DEFAULT TRUE,
rsa_public_key TEXT,
rsa_private_key TEXT,
aes_key VARCHAR(256),
signing_key VARCHAR(256),
track_validation_enabled BOOLEAN DEFAULT TRUE,
track_human_threshold FLOAT DEFAULT 0.5,
obfuscation_enabled BOOLEAN DEFAULT TRUE,
encryption_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
label VARCHAR(64) NOT NULL,
items TEXT[] NOT NULL DEFAULT '{}',
is_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_challenges (
id SERIAL PRIMARY KEY,
category_id INT REFERENCES captcha_categories(id) ON DELETE CASCADE,
prompt TEXT NOT NULL,
correct_items TEXT[] NOT NULL DEFAULT '{}',
difficulty VARCHAR(16) DEFAULT 'MEDIUM',
is_enabled BOOLEAN DEFAULT TRUE,
use_count INT DEFAULT 0,
success_rate DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE verification_logs (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES sites(id),
site_key UUID,
captcha_type VARCHAR(32),
scene VARCHAR(64) DEFAULT 'default',
ip VARCHAR(45),
is_pass BOOLEAN,
behavior_score DOUBLE PRECISION,
risk_level VARCHAR(16),
cost_time INT,
captcha_id VARCHAR(128),
user_agent TEXT,
track_score FLOAT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE track_samples (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES sites(id),
captcha_type VARCHAR(32),
is_human BOOLEAN,
ml_score DOUBLE PRECISION,
basic_pass BOOLEAN,
total_points INT,
total_duration BIGINT,
displacement_x FLOAT,
displacement_y FLOAT,
displacement_x_ratio FLOAT,
total_path_length DOUBLE PRECISION,
path_efficiency DOUBLE PRECISION,
avg_speed FLOAT,
max_speed FLOAT,
min_speed FLOAT,
speed_variance DOUBLE PRECISION,
speed_std_dev DOUBLE PRECISION,
speed_skewness DOUBLE PRECISION,
avg_acceleration FLOAT,
max_acceleration FLOAT,
min_acceleration FLOAT,
acceleration_variance DOUBLE PRECISION,
direction_changes INT,
y_direction_changes INT,
pauses INT,
start_offset DOUBLE PRECISION,
straightness DOUBLE PRECISION,
x_uniformity DOUBLE PRECISION,
y_uniformity DOUBLE PRECISION,
avg_point_interval FLOAT,
speed_phase_correlation DOUBLE PRECISION,
max_jump_distance DOUBLE PRECISION,
overshoot_ratio DOUBLE PRECISION,
track_json TEXT,
ip VARCHAR(45),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(256) NOT NULL,
content TEXT,
is_pinned BOOLEAN DEFAULT FALSE,
is_published BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_ip_blacklist (
id SERIAL PRIMARY KEY,
ip VARCHAR(64) NOT NULL,
reason VARCHAR(256),
ban_until TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_sites_key ON sites(site_key);
CREATE INDEX idx_sites_user ON sites(user_id);
CREATE INDEX idx_challenges_category ON captcha_challenges(category_id);
CREATE INDEX idx_challenges_difficulty ON captcha_challenges(difficulty);
CREATE INDEX idx_logs_site ON verification_logs(site_id);
CREATE INDEX idx_logs_time ON verification_logs(created_at);
CREATE INDEX idx_logs_type ON verification_logs(captcha_type);
CREATE INDEX idx_logs_pass ON verification_logs(is_pass);
CREATE INDEX idx_ip_blacklist_ip ON captcha_ip_blacklist(ip);
CREATE INDEX idx_track_human ON track_samples(is_human);
CREATE INDEX idx_track_type ON track_samples(captcha_type);
CREATE INDEX idx_track_time ON track_samples(created_at);
CREATE INDEX idx_track_score ON track_samples(ml_score);
INSERT INTO users (username, email, password, role, site_amount, is_enabled, is_system)
VALUES ('admin', 'admin@captchaforge.local', '$2a$10$85QOiy3qD5KPuxyrrN/LcuZe3ty/OrTk6yEKAJEGtJKk97ukw2yPG', 'ADMIN', 999, TRUE, TRUE);
INSERT INTO plans (name, description, qps, daily_limit, is_enabled, is_system) VALUES
('免费套餐', '默认免费套餐', 5, 500, TRUE, TRUE),
('基础套餐', '适合中小站点', 20, 5000, TRUE, TRUE),
('专业套餐', '适合大型站点', 100, 50000, TRUE, TRUE);
INSERT INTO captcha_categories (name, label, items) VALUES
('animals', '动物', '{"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐔","🐧","🐦","🦅","🦆","🦉","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳","🐊","🐘","🦏","🐪","🦒","🐕","🐈","🐓","🦃","🦚","🦜","🐇","🦔","🐺","🐗"}'),
('food', '食物饮品', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🌽","🥕","🍞","🧀","🍳","🍔","🍟","🍕","🌮","🍣","🍜","🍩","🍪","🎂","🍫","🍭","☕","🍵","🧃","🥤","🍺","🍷"}'),
('vehicles', '交通工具', '{"🚗","🚕","🚙","🚌","🏎","🚓","🚑","🚒","🚐","🚚","🚛","🚜","🚲","🛵","🏍","🚨","🚔","🚡","🚠","🚃","🚋","🚄","🚅","🚂","✈️","🛩","🚀","🛸","🚁","⛵","🚤","🛳","⛴","🚢"}'),
('nature', '天气自然', '{"☀️","🌤","⛅","🌥","☁️","🌦","🌧","⛈","🌩","🌨","❄️","☃️","🌪","🌈","🌊","💧","🔥","⭐","🌟","✨","⚡","☄️","🌸","🌺","🌻","🌹","🌷","🌱","🌿","🍀","🍁","🍂","🍃","🌴","🌵"}'),
('sports', '运动娱乐', '{"⚽","🏀","🏈","⚾","🎾","🏐","🏉","🎱","🏓","🏸","🏒","🏑","🥍","🎯","🎳","🎮","🎲","♟","🧩","🪀","🪁","🎪","🤹","🎭","🎨","🎬","🎤","🎧","🎹","🥁","🎸","🎻"}'),
('buildings', '建筑地点', '{"🏠","🏡","🏢","🏣","🏤","🏥","🏦","🏨","🏩","🏪","🏫","🏬","🏭","🏯","🏰","💒","🗼","🗽","⛪","🕌","🛕","🕍","⛩","🕋","⛲","⛺","🏕"}'),
('objects', '电子物品', '{"⌚","📱","💻","⌨️","🖥","🖨","🖱","🖲","💾","💿","📷","📹","🎥","📞","📺","📻","🔋","🔌","💡","🔦","🕯","🔑","🔒","🔓","📧","📮","📦","📋","📁","✏️","🖊","🖋","✒️","🖌","📝","🔍","📎","📐","📌","✂️","🧲","🔧","🔨","⚙️","💊","💉","🩺","🧬","🔭","🔬","🧪"}'),
('gestures', '手势动作', '{"👋","🤚","🖐","✋","🖖","👌","🤌","🤏","✌","🤞","🤟","🤘","🤙","👈","👉","👆","👇","☝️","👍","👎","✊","👊","🤛","🤜","👏","🙌","👐","🤲","🤝","🙏","✍️","💅","🤳","💪"}');
INSERT INTO captcha_challenges (category_id, prompt, correct_items, difficulty) VALUES
(1, '请点击所有的猫科动物', '{"🐱","🐯","🦁","🐈"}', 'MEDIUM'),
(1, '请点击所有的犬科动物', '{"🐶","🐺","🐕"}', 'MEDIUM'),
(1, '请点击所有的鸟类', '{"🐔","🐧","🐦","🦅","🦆","🦉","🦜","🦚","🦃"}', 'MEDIUM'),
(1, '请点击所有的水生动物', '{"🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳"}', 'MEDIUM'),
(1, '请点击所有的昆虫', '{"🐝","🐛","🦋","🐌","🐞","🦟"}', 'MEDIUM'),
(2, '请点击所有的水果', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥝"}', 'MEDIUM'),
(2, '请点击所有的蔬菜', '{"🍅","🍆","🥑","🥦","🌽","🥕"}', 'MEDIUM'),
(2, '请点击所有的饮品', '{"☕","🍵","🧃","🥤","🍺","🍷"}', 'MEDIUM'),
(2, '请点击所有的甜点', '{"🍩","🍪","🎂","🍫","🍭"}', 'MEDIUM'),
(3, '请点击所有的汽车', '{"🚗","🚕","🚙","🏎","🚓","🚑","🚒","🚐"}', 'MEDIUM'),
(3, '请点击所有的飞行器', '{"✈️","🛩","🚀","🛸","🚁"}', 'MEDIUM'),
(3, '请点击所有的船只', '{"⛵","🚤","🛳","⛴","🚢"}', 'MEDIUM'),
(4, '请点击所有与降水相关的', '{"🌦","🌧","⛈","🌩","🌨"}', 'MEDIUM'),
(4, '请点击所有的花卉', '{"🌸","🌺","🌻","🌹","🌷"}', 'MEDIUM'),
(4, '请点击所有的天体', '{"☀️","⭐","🌟","✨","⚡","☄️"}', 'MEDIUM'),
(5, '请点击所有的球类运动', '{"⚽","🏀","🏈","⚾","🎾","🏐","🏉"}', 'MEDIUM'),
(5, '请点击所有的音乐相关', '{"🎤","🎧","🎹","🥁","🎸","🎻"}', 'MEDIUM'),
(5, '请点击所有的棋牌游戏', '{"🎲","♟","🧩","🎮","🎯","🎱"}', 'MEDIUM'),
(1, '请点击所有的动物', '{"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐔","🐧","🐦","🦅","🦆","🦉","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳","🐊","🐘","🦏","🐪","🦒","🐕","🐈","🐓","🦃","🦚","🦜","🐇","🦔","🐺","🐗"}', 'LOW'),
(2, '请点击所有的食物饮品', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🌽","🥕","🍞","🧀","🍳","🍔","🍟","🍕","🌮","🍣","🍜","🍩","🍪","🎂","🍫","🍭","☕","🍵","🧃","🥤","🍺","🍷"}', 'LOW'),
(3, '请点击所有的交通工具', '{"🚗","🚕","🚙","🚌","🏎","🚓","🚑","🚒","🚐","🚚","🚛","🚜","🚲","🛵","🏍","🚨","🚔","🚡","🚠","🚃","🚋","🚄","🚅","🚂","✈️","🛩","🚀","🛸","🚁","⛵","🚤","🛳","⛴","🚢"}', 'LOW'),
(4, '请点击所有的天气自然', '{"☀️","🌤","⛅","🌥","☁️","🌦","🌧","⛈","🌩","🌨","❄️","☃️","🌪","🌈","🌊","💧","🔥","⭐","🌟","✨","⚡","☄️","🌸","🌺","🌻","🌹","🌷","🌱","🌿","🍀","🍁","🍂","🍃","🌴","🌵"}', 'LOW');
INSERT INTO announcements (title, content, is_pinned, is_published, sort_order) VALUES
('tianai-captcha-enhanced 2.0.0 上线', '# tianai-captcha-enhanced
基于 tianai-captcha 开源版的增强版行为验证码平台
## 特性
- 16种验证码类型
- ML轨迹校验器(28维特征+14条规则)
- 对抗扰动防YOLO
- 行为风控引擎
- 端到端加密(AES-256+RSA-4096)
- 背景乱序/正弦扭曲/噪声注入
- IP黑名单+滑动窗口限流', TRUE, TRUE, 0);
@@ -0,0 +1,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<String, Object> 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";
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<String, String> 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<String, Object> 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<String, String> 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<String, Object> 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);
}
}
@@ -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<List<Plan>> listPlans() {
return ApiResponse.ofSuccess(planRepository.findAll());
}
@GetMapping("/active")
public ApiResponse<List<Plan>> listActivePlans() {
return ApiResponse.ofSuccess(planRepository.findByIsActiveTrue());
}
@GetMapping("/{id}")
public ApiResponse<Plan> 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<Plan> createPlan(@RequestBody Plan plan) {
plan.setId(null);
return ApiResponse.ofSuccess(planRepository.save(plan));
}
@PutMapping("/{id}")
public ApiResponse<Plan> 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();
}
}
@@ -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<String, Object> 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<String, Object> data = new HashMap<>();
data.put("url", url);
return ApiResponse.ofSuccess(data);
} catch (Exception e) {
return ApiResponse.of(500, "url_generation_failed", null);
}
}
}
@@ -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<String> 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<String> getFeatures() { return features; }
public void setFeatures(java.util.Set<String> 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; }
}
@@ -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; }
}
@@ -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<CaptchaLog, Integer> {
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<Object[]> 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<Object[]> 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<Object[]> dailyStats(@Param("siteId") Integer siteId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end);
List<CaptchaLog> findTop100BySiteIdOrderByCreatedAtDesc(Integer siteId);
}
@@ -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<Plan, Integer> {
List<Plan> findByIsActiveTrue();
}
@@ -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<User, Integer> {
Optional<User> findByUsername(String username);
Boolean existsByUsername(String username);
}
@@ -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<String, AnomalyAlert> 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<Object[]> 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<Object[]> 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<AnomalyAlert> 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; }
}
}
@@ -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<String, Object> getDailyStats(Integer siteId, int days) {
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
List<Object[]> rows = logRepository.dailyStats(siteId, start, end);
List<String> dates = new ArrayList<>();
List<Long> totals = new ArrayList<>();
List<Long> 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<String, Object> result = new LinkedHashMap<>();
result.put("dates", dates);
result.put("totals", totals);
result.put("successes", successes);
return result;
}
public Map<String, Object> getTypeDistribution(Integer siteId, int days) {
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
List<Object[]> rows = logRepository.countByTypeGrouped(siteId, start, end);
Map<String, Long> 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<String, Object> result = new LinkedHashMap<>();
result.put("distribution", distribution);
result.put("total", total);
return result;
}
public List<Map<String, Object>> getTopIps(int days, int limit) {
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
List<Object[]> rows = logRepository.topIpStats(start, end);
return rows.stream()
.limit(limit)
.map(row -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("ip", row[0]);
item.put("count", ((Number) row[1]).longValue());
return item;
})
.collect(Collectors.toList());
}
public Map<String, Object> getHourlyDistribution(Integer siteId, int days) {
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
List<CaptchaLog> logs = logRepository.findTop100BySiteIdOrderByCreatedAtDesc(siteId);
Map<Integer, Long> hourly = logs.stream()
.filter(l -> l.getCreatedAt() != null && l.getCreatedAt().isAfter(start))
.collect(Collectors.groupingBy(
l -> l.getCreatedAt().getHour(),
Collectors.counting()
));
Map<String, Object> result = new LinkedHashMap<>();
result.put("hours", hourly);
return result;
}
}
@@ -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<String, Object> 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");
}
}
@@ -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";
}
}
@@ -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<String, AtomicLong> ipRequestCounts = new ConcurrentHashMap<>();
private final Map<String, AtomicLong> 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<String, Object> getRealtimeStats() {
Map<String, Object> 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<String, Object> getDashboardStats(Integer siteId, int days) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("realtime", getRealtimeStats());
Map<String, Long> dailyTotals = new LinkedHashMap<>();
Map<String, Long> 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<Map<String, Object>> topIps = new ArrayList<>();
ipRequestCounts.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get()))
.limit(10)
.forEach(e -> {
Map<String, Object> ipStat = new LinkedHashMap<>();
ipStat.put("ip", e.getKey());
ipStat.put("count", e.getValue().get());
topIps.add(ipStat);
});
result.put("topIps", topIps);
List<Map<String, Object>> topSites = new ArrayList<>();
siteRequestCounts.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get()))
.limit(10)
.forEach(e -> {
Map<String, Object> 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());
}
}
}
@@ -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
@@ -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
@@ -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<Map<String, String>> 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<String, Object> data = (Map<String, Object>) 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<Void> 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<Void> 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());
}
}
@@ -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