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:
89
internal/service/auth_service.go
Normal file
89
internal/service/auth_service.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"vision-tool/internal/middleware"
|
||||
"vision-tool/internal/model"
|
||||
"vision-tool/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAdminExists = errors.New("管理员已初始化,禁止重复初始化")
|
||||
ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
)
|
||||
|
||||
// AuthService 认证业务:管理员初始化 / 登录 / 用户信息。
|
||||
type AuthService struct {
|
||||
repo *repository.UserRepository
|
||||
secret string
|
||||
expire time.Duration
|
||||
}
|
||||
|
||||
func NewAuthService(repo *repository.UserRepository, secret string, expire time.Duration) *AuthService {
|
||||
return &AuthService{repo: repo, secret: secret, expire: expire}
|
||||
}
|
||||
|
||||
// CheckAdmin 判断是否已存在管理员(方式 A:check + init)。
|
||||
func (s *AuthService) CheckAdmin() (bool, error) {
|
||||
count, err := s.repo.Count()
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// InitAdmin 初始化管理员,已存在时拒绝。
|
||||
func (s *AuthService) InitAdmin(username, password string) error {
|
||||
initialized, err := s.CheckAdmin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if initialized {
|
||||
return ErrAdminExists
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Role: "admin",
|
||||
}
|
||||
return s.repo.Create(user)
|
||||
}
|
||||
|
||||
// Login 校验账号密码,签发 JWT token。
|
||||
func (s *AuthService) Login(username, password string) (string, error) {
|
||||
user, err := s.repo.FindByUsername(username)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
claims := middleware.Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.expire)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.secret))
|
||||
}
|
||||
|
||||
// Profile 按 ID 返回用户信息。
|
||||
func (s *AuthService) Profile(userID uint) (*model.User, error) {
|
||||
return s.repo.FindByID(userID)
|
||||
}
|
||||
253
internal/service/vision_service.go
Normal file
253
internal/service/vision_service.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vision-tool/internal/config"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// API Types (OpenAI-compatible format used by Agnes)
|
||||
// ============================================================================
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content []MessagePart `json:"content"`
|
||||
}
|
||||
|
||||
type MessagePart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
type ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
ChatTemplateKwargs *ChatTemplateKwargs `json:"chat_template_kwargs,omitempty"`
|
||||
}
|
||||
|
||||
// ChatTemplateKwargs 开启 Agnes 的 thinking 模式(OpenAI 兼容格式)。
|
||||
type ChatTemplateKwargs struct {
|
||||
EnableThinking bool `json:"enable_thinking"`
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message RespMessage `json:"message"`
|
||||
}
|
||||
|
||||
type RespMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// AnalyzeResult 识别结果。
|
||||
type AnalyzeResult struct {
|
||||
Content string
|
||||
Usage *Usage
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Vision Service
|
||||
// ============================================================================
|
||||
|
||||
// VisionService 调用 Agnes 免费视觉模型识别图片内容。
|
||||
type VisionService struct {
|
||||
apiKey string
|
||||
model string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func NewVisionService(cfg *config.AIConfig) *VisionService {
|
||||
return &VisionService{
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
baseURL: cfg.BaseURL,
|
||||
maxRetries: 3,
|
||||
httpClient: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze 分析一张或多张图片(本地路径或 http(s) URL 均可),返回模型文本与 token 用量。
|
||||
// noThinking 为 true 时关闭 thinking 模式(更快、更低负载)。
|
||||
func (s *VisionService) Analyze(images []string, prompt string, noThinking bool) (*AnalyzeResult, error) {
|
||||
if prompt == "" {
|
||||
if len(images) > 1 {
|
||||
prompt = "请详细描述这些图片的内容,比较它们之间的异同。请用中文回答。"
|
||||
} else {
|
||||
prompt = "请详细描述这张图片的内容。如果图片中有文字,请完整识别出来。请用中文回答。"
|
||||
}
|
||||
}
|
||||
|
||||
var parts []MessagePart
|
||||
for _, path := range images {
|
||||
content, err := s.buildImageContent(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取图片 %s: %w", path, err)
|
||||
}
|
||||
parts = append(parts, content)
|
||||
}
|
||||
parts = append(parts, MessagePart{Type: "text", Text: prompt})
|
||||
|
||||
req := ChatRequest{
|
||||
Model: s.model,
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: parts},
|
||||
},
|
||||
ChatTemplateKwargs: &ChatTemplateKwargs{EnableThinking: !noThinking},
|
||||
}
|
||||
|
||||
resp, err := s.callWithRetry(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("模型未返回内容")
|
||||
}
|
||||
return &AnalyzeResult{Content: resp.Choices[0].Message.Content, Usage: resp.Usage}, nil
|
||||
}
|
||||
|
||||
// buildImageContent 构建图片消息块:URL 直接使用,本地文件 base64 编码。
|
||||
func (s *VisionService) buildImageContent(path string) (MessagePart, error) {
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return MessagePart{
|
||||
Type: "image_url",
|
||||
ImageURL: &ImageURL{URL: path},
|
||||
}, nil
|
||||
}
|
||||
|
||||
imgData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return MessagePart{}, err
|
||||
}
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", detectMimeType(path), base64.StdEncoding.EncodeToString(imgData))
|
||||
return MessagePart{
|
||||
Type: "image_url",
|
||||
ImageURL: &ImageURL{URL: dataURL},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// callWithRetry 调用 API,对 429/5xx/rate/busy 指数退避重试(2s → 5s → 8s → 11s)。
|
||||
func (s *VisionService) callWithRetry(req ChatRequest) (*ChatResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= s.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := time.Duration(2+attempt*3) * time.Second
|
||||
fmt.Fprintf(os.Stderr, "Retrying in %v (attempt %d/%d)...\n", delay, attempt, s.maxRetries)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
resp, err := s.call(req)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "429") ||
|
||||
strings.Contains(errStr, "500") ||
|
||||
strings.Contains(errStr, "502") ||
|
||||
strings.Contains(errStr, "503") ||
|
||||
strings.Contains(errStr, "rate") ||
|
||||
strings.Contains(errStr, "busy") {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("重试 %d 次仍失败: %w", s.maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func (s *VisionService) call(req ChatRequest) (*ChatResponse, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodPost, s.baseURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+s.apiKey)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("调用 API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var chatResp ChatResponse
|
||||
if json.Unmarshal(respBody, &chatResp) == nil && chatResp.Error != nil {
|
||||
return nil, fmt.Errorf("API error (HTTP %d): [%s] %s",
|
||||
resp.StatusCode, chatResp.Error.Code, chatResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp ChatResponse
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, fmt.Errorf("解析响应: %w\nRaw: %s", err, string(respBody))
|
||||
}
|
||||
return &chatResp, nil
|
||||
}
|
||||
|
||||
// detectMimeType 按扩展名判断图片 MIME 类型。
|
||||
func detectMimeType(path string) string {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".bmp":
|
||||
return "image/bmp"
|
||||
case ".tiff", ".tif":
|
||||
return "image/tiff"
|
||||
case ".png":
|
||||
fallthrough
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user