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