- 移除管理员初始化/登录/JWT 流程,改为固定进入密钥(19735500), 首次启动自动初始化到数据库 settings 表(可改库更新) - analyze 接口支持可选密钥鉴权:X-Access-Key 请求头或 access_key 字段, 带密钥强制校验(错误 401),不带密钥保持免鉴权 - 解锁后顶栏"复制 API 文档"按钮:以当前访问 URL 为基础地址动态生成 Markdown 文档(免鉴权/带密钥两种 curl 示例,供智能体直接调用) - 白昼/黑夜主题切换:CSS 变量驱动,localStorage 持久化,默认跟随系统 - 修复 hidden 属性被 display:flex 覆盖导致解锁层无法隐藏的问题 - 页面去除"免费/Agnes"文案,页脚署名青梧映星软件开发工作室 - build.bat/build.sh 简化为 Linux 交叉编译(CGO_ENABLED=0) - 依赖精简:移除 golang-jwt/bcrypt,直接依赖仅 gin/sqlite/gorm/yaml Co-Authored-By: Claude <noreply@anthropic.com>
137 lines
4.0 KiB
Go
137 lines
4.0 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
|
||
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)
|
||
}
|