feat: initial commit - Go + CGO captcha recognition service
Build and Deploy / build-frontend (push) Failing after 2s
Build and Deploy / build-backend (push) Has been skipped
Build and Deploy / build-docker (push) Has been skipped
Build and Deploy / deploy (push) Has been skipped

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:
2026-07-16 08:56:38 +00:00
commit 524c404194
32 changed files with 2971 additions and 0 deletions
+126
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+34
View File
@@ -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. 禁止滥用:不得将本项目用于黑产或其他不当商业用途。
使用视为同意上述条款,如不同意请立即停止使用并删除本项目。
+91
View File
@@ -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
+183
View File
@@ -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)
}
+21
View File
@@ -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
+40
View File
@@ -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:
+16
View File
@@ -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
)
+146
View File
@@ -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
}
+112
View File
@@ -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
}
+88
View File
@@ -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
}
+332
View File
@@ -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": "设置成功"})
}
+96
View File
@@ -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
}
+106
View File
@@ -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"`
}
+118
View File
@@ -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()
}
+178
View File
@@ -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"
+146
View File
@@ -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
}
+107
View File
@@ -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 "$@"
+13
View File
@@ -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>
+23
View File
@@ -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"
}
}
+49
View File
@@ -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
+160
View File
@@ -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;
}
}
+10
View File
@@ -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>,
)
+101
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}
+319
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+96
View File
@@ -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>
)
}
+21
View File
@@ -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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -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',
},
},
})