- 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>
125 lines
3.5 KiB
Go
125 lines
3.5 KiB
Go
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)
|
||
}
|