新增位置

This commit is contained in:
537yaha
2026-05-18 16:02:34 +08:00
parent ad0c9fddc9
commit a9c28a4d0b
12 changed files with 300 additions and 9 deletions
+9
View File
@@ -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
View File
@@ -32,6 +32,7 @@
- 可选“本回合联网搜索”开关(是否对访客展示可在后台控制)
- **客服侧(工作台)**
- 会话列表、实时消息(WebSocket)、未读角标提示
- 访客 **IP 与大致地理位置**(离线 [ip2region](https://github.com/lionsoul2014/ip2region),客服工作台访客详情展示)
- 支持“实时共享草稿输入”(双方未发送内容可实时可见)
- 多模型管理(文本/绘画等)与对话配置
- **提示词配置**Prompt 管理)
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)"