diff --git a/Makefile b/Makefile index 7bf437b..4cfda94 100644 --- a/Makefile +++ b/Makefile @@ -8,12 +8,13 @@ dev: db-up @echo "=== 启动前端开发服务器 ===" cd web && npm run dev -# 仅启动数据库 +# 仅启动数据库 + MinIO db-up: - @echo "=== 启动 PostgreSQL ===" + @echo "=== 启动 PostgreSQL + MinIO ===" docker compose -f docker-compose.dev.yml up -d - @sleep 2 - @echo "数据库已启动: localhost:5432" + @sleep 3 + @echo "PostgreSQL: localhost:5433" + @echo "MinIO API: localhost:9000 (控制台 :9001 minioadmin/minioadmin)" # 停止数据库 db-down: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ad284d9..565bf58 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,5 +11,34 @@ services: volumes: - pgdata:/var/lib/postgresql/data + minio: + image: minio/minio:latest + container_name: kefu-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + + # 创建 bucket 并开放下载(开发环境) + minio-init: + image: minio/mc:latest + container_name: kefu-minio-init + depends_on: + - minio + entrypoint: > + /bin/sh -c " + sleep 3; + mc alias set local http://minio:9000 minioadmin minioadmin; + mc mb -p local/kefu || true; + mc anonymous set download local/kefu; + echo 'MinIO bucket kefu ready'; + " + volumes: pgdata: + miniodata: diff --git a/server/cmd/main.go b/server/cmd/main.go index ee48a29..ac98dd5 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -1,13 +1,16 @@ package main import ( + "context" "log" + "time" "github.com/gin-gonic/gin" "kefu-sys/server/internal/config" "kefu-sys/server/internal/handler" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" + "kefu-sys/server/internal/storage" "kefu-sys/server/internal/ws" ) @@ -20,6 +23,19 @@ func main() { // 初始化 JWT middleware.InitJWT(cfg.JWT.Secret) + // 对象存储(MinIO / S3 兼容) + store, err := storage.NewS3(cfg.Storage) + if err != nil { + log.Fatalf("对象存储初始化失败: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := store.EnsureBucket(ctx); err != nil { + log.Printf("警告: 确保存储桶失败(可稍后启动 MinIO): %v", err) + } else { + log.Printf("对象存储就绪: %s bucket=%s public=%s", cfg.Storage.Endpoint, cfg.Storage.Bucket, cfg.Storage.PublicBase) + } + // 启动 WebSocket Hub go ws.DefaultHub.Run() @@ -32,7 +48,7 @@ func main() { r.Use(func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") - c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization") + c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Visitor-Token") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return @@ -46,7 +62,7 @@ func main() { }) // 注册路由 - handler.SetupRoutes(r) + handler.SetupRoutes(r, store, cfg.Storage) log.Printf("客服云服务启动在 :%s", cfg.Server.Port) if err := r.Run(":" + cfg.Server.Port); err != nil { diff --git a/server/go.mod b/server/go.mod index c7a9ec9..7c99d5b 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,10 +3,13 @@ module kefu-sys/server go 1.26.3 require ( + github.com/chai2010/webp v1.4.0 + github.com/disintegration/imaging v1.6.2 github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/gorilla/websocket v1.5.3 - golang.org/x/crypto v0.28.0 + github.com/minio/minio-go/v7 v7.2.1 + golang.org/x/crypto v0.51.0 gorm.io/driver/postgres v1.5.9 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 @@ -15,14 +18,17 @@ require ( require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect @@ -30,22 +36,33 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index 02350a7..000f90d 100644 --- a/server/go.sum +++ b/server/go.sum @@ -2,6 +2,10 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= +github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= @@ -10,6 +14,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -28,9 +36,11 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -47,9 +57,14 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -61,17 +76,27 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw= +github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -82,34 +107,47 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 7cd5a24..40dc678 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "strconv" "time" ) @@ -9,6 +10,7 @@ type Config struct { Server ServerConfig Database DatabaseConfig JWT JWTConfig + Storage StorageConfig } type ServerConfig struct { @@ -30,6 +32,20 @@ type JWTConfig struct { ExpireTime time.Duration } +// StorageConfig S3 兼容对象存储(MinIO / OSS / COS / S3 共用字段) +type StorageConfig struct { + Endpoint string // 如 localhost:9000 或 oss-cn-hangzhou.aliyuncs.com + AccessKey string + SecretKey string + Bucket string + UseSSL bool + Region string + PublicBase string // 浏览器可访问前缀,如 http://localhost:9000/kefu + MaxUploadMB int + MaxImageEdge int // 最长边像素 + WebPQuality float32 +} + func Load() *Config { return &Config{ Server: ServerConfig{ @@ -38,7 +54,7 @@ func Load() *Config { }, Database: DatabaseConfig{ Host: getEnv("DB_HOST", "localhost"), - Port: getEnv("DB_PORT", "5432"), + Port: getEnv("DB_PORT", "5433"), User: getEnv("DB_USER", "postgres"), Password: getEnv("DB_PASSWORD", "postgres"), Name: getEnv("DB_NAME", "kefu_sys"), @@ -48,6 +64,18 @@ func Load() *Config { Secret: getEnv("JWT_SECRET", "kefu-sys-secret-key"), ExpireTime: 24 * time.Hour, }, + Storage: StorageConfig{ + Endpoint: getEnv("STORAGE_ENDPOINT", "localhost:9000"), + AccessKey: getEnv("STORAGE_ACCESS_KEY", "minioadmin"), + SecretKey: getEnv("STORAGE_SECRET_KEY", "minioadmin"), + Bucket: getEnv("STORAGE_BUCKET", "kefu"), + UseSSL: getEnvBool("STORAGE_USE_SSL", false), + Region: getEnv("STORAGE_REGION", "us-east-1"), + PublicBase: getEnv("STORAGE_PUBLIC_BASE_URL", "http://localhost:9000/kefu"), + MaxUploadMB: getEnvInt("STORAGE_MAX_UPLOAD_MB", 10), + MaxImageEdge: getEnvInt("STORAGE_MAX_IMAGE_EDGE", 1920), + WebPQuality: float32(getEnvInt("STORAGE_WEBP_QUALITY", 80)), + }, } } @@ -62,3 +90,27 @@ func getEnv(key, fallback string) string { } return fallback } + +func getEnvBool(key string, fallback bool) bool { + v := os.Getenv(key) + if v == "" { + return fallback + } + b, err := strconv.ParseBool(v) + if err != nil { + return fallback + } + return b +} + +func getEnvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} diff --git a/server/internal/handler/message_validation.go b/server/internal/handler/message_validation.go index 94816d8..cf2be22 100644 --- a/server/internal/handler/message_validation.go +++ b/server/internal/handler/message_validation.go @@ -1,17 +1,24 @@ package handler import ( - "encoding/base64" "errors" "strings" "unicode/utf8" + + "kefu-sys/server/internal/storage" ) const ( maxTextMessageLength = 2000 - maxImageMessageSize = 5 * 1024 * 1024 ) +// imagePublicBase 由 SetupRoutes 注入,用于校验图片消息 URL 归属 +var imagePublicBase string + +func SetImagePublicBase(base string) { + imagePublicBase = storage.NormalizePublicBase(base) +} + func validateMessageContent(messageType, content string) (string, error) { switch messageType { case "text": @@ -24,19 +31,22 @@ func validateMessageContent(messageType, content string) (string, error) { } return content, nil case "image": - parts := strings.SplitN(content, ",", 2) - if len(parts) != 2 { - return "", errors.New("图片格式无效") + content = strings.TrimSpace(content) + if content == "" { + return "", errors.New("图片地址不能为空") } - if parts[0] != "data:image/jpeg;base64" && parts[0] != "data:image/png;base64" && parts[0] != "data:image/gif;base64" { - return "", errors.New("仅支持 jpg、png、gif 图片") + // 开发阶段直接切换为对象存储 URL,不再接受 base64 + if strings.HasPrefix(content, "data:") { + return "", errors.New("请先上传图片,勿直接发送 base64") } - bytes, err := base64.StdEncoding.DecodeString(parts[1]) - if err != nil || len(bytes) == 0 { - return "", errors.New("图片内容无效") + if !strings.HasPrefix(content, "http://") && !strings.HasPrefix(content, "https://") { + return "", errors.New("图片地址无效") } - if len(bytes) > maxImageMessageSize { - return "", errors.New("图片不能超过 5 MB") + if imagePublicBase != "" && !storage.IsAllowedObjectURL(imagePublicBase, content) { + return "", errors.New("图片地址不在允许的存储域名下") + } + if len(content) > 2000 { + return "", errors.New("图片地址过长") } return content, nil default: diff --git a/server/internal/handler/message_validation_test.go b/server/internal/handler/message_validation_test.go index 5beaf63..1870ece 100644 --- a/server/internal/handler/message_validation_test.go +++ b/server/internal/handler/message_validation_test.go @@ -1,22 +1,25 @@ package handler -import ( - "strings" - "testing" -) +import "testing" func TestValidateMessageContent(t *testing.T) { - validImage := "data:image/png;base64,aGVsbG8=" - if content, err := validateMessageContent("image", validImage); err != nil || content != validImage { - t.Fatalf("合法图片校验失败: content=%q err=%v", content, err) + SetImagePublicBase("http://localhost:9000/kefu") + + if _, err := validateMessageContent("text", "hello"); err != nil { + t.Fatalf("文本消息应通过: %v", err) } - if _, err := validateMessageContent("image", "data:image/webp;base64,aGVsbG8="); err == nil { - t.Fatal("不支持的图片格式未被拦截") + if _, err := validateMessageContent("text", ""); err == nil { + t.Fatal("空文本应失败") } - if _, err := validateMessageContent("text", " "); err == nil { - t.Fatal("空白文本未被拦截") + + validURL := "http://localhost:9000/kefu/tenants/1/chat/a.webp" + if content, err := validateMessageContent("image", validURL); err != nil || content != validURL { + t.Fatalf("合法图片 URL 校验失败: content=%q err=%v", content, err) } - if _, err := validateMessageContent("text", strings.Repeat("字", maxTextMessageLength+1)); err == nil { - t.Fatal("超长文本未被拦截") + if _, err := validateMessageContent("image", "data:image/png;base64,aGVsbG8="); err == nil { + t.Fatal("base64 图片应被拒绝") + } + if _, err := validateMessageContent("image", "https://evil.example.com/a.webp"); err == nil { + t.Fatal("外域图片 URL 应被拒绝") } } diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index e4cce33..18b01c4 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -2,10 +2,12 @@ package handler import ( "github.com/gin-gonic/gin" + "kefu-sys/server/internal/config" "kefu-sys/server/internal/middleware" + "kefu-sys/server/internal/storage" ) -func SetupRoutes(r *gin.Engine) { +func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.StorageConfig) { auth := NewAuthHandler() session := NewSessionHandler() customer := NewCustomerHandler() @@ -16,6 +18,9 @@ func SetupRoutes(r *gin.Engine) { settings := NewSettingsHandler() ws := NewWsHandler() widget := NewWidgetHandler() + upload := NewUploadHandler(store, storageCfg) + + SetImagePublicBase(storageCfg.PublicBase) api := r.Group("/api") @@ -31,6 +36,7 @@ func SetupRoutes(r *gin.Engine) { widgetApi.GET("/messages", widget.GetMessages) widgetApi.GET("/ws", widget.Connect) widgetApi.POST("/rating", widget.SubmitRating) + widgetApi.POST("/upload", upload.WidgetUploadImage) // 需要认证的接口 authRequired := api.Group("") @@ -39,6 +45,9 @@ func SetupRoutes(r *gin.Engine) { // WebSocket authRequired.GET("/ws", ws.Connect) + // 上传 + authRequired.POST("/uploads", upload.UploadImage) + // 会话管理 authRequired.GET("/agents/available", session.ListAvailableAgents) sessions := authRequired.Group("/sessions") diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 631e124..3aa4d15 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -2,8 +2,10 @@ package handler_test import ( "bytes" + "encoding/base64" "encoding/json" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -13,9 +15,11 @@ import ( "golang.org/x/crypto/bcrypt" "gorm.io/driver/sqlite" "gorm.io/gorm" + "kefu-sys/server/internal/config" "kefu-sys/server/internal/handler" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" + "kefu-sys/server/internal/storage" ) func setupRouter(t *testing.T) *gin.Engine { @@ -32,10 +36,45 @@ func setupRouter(t *testing.T) *gin.Engine { model.DB = db middleware.InitJWT("test-secret") router := gin.New() - handler.SetupRoutes(router) + mem := storage.NewMemory("http://localhost:9000/kefu") + handler.SetupRoutes(router, mem, config.StorageConfig{ + PublicBase: "http://localhost:9000/kefu", + MaxUploadMB: 10, + MaxImageEdge: 1920, + WebPQuality: 80, + }) return router } +// tinyPNG 1x1 像素 PNG +func tinyPNG() []byte { + b, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") + return b +} + +func multipartImageRequest(t *testing.T, method, target string, fileField string, filename string, data []byte, user model.User) *http.Request { + t.Helper() + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, err := w.CreateFormFile(fileField, filename) + if err != nil { + t.Fatalf("创建 form file 失败: %v", err) + } + if _, err := part.Write(data); err != nil { + t.Fatalf("写入文件失败: %v", err) + } + _ = w.Close() + req := httptest.NewRequest(method, target, &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role) + if err != nil { + t.Fatalf("生成令牌失败: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + return req +} + + func createTenant(t *testing.T, name, status string) model.Tenant { t.Helper() tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)} @@ -417,8 +456,22 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) { t.Fatalf("转接后原客服仍可回复: %d %s", oldAgentMessageRecorder.Code, oldAgentMessageRecorder.Body.String()) } + uploadRecorder := httptest.NewRecorder() + router.ServeHTTP(uploadRecorder, multipartImageRequest(t, http.MethodPost, "/api/uploads", "file", "dot.png", tinyPNG(), agentTwo)) + if uploadRecorder.Code != http.StatusOK { + t.Fatalf("上传图片失败: %d %s", uploadRecorder.Code, uploadRecorder.Body.String()) + } + var uploadResp struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal(uploadRecorder.Body.Bytes(), &uploadResp); err != nil || uploadResp.Data.URL == "" { + t.Fatalf("解析上传响应失败: %v body=%s", err, uploadRecorder.Body.String()) + } + imageRecorder := httptest.NewRecorder() - imageBody := []byte(`{"content":"data:image/png;base64,aGVsbG8=","type":"image"}`) + imageBody := []byte(fmt.Sprintf(`{"content":%q,"type":"image"}`, uploadResp.Data.URL)) router.ServeHTTP(imageRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/messages", session.ID), imageBody, agentTwo)) if imageRecorder.Code != http.StatusOK { t.Fatalf("发送图片消息失败: %d %s", imageRecorder.Code, imageRecorder.Body.String()) diff --git a/server/internal/handler/upload.go b/server/internal/handler/upload.go new file mode 100644 index 0000000..d79c406 --- /dev/null +++ b/server/internal/handler/upload.go @@ -0,0 +1,156 @@ +package handler + +import ( + "bytes" + "fmt" + "io" + "net/http" + "path" + "strings" + + "github.com/gin-gonic/gin" + "kefu-sys/server/internal/config" + "kefu-sys/server/internal/media" + "kefu-sys/server/internal/middleware" + "kefu-sys/server/internal/storage" +) + +type UploadHandler struct { + store storage.ObjectStorage + cfg config.StorageConfig +} + +func NewUploadHandler(store storage.ObjectStorage, cfg config.StorageConfig) *UploadHandler { + return &UploadHandler{store: store, cfg: cfg} +} + +// UploadImage 客服端上传:multipart file → 处理 → 存对象存储 +func (h *UploadHandler) UploadImage(c *gin.Context) { + h.handleUpload(c, fmt.Sprintf("tenants/%d/chat", middleware.GetTenantID(c))) +} + +// WidgetUploadImage 访客端上传:需 visitor token + session_id +func (h *UploadHandler) WidgetUploadImage(c *gin.Context) { + sessionID := c.PostForm("session_id") + if sessionID == "" { + sessionID = c.Query("session_id") + } + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少 session_id"}) + return + } + token := visitorTokenFromRequest(c, c.PostForm("visitor_token")) + var sid uint + if _, err := fmt.Sscanf(sessionID, "%d", &sid); err != nil || sid == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"}) + return + } + session, ok := loadVisitorSession(c, sid, token) + if !ok { + return + } + if session.Status == "ended" || session.Status == "archived" { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"}) + return + } + h.handleUpload(c, fmt.Sprintf("tenants/%d/widget/%d", session.TenantID, session.ID)) +} + +func (h *UploadHandler) handleUpload(c *gin.Context, keyPrefix string) { + if h.store == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": "对象存储未配置"}) + return + } + + maxBytes := int64(h.cfg.MaxUploadMB) * 1024 * 1024 + if maxBytes <= 0 { + maxBytes = 10 * 1024 * 1024 + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+512) + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请上传文件字段 file"}) + return + } + defer file.Close() + + if header.Size > maxBytes { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("文件不能超过 %d MB", h.cfg.MaxUploadMB)}) + return + } + + // 嗅探 MIME + head := make([]byte, 512) + n, _ := io.ReadFull(file, head) + contentType := http.DetectContentType(head[:n]) + // 复位读取:拼回 head + 剩余 + reader := io.MultiReader(bytes.NewReader(head[:n]), file) + + // 部分浏览器 Content-Type 不准,也允许 header 中的 type + if !media.AllowedUploadMIME(contentType) { + ct := header.Header.Get("Content-Type") + if media.AllowedUploadMIME(ct) { + contentType = ct + } else { + // 按扩展名兜底 + ext := strings.ToLower(path.Ext(header.Filename)) + switch ext { + case ".jpg", ".jpeg": + contentType = "image/jpeg" + case ".png": + contentType = "image/png" + case ".gif": + contentType = "image/gif" + case ".webp": + contentType = "image/webp" + default: + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅支持 jpg/png/gif/webp 图片"}) + return + } + } + } + _ = contentType + + raw, err := io.ReadAll(reader) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "读取文件失败"}) + return + } + if int64(len(raw)) > maxBytes { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "文件过大"}) + return + } + + processed, err := media.ProcessImage(bytes.NewReader(raw), media.ProcessOptions{ + MaxEdge: h.cfg.MaxImageEdge, + ThumbEdge: 400, + WebPQuality: h.cfg.WebPQuality, + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + + mainKey := storage.NewObjectKey(keyPrefix, processed.MainExt) + thumbKey := storage.NewObjectKey(keyPrefix+"/thumbs", processed.ThumbExt) + + mainURL, err := h.store.Put(c.Request.Context(), mainKey, bytes.NewReader(processed.Main), int64(len(processed.Main)), processed.MainType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "上传失败: " + err.Error()}) + return + } + thumbURL, err := h.store.Put(c.Request.Context(), thumbKey, bytes.NewReader(processed.Thumb), int64(len(processed.Thumb)), processed.ThumbType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "缩略图上传失败: " + err.Error()}) + return + } + + middleware.JSON(c, gin.H{ + "url": mainURL, + "thumb_url": thumbURL, + "content_type": processed.MainType, + "width": processed.Width, + "height": processed.Height, + "size": len(processed.Main), + }) +} diff --git a/server/internal/media/image.go b/server/internal/media/image.go new file mode 100644 index 0000000..88aa7eb --- /dev/null +++ b/server/internal/media/image.go @@ -0,0 +1,106 @@ +package media + +import ( + "bytes" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + + "github.com/chai2010/webp" + "github.com/disintegration/imaging" +) + +const ( + DefaultMaxEdge = 1920 + DefaultThumbEdge = 400 + DefaultQuality = 80 +) + +type ProcessOptions struct { + MaxEdge int + ThumbEdge int + WebPQuality float32 +} + +type ProcessedImage struct { + Main []byte + MainType string // image/webp + MainExt string // .webp + Thumb []byte + ThumbType string + ThumbExt string + Width int + Height int + ThumbWidth int + ThumbHeight int +} + +// ProcessImage 解码 → 等比缩放 → WebP 主图 + 缩略图 +func ProcessImage(r io.Reader, opt ProcessOptions) (*ProcessedImage, error) { + if opt.MaxEdge <= 0 { + opt.MaxEdge = DefaultMaxEdge + } + if opt.ThumbEdge <= 0 { + opt.ThumbEdge = DefaultThumbEdge + } + if opt.WebPQuality <= 0 || opt.WebPQuality > 100 { + opt.WebPQuality = DefaultQuality + } + + src, _, err := image.Decode(r) + if err != nil { + return nil, fmt.Errorf("无法解码图片: %w", err) + } + + mainImg := fitWithin(src, opt.MaxEdge) + thumbImg := fitWithin(src, opt.ThumbEdge) + + mainBuf := &bytes.Buffer{} + if err := webp.Encode(mainBuf, mainImg, &webp.Options{Quality: opt.WebPQuality}); err != nil { + return nil, fmt.Errorf("webp 编码失败: %w", err) + } + thumbBuf := &bytes.Buffer{} + if err := webp.Encode(thumbBuf, thumbImg, &webp.Options{Quality: opt.WebPQuality}); err != nil { + return nil, fmt.Errorf("缩略图编码失败: %w", err) + } + + bMain := mainImg.Bounds() + bThumb := thumbImg.Bounds() + return &ProcessedImage{ + Main: mainBuf.Bytes(), + MainType: "image/webp", + MainExt: ".webp", + Thumb: thumbBuf.Bytes(), + ThumbType: "image/webp", + ThumbExt: ".webp", + Width: bMain.Dx(), + Height: bMain.Dy(), + ThumbWidth: bThumb.Dx(), + ThumbHeight: bThumb.Dy(), + }, nil +} + +func fitWithin(img image.Image, maxEdge int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxEdge && h <= maxEdge { + return img + } + if w >= h { + return imaging.Resize(img, maxEdge, 0, imaging.Lanczos) + } + return imaging.Resize(img, 0, maxEdge, imaging.Lanczos) +} + +// AllowedUploadMIME 上传阶段允许的源格式 +func AllowedUploadMIME(contentType string) bool { + switch contentType { + case "image/jpeg", "image/png", "image/gif", "image/webp": + return true + default: + return false + } +} diff --git a/server/internal/storage/memory.go b/server/internal/storage/memory.go new file mode 100644 index 0000000..1e528ca --- /dev/null +++ b/server/internal/storage/memory.go @@ -0,0 +1,55 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "io" + "sync" +) + +// MemoryStorage 测试用内存存储 +type MemoryStorage struct { + mu sync.RWMutex + data map[string][]byte + base string +} + +func NewMemory(publicBase string) *MemoryStorage { + return &MemoryStorage{ + data: make(map[string][]byte), + base: NormalizePublicBase(publicBase), + } +} + +func (m *MemoryStorage) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) (string, error) { + b, err := io.ReadAll(r) + if err != nil { + return "", err + } + m.mu.Lock() + m.data[key] = b + m.mu.Unlock() + return m.base + "/" + key, nil +} + +func (m *MemoryStorage) Delete(_ context.Context, key string) error { + m.mu.Lock() + delete(m.data, key) + m.mu.Unlock() + return nil +} + +func (m *MemoryStorage) PublicBase() string { return m.base } + +func (m *MemoryStorage) EnsureBucket(context.Context) error { return nil } + +func (m *MemoryStorage) Get(key string) ([]byte, error) { + m.mu.RLock() + defer m.mu.RUnlock() + b, ok := m.data[key] + if !ok { + return nil, fmt.Errorf("not found") + } + return bytes.Clone(b), nil +} diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go new file mode 100644 index 0000000..4a35242 --- /dev/null +++ b/server/internal/storage/s3.go @@ -0,0 +1,104 @@ +package storage + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "kefu-sys/server/internal/config" +) + +// S3Storage 基于 minio-go 的 S3 兼容实现(MinIO / OSS / COS / AWS S3) +type S3Storage struct { + client *minio.Client + bucket string + base string +} + +func NewS3(cfg config.StorageConfig) (*S3Storage, error) { + endpoint := strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://") + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + Region: cfg.Region, + }) + if err != nil { + return nil, fmt.Errorf("init s3 client: %w", err) + } + base := NormalizePublicBase(cfg.PublicBase) + if base == "" { + scheme := "http" + if cfg.UseSSL { + scheme = "https" + } + base = fmt.Sprintf("%s://%s/%s", scheme, endpoint, cfg.Bucket) + } + return &S3Storage{client: client, bucket: cfg.Bucket, base: base}, nil +} + +func (s *S3Storage) EnsureBucket(ctx context.Context) error { + exists, err := s.client.BucketExists(ctx, s.bucket) + if err != nil { + return err + } + if !exists { + if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil { + return err + } + } + // 开发环境尽量开放公共读;生产用 CDN/桶策略配置 + policy := fmt.Sprintf(`{ + "Version":"2012-10-17", + "Statement":[{ + "Effect":"Allow", + "Principal":{"AWS":["*"]}, + "Action":["s3:GetObject"], + "Resource":["arn:aws:s3:::%s/*"] + }] + }`, s.bucket) + _ = s.client.SetBucketPolicy(ctx, s.bucket, policy) + return nil +} + +func (s *S3Storage) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (string, error) { + if contentType == "" { + contentType = "application/octet-stream" + } + _, err := s.client.PutObject(ctx, s.bucket, key, r, size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return "", err + } + return s.objectURL(key), nil +} + +func (s *S3Storage) Delete(ctx context.Context, key string) error { + return s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}) +} + +func (s *S3Storage) PublicBase() string { return s.base } + +func (s *S3Storage) objectURL(key string) string { + // PublicBase 形如 http://localhost:9000/kefu + return s.base + "/" + strings.TrimPrefix(key, "/") +} + +// ParseKeyFromURL 从公网 URL 还原 object key(删除时用) +func (s *S3Storage) ParseKeyFromURL(publicURL string) (string, error) { + if !strings.HasPrefix(publicURL, s.base+"/") { + return "", fmt.Errorf("url not in bucket") + } + key := strings.TrimPrefix(publicURL, s.base+"/") + if key == "" { + return "", fmt.Errorf("empty key") + } + if u, err := url.PathUnescape(key); err == nil { + return u, nil + } + return key, nil +} diff --git a/server/internal/storage/storage.go b/server/internal/storage/storage.go new file mode 100644 index 0000000..5d5583b --- /dev/null +++ b/server/internal/storage/storage.go @@ -0,0 +1,34 @@ +package storage + +import ( + "context" + "io" + "time" +) + +// ObjectStorage 对象存储抽象。MinIO / 阿里云 OSS(S3 兼容) / 腾讯云 COS 等共用此接口, +// 业务层只依赖 Put/Delete/URL,迁移云厂商时只改配置与驱动初始化。 +type ObjectStorage interface { + // Put 上传对象,返回公网可访问 URL + Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (publicURL string, err error) + // Delete 删除对象(可选清理) + Delete(ctx context.Context, key string) error + // PublicBase 浏览器访问前缀(用于校验消息中的图片 URL) + PublicBase() string + // EnsureBucket 开发环境自动建桶 + EnsureBucket(ctx context.Context) error +} + +// PutResult 上传结果 +type PutResult struct { + Key string + URL string + ContentType string + Size int64 +} + +// NewObjectKey 生成带日期前缀的对象键,避免冲突 +func NewObjectKey(prefix, ext string) string { + now := time.Now() + return prefix + "/" + now.Format("2006/01/02") + "/" + now.Format("150405") + "-" + randomHex(8) + ext +} diff --git a/server/internal/storage/util.go b/server/internal/storage/util.go new file mode 100644 index 0000000..14ae7eb --- /dev/null +++ b/server/internal/storage/util.go @@ -0,0 +1,27 @@ +package storage + +import ( + "crypto/rand" + "encoding/hex" + "strings" +) + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// NormalizePublicBase 去掉末尾斜杠 +func NormalizePublicBase(base string) string { + return strings.TrimRight(strings.TrimSpace(base), "/") +} + +// IsAllowedObjectURL 判断 url 是否属于当前存储公网前缀 +func IsAllowedObjectURL(publicBase, url string) bool { + base := NormalizePublicBase(publicBase) + if base == "" || url == "" { + return false + } + return strings.HasPrefix(url, base+"/") || url == base +} diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 78e252d..22913c0 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -10,6 +10,7 @@ import { useAuth } from '@/stores/auth' import { addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries, getSession, getSessions, markSessionRead, sendSessionMessage, transferSession, updateSessionPriority, + uploadImage, type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type Session, type SessionEvent, } from '@/services/api' @@ -111,7 +112,7 @@ const Dashboard = () => { const [knowledgeKeyword, setKnowledgeKeyword] = useState('') const [knowledgeEntries, setKnowledgeEntries] = useState([]) const [knowledgeLoading, setKnowledgeLoading] = useState(false) - const [imagePreview, setImagePreview] = useState(null) + const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null) const [noteInput, setNoteInput] = useState('') const [savingNote, setSavingNote] = useState(false) const [customerHistory, setCustomerHistory] = useState([]) @@ -312,17 +313,34 @@ const Dashboard = () => { const handleImage = (file?: File) => { if (!file) return - if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) { - antMsg.error('仅支持 jpg、png、gif 图片') + const okTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] + if (!okTypes.includes(file.type)) { + antMsg.error('仅支持 jpg、png、gif、webp 图片') return } - if (file.size > 5 * 1024 * 1024) { - antMsg.error('图片不能超过 5 MB') + if (file.size > 10 * 1024 * 1024) { + antMsg.error('图片不能超过 10 MB') return } - const reader = new FileReader() - reader.onload = () => setImagePreview(String(reader.result)) - reader.readAsDataURL(file) + if (pendingImage?.preview) URL.revokeObjectURL(pendingImage.preview) + setPendingImage({ file, preview: URL.createObjectURL(file) }) + } + + const confirmSendImage = async () => { + if (!pendingImage || !selectedId || !canOperate) return + setSending(true) + try { + const uploaded = await uploadImage(pendingImage.file) + await sendSessionMessage(selectedId, uploaded.data.url, 'image') + URL.revokeObjectURL(pendingImage.preview) + setPendingImage(null) + await loadDetail(selectedId) + await loadAll() + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '图片发送失败') + } finally { + setSending(false) + } } const handleClaim = async (sessionID: number) => { @@ -412,7 +430,7 @@ const Dashboard = () => { { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }} /> @@ -1006,15 +1024,19 @@ const Dashboard = () => { setImagePreview(null)} - onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }} - okText="发送" + open={Boolean(pendingImage)} + onCancel={() => { + if (pendingImage?.preview) URL.revokeObjectURL(pendingImage.preview) + setPendingImage(null) + }} + onOk={confirmSendImage} + okText="上传并发送" okButtonProps={{ loading: sending }} >
- 待发送图片预览 + 待发送图片预览
+

