优化工作台输入与表情,并补充访客 IP 地区

- 输入框改为单层大尺寸 textarea,修复双层边框
- 新增表情选择器(工作台/Widget),插入 Unicode 表情
- 会话记录访客 IP/地区/UA,工作台顶栏与侧栏展示
- 去掉表情面板无用横向滚动条
This commit is contained in:
yml2213
2026-07-15 12:13:59 +08:00
parent 318ba8a563
commit 85b407fddf
8 changed files with 441 additions and 36 deletions
+133
View File
@@ -0,0 +1,133 @@
package handler
import (
"net"
"strings"
"github.com/gin-gonic/gin"
)
// resolveVisitorIP 优先信任反代头,回落到 Gin ClientIP。
func resolveVisitorIP(c *gin.Context) string {
candidates := []string{
c.GetHeader("CF-Connecting-IP"),
c.GetHeader("True-Client-IP"),
c.GetHeader("X-Real-IP"),
}
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
// 取最左侧第一个非空 IP
for _, part := range strings.Split(xff, ",") {
part = strings.TrimSpace(part)
if part != "" {
candidates = append(candidates, part)
break
}
}
}
for _, raw := range candidates {
ip := strings.TrimSpace(raw)
if ip == "" {
continue
}
// 去掉端口
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if net.ParseIP(ip) != nil {
return ip
}
}
ip := c.ClientIP()
if host, _, err := net.SplitHostPort(ip); err == nil {
return host
}
return ip
}
// resolveVisitorRegion 基于 IP 与常见 CDN 国家头做粗粒度地区,无需第三方库。
// 私网/本机显示「内网」;有国家头则映射中文名;否则「未知」。
func resolveVisitorRegion(c *gin.Context, ip string) string {
// Cloudflare / 部分网关国家码
country := strings.ToUpper(strings.TrimSpace(c.GetHeader("CF-IPCountry")))
if country == "" {
country = strings.ToUpper(strings.TrimSpace(c.GetHeader("X-AppEngine-Country")))
}
if country == "" {
country = strings.ToUpper(strings.TrimSpace(c.GetHeader("CloudFront-Viewer-Country")))
}
if country != "" && country != "XX" && country != "T1" {
if name, ok := countryNameCN(country); ok {
return name
}
return country
}
parsed := net.ParseIP(ip)
if parsed == nil {
return "未知"
}
if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() {
return "内网"
}
// 未接入 GeoIP 库时,公网 IP 地区暂标未知(可后续接入离线库)
return "未知"
}
func countryNameCN(code string) (string, bool) {
names := map[string]string{
"CN": "中国", "HK": "中国香港", "MO": "中国澳门", "TW": "中国台湾",
"US": "美国", "JP": "日本", "KR": "韩国", "SG": "新加坡",
"GB": "英国", "DE": "德国", "FR": "法国", "AU": "澳大利亚",
"CA": "加拿大", "RU": "俄罗斯", "IN": "印度", "BR": "巴西",
"MY": "马来西亚", "TH": "泰国", "VN": "越南", "PH": "菲律宾",
"ID": "印度尼西亚", "AE": "阿联酋", "SA": "沙特阿拉伯",
}
name, ok := names[code]
return name, ok
}
// summarizeUserAgent 将 UA 压缩为可读设备摘要,如 "Chrome / macOS"。
func summarizeUserAgent(ua string) string {
ua = strings.TrimSpace(ua)
if ua == "" {
return ""
}
browser := "浏览器"
osName := "未知系统"
switch {
case strings.Contains(ua, "Edg/"):
browser = "Edge"
case strings.Contains(ua, "Chrome/") && !strings.Contains(ua, "Edg/"):
browser = "Chrome"
case strings.Contains(ua, "Firefox/"):
browser = "Firefox"
case strings.Contains(ua, "Safari/") && !strings.Contains(ua, "Chrome/"):
browser = "Safari"
case strings.Contains(ua, "MicroMessenger"):
browser = "微信"
}
switch {
case strings.Contains(ua, "Windows"):
osName = "Windows"
case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"):
osName = "macOS"
case strings.Contains(ua, "Android"):
osName = "Android"
case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad"):
osName = "iOS"
case strings.Contains(ua, "Linux"):
osName = "Linux"
}
return browser + " / " + osName
}
func captureVisitorMeta(c *gin.Context) (ip, region, ua, device string) {
ip = resolveVisitorIP(c)
region = resolveVisitorRegion(c, ip)
ua = c.Request.UserAgent()
if len(ua) > 500 {
ua = ua[:500]
}
device = summarizeUserAgent(ua)
return
}
@@ -0,0 +1,42 @@
package handler
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestResolveVisitorIPAndRegion(t *testing.T) {
gin.SetMode(gin.TestMode)
// 内网 IP
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.RemoteAddr = "192.168.1.105:12345"
ip := resolveVisitorIP(c)
if ip != "192.168.1.105" {
t.Fatalf("内网 IP 解析错误: %s", ip)
}
if region := resolveVisitorRegion(c, ip); region != "内网" {
t.Fatalf("内网地区应为内网, got %s", region)
}
// CF 国家头
c2, _ := gin.CreateTestContext(httptest.NewRecorder())
c2.Request = httptest.NewRequest("GET", "/", nil)
c2.Request.Header.Set("CF-Connecting-IP", "8.8.8.8")
c2.Request.Header.Set("CF-IPCountry", "US")
ip2 := resolveVisitorIP(c2)
if ip2 != "8.8.8.8" {
t.Fatalf("CF IP 解析错误: %s", ip2)
}
if region := resolveVisitorRegion(c2, ip2); region != "美国" {
t.Fatalf("期望美国, got %s", region)
}
if summarizeUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/120.0.0.0") != "Chrome / macOS" {
t.Fatalf("UA 摘要错误")
}
}
+5
View File
@@ -103,12 +103,17 @@ func (h *WidgetHandler) Init(c *gin.Context) {
return
}
visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c)
// 创建会话
session := model.Session{
TenantID: channel.TenantID,
ChannelID: channel.ID,
CustomerID: customer.ID,
VisitorTokenHash: visitorTokenHash,
VisitorIP: visitorIP,
VisitorRegion: visitorRegion,
UserAgent: userAgent,
Status: "waiting",
Priority: "normal",
}
+3
View File
@@ -70,6 +70,9 @@ type Session struct {
CustomerID uint `gorm:"index" json:"customer_id"`
AgentID *uint `gorm:"index" json:"agent_id"`
VisitorTokenHash string `gorm:"size:64;index" json:"-"`
VisitorIP string `gorm:"size:64" json:"visitor_ip"`
VisitorRegion string `gorm:"size:100" json:"visitor_region"`
UserAgent string `gorm:"size:500" json:"user_agent"`
LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
Status string `gorm:"size:20;default:waiting" json:"status"`
Priority string `gorm:"size:20;default:normal" json:"priority"`