feat: 数据分析
This commit is contained in:
@@ -0,0 +1,178 @@
|
|||||||
|
package radio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sundynix-go/global"
|
||||||
|
"sundynix-go/model/commom/response"
|
||||||
|
"sundynix-go/model/radio/request"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnalyticsApi struct{}
|
||||||
|
|
||||||
|
// GetListeningTrend 获取收听趋势
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取收听趋势(折线图)
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param startDate query string false "开始日期 2026-01-01"
|
||||||
|
// @Param endDate query string false "结束日期 2026-03-10"
|
||||||
|
// @Param channelId query string false "频道ID(可选筛选)"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/listening-trend [get]
|
||||||
|
func (a *AnalyticsApi) GetListeningTrend(c *gin.Context) {
|
||||||
|
var req request.TrendQuery
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := analyticsService.GetListeningTrend(req.StartDate, req.EndDate, req.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取收听趋势失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubscriptionTrend 获取订阅趋势
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取订阅趋势(折线图)
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param startDate query string false "开始日期"
|
||||||
|
// @Param endDate query string false "结束日期"
|
||||||
|
// @Param channelId query string false "频道ID"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/subscription-trend [get]
|
||||||
|
func (a *AnalyticsApi) GetSubscriptionTrend(c *gin.Context) {
|
||||||
|
var req request.TrendQuery
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := analyticsService.GetSubscriptionTrend(req.StartDate, req.EndDate, req.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取订阅趋势失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRenewalTrend 获取续费趋势
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取续费趋势(折线图)
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param startDate query string false "开始日期"
|
||||||
|
// @Param endDate query string false "结束日期"
|
||||||
|
// @Param channelId query string false "频道ID"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/renewal-trend [get]
|
||||||
|
func (a *AnalyticsApi) GetRenewalTrend(c *gin.Context) {
|
||||||
|
var req request.TrendQuery
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := analyticsService.GetRenewalTrend(req.StartDate, req.EndDate, req.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取续费趋势失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubscriberStats 获取订阅用户统计
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取订阅用户统计(折线图 + 概览)
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param startDate query string false "开始日期"
|
||||||
|
// @Param endDate query string false "结束日期"
|
||||||
|
// @Param channelId query string false "频道ID"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/subscriber-stats [get]
|
||||||
|
func (a *AnalyticsApi) GetSubscriberStats(c *gin.Context) {
|
||||||
|
var req request.TrendQuery
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := analyticsService.GetSubscriberStats(req.StartDate, req.EndDate, req.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取订阅用户统计失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContentQuality 获取内容质量分析
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取内容质量分析(完播率等)
|
||||||
|
// @Param channelId query string false "频道ID"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/content-quality [get]
|
||||||
|
func (a *AnalyticsApi) GetContentQuality(c *gin.Context) {
|
||||||
|
channelId := c.Query("channelId")
|
||||||
|
data, err := analyticsService.GetContentQuality(channelId)
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取内容质量分析失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserStickiness 获取用户留存分析
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取用户留存分析 (Cohort)
|
||||||
|
// @Param startDate query string false "开始日期"
|
||||||
|
// @Param endDate query string false "结束日期"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/user-stickiness [get]
|
||||||
|
func (a *AnalyticsApi) GetUserStickiness(c *gin.Context) {
|
||||||
|
var req request.TrendQuery
|
||||||
|
c.ShouldBindQuery(&req)
|
||||||
|
data, err := analyticsService.GetUserStickiness(req.StartDate, req.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取用户留存分析失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBusinessConversion 获取商业转化分析
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取商业转化分析 (Funnel & LTV)
|
||||||
|
// @Param startDate query string false "开始日期"
|
||||||
|
// @Param endDate query string false "结束日期"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/business-conversion [get]
|
||||||
|
func (a *AnalyticsApi) GetBusinessConversion(c *gin.Context) {
|
||||||
|
var req request.TrendQuery
|
||||||
|
c.ShouldBindQuery(&req)
|
||||||
|
data, err := analyticsService.GetBusinessConversion(req.StartDate, req.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取商业转化分析失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPreferenceAnalysis 获取品类偏好分析
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取品类偏好分析
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/preference [get]
|
||||||
|
func (a *AnalyticsApi) GetPreferenceAnalysis(c *gin.Context) {
|
||||||
|
data, err := analyticsService.GetPreferenceAnalysis()
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取品类偏好分析失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type ApiGroup struct {
|
|||||||
InteractionApi
|
InteractionApi
|
||||||
PayApi
|
PayApi
|
||||||
VipApi
|
VipApi
|
||||||
|
AnalyticsApi
|
||||||
}
|
}
|
||||||
|
|
||||||
var ApiGroupApp = new(ApiGroup)
|
var ApiGroupApp = new(ApiGroup)
|
||||||
@@ -22,4 +23,5 @@ var (
|
|||||||
interactionService = service.GroupApp.RadioServiceGroup.InteractionService
|
interactionService = service.GroupApp.RadioServiceGroup.InteractionService
|
||||||
payService = service.GroupApp.RadioServiceGroup.PayService
|
payService = service.GroupApp.RadioServiceGroup.PayService
|
||||||
vipService = service.GroupApp.RadioServiceGroup.VipService
|
vipService = service.GroupApp.RadioServiceGroup.VipService
|
||||||
|
analyticsService = service.GroupApp.RadioServiceGroup.AnalyticsService
|
||||||
)
|
)
|
||||||
|
|||||||
+256
@@ -1487,6 +1487,262 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/radio/analytics/business-conversion": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取商业转化分析 (Funnel \u0026 LTV)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/content-quality": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取内容质量分析(完播率等)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/listening-trend": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取收听趋势(折线图)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期 2026-01-01",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期 2026-03-10",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID(可选筛选)",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/preference": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取品类偏好分析",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/renewal-trend": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取续费趋势(折线图)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/subscriber-stats": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取订阅用户统计(折线图 + 概览)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/subscription-trend": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取订阅趋势(折线图)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/user-stickiness": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取用户留存分析 (Cohort)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/radio/category/delete": {
|
"/radio/category/delete": {
|
||||||
"post": {
|
"post": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
|||||||
@@ -1480,6 +1480,262 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/radio/analytics/business-conversion": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取商业转化分析 (Funnel \u0026 LTV)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/content-quality": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取内容质量分析(完播率等)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/listening-trend": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取收听趋势(折线图)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期 2026-01-01",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期 2026-03-10",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID(可选筛选)",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/preference": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取品类偏好分析",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/renewal-trend": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取续费趋势(折线图)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/subscriber-stats": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取订阅用户统计(折线图 + 概览)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/subscription-trend": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取订阅趋势(折线图)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "频道ID",
|
||||||
|
"name": "channelId",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/radio/analytics/user-stickiness": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"数据分析"
|
||||||
|
],
|
||||||
|
"summary": "获取用户留存分析 (Cohort)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "开始日期",
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "结束日期",
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/radio/category/delete": {
|
"/radio/category/delete": {
|
||||||
"post": {
|
"post": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
|||||||
@@ -1575,6 +1575,169 @@ paths:
|
|||||||
summary: 支付
|
summary: 支付
|
||||||
tags:
|
tags:
|
||||||
- 微信支付
|
- 微信支付
|
||||||
|
/radio/analytics/business-conversion:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 开始日期
|
||||||
|
in: query
|
||||||
|
name: startDate
|
||||||
|
type: string
|
||||||
|
- description: 结束日期
|
||||||
|
in: query
|
||||||
|
name: endDate
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Response'
|
||||||
|
summary: 获取商业转化分析 (Funnel & LTV)
|
||||||
|
tags:
|
||||||
|
- 数据分析
|
||||||
|
/radio/analytics/content-quality:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 频道ID
|
||||||
|
in: query
|
||||||
|
name: channelId
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Response'
|
||||||
|
summary: 获取内容质量分析(完播率等)
|
||||||
|
tags:
|
||||||
|
- 数据分析
|
||||||
|
/radio/analytics/listening-trend:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 开始日期 2026-01-01
|
||||||
|
in: query
|
||||||
|
name: startDate
|
||||||
|
type: string
|
||||||
|
- description: 结束日期 2026-03-10
|
||||||
|
in: query
|
||||||
|
name: endDate
|
||||||
|
type: string
|
||||||
|
- description: 频道ID(可选筛选)
|
||||||
|
in: query
|
||||||
|
name: channelId
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Response'
|
||||||
|
summary: 获取收听趋势(折线图)
|
||||||
|
tags:
|
||||||
|
- 数据分析
|
||||||
|
/radio/analytics/preference:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Response'
|
||||||
|
summary: 获取品类偏好分析
|
||||||
|
tags:
|
||||||
|
- 数据分析
|
||||||
|
/radio/analytics/renewal-trend:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 开始日期
|
||||||
|
in: query
|
||||||
|
name: startDate
|
||||||
|
type: string
|
||||||
|
- description: 结束日期
|
||||||
|
in: query
|
||||||
|
name: endDate
|
||||||
|
type: string
|
||||||
|
- description: 频道ID
|
||||||
|
in: query
|
||||||
|
name: channelId
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Response'
|
||||||
|
summary: 获取续费趋势(折线图)
|
||||||
|
tags:
|
||||||
|
- 数据分析
|
||||||
|
/radio/analytics/subscriber-stats:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 开始日期
|
||||||
|
in: query
|
||||||
|
name: startDate
|
||||||
|
type: string
|
||||||
|
- description: 结束日期
|
||||||
|
in: query
|
||||||
|
name: endDate
|
||||||
|
type: string
|
||||||
|
- description: 频道ID
|
||||||
|
in: query
|
||||||
|
name: channelId
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Response'
|
||||||
|
summary: 获取订阅用户统计(折线图 + 概览)
|
||||||
|
tags:
|
||||||
|
- 数据分析
|
||||||
|
/radio/analytics/subscription-trend:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 开始日期
|
||||||
|
in: query
|
||||||
|
name: startDate
|
||||||
|
type: string
|
||||||
|
- description: 结束日期
|
||||||
|
in: query
|
||||||
|
name: endDate
|
||||||
|
type: string
|
||||||
|
- description: 频道ID
|
||||||
|
in: query
|
||||||
|
name: channelId
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Response'
|
||||||
|
summary: 获取订阅趋势(折线图)
|
||||||
|
tags:
|
||||||
|
- 数据分析
|
||||||
|
/radio/analytics/user-stickiness:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 开始日期
|
||||||
|
in: query
|
||||||
|
name: startDate
|
||||||
|
type: string
|
||||||
|
- description: 结束日期
|
||||||
|
in: query
|
||||||
|
name: endDate
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Response'
|
||||||
|
summary: 获取用户留存分析 (Cohort)
|
||||||
|
tags:
|
||||||
|
- 数据分析
|
||||||
/radio/category/delete:
|
/radio/category/delete:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func MigrateTable() {
|
|||||||
radio.RadioHistory{},
|
radio.RadioHistory{},
|
||||||
radio.RadioFavorite{},
|
radio.RadioFavorite{},
|
||||||
radio.RadioLike{},
|
radio.RadioLike{},
|
||||||
|
radio.RadioListenLog{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
global.Logger.Error("Migrate table failed,err:", zap.Error(err))
|
global.Logger.Error("Migrate table failed,err:", zap.Error(err))
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func Routers() {
|
|||||||
radioRouter.InitSubscriptionRouter(NeedAuthGroup) //订阅相关
|
radioRouter.InitSubscriptionRouter(NeedAuthGroup) //订阅相关
|
||||||
radioRouter.InitPayRouter(NeedAuthGroup, PublicGroup) //支付和回调
|
radioRouter.InitPayRouter(NeedAuthGroup, PublicGroup) //支付和回调
|
||||||
radioRouter.InitInteractionRouter(NeedAuthGroup) //用户互动相关
|
radioRouter.InitInteractionRouter(NeedAuthGroup) //用户互动相关
|
||||||
|
radioRouter.InitAnalyticsRouter(NeedAuthGroup) //数据分析相关
|
||||||
}
|
}
|
||||||
|
|
||||||
address := fmt.Sprintf(":%d", global.Config.System.Addr)
|
address := fmt.Sprintf(":%d", global.Config.System.Addr)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package radio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sundynix-go/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RadioListenLog 播放日志表(用于统计收听趋势,不可删除,不作为书签)
|
||||||
|
type RadioListenLog struct {
|
||||||
|
global.BaseModel
|
||||||
|
UserId string `gorm:"size:50;index" json:"userId"` // 用户ID
|
||||||
|
ProgramId string `gorm:"size:50;index" json:"programId"` // 节目ID
|
||||||
|
ChannelId string `gorm:"size:50;index" json:"channelId"` // 频道ID(冗余,方便按频道统计)
|
||||||
|
Progress int `gorm:"default:0" json:"progress"` // 当前播放进度
|
||||||
|
Duration int `gorm:"default:0" json:"duration"` // 节目总时长 (由播放器实时反馈,用于修复元数据)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RadioListenLog) TableName() string {
|
||||||
|
return "sundynix_radio_listen_log"
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ type PayNotify struct {
|
|||||||
BankType string `json:"bank_type" gorm:"column:bank_type"`
|
BankType string `json:"bank_type" gorm:"column:bank_type"`
|
||||||
MchId string `json:"mchId" gorm:"column:mch_id"`
|
MchId string `json:"mchId" gorm:"column:mch_id"`
|
||||||
OutTradeNo string `json:"out_trade_no" gorm:"column:out_trade_no"`
|
OutTradeNo string `json:"out_trade_no" gorm:"column:out_trade_no"`
|
||||||
Payer string `json:"payer" gorm:"column:out_trade_no"`
|
Payer string `json:"payer" gorm:"column:payer"`
|
||||||
SuccessTime string `json:"success_time" gorm:"column:success_time"`
|
SuccessTime string `json:"success_time" gorm:"column:success_time"`
|
||||||
TradeState string `json:"trade_state" gorm:"column:trade_state"`
|
TradeState string `json:"trade_state" gorm:"column:trade_state"`
|
||||||
TradeStateDesc string `json:"trade_state_desc" gorm:"column:trade_state_desc"`
|
TradeStateDesc string `json:"trade_state_desc" gorm:"column:trade_state_desc"`
|
||||||
|
|||||||
@@ -75,3 +75,10 @@ type GetCommentList struct {
|
|||||||
type GetSubscriptionList struct {
|
type GetSubscriptionList struct {
|
||||||
common.PageInfo
|
common.PageInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrendQuery 趋势查询请求(复用于所有趋势分析接口)
|
||||||
|
type TrendQuery struct {
|
||||||
|
StartDate string `json:"startDate" form:"startDate"` // 开始日期 "2026-01-01"
|
||||||
|
EndDate string `json:"endDate" form:"endDate"` // 结束日期 "2026-03-10"
|
||||||
|
ChannelId string `json:"channelId" form:"channelId"` // 可选,按频道筛选
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
// TrendPoint 折线图通用数据点(日期 + 数量)
|
||||||
|
type TrendPoint struct {
|
||||||
|
Date string `json:"date"` // 日期 "2026-03-01"
|
||||||
|
Count int64 `json:"count"` // 数量
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListeningTrendResponse 收听趋势响应
|
||||||
|
type ListeningTrendResponse struct {
|
||||||
|
Trend []TrendPoint `json:"trend"` // 每日收听人次趋势
|
||||||
|
TotalCount int64 `json:"totalCount"` // 期间总收听次数
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionTrendResponse 订阅趋势响应
|
||||||
|
type SubscriptionTrendResponse struct {
|
||||||
|
Trend []TrendPoint `json:"trend"` // 每日新增订阅数趋势
|
||||||
|
TotalNewSubs int64 `json:"totalNewSubs"` // 期间新增订阅总数
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewalTrendResponse 续费趋势响应
|
||||||
|
type RenewalTrendResponse struct {
|
||||||
|
Trend []TrendPoint `json:"trend"` // 每日续费订单数趋势
|
||||||
|
TotalRenewals int64 `json:"totalRenewals"` // 期间续费总数
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriberStatsResponse 订阅用户统计响应
|
||||||
|
type SubscriberStatsResponse struct {
|
||||||
|
ActiveSubscribers int64 `json:"activeSubscribers"` // 当前有效订阅用户数
|
||||||
|
ExpiredSubscribers int64 `json:"expiredSubscribers"` // 已过期订阅用户数
|
||||||
|
TotalSubscribers int64 `json:"totalSubscribers"` // 历史总订阅用户数(去重)
|
||||||
|
ActiveTrend []TrendPoint `json:"activeTrend"` // 每日有效订阅用户数趋势
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletionRateResponse 内容质量:完播率
|
||||||
|
type CompletionRateResponse struct {
|
||||||
|
ProgramId string `json:"programId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
AvgCompletion float64 `json:"avgCompletion"` // 0.0 - 1.0 平均完播进度
|
||||||
|
PlayCount int64 `json:"playCount"` // 总播放样本数
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionResponse 用户黏性:留存分析
|
||||||
|
type RetentionResponse struct {
|
||||||
|
Date string `json:"date"` // 初始日期
|
||||||
|
NewUsers int64 `json:"newUsers"` // 该日新增活跃用户数
|
||||||
|
Retention []float64 `json:"retention"` // [次日留存, 3日留存, 7日留存, 30日留存]
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunnelResponse 商业转化:漏斗分析
|
||||||
|
type FunnelResponse struct {
|
||||||
|
ListenUsers int64 `json:"listenUsers"` // 活跃收听用户数
|
||||||
|
OrderUsers int64 `json:"orderUsers"` // 尝试下单用户数
|
||||||
|
PayUsers int64 `json:"payUsers"` // 支付成功用户数
|
||||||
|
LTV float64 `json:"ltv"` // 人均生命周期价值 (Revenue / ListenUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryContribution 偏好探测:品类贡献点
|
||||||
|
type CategoryContribution struct {
|
||||||
|
CategoryId string `json:"categoryId"`
|
||||||
|
CategoryName string `json:"categoryName"`
|
||||||
|
ListenCount int64 `json:"listenCount"` // 播放量
|
||||||
|
Revenue int64 `json:"revenue"` // 营收(分)
|
||||||
|
Share float64 `json:"share"` // 营收占比 (0.0 - 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreferenceAnalysisResponse 品类偏好分析响应
|
||||||
|
type PreferenceAnalysisResponse struct {
|
||||||
|
List []CategoryContribution `json:"list"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package radio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnalyticsRouter struct{}
|
||||||
|
|
||||||
|
func (r *AnalyticsRouter) InitAnalyticsRouter(Router *gin.RouterGroup) {
|
||||||
|
analyticsRouter := Router.Group("radio/analytics")
|
||||||
|
{
|
||||||
|
analyticsRouter.GET("listening-trend", analyticsApi.GetListeningTrend)
|
||||||
|
analyticsRouter.GET("subscription-trend", analyticsApi.GetSubscriptionTrend)
|
||||||
|
analyticsRouter.GET("renewal-trend", analyticsApi.GetRenewalTrend)
|
||||||
|
analyticsRouter.GET("subscriber-stats", analyticsApi.GetSubscriberStats)
|
||||||
|
analyticsRouter.GET("content-quality", analyticsApi.GetContentQuality)
|
||||||
|
analyticsRouter.GET("user-stickiness", analyticsApi.GetUserStickiness)
|
||||||
|
analyticsRouter.GET("business-conversion", analyticsApi.GetBusinessConversion)
|
||||||
|
analyticsRouter.GET("preference", analyticsApi.GetPreferenceAnalysis)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type RadioRouterGroup struct {
|
|||||||
InteractionRouter
|
InteractionRouter
|
||||||
PayRouter
|
PayRouter
|
||||||
VipRouter
|
VipRouter
|
||||||
|
AnalyticsRouter
|
||||||
}
|
}
|
||||||
|
|
||||||
var GroupApp = new(RadioRouterGroup)
|
var GroupApp = new(RadioRouterGroup)
|
||||||
@@ -22,4 +23,5 @@ var (
|
|||||||
interactionApi = v1.ApiGroupApp.RadioApiGroup.InteractionApi
|
interactionApi = v1.ApiGroupApp.RadioApiGroup.InteractionApi
|
||||||
payApi = v1.ApiGroupApp.RadioApiGroup.PayApi
|
payApi = v1.ApiGroupApp.RadioApiGroup.PayApi
|
||||||
vipApi = v1.ApiGroupApp.RadioApiGroup.VipApi
|
vipApi = v1.ApiGroupApp.RadioApiGroup.VipApi
|
||||||
|
analyticsApi = v1.ApiGroupApp.RadioApiGroup.AnalyticsApi
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type ServiceGroup struct {
|
|||||||
OrderService
|
OrderService
|
||||||
VipService
|
VipService
|
||||||
TTSService
|
TTSService
|
||||||
|
AnalyticsService
|
||||||
}
|
}
|
||||||
|
|
||||||
var GroupApp = new(ServiceGroup)
|
var GroupApp = new(ServiceGroup)
|
||||||
|
|||||||
@@ -15,30 +15,55 @@ var InteractionServiceApp = new(InteractionService)
|
|||||||
|
|
||||||
// AddHistory 添加收听历史
|
// AddHistory 添加收听历史
|
||||||
func (s *InteractionService) AddHistory(userId string, req radioReq.AddHistory) error {
|
func (s *InteractionService) AddHistory(userId string, req radioReq.AddHistory) error {
|
||||||
// 先查找是否已存在记录
|
// 1. 获取节目信息以拿到 ChannelId (用于日志冗余方便统计)
|
||||||
|
var program radio.RadioProgram
|
||||||
|
if err := global.DB.Select("id, channel_id").Where("id = ?", req.ProgramId).First(&program).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 写入/更新用户书签 (RadioHistory)
|
||||||
var history radio.RadioHistory
|
var history radio.RadioHistory
|
||||||
err := global.DB.Where("user_id = ? AND program_id = ?", userId, req.ProgramId).First(&history).Error
|
err := global.DB.Where("user_id = ? AND program_id = ?", userId, req.ProgramId).First(&history).Error
|
||||||
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
// 不存在,创建新记录
|
|
||||||
history = radio.RadioHistory{
|
history = radio.RadioHistory{
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
ProgramId: req.ProgramId,
|
ProgramId: req.ProgramId,
|
||||||
Progress: req.Progress,
|
Progress: req.Progress,
|
||||||
Duration: req.Duration,
|
Duration: req.Duration,
|
||||||
}
|
}
|
||||||
return global.DB.Create(&history).Error
|
if err := global.DB.Create(&history).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
} else if err == nil {
|
||||||
if err != nil {
|
if err := global.DB.Model(&history).Updates(map[string]interface{}{
|
||||||
|
"progress": req.Progress,
|
||||||
|
"duration": req.Duration,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存在,更新进度
|
// 3. 贪婪学习:如果节目表时长为0,且前端传回了有效时长,则自动补全元数据
|
||||||
return global.DB.Model(&history).Updates(map[string]interface{}{
|
if req.Duration > 0 && program.Duration == 0 {
|
||||||
"progress": req.Progress,
|
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", req.ProgramId).Update("duration", req.Duration)
|
||||||
"duration": req.Duration,
|
}
|
||||||
}).Error
|
|
||||||
|
// 4. 异步写入不可删除的日志表 (RadioListenLog) 用于趋势统计
|
||||||
|
go func() {
|
||||||
|
listenLog := radio.RadioListenLog{
|
||||||
|
UserId: userId,
|
||||||
|
ProgramId: req.ProgramId,
|
||||||
|
ChannelId: program.ChannelId,
|
||||||
|
Progress: req.Progress,
|
||||||
|
Duration: req.Duration,
|
||||||
|
}
|
||||||
|
global.DB.Create(&listenLog)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHistoryList 获取收听历史列表
|
// GetHistoryList 获取收听历史列表
|
||||||
|
|||||||
@@ -147,16 +147,13 @@ func (s *PayService) PayCallback(c *gin.Context) error {
|
|||||||
TradeType: *transaction.TradeType,
|
TradeType: *transaction.TradeType,
|
||||||
TransactionId: *transaction.TransactionId,
|
TransactionId: *transaction.TransactionId,
|
||||||
}
|
}
|
||||||
err = global.DB.Create(&payNotify).Error
|
if err := tx.Create(&payNotify).Error; err != nil {
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("wxPay回调-存储数据异常:", zap.Error(err))
|
global.Logger.Error("wxPay回调-存储数据异常:", zap.Error(err))
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if payNotify.TradeState == "SUCCESS" {
|
if payNotify.TradeState == "SUCCESS" {
|
||||||
return OrderServiceApp.ExecuteOrderUnlock(tx, *transaction.OutTradeNo)
|
return OrderServiceApp.ExecuteOrderUnlock(tx, *transaction.OutTradeNo)
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user