将自动压缩并转为 WebP 后上传

setKnowledgeOpen(false)} footer={null} width={640}> post post(`/sessions/${id}/messages`, { content, type }) export const getAvailableAgents = () => get('/agents/available') +export interface UploadImageResult { + url: string + thumb_url: string + content_type: string + width: number + height: number + size: number +} + +/** 客服端上传图片 → 返回对象存储 URL */ +export const uploadImage = (file: File) => { + const form = new FormData() + form.append('file', file) + return postForm('/uploads', form) +} + +/** 访客端上传图片 */ +export const uploadWidgetImage = async (file: File, sessionId: number, visitorToken: string) => { + const form = new FormData() + form.append('file', file) + form.append('session_id', String(sessionId)) + form.append('visitor_token', visitorToken) + const res = await fetch('/api/widget/upload', { + method: 'POST', + headers: { 'X-Visitor-Token': visitorToken }, + body: form, + }) + const json = await res.json() + if (json.code !== 0) throw new Error(json.message || '上传失败') + return json as { code: number; data: UploadImageResult } +} + // Customers export const getCustomers = (params?: { search?: string; status?: string; source?: string; page?: number; pageSize?: number }) => { const search = new URLSearchParams() diff --git a/web/src/services/request.ts b/web/src/services/request.ts index 3a2de31..0fa04aa 100644 --- a/web/src/services/request.ts +++ b/web/src/services/request.ts @@ -22,9 +22,13 @@ interface ListResponse { async function request(url: string, options: RequestInit = {}): Promise { const headers: Record = { - 'Content-Type': 'application/json', ...((options.headers as Record) || {}), } + // FormData 时不要手动设置 Content-Type,由浏览器带 boundary + const isForm = typeof FormData !== 'undefined' && options.body instanceof FormData + if (!isForm && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json' + } if (token) headers['Authorization'] = `Bearer ${token}` const res = await fetch(`${BASE}${url}`, { ...options, headers }) @@ -44,3 +48,8 @@ export const post = (url: string, data: unknown) => request>(url, export const put = (url: string, data: unknown) => request>(url, { method: 'PUT', body: JSON.stringify(data) }) export const del = (url: string) => request>(url, { method: 'DELETE' }) export const getList = (url: string) => request>(url) + +/** multipart 上传(图片等) */ +export const postForm = (url: string, form: FormData) => + request>(url, { method: 'POST', body: form }) + diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 86fc217..6cae776 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -53,7 +53,7 @@ const VisitorChat = ({ const [agentTyping, setAgentTyping] = useState(false) const [ratingText, setRatingText] = useState('') const [hoverStar, setHoverStar] = useState(0) - const [imagePreview, setImagePreview] = useState(null) + const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null) const [sendError, setSendError] = useState('') const [agentsOnline, setAgentsOnline] = useState(true) const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。') @@ -201,7 +201,7 @@ const VisitorChat = ({ useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, agentTyping, open, imagePreview]) + }, [messages, agentTyping, open, pendingImage]) const notifyHost = (type: 'kefu-widget-close' | 'kefu-widget-minimize') => { if (embedded && window.parent && window.parent !== window) { @@ -231,6 +231,22 @@ const VisitorChat = ({ return json.data } + const uploadVisitorImage = async (file: File) => { + if (!sessionId || !visitorToken) throw new Error('会话未就绪') + const form = new FormData() + form.append('file', file) + form.append('session_id', String(sessionId)) + form.append('visitor_token', visitorToken) + const res = await fetch('/api/widget/upload', { + method: 'POST', + headers: { 'X-Visitor-Token': visitorToken }, + body: form, + }) + const json = await res.json() + if (json.code !== 0) throw new Error(json.message || '上传失败') + return json.data as { url: string; thumb_url: string } + } + const sendMessage = async (text: string) => { if (!text.trim() || sending || sessionEnded) return if (!agentsOnline) { @@ -298,39 +314,40 @@ const VisitorChat = ({ const handleImageFile = (file?: File | null) => { if (!file || sessionEnded || !sessionId || !agentsOnline) return - if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) { - setSendError('仅支持 jpg、png、gif 图片') + const okTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] + if (!okTypes.includes(file.type)) { + setSendError('仅支持 jpg、png、gif、webp 图片') return } - if (file.size > 5 * 1024 * 1024) { - setSendError('图片不能超过 5 MB') + if (file.size > 10 * 1024 * 1024) { + setSendError('图片不能超过 10 MB') return } setSendError('') - const reader = new FileReader() - reader.onload = () => setImagePreview(String(reader.result)) - reader.readAsDataURL(file) + if (pendingImage?.preview) URL.revokeObjectURL(pendingImage.preview) + setPendingImage({ file, preview: URL.createObjectURL(file) }) } const sendImage = async () => { - if (!imagePreview || sending || sessionEnded || !agentsOnline) return + if (!pendingImage || sending || sessionEnded || !agentsOnline) return setSending(true) setSendError('') - const localMsg: Message = { - id: -Date.now(), - sender: 'visitor', - content: imagePreview, - type: 'image', - time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), - } - setMessages(prev => [...prev, localMsg]) - setImagePreview(null) try { - await postMessage(localMsg.content, 'image') + const uploaded = await uploadVisitorImage(pendingImage.file) + const localMsg: Message = { + id: -Date.now(), + sender: 'visitor', + content: uploaded.url, + type: 'image', + time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + } + setMessages(prev => [...prev, localMsg]) + URL.revokeObjectURL(pendingImage.preview) + setPendingImage(null) + await postMessage(uploaded.url, 'image') await loadMessages(sessionId || undefined) } catch (e) { setSendError(e instanceof Error ? e.message : '图片发送失败') - setMessages(prev => prev.filter(m => m.id !== localMsg.id)) } finally { setSending(false) } @@ -530,11 +547,20 @@ const VisitorChat = ({ )} - {imagePreview && agentsOnline && ( + {pendingImage && agentsOnline && (
- 预览 -
确认发送图片?
- + 预览 +
确认上传并发送?(自动转 WebP)
+
)} @@ -559,7 +585,7 @@ const VisitorChat = ({ { handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }} />