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:
@@ -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:
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
+100
@@ -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";
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+60
@@ -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; }
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
|
||||
+10
@@ -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();
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+97
@@ -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; }
|
||||
}
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+72
@@ -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");
|
||||
}
|
||||
}
|
||||
+106
@@ -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";
|
||||
}
|
||||
}
|
||||
+165
@@ -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
|
||||
|
||||
+74
@@ -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
|
||||
Reference in New Issue
Block a user