300 lines
10 KiB
Go
300 lines
10 KiB
Go
package radio
|
|
|
|
import (
|
|
"sundynix-go/global"
|
|
"sundynix-go/model/radio/response"
|
|
"time"
|
|
)
|
|
|
|
type AnalyticsService struct{}
|
|
|
|
// parseDateRange 解析日期范围,未传则默认最近30天
|
|
func parseDateRange(startDate, endDate string) (time.Time, time.Time) {
|
|
now := time.Now()
|
|
layout := "2006-01-02"
|
|
|
|
end, err := time.ParseInLocation(layout, endDate, now.Location())
|
|
if err != nil {
|
|
end = now
|
|
}
|
|
// 结束日期取当天 23:59:59
|
|
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, end.Location())
|
|
|
|
start, err := time.ParseInLocation(layout, startDate, now.Location())
|
|
if err != nil {
|
|
start = end.AddDate(0, 0, -29) // 默认30天
|
|
}
|
|
// 开始日期取当天 00:00:00
|
|
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
|
|
|
return start, end
|
|
}
|
|
|
|
// GetListeningTrend 获取收听趋势 (使用持久化的 ListenLog)
|
|
func (s *AnalyticsService) GetListeningTrend(startDate, endDate, channelId string) (response.ListeningTrendResponse, error) {
|
|
var resp response.ListeningTrendResponse
|
|
start, end := parseDateRange(startDate, endDate)
|
|
|
|
db := global.DB.Table("sundynix_radio_listen_log")
|
|
if channelId != "" {
|
|
db = db.Where("channel_id = ?", channelId)
|
|
}
|
|
|
|
// 按天聚合收听次数 (即便用户删除了 history,日志依然存在)
|
|
err := db.Select("DATE(created_at) AS date, COUNT(*) AS count").
|
|
Where("created_at BETWEEN ? AND ?", start, end).
|
|
Where("deleted_at IS NULL").
|
|
Group("DATE(created_at)").
|
|
Order("date ASC").
|
|
Scan(&resp.Trend).Error
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
for _, p := range resp.Trend {
|
|
resp.TotalCount += p.Count
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// GetSubscriptionTrend 获取新增订阅趋势 (使用永久 Order 记录)
|
|
func (s *AnalyticsService) GetSubscriptionTrend(startDate, endDate, channelId string) (response.SubscriptionTrendResponse, error) {
|
|
var resp response.SubscriptionTrendResponse
|
|
start, end := parseDateRange(startDate, endDate)
|
|
|
|
// 订阅趋势 = 首次购买该频道成功的订单
|
|
// 通过子查询找到每个 (user_id, channel_id) 的最小成功订单日期
|
|
subQuery := global.DB.Table("sundynix_order").
|
|
Select("MIN(updated_at) as first_pay").
|
|
Where("type = 1 AND status = 1 AND deleted_at IS NULL").
|
|
Group("user_id, channel_id")
|
|
|
|
db := global.DB.Table("(?) as first_orders", subQuery).
|
|
Where("first_pay BETWEEN ? AND ?", start, end)
|
|
|
|
err := db.Select("DATE(first_pay) AS date, COUNT(*) AS count").
|
|
Group("DATE(first_pay)").
|
|
Order("date ASC").
|
|
Scan(&resp.Trend).Error
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
for _, p := range resp.Trend {
|
|
resp.TotalNewSubs += p.Count
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// GetRenewalTrend 获取续费趋势 (使用永久 Order 记录)
|
|
func (s *AnalyticsService) GetRenewalTrend(startDate, endDate, channelId string) (response.RenewalTrendResponse, error) {
|
|
var resp response.RenewalTrendResponse
|
|
start, end := parseDateRange(startDate, endDate)
|
|
|
|
// 续费 = 成功支付的订阅订单,且不是该用户对该频道的首笔订单
|
|
db := global.DB.Table("sundynix_order AS o").
|
|
Where("o.type = 1 AND o.status = 1 AND o.deleted_at IS NULL").
|
|
Where("o.updated_at BETWEEN ? AND ?", start, end).
|
|
Where("EXISTS (SELECT 1 FROM sundynix_order AS o2 WHERE o2.user_id = o.user_id AND o2.channel_id = o.channel_id AND o2.updated_at < o.updated_at AND o2.status = 1)")
|
|
|
|
if channelId != "" {
|
|
db = db.Where("o.channel_id = ?", channelId)
|
|
}
|
|
|
|
err := db.Select("DATE(o.updated_at) AS date, COUNT(*) AS count").
|
|
Group("DATE(o.updated_at)").
|
|
Order("date ASC").
|
|
Scan(&resp.Trend).Error
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
for _, p := range resp.Trend {
|
|
resp.TotalRenewals += p.Count
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// GetSubscriberStats 获取订阅用户统计 (混合实时 Subscription 与历史 Order)
|
|
func (s *AnalyticsService) GetSubscriberStats(startDate, endDate, channelId string) (response.SubscriberStatsResponse, error) {
|
|
var resp response.SubscriberStatsResponse
|
|
start, end := parseDateRange(startDate, endDate)
|
|
now := time.Now()
|
|
|
|
// 1. 当前有效订阅用户数 (实时表)
|
|
activeQuery := global.DB.Table("sundynix_radio_subscription").
|
|
Where("deleted_at IS NULL AND status = 1 AND expired_at > ?", now)
|
|
if channelId != "" {
|
|
activeQuery = activeQuery.Where("channel_id = ?", channelId)
|
|
}
|
|
activeQuery.Select("COUNT(DISTINCT user_id)").Scan(&resp.ActiveSubscribers)
|
|
|
|
// 2. 累积总订阅人数 (从历史 Order 表统计全量真实去重用户)
|
|
totalUserQuery := global.DB.Table("sundynix_order").
|
|
Where("type = 1 AND status = 1 AND deleted_at IS NULL")
|
|
if channelId != "" {
|
|
totalUserQuery = totalUserQuery.Where("channel_id = ?", channelId)
|
|
}
|
|
totalUserQuery.Select("COUNT(DISTINCT user_id)").Scan(&resp.TotalSubscribers)
|
|
|
|
// 3. 已流失/过期用户 = 历史总计 - 当前有效
|
|
resp.ExpiredSubscribers = resp.TotalSubscribers - resp.ActiveSubscribers
|
|
if resp.ExpiredSubscribers < 0 {
|
|
resp.ExpiredSubscribers = 0
|
|
}
|
|
|
|
// 4. 每日新增转化用户趋势 (从 Order 表提取)
|
|
trendQuery := global.DB.Table("sundynix_order").
|
|
Where("type = 1 AND status = 1 AND updated_at BETWEEN ? AND ? AND deleted_at IS NULL", start, end)
|
|
if channelId != "" {
|
|
trendQuery = trendQuery.Where("channel_id = ?", channelId)
|
|
}
|
|
|
|
err := trendQuery.Select("DATE(updated_at) AS date, COUNT(DISTINCT user_id) AS count").
|
|
Group("DATE(updated_at)").
|
|
Order("date ASC").
|
|
Scan(&resp.ActiveTrend).Error
|
|
|
|
return resp, err
|
|
}
|
|
|
|
// GetContentQuality 内容质量分析:完播率
|
|
func (s *AnalyticsService) GetContentQuality(channelId string) ([]response.CompletionRateResponse, error) {
|
|
var results []response.CompletionRateResponse
|
|
|
|
// 使用更具韧性的 SQL 计算完播率:
|
|
// 1. 优先使用 program 表中的 duration (得益于“贪婪学习”,它会越来越准)
|
|
// 2. 如果 program.duration 为 0,则动态使用该节目在日志中的 MAX(progress) 作为推定时长
|
|
// 3. 过滤掉完全没有任何播放深度记录的异常数据
|
|
|
|
baseQuery := global.DB.Table("sundynix_radio_program AS p").
|
|
Select("p.id as program_id, p.title, " +
|
|
"AVG(CAST(h.progress AS DECIMAL) / " +
|
|
"NULLIF(COALESCE(NULLIF(p.duration, 0), (SELECT MAX(progress) FROM sundynix_radio_listen_log WHERE program_id = p.id)), 0)) as avg_completion, " +
|
|
"COUNT(DISTINCT h.user_id) as play_count").
|
|
Joins("INNER JOIN sundynix_radio_history AS h ON h.program_id = p.id")
|
|
|
|
if channelId != "" {
|
|
baseQuery = baseQuery.Where("p.channel_id = ?", channelId)
|
|
}
|
|
|
|
err := baseQuery.Group("p.id, p.title").
|
|
Having("avg_completion >= 0").
|
|
Order("avg_completion DESC").
|
|
Limit(20).
|
|
Scan(&results).Error
|
|
|
|
return results, err
|
|
}
|
|
|
|
// GetUserStickiness 用户黏性分析:留存分析 (Cohort Analysis)
|
|
func (s *AnalyticsService) GetUserStickiness(startDate, endDate string) ([]response.RetentionResponse, error) {
|
|
var list []response.RetentionResponse
|
|
start, end := parseDateRange(startDate, endDate)
|
|
|
|
// 获取时间范围内的每日新增活跃用户
|
|
var dailyNewUsers []struct {
|
|
Date string
|
|
Count int64
|
|
}
|
|
global.DB.Table("sundynix_radio_listen_log").
|
|
Select("DATE(created_at) as date, COUNT(DISTINCT user_id) as count").
|
|
Where("created_at BETWEEN ? AND ?", start, end).
|
|
Group("DATE(created_at)").
|
|
Scan(&dailyNewUsers)
|
|
|
|
for _, day := range dailyNewUsers {
|
|
dayTime, _ := time.Parse("2006-01-02", day.Date)
|
|
res := response.RetentionResponse{
|
|
Date: day.Date,
|
|
NewUsers: day.Count,
|
|
}
|
|
|
|
// 计算 1, 3, 7, 30 天后的留存率
|
|
intervals := []int{1, 3, 7, 30}
|
|
for _, dayDelta := range intervals {
|
|
checkDayStart := dayTime.AddDate(0, 0, dayDelta)
|
|
checkDayEnd := checkDayStart.AddDate(0, 0, 1)
|
|
|
|
var retainedCount int64
|
|
// 统计在 day.Date 活跃过的用户中,有多少在 checkDay 再次出现了
|
|
global.DB.Table("sundynix_radio_listen_log").
|
|
Where("user_id IN (SELECT DISTINCT user_id FROM sundynix_radio_listen_log WHERE DATE(created_at) = ?)", day.Date).
|
|
Where("created_at BETWEEN ? AND ?", checkDayStart, checkDayEnd).
|
|
Distinct("user_id").
|
|
Count(&retainedCount)
|
|
|
|
rate := 0.0
|
|
if day.Count > 0 {
|
|
rate = float64(retainedCount) / float64(day.Count)
|
|
}
|
|
res.Retention = append(res.Retention, rate)
|
|
}
|
|
list = append(list, res)
|
|
}
|
|
|
|
return list, nil
|
|
}
|
|
|
|
// GetBusinessConversion 商业转化分析:漏斗与 LTV
|
|
func (s *AnalyticsService) GetBusinessConversion(startDate, endDate string) (response.FunnelResponse, error) {
|
|
var resp response.FunnelResponse
|
|
start, end := parseDateRange(startDate, endDate)
|
|
|
|
// 1. 活跃收听用户数 (Top of Funnel)
|
|
global.DB.Table("sundynix_radio_listen_log").
|
|
Where("created_at BETWEEN ? AND ?", start, end).
|
|
Distinct("user_id").Count(&resp.ListenUsers)
|
|
|
|
// 2. 尝试下单用户数 (Middle of Funnel) - 只要创建过订单就算
|
|
global.DB.Table("sundynix_order").
|
|
Where("created_at BETWEEN ? AND ?", start, end).
|
|
Distinct("user_id").Count(&resp.OrderUsers)
|
|
|
|
// 3. 支付成功用户数 (Bottom of Funnel)
|
|
global.DB.Table("sundynix_order").
|
|
Where("updated_at BETWEEN ? AND ? AND status = 1", start, end).
|
|
Distinct("user_id").Count(&resp.PayUsers)
|
|
|
|
// 4. LTV 计算 (活跃期内总营收 / 总活跃用户数)
|
|
var totalRevenue int64
|
|
global.DB.Table("sundynix_order").
|
|
Where("updated_at BETWEEN ? AND ? AND status = 1", start, end).
|
|
Select("SUM(amount)").Scan(&totalRevenue)
|
|
|
|
if resp.ListenUsers > 0 {
|
|
resp.LTV = float64(totalRevenue) / float64(resp.ListenUsers)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// GetPreferenceAnalysis 品类偏好分析
|
|
func (s *AnalyticsService) GetPreferenceAnalysis() (response.PreferenceAnalysisResponse, error) {
|
|
var resp response.PreferenceAnalysisResponse
|
|
var totalRevenue int64
|
|
|
|
// 获取总营收用于计算占比
|
|
global.DB.Table("sundynix_order").Where("status = 1").Select("SUM(amount)").Scan(&totalRevenue)
|
|
|
|
// 按分类聚合播放量与营收
|
|
err := global.DB.Table("sundynix_radio_category AS cat").
|
|
Select("cat.id as category_id, cat.name as category_name, " +
|
|
"COUNT(DISTINCT l.id) as listen_count, " +
|
|
"COALESCE(SUM(DISTINCT o.amount), 0) as revenue").
|
|
Joins("LEFT JOIN sundynix_radio_channel AS ch ON ch.category_id = cat.id").
|
|
Joins("LEFT JOIN sundynix_radio_listen_log AS l ON l.channel_id = ch.id").
|
|
Joins("LEFT JOIN sundynix_order AS o ON o.channel_id = ch.id AND o.status = 1").
|
|
Group("cat.id, cat.name").
|
|
Order("revenue DESC").
|
|
Scan(&resp.List).Error
|
|
|
|
if totalRevenue > 0 {
|
|
for i := range resp.List {
|
|
resp.List[i].Share = float64(resp.List[i].Revenue) / float64(totalRevenue)
|
|
}
|
|
}
|
|
|
|
return resp, err
|
|
}
|