mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
新增位置
This commit is contained in:
@@ -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=
|
||||
|
||||
# =========================
|
||||
# 端口映射(非必须)
|
||||
# =========================
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
- 可选“本回合联网搜索”开关(是否对访客展示可在后台控制)
|
||||
- **客服侧(工作台)**
|
||||
- 会话列表、实时消息(WebSocket)、未读角标提示
|
||||
- 访客 **IP 与大致地理位置**(离线 [ip2region](https://github.com/lionsoul2014/ip2region),客服工作台访客详情展示)
|
||||
- 支持“实时共享草稿输入”(双方未发送内容可实时可见)
|
||||
- 多模型管理(文本/绘画等)与对话配置
|
||||
- **提示词配置**(Prompt 管理)
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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/`。
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,更新对话模式
|
||||
|
||||
@@ -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)"
|
||||
Reference in New Issue
Block a user