537yaha 1 сар өмнө
parent
commit
a9c28a4d0b

+ 9 - 0
.env.example

@@ -66,6 +66,15 @@ REDIS_DB=0
 # WebSocket 分布式事件频道(一般无需修改)
 REDIS_WS_CHANNEL=ai_cs:ws_events
 
+# =========================
+# 访客 IP 地理位置(ip2region 离线库,可选)
+# =========================
+# true 时关闭解析;Docker 镜像内已内置 data/ip2region_v4.xdb
+IP2REGION_DISABLED=false
+# 自定义 xdb 路径(本地开发可下载到 backend/data/,见 backend/data/README.md)
+# IP2REGION_V4_XDB=backend/data/ip2region_v4.xdb
+# IP2REGION_V6_XDB=
+
 # =========================
 # 端口映射(非必须)
 # =========================

+ 1 - 0
README.md

@@ -32,6 +32,7 @@
   - 可选“本回合联网搜索”开关(是否对访客展示可在后台控制)
 - **客服侧(工作台)**
   - 会话列表、实时消息(WebSocket)、未读角标提示
+  - 访客 **IP 与大致地理位置**(离线 [ip2region](https://github.com/lionsoul2014/ip2region),客服工作台访客详情展示)
   - 支持“实时共享草稿输入”(双方未发送内容可实时可见)
   - 多模型管理(文本/绘画等)与对话配置
   - **提示词配置**(Prompt 管理)

+ 8 - 1
backend/Dockerfile

@@ -6,7 +6,7 @@ FROM golang:1.24-alpine AS builder
 WORKDIR /app
 
 # 安装必要的构建工具
-RUN apk add --no-cache git ca-certificates tzdata
+RUN apk add --no-cache git ca-certificates tzdata curl
 
 # 复制 go mod 文件
 COPY go.mod go.sum ./
@@ -17,6 +17,10 @@ RUN go mod download
 # 复制源代码
 COPY . .
 
+# 离线 IP 库(访客地理位置,见 backend/data/README.md)
+RUN mkdir -p data && curl -fsSL -o data/ip2region_v4.xdb \
+    https://github.com/lionsoul2014/ip2region/raw/master/data/ip2region_v4.xdb
+
 # 构建应用
 # CGO_ENABLED=0: 禁用 CGO,生成纯 Go 二进制文件
 # GOOS=linux: 目标操作系统
@@ -36,6 +40,9 @@ RUN apk --no-cache add ca-certificates tzdata
 # 从构建阶段复制二进制文件
 COPY --from=builder /app/backend .
 
+# ip2region 数据文件
+COPY --from=builder /app/data/ip2region_v4.xdb /app/data/ip2region_v4.xdb
+
 # 从构建阶段复制时区数据(可选)
 COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
 

+ 25 - 0
backend/data/README.md

@@ -0,0 +1,25 @@
+# ip2region 离线 IP 库
+
+访客地理位置解析使用 [ip2region](https://github.com/lionsoul2014/ip2region) 的 `xdb` 文件。
+
+## 获取数据文件
+
+将 **`ip2region_v4.xdb`** 放到本目录(与本文档同级):
+
+```bash
+# 在项目根目录执行(需 curl)
+curl -L -o backend/data/ip2region_v4.xdb \
+  https://github.com/lionsoul2014/ip2region/raw/master/data/ip2region_v4.xdb
+```
+
+可选 IPv6:下载 `ip2region_v6.xdb` 并设置环境变量 `IP2REGION_V6_XDB`。
+
+## 环境变量
+
+| 变量 | 说明 |
+|------|------|
+| `IP2REGION_DISABLED` | `true` 时关闭解析 |
+| `IP2REGION_V4_XDB` | v4 库路径(默认自动查找 `backend/data/ip2region_v4.xdb`) |
+| `IP2REGION_V6_XDB` | v6 库路径(可选) |
+
+Docker 构建时会自动下载 v4 库到镜像内 `/app/data/`。

+ 2 - 0
backend/go.mod

@@ -46,8 +46,10 @@ require (
 	github.com/kr/pretty v0.3.0 // indirect
 	github.com/kr/text v0.2.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
+	github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260516030638-f4fcd5e900a9 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/milvus-io/milvus-proto/go-api/v2 v2.4.10-0.20240819025435-512e3b98866a // indirect
+	github.com/mitchellh/go-homedir v1.1.0 // 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.4 // indirect

+ 3 - 0
backend/go.sum

@@ -204,6 +204,8 @@ github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awS
 github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260516030638-f4fcd5e900a9 h1:4mP3vUJlSD6f9VBPzPQNXHOtAj4P7Fhuvl2whtUWRK4=
+github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260516030638-f4fcd5e900a9/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -222,6 +224,7 @@ github.com/milvus-io/milvus-proto/go-api/v2 v2.4.10-0.20240819025435-512e3b98866
 github.com/milvus-io/milvus-proto/go-api/v2 v2.4.10-0.20240819025435-512e3b98866a/go.mod h1:1OIl0v5PQeNxIJhCvY+K55CBUOYDZevw9g9380u1Wek=
 github.com/milvus-io/milvus-sdk-go/v2 v2.4.2 h1:Xqf+S7iicElwYoS2Zly8Nf/zKHuZsNy1xQajfdtygVY=
 github.com/milvus-io/milvus-sdk-go/v2 v2.4.2/go.mod h1:ulO1YUXKH0PGg50q27grw048GDY9ayB4FPmh7D+FFTA=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=

+ 76 - 0
backend/infra/geoip/format.go

@@ -0,0 +1,76 @@
+package geoip
+
+import "strings"
+
+// FormatRegion 将 ip2region 原始串格式化为客服可读位置(最长约 200 字符)。
+// 原始格式:国家|省份|城市|ISP|国家代码
+func FormatRegion(raw string) string {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return ""
+	}
+	parts := strings.Split(raw, "|")
+	for i := range parts {
+		parts[i] = strings.TrimSpace(parts[i])
+	}
+	if len(parts) == 0 {
+		return ""
+	}
+
+	country := ""
+	province := ""
+	city := ""
+	isp := ""
+	if len(parts) > 0 {
+		country = parts[0]
+	}
+	if len(parts) > 1 {
+		province = parts[1]
+	}
+	if len(parts) > 2 {
+		city = parts[2]
+	}
+	if len(parts) > 3 {
+		isp = parts[3]
+	}
+
+	// 中国:省略国家,优先「省·市」,运营商单独括号
+	if country == "中国" || country == "China" {
+		loc := joinNonEmpty("·", province, city)
+		if loc == "" {
+			loc = country
+		}
+		if isp != "" && isp != "0" {
+			return trimToMax(loc+" ("+isp+")", 200)
+		}
+		return trimToMax(loc, 200)
+	}
+
+	loc := joinNonEmpty(" · ", country, province, city)
+	if loc == "" {
+		loc = country
+	}
+	if isp != "" && isp != "0" {
+		return trimToMax(loc+" ("+isp+")", 200)
+	}
+	return trimToMax(loc, 200)
+}
+
+func joinNonEmpty(sep string, items ...string) string {
+	var out []string
+	for _, s := range items {
+		s = strings.TrimSpace(s)
+		if s == "" || s == "0" {
+			continue
+		}
+		out = append(out, s)
+	}
+	return strings.Join(out, sep)
+}
+
+func trimToMax(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	return s[:max]
+}

+ 18 - 0
backend/infra/geoip/format_test.go

@@ -0,0 +1,18 @@
+package geoip
+
+import "testing"
+
+func TestFormatRegion_China(t *testing.T) {
+	got := FormatRegion("中国|广东省|深圳市|电信|CN")
+	want := "广东省·深圳市 (电信)"
+	if got != want {
+		t.Fatalf("got %q want %q", got, want)
+	}
+}
+
+func TestFormatRegion_Overseas(t *testing.T) {
+	got := FormatRegion("United States|California|San Jose|xTom|US")
+	if got == "" {
+		t.Fatal("expected non-empty")
+	}
+}

+ 130 - 0
backend/infra/geoip/resolver.go

@@ -0,0 +1,130 @@
+package geoip
+
+import (
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+
+	"github.com/lionsoul2014/ip2region/binding/golang/service"
+)
+
+// Resolver 根据 IP 解析大致地理位置(离线)。
+type Resolver interface {
+	Lookup(ip string) string
+	Close()
+}
+
+// NoopResolver 未配置 xdb 时使用。
+type NoopResolver struct{}
+
+func (NoopResolver) Lookup(string) string { return "" }
+func (NoopResolver) Close()               {}
+
+type ip2regionResolver struct {
+	svc *service.Ip2Region
+}
+
+func (r *ip2regionResolver) Lookup(ip string) string {
+	ip = strings.TrimSpace(ip)
+	if ip == "" {
+		return ""
+	}
+	if host, _, err := net.SplitHostPort(ip); err == nil {
+		ip = host
+	}
+	parsed := net.ParseIP(ip)
+	if parsed == nil {
+		return ""
+	}
+	if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() {
+		return "内网"
+	}
+	region, err := r.svc.Search(ip)
+	if err != nil || strings.TrimSpace(region) == "" {
+		return ""
+	}
+	return FormatRegion(region)
+}
+
+func (r *ip2regionResolver) Close() {
+	if r.svc != nil {
+		r.svc.Close()
+	}
+}
+
+var (
+	globalResolver Resolver = NoopResolver{}
+	globalMu       sync.Mutex
+)
+
+// Get 返回全局 Resolver(未初始化时为 Noop)。
+func Get() Resolver {
+	globalMu.Lock()
+	defer globalMu.Unlock()
+	return globalResolver
+}
+
+// InitFromEnv 按环境变量与默认路径加载 ip2region;失败则降级为 Noop,不阻塞启动。
+// IP2REGION_DISABLED=true 时跳过;IP2REGION_V4_XDB 指定 v4 库路径;IP2REGION_V6_XDB 可选 IPv6。
+func InitFromEnv() {
+	globalMu.Lock()
+	defer globalMu.Unlock()
+
+	if old, ok := globalResolver.(*ip2regionResolver); ok {
+		old.Close()
+	}
+	globalResolver = NoopResolver{}
+
+	if strings.EqualFold(strings.TrimSpace(os.Getenv("IP2REGION_DISABLED")), "true") {
+		log.Println("ℹ️ IP2REGION_DISABLED=true,跳过访客 IP 地理位置解析")
+		return
+	}
+
+	v4Path := strings.TrimSpace(os.Getenv("IP2REGION_V4_XDB"))
+	v6Path := strings.TrimSpace(os.Getenv("IP2REGION_V6_XDB"))
+	if v4Path == "" {
+		v4Path = findDefaultV4XDB()
+	}
+	if v4Path == "" {
+		log.Println("⚠️ 未找到 ip2region xdb 文件,访客「位置」将保持为空;可将 data/ip2region_v4.xdb 放到 backend/data/ 或设置 IP2REGION_V4_XDB")
+		return
+	}
+
+	svc, err := service.NewIp2RegionWithPath(v4Path, v6Path)
+	if err != nil {
+		log.Printf("⚠️ 初始化 ip2region 失败: %v", err)
+		return
+	}
+	globalResolver = &ip2regionResolver{svc: svc}
+	log.Printf("✅ ip2region 已加载 (v4: %s)", v4Path)
+	if v6Path != "" {
+		log.Printf("   IPv6 库: %s", v6Path)
+	}
+}
+
+func findDefaultV4XDB() string {
+	candidates := []string{
+		"data/ip2region_v4.xdb",
+		"backend/data/ip2region_v4.xdb",
+		"/app/data/ip2region_v4.xdb",
+	}
+	if wd, err := os.Getwd(); err == nil {
+		candidates = append(candidates,
+			filepath.Join(wd, "data", "ip2region_v4.xdb"),
+			filepath.Join(wd, "..", "backend", "data", "ip2region_v4.xdb"),
+		)
+	}
+	for _, p := range candidates {
+		if st, err := os.Stat(p); err == nil && !st.IsDir() {
+			abs, err := filepath.Abs(p)
+			if err == nil {
+				return abs
+			}
+			return p
+		}
+	}
+	return ""
+}

+ 4 - 0
backend/main.go

@@ -10,6 +10,7 @@ import (
 
 	"github.com/2930134478/AI-CS/backend/controller"
 	"github.com/2930134478/AI-CS/backend/infra"
+	"github.com/2930134478/AI-CS/backend/infra/geoip"
 	"github.com/2930134478/AI-CS/backend/infra/mcp"
 	infra_search "github.com/2930134478/AI-CS/backend/infra/search"
 	"github.com/2930134478/AI-CS/backend/middleware"
@@ -133,6 +134,9 @@ func main() {
 		}
 	}
 
+	geoip.InitFromEnv()
+	defer geoip.Get().Close()
+
 	db, err := infra.NewDB()
 	if err != nil {
 		log.Fatalf("数据库连接失败:%v", err)

+ 15 - 8
backend/service/conversation_service.go

@@ -5,6 +5,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/2930134478/AI-CS/backend/infra/geoip"
 	"github.com/2930134478/AI-CS/backend/models"
 	"github.com/2930134478/AI-CS/backend/repository"
 	"gorm.io/gorm"
@@ -103,14 +104,15 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 				VisitorID:        input.VisitorID,
 				Status:           "open",
 				Website:          input.Website,
-				Referrer:   input.Referrer,
-				Browser:    input.Browser,
-				OS:         input.OS,
-				Language:   input.Language,
-				IPAddress:  input.IPAddress,
-				LastSeenAt: &now,
-				ChatMode:   chatMode,
-				AIConfigID: aiConfigID,
+				Referrer:         input.Referrer,
+				Browser:          input.Browser,
+				OS:               input.OS,
+				Language:         input.Language,
+				IPAddress:        input.IPAddress,
+				Location:         geoip.Get().Lookup(input.IPAddress),
+				LastSeenAt:       &now,
+				ChatMode:         chatMode,
+				AIConfigID:       aiConfigID,
 			}
 			if err := s.conversations.Create(conv); err != nil {
 				return nil, err
@@ -159,6 +161,11 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 		}
 		if input.IPAddress != "" && conv.IPAddress == "" {
 			updates["ip_address"] = input.IPAddress
+			if conv.Location == "" {
+				if loc := geoip.Get().Lookup(input.IPAddress); loc != "" {
+					updates["location"] = loc
+				}
+			}
 		}
 
 		// 重要:如果用户选择了新的 ChatMode,更新对话模式

+ 9 - 0
scripts/download-ip2region-xdb.sh

@@ -0,0 +1,9 @@
+#!/usr/bin/env sh
+# 下载 ip2region v4 xdb 到 backend/data/(本地开发用)
+set -e
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+DEST="$ROOT/backend/data/ip2region_v4.xdb"
+mkdir -p "$(dirname "$DEST")"
+curl -fsSL -o "$DEST" \
+  https://github.com/lionsoul2014/ip2region/raw/master/data/ip2region_v4.xdb
+echo "OK: $DEST ($(wc -c < "$DEST" | tr -d ' ') bytes)"