修复 MinIO 端口冲突并优化顶栏 IP 展示
- MinIO 宿主机端口改为 9100/9101,避免与 hfb-minio 冲突 - 测试与配置默认 PublicBase 同步为 9100 - 工作台顶栏固定展示 IP|地区,详情回填会话元数据 - 种子数据补充访客 IP/地区示例
This commit is contained in:
@@ -14,7 +14,7 @@ db-up:
|
|||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
@sleep 3
|
@sleep 3
|
||||||
@echo "PostgreSQL: localhost:5433"
|
@echo "PostgreSQL: localhost:5433"
|
||||||
@echo "MinIO API: localhost:9000 (控制台 :9001 minioadmin/minioadmin)"
|
@echo "MinIO API: localhost:9100 (控制台 :9101 minioadmin/minioadmin)"
|
||||||
|
|
||||||
# 停止数据库
|
# 停止数据库
|
||||||
db-down:
|
db-down:
|
||||||
|
|||||||
@@ -18,18 +18,21 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: minioadmin
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
# 避开本机其它 MinIO(如 hfb-minio 占用 9000/9001)
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9100:9000"
|
||||||
- "9001:9001"
|
- "9101:9001"
|
||||||
volumes:
|
volumes:
|
||||||
- miniodata:/data
|
- miniodata:/data
|
||||||
|
|
||||||
# 创建 bucket 并开放下载(开发环境)
|
# 一次性初始化:创建 bucket 并开放公共下载(跑完即退出,Exited 0 属正常)
|
||||||
|
# 注意:容器网络内仍访问 minio:9000(服务端口),宿主机映射为 9100
|
||||||
minio-init:
|
minio-init:
|
||||||
image: minio/mc:latest
|
image: minio/mc:latest
|
||||||
container_name: kefu-minio-init
|
container_name: kefu-minio-init
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio
|
- minio
|
||||||
|
restart: "no"
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
sleep 3;
|
sleep 3;
|
||||||
|
|||||||
@@ -85,13 +85,13 @@ func seed() {
|
|||||||
}
|
}
|
||||||
model.DB.Create(&customers)
|
model.DB.Create(&customers)
|
||||||
|
|
||||||
// Sessions
|
// Sessions(含访客 IP/地区,与工作台顶栏展示一致)
|
||||||
sessions := []model.Session{
|
sessions := []model.Session{
|
||||||
{TenantID: tenants[0].ID, ChannelID: channels[0].ID, CustomerID: customers[0].ID, AgentID: &agent1.ID, Status: "active", Priority: "urgent"},
|
{TenantID: tenants[0].ID, ChannelID: channels[0].ID, CustomerID: customers[0].ID, AgentID: &agent1.ID, Status: "active", Priority: "urgent", VisitorIP: "192.168.1.105", VisitorRegion: "北京", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"},
|
||||||
{TenantID: tenants[0].ID, ChannelID: channels[1].ID, CustomerID: customers[1].ID, AgentID: &agent2.ID, Status: "active", Priority: "normal"},
|
{TenantID: tenants[0].ID, ChannelID: channels[1].ID, CustomerID: customers[1].ID, AgentID: &agent2.ID, Status: "active", Priority: "normal", VisitorIP: "10.0.0.23", VisitorRegion: "内网", UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Safari/604.1"},
|
||||||
{TenantID: tenants[0].ID, ChannelID: channels[0].ID, CustomerID: customers[2].ID, AgentID: nil, Status: "waiting", Priority: "normal"},
|
{TenantID: tenants[0].ID, ChannelID: channels[0].ID, CustomerID: customers[2].ID, AgentID: nil, Status: "waiting", Priority: "normal", VisitorIP: "223.5.5.5", VisitorRegion: "中国", UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/120.0.0.0"},
|
||||||
{TenantID: tenants[0].ID, ChannelID: channels[0].ID, CustomerID: customers[3].ID, AgentID: &agent3.ID, Status: "ended", Priority: "normal", SatisfactionScore: ptr(5)},
|
{TenantID: tenants[0].ID, ChannelID: channels[0].ID, CustomerID: customers[3].ID, AgentID: &agent3.ID, Status: "ended", Priority: "normal", SatisfactionScore: ptr(5), VisitorIP: "114.114.114.114", VisitorRegion: "中国"},
|
||||||
{TenantID: tenants[0].ID, ChannelID: channels[0].ID, CustomerID: customers[4].ID, AgentID: &agent1.ID, Status: "ended", Priority: "normal", SatisfactionScore: ptr(4)},
|
{TenantID: tenants[0].ID, ChannelID: channels[0].ID, CustomerID: customers[4].ID, AgentID: &agent1.ID, Status: "ended", Priority: "normal", SatisfactionScore: ptr(4), VisitorIP: "8.8.8.8", VisitorRegion: "美国"},
|
||||||
}
|
}
|
||||||
model.DB.Create(&sessions)
|
model.DB.Create(&sessions)
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ type JWTConfig struct {
|
|||||||
|
|
||||||
// StorageConfig S3 兼容对象存储(MinIO / OSS / COS / S3 共用字段)
|
// StorageConfig S3 兼容对象存储(MinIO / OSS / COS / S3 共用字段)
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
Endpoint string // 如 localhost:9000 或 oss-cn-hangzhou.aliyuncs.com
|
Endpoint string // 如 localhost:9100(开发 MinIO)或 oss-cn-hangzhou.aliyuncs.com
|
||||||
AccessKey string
|
AccessKey string
|
||||||
SecretKey string
|
SecretKey string
|
||||||
Bucket string
|
Bucket string
|
||||||
UseSSL bool
|
UseSSL bool
|
||||||
Region string
|
Region string
|
||||||
PublicBase string // 浏览器可访问前缀,如 http://localhost:9000/kefu
|
PublicBase string // 浏览器可访问前缀,如 http://localhost:9100/kefu
|
||||||
MaxUploadMB int
|
MaxUploadMB int
|
||||||
MaxImageEdge int // 最长边像素
|
MaxImageEdge int // 最长边像素
|
||||||
WebPQuality float32
|
WebPQuality float32
|
||||||
@@ -65,13 +65,14 @@ func Load() *Config {
|
|||||||
ExpireTime: 24 * time.Hour,
|
ExpireTime: 24 * time.Hour,
|
||||||
},
|
},
|
||||||
Storage: StorageConfig{
|
Storage: StorageConfig{
|
||||||
Endpoint: getEnv("STORAGE_ENDPOINT", "localhost:9000"),
|
// 默认映射宿主机 9100→容器 9000,避免与其它项目 MinIO 冲突
|
||||||
|
Endpoint: getEnv("STORAGE_ENDPOINT", "localhost:9100"),
|
||||||
AccessKey: getEnv("STORAGE_ACCESS_KEY", "minioadmin"),
|
AccessKey: getEnv("STORAGE_ACCESS_KEY", "minioadmin"),
|
||||||
SecretKey: getEnv("STORAGE_SECRET_KEY", "minioadmin"),
|
SecretKey: getEnv("STORAGE_SECRET_KEY", "minioadmin"),
|
||||||
Bucket: getEnv("STORAGE_BUCKET", "kefu"),
|
Bucket: getEnv("STORAGE_BUCKET", "kefu"),
|
||||||
UseSSL: getEnvBool("STORAGE_USE_SSL", false),
|
UseSSL: getEnvBool("STORAGE_USE_SSL", false),
|
||||||
Region: getEnv("STORAGE_REGION", "us-east-1"),
|
Region: getEnv("STORAGE_REGION", "us-east-1"),
|
||||||
PublicBase: getEnv("STORAGE_PUBLIC_BASE_URL", "http://localhost:9000/kefu"),
|
PublicBase: getEnv("STORAGE_PUBLIC_BASE_URL", "http://localhost:9100/kefu"),
|
||||||
MaxUploadMB: getEnvInt("STORAGE_MAX_UPLOAD_MB", 10),
|
MaxUploadMB: getEnvInt("STORAGE_MAX_UPLOAD_MB", 10),
|
||||||
MaxImageEdge: getEnvInt("STORAGE_MAX_IMAGE_EDGE", 1920),
|
MaxImageEdge: getEnvInt("STORAGE_MAX_IMAGE_EDGE", 1920),
|
||||||
WebPQuality: float32(getEnvInt("STORAGE_WEBP_QUALITY", 80)),
|
WebPQuality: float32(getEnvInt("STORAGE_WEBP_QUALITY", 80)),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package handler
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestValidateMessageContent(t *testing.T) {
|
func TestValidateMessageContent(t *testing.T) {
|
||||||
SetImagePublicBase("http://localhost:9000/kefu")
|
SetImagePublicBase("http://localhost:9100/kefu")
|
||||||
|
|
||||||
if _, err := validateMessageContent("text", "hello"); err != nil {
|
if _, err := validateMessageContent("text", "hello"); err != nil {
|
||||||
t.Fatalf("文本消息应通过: %v", err)
|
t.Fatalf("文本消息应通过: %v", err)
|
||||||
@@ -12,7 +12,7 @@ func TestValidateMessageContent(t *testing.T) {
|
|||||||
t.Fatal("空文本应失败")
|
t.Fatal("空文本应失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
validURL := "http://localhost:9000/kefu/tenants/1/chat/a.webp"
|
validURL := "http://localhost:9100/kefu/tenants/1/chat/a.webp"
|
||||||
if content, err := validateMessageContent("image", validURL); err != nil || content != validURL {
|
if content, err := validateMessageContent("image", validURL); err != nil || content != validURL {
|
||||||
t.Fatalf("合法图片 URL 校验失败: content=%q err=%v", content, err)
|
t.Fatalf("合法图片 URL 校验失败: content=%q err=%v", content, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ func setupRouter(t *testing.T) *gin.Engine {
|
|||||||
model.DB = db
|
model.DB = db
|
||||||
middleware.InitJWT("test-secret")
|
middleware.InitJWT("test-secret")
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
mem := storage.NewMemory("http://localhost:9000/kefu")
|
mem := storage.NewMemory("http://localhost:9100/kefu")
|
||||||
handler.SetupRoutes(router, mem, config.StorageConfig{
|
handler.SetupRoutes(router, mem, config.StorageConfig{
|
||||||
PublicBase: "http://localhost:9000/kefu",
|
PublicBase: "http://localhost:9100/kefu",
|
||||||
MaxUploadMB: 10,
|
MaxUploadMB: 10,
|
||||||
MaxImageEdge: 1920,
|
MaxImageEdge: 1920,
|
||||||
WebPQuality: 80,
|
WebPQuality: 80,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
|||||||
func (s *S3Storage) PublicBase() string { return s.base }
|
func (s *S3Storage) PublicBase() string { return s.base }
|
||||||
|
|
||||||
func (s *S3Storage) objectURL(key string) string {
|
func (s *S3Storage) objectURL(key string) string {
|
||||||
// PublicBase 形如 http://localhost:9000/kefu
|
// PublicBase 形如 http://localhost:9100/kefu(宿主机映射端口)
|
||||||
return s.base + "/" + strings.TrimPrefix(key, "/")
|
return s.base + "/" + strings.TrimPrefix(key, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,9 +175,24 @@ const Dashboard = () => {
|
|||||||
const response = await getSession(id)
|
const response = await getSession(id)
|
||||||
const data = response.data
|
const data = response.data
|
||||||
setDetail({ messages: data.messages || [], events: data.events || [], pendingCount: data.pending_count || 0 })
|
setDetail({ messages: data.messages || [], events: data.events || [], pendingCount: data.pending_count || 0 })
|
||||||
|
// 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确
|
||||||
|
if (data.session) {
|
||||||
|
setSessions(previous => previous.map(session =>
|
||||||
|
session.id === id
|
||||||
|
? {
|
||||||
|
...session,
|
||||||
|
...data.session,
|
||||||
|
unread_count: markRead ? 0 : session.unread_count,
|
||||||
|
last_message: session.last_message,
|
||||||
|
last_message_at: session.last_message_at,
|
||||||
|
}
|
||||||
|
: session,
|
||||||
|
))
|
||||||
|
} else if (markRead) {
|
||||||
|
setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session))
|
||||||
|
}
|
||||||
if (markRead) {
|
if (markRead) {
|
||||||
await markSessionRead(id)
|
await markSessionRead(id)
|
||||||
setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session))
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
antMsg.error('加载消息失败')
|
antMsg.error('加载消息失败')
|
||||||
@@ -615,10 +630,7 @@ const Dashboard = () => {
|
|||||||
<section className="flex-1 flex flex-col min-w-0 h-full bg-neutral-50">
|
<section className="flex-1 flex flex-col min-w-0 h-full bg-neutral-50">
|
||||||
{selected && selectedCustomer ? (
|
{selected && selectedCustomer ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className="shrink-0 flex items-center justify-between px-5 py-2.5 bg-white border-b border-neutral-200 min-h-14">
|
||||||
className="shrink-0 flex items-center justify-between px-5 bg-white border-b border-neutral-200"
|
|
||||||
style={{ height: 'var(--header-height)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="w-[38px] h-[38px] rounded-full flex items-center justify-center shrink-0 text-base font-semibold"
|
className="w-[38px] h-[38px] rounded-full flex items-center justify-center shrink-0 text-base font-semibold"
|
||||||
@@ -640,24 +652,15 @@ const Dashboard = () => {
|
|||||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400 flex-wrap">
|
{/* 与设计稿一致:姓名下方展示 IP | 地区 */}
|
||||||
{selected.visitor_ip ? (
|
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-neutral-400 min-w-0">
|
||||||
<span className="truncate">IP: {selected.visitor_ip}</span>
|
<span className="truncate">
|
||||||
) : (
|
IP: {selected.visitor_ip || '—'}
|
||||||
<span className="truncate">会话 #{selected.id}</span>
|
</span>
|
||||||
)}
|
<span className="shrink-0 text-neutral-300">|</span>
|
||||||
{selected.visitor_region && (
|
<span className="truncate">
|
||||||
<>
|
{selected.visitor_region || '未知'}
|
||||||
<span>|</span>
|
</span>
|
||||||
<span className="truncate">{selected.visitor_region}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!selected.visitor_ip && selectedCustomer.source && (
|
|
||||||
<>
|
|
||||||
<span>|</span>
|
|
||||||
<span className="truncate">{selectedCustomer.source}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -898,10 +901,6 @@ const Dashboard = () => {
|
|||||||
<span className="shrink-0 text-neutral-400 min-w-12">地区</span>
|
<span className="shrink-0 text-neutral-400 min-w-12">地区</span>
|
||||||
<span className="truncate text-neutral-800">{selected?.visitor_region || '—'}</span>
|
<span className="truncate text-neutral-800">{selected?.visitor_region || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="shrink-0 text-neutral-400 min-w-12">IP</span>
|
|
||||||
<span className="truncate text-neutral-800 font-mono text-xs">{selected?.visitor_ip || '—'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user