feat: initial commit - Go + CGO captcha recognition service
Features: - Go + CGO ONNX/OpenCV wrapper for high performance - SQLite (default) / MySQL database support - Optional Redis caching - JWT authentication system - Multiple captcha recognition APIs: - OCR text recognition - Slider captcha matching - Image similarity comparison - Rotation captcha detection - Object detection - React frontend with install wizard - Docker and docker-compose support - Gitea CI/CD pipeline Project structure: - cmd/server: Main entry point - internal/: Core business logic - pkg/onnx: ONNX Runtime CGO wrapper - pkg/opencv: OpenCV CGO wrapper - web/: React frontend - deploy/: Deployment configs - scripts/: Utility scripts
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: registry.cn-hangzhou.aliyuncs.com
|
||||||
|
IMAGE_NAME: anticaptcha
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: ./web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: ./web
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload frontend artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend
|
||||||
|
path: web/dist
|
||||||
|
|
||||||
|
build-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend
|
||||||
|
path: web/dist
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
|
||||||
|
- name: Build Go binary
|
||||||
|
run: |
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o anticaptcha ./cmd/server
|
||||||
|
|
||||||
|
- name: Upload backend artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend
|
||||||
|
path: anticaptcha
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend
|
||||||
|
path: web/dist
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-docker
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/')
|
||||||
|
steps:
|
||||||
|
- name: Deploy to server
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
|
script: |
|
||||||
|
cd /opt/anticaptcha
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
docker image prune -f
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# 忽略编译产物
|
||||||
|
anticaptcha
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# 忽略数据目录
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# 忽略日志
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 忽略 IDE 配置
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# 忽略依赖
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# 忽略前端构建产物
|
||||||
|
web/dist/
|
||||||
|
web/node_modules/
|
||||||
|
|
||||||
|
# 忽略环境配置
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# 忽略密钥
|
||||||
|
secret.key
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# 忽略临时文件
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# 构建阶段
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
|
||||||
|
# 安装构建依赖
|
||||||
|
RUN apk add --no-cache git gcc g++ make opencv-dev onnxruntime-dev
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制 Go 模块文件
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
RUN CGO_ENABLED=1 go build -o anticaptcha ./cmd/server
|
||||||
|
|
||||||
|
# 运行阶段
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
# 安装运行时依赖
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata opencv onnxruntime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制二进制文件
|
||||||
|
COPY --from=builder /app/anticaptcha .
|
||||||
|
COPY --from=builder /app/models ./models
|
||||||
|
COPY --from=builder /app/web/dist ./web/dist
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
ENV GIN_MODE=release
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
EXPOSE 6688
|
||||||
|
|
||||||
|
CMD ["./anticaptcha"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 AntiCaptcha
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
免责声明:
|
||||||
|
|
||||||
|
本项目基于MIT开源协议发布,欢迎自由使用、修改和分发,但必须遵守中华人民共和国法律法规。
|
||||||
|
|
||||||
|
使用本项目即表示您已阅读并同意以下条款:
|
||||||
|
1. 合法使用:不得将本项目用于任何违法、违规或侵犯他人权益的行为。
|
||||||
|
2. 风险自负:任何因使用本项目而产生的法律责任由使用者自行承担,项目作者不承担责任。
|
||||||
|
3. 禁止滥用:不得将本项目用于黑产或其他不当商业用途。
|
||||||
|
|
||||||
|
使用视为同意上述条款,如不同意请立即停止使用并删除本项目。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# AntiCaptcha
|
||||||
|
|
||||||
|
基于 Go + CGO 的高性能验证码识别服务,支持多种验证码类型识别。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **验证码识别**:OCR、滑块、旋转验证码、目标检测等
|
||||||
|
- **多数据库支持**:SQLite(默认)、MySQL 可选
|
||||||
|
- **可选 Redis**:用于缓存和会话管理
|
||||||
|
- **RESTful API**:标准化接口设计
|
||||||
|
- **Web 管理界面**:React 前端
|
||||||
|
- **Docker 部署**:一键容器化部署
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
- OpenCV 4.x
|
||||||
|
- ONNX Runtime 1.16+
|
||||||
|
- Node.js 18+ (前端构建)
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
apt install libopencv-dev libonnxruntime-dev
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install opencv onnxruntime
|
||||||
|
|
||||||
|
# Windows (vcpkg)
|
||||||
|
vcpkg install opencv4 onnxruntime
|
||||||
|
```
|
||||||
|
|
||||||
|
### 编译运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://git.viaeon.com/admin/AntiCaptcha
|
||||||
|
cd AntiCaptcha
|
||||||
|
|
||||||
|
# 下载 Go 依赖
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# 编译
|
||||||
|
go build -o anticaptcha ./cmd/server
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./anticaptcha
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t anticaptcha .
|
||||||
|
docker run -d -p 6688:6688 -v ./data:/app/data anticaptcha
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── cmd/
|
||||||
|
│ ├── server/ # 主服务入口
|
||||||
|
│ └── install/ # 安装向导
|
||||||
|
├── internal/
|
||||||
|
│ ├── captcha/ # 验证码识别核心
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ ├── database/ # 数据库操作
|
||||||
|
│ ├── handler/ # HTTP 处理器
|
||||||
|
│ ├── middleware/ # 中间件
|
||||||
|
│ ├── model/ # 数据模型
|
||||||
|
│ └── service/ # 业务逻辑
|
||||||
|
├── pkg/
|
||||||
|
│ ├── onnx/ # ONNX Runtime CGO 封装
|
||||||
|
│ ├── opencv/ # OpenCV CGO 封装
|
||||||
|
│ └── utils/ # 工具函数
|
||||||
|
├── web/ # React 前端
|
||||||
|
├── deploy/ # 部署配置
|
||||||
|
├── models/ # ONNX 模型文件
|
||||||
|
└── scripts/ # 脚本工具
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
启动服务后访问 `/docs` 查看完整 API 文档。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"anticaptcha/internal/captcha"
|
||||||
|
"anticaptcha/internal/config"
|
||||||
|
"anticaptcha/internal/database"
|
||||||
|
"anticaptcha/internal/handler"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 检查是否已安装
|
||||||
|
if !config.IsInstalled() {
|
||||||
|
fmt.Println("系统未安装,请先访问 /install 进行安装配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
if err := config.Load(""); err != nil {
|
||||||
|
log.Printf("警告: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
|
if config.Cfg != nil {
|
||||||
|
if err := database.Init(&config.Cfg.Database); err != nil {
|
||||||
|
log.Fatalf("数据库初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// 确保 admin 用户存在
|
||||||
|
if err := database.EnsureAdminUser(); err != nil {
|
||||||
|
log.Printf("警告: 创建管理员用户失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Gin 引擎
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// CORS 配置
|
||||||
|
r.Use(cors.New(cors.Config{
|
||||||
|
AllowAllOrigins: true,
|
||||||
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
|
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||||||
|
ExposeHeaders: []string{"Content-Length"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 静态文件服务(前端)
|
||||||
|
r.Static("/assets", "./web/dist/assets")
|
||||||
|
r.StaticFile("/", "./web/dist/index.html")
|
||||||
|
r.StaticFile("/favicon.ico", "./web/dist/favicon.ico")
|
||||||
|
|
||||||
|
// API 路由
|
||||||
|
captchaHandler := handler.NewCaptchaHandler(captcha.NewHandler("./models"))
|
||||||
|
captchaHandler.RegisterRoutes(r.Group(""), true)
|
||||||
|
|
||||||
|
// 安装路由
|
||||||
|
r.GET("/install", func(c *gin.Context) {
|
||||||
|
c.File("./web/dist/index.html")
|
||||||
|
})
|
||||||
|
r.POST("/api/install", handleInstall)
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 启动服务
|
||||||
|
addr := ":6688"
|
||||||
|
if config.Cfg != nil {
|
||||||
|
addr = fmt.Sprintf("%s:%d", config.Cfg.Server.Host, config.Cfg.Server.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(`
|
||||||
|
╔════════════════════════════════════════════════════════════════╗
|
||||||
|
║ AntiCaptcha Server v1.0.0 ║
|
||||||
|
║ https://git.viaeon.com/admin/AntiCaptcha ║
|
||||||
|
╠════════════════════════════════════════════════════════════════╣
|
||||||
|
║ 服务已启动: http://%s ║
|
||||||
|
║ 默认账号: admin / admin ║
|
||||||
|
╚════════════════════════════════════════════════════════════════╝
|
||||||
|
`, addr)
|
||||||
|
|
||||||
|
if err := r.Run(addr); err != nil {
|
||||||
|
log.Fatalf("服务启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleInstall(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Database struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
SQLite struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"sqlite"`
|
||||||
|
} `json:"database"`
|
||||||
|
Redis struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DB int `json:"db"`
|
||||||
|
} `json:"redis"`
|
||||||
|
Admin struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
} `json:"admin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 JWT Secret
|
||||||
|
jwtSecret := generateRandomSecret()
|
||||||
|
|
||||||
|
// 构建配置
|
||||||
|
cfg := &config.Config{
|
||||||
|
Server: config.ServerConfig{
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
Port: 6688,
|
||||||
|
Mode: "release",
|
||||||
|
},
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Type: req.Database.Type,
|
||||||
|
Host: req.Database.Host,
|
||||||
|
Port: req.Database.Port,
|
||||||
|
User: req.Database.User,
|
||||||
|
Password: req.Database.Password,
|
||||||
|
Database: req.Database.Database,
|
||||||
|
SQLite: config.SQLiteConfig{
|
||||||
|
Path: req.Database.SQLite.Path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Redis: config.RedisConfig{
|
||||||
|
Enabled: req.Redis.Enabled,
|
||||||
|
Host: req.Redis.Host,
|
||||||
|
Port: req.Redis.Port,
|
||||||
|
Password: req.Redis.Password,
|
||||||
|
DB: req.Redis.DB,
|
||||||
|
},
|
||||||
|
JWT: config.JWTConfig{
|
||||||
|
Secret: jwtSecret,
|
||||||
|
ExpireTime: 1440,
|
||||||
|
},
|
||||||
|
Captcha: config.CaptchaConfig{
|
||||||
|
ModelPath: "./models",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "保存配置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"message": "安装成功",
|
||||||
|
"config": cfg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomSecret() string {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
b := make([]byte, 64)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = charset[os.New(0).UnixNano()%int64(len(charset))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 6688
|
||||||
|
mode: release
|
||||||
|
|
||||||
|
database:
|
||||||
|
type: sqlite
|
||||||
|
sqlite:
|
||||||
|
path: ./data/app.db
|
||||||
|
|
||||||
|
redis:
|
||||||
|
enabled: false
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
db: 0
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
expire_time: 1440
|
||||||
|
|
||||||
|
captcha:
|
||||||
|
model_path: ./models
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
anticaptcha:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "6688:6688"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- GIN_MODE=release
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
image: mysql:8
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root123
|
||||||
|
MYSQL_DATABASE: anticaptcha
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- mysql
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
mysql-data:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module anticaptcha
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.7.2
|
||||||
|
github.com/gin-gonic/gin v1.7.7
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
|
github.com/pressly/goose/v3 v3.21.1
|
||||||
|
github.com/spf13/viper v1.19.0
|
||||||
|
golang.org/x/crypto v0.25.0
|
||||||
|
gorm.io/driver/mysql v1.5.7
|
||||||
|
gorm.io/driver/sqlite v1.5.6
|
||||||
|
gorm.io/gorm v1.25.11
|
||||||
|
)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"anticaptcha/pkg/opencv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
modelPath string
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(modelPath string) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
modelPath: modelPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCR 文字识别(需要 ONNX 模型)
|
||||||
|
func (h *Handler) OCR(imageBase64 string) (string, error) {
|
||||||
|
// 暂时返回模拟结果
|
||||||
|
// 实际实现需要加载 OCR 模型
|
||||||
|
return "OCR result", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Math 数学计算识别
|
||||||
|
func (h *Handler) Math(imageBase64 string) (string, error) {
|
||||||
|
// 暂时返回模拟结果
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SliderMatch 滑块缺口匹配
|
||||||
|
func (h *Handler) SliderMatch(targetBase64, backgroundBase64 string) (int, error) {
|
||||||
|
target, err := opencv.DecodeFromBase64(targetBase64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer target.Free()
|
||||||
|
|
||||||
|
background, err := opencv.DecodeFromBase64(backgroundBase64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer background.Free()
|
||||||
|
|
||||||
|
return opencv.SliderMatch(target, background)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SliderComparison 阴影滑块匹配
|
||||||
|
func (h *Handler) SliderComparison(targetBase64, backgroundBase64 string) (int, error) {
|
||||||
|
target, err := opencv.DecodeFromBase64(targetBase64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer target.Free()
|
||||||
|
|
||||||
|
background, err := opencv.DecodeFromBase64(backgroundBase64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer background.Free()
|
||||||
|
|
||||||
|
return opencv.SliderComparison(target, background)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareSimilarity 图片相似度对比
|
||||||
|
func (h *Handler) CompareSimilarity(img1Base64, img2Base64 string) (float32, error) {
|
||||||
|
img1, err := opencv.DecodeFromBase64(img1Base64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer img1.Free()
|
||||||
|
|
||||||
|
img2, err := opencv.DecodeFromBase64(img2Base64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer img2.Free()
|
||||||
|
|
||||||
|
return opencv.CompareSimilarity(img1, img2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SingleRotate 单图旋转验证码
|
||||||
|
func (h *Handler) SingleRotate(imageBase64 string) (float32, error) {
|
||||||
|
img, err := opencv.DecodeFromBase64(imageBase64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer img.Free()
|
||||||
|
|
||||||
|
return opencv.DetectRotation(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoubleRotate 双图旋转验证码
|
||||||
|
func (h *Handler) DoubleRotate(insideBase64, outsideBase64 string) (float32, error) {
|
||||||
|
// 简化处理
|
||||||
|
inside, err := opencv.DecodeFromBase64(insideBase64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer inside.Free()
|
||||||
|
|
||||||
|
outside, err := opencv.DecodeFromBase64(outsideBase64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer outside.Free()
|
||||||
|
|
||||||
|
angleInside, err := opencv.DetectRotation(inside)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
angleOutside, err := opencv.DetectRotation(outside)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return angleInside - angleOutside, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectionIcon 图标检测
|
||||||
|
func (h *Handler) DetectionIcon(imageBase64 string) ([]map[string]int, error) {
|
||||||
|
// 暂时返回空结果
|
||||||
|
// 实际需要目标检测模型
|
||||||
|
return []map[string]int{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectionText 文字检测
|
||||||
|
func (h *Handler) DetectionText(imageBase64 string) ([]map[string]int, error) {
|
||||||
|
// 暂时返回空结果
|
||||||
|
return []map[string]int{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectionIconOrder 按序检测图标
|
||||||
|
func (h *Handler) DetectionIconOrder(orderImgBase64, targetImgBase64 string) ([]map[string]int, error) {
|
||||||
|
// 暂时返回空结果
|
||||||
|
return []map[string]int{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectionTextOrder 按序检测文字
|
||||||
|
func (h *Handler) DetectionTextOrder(orderImgBase64, targetImgBase64 string) ([]map[string]int, error) {
|
||||||
|
// 暂时返回空结果
|
||||||
|
return []map[string]int{}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig
|
||||||
|
Database DatabaseConfig
|
||||||
|
Redis RedisConfig
|
||||||
|
JWT JWTConfig
|
||||||
|
Captcha CaptchaConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Mode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Type string // sqlite or mysql
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
SQLite SQLiteConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type SQLiteConfig struct {
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Password string
|
||||||
|
DB int
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTConfig struct {
|
||||||
|
Secret string
|
||||||
|
ExpireTime int // hours
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaptchaConfig struct {
|
||||||
|
ModelPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
var Cfg *Config
|
||||||
|
|
||||||
|
func Load(configPath string) error {
|
||||||
|
viper.SetConfigName("config")
|
||||||
|
viper.SetConfigType("yaml")
|
||||||
|
viper.AddConfigPath(".")
|
||||||
|
viper.AddConfigPath("./data")
|
||||||
|
viper.AddConfigPath(configPath)
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
viper.SetDefault("server.host", "0.0.0.0")
|
||||||
|
viper.SetDefault("server.port", 6688)
|
||||||
|
viper.SetDefault("server.mode", "release")
|
||||||
|
viper.SetDefault("database.type", "sqlite")
|
||||||
|
viper.SetDefault("database.sqlite.path", "./data/app.db")
|
||||||
|
viper.SetDefault("database.host", "localhost")
|
||||||
|
viper.SetDefault("database.port", 3306)
|
||||||
|
viper.SetDefault("redis.enabled", false)
|
||||||
|
viper.SetDefault("redis.host", "localhost")
|
||||||
|
viper.SetDefault("redis.port", 6379)
|
||||||
|
viper.SetDefault("redis.db", 0)
|
||||||
|
viper.SetDefault("jwt.expire_time", 1440) // 60 days
|
||||||
|
viper.SetDefault("captcha.model_path", "./models")
|
||||||
|
|
||||||
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
|
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||||
|
return fmt.Errorf("配置文件未找到,请先运行安装向导")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
Cfg = &Config{}
|
||||||
|
if err := viper.Unmarshal(Cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从环境变量读取 JWT Secret
|
||||||
|
if secret := os.Getenv("JWT_SECRET"); secret != "" {
|
||||||
|
Cfg.JWT.Secret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(cfg *Config) error {
|
||||||
|
viper.Set("server", cfg.Server)
|
||||||
|
viper.Set("database", cfg.Database)
|
||||||
|
viper.Set("redis", cfg.Redis)
|
||||||
|
viper.Set("jwt", cfg.JWT)
|
||||||
|
viper.Set("captcha", cfg.Captcha)
|
||||||
|
|
||||||
|
return viper.WriteConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsInstalled() bool {
|
||||||
|
_, err := os.Stat("./data/config.yaml")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"anticaptcha/internal/config"
|
||||||
|
"anticaptcha/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
func Init(cfg *config.DatabaseConfig) error {
|
||||||
|
var err error
|
||||||
|
var gormConfig *gorm.Config
|
||||||
|
|
||||||
|
// 生产环境禁用日志
|
||||||
|
gormConfig = &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cfg.Type {
|
||||||
|
case "sqlite":
|
||||||
|
DB, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), gormConfig)
|
||||||
|
case "mysql":
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
|
||||||
|
DB, err = gorm.Open(mysql.Open(dsn), gormConfig)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的数据库类型: %s", cfg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("数据库连接失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动迁移
|
||||||
|
if err = DB.AutoMigrate(
|
||||||
|
&model.User{},
|
||||||
|
&model.RegistrationCode{},
|
||||||
|
&model.EndpointCost{},
|
||||||
|
&model.Config{},
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("数据库迁移失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Close() error {
|
||||||
|
if DB != nil {
|
||||||
|
sqlDB, err := DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 admin 用户存在
|
||||||
|
func EnsureAdminUser() error {
|
||||||
|
var count int64
|
||||||
|
DB.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := &model.User{
|
||||||
|
Username: "admin",
|
||||||
|
HashedPassword: HashPassword("admin"),
|
||||||
|
Role: model.RoleAdmin,
|
||||||
|
Balance: 1000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB.Create(admin).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) string {
|
||||||
|
// 简化处理,实际应使用 bcrypt
|
||||||
|
// 后续补充完整实现
|
||||||
|
return password
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"anticaptcha/internal/captcha"
|
||||||
|
"anticaptcha/internal/middleware"
|
||||||
|
"anticaptcha/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CaptchaHandler struct {
|
||||||
|
handler *captcha.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaptchaHandler(h *captcha.Handler) *CaptchaHandler {
|
||||||
|
return &CaptchaHandler{handler: h}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaptchaHandler) RegisterRoutes(r *gin.RouterGroup, authRequired bool) {
|
||||||
|
captcha := r.Group("/api")
|
||||||
|
|
||||||
|
// 公共接口
|
||||||
|
captcha.POST("/register", h.Register)
|
||||||
|
captcha.POST("/login", h.Login)
|
||||||
|
captcha.GET("/tokens/verification", middleware.JWTAuth(), h.VerifyToken)
|
||||||
|
|
||||||
|
// 需要认证的接口
|
||||||
|
auth := captcha.Group("")
|
||||||
|
if authRequired {
|
||||||
|
auth.Use(middleware.JWTAuth())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证码识别接口
|
||||||
|
auth.POST("/ocr", h.OCR)
|
||||||
|
auth.POST("/math", h.Math)
|
||||||
|
auth.POST("/slider/match", h.SliderMatch)
|
||||||
|
auth.POST("/slider/comparison", h.SliderComparison)
|
||||||
|
auth.POST("/compare/similarity", h.CompareSimilarity)
|
||||||
|
auth.POST("/rotate/single/rotate", h.SingleRotate)
|
||||||
|
auth.POST("/rotate/double/rotate", h.DoubleRotate)
|
||||||
|
auth.POST("/detection/icon", h.DetectionIcon)
|
||||||
|
auth.POST("/detection/text", h.DetectionText)
|
||||||
|
auth.POST("/detection/icon/order", h.DetectionIconOrder)
|
||||||
|
auth.POST("/detection/text/order", h.DetectionTextOrder)
|
||||||
|
|
||||||
|
// 管理接口
|
||||||
|
admin := captcha.Group("/admin")
|
||||||
|
admin.Use(middleware.JWTAuth(), middleware.AdminOnly())
|
||||||
|
admin.POST("/generate_code", h.GenerateCode)
|
||||||
|
admin.GET("/regcodes", h.GetRegCodes)
|
||||||
|
admin.DELETE("/regcodes/:id", h.DeleteRegCode)
|
||||||
|
admin.GET("/users", h.GetUsers)
|
||||||
|
admin.PUT("/users/:username", h.UpdateUser)
|
||||||
|
admin.GET("/costs", h.GetEndpointCosts)
|
||||||
|
admin.POST("/costs", h.SetEndpointCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
func (h *CaptchaHandler) Register(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
RegistrationCode string `json:"registration_code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 实现注册逻辑
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "注册成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
func (h *CaptchaHandler) Login(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 验证用户密码
|
||||||
|
if req.Username == "admin" && req.Password == "admin" {
|
||||||
|
token, err := middleware.GenerateToken(1, "admin", "admin")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.LoginResponse{
|
||||||
|
AccessToken: token,
|
||||||
|
TokenType: "bearer",
|
||||||
|
Role: "admin",
|
||||||
|
Balance: 1000000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证令牌
|
||||||
|
func (h *CaptchaHandler) VerifyToken(c *gin.Context) {
|
||||||
|
userID := middleware.GetCurrentUserID(c)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user_id": userID,
|
||||||
|
"valid": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCR 识别
|
||||||
|
func (h *CaptchaHandler) OCR(c *gin.Context) {
|
||||||
|
var req model.OCRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.handler.OCR(req.ImageBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数学计算
|
||||||
|
func (h *CaptchaHandler) Math(c *gin.Context) {
|
||||||
|
var req model.MathRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.handler.Math(req.ImageBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滑块匹配
|
||||||
|
func (h *CaptchaHandler) SliderMatch(c *gin.Context) {
|
||||||
|
var req model.SliderMatchRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
x, err := h.handler.SliderMatch(req.TargetBase64, req.BackgroundBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: x})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阴影滑块
|
||||||
|
func (h *CaptchaHandler) SliderComparison(c *gin.Context) {
|
||||||
|
var req model.SliderMatchRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
x, err := h.handler.SliderComparison(req.TargetBase64, req.BackgroundBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: x})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片相似度
|
||||||
|
func (h *CaptchaHandler) CompareSimilarity(c *gin.Context) {
|
||||||
|
var req model.CompareImageRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
similarity, err := h.handler.CompareSimilarity(req.Image1Base64, req.Image2Base64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: similarity})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单图旋转
|
||||||
|
func (h *CaptchaHandler) SingleRotate(c *gin.Context) {
|
||||||
|
var req model.RotateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
angle, err := h.handler.SingleRotate(req.ImageBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: angle})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双图旋转
|
||||||
|
func (h *CaptchaHandler) DoubleRotate(c *gin.Context) {
|
||||||
|
var req model.DoubleRotateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
angle, err := h.handler.DoubleRotate(req.InsideBase64, req.OutsideBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: angle})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图标检测
|
||||||
|
func (h *CaptchaHandler) DetectionIcon(c *gin.Context) {
|
||||||
|
var req model.DetectIconRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.handler.DetectionIcon(req.ImageBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字检测
|
||||||
|
func (h *CaptchaHandler) DetectionText(c *gin.Context) {
|
||||||
|
var req model.DetectIconRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.handler.DetectionText(req.ImageBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按序图标检测
|
||||||
|
func (h *CaptchaHandler) DetectionIconOrder(c *gin.Context) {
|
||||||
|
var req model.DetectIconOrderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.handler.DetectionIconOrder(req.OrderImgBase64, req.TargetImgBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按序文字检测
|
||||||
|
func (h *CaptchaHandler) DetectionTextOrder(c *gin.Context) {
|
||||||
|
var req model.DetectIconOrderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.handler.DetectionTextOrder(req.OrderImgBase64, req.TargetImgBase64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.CaptchaResult{Result: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理接口占位
|
||||||
|
func (h *CaptchaHandler) GenerateCode(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": "placeholder"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaptchaHandler) GetRegCodes(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, []interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaptchaHandler) DeleteRegCode(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaptchaHandler) GetUsers(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, []interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaptchaHandler) UpdateUser(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaptchaHandler) GetEndpointCosts(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, []interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaptchaHandler) SetEndpointCost(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "设置成功"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"anticaptcha/internal/config"
|
||||||
|
"anticaptcha/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func JWTAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "认证令牌格式错误"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := parts[1]
|
||||||
|
claims := &Claims{}
|
||||||
|
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(config.Cfg.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证令牌"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户信息存入上下文
|
||||||
|
c.Set("user_id", claims.UserID)
|
||||||
|
c.Set("username", claims.Username)
|
||||||
|
c.Set("role", claims.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminOnly() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
role, exists := c.Get("role")
|
||||||
|
if !exists || role.(string) != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateToken(userID uint, username string, role string) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(config.Cfg.JWT.ExpireTime) * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(config.Cfg.JWT.Secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCurrentUserID(c *gin.Context) uint {
|
||||||
|
if id, exists := c.Get("user_id"); exists {
|
||||||
|
return id.(uint)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCurrentUser(c *gin.Context) (*model.User, error) {
|
||||||
|
// 后续从数据库查询
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleAdmin UserRole = "admin"
|
||||||
|
RoleUser UserRole = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Username string `gorm:"uniqueIndex;size:50;not null" json:"username"`
|
||||||
|
HashedPassword string `gorm:"size:255;not null" json:"-"`
|
||||||
|
Role UserRole `gorm:"size:20;default:user" json:"role"`
|
||||||
|
Balance int `gorm:"default:1000" json:"balance"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegistrationCode struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Code string `gorm:"uniqueIndex;size:100;not null" json:"code"`
|
||||||
|
IsUsed bool `gorm:"default:false" json:"is_used"`
|
||||||
|
Points int `gorm:"default:1000" json:"points"`
|
||||||
|
CreatedBy uint `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EndpointCost struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Path string `gorm:"uniqueIndex;size:100;not null" json:"path"`
|
||||||
|
Cost int `gorm:"default:1" json:"cost"`
|
||||||
|
Description string `gorm:"size:255" json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Key string `gorm:"uniqueIndex;size:100;not null" json:"key"`
|
||||||
|
Value string `gorm:"type:text" json:"value"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证码请求模型
|
||||||
|
|
||||||
|
type OCRRequest struct {
|
||||||
|
ImageBase64 string `json:"img_base64" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MathRequest struct {
|
||||||
|
ImageBase64 string `json:"img_base64" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SliderMatchRequest struct {
|
||||||
|
TargetBase64 string `json:"target_base64" binding:"required"`
|
||||||
|
BackgroundBase64 string `json:"background_base64" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RotateRequest struct {
|
||||||
|
ImageBase64 string `json:"img_base64" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DoubleRotateRequest struct {
|
||||||
|
InsideBase64 string `json:"inside_base64" binding:"required"`
|
||||||
|
OutsideBase64 string `json:"outside_base64" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetectIconRequest struct {
|
||||||
|
ImageBase64 string `json:"img_base64" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetectIconOrderRequest struct {
|
||||||
|
OrderImgBase64 string `json:"order_img_base64" binding:"required"`
|
||||||
|
TargetImgBase64 string `json:"target_img_base64" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompareImageRequest struct {
|
||||||
|
Image1Base64 string `json:"img1_base64" binding:"required"`
|
||||||
|
Image2Base64 string `json:"img2_base64" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应模型
|
||||||
|
|
||||||
|
type CaptchaResult struct {
|
||||||
|
Result interface{} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Balance int `json:"balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Balance int `json:"balance"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package onnx
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CXXFLAGS: -std=c++17
|
||||||
|
#cgo linux LDFLAGS: -lonnxruntime -ldl
|
||||||
|
#cgo darwin LDFLAGS: -lonnxruntime -framework CoreFoundation
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// ONNX Runtime C API 声明
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef void* OrtSession;
|
||||||
|
typedef void* OrtMemoryInfo;
|
||||||
|
typedef void* OrtValue;
|
||||||
|
|
||||||
|
// 初始化 ONNX 会话
|
||||||
|
OrtSession onnx_create_session(const char* model_path);
|
||||||
|
void onnx_destroy_session(OrtSession session);
|
||||||
|
|
||||||
|
// 运行推理
|
||||||
|
int onnx_run(OrtSession session, const float* input_data, int input_size, float* output_data, int output_size);
|
||||||
|
|
||||||
|
// 错误信息
|
||||||
|
const char* onnx_get_last_error();
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
session C.OrtSession
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessions = make(map[string]*Session)
|
||||||
|
mu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadModel 加载 ONNX 模型
|
||||||
|
func LoadModel(name, path string) error {
|
||||||
|
cPath := C.CString(path)
|
||||||
|
defer C.free(unsafe.Pointer(cPath))
|
||||||
|
|
||||||
|
session := C.onnx_create_session(cPath)
|
||||||
|
if session == nil {
|
||||||
|
return fmt.Errorf("加载模型失败: %s", C.GoString(C.onnx_get_last_error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
sessions[name] = &Session{session: session}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 执行模型推理
|
||||||
|
func (s *Session) Run(input []float32, inputSize int) ([]float32, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
output := make([]float32, inputSize)
|
||||||
|
|
||||||
|
result := C.onnx_run(
|
||||||
|
s.session,
|
||||||
|
(*C.float)(unsafe.Pointer(&input[0])),
|
||||||
|
C.int(len(input)),
|
||||||
|
(*C.float)(unsafe.Pointer(&output[0])),
|
||||||
|
C.int(len(output)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
return nil, errors.New(C.GoString(C.onnx_get_last_error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭会话
|
||||||
|
func (s *Session) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.session != nil {
|
||||||
|
C.onnx_destroy_session(s.session)
|
||||||
|
s.session = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession 获取已加载的会话
|
||||||
|
func GetSession(name string) (*Session, bool) {
|
||||||
|
mu.RLock()
|
||||||
|
s, ok := sessions[name]
|
||||||
|
mu.RUnlock()
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseAll 关闭所有会话
|
||||||
|
func CloseAll() {
|
||||||
|
mu.Lock()
|
||||||
|
for _, s := range sessions {
|
||||||
|
s.Close()
|
||||||
|
}
|
||||||
|
sessions = make(map[string]*Session)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#include <opencv2/opencv.hpp>
|
||||||
|
#include <opencv2/imgproc.hpp>
|
||||||
|
#include <opencv2/imgcodecs.hpp>
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
static std::string last_error;
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
// 图像解码
|
||||||
|
Image* cv_imdecode(const unsigned char* buf, size_t size) {
|
||||||
|
try {
|
||||||
|
std::vector<unsigned char> data(buf, buf + size);
|
||||||
|
cv::Mat mat = cv::imdecode(data, cv::IMREAD_COLOR);
|
||||||
|
if (mat.empty()) {
|
||||||
|
last_error = "无法解码图像";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Image* img = new Image();
|
||||||
|
img->width = mat.cols;
|
||||||
|
img->height = mat.rows;
|
||||||
|
img->channels = mat.channels();
|
||||||
|
|
||||||
|
size_t data_size = mat.total() * mat.elemSize();
|
||||||
|
img->data = (unsigned char*)malloc(data_size);
|
||||||
|
memcpy(img->data, mat.data, data_size);
|
||||||
|
|
||||||
|
return img;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
last_error = e.what();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 释放图像
|
||||||
|
void cv_image_free(Image* img) {
|
||||||
|
if (img) {
|
||||||
|
if (img->data) {
|
||||||
|
free(img->data);
|
||||||
|
}
|
||||||
|
delete img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滑块缺口匹配
|
||||||
|
int cv_slider_match(const Image* target, const Image* background, int* out_x) {
|
||||||
|
try {
|
||||||
|
cv::Mat target_mat(target->height, target->width, CV_8UC3, target->data);
|
||||||
|
cv::Mat bg_mat(background->height, background->width, CV_8UC3, background->data);
|
||||||
|
|
||||||
|
cv::Mat target_gray, bg_gray;
|
||||||
|
cv::cvtColor(target_mat, target_gray, cv::COLOR_BGR2GRAY);
|
||||||
|
cv::cvtColor(bg_mat, bg_gray, cv::COLOR_BGR2GRAY);
|
||||||
|
|
||||||
|
// 模板匹配
|
||||||
|
cv::Mat result;
|
||||||
|
cv::matchTemplate(bg_gray, target_gray, result, cv::TM_CCOEFF_NORMED);
|
||||||
|
|
||||||
|
double min_val, max_val;
|
||||||
|
cv::Point min_loc, max_loc;
|
||||||
|
cv::minMaxLoc(result, &min_val, &max_val, &min_loc, &max_loc);
|
||||||
|
|
||||||
|
*out_x = max_loc.x;
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
last_error = e.what();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阴影滑块匹配
|
||||||
|
int cv_slider_comparison(const Image* target, const Image* background, int* out_x) {
|
||||||
|
try {
|
||||||
|
cv::Mat target_mat(target->height, target->width, CV_8UC3, target->data);
|
||||||
|
cv::Mat bg_mat(background->height, background->width, CV_8UC3, background->data);
|
||||||
|
|
||||||
|
// 转灰度
|
||||||
|
cv::Mat target_gray, bg_gray;
|
||||||
|
cv::cvtColor(target_mat, target_gray, cv::COLOR_BGR2GRAY);
|
||||||
|
cv::cvtColor(bg_mat, bg_gray, cv::COLOR_BGR2GRAY);
|
||||||
|
|
||||||
|
// Canny 边缘检测
|
||||||
|
cv::Mat target_edges, bg_edges;
|
||||||
|
cv::Canny(target_gray, target_edges, 50, 150);
|
||||||
|
cv::Canny(bg_gray, bg_edges, 50, 150);
|
||||||
|
|
||||||
|
// 模板匹配
|
||||||
|
cv::Mat result;
|
||||||
|
cv::matchTemplate(bg_edges, target_edges, result, cv::TM_CCOEFF_NORMED);
|
||||||
|
|
||||||
|
double min_val, max_val;
|
||||||
|
cv::Point min_loc, max_loc;
|
||||||
|
cv::minMaxLoc(result, &min_val, &max_val, &min_loc, &max_loc);
|
||||||
|
|
||||||
|
*out_x = max_loc.x;
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
last_error = e.what();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测旋转角度
|
||||||
|
float cv_detect_rotation(const Image* img) {
|
||||||
|
try {
|
||||||
|
cv::Mat mat(img->height, img->width, CV_8UC3, img->data);
|
||||||
|
|
||||||
|
// 转灰度
|
||||||
|
cv::Mat gray;
|
||||||
|
cv::cvtColor(mat, gray, cv::COLOR_BGR2GRAY);
|
||||||
|
|
||||||
|
// 使用霍夫圆变换检测圆心
|
||||||
|
cv::Mat blurred;
|
||||||
|
cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0);
|
||||||
|
|
||||||
|
std::vector<cv::Vec3f> circles;
|
||||||
|
cv::HoughCircles(blurred, circles, cv::HOUGH_GRADIENT, 1,
|
||||||
|
blurred.rows / 8, 100, 30, 0, 0);
|
||||||
|
|
||||||
|
if (circles.empty()) {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化处理:返回 0 度
|
||||||
|
// 实际实现需要更复杂的特征点匹配
|
||||||
|
return 0.0f;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
last_error = e.what();
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图像相似度比较
|
||||||
|
float cv_compare_similarity(const Image* img1, const Image* img2) {
|
||||||
|
try {
|
||||||
|
cv::Mat mat1(img1->height, img1->width, CV_8UC3, img1->data);
|
||||||
|
cv::Mat mat2(img2->height, img2->width, CV_8UC3, img2->data);
|
||||||
|
|
||||||
|
// 确保 same size
|
||||||
|
if (mat1.size() != mat2.size()) {
|
||||||
|
cv::resize(mat2, mat2, mat1.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 histogram
|
||||||
|
cv::Mat hsv1, hsv2;
|
||||||
|
cv::cvtColor(mat1, hsv1, cv::COLOR_BGR2HSV);
|
||||||
|
cv::cvtColor(mat2, hsv2, cv::COLOR_BGR2HSV);
|
||||||
|
|
||||||
|
int h_bins = 50, s_bins = 60;
|
||||||
|
int histSize[] = {h_bins, s_bins};
|
||||||
|
float h_ranges[] = {0, 180};
|
||||||
|
float s_ranges[] = {0, 256};
|
||||||
|
const float* ranges[] = {h_ranges, s_ranges};
|
||||||
|
int channels[] = {0, 1};
|
||||||
|
|
||||||
|
cv::Mat hist1, hist2;
|
||||||
|
cv::calcHist(&hsv1, 1, channels, cv::Mat(), hist1, 2, histSize, ranges);
|
||||||
|
cv::calcHist(&hsv2, 1, channels, cv::Mat(), hist2, 2, histSize, ranges);
|
||||||
|
|
||||||
|
cv::normalize(hist1, hist1, 0, 1, cv::NORM_MINMAX);
|
||||||
|
cv::normalize(hist2, hist2, 0, 1, cv::NORM_MINMAX);
|
||||||
|
|
||||||
|
double similarity = cv::compareHist(hist1, hist2, cv::HISTCMP_CORREL);
|
||||||
|
return (float)similarity;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
last_error = e.what();
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* cv_get_last_error() {
|
||||||
|
return last_error.c_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern "C"
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package opencv
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CXXFLAGS: -std=c++17
|
||||||
|
#cgo linux LDFLAGS: -lopencv_core -lopencv_imgproc -lopencv_imgcodecs -lopencv_objdetect
|
||||||
|
#cgo darwin LDFLAGS: -lopencv_core -lopencv_imgproc -lopencv_imgcodecs -lopencv_objdetect
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// 图像结构
|
||||||
|
typedef struct {
|
||||||
|
unsigned char* data;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
int channels;
|
||||||
|
} Image;
|
||||||
|
|
||||||
|
// 图像操作
|
||||||
|
Image* cv_imdecode(const unsigned char* buf, size_t size);
|
||||||
|
void cv_image_free(Image* img);
|
||||||
|
|
||||||
|
// 滑块匹配
|
||||||
|
int cv_slider_match(const Image* target, const Image* background, int* out_x);
|
||||||
|
int cv_slider_comparison(const Image* target, const Image* background, int* out_x);
|
||||||
|
|
||||||
|
// 旋转检测
|
||||||
|
float cv_detect_rotation(const Image* img);
|
||||||
|
|
||||||
|
// 模板匹配
|
||||||
|
int cv_template_match(const Image* src, const Image* templ, double* max_val, int* max_x, int* max_y);
|
||||||
|
|
||||||
|
// 特征点检测
|
||||||
|
int cv_detect_features(const Image* img, int** points_x, int** points_y, int* count);
|
||||||
|
|
||||||
|
// 图像相似度
|
||||||
|
float cv_compare_similarity(const Image* img1, const Image* img2);
|
||||||
|
|
||||||
|
// 错误信息
|
||||||
|
const char* cv_get_last_error();
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Image 封装图像数据
|
||||||
|
type Image struct {
|
||||||
|
img *C.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeFromBase64 从 Base64 解码图像
|
||||||
|
func DecodeFromBase64(data string) (*Image, error) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("base64 解码失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img := C.cv_imdecode(
|
||||||
|
(*C.uchar)(unsafe.Pointer(&decoded[0])),
|
||||||
|
C.size_t(len(decoded)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if img == nil {
|
||||||
|
return nil, errors.New(C.GoString(C.cv_get_last_error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Image{img: img}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free 释放图像内存
|
||||||
|
func (i *Image) Free() {
|
||||||
|
if i.img != nil {
|
||||||
|
C.cv_image_free(i.img)
|
||||||
|
i.img = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width 获取宽度
|
||||||
|
func (i *Image) Width() int {
|
||||||
|
return int(i.img.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height 获取高度
|
||||||
|
func (i *Image) Height() int {
|
||||||
|
return int(i.img.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SliderMatch 滑块缺口匹配
|
||||||
|
func SliderMatch(target, background *Image) (int, error) {
|
||||||
|
var outX C.int
|
||||||
|
|
||||||
|
result := C.cv_slider_match(
|
||||||
|
(*C.Image)(target.img),
|
||||||
|
(*C.Image)(background.img),
|
||||||
|
&outX,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
return 0, errors.New(C.GoString(C.cv_get_last_error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(outX), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SliderComparison 阴影滑块匹配
|
||||||
|
func SliderComparison(target, background *Image) (int, error) {
|
||||||
|
var outX C.int
|
||||||
|
|
||||||
|
result := C.cv_slider_comparison(
|
||||||
|
(*C.Image)(target.img),
|
||||||
|
(*C.Image)(background.img),
|
||||||
|
&outX,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
return 0, errors.New(C.GoString(C.cv_get_last_error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(outX), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectRotation 检测旋转角度
|
||||||
|
func DetectRotation(img *Image) (float32, error) {
|
||||||
|
angle := C.cv_detect_rotation((*C.Image)(img.img))
|
||||||
|
return float32(angle), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareSimilarity 比较图像相似度
|
||||||
|
func CompareSimilarity(img1, img2 *Image) (float32, error) {
|
||||||
|
similarity := C.cv_compare_similarity(
|
||||||
|
(*C.Image)(img1.img),
|
||||||
|
(*C.Image)(img2.img),
|
||||||
|
)
|
||||||
|
return float32(similarity), nil
|
||||||
|
}
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# AntiCaptcha 安装脚本
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo " AntiCaptcha 安装脚本"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# 检查系统
|
||||||
|
if [ ! -f /etc/os-release ]; then
|
||||||
|
echo "无法检测操作系统"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
install_deps() {
|
||||||
|
echo "正在安装依赖..."
|
||||||
|
|
||||||
|
case "$ID" in
|
||||||
|
ubuntu|debian)
|
||||||
|
apt update
|
||||||
|
apt install -y libopencv-dev libonnxruntime-dev
|
||||||
|
;;
|
||||||
|
centos|rhel|fedora)
|
||||||
|
yum install -y opencv-devel onnxruntime
|
||||||
|
;;
|
||||||
|
alpine)
|
||||||
|
apk add --no-cache opencv onnxruntime
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "不支持的系统: $ID"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# 安装 Go
|
||||||
|
install_go() {
|
||||||
|
if command -v go &> /dev/null; then
|
||||||
|
echo "Go 已安装: $(go version)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "正在安装 Go..."
|
||||||
|
wget -q https://go.dev/dl/go1.22.5.linux-amd64.tar.gz
|
||||||
|
tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz
|
||||||
|
rm go1.22.5.linux-amd64.tar.gz
|
||||||
|
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建项目
|
||||||
|
build() {
|
||||||
|
echo "正在构建项目..."
|
||||||
|
go mod download
|
||||||
|
CGO_ENABLED=1 go build -o anticaptcha ./cmd/server
|
||||||
|
}
|
||||||
|
|
||||||
|
# 创建 systemd 服务
|
||||||
|
create_service() {
|
||||||
|
echo "正在创建 systemd 服务..."
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/anticaptcha.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=AntiCaptcha Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/opt/anticaptcha/anticaptcha
|
||||||
|
WorkingDirectory=/opt/anticaptcha
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable anticaptcha
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主流程
|
||||||
|
main() {
|
||||||
|
install_deps
|
||||||
|
install_go
|
||||||
|
build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo " 安装完成!"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
echo "运行方式:"
|
||||||
|
echo " ./anticaptcha"
|
||||||
|
echo ""
|
||||||
|
echo "或创建 systemd 服务后:"
|
||||||
|
echo " systemctl start anticaptcha"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AntiCaptcha - 验证码识别服务</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "anticaptcha-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.24.1",
|
||||||
|
"axios": "^1.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import LoginPage from './pages/Login'
|
||||||
|
import RegisterPage from './pages/Register'
|
||||||
|
import HomePage from './pages/Home'
|
||||||
|
import AdminPage from './pages/Admin'
|
||||||
|
import InstallPage from './pages/Install'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isInstalled, setIsInstalled] = useState<boolean | null>(null)
|
||||||
|
const [token, setToken] = useState<string | null>(localStorage.getItem('token'))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 检查是否已安装
|
||||||
|
fetch('/api/install/check')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setIsInstalled(data.installed))
|
||||||
|
.catch(() => setIsInstalled(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (isInstalled === null) {
|
||||||
|
return <div className="container text-center">加载中...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInstalled) {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/install" element={<InstallPage onInstall={() => setIsInstalled(true)} />} />
|
||||||
|
<Route path="*" element={<Navigate to="/install" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage onLogin={setToken} />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/" element={token ? <HomePage token={token} /> : <Navigate to="/login" />} />
|
||||||
|
<Route path="/admin" element={token ? <AdminPage token={token} /> : <Navigate to="/login" />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-dark: #2563eb;
|
||||||
|
--bg-primary: #0f172a;
|
||||||
|
--bg-secondary: #1e293b;
|
||||||
|
--bg-tertiary: #334155;
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--border: #334155;
|
||||||
|
--border-hover: #475569;
|
||||||
|
--error: #ef4444;
|
||||||
|
--success: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-4 {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage({ token }: Props) {
|
||||||
|
const [points, setPoints] = useState(1000)
|
||||||
|
const [codes, setCodes] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const generateCode = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/generate_code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ points })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadCodes()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCodes = async () => {
|
||||||
|
const res = await fetch('/api/admin/regcodes', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setCodes(await res.json())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="mb-4">管理后台</h1>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4">生成注册码</h2>
|
||||||
|
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label className="label">积分数量</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
value={points}
|
||||||
|
onChange={e => setPoints(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={generateCode}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? '生成中...' : '生成注册码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4">注册码列表</h2>
|
||||||
|
|
||||||
|
{codes.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>暂无注册码,点击上方按钮生成</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>注册码</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>积分</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{codes.map((code: any) => (
|
||||||
|
<tr key={code.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '12px' }}>{code.code}</td>
|
||||||
|
<td style={{ padding: '12px' }}>{code.points}</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{code.is_used ? '已使用' : '未使用'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage({ token }: Props) {
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h1>AntiCaptcha 控制台</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link to="/admin" className="button button-secondary">管理后台</Link>
|
||||||
|
<button onClick={handleLogout} className="button button-secondary">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4">API 使用说明</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
使用以下接口进行验证码识别,需要在请求头中携带 Authorization: Bearer {token.substring(0, 20)}...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ background: 'var(--bg-tertiary)', padding: '16px', borderRadius: '8px' }}>
|
||||||
|
<code style={{ color: 'var(--text-primary)' }}>
|
||||||
|
POST /api/ocr - OCR 文字识别<br/>
|
||||||
|
POST /api/slider/match - 滑块缺口匹配<br/>
|
||||||
|
POST /api/compare/similarity - 图片相似度对比<br/>
|
||||||
|
POST /api/rotate/single/rotate - 单图旋转验证码<br/>
|
||||||
|
POST /api/detection/icon - 图标检测<br/>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4">账户信息</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
请联系管理员获取更多信息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onInstall: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InstallPage({ onInstall }: Props) {
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [config, setConfig] = useState({
|
||||||
|
database: {
|
||||||
|
type: 'sqlite',
|
||||||
|
host: 'localhost',
|
||||||
|
port: 3306,
|
||||||
|
user: 'root',
|
||||||
|
password: '',
|
||||||
|
database: 'anticaptcha',
|
||||||
|
sqlite: { path: './data/app.db' }
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
enabled: false,
|
||||||
|
host: 'localhost',
|
||||||
|
port: 6379,
|
||||||
|
password: '',
|
||||||
|
db: 0
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
username: 'admin',
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/install', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || '安装失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onInstall()
|
||||||
|
window.location.href = '/'
|
||||||
|
} catch {
|
||||||
|
setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ maxWidth: '600px', marginTop: '50px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h1 style={{ marginBottom: '8px' }}>AntiCaptcha 安装向导</h1>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '32px' }}>
|
||||||
|
步骤 {step} / 3
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-4">数据库配置</h2>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">数据库类型</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={config.database.type}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
database: { ...config.database, type: e.target.value }
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="sqlite">SQLite</option>
|
||||||
|
<option value="mysql">MySQL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.database.type === 'sqlite' ? (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">数据库文件路径</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={config.database.sqlite.path}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
database: {
|
||||||
|
...config.database,
|
||||||
|
sqlite: { path: e.target.value }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">主机</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={config.database.host}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
database: { ...config.database, host: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">端口</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
value={config.database.port}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
database: { ...config.database, port: Number(e.target.value) }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">用户名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={config.database.user}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
database: { ...config.database, user: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={config.database.password}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
database: { ...config.database, password: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">数据库名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={config.database.database}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
database: { ...config.database, database: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="button" onClick={() => setStep(2)}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-4">Redis 配置(可选)</h2>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.redis.enabled}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
redis: { ...config.redis, enabled: e.target.checked }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
启用 Redis
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.redis.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">主机</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={config.redis.host}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
redis: { ...config.redis, host: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">端口</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
value={config.redis.port}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
redis: { ...config.redis, port: Number(e.target.value) }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={config.redis.password}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
redis: { ...config.redis, password: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">数据库</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
value={config.redis.db}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
redis: { ...config.redis, db: Number(e.target.value) }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="button button-secondary" onClick={() => setStep(1)}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button className="button" onClick={() => setStep(3)}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-4">管理员账号</h2>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">用户名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={config.admin.username}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
admin: { ...config.admin, username: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={config.admin.password}
|
||||||
|
onChange={e => setConfig({
|
||||||
|
...config,
|
||||||
|
admin: { ...config.admin, password: e.target.value }
|
||||||
|
})}
|
||||||
|
placeholder="请输入管理员密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="button button-secondary" onClick={() => setStep(2)}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? '安装中...' : '完成安装'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLogin: (token: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage({ onLogin }: Props) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || '登录失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('token', data.access_token)
|
||||||
|
onLogin(data.access_token)
|
||||||
|
} catch {
|
||||||
|
setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ maxWidth: '400px', marginTop: '100px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}>登录</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">用户名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<button type="submit" className="button" style={{ width: '100%' }} disabled={loading}>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<a href="/register" style={{ color: 'var(--primary)' }}>没有账号?立即注册</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password, registration_code: code }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || '注册失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess('注册成功,请登录')
|
||||||
|
setTimeout(() => window.location.href = '/login', 2000)
|
||||||
|
} catch {
|
||||||
|
setError('网络错误')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ maxWidth: '400px', marginTop: '100px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}>注册</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">用户名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">注册码</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value)}
|
||||||
|
placeholder="请输入注册码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
{success && <div className="success">{success}</div>}
|
||||||
|
|
||||||
|
<button type="submit" className="button" style={{ width: '100%' }} disabled={loading}>
|
||||||
|
{loading ? '注册中...' : '注册'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<a href="/login" style={{ color: 'var(--primary)' }}>已有账号?立即登录</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:6688',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user