package visit import ( "bytes" "encoding/json" "io" "log" "net/http" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type SiteVisit struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` Fingerprint string `gorm:"column:fingerprint;size:128;not null;index;comment:浏览器指纹" json:"fingerprint"` ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:本地客户端ID" json:"client_id"` IP string `gorm:"column:ip;size:64;not null;default:'';index;comment:访客IP" json:"ip"` Country string `gorm:"column:country;size:64;not null;default:'';comment:国家" json:"country"` Region string `gorm:"column:region;size:64;not null;default:'';index;comment:省/地区" json:"region"` City string `gorm:"column:city;size:64;not null;default:'';comment:城市" json:"city"` ISP string `gorm:"column:isp;size:128;not null;default:'';comment:运营商" json:"isp"` RawLocation string `gorm:"column:raw_location;size:255;not null;default:'';comment:归属地展示" json:"raw_location"` UserAgent string `gorm:"column:user_agent;size:512;not null;default:'';comment:UA" json:"user_agent"` Referer string `gorm:"column:referer;size:512;not null;default:'';comment:来源" json:"referer"` VisitCount int `gorm:"column:visit_count;not null;default:1;comment:访问次数" json:"visit_count"` FirstSeenAt time.Time `gorm:"column:first_seen_at;not null;comment:首次访问" json:"first_seen_at"` LastSeenAt time.Time `gorm:"column:last_seen_at;not null;index;comment:最近访问" json:"last_seen_at"` } func (SiteVisit) TableName() string { return "site_visits" } var ( db *gorm.DB requireAdmin func(*gin.Context) bool ) // Register 由 main 入口调用:注入依赖并挂载路由 func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) { db = database requireAdmin = adminGuard api.POST("/visit", handleTrackVisit) api.GET("/visit/stats", handleVisitStats) api.GET("/visit/list", handleVisitList) } type ipGeoResult struct { Country string Region string City string ISP string Raw string } type ipCacheEntry struct { result ipGeoResult expiresAt time.Time } const ( ipGeoBaseURL = "http://ip.nailaoyun.cn/" // 与 PHP 侧 app-key 一致 ipGeoAppKey = "aff324073c3d236da5fff61aafd8b15bVTJGc2RHVmtYMSsyaUVIZzAxTkYxRnluN3lvcDVyQlJKSTljVnMvOC9iTT0=" ) var ( ipGeoCache = map[string]ipCacheEntry{} ipGeoCacheMu sync.Mutex ipHTTPClient = &http.Client{Timeout: 3 * time.Second} ) func lookupIPGeo(ip string) ipGeoResult { ip = strings.TrimSpace(ip) if ip == "" { return ipGeoResult{Raw: "未知"} } if ip == "127.0.0.1" || ip == "::1" || strings.HasPrefix(ip, "192.168.") || strings.HasPrefix(ip, "10.") { return ipGeoResult{Raw: "本地", Region: "本地"} } ipGeoCacheMu.Lock() if ent, ok := ipGeoCache[ip]; ok && time.Now().Before(ent.expiresAt) { ipGeoCacheMu.Unlock() return ent.result } ipGeoCacheMu.Unlock() result := fetchIPGeo(ip) ipGeoCacheMu.Lock() ipGeoCache[ip] = ipCacheEntry{result: result, expiresAt: time.Now().Add(30 * time.Minute)} ipGeoCacheMu.Unlock() return result } func fetchIPGeo(ip string) ipGeoResult { payload, _ := json.Marshal(map[string]interface{}{ "ip": ip, "type": 2, }) req, err := http.NewRequest(http.MethodPost, ipGeoBaseURL+"api/search-ip", bytes.NewReader(payload)) if err != nil { return ipGeoResult{Raw: "未知"} } req.Header.Set("Content-Type", "application/json") req.Header.Set("app-key", ipGeoAppKey) resp, err := ipHTTPClient.Do(req) if err != nil { log.Printf("IP归属地查询失败 %s: %v", ip, err) return ipGeoResult{Raw: "未知"} } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil || len(raw) == 0 { return ipGeoResult{Raw: "未知"} } return parseIPGeoJSON(raw) } func ipGeoCodeFailed(code interface{}) bool { switch v := code.(type) { case nil: return false case bool: return v case float64: return v != 0 case int: return v != 0 case int64: return v != 0 case string: s := strings.TrimSpace(v) return s != "" && s != "0" && !strings.EqualFold(s, "false") && !strings.EqualFold(s, "ok") && !strings.EqualFold(s, "success") default: return false } } func asString(v interface{}) string { switch t := v.(type) { case string: return strings.TrimSpace(t) case float64: return strconv.FormatInt(int64(t), 10) case json.Number: return t.String() default: return "" } } func parseIPGeoJSON(raw []byte) ipGeoResult { var root map[string]interface{} if err := json.Unmarshal(raw, &root); err != nil { return ipGeoResult{Raw: "未知"} } // PHP: if ($result['body']['code']) return 未知 if ipGeoCodeFailed(root["code"]) { return ipGeoResult{Raw: "未知"} } // 成功时 PHP 直接返回 body;归属地主要在 result 字段 resultStr := asString(root["result"]) data := root if nested, ok := root["data"].(map[string]interface{}); ok { data = nested if resultStr == "" { resultStr = asString(nested["result"]) } } else if nested, ok := root["result"].(map[string]interface{}); ok { data = nested if resultStr == "" { resultStr = asString(nested["result"]) } } pick := func(keys ...string) string { for _, k := range keys { if s := asString(data[k]); s != "" { return s } } return "" } country := pick("country", "Country", "nation", "country_name") region := pick("province", "region", "Region", "Province", "state") city := pick("city", "City") isp := pick("isp", "ISP", "operator", "org") // result 常为完整文案,如「中国 广东省 深圳市 电信」 rawLoc := resultStr if rawLoc == "" { parts := []string{} for _, p := range []string{country, region, city} { if p != "" && (len(parts) == 0 || parts[len(parts)-1] != p) { parts = append(parts, p) } } rawLoc = strings.Join(parts, " ") } if rawLoc == "" { rawLoc = pick("addr", "address", "location", "Location", "area") } if rawLoc == "" { rawLoc = "未知" } // 若只有完整文案,尽量拆出省用于统计 if region == "" && rawLoc != "" && rawLoc != "未知" && rawLoc != "本地" { region = guessProvince(rawLoc) } return ipGeoResult{ Country: country, Region: region, City: city, ISP: isp, Raw: rawLoc, } } func guessProvince(raw string) string { // 粗略从完整归属地文案中取省级片段,供地区分布统计 tokens := strings.Fields(strings.ReplaceAll(raw, " ", " ")) for _, t := range tokens { if strings.Contains(t, "省") || strings.Contains(t, "自治区") || strings.HasSuffix(t, "市") && (strings.HasPrefix(t, "北京") || strings.HasPrefix(t, "上海") || strings.HasPrefix(t, "天津") || strings.HasPrefix(t, "重庆")) { return t } } if len(tokens) >= 2 { return tokens[1] } return "" } func truncateRunes(s string, max int) string { r := []rune(s) if len(r) <= max { return s } return string(r[:max]) } func handleTrackVisit(c *gin.Context) { var body struct { Fingerprint string `json:"fingerprint"` ClientID string `json:"client_id"` UserAgent string `json:"user_agent"` Referer string `json:"referer"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } fp := strings.TrimSpace(body.Fingerprint) if fp == "" || len(fp) > 128 { c.JSON(400, gin.H{"error": "无效的 fingerprint"}) return } clientID := strings.TrimSpace(body.ClientID) if len(clientID) > 64 { clientID = clientID[:64] } ua := strings.TrimSpace(body.UserAgent) if ua == "" { ua = c.GetHeader("User-Agent") } ua = truncateRunes(ua, 512) referer := strings.TrimSpace(body.Referer) if referer == "" { referer = c.GetHeader("Referer") } referer = truncateRunes(referer, 512) ip := c.ClientIP() geo := lookupIPGeo(ip) now := time.Now() var existing SiteVisit err := db.Where("fingerprint = ?", fp).Order("last_seen_at desc").First(&existing).Error if err == nil && now.Sub(existing.LastSeenAt) < 24*time.Hour { updates := map[string]interface{}{ "ip": ip, "country": geo.Country, "region": geo.Region, "city": geo.City, "isp": geo.ISP, "raw_location": geo.Raw, "user_agent": ua, "referer": referer, "visit_count": existing.VisitCount + 1, "last_seen_at": now, } if clientID != "" { updates["client_id"] = clientID } if err := db.Model(&existing).Updates(updates).Error; err != nil { c.JSON(500, gin.H{"error": "记录访客失败"}) return } c.JSON(200, gin.H{"code": 200, "msg": "ok"}) return } if err != nil && err != gorm.ErrRecordNotFound { c.JSON(500, gin.H{"error": "记录访客失败"}) return } row := SiteVisit{ Fingerprint: fp, ClientID: clientID, IP: ip, Country: geo.Country, Region: geo.Region, City: geo.City, ISP: geo.ISP, RawLocation: geo.Raw, UserAgent: ua, Referer: referer, VisitCount: 1, FirstSeenAt: now, LastSeenAt: now, } if err := db.Create(&row).Error; err != nil { c.JSON(500, gin.H{"error": "记录访客失败"}) return } c.JSON(200, gin.H{"code": 200, "msg": "ok"}) } func handleVisitStats(c *gin.Context) { if !requireAdmin(c) { return } var uv int64 db.Model(&SiteVisit{}).Distinct("fingerprint").Count(&uv) var pv int64 db.Model(&SiteVisit{}).Select("COALESCE(SUM(visit_count),0)").Scan(&pv) nowDay := time.Now() startOfDay := time.Date(nowDay.Year(), nowDay.Month(), nowDay.Day(), 0, 0, 0, 0, nowDay.Location()) var todayUV int64 db.Model(&SiteVisit{}). Where("last_seen_at >= ?", startOfDay). Distinct("fingerprint"). Count(&todayUV) type regionRow struct { Name string `json:"name"` Value int64 `json:"value"` } var regions []regionRow db.Model(&SiteVisit{}). Select("CASE WHEN region = '' OR region IS NULL THEN '未知' ELSE region END as name, COUNT(DISTINCT fingerprint) as value"). Group("name"). Order("value desc"). Limit(30). Scan(®ions) if regions == nil { regions = []regionRow{} } c.JSON(200, gin.H{ "code": 200, "data": gin.H{ "uv": uv, "pv": pv, "today_uv": todayUV, "regions": regions, }, }) } func handleVisitList(c *gin.Context) { if !requireAdmin(c) { return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } var total int64 db.Model(&SiteVisit{}).Count(&total) var list []SiteVisit db.Order("last_seen_at desc"). Offset((page - 1) * pageSize). Limit(pageSize). Find(&list) if list == nil { list = []SiteVisit{} } c.JSON(200, gin.H{ "code": 200, "data": gin.H{ "list": list, "total": total, "page": page, "pageSize": pageSize, }, }) }