Files
jiang13-bbs/backend/service/visitstats_query.go
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

617 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
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 service
import (
"strconv"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// ============ 管理端聚合查询(visit_events SQL 聚合;长趋势用 site_daily_stats) ============
// VisitDayPoint 日趋势点
type VisitDayPoint struct {
Date string `json:"date"`
PV int64 `json:"pv"`
UV int64 `json:"uv"`
}
// VisitNamedCount 命名计数(设备/来源/爬虫等)
type VisitNamedCount struct {
Key string `json:"key"`
Label string `json:"label"`
Count int64 `json:"count"`
}
// VisitPageRow 受访页面行
type VisitPageRow struct {
Path string `json:"path"`
PV int64 `json:"pv"`
UV int64 `json:"uv"`
Share float64 `json:"share"` // 占区间总 PV 比例(0–100)
}
// VisitOverview 概览 Tab
type VisitOverview struct {
RangeDays int `json:"range_days"`
Trend []VisitDayPoint `json:"trend"`
TodayPV int64 `json:"today_pv"`
TodayUV int64 `json:"today_uv"`
YesterdayPV int64 `json:"yesterday_pv"`
YesterdayUV int64 `json:"yesterday_uv"`
LoggedInPV int64 `json:"logged_in_pv"` // 今日登录 PV
Devices []VisitNamedCount `json:"devices"`
Sources []VisitNamedCount `json:"sources"`
TopPages []VisitPageRow `json:"top_pages"`
}
// VisitMinutePoint 分钟级 PV 点
type VisitMinutePoint struct {
Minute string `json:"minute"` // YYYY-MM-DD HH24:MI
PV int64 `json:"pv"`
}
// VisitEventItem 明细列表行
type VisitEventItem struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Kind string `json:"kind"`
Path string `json:"path"`
RefHost string `json:"ref_host"`
RefKind string `json:"ref_kind"`
UserID uint `json:"user_id"`
Username string `json:"username"`
IP string `json:"ip"`
Device string `json:"device"`
OS string `json:"os"`
Browser string `json:"browser"`
Bot string `json:"bot"`
Status int `json:"status"`
}
// VisitRealtime 实时 Tab
type VisitRealtime struct {
Minutes []VisitMinutePoint `json:"minutes"`
PV5min int64 `json:"pv_5min"`
PV30min int64 `json:"pv_30min"`
Online int64 `json:"online"`
TopPages []VisitNamedCount `json:"top_pages"`
Recent []VisitEventItem `json:"recent"`
}
// VisitSourcesKindDay 来源按日(kind 分列行)
type VisitSourcesKindDay struct {
Date string `json:"date"`
Items []VisitNamedCount `json:"items"`
}
// VisitSources 来源分析 Tab
type VisitSources struct {
RangeDays int `json:"range_days"`
Kinds []VisitNamedCount `json:"kinds"`
SearchHost []VisitNamedCount `json:"search_hosts"`
External []VisitNamedCount `json:"external"`
Trend []VisitSourcesKindDay `json:"trend"`
}
// VisitPages 受访页面 Tab
type VisitPages struct {
RangeDays int `json:"range_days"`
Total int64 `json:"total"` // 命中的 path 数
Items []VisitPageRow `json:"items"`
}
// VisitVisitors 访客画像 Tab
type VisitVisitors struct {
RangeDays int `json:"range_days"`
Devices []VisitNamedCount `json:"devices"`
OSes []VisitNamedCount `json:"oses"`
Browsers []VisitNamedCount `json:"browsers"`
LoggedInPV int64 `json:"logged_in_pv"`
GuestPV int64 `json:"guest_pv"`
NewVisitors int64 `json:"new_visitors"`
Returning int64 `json:"returning"`
Hourly []VisitNamedCount `json:"hourly"` // 0–23 时段 PV
}
// VisitSecurity 爬虫与安全 Tab
type VisitSecurity struct {
RangeDays int `json:"range_days"`
Bots []VisitNamedCount `json:"bots"`
BotTrend []VisitDayPoint `json:"bot_trend"`
ProbeStatus []VisitNamedCount `json:"probe_status"`
ProbeIPs []VisitNamedCount `json:"probe_ips"`
LoginFailed int64 `json:"login_failed"` // 近 N 日登录失败次数
FailedUsers int64 `json:"failed_users"` // 涉及用户名数
}
// VisitEventFilter 明细筛选
type VisitEventFilter struct {
Kind string
Bot string
Path string
IP string
Start time.Time
End time.Time
Page int
Size int
}
// VisitEventsResult 明细分页结果
type VisitEventsResult struct {
Total int64 `json:"total"`
Items []VisitEventItem `json:"items"`
}
// VisitSettingsPayload 设置 Tab(开关 + 保留天数 + 容量)
type VisitSettingsPayload struct {
Enabled bool `json:"enabled"`
RetentionDays int `json:"retention_days"`
Usage VisitUsage `json:"usage"`
Dropped uint64 `json:"dropped"`
}
type pvUVRow struct {
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
func visitRangeStart(days int) time.Time {
return todayDate().AddDate(0, 0, -(days - 1))
}
// groupCounts 通用 GROUP BY 计数(expr 为聚合表达式),按计数降序取前 limit 条。
// extraSQL 追加到 WHERE(如 " AND ref_kind = 'search'")。
func (s *VisitStatsService) groupCounts(start time.Time, expr, kind, extraSQL string, limit int) ([]VisitNamedCount, error) {
var rows []struct {
Key string
Count int64
}
q := s.db.Model(&model.VisitEvent{}).
Select(expr+" AS key, COUNT(*) AS count").
Where("created_at >= ?", start)
if kind != "" {
q = q.Where("kind = ?", kind)
}
if extraSQL != "" {
q = q.Where(extraSQL)
}
if err := q.Group("key").Order("count DESC").Limit(limit).Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]VisitNamedCount, 0, len(rows))
for _, r := range rows {
out = append(out, VisitNamedCount{Key: r.Key, Label: r.Key, Count: r.Count})
}
return out, nil
}
// Overview 概览:趋势(site_daily_stats)+ 今日/昨日 + 设备/来源/热门页面
func (s *VisitStatsService) Overview(rangeQ string) (*VisitOverview, error) {
days := parseRangeDays(rangeQ)
start := visitRangeStart(days)
out := &VisitOverview{RangeDays: days}
// 日趋势:日聚合表(每天一行,不随明细清理丢失)
if err := s.db.Model(&model.SiteDailyStats{}).
Select("to_char(date, 'YYYY-MM-DD') AS date, pv, uv").
Where("date >= ?", start).
Order("date ASC").Scan(&out.Trend).Error; err != nil {
return nil, err
}
// 空结果时 GORM 不赋值,nil 切片会序列化成 JSON null(前端期望 [])
if out.Trend == nil {
out.Trend = []VisitDayPoint{}
}
var today pvUVRow
if err := s.db.Model(&model.SiteDailyStats{}).
Select("COALESCE(SUM(pv),0) AS pv, COALESCE(SUM(uv),0) AS uv").
Where("date = ?", todayDate()).Scan(&today).Error; err != nil {
return nil, err
}
out.TodayPV, out.TodayUV = today.PV, today.UV
var yesterday pvUVRow
if err := s.db.Model(&model.SiteDailyStats{}).
Select("COALESCE(SUM(pv),0) AS pv, COALESCE(SUM(uv),0) AS uv").
Where("date = ?", todayDate().AddDate(0, 0, -1)).Scan(&yesterday).Error; err != nil {
return nil, err
}
out.YesterdayPV, out.YesterdayUV = yesterday.PV, yesterday.UV
if err := s.db.Model(&model.SiteDailyStats{}).
Select("COALESCE(SUM(logged_in_pv),0) AS logged_in_pv").
Where("date = ?", todayDate()).Scan(&out.LoggedInPV).Error; err != nil {
return nil, err
}
var err error
if out.Devices, err = s.groupCounts(start, "device", model.VisitKindPageview, "", 8); err != nil {
return nil, err
}
if out.Sources, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
return nil, err
}
if out.TopPages, err = s.pageAgg(start, "", 1, 10); err != nil {
return nil, err
}
return out, nil
}
// pageAgg path 聚合 PV/UV/占比(q 为 path 模糊匹配;page 从 1 起)
func (s *VisitStatsService) pageAgg(start time.Time, q string, page, size int) ([]VisitPageRow, error) {
var totalPV int64
if err := s.db.Model(&model.VisitEvent{}).
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
Scopes(pathFilter(q)).
Count(&totalPV).Error; err != nil {
return nil, err
}
if totalPV == 0 {
return []VisitPageRow{}, nil
}
var rows []VisitPageRow
if err := s.db.Model(&model.VisitEvent{}).
Select("path, COUNT(*) AS pv, COUNT(DISTINCT vid_hash) AS uv").
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
Scopes(pathFilter(q)).
Group("path").Order("pv DESC").
Offset((page - 1) * size).Limit(size).Scan(&rows).Error; err != nil {
return nil, err
}
for i := range rows {
rows[i].Share = float64(rows[i].PV) * 100 / float64(totalPV)
}
return rows, nil
}
func pathFilter(q string) func(*gorm.DB) *gorm.DB {
if q == "" {
return func(db *gorm.DB) *gorm.DB { return db }
}
return func(db *gorm.DB) *gorm.DB { return db.Where("path ILIKE ?", "%"+q+"%") }
}
// Realtime 实时:近 60 分钟分钟级 PV + 在线 + 近 5 分钟热门路径 + 最新 20 条
func (s *VisitStatsService) Realtime() (*VisitRealtime, error) {
out := &VisitRealtime{}
now := time.Now()
if err := s.db.Model(&model.VisitEvent{}).
Select("to_char(created_at, 'YYYY-MM-DD HH24:MI') AS minute, COUNT(*) AS pv").
Where("created_at >= ? AND kind = ?", now.Add(-time.Hour), model.VisitKindPageview).
Group("minute").Order("minute ASC").Scan(&out.Minutes).Error; err != nil {
return nil, err
}
if out.Minutes == nil {
out.Minutes = []VisitMinutePoint{}
}
if err := s.db.Model(&model.VisitEvent{}).
Where("created_at >= ? AND kind = ?", now.Add(-5*time.Minute), model.VisitKindPageview).
Count(&out.PV5min).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.VisitEvent{}).
Where("created_at >= ? AND kind = ?", now.Add(-30*time.Minute), model.VisitKindPageview).
Count(&out.PV30min).Error; err != nil {
return nil, err
}
// 在线口径与首页概览一致:users.last_seen_at 近 5 分钟且未封禁
if err := s.db.Model(&model.User{}).
Where("last_seen_at IS NOT NULL AND last_seen_at >= ? AND banned = ?", now.Add(-5*time.Minute), false).
Count(&out.Online).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.VisitEvent{}).
Select("path AS key, COUNT(*) AS count").
Where("created_at >= ? AND kind = ?", now.Add(-5*time.Minute), model.VisitKindPageview).
Group("path").Order("count DESC").Limit(10).
Scan(&out.TopPages).Error; err != nil {
return nil, err
}
for i := range out.TopPages {
out.TopPages[i].Label = out.TopPages[i].Key
}
if out.TopPages == nil {
out.TopPages = []VisitNamedCount{}
}
res, err := s.Events(VisitEventFilter{Kind: model.VisitKindPageview, Page: 1, Size: 20})
if err != nil {
return nil, err
}
out.Recent = res.Items
return out, nil
}
// Sources 来源分析
func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
days := parseRangeDays(rangeQ)
start := visitRangeStart(days)
out := &VisitSources{RangeDays: days}
var err error
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
return nil, err
}
if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
"ref_kind = '"+RefKindSearch+"'", 20); err != nil {
return nil, err
}
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
"ref_kind = '"+RefKindExternal+"'", 20); err != nil {
return nil, err
}
if out.Trend, err = s.SourcesTrend(rangeQ); err != nil {
return nil, err
}
return out, nil
}
// SourcesTrend 来源按日明细(kind 分列行)
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
days := parseRangeDays(rangeQ)
start := visitRangeStart(days)
var rows []struct {
Date string
RefKind string
Count int64
}
if err := s.db.Model(&model.VisitEvent{}).
Select("to_char(created_at, 'YYYY-MM-DD') AS date, ref_kind, COUNT(*) AS count").
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
Group("date, ref_kind").Order("date ASC").Scan(&rows).Error; err != nil {
return nil, err
}
byDay := make(map[string][]VisitNamedCount)
var dates []string
seen := make(map[string]struct{})
for _, r := range rows {
if _, ok := seen[r.Date]; !ok {
seen[r.Date] = struct{}{}
dates = append(dates, r.Date)
}
byDay[r.Date] = append(byDay[r.Date], VisitNamedCount{Key: r.RefKind, Label: r.RefKind, Count: r.Count})
}
out := make([]VisitSourcesKindDay, 0, len(dates))
for _, d := range dates {
out = append(out, VisitSourcesKindDay{Date: d, Items: byDay[d]})
}
return out, nil
}
// Pages 受访页面(分页 + 模糊)
func (s *VisitStatsService) Pages(rangeQ, q string, page, size int) (*VisitPages, error) {
days := parseRangeDays(rangeQ)
start := visitRangeStart(days)
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
out := &VisitPages{RangeDays: days}
if err := s.db.Model(&model.VisitEvent{}).
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
Scopes(pathFilter(q)).
Distinct("path").Count(&out.Total).Error; err != nil {
return nil, err
}
items, err := s.pageAgg(start, q, page, size)
if err != nil {
return nil, err
}
if items == nil {
items = []VisitPageRow{}
}
out.Items = items
return out, nil
}
// Visitors 访客画像
func (s *VisitStatsService) Visitors(rangeQ string) (*VisitVisitors, error) {
days := parseRangeDays(rangeQ)
start := visitRangeStart(days)
out := &VisitVisitors{RangeDays: days}
var err error
if out.Devices, err = s.groupCounts(start, "device", model.VisitKindPageview, "", 8); err != nil {
return nil, err
}
if out.OSes, err = s.groupCounts(start, "os", model.VisitKindPageview, "", 10); err != nil {
return nil, err
}
if out.Browsers, err = s.groupCounts(start, "browser", model.VisitKindPageview, "", 10); err != nil {
return nil, err
}
var lg pvUVRow
if err := s.db.Model(&model.VisitEvent{}).
Select("COALESCE(SUM(CASE WHEN user_id > 0 THEN 1 ELSE 0 END),0) AS pv, COALESCE(SUM(CASE WHEN user_id = 0 THEN 1 ELSE 0 END),0) AS uv").
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
Scan(&lg).Error; err != nil {
return nil, err
}
out.LoggedInPV, out.GuestPV = lg.PV, lg.UV
// 新老访客:区间内 vid 首次出现时间晚于区间起点视为新访客
var uvRows []struct {
VidHash string
FirstAt time.Time `gorm:"column:first_at"`
}
if err := s.db.Model(&model.VisitEvent{}).
Select("vid_hash, MIN(created_at) AS first_at").
Where("created_at >= ? AND kind = ? AND vid_hash <> ''", start, model.VisitKindPageview).
Group("vid_hash").Scan(&uvRows).Error; err != nil {
return nil, err
}
for _, r := range uvRows {
if r.FirstAt.After(start) {
out.NewVisitors++
} else {
out.Returning++
}
}
// 24 小时时段 PV(区间合计)
var hours []struct {
Hour int
Count int64
}
if err := s.db.Model(&model.VisitEvent{}).
Select("EXTRACT(HOUR FROM created_at)::int AS hour, COUNT(*) AS count").
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
Group("hour").Order("hour ASC").Scan(&hours).Error; err != nil {
return nil, err
}
out.Hourly = make([]VisitNamedCount, 24)
for i := range out.Hourly {
label := strconv.Itoa(i)
if i < 10 {
label = "0" + label
}
out.Hourly[i].Key = label
out.Hourly[i].Label = label + " 时"
}
for _, h := range hours {
if h.Hour >= 0 && h.Hour < 24 {
out.Hourly[h.Hour].Count = h.Count
}
}
return out, nil
}
// Security 爬虫与安全
func (s *VisitStatsService) Security(rangeQ string) (*VisitSecurity, error) {
days := parseRangeDays(rangeQ)
start := visitRangeStart(days)
out := &VisitSecurity{RangeDays: days}
var err error
if out.Bots, err = s.groupCounts(start, "bot", model.VisitKindBot, "", 20); err != nil {
return nil, err
}
if err := s.db.Model(&model.VisitEvent{}).
Select("to_char(created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS pv").
Where("created_at >= ? AND kind = ?", start, model.VisitKindBot).
Group("date").Order("date ASC").Scan(&out.BotTrend).Error; err != nil {
return nil, err
}
if out.BotTrend == nil {
out.BotTrend = []VisitDayPoint{}
}
if out.ProbeStatus, err = s.groupCounts(start, "status::text", model.VisitKindProbe, "", 15); err != nil {
return nil, err
}
if out.ProbeIPs, err = s.groupCounts(start, "ip", model.VisitKindProbe, "", 20); err != nil {
return nil, err
}
if err := s.db.Model(&model.LoginLog{}).
Where("success = ? AND created_at >= ?", false, start).
Count(&out.LoginFailed).Error; err != nil {
return nil, err
}
if err := s.db.Raw(`SELECT COUNT(DISTINCT username) FROM login_logs WHERE success = ? AND created_at >= ?`,
false, start).Scan(&out.FailedUsers).Error; err != nil {
return nil, err
}
return out, nil
}
// Events 访问明细(筛选 + 分页 + 补用户昵称)
func (s *VisitStatsService) Events(f VisitEventFilter) (*VisitEventsResult, error) {
if f.Page < 1 {
f.Page = 1
}
if f.Size < 1 || f.Size > 200 {
f.Size = 50
}
apply := func(db *gorm.DB) *gorm.DB {
if f.Kind != "" {
db = db.Where("kind = ?", f.Kind)
}
if f.Bot != "" {
db = db.Where("bot = ?", f.Bot)
}
if f.Path != "" {
db = db.Where("path ILIKE ?", "%"+f.Path+"%")
}
if f.IP != "" {
db = db.Where("ip = ?", f.IP)
}
if !f.Start.IsZero() {
db = db.Where("created_at >= ?", f.Start)
}
if !f.End.IsZero() {
db = db.Where("created_at < ?", f.End)
}
return db
}
out := &VisitEventsResult{}
if err := apply(s.db.Model(&model.VisitEvent{})).Count(&out.Total).Error; err != nil {
return nil, err
}
var events []model.VisitEvent
if err := apply(s.db.Model(&model.VisitEvent{})).
Order("id DESC").
Offset((f.Page - 1) * f.Size).Limit(f.Size).
Find(&events).Error; err != nil {
return nil, err
}
// 批量补昵称
userIDs := make([]uint, 0, len(events))
for _, e := range events {
if e.UserID > 0 {
userIDs = append(userIDs, e.UserID)
}
}
nameMap := make(map[uint]string, len(userIDs))
if len(userIDs) > 0 {
var users []model.User
if err := s.db.Select("id, username, nickname").
Where("id IN ?", userIDs).Find(&users).Error; err != nil {
return nil, err
}
for _, u := range users {
nameMap[u.ID] = u.Nickname
}
}
out.Items = make([]VisitEventItem, 0, len(events))
for _, e := range events {
out.Items = append(out.Items, VisitEventItem{
ID: e.ID, CreatedAt: e.CreatedAt, Kind: e.Kind, Path: e.Path,
RefHost: e.RefHost, RefKind: e.RefKind,
UserID: e.UserID, Username: nameMap[e.UserID],
IP: e.IP, Device: e.Device, OS: e.OS, Browser: e.Browser,
Bot: e.Bot, Status: e.Status,
})
}
return out, nil
}
// SettingsPayload 设置 Tab 数据
func (s *VisitStatsService) SettingsPayload() (*VisitSettingsPayload, error) {
out := &VisitSettingsPayload{
Enabled: s.Enabled(),
RetentionDays: s.retentionDays(),
Dropped: s.DroppedCount(),
}
u, err := s.Usage()
if err != nil {
return nil, err
}
out.Usage = u
return out, nil
}
// SaveSettings 保存采集开关与保留天数
func (s *VisitStatsService) SaveSettings(enabled bool, retentionDays int) error {
if err := s.setting.SetAnalyticsEnabled(enabled); err != nil {
return err
}
return s.setting.SetAnalyticsRetentionDays(retentionDays)
}