909 lines
23 KiB
Go
909 lines
23 KiB
Go
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, "; ")
|
||
// 部分 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,
|
||
},
|
||
})
|
||
}
|