Files
vision-tool/internal/handler/vision_handler.go
Geliebte a3bbf33358 feat: 重构为 Go Web 脚手架——在线图片识别 Web 服务 + vision-cli 独立工具
- Gin + config.yaml + SQLite 后端骨架,JWT 登录 + 管理员初始化
- web/ 原生单页:拖拽多图上传 → Agnes 免费识别 → 结果复制
- HTTP API:/api/v1/vision/analyze 免鉴权 + 令牌桶限流,支持程序直调
- cmd/vision-cli 独立 CLI 工具(复用 service 层,不依赖 Web 服务)
- build.bat / build.sh 构建脚本,移除 mcp-server
- 统一响应格式 {code, message, data},CORS/JWT/限流中间件

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 23:59:17 +08:00

125 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"vision-tool/internal/config"
"vision-tool/internal/model"
"vision-tool/internal/service"
)
// VisionHandler HTTP 处理器:在线图片识别。
type VisionHandler struct {
svc *service.VisionService
upload config.UploadConfig
}
func NewVisionHandler(svc *service.VisionService, upload config.UploadConfig) *VisionHandler {
return &VisionHandler{svc: svc, upload: upload}
}
// Analyze POST /api/v1/vision/analyze — 上传 1-N 张图片并调用免费视觉模型识别。
//
// multipart 表单字段:
//
// images 图片文件(可多个,支持 png/jpg/gif/webp/bmp/tiff)
// prompt 可选,自定义分析提问(默认:结构化描述图片内容)
//
// 免鉴权 + 限流,方便 curl / Claude Code 直接调用。
func (h *VisionHandler) Analyze(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请使用 multipart/form-data 上传图片"))
return
}
files := form.File["images"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请至少上传一张图片 (images)"))
return
}
// prompt 字段可选:未传时表单里没有该 key(切片为空),直接取 [0] 会越界 panic
prompt := ""
if len(form.Value["prompt"]) > 0 {
prompt = strings.TrimSpace(form.Value["prompt"][0])
}
// 保存上传图片到 uploads/,返回本地路径列表
paths := make([]string, 0, len(files))
urls := make([]string, 0, len(files))
for _, fh := range files {
path, url, err := h.saveImage(fh)
if err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, err.Error()))
return
}
paths = append(paths, path)
urls = append(urls, url)
}
// 调用免费视觉模型(Web 端默认开启 thinking)
result, err := h.svc.Analyze(paths, prompt, false)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, "识别失败: "+err.Error()))
return
}
c.JSON(http.StatusOK, model.OK(gin.H{
"result": result.Content,
"images": urls,
"usage": result.Usage,
}))
}
// saveImage 校验并保存上传图片,返回磁盘路径与访问 URL。
func (h *VisionHandler) saveImage(fh *multipart.FileHeader) (string, string, error) {
ext := strings.ToLower(filepath.Ext(fh.Filename))
allowed := map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".bmp": true, ".tiff": true, ".tif": true}
if !allowed[ext] {
return "", "", fmt.Errorf("不支持的图片格式: %s(支持 png/jpg/gif/webp/bmp/tiff)", fh.Filename)
}
if fh.Size > h.upload.MaxSize {
return "", "", fmt.Errorf("图片超过大小限制: %s", fh.Filename)
}
src, err := fh.Open()
if err != nil {
return "", "", fmt.Errorf("读取上传文件: %w", err)
}
defer src.Close()
if err := os.MkdirAll(h.upload.Path, 0755); err != nil {
return "", "", fmt.Errorf("创建上传目录: %w", err)
}
name := randomName() + ext
diskPath := filepath.Join(h.upload.Path, name)
dst, err := os.Create(diskPath)
if err != nil {
return "", "", fmt.Errorf("保存文件: %w", err)
}
defer dst.Close()
if _, err := dst.ReadFrom(src); err != nil {
return "", "", fmt.Errorf("写入文件: %w", err)
}
return diskPath, "/uploads/" + name, nil
}
// randomName 生成 16 字节 hex 随机文件名。
func randomName() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}