实现 MinIO 对象存储图片流水线(可迁移 OSS)
- 新增 S3 兼容 Storage 抽象与 MinIO 实现,compose 启动 MinIO - 上传接口:校验 → 缩放 → WebP → 主图/缩略图入库 - 消息图片 content 改为对象 URL,拒绝 base64 - 工作台/访客端改为先上传再发消息
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+18
-2
@@ -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 {
|
||||
|
||||
+25
-8
@@ -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
|
||||
)
|
||||
|
||||
+61
-23
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<KnowledgeEntry[]>([])
|
||||
const [knowledgeLoading, setKnowledgeLoading] = useState(false)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
|
||||
const [noteInput, setNoteInput] = useState('')
|
||||
const [savingNote, setSavingNote] = useState(false)
|
||||
const [customerHistory, setCustomerHistory] = useState<Session[]>([])
|
||||
@@ -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 = () => {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={event => { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }}
|
||||
/>
|
||||
@@ -1006,15 +1024,19 @@ const Dashboard = () => {
|
||||
</Modal>
|
||||
<Modal
|
||||
title="图片预览"
|
||||
open={Boolean(imagePreview)}
|
||||
onCancel={() => 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 }}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<img src={imagePreview || ''} alt="待发送图片预览" className="max-h-[420px] max-w-full rounded-lg" />
|
||||
<img src={pendingImage?.preview || ''} alt="待发送图片预览" className="max-h-[420px] max-w-full rounded-lg" />
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 text-center mt-3 mb-0">将自动压缩并转为 WebP 后上传</p>
|
||||
</Modal>
|
||||
<Modal title="知识库与快捷回复" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}>
|
||||
<Input
|
||||
|
||||
+33
-1
@@ -1,4 +1,4 @@
|
||||
import { get, post, put, del, getList } from './request'
|
||||
import { get, post, put, del, getList, postForm } from './request'
|
||||
|
||||
export interface LoginParams { username: string; password: string }
|
||||
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
|
||||
@@ -107,6 +107,38 @@ export const addSessionNote = (id: number, content: string) => post<SessionEvent
|
||||
export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post<Message>(`/sessions/${id}/messages`, { content, type })
|
||||
export const getAvailableAgents = () => get<AvailableAgent[]>('/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<UploadImageResult>('/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()
|
||||
|
||||
@@ -22,9 +22,13 @@ interface ListResponse<T = unknown> {
|
||||
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
}
|
||||
// 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 = <T>(url: string, data: unknown) => request<Response<T>>(url,
|
||||
export const put = <T>(url: string, data: unknown) => request<Response<T>>(url, { method: 'PUT', body: JSON.stringify(data) })
|
||||
export const del = <T>(url: string) => request<Response<T>>(url, { method: 'DELETE' })
|
||||
export const getList = <T>(url: string) => request<ListResponse<T>>(url)
|
||||
|
||||
/** multipart 上传(图片等) */
|
||||
export const postForm = <T>(url: string, form: FormData) =>
|
||||
request<Response<T>>(url, { method: 'POST', body: form })
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ const VisitorChat = ({
|
||||
const [agentTyping, setAgentTyping] = useState(false)
|
||||
const [ratingText, setRatingText] = useState('')
|
||||
const [hoverStar, setHoverStar] = useState(0)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(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 = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreview && agentsOnline && (
|
||||
{pendingImage && agentsOnline && (
|
||||
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||||
<img src={imagePreview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认发送图片?</div>
|
||||
<button type="button" className="text-xs text-neutral-400 border-0 bg-transparent cursor-pointer" onClick={() => setImagePreview(null)}>取消</button>
|
||||
<img src={pendingImage.preview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认上传并发送?(自动转 WebP)</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-neutral-400 border-0 bg-transparent cursor-pointer"
|
||||
onClick={() => {
|
||||
URL.revokeObjectURL(pendingImage.preview)
|
||||
setPendingImage(null)
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" disabled={sending} className="text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50" onClick={sendImage}>发送</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -559,7 +585,7 @@ const VisitorChat = ({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={e => { handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user