67b97e4b38
看板(Recharts): - 概览页加 DAU/WAU/MAU、今日/本月新增 KPI,新增趋势/活跃趋势/月活/留存曲线 - GET /admin/analytics 内存计算,活跃口径=当天有记录/发帖/评论,排除 bot 内容安全(微信官方 UGC): - 文本 msg_sec_check 同步判、图片 media_check_async 异步查 - 帖子/评论加 pending/rejected 审核态,feed 只放 published - 图片结果回调 /api/wx/sec-callback,JSON/XML + 明文模式,签名校验 - 检测不了/未发布时一律转待审核,走后台手动审核 - 后台帖子/评论审核页:修好看不到图(补 attachPostImages)、 加状态筛选 + 通过/打回;新增评论审核状态接口 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
5.9 KiB
Go
200 lines
5.9 KiB
Go
package service
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/sundynix/pets-be/internal/model"
|
|
)
|
|
|
|
// 后台看板的分析数据。真实用户口径(排除 bot),活跃口径=当天产生过
|
|
// 健康记录/发帖/评论中的任意一种。数据量在预生产规模下很小,直接拉进内存算,
|
|
// 省掉一堆按天分组的 SQL。
|
|
|
|
// DayPoint 折线/柱状的一个点。Date 视粒度是 MM-DD 或 YYYY-MM
|
|
type DayPoint struct {
|
|
Date string `json:"date"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// RetPoint 留存曲线一个点:注册后第 Day 天仍活跃的比例
|
|
type RetPoint struct {
|
|
Day int `json:"day"` // 注册后天数
|
|
Rate float64 `json:"rate"` // 0~1
|
|
Base int `json:"base"` // 该口径下够“年龄”的用户数(分母)
|
|
}
|
|
|
|
type Analytics struct {
|
|
Summary struct {
|
|
TotalUsers int `json:"total_users"` // 真实用户总数
|
|
NewToday int `json:"new_today"`
|
|
NewMonth int `json:"new_month"`
|
|
DAU int `json:"dau"` // 今日活跃
|
|
WAU int `json:"wau"` // 近 7 日活跃
|
|
MAU int `json:"mau"` // 近 30 日活跃
|
|
TotalPets int `json:"total_pets"`
|
|
TotalPosts int `json:"total_posts"`
|
|
TotalRecords int `json:"total_records"`
|
|
} `json:"summary"`
|
|
NewTrend []DayPoint `json:"new_trend"` // 每日新增,近 days 天
|
|
ActiveTrend []DayPoint `json:"active_trend"` // 每日活跃(DAU),近 days 天
|
|
MauTrend []DayPoint `json:"mau_trend"` // 月活,近 6 个月
|
|
Retention []RetPoint `json:"retention"` // 留存曲线
|
|
}
|
|
|
|
// dayKey 把时间压成 yyyymmdd 整数,单调,便于比较和当 map key
|
|
func dayKey(t time.Time) int {
|
|
return t.Year()*10000 + int(t.Month())*100 + t.Day()
|
|
}
|
|
|
|
// AdminAnalytics days=趋势天数(默认 30,封顶 180)
|
|
func (s *Service) AdminAnalytics(days int) (*Analytics, error) {
|
|
if days <= 0 || days > 180 {
|
|
days = 30
|
|
}
|
|
loc := time.Local
|
|
now := time.Now().In(loc)
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
|
|
|
out := &Analytics{}
|
|
|
|
// —— 真实用户(排除 bot)——
|
|
type uRow struct {
|
|
ID string
|
|
CreatedAt time.Time
|
|
}
|
|
var users []uRow
|
|
s.db.Model(&model.User{}).
|
|
Where("is_bot IS NULL OR is_bot = 0").
|
|
Select("id, created_at").Scan(&users)
|
|
|
|
signup := make(map[string]int, len(users)) // userID -> 注册日 dayKey
|
|
signupTime := make(map[string]time.Time, len(users))
|
|
ids := make([]string, 0, len(users))
|
|
todayKey := dayKey(today)
|
|
monthStart := dayKey(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc))
|
|
for _, u := range users {
|
|
t := u.CreatedAt.In(loc)
|
|
signup[u.ID] = dayKey(t)
|
|
signupTime[u.ID] = t
|
|
ids = append(ids, u.ID)
|
|
k := dayKey(t)
|
|
if k == todayKey {
|
|
out.Summary.NewToday++
|
|
}
|
|
if k >= monthStart {
|
|
out.Summary.NewMonth++
|
|
}
|
|
}
|
|
out.Summary.TotalUsers = len(users)
|
|
|
|
// —— 活跃事件:近 180 天,真实用户的 记录/帖子/评论 ——
|
|
windowStart := today.AddDate(0, 0, -180)
|
|
type ev struct {
|
|
UserID string
|
|
CreatedAt time.Time
|
|
}
|
|
// userID -> 活跃日集合;以及每日活跃用户集合(供 DAU/MAU 用)
|
|
userActive := make(map[string]map[int]bool)
|
|
dayUsers := make(map[int]map[string]bool)
|
|
mark := func(table string) {
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
var rows []ev
|
|
s.db.Table(table).Select("user_id, created_at").
|
|
Where("created_at >= ?", windowStart).
|
|
Where("user_id IN ?", ids).Scan(&rows)
|
|
for _, r := range rows {
|
|
k := dayKey(r.CreatedAt.In(loc))
|
|
if userActive[r.UserID] == nil {
|
|
userActive[r.UserID] = map[int]bool{}
|
|
}
|
|
userActive[r.UserID][k] = true
|
|
if dayUsers[k] == nil {
|
|
dayUsers[k] = map[string]bool{}
|
|
}
|
|
dayUsers[k][r.UserID] = true
|
|
}
|
|
}
|
|
mark("sundynix_health_records")
|
|
mark("sundynix_posts")
|
|
mark("sundynix_comments")
|
|
|
|
// distinctInRange 统计 [fromDay, toDay] 内的去重活跃用户
|
|
distinctInRange := func(from, to time.Time) int {
|
|
seen := map[string]bool{}
|
|
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
|
for u := range dayUsers[dayKey(d)] {
|
|
seen[u] = true
|
|
}
|
|
}
|
|
return len(seen)
|
|
}
|
|
out.Summary.DAU = len(dayUsers[todayKey])
|
|
out.Summary.WAU = distinctInRange(today.AddDate(0, 0, -6), today)
|
|
out.Summary.MAU = distinctInRange(today.AddDate(0, 0, -29), today)
|
|
|
|
// —— 趋势:新增 + DAU,近 days 天 ——
|
|
for i := days - 1; i >= 0; i-- {
|
|
d := today.AddDate(0, 0, -i)
|
|
k := dayKey(d)
|
|
label := d.Format("01-02")
|
|
newN := 0
|
|
for _, sk := range signup {
|
|
if sk == k {
|
|
newN++
|
|
}
|
|
}
|
|
out.NewTrend = append(out.NewTrend, DayPoint{Date: label, Count: newN})
|
|
out.ActiveTrend = append(out.ActiveTrend, DayPoint{Date: label, Count: len(dayUsers[k])})
|
|
}
|
|
|
|
// —— 月活:近 6 个月 ——
|
|
for i := 5; i >= 0; i-- {
|
|
mStart := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -i, 0)
|
|
mEnd := mStart.AddDate(0, 1, -1)
|
|
out.MauTrend = append(out.MauTrend, DayPoint{
|
|
Date: mStart.Format("2006-01"),
|
|
Count: distinctInRange(mStart, mEnd),
|
|
})
|
|
}
|
|
|
|
// —— 留存曲线:注册后第 K 天仍活跃(滚动口径:K 天当天或之后还有活跃)——
|
|
// 分母只算“年龄”够 K 天、且在观测窗内的用户
|
|
offsets := []int{1, 3, 7, 14, 30}
|
|
minSignup := dayKey(windowStart)
|
|
for _, K := range offsets {
|
|
base, retained := 0, 0
|
|
for id, sk := range signup {
|
|
if sk < minSignup {
|
|
continue // 太老,活跃窗看不全
|
|
}
|
|
plusK := dayKey(signupTime[id].AddDate(0, 0, K))
|
|
if plusK > todayKey {
|
|
continue // 还没到第 K 天,不够“年龄”
|
|
}
|
|
base++
|
|
for ak := range userActive[id] {
|
|
if ak >= plusK {
|
|
retained++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
rate := 0.0
|
|
if base > 0 {
|
|
rate = float64(retained) / float64(base)
|
|
}
|
|
out.Retention = append(out.Retention, RetPoint{Day: K, Rate: rate, Base: base})
|
|
}
|
|
|
|
var pets, posts, records int64
|
|
s.db.Model(&model.Pet{}).Count(&pets)
|
|
s.db.Model(&model.Post{}).Count(&posts)
|
|
s.db.Model(&model.HealthRecord{}).Count(&records)
|
|
out.Summary.TotalPets = int(pets)
|
|
out.Summary.TotalPosts = int(posts)
|
|
out.Summary.TotalRecords = int(records)
|
|
return out, nil
|
|
}
|