commit 524c4041948630ffebf7cc9b7d92a979fb51a825 Author: Admin Date: Thu Jul 16 08:56:38 2026 +0000 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 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..9e575b3 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..075bfe2 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d58977b --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b3fe03c --- /dev/null +++ b/LICENSE @@ -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. 禁止滥用:不得将本项目用于黑产或其他不当商业用途。 + +使用视为同意上述条款,如不同意请立即停止使用并删除本项目。 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9664883 --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4e1f1e4 --- /dev/null +++ b/cmd/server/main.go @@ -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) +} \ No newline at end of file diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml new file mode 100644 index 0000000..4d21470 --- /dev/null +++ b/deploy/config.example.yaml @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e49cc67 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6581c4c --- /dev/null +++ b/go.mod @@ -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 +) \ No newline at end of file diff --git a/internal/captcha/handler.go b/internal/captcha/handler.go new file mode 100644 index 0000000..0ca4ed3 --- /dev/null +++ b/internal/captcha/handler.go @@ -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 +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..49bc1b8 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..b6d89df --- /dev/null +++ b/internal/database/database.go @@ -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 +} \ No newline at end of file diff --git a/internal/handler/captcha.go b/internal/handler/captcha.go new file mode 100644 index 0000000..1be24f6 --- /dev/null +++ b/internal/handler/captcha.go @@ -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": "设置成功"}) +} \ No newline at end of file diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..c741a2a --- /dev/null +++ b/internal/middleware/auth.go @@ -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 +} \ No newline at end of file diff --git a/internal/model/models.go b/internal/model/models.go new file mode 100644 index 0000000..34c2017 --- /dev/null +++ b/internal/model/models.go @@ -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"` +} diff --git a/pkg/onnx/onnx.go b/pkg/onnx/onnx.go new file mode 100644 index 0000000..657a44b --- /dev/null +++ b/pkg/onnx/onnx.go @@ -0,0 +1,118 @@ +package onnx + +/* +#cgo CXXFLAGS: -std=c++17 +#cgo linux LDFLAGS: -lonnxruntime -ldl +#cgo darwin LDFLAGS: -lonnxruntime -framework CoreFoundation + +#include +#include + +// 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() +} \ No newline at end of file diff --git a/pkg/opencv/opencv.cpp b/pkg/opencv/opencv.cpp new file mode 100644 index 0000000..5680d4a --- /dev/null +++ b/pkg/opencv/opencv.cpp @@ -0,0 +1,178 @@ +#include +#include +#include +#include +#include +#include + +static std::string last_error; + +extern "C" { + +// 图像解码 +Image* cv_imdecode(const unsigned char* buf, size_t size) { + try { + std::vector 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 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" \ No newline at end of file diff --git a/pkg/opencv/opencv.go b/pkg/opencv/opencv.go new file mode 100644 index 0000000..b9c0b5d --- /dev/null +++ b/pkg/opencv/opencv.go @@ -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 + +#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 +} \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..94eef33 --- /dev/null +++ b/scripts/install.sh @@ -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 "$@" \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a3a030d --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + AntiCaptcha - 验证码识别服务 + + +
+ + + \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..d4d0b95 --- /dev/null +++ b/web/package.json @@ -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" + } +} \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..2f3a757 --- /dev/null +++ b/web/src/App.tsx @@ -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(null) + const [token, setToken] = useState(localStorage.getItem('token')) + + useEffect(() => { + // 检查是否已安装 + fetch('/api/install/check') + .then(res => res.json()) + .then(data => setIsInstalled(data.installed)) + .catch(() => setIsInstalled(false)) + }, []) + + if (isInstalled === null) { + return
加载中...
+ } + + if (!isInstalled) { + return ( + + + setIsInstalled(true)} />} /> + } /> + + + ) + } + + return ( + + + } /> + } /> + : } /> + : } /> + } /> + + + ) +} + +export default App \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..97c5b11 --- /dev/null +++ b/web/src/index.css @@ -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; + } +} \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..2fbbbc1 --- /dev/null +++ b/web/src/main.tsx @@ -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( + + + , +) \ No newline at end of file diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx new file mode 100644 index 0000000..4e25d79 --- /dev/null +++ b/web/src/pages/Admin.tsx @@ -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([]) + 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 ( +
+

管理后台

+ +
+

生成注册码

+ +
+
+ + setPoints(Number(e.target.value))} + /> +
+ + +
+
+ +
+

注册码列表

+ + {codes.length === 0 ? ( +

暂无注册码,点击上方按钮生成

+ ) : ( +
+ + + + + + + + + + {codes.map((code: any) => ( + + + + + + ))} + +
注册码积分状态
{code.code}{code.points} + {code.is_used ? '已使用' : '未使用'} +
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx new file mode 100644 index 0000000..79d20dd --- /dev/null +++ b/web/src/pages/Home.tsx @@ -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 ( +
+
+

AntiCaptcha 控制台

+
+ 管理后台 + +
+
+ +
+

API 使用说明

+

+ 使用以下接口进行验证码识别,需要在请求头中携带 Authorization: Bearer {token.substring(0, 20)}... +

+ +
+ + POST /api/ocr - OCR 文字识别
+ POST /api/slider/match - 滑块缺口匹配
+ POST /api/compare/similarity - 图片相似度对比
+ POST /api/rotate/single/rotate - 单图旋转验证码
+ POST /api/detection/icon - 图标检测
+
+
+
+ +
+

账户信息

+

+ 请联系管理员获取更多信息 +

+
+
+ ) +} \ No newline at end of file diff --git a/web/src/pages/Install.tsx b/web/src/pages/Install.tsx new file mode 100644 index 0000000..f42aaab --- /dev/null +++ b/web/src/pages/Install.tsx @@ -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 ( +
+
+

AntiCaptcha 安装向导

+

+ 步骤 {step} / 3 +

+ + {step === 1 && ( + <> +

数据库配置

+ +
+ + +
+ + {config.database.type === 'sqlite' ? ( +
+ + setConfig({ + ...config, + database: { + ...config.database, + sqlite: { path: e.target.value } + } + })} + /> +
+ ) : ( + <> +
+
+ + setConfig({ + ...config, + database: { ...config.database, host: e.target.value } + })} + /> +
+
+ + setConfig({ + ...config, + database: { ...config.database, port: Number(e.target.value) } + })} + /> +
+
+ +
+
+ + setConfig({ + ...config, + database: { ...config.database, user: e.target.value } + })} + /> +
+
+ + setConfig({ + ...config, + database: { ...config.database, password: e.target.value } + })} + /> +
+
+ +
+ + setConfig({ + ...config, + database: { ...config.database, database: e.target.value } + })} + /> +
+ + )} + + + + )} + + {step === 2 && ( + <> +

Redis 配置(可选)

+ +
+ +
+ + {config.redis.enabled && ( + <> +
+
+ + setConfig({ + ...config, + redis: { ...config.redis, host: e.target.value } + })} + /> +
+
+ + setConfig({ + ...config, + redis: { ...config.redis, port: Number(e.target.value) } + })} + /> +
+
+ +
+
+ + setConfig({ + ...config, + redis: { ...config.redis, password: e.target.value } + })} + /> +
+
+ + setConfig({ + ...config, + redis: { ...config.redis, db: Number(e.target.value) } + })} + /> +
+
+ + )} + +
+ + +
+ + )} + + {step === 3 && ( + <> +

管理员账号

+ +
+ + setConfig({ + ...config, + admin: { ...config.admin, username: e.target.value } + })} + /> +
+ +
+ + setConfig({ + ...config, + admin: { ...config.admin, password: e.target.value } + })} + placeholder="请输入管理员密码" + /> +
+ + {error &&
{error}
} + +
+ + +
+ + )} +
+
+ ) +} \ No newline at end of file diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..8bb9a17 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -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 ( +
+
+

登录

+ +
+
+ + setUsername(e.target.value)} + placeholder="请输入用户名" + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="请输入密码" + required + /> +
+ + {error &&
{error}
} + + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/web/src/pages/Register.tsx b/web/src/pages/Register.tsx new file mode 100644 index 0000000..192529e --- /dev/null +++ b/web/src/pages/Register.tsx @@ -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 ( +
+
+

注册

+ +
+
+ + setUsername(e.target.value)} + placeholder="请输入用户名" + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="请输入密码" + required + /> +
+ +
+ + setCode(e.target.value)} + placeholder="请输入注册码" + required + /> +
+ + {error &&
{error}
} + {success &&
{success}
} + + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d0104ed --- /dev/null +++ b/web/tsconfig.json @@ -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" }] +} \ No newline at end of file diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..4eb43d0 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..eabde20 --- /dev/null +++ b/web/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', + }, + }, +}) \ No newline at end of file