Files
hunli/hunliji-api/visit/visit.go
2026-08-04 10:26:00 +08:00

909 lines
23 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"
"sort"
"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/stats/cities", handleVisitCityStats)
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 常为完整文案,如「中国|浙江省|杭州市|联通」——运营商单独记 isp不并入地区
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 = "未知"
}
tokens := normalizeLocTokens(rawLoc)
if isp == "" {
isp = extractISPFromTokens(tokens)
}
// 归属地展示去掉运营商,避免与 isp 字段重复、也避免被当成地区
rawLoc = joinLocationWithoutISP(tokens)
if rawLoc == "" {
rawLoc = "未知"
}
// 省/地区:结构化字段优先,但若整串误带了运营商则清洗;否则从文案推断
region = sanitizeRegion(region)
if region == "" && rawLoc != "" && rawLoc != "未知" && rawLoc != "本地" {
region = guessProvince(rawLoc)
}
if city == "" {
city = guessCity(tokens, region)
}
return ipGeoResult{
Country: country,
Region: region,
City: city,
ISP: isp,
Raw: rawLoc,
}
}
// 常见运营商名:不得写入 region / raw_location
var ispNameTokens = []string{
"联通", "电信", "移动", "铁通", "广电", "教育网",
"长城宽带", "鹏博士", "方正宽带", "宽带通",
"China Unicom", "China Telecom", "China Mobile",
"CHINANET", "UNICOM", "CMNET", "CRTC",
}
func normalizeLocTokens(raw string) []string {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
replacer := strings.NewReplacer(
"|", " ", "", " ", "/", " ", "", " ",
" ", " ", "", " ", ",", " ",
)
return strings.Fields(replacer.Replace(s))
}
func isISPToken(t string) bool {
t = strings.TrimSpace(t)
if t == "" {
return false
}
lower := strings.ToLower(t)
for _, name := range ispNameTokens {
if t == name || strings.EqualFold(t, name) {
return true
}
if strings.EqualFold(lower, strings.ToLower(name)) {
return true
}
// 「杭州联通」「浙江电信」等后缀
if strings.HasSuffix(t, name) {
rest := strings.TrimSuffix(t, name)
if rest == "" || len([]rune(rest)) <= 4 {
return true
}
}
}
return false
}
func extractISPFromTokens(tokens []string) string {
for i := len(tokens) - 1; i >= 0; i-- {
if isISPToken(tokens[i]) {
return tokens[i]
}
}
return ""
}
func joinLocationWithoutISP(tokens []string) string {
parts := make([]string, 0, len(tokens))
for _, t := range tokens {
if isISPToken(t) {
continue
}
if len(parts) > 0 && parts[len(parts)-1] == t {
continue
}
parts = append(parts, t)
}
return strings.Join(parts, " ")
}
func sanitizeRegion(region string) string {
region = strings.TrimSpace(region)
if region == "" || region == "未知" {
return ""
}
// 误把整段「中国|浙江省|杭州市|联通」写入 region 时,只保留省级
if strings.ContainsAny(region, "|/ ") || isISPToken(region) {
return guessProvince(region)
}
return normalizeMunicipalityName(region)
}
var municipalityRoots = []string{"北京", "上海", "天津", "重庆"}
func isMunicipalityName(name string) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for _, m := range municipalityRoots {
if name == m || name == m+"市" {
return true
}
}
return false
}
// normalizeMunicipalityName 统一「北京」「北京市」→「北京市」,避免分布被拆开
func normalizeMunicipalityName(name string) string {
name = strings.TrimSpace(name)
for _, m := range municipalityRoots {
if name == m || name == m+"市" {
return m + "市"
}
}
return name
}
func guessProvince(raw string) string {
// 从完整归属地文案中取省级片段,供地区分布统计(不含运营商)
tokens := normalizeLocTokens(raw)
for _, t := range tokens {
if isISPToken(t) {
continue
}
if strings.Contains(t, "省") || strings.Contains(t, "自治区") {
return t
}
// 直辖市(含「北京」无「市」后缀)
if isMunicipalityName(t) {
return normalizeMunicipalityName(t)
}
}
for _, t := range tokens {
if t == "中国" || t == "国内" || isISPToken(t) {
continue
}
return normalizeMunicipalityName(t)
}
return ""
}
func guessCity(tokens []string, province string) string {
province = normalizeMunicipalityName(province)
// 优先区/县(直辖市下沉一级)
for _, t := range tokens {
if isISPToken(t) || t == "中国" || t == "国内" {
continue
}
if t == province || normalizeMunicipalityName(t) == province {
continue
}
if strings.HasSuffix(t, "区") || strings.HasSuffix(t, "县") {
return t
}
}
for _, t := range tokens {
if isISPToken(t) || t == "中国" || t == "国内" {
continue
}
if t == province || normalizeMunicipalityName(t) == province {
continue
}
if strings.HasSuffix(t, "市") || strings.HasSuffix(t, "州") || strings.HasSuffix(t, "盟") {
return t
}
}
// 直辖市常无独立「市」字段:用直辖市名本身,避免落入「未知」
if isMunicipalityName(province) {
return normalizeMunicipalityName(province)
}
return ""
}
// normalizeRegionName 清洗历史脏数据(整串含运营商),用于地区分布聚合
func normalizeRegionName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "未知"
}
if cleaned := sanitizeRegion(name); cleaned != "" {
return normalizeMunicipalityName(cleaned)
}
if isISPToken(name) {
return "未知"
}
return normalizeMunicipalityName(name)
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
// parseDeviceFromUA 从 UA 解析机型/平台,用于用户画像统计
func parseDeviceFromUA(ua string) string {
ua = strings.TrimSpace(ua)
if ua == "" {
return "未知"
}
lower := strings.ToLower(ua)
if strings.Contains(ua, "iPhone") {
if ver := extractIOSVersion(ua); ver != "" {
return "iPhone · iOS " + ver
}
return "iPhone"
}
if strings.Contains(ua, "iPad") {
if ver := extractIOSVersion(ua); ver != "" {
return "iPad · iOS " + ver
}
return "iPad"
}
if strings.Contains(lower, "android") {
if model := extractAndroidModel(ua); model != "" {
return friendlyAndroidName(model)
}
return "Android"
}
if strings.Contains(lower, "windows phone") {
return "Windows Phone"
}
if strings.Contains(lower, "windows") {
return "Windows"
}
if strings.Contains(ua, "Macintosh") || strings.Contains(lower, "mac os") {
return "Mac"
}
if strings.Contains(lower, "linux") {
return "Linux"
}
return "其他"
}
func extractIOSVersion(ua string) string {
// CPU iPhone OS 17_2 like Mac OS X → 17.2
for _, key := range []string{"iPhone OS ", "CPU OS "} {
idx := strings.Index(ua, key)
if idx < 0 {
continue
}
rest := ua[idx+len(key):]
end := 0
for end < len(rest) {
c := rest[end]
if (c >= '0' && c <= '9') || c == '_' || c == '.' {
end++
continue
}
break
}
if end == 0 {
continue
}
return strings.ReplaceAll(rest[:end], "_", ".")
}
return ""
}
func extractAndroidModel(ua string) string {
// 常见形态Linux; Android 13; SM-S9180 Build/... 或 Android 12; Pixel 7)
idx := strings.Index(ua, "Android")
if idx < 0 {
return ""
}
rest := ua[idx:]
semi := strings.Index(rest, ";")
if semi < 0 {
return ""
}
rest = strings.TrimSpace(rest[semi+1:])
end := len(rest)
for _, sep := range []string{" Build", ")", "; HMSCore", "; HarmonyOS", "; wv"} {
if p := strings.Index(rest, sep); p >= 0 && p < end {
end = p
}
}
model := strings.TrimSpace(rest[:end])
model = strings.Trim(model, "; ")
// 部分 UAHarmonyOS; NOH-AN00 Build
if model == "" || strings.EqualFold(model, "wv") || strings.HasPrefix(strings.ToLower(model), "linux") {
return ""
}
// 「zh-cn; SM-S9180」或多余语言标签
if p := strings.LastIndex(model, ";"); p >= 0 {
tail := strings.TrimSpace(model[p+1:])
if tail != "" {
model = tail
}
}
r := []rune(model)
if len(r) > 40 {
model = string(r[:40])
}
return model
}
func aggregateDevices() []map[string]interface{} {
type uaRow struct {
Fingerprint string
UserAgent string
LastSeenAt time.Time
}
var rows []uaRow
db.Model(&SiteVisit{}).
Select("fingerprint, user_agent, last_seen_at").
Order("last_seen_at desc").
Find(&rows)
seen := map[string]struct{}{}
counts := map[string]int64{}
for _, r := range rows {
if _, ok := seen[r.Fingerprint]; ok {
continue
}
seen[r.Fingerprint] = struct{}{}
name := parseDeviceFromUA(r.UserAgent)
counts[name]++
}
type kv struct {
Name string
Value int64
}
list := make([]kv, 0, len(counts))
for name, value := range counts {
list = append(list, kv{Name: name, Value: value})
}
sort.Slice(list, func(i, j int) bool { return list[i].Value > list[j].Value })
if len(list) > 30 {
list = list[:30]
}
out := make([]map[string]interface{}, 0, len(list))
for _, item := range list {
out = append(out, map[string]interface{}{
"name": item.Name,
"value": item.Value,
})
}
return out
}
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 rawRegions []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").
Scan(&rawRegions)
// 合并历史脏数据(如「中国|浙江省|杭州市|联通」被整段写入 region
merged := map[string]int64{}
for _, r := range rawRegions {
name := normalizeRegionName(r.Name)
merged[name] += r.Value
}
regions := make([]regionRow, 0, len(merged))
for name, value := range merged {
regions = append(regions, regionRow{Name: name, Value: value})
}
sort.Slice(regions, func(i, j int) bool {
if regions[i].Value == regions[j].Value {
return regions[i].Name < regions[j].Name
}
return regions[i].Value > regions[j].Value
})
if len(regions) > 30 {
regions = regions[:30]
}
if regions == nil {
regions = []regionRow{}
}
devices := aggregateDevices()
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"uv": uv,
"pv": pv,
"today_uv": todayUV,
"regions": regions,
"devices": devices,
},
})
}
type namedCount struct {
Name string `json:"name"`
Value int64 `json:"value"`
}
// handleVisitCityStats 某省下的市级分布(按指纹去重)
func handleVisitCityStats(c *gin.Context) {
if !requireAdmin(c) {
return
}
regionQ := strings.TrimSpace(c.Query("region"))
if regionQ == "" {
c.JSON(400, gin.H{"error": "缺少 region 参数"})
return
}
target := normalizeRegionName(regionQ)
type locRow struct {
Fingerprint string
Region string
City string
RawLocation string
}
var rows []locRow
q := db.Model(&SiteVisit{}).Select("fingerprint, region, city, raw_location")
if target == "未知" {
q = q.Where("region = '' OR region IS NULL OR region = ? OR region LIKE ?", "未知", "%未知%")
} else if target == "本地" {
q = q.Where("region = ? OR raw_location = ?", "本地", "本地")
} else {
like := "%" + target + "%"
q = q.Where("region = ? OR region LIKE ? OR raw_location LIKE ?", target, like, like)
}
if err := q.Find(&rows).Error; err != nil {
c.JSON(500, gin.H{"error": "查询失败"})
return
}
// fingerprint → city同指纹多条时取首次有效市
fpCity := map[string]string{}
for _, row := range rows {
if !rowBelongsToRegion(row.Region, row.RawLocation, target) {
continue
}
city := resolveVisitCity(row.City, row.RawLocation, target)
if prev, ok := fpCity[row.Fingerprint]; ok {
if prev != "未知" || city == "未知" {
continue
}
}
fpCity[row.Fingerprint] = city
}
merged := map[string]int64{}
for _, city := range fpCity {
merged[city]++
}
cities := make([]namedCount, 0, len(merged))
for name, value := range merged {
cities = append(cities, namedCount{Name: name, Value: value})
}
sort.Slice(cities, func(i, j int) bool {
if cities[i].Value == cities[j].Value {
return cities[i].Name < cities[j].Name
}
return cities[i].Value > cities[j].Value
})
if len(cities) > 40 {
cities = cities[:40]
}
if cities == nil {
cities = []namedCount{}
}
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"region": target,
"cities": cities,
},
})
}
func rowBelongsToRegion(region, raw, target string) bool {
if target == "未知" {
n := normalizeRegionName(region)
return n == "未知" || n == ""
}
if normalizeRegionName(region) == target {
return true
}
if guessProvince(raw) == target {
return true
}
return strings.Contains(region, target) || strings.Contains(raw, target)
}
func resolveVisitCity(city, raw, province string) string {
province = normalizeMunicipalityName(strings.TrimSpace(province))
city = strings.TrimSpace(city)
if city != "" && !isISPToken(city) {
// 历史脏数据city 里也可能是整段归属地
if strings.ContainsAny(city, "|/") {
if c := guessCity(normalizeLocTokens(city), province); c != "" {
return c
}
} else if normalizeMunicipalityName(city) != province {
// 普通省:市字段有效;若 city 本身是区县也保留
return city
}
// city 与直辖市同名时,继续从 raw 里找区县,找不到再用直辖市名
}
if c := guessCity(normalizeLocTokens(raw), province); c != "" {
return c
}
if isMunicipalityName(province) {
return normalizeMunicipalityName(province)
}
return "未知"
}
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{}
}
enriched := make([]gin.H, 0, len(list))
for _, row := range list {
enriched = append(enriched, gin.H{
"id": row.ID,
"fingerprint": row.Fingerprint,
"client_id": row.ClientID,
"ip": row.IP,
"country": row.Country,
"region": row.Region,
"city": row.City,
"isp": row.ISP,
"raw_location": row.RawLocation,
"user_agent": row.UserAgent,
"device": parseDeviceFromUA(row.UserAgent),
"referer": row.Referer,
"visit_count": row.VisitCount,
"first_seen_at": row.FirstSeenAt,
"last_seen_at": row.LastSeenAt,
})
}
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"list": enriched,
"total": total,
"page": page,
"pageSize": pageSize,
},
})
}