Files
hunli/hunliji-api/visit/visit.go
2026-08-02 19:12:24 +08:00

421 lines
11 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&regions)
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,
},
})
}