Files
hunli/hunliji-api/visit/visit.go

1069 lines
27 KiB
Go
Raw Normal View History

package visit
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
2026-08-03 10:27:56 +08:00
"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)
2026-08-04 10:26:00 +08:00
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")
2026-08-04 10:26:00 +08:00
// 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 = "未知"
}
2026-08-04 10:26:00 +08:00
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)
}
2026-08-04 10:26:00 +08:00
if city == "" {
city = guessCity(tokens, region)
}
return ipGeoResult{
Country: country,
Region: region,
City: city,
ISP: isp,
Raw: rawLoc,
}
}
2026-08-04 10:26:00 +08:00
// 常见运营商名:不得写入 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 {
2026-08-04 10:26:00 +08:00
// 从完整归属地文案中取省级片段,供地区分布统计(不含运营商)
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 {
2026-08-04 10:26:00 +08:00
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
}
}
2026-08-04 10:26:00 +08:00
// 直辖市常无独立「市」字段:用直辖市名本身,避免落入「未知」
if isMunicipalityName(province) {
return normalizeMunicipalityName(province)
}
return ""
}
2026-08-04 10:26:00 +08:00
// 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])
}
2026-08-03 10:27:56 +08:00
// parseDeviceFromUA 从 UA 解析机型/平台,用于用户画像统计
func parseDeviceFromUA(ua string) string {
ua = strings.TrimSpace(ua)
if ua == "" {
return "未知"
}
lower := strings.ToLower(ua)
if strings.Contains(ua, "iPhone") {
2026-08-04 10:26:00 +08:00
if ver := extractIOSVersion(ua); ver != "" {
return "iPhone · iOS " + ver
}
2026-08-03 10:27:56 +08:00
return "iPhone"
}
if strings.Contains(ua, "iPad") {
2026-08-04 10:26:00 +08:00
if ver := extractIOSVersion(ua); ver != "" {
return "iPad · iOS " + ver
}
2026-08-03 10:27:56 +08:00
return "iPad"
}
if strings.Contains(lower, "android") {
if model := extractAndroidModel(ua); model != "" {
2026-08-04 10:26:00 +08:00
return friendlyAndroidName(model)
2026-08-03 10:27:56 +08:00
}
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 "其他"
}
2026-08-04 10:26:00 +08:00
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 ""
}
2026-08-03 10:27:56 +08:00
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)
2026-08-04 10:26:00 +08:00
for _, sep := range []string{" Build", ")", "; HMSCore", "; HarmonyOS", "; wv"} {
2026-08-03 10:27:56 +08:00
if p := strings.Index(rest, sep); p >= 0 && p < end {
end = p
}
}
model := strings.TrimSpace(rest[:end])
model = strings.Trim(model, "; ")
2026-08-04 10:26:00 +08:00
// 部分 UA:HarmonyOS; NOH-AN00 Build
2026-08-03 10:27:56 +08:00
if model == "" || strings.EqualFold(model, "wv") || strings.HasPrefix(strings.ToLower(model), "linux") {
return ""
}
2026-08-04 10:26:00 +08:00
// 「zh-cn; SM-S9180」或多余语言标签
if p := strings.LastIndex(model, ";"); p >= 0 {
tail := strings.TrimSpace(model[p+1:])
if tail != "" {
model = tail
}
}
2026-08-03 10:27:56 +08:00
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"`
}
2026-08-04 10:26:00 +08:00
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").
2026-08-04 10:26:00 +08:00
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{}
}
2026-08-03 10:27:56 +08:00
devices := aggregateDevices()
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"uv": uv,
"pv": pv,
"today_uv": todayUV,
"regions": regions,
2026-08-03 10:27:56 +08:00
"devices": devices,
},
})
}
2026-08-04 10:26:00 +08:00
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{}
}
2026-08-03 10:27:56 +08:00
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{
2026-08-03 10:27:56 +08:00
"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,
},
})
}