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:
2026-08-12 23:59:17 +08:00
parent 8bf77301bd
commit a3bbf33358
25 changed files with 1902 additions and 798 deletions

View 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)
}