修复 MinIO 端口冲突并优化顶栏 IP 展示

- MinIO 宿主机端口改为 9100/9101,避免与 hfb-minio 冲突
- 测试与配置默认 PublicBase 同步为 9100
- 工作台顶栏固定展示 IP|地区,详情回填会话元数据
- 种子数据补充访客 IP/地区示例
This commit is contained in:
yml2213
2026-07-15 12:39:39 +08:00
parent ec2f12c6ed
commit 32d88f42e7
8 changed files with 49 additions and 46 deletions
+1 -1
View File
@@ -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:
+6 -3
View File
@@ -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;
+6 -6
View File
@@ -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)
+5 -4
View File
@@ -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,
+1 -1
View File
@@ -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, "/")
} }
+26 -27
View File
@@ -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>