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>
This commit is contained in:
124
internal/handler/vision_handler.go
Normal file
124
internal/handler/vision_handler.go
Normal file
@@ -0,0 +1,124 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user