271 lines
7.3 KiB
Go
271 lines
7.3 KiB
Go
package service
|
|
|
|
import (
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/freefire/jiang13-bbs/model"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AnalyticsService 管理端聚合分析
|
|
type AnalyticsService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewAnalyticsService(db *gorm.DB) *AnalyticsService {
|
|
return &AnalyticsService{db: db}
|
|
}
|
|
|
|
// DayCount 按日计数点
|
|
type DayCount struct {
|
|
Date string `json:"date"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
// VisitDay 访问日序列
|
|
type VisitDay struct {
|
|
Date string `json:"date"`
|
|
PV int64 `json:"pv"`
|
|
UV int64 `json:"uv"`
|
|
}
|
|
|
|
// NamedCount 命名计数(板块/类型等)
|
|
type NamedCount struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
// TopViewPost 高浏览帖
|
|
type TopViewPost struct {
|
|
ID uint `json:"id"`
|
|
Title string `json:"title"`
|
|
ViewCount int `json:"view_count"`
|
|
BoardID uint `json:"board_id"`
|
|
}
|
|
|
|
// AnalyticsOverview 仪表盘聚合
|
|
type AnalyticsOverview struct {
|
|
RangeDays int `json:"range_days"`
|
|
TodayPV int64 `json:"today_pv"`
|
|
TodayUV int64 `json:"today_uv"`
|
|
Visits []VisitDay `json:"visits"`
|
|
PostsSeries []DayCount `json:"posts_series"`
|
|
CommentsSeries []DayCount `json:"comments_series"`
|
|
RegistersSeries []DayCount `json:"registers_series"`
|
|
Boards []NamedCount `json:"boards"`
|
|
PostTypes []NamedCount `json:"post_types"`
|
|
TopViewed []TopViewPost `json:"top_viewed"`
|
|
}
|
|
|
|
func parseRangeDays(rangeQ string) int {
|
|
switch rangeQ {
|
|
case "30d", "30":
|
|
return 30
|
|
default:
|
|
return 7
|
|
}
|
|
}
|
|
|
|
func dateKey(t time.Time) string {
|
|
return t.Format("2006-01-02")
|
|
}
|
|
|
|
func fillDaySeries(start, end time.Time, got map[string]int64) []DayCount {
|
|
out := make([]DayCount, 0)
|
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
k := dateKey(d)
|
|
out = append(out, DayCount{Date: k, Count: got[k]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Overview 返回近 N 日访问与内容趋势。
|
|
// scoped=true 时按 boardIDs 过滤内容类指标,并隐藏全站 PV/UV/注册。
|
|
func (s *AnalyticsService) Overview(rangeQ string, boardIDs []uint, scoped bool) (*AnalyticsOverview, error) {
|
|
days := parseRangeDays(rangeQ)
|
|
now := time.Now()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
start := today.AddDate(0, 0, -(days - 1))
|
|
end := today
|
|
|
|
out := &AnalyticsOverview{
|
|
RangeDays: days,
|
|
Visits: make([]VisitDay, 0, days),
|
|
Boards: make([]NamedCount, 0),
|
|
PostTypes: make([]NamedCount, 0),
|
|
TopViewed: make([]TopViewPost, 0),
|
|
}
|
|
|
|
if scoped && len(boardIDs) == 0 {
|
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)})
|
|
}
|
|
out.PostsSeries = fillDaySeries(start, end, nil)
|
|
out.CommentsSeries = fillDaySeries(start, end, nil)
|
|
out.RegistersSeries = fillDaySeries(start, end, nil)
|
|
return out, nil
|
|
}
|
|
|
|
if !scoped {
|
|
var stats []model.SiteDailyStats
|
|
if err := s.db.Where("date >= ? AND date <= ?", start, end).
|
|
Order("date ASC").Find(&stats).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
visitMap := map[string]model.SiteDailyStats{}
|
|
for _, st := range stats {
|
|
visitMap[dateKey(st.Date)] = st
|
|
}
|
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
k := dateKey(d)
|
|
st := visitMap[k]
|
|
out.Visits = append(out.Visits, VisitDay{Date: k, PV: st.PV, UV: st.UV})
|
|
if k == dateKey(today) {
|
|
out.TodayPV = st.PV
|
|
out.TodayUV = st.UV
|
|
}
|
|
}
|
|
} else {
|
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)})
|
|
}
|
|
}
|
|
|
|
type dayRow struct {
|
|
Day string
|
|
Cnt int64
|
|
}
|
|
scanDayCounts := func(sql string, args ...interface{}) (map[string]int64, error) {
|
|
var rows []dayRow
|
|
if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
m := map[string]int64{}
|
|
for _, r := range rows {
|
|
m[r.Day] = r.Cnt
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
boardFilter := ""
|
|
var postArgs []interface{}
|
|
postArgs = append(postArgs, model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
|
|
if scoped {
|
|
boardFilter = " AND board_id IN ?"
|
|
postArgs = append(postArgs, boardIDs)
|
|
}
|
|
postMap, err := scanDayCounts(`
|
|
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
|
FROM posts
|
|
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?`+boardFilter+`
|
|
GROUP BY day ORDER BY day`,
|
|
postArgs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.PostsSeries = fillDaySeries(start, end, postMap)
|
|
|
|
var commentArgs []interface{}
|
|
commentArgs = append(commentArgs, model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
|
|
commentBoard := ""
|
|
if scoped {
|
|
commentBoard = " AND post_id IN (SELECT id FROM posts WHERE board_id IN ?)"
|
|
commentArgs = append(commentArgs, boardIDs)
|
|
}
|
|
commentMap, err := scanDayCounts(`
|
|
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
|
FROM comments
|
|
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?`+commentBoard+`
|
|
GROUP BY day ORDER BY day`,
|
|
commentArgs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.CommentsSeries = fillDaySeries(start, end, commentMap)
|
|
|
|
if !scoped {
|
|
regMap, err := scanDayCounts(`
|
|
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
|
FROM users
|
|
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?
|
|
GROUP BY day ORDER BY day`,
|
|
start, end.AddDate(0, 0, 1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.RegistersSeries = fillDaySeries(start, end, regMap)
|
|
} else {
|
|
out.RegistersSeries = fillDaySeries(start, end, nil)
|
|
}
|
|
|
|
type boardRow struct {
|
|
ID uint
|
|
Name string
|
|
PostCount int64
|
|
}
|
|
var boards []boardRow
|
|
boardSQL := `
|
|
SELECT b.id, b.name, count(p.id) AS post_count
|
|
FROM boards b
|
|
LEFT JOIN posts p ON p.board_id = b.id AND p.deleted_at IS NULL AND p.status = ?
|
|
WHERE b.deleted_at IS NULL`
|
|
boardArgs := []interface{}{model.ContentStatusPublished}
|
|
if scoped {
|
|
boardSQL += ` AND b.id IN ?`
|
|
boardArgs = append(boardArgs, boardIDs)
|
|
}
|
|
boardSQL += ` GROUP BY b.id, b.name ORDER BY post_count DESC`
|
|
if err := s.db.Raw(boardSQL, boardArgs...).Scan(&boards).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, b := range boards {
|
|
out.Boards = append(out.Boards, NamedCount{
|
|
Key: strconv.FormatUint(uint64(b.ID), 10),
|
|
Label: b.Name,
|
|
Count: b.PostCount,
|
|
})
|
|
}
|
|
|
|
type typeRow struct {
|
|
PostType string
|
|
Cnt int64
|
|
}
|
|
typeQ := s.db.Model(&model.Post{}).
|
|
Select("post_type, count(*) as cnt").
|
|
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished)
|
|
if scoped {
|
|
typeQ = typeQ.Where("board_id IN ?", boardIDs)
|
|
}
|
|
var types []typeRow
|
|
if err := typeQ.Group("post_type").Scan(&types).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, t := range types {
|
|
pt := model.NormalizePostType(t.PostType)
|
|
out.PostTypes = append(out.PostTypes, NamedCount{
|
|
Key: pt,
|
|
Label: pt,
|
|
Count: t.Cnt,
|
|
})
|
|
}
|
|
|
|
topQ := s.db.Select("id, title, view_count, board_id").
|
|
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished)
|
|
if scoped {
|
|
topQ = topQ.Where("board_id IN ?", boardIDs)
|
|
}
|
|
var tops []model.Post
|
|
if err := topQ.Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, p := range tops {
|
|
out.TopViewed = append(out.TopViewed, TopViewPost{
|
|
ID: p.ID, Title: p.Title, ViewCount: p.ViewCount, BoardID: p.BoardID,
|
|
})
|
|
}
|
|
|
|
return out, nil
|
|
}
|