From f9cdeb5d640d5aec25994d7484833d117ba66301 Mon Sep 17 00:00:00 2001 From: Geliebte <1297754537@qq.com> Date: Thu, 13 Aug 2026 01:21:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=A4=E8=AF=81=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E4=B8=BA=E5=9B=BA=E5=AE=9A=E8=BF=9B=E5=85=A5=E5=AF=86=E9=92=A5?= =?UTF-8?q?=20+=20=E9=A1=B5=E9=9D=A2=E5=A2=9E=E5=BC=BA=EF=BC=88API=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3/=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2/?= =?UTF-8?q?=E7=89=88=E6=9D=83=E7=BD=B2=E5=90=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除管理员初始化/登录/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 --- README.md | 32 ++-- build.bat | 14 +- build.sh | 11 +- config.yaml | 7 +- go.mod | 3 +- go.sum | 2 - internal/config/config.go | 9 +- internal/database/database.go | 22 ++- internal/handler/auth_handler.go | 75 ++------- internal/handler/vision_handler.go | 18 ++- internal/middleware/middleware.go | 40 ----- internal/model/model.go | 30 ++-- internal/repository/user_repo.go | 45 ------ internal/router/router.go | 21 +-- internal/service/access_service.go | 26 ++++ internal/service/auth_service.go | 89 ----------- main.go | 4 +- web/css/style.css | 77 ++++++--- web/index.html | 39 ++--- web/js/app.js | 242 +++++++++++++++++------------ 20 files changed, 341 insertions(+), 465 deletions(-) delete mode 100644 internal/repository/user_repo.go create mode 100644 internal/service/access_service.go delete mode 100644 internal/service/auth_service.go diff --git a/README.md b/README.md index cd2ee12..d7132f9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - 🖼️ 多图对比、base64 直传(无需图床/公网 URL) - 🔁 指数退避自动重试(429/5xx:2s → 5s → 8s → 11s) - 🔧 配置:命令行 > 环境变量 > config.yaml > 内置默认 -- 🔒 JWT 登录 + 管理员初始化(脚手架模板能力) +- 🔑 固定进入密钥(默认 `19735500`),密钥初始化到数据库,页面解锁后使用 ## 🚀 快速开始 @@ -32,7 +32,7 @@ go run . # 或 ./vision-tool.exe # 打开 http://localhost:8080 ``` -1. 首次访问:初始化管理员账号(右上角) +1. 输入进入密钥(默认 `19735500`,存于数据库,可改库更新)解锁页面 2. 拖拽或点击选择图片(可多张) 3. (可选)输入自定义提问 4. 点击「开始识别」→ 得到识别结果,可一键复制 @@ -57,14 +57,21 @@ Claude Code 在线调用方式:粘贴图片后执行 统一响应格式:`{code, message, data}`(`code=0` 为成功) -### 在线图片识别(免鉴权 + 限流) +### 在线图片识别(免鉴权或带密钥,限流防刷) ```bash +# 免鉴权调用 curl -F "images=@a.png" -F "images=@b.png" \ -F "prompt=比较这两张图的异同" \ http://localhost:8080/api/v1/vision/analyze + +# 带密钥调用(可选,密钥错误返回 401) +curl -H "X-Access-Key: 19735500" -F "images=@photo.png" \ + http://localhost:8080/api/v1/vision/analyze ``` +鉴权规则:请求头 `X-Access-Key`(或表单字段 `access_key`)提供密钥时强制校验,不提供则放行。 + 响应: ```json @@ -84,11 +91,8 @@ curl -F "images=@a.png" -F "images=@b.png" \ | 接口 | 方法 | 说明 | |---|---|---| | `/api/health` | GET | 健康检查 | -| `/api/v1/admin/check` | GET | 管理员是否已初始化 | -| `/api/v1/admin/init` | POST | 初始化管理员 `{username, password}` | -| `/api/v1/auth/login` | POST | 登录 `{username, password}` → `{token}` | -| `/api/v1/user/profile` | GET | 当前用户信息(需 `Authorization: Bearer `) | -| `/api/v1/vision/analyze` | POST | 图片识别(multipart:`images` × 1-N + 可选 `prompt`) | +| `/api/v1/auth/verify` | POST | 校验进入密钥 `{key}` → `{ok}`(密钥存于数据库) | +| `/api/v1/vision/analyze` | POST | 图片识别(multipart:`images` × 1-N + 可选 `prompt`;可选 `X-Access-Key` 密钥鉴权) | | `/uploads/*` | GET | 上传图片静态访问 | ## ⚙️ 配置(config.yaml) @@ -100,9 +104,8 @@ database: driver: sqlite # 当前仅 sqlite(pure-go,无需 CGO) path: ./data/app.db auto_migrate: true -jwt: - secret: "change-me-in-production" - expire_hours: 720 +auth: + access_key: "19735500" # 进入密钥,首次启动初始化到数据库(可改库更新) upload: path: uploads max_size: 10485760 # 10MB @@ -125,12 +128,11 @@ vision-tool/ │ └── vision-cli/ # 独立 CLI 工具(打包为单个二进制) ├── internal/ │ ├── config/ # 配置加载(yaml + 环境变量覆盖) -│ ├── database/ # GORM + SQLite 初始化 + AutoMigrate +│ ├── database/ # GORM + SQLite 初始化 + AutoMigrate + 密钥种子数据 │ ├── model/ # 数据模型 + 统一响应格式 -│ ├── repository/ # 数据访问层 -│ ├── service/ # 业务逻辑(auth / vision 免费模型调用) +│ ├── service/ # 业务逻辑(密钥校验 / vision 免费模型调用) │ ├── handler/ # HTTP 处理器 -│ ├── middleware/ # CORS / JWT / 令牌桶限流 +│ ├── middleware/ # CORS / 令牌桶限流 │ └── router/ # 路由注册 + 前端内嵌 + SPA 回退 └── web/ # 原生单页(在线图片识别,内嵌进二进制) ├── index.html diff --git a/build.bat b/build.bat index 5cf732e..edc0655 100644 --- a/build.bat +++ b/build.bat @@ -1,10 +1,8 @@ @echo off -rem ============================================ -rem vision-tool 构建脚本(Windows 本地) -rem 产物:vision-tool.exe(Web 服务) / vision-cli.exe(CLI 工具) -rem ============================================ +rem Build Linux binaries: vision-tool / vision-cli +set GOOS=linux +set GOARCH=amd64 set CGO_ENABLED=0 -go build -o vision-tool.exe . -go build -o vision-cli.exe ./cmd/vision-cli -echo. -echo Build done: vision-tool.exe / vision-cli.exe +go build -o vision-tool . +go build -o vision-cli ./cmd/vision-cli +echo Build done: vision-tool / vision-cli (linux/amd64) diff --git a/build.sh b/build.sh index 3cb4ca7..9cc3141 100644 --- a/build.sh +++ b/build.sh @@ -1,10 +1,5 @@ #!/bin/bash -# ============================================ -# vision-tool 构建脚本(Linux 交叉编译,用于部署) -# 产物:vision-tool(Web 服务) / vision-cli(CLI 工具) -# ============================================ -set -e -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o vision-tool . -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o vision-cli ./cmd/vision-cli -chmod +x vision-tool vision-cli +# 构建 Linux 可执行文件(vision-tool 服务 / vision-cli 工具) +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o vision-tool . +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o vision-cli ./cmd/vision-cli echo "Build done: vision-tool / vision-cli (linux/amd64)" diff --git a/config.yaml b/config.yaml index e6c8c3e..033e2e2 100644 --- a/config.yaml +++ b/config.yaml @@ -8,10 +8,9 @@ database: path: ./data/app.db auto_migrate: true # 启动自动建表,生产环境建议 false -# ========== JWT 认证 ========== -jwt: - secret: "change-me-in-production" - expire_hours: 720 # Token 有效期(小时) +# ========== 访问密钥 ========== +auth: + access_key: "19735500" # 进入密钥,首次启动初始化到数据库 # ========== 文件上传 ========== upload: diff --git a/go.mod b/go.mod index 51dcfc6..a6e3fac 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,6 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 - github.com/golang-jwt/jwt/v5 v5.3.1 - golang.org/x/crypto v0.55.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.31.2 ) @@ -42,6 +40,7 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/go.sum b/go.sum index dbfee6d..4e9fcde 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,6 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/internal/config/config.go b/internal/config/config.go index ee4a56b..47ee774 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,7 @@ import ( type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` - JWT JWTConfig `yaml:"jwt"` + Auth AuthConfig `yaml:"auth"` Upload UploadConfig `yaml:"upload"` AI AIConfig `yaml:"ai"` Env EnvConfig `yaml:"env"` @@ -27,9 +27,8 @@ type DatabaseConfig struct { AutoMigrate bool `yaml:"auto_migrate"` } -type JWTConfig struct { - Secret string `yaml:"secret"` - ExpireHours int `yaml:"expire_hours"` +type AuthConfig struct { + AccessKey string `yaml:"access_key"` // 进入密钥,首次启动初始化到数据库 } type UploadConfig struct { @@ -56,7 +55,7 @@ func DefaultConfig() *Config { return &Config{ Server: ServerConfig{Port: 8080}, Database: DatabaseConfig{Driver: "sqlite", Path: "./data/app.db", AutoMigrate: true}, - JWT: JWTConfig{Secret: "change-me-in-production", ExpireHours: 720}, + Auth: AuthConfig{AccessKey: "19735500"}, Upload: UploadConfig{Path: "uploads", MaxSize: 10 * 1024 * 1024}, AI: AIConfig{ APIKey: hardcodedAPIKey, diff --git a/internal/database/database.go b/internal/database/database.go index 89c000c..95e32ff 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -13,9 +13,10 @@ import ( "vision-tool/internal/model" ) -// Init 初始化数据库连接并执行 AutoMigrate。 +// Init 初始化数据库连接,执行 AutoMigrate,并写入初始化数据(进入密钥)。 // 使用 pure-go 的 glebarez/sqlite 驱动,CGO_ENABLED=0 下可编译运行。 -func Init(cfg *config.DatabaseConfig) (*gorm.DB, error) { +// seedAccessKey 为空时跳过密钥初始化。 +func Init(cfg *config.DatabaseConfig, seedAccessKey string) (*gorm.DB, error) { var db *gorm.DB var err error @@ -39,10 +40,25 @@ func Init(cfg *config.DatabaseConfig) (*gorm.DB, error) { // auto_migrate:自动建表/更新表结构,生产环境建议手动管理 if cfg.AutoMigrate { - if err := db.AutoMigrate(&model.User{}); err != nil { + if err := db.AutoMigrate(&model.Setting{}); err != nil { return nil, fmt.Errorf("自动建表: %w", err) } } + // init_data:首次启动写入默认进入密钥(仅当记录不存在时) + if seedAccessKey != "" { + var count int64 + if err := db.Model(&model.Setting{}). + Where("key = ?", model.KeyAccessKey). + Count(&count).Error; err != nil { + return nil, fmt.Errorf("检查配置: %w", err) + } + if count == 0 { + if err := db.Create(&model.Setting{Key: model.KeyAccessKey, Value: seedAccessKey}).Error; err != nil { + return nil, fmt.Errorf("初始化进入密钥: %w", err) + } + } + } + return db, nil } diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index da723dc..f4bf781 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -1,7 +1,6 @@ package handler import ( - "errors" "net/http" "github.com/gin-gonic/gin" @@ -10,79 +9,27 @@ import ( "vision-tool/internal/service" ) -// AuthHandler HTTP 处理器:管理员初始化 / 登录 / 用户信息。 +// AuthHandler HTTP 处理器:进入密钥校验。 type AuthHandler struct { - svc *service.AuthService + svc *service.AccessService } -func NewAuthHandler(svc *service.AuthService) *AuthHandler { +func NewAuthHandler(svc *service.AccessService) *AuthHandler { return &AuthHandler{svc: svc} } -type initRequest struct { - Username string `json:"username" binding:"required,min=2,max=32"` - Password string `json:"password" binding:"required,min=6,max=64"` -} - -type loginRequest struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` -} - -// Check GET /api/v1/admin/check — 管理员是否已初始化 -func (h *AuthHandler) Check(c *gin.Context) { - initialized, err := h.svc.CheckAdmin() - if err != nil { - c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error())) - return +// Verify POST /api/v1/auth/verify — 校验进入密钥(固定密钥,存于数据库) +func (h *AuthHandler) Verify(c *gin.Context) { + var req struct { + Key string `json:"key" binding:"required"` } - c.JSON(http.StatusOK, model.OK(gin.H{"initialized": initialized})) -} - -// Init POST /api/v1/admin/init — 初始化管理员(仅首次) -func (h *AuthHandler) Init(c *gin.Context) { - var req initRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "用户名 2-32 位,密码至少 6 位")) + c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请输入进入密钥")) return } - if err := h.svc.InitAdmin(req.Username, req.Password); err != nil { - if errors.Is(err, service.ErrAdminExists) { - c.JSON(http.StatusConflict, model.Err(model.CodeConflict, err.Error())) - return - } - c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error())) + if !h.svc.VerifyKey(req.Key) { + c.JSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "密钥错误")) return } - c.JSON(http.StatusOK, model.OK(gin.H{"message": "管理员初始化成功"})) -} - -// Login POST /api/v1/auth/login — 登录获取 JWT token -func (h *AuthHandler) Login(c *gin.Context) { - var req loginRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请输入用户名和密码")) - return - } - token, err := h.svc.Login(req.Username, req.Password) - if err != nil { - if errors.Is(err, service.ErrInvalidCredentials) { - c.JSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, err.Error())) - return - } - c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error())) - return - } - c.JSON(http.StatusOK, model.OK(gin.H{"token": token})) -} - -// Profile GET /api/v1/user/profile — 当前用户信息(JWT 保护) -func (h *AuthHandler) Profile(c *gin.Context) { - userID := c.GetUint("user_id") - user, err := h.svc.Profile(userID) - if err != nil { - c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error())) - return - } - c.JSON(http.StatusOK, model.OK(user)) + c.JSON(http.StatusOK, model.OK(gin.H{"ok": true})) } diff --git a/internal/handler/vision_handler.go b/internal/handler/vision_handler.go index e55d8ae..88f9411 100644 --- a/internal/handler/vision_handler.go +++ b/internal/handler/vision_handler.go @@ -20,11 +20,12 @@ import ( // VisionHandler HTTP 处理器:在线图片识别。 type VisionHandler struct { svc *service.VisionService + access *service.AccessService upload config.UploadConfig } -func NewVisionHandler(svc *service.VisionService, upload config.UploadConfig) *VisionHandler { - return &VisionHandler{svc: svc, upload: upload} +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 张图片并调用免费视觉模型识别。 @@ -34,8 +35,19 @@ func NewVisionHandler(svc *service.VisionService, upload config.UploadConfig) *V // images 图片文件(可多个,支持 png/jpg/gif/webp/bmp/tiff) // prompt 可选,自定义分析提问(默认:结构化描述图片内容) // -// 免鉴权 + 限流,方便 curl / Claude Code 直接调用。 +// 鉴权(可选):提供 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 上传图片")) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index eec8d71..efb2411 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -3,12 +3,10 @@ package middleware import ( "math" "net/http" - "strings" "sync" "time" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" "vision-tool/internal/model" ) @@ -27,44 +25,6 @@ func CORS() gin.HandlerFunc { } } -// ============================================================================ -// JWT 鉴权 -// ============================================================================ - -// Claims JWT 载荷。 -type Claims struct { - UserID uint `json:"uid"` - Username string `json:"username"` - Role string `json:"role"` - jwt.RegisteredClaims -} - -// JWTAuth 校验 Authorization: Bearer ,通过后将用户信息写入 context。 -func JWTAuth(secret string) gin.HandlerFunc { - return func(c *gin.Context) { - header := c.GetHeader("Authorization") - tokenStr, ok := strings.CutPrefix(header, "Bearer ") - if !ok || tokenStr == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "未登录")) - return - } - - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { - return []byte(secret), nil - }) - if err != nil || !token.Valid { - c.AbortWithStatusJSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "登录已过期,请重新登录")) - return - } - - c.Set("user_id", claims.UserID) - c.Set("username", claims.Username) - c.Set("role", claims.Role) - c.Next() - } -} - // ============================================================================ // 内存令牌桶限流(全局限流,防匿名刷接口) // ============================================================================ diff --git a/internal/model/model.go b/internal/model/model.go index 95e9808..0df042e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -2,13 +2,17 @@ package model import "time" -// User 管理员账号。 -type User struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"uniqueIndex;size:64" json:"username"` - PasswordHash string `gorm:"size:255" json:"-"` - Role string `gorm:"size:32" json:"role"` - CreatedAt time.Time `json:"created_at"` +// 配置键 +const ( + KeyAccessKey = "access_key" // 页面进入密钥 +) + +// Setting 应用配置(key-value),初始数据在启动时写入数据库。 +type Setting struct { + Key string `gorm:"primaryKey;size:64" json:"key"` + Value string `gorm:"size:255" json:"value"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Response 统一响应格式 {code, message, data}。 @@ -20,13 +24,13 @@ type Response struct { // 业务错误码 const ( - CodeOK = 0 - CodeBadParam = 400 + CodeOK = 0 + CodeBadParam = 400 CodeUnauthorized = 401 - CodeForbidden = 403 - CodeNotFound = 404 - CodeConflict = 409 - CodeServerErr = 500 + CodeForbidden = 403 + CodeNotFound = 404 + CodeConflict = 409 + CodeServerErr = 500 ) func OK(data interface{}) *Response { diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go deleted file mode 100644 index b9f1c8c..0000000 --- a/internal/repository/user_repo.go +++ /dev/null @@ -1,45 +0,0 @@ -package repository - -import ( - "gorm.io/gorm" - - "vision-tool/internal/model" -) - -// UserRepository 数据访问层:用户表 CRUD。 -type UserRepository struct { - db *gorm.DB -} - -func NewUserRepository(db *gorm.DB) *UserRepository { - return &UserRepository{db: db} -} - -// Count 返回用户总数(用于判断管理员是否已初始化)。 -func (r *UserRepository) Count() (int64, error) { - var count int64 - err := r.db.Model(&model.User{}).Count(&count).Error - return count, err -} - -func (r *UserRepository) Create(user *model.User) error { - return r.db.Create(user).Error -} - -func (r *UserRepository) FindByUsername(username string) (*model.User, error) { - var user model.User - err := r.db.Where("username = ?", username).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (r *UserRepository) FindByID(id uint) (*model.User, error) { - var user model.User - err := r.db.First(&user, id).Error - if err != nil { - return nil, err - } - return &user, nil -} diff --git a/internal/router/router.go b/internal/router/router.go index a157ac8..0f30869 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -5,7 +5,6 @@ import ( "net/http" "path/filepath" "strings" - "time" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -14,23 +13,20 @@ import ( "vision-tool/internal/handler" "vision-tool/internal/middleware" "vision-tool/internal/model" - "vision-tool/internal/repository" "vision-tool/internal/service" ) -// Setup 构建路由,并完成分层依赖注入: -// db → repository → service → handler → router。 +// Setup 构建路由,并完成分层依赖注入。 // webFS 为 //go:embed web 的嵌入文件系统,用于内嵌前端。 func Setup(cfg *config.Config, db *gorm.DB, webFS fs.FS) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery(), middleware.CORS()) // ---------- 分层依赖注入 ---------- - userRepo := repository.NewUserRepository(db) - authSvc := service.NewAuthService(userRepo, cfg.JWT.Secret, time.Duration(cfg.JWT.ExpireHours)*time.Hour) + accessSvc := service.NewAccessService(db) visionSvc := service.NewVisionService(&cfg.AI) - authH := handler.NewAuthHandler(authSvc) - visionH := handler.NewVisionHandler(visionSvc, cfg.Upload) + authH := handler.NewAuthHandler(accessSvc) + visionH := handler.NewVisionHandler(visionSvc, accessSvc, cfg.Upload) // 识别接口免鉴权,用令牌桶限流防刷 analyzeLimiter := middleware.NewRateLimiter(10, 20) @@ -43,13 +39,8 @@ func Setup(cfg *config.Config, db *gorm.DB, webFS fs.FS) *gin.Engine { v1 := api.Group("/v1") { - // 管理员初始化(方式 A:check + init) - v1.GET("/admin/check", authH.Check) - v1.POST("/admin/init", authH.Init) - - // 认证 - v1.POST("/auth/login", authH.Login) - v1.GET("/user/profile", middleware.JWTAuth(cfg.JWT.Secret), authH.Profile) + // 进入密钥校验(固定密钥,初始化在数据库) + v1.POST("/auth/verify", authH.Verify) // 在线图片识别(免鉴权 + 限流) v1.POST("/vision/analyze", analyzeLimiter.Middleware(), visionH.Analyze) diff --git a/internal/service/access_service.go b/internal/service/access_service.go new file mode 100644 index 0000000..0f20b52 --- /dev/null +++ b/internal/service/access_service.go @@ -0,0 +1,26 @@ +package service + +import ( + "gorm.io/gorm" + + "vision-tool/internal/model" +) + +// AccessService 访问密钥校验。密钥为固定进入密钥, +// 首次启动由 database.Init 初始化到数据库(可后续直接改库)。 +type AccessService struct { + db *gorm.DB +} + +func NewAccessService(db *gorm.DB) *AccessService { + return &AccessService{db: db} +} + +// VerifyKey 校验进入密钥是否与数据库中的值一致。 +func (s *AccessService) VerifyKey(key string) bool { + var setting model.Setting + if err := s.db.Where("key = ?", model.KeyAccessKey).First(&setting).Error; err != nil { + return false + } + return setting.Value == key +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go deleted file mode 100644 index 1e4947f..0000000 --- a/internal/service/auth_service.go +++ /dev/null @@ -1,89 +0,0 @@ -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) -} diff --git a/main.go b/main.go index 6cad632..58e53b5 100644 --- a/main.go +++ b/main.go @@ -26,8 +26,8 @@ func main() { log.Fatalf("加载配置失败: %v", err) } - // 2. 初始化数据库 - db, err := database.Init(&cfg.Database) + // 2. 初始化数据库(首次启动自动写入进入密钥) + db, err := database.Init(&cfg.Database, cfg.Auth.AccessKey) if err != nil { log.Fatalf("初始化数据库失败: %v", err) } diff --git a/web/css/style.css b/web/css/style.css index 7ff7bc3..81fc53c 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -1,6 +1,9 @@ /* ===== 基础 ===== */ * { margin: 0; padding: 0; box-sizing: border-box; } +/* 保证 hidden 属性始终生效(防止被 display:flex 等样式覆盖) */ +[hidden] { display: none !important; } + :root { --primary: #4f6ef7; --primary-dark: #3a55d8; @@ -11,6 +14,32 @@ --border: #e5e9f2; --danger: #e5484d; --radius: 12px; + /* 组件级颜色 */ + --chip-bg: #eef1fe; + --dropzone-border: #c7d2fe; + --dropzone-bg: #fafbff; + --dropzone-hover: #eef1fe; + --result-bg: #f8f9fc; + --unlock-bg-1: #eef1fe; + --unlock-bg-2: #f4f6fb; +} + +/* 黑夜模式 */ +[data-theme="dark"] { + --primary: #6d8bff; + --primary-dark: #8aa4ff; + --bg: #0f1524; + --card: #1a2234; + --text: #e6e9f0; + --text-light: #9aa3b5; + --border: #2b3548; + --chip-bg: rgba(109, 139, 255, .18); + --dropzone-border: #3a4760; + --dropzone-bg: #141c2e; + --dropzone-hover: #1e2a42; + --result-bg: #121a2b; + --unlock-bg-1: #0f1524; + --unlock-bg-2: #1a2234; } body { @@ -39,13 +68,16 @@ body { .account { display: flex; align-items: center; gap: 10px; font-size: 14px; } .account .user-chip { - background: #eef1fe; - color: var(--primary-dark); + background: var(--chip-bg); + color: var(--primary); padding: 6px 12px; border-radius: 999px; font-weight: 600; } +/* 顶栏常驻于解锁层之上,主题切换随时可用 */ +.topbar { position: relative; z-index: 150; } + /* ===== 布局 ===== */ .container { flex: 1; @@ -68,15 +100,15 @@ body { /* ===== 上传区 ===== */ .dropzone { - border: 2px dashed #c7d2fe; + border: 2px dashed var(--dropzone-border); border-radius: var(--radius); padding: 40px 20px; text-align: center; cursor: pointer; transition: all .15s ease; - background: #fafbff; + background: var(--dropzone-bg); } -.dropzone:hover, .dropzone.dragover { border-color: var(--primary); background: #eef1fe; } +.dropzone:hover, .dropzone.dragover { border-color: var(--primary); background: var(--dropzone-hover); } .dropzone-icon { font-size: 40px; } .dropzone-title { margin-top: 10px; font-weight: 600; font-size: 15px; } .dropzone-hint { margin-top: 6px; font-size: 12px; color: var(--text-light); } @@ -147,7 +179,7 @@ body { .result-head h2 { font-size: 16px; } .result-body { margin-top: 14px; - background: #f8f9fc; + background: var(--result-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; @@ -171,35 +203,40 @@ body { } @keyframes spin { to { transform: rotate(360deg); } } -/* ===== 模态框 ===== */ -.modal-mask { +/* ===== 进入密钥解锁层 ===== */ +.unlock-mask { position: fixed; inset: 0; - background: rgba(15, 20, 35, .45); + background: linear-gradient(135deg, var(--unlock-bg-1), var(--unlock-bg-2)); display: flex; align-items: center; justify-content: center; z-index: 100; } -.modal { +.unlock-card { background: var(--card); - border-radius: 14px; - padding: 28px; - width: 380px; - box-shadow: 0 20px 60px rgba(0, 0, 0, .2); + border: 1px solid var(--border); + border-radius: 16px; + padding: 36px 32px; + width: 360px; + text-align: center; + box-shadow: 0 20px 60px rgba(16, 24, 40, .12); } -.modal h2 { font-size: 18px; } -.modal-desc { font-size: 13px; color: var(--text-light); margin: 6px 0 16px; } -.modal-input { +.unlock-icon { font-size: 42px; } +.unlock-card h2 { font-size: 20px; margin-top: 10px; } +.unlock-desc { font-size: 13px; color: var(--text-light); margin: 6px 0 18px; } +.unlock-input { width: 100%; - padding: 11px 13px; + padding: 12px 14px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 10px; - font-size: 14px; + font-size: 16px; + letter-spacing: 2px; + text-align: center; outline: none; } -.modal-input:focus { border-color: var(--primary); } +.unlock-input:focus { border-color: var(--primary); } .modal-error { color: var(--danger); font-size: 13px; min-height: 18px; margin-bottom: 4px; } /* ===== 页脚 ===== */ diff --git a/web/index.html b/web/index.html index 6656887..fa6428a 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - Vision Tool — 免费图片识别 + Vision Tool — 在线图片识别 @@ -12,11 +12,12 @@

Vision Tool

-

免费图片识别 · Agnes-2.0-Flash

+

在线图片识别

@@ -49,31 +50,19 @@ - -