package visit import ( "bytes" "encoding/json" "io" "log" "net/http" "sort" "strconv" "strings" "sync" "time" "hunliji-api/models" "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) api.GET("/visit/detail", handleVisitDetail) } 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, "; ") // 部分 UA:HarmonyOS; 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, }, }) } // handleVisitDetail 按指纹汇总访客行为(回执/弹幕/点赞/礼物) func handleVisitDetail(c *gin.Context) { if !requireAdmin(c) { return } fp := strings.TrimSpace(c.Query("fingerprint")) if fp == "" { c.JSON(400, gin.H{"error": "缺少 fingerprint"}) return } var visits []SiteVisit db.Where("fingerprint = ?", fp).Order("last_seen_at desc").Find(&visits) if len(visits) == 0 { c.JSON(404, gin.H{"error": "未找到该访客"}) return } clientIDs := make([]string, 0) seenCID := map[string]struct{}{} var visitCountSum int var firstSeen, lastSeen time.Time for i, v := range visits { visitCountSum += v.VisitCount if i == 0 { lastSeen = v.LastSeenAt firstSeen = v.FirstSeenAt } if v.FirstSeenAt.Before(firstSeen) { firstSeen = v.FirstSeenAt } if v.LastSeenAt.After(lastSeen) { lastSeen = v.LastSeenAt } cid := strings.TrimSpace(v.ClientID) if cid == "" { continue } if _, ok := seenCID[cid]; ok { continue } seenCID[cid] = struct{}{} clientIDs = append(clientIDs, cid) } latest := visits[0] visitorFilter := func(q *gorm.DB) *gorm.DB { if len(clientIDs) > 0 { return q.Where("fingerprint = ? OR client_id IN ?", fp, clientIDs) } return q.Where("fingerprint = ?", fp) } var likeCount int64 visitorFilter(db.Model(&models.SiteLike{})).Count(&likeCount) var likes []models.SiteLike visitorFilter(db.Model(&models.SiteLike{})). Order("created_at desc"). Limit(100). Find(&likes) var giftCount int64 visitorFilter(db.Model(&models.SiteGift{})).Count(&giftCount) var gifts []models.SiteGift visitorFilter(db.Model(&models.SiteGift{})). Order("created_at desc"). Limit(100). Find(&gifts) giftTypeCounts := map[string]int64{} for _, g := range gifts { giftTypeCounts[g.GiftType]++ } var rsvps []models.Rsvp visitorFilter(db.Model(&models.Rsvp{})). Order("created_at desc"). Limit(50). Find(&rsvps) var danmakus []models.Danmaku visitorFilter(db.Model(&models.Danmaku{})). Order("created_at desc"). Limit(100). Find(&danmakus) likeItems := make([]gin.H, 0, len(likes)) for _, row := range likes { likeItems = append(likeItems, gin.H{ "id": row.ID, "created_at": row.CreatedAt, }) } giftItems := make([]gin.H, 0, len(gifts)) for _, g := range gifts { giftItems = append(giftItems, gin.H{ "id": g.ID, "gift_type": g.GiftType, "name": g.Name, "cost": g.Cost, "created_at": g.CreatedAt, }) } rsvpItems := make([]gin.H, 0, len(rsvps)) for _, r := range rsvps { rsvpItems = append(rsvpItems, gin.H{ "id": r.ID, "name": r.Name, "guest_count": r.GuestCount, "wishes": r.Wishes, "created_at": r.CreatedAt, }) } danmakuItems := make([]gin.H, 0, len(danmakus)) for _, d := range danmakus { danmakuItems = append(danmakuItems, gin.H{ "id": d.ID, "name": d.Name, "content": d.Content, "status": d.Status, "source": d.Source, "color": d.Color, "created_at": d.CreatedAt, }) } c.JSON(200, gin.H{ "code": 200, "data": gin.H{ "visitor": gin.H{ "fingerprint": fp, "client_ids": clientIDs, "ip": latest.IP, "raw_location": latest.RawLocation, "region": latest.Region, "city": latest.City, "isp": latest.ISP, "user_agent": latest.UserAgent, "device": parseDeviceFromUA(latest.UserAgent), "visit_count": visitCountSum, "visit_rows": len(visits), "first_seen_at": firstSeen, "last_seen_at": lastSeen, }, "like_count": likeCount, "likes": likeItems, "gift_count": giftCount, "gift_type_counts": giftTypeCounts, "gifts": giftItems, "rsvps": rsvpItems, "danmakus": danmakuItems, }, }) }