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 access *service.AccessService upload config.UploadConfig } func NewVisionHandler(svc *service.VisionService, access *service.AccessService, upload config.UploadConfig) *VisionHandler { return &VisionHandler{svc: svc, access: access, upload: upload} } // Analyze POST /api/v1/vision/analyze — 上传 1-N 张图片并调用免费视觉模型识别。 // // multipart 表单字段: // // images 图片文件(可多个,支持 png/jpg/gif/webp/bmp/tiff) // prompt 可选,自定义分析提问(默认:结构化描述图片内容) // // 鉴权(可选):提供 X-Access-Key 请求头 或 access_key 表单字段时校验进入密钥, // 密钥错误返回 401;不提供时保持免鉴权调用,方便 curl / Claude Code 直接使用。 func (h *VisionHandler) Analyze(c *gin.Context) { // 可选密钥校验:带密钥必须正确 key := c.GetHeader("X-Access-Key") if key == "" { key = c.PostForm("access_key") } if key != "" && !h.access.VerifyKey(key) { c.JSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "密钥错误")) return } 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) }