diff --git a/hunliji-api/visit/visit.go b/hunliji-api/visit/visit.go index 10af82c..6773487 100644 --- a/hunliji-api/visit/visit.go +++ b/hunliji-api/visit/visit.go @@ -853,10 +853,26 @@ func resolveVisitCity(city, raw, province string) string { return "未知" } -func handleVisitList(c *gin.Context) { - if !requireAdmin(c) { - return - } +type visitListQuery struct { + Page int + PageSize int + Region string + City string + HasInteraction bool + HasGift bool + Sort string +} + +type visitEnrichment struct { + GuestNames []string + GiftCount int64 + LikeCount int64 + RsvpCount int64 + DanmakuCount int64 + HasInteraction bool +} + +func parseVisitListQuery(c *gin.Context) visitListQuery { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) if page < 1 { @@ -865,37 +881,369 @@ func handleVisitList(c *gin.Context) { if pageSize < 1 || pageSize > 100 { pageSize = 20 } + sort := strings.TrimSpace(c.DefaultQuery("sort", "last_seen_desc")) + switch sort { + case "last_seen_desc", "last_seen_asc", "visit_count_desc", "visit_count_asc": + default: + sort = "last_seen_desc" + } + return visitListQuery{ + Page: page, + PageSize: pageSize, + Region: strings.TrimSpace(c.Query("region")), + City: strings.TrimSpace(c.Query("city")), + HasInteraction: c.Query("has_interaction") == "1" || strings.EqualFold(c.Query("has_interaction"), "true"), + HasGift: c.Query("has_gift") == "1" || strings.EqualFold(c.Query("has_gift"), "true"), + Sort: sort, + } +} + +func applyRegionWhere(q *gorm.DB, region string) *gorm.DB { + target := normalizeRegionName(region) + if target == "未知" { + return q.Where("region = '' OR region IS NULL OR region = ? OR region LIKE ?", "未知", "%未知%") + } + if target == "本地" { + return q.Where("region = ? OR raw_location = ?", "本地", "本地") + } + like := "%" + target + "%" + return q.Where("region = ? OR region LIKE ? OR raw_location LIKE ?", target, like, like) +} + +// fingerprintsForRegionCity 按指纹去重,与市级分布统计口径一致 +func fingerprintsForRegionCity(region, city string) []string { + targetRegion := normalizeRegionName(region) + targetCity := strings.TrimSpace(city) + + type locRow struct { + Fingerprint string + Region string + City string + RawLocation string + } + var rows []locRow + q := db.Model(&SiteVisit{}).Select("fingerprint, region, city, raw_location") + q = applyRegionWhere(q, targetRegion) + if err := q.Find(&rows).Error; err != nil { + return nil + } + + fpCity := map[string]string{} + for _, row := range rows { + if !rowBelongsToRegion(row.Region, row.RawLocation, targetRegion) { + continue + } + c := resolveVisitCity(row.City, row.RawLocation, targetRegion) + if prev, ok := fpCity[row.Fingerprint]; ok { + if prev != "未知" || c == "未知" { + continue + } + } + fpCity[row.Fingerprint] = c + } + + out := make([]string, 0) + for fp, c := range fpCity { + if targetCity != "" && c != targetCity { + continue + } + out = append(out, fp) + } + return out +} + +func visitorFingerprintsWithGift() map[string]struct{} { + result := map[string]struct{}{} + var fps []string + db.Model(&models.SiteGift{}).Distinct().Where("fingerprint != ''").Pluck("fingerprint", &fps) + for _, fp := range fps { + result[fp] = struct{}{} + } + var cids []string + db.Model(&models.SiteGift{}).Distinct().Where("client_id != ''").Pluck("client_id", &cids) + if len(cids) > 0 { + var visitFps []string + db.Model(&SiteVisit{}).Where("client_id IN ?", cids).Distinct().Pluck("fingerprint", &visitFps) + for _, fp := range visitFps { + result[fp] = struct{}{} + } + } + return result +} + +func visitorFingerprintsWithInteraction() map[string]struct{} { + result := visitorFingerprintsWithGift() + addByField := func(model interface{}, fpField, cidField string) { + var fps []string + db.Model(model).Distinct().Where(fpField+" != ''").Pluck(fpField, &fps) + for _, fp := range fps { + result[fp] = struct{}{} + } + var cids []string + db.Model(model).Distinct().Where(cidField+" != ''").Pluck(cidField, &cids) + if len(cids) == 0 { + return + } + var visitFps []string + db.Model(&SiteVisit{}).Where("client_id IN ?", cids).Distinct().Pluck("fingerprint", &visitFps) + for _, fp := range visitFps { + result[fp] = struct{}{} + } + } + addByField(&models.SiteLike{}, "fingerprint", "client_id") + addByField(&models.Rsvp{}, "fingerprint", "client_id") + addByField(&models.Danmaku{}, "fingerprint", "client_id") + return result +} + +func mapKeys(m map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func visitListOrder(sort string) string { + switch sort { + case "visit_count_desc": + return "visit_count desc, last_seen_at desc" + case "visit_count_asc": + return "visit_count asc, last_seen_at desc" + case "last_seen_asc": + return "last_seen_at asc" + default: + return "last_seen_at desc" + } +} + +func appendUniqueName(names []string, name string) []string { + name = strings.TrimSpace(name) + if name == "" { + return names + } + for _, n := range names { + if n == name { + return names + } + } + return append(names, name) +} + +func batchEnrichVisits(rows []SiteVisit) map[string]visitEnrichment { + result := map[string]visitEnrichment{} + if len(rows) == 0 { + return result + } + + fps := make([]string, 0, len(rows)) + cids := make([]string, 0, len(rows)) + cidToFp := map[string]string{} + seenFp := map[string]struct{}{} + for _, row := range rows { + if row.Fingerprint != "" { + if _, ok := seenFp[row.Fingerprint]; !ok { + seenFp[row.Fingerprint] = struct{}{} + fps = append(fps, row.Fingerprint) + } + } + if row.ClientID != "" { + cids = append(cids, row.ClientID) + cidToFp[row.ClientID] = row.Fingerprint + } + } + + apply := func(fp string, fn func(*visitEnrichment)) { + if fp == "" { + return + } + e := result[fp] + fn(&e) + result[fp] = e + } + + var gifts []models.SiteGift + if len(fps) > 0 || len(cids) > 0 { + q := db.Model(&models.SiteGift{}) + if len(fps) > 0 && len(cids) > 0 { + q = q.Where("fingerprint IN ? OR client_id IN ?", fps, cids) + } else if len(fps) > 0 { + q = q.Where("fingerprint IN ?", fps) + } else { + q = q.Where("client_id IN ?", cids) + } + q.Find(&gifts) + } + for _, g := range gifts { + fp := g.Fingerprint + if fp == "" { + fp = cidToFp[g.ClientID] + } + apply(fp, func(e *visitEnrichment) { + e.GiftCount++ + e.GuestNames = appendUniqueName(e.GuestNames, g.Name) + e.HasInteraction = true + }) + } + + var likes []models.SiteLike + if len(fps) > 0 || len(cids) > 0 { + q := db.Model(&models.SiteLike{}) + if len(fps) > 0 && len(cids) > 0 { + q = q.Where("fingerprint IN ? OR client_id IN ?", fps, cids) + } else if len(fps) > 0 { + q = q.Where("fingerprint IN ?", fps) + } else { + q = q.Where("client_id IN ?", cids) + } + q.Find(&likes) + } + for _, row := range likes { + fp := row.Fingerprint + if fp == "" { + fp = cidToFp[row.ClientID] + } + apply(fp, func(e *visitEnrichment) { + e.LikeCount++ + e.HasInteraction = true + }) + } + + var rsvps []models.Rsvp + if len(fps) > 0 || len(cids) > 0 { + q := db.Model(&models.Rsvp{}) + if len(fps) > 0 && len(cids) > 0 { + q = q.Where("fingerprint IN ? OR client_id IN ?", fps, cids) + } else if len(fps) > 0 { + q = q.Where("fingerprint IN ?", fps) + } else { + q = q.Where("client_id IN ?", cids) + } + q.Find(&rsvps) + } + for _, r := range rsvps { + fp := r.Fingerprint + if fp == "" { + fp = cidToFp[r.ClientID] + } + apply(fp, func(e *visitEnrichment) { + e.RsvpCount++ + e.GuestNames = appendUniqueName(e.GuestNames, r.Name) + e.HasInteraction = true + }) + } + + var danmakus []models.Danmaku + if len(fps) > 0 || len(cids) > 0 { + q := db.Model(&models.Danmaku{}) + if len(fps) > 0 && len(cids) > 0 { + q = q.Where("fingerprint IN ? OR client_id IN ?", fps, cids) + } else if len(fps) > 0 { + q = q.Where("fingerprint IN ?", fps) + } else { + q = q.Where("client_id IN ?", cids) + } + q.Find(&danmakus) + } + for _, d := range danmakus { + fp := d.Fingerprint + if fp == "" { + fp = cidToFp[d.ClientID] + } + apply(fp, func(e *visitEnrichment) { + e.DanmakuCount++ + e.GuestNames = appendUniqueName(e.GuestNames, d.Name) + e.HasInteraction = true + }) + } + + return result +} + +func buildVisitListDB(q visitListQuery) *gorm.DB { + query := db.Model(&SiteVisit{}) + + if q.Region != "" { + if q.City != "" { + fps := fingerprintsForRegionCity(q.Region, q.City) + if len(fps) == 0 { + return query.Where("1 = 0") + } + query = query.Where("fingerprint IN ?", fps) + } else { + query = applyRegionWhere(query, q.Region) + } + } + + if q.HasGift { + fps := mapKeys(visitorFingerprintsWithGift()) + if len(fps) == 0 { + return query.Where("1 = 0") + } + query = query.Where("fingerprint IN ?", fps) + } + if q.HasInteraction { + fps := mapKeys(visitorFingerprintsWithInteraction()) + if len(fps) == 0 { + return query.Where("1 = 0") + } + query = query.Where("fingerprint IN ?", fps) + } + + return query +} + +func handleVisitList(c *gin.Context) { + if !requireAdmin(c) { + return + } + q := parseVisitListQuery(c) + + baseQuery := buildVisitListDB(q) var total int64 - db.Model(&SiteVisit{}).Count(&total) + baseQuery.Count(&total) var list []SiteVisit - db.Order("last_seen_at desc"). - Offset((page - 1) * pageSize). - Limit(pageSize). + baseQuery.Order(visitListOrder(q.Sort)). + Offset((q.Page - 1) * q.PageSize). + Limit(q.PageSize). Find(&list) if list == nil { list = []SiteVisit{} } + enrichMap := batchEnrichVisits(list) enriched := make([]gin.H, 0, len(list)) for _, row := range list { + e := enrichMap[row.Fingerprint] + guestNames := e.GuestNames + if guestNames == nil { + guestNames = []string{} + } 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, + "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, + "guest_names": guestNames, + "guest_name": strings.Join(guestNames, "、"), + "gift_count": e.GiftCount, + "like_count": e.LikeCount, + "rsvp_count": e.RsvpCount, + "danmaku_count": e.DanmakuCount, + "has_interaction": e.HasInteraction, }) } @@ -904,8 +1252,8 @@ func handleVisitList(c *gin.Context) { "data": gin.H{ "list": enriched, "total": total, - "page": page, - "pageSize": pageSize, + "page": q.Page, + "pageSize": q.PageSize, }, }) } diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index b0e4d01..9f40cc5 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -199,19 +199,40 @@