421 lines
11 KiB
Go
421 lines
11 KiB
Go
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,
|
||
},
|
||
})
|
||
}
|