diff --git a/api/v1/radio/analytics.go b/api/v1/radio/analytics.go new file mode 100644 index 0000000..4f99646 --- /dev/null +++ b/api/v1/radio/analytics.go @@ -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) +} diff --git a/api/v1/radio/enter.go b/api/v1/radio/enter.go index f8d41b7..da05de7 100644 --- a/api/v1/radio/enter.go +++ b/api/v1/radio/enter.go @@ -10,6 +10,7 @@ type ApiGroup struct { InteractionApi PayApi VipApi + AnalyticsApi } var ApiGroupApp = new(ApiGroup) @@ -22,4 +23,5 @@ var ( interactionService = service.GroupApp.RadioServiceGroup.InteractionService payService = service.GroupApp.RadioServiceGroup.PayService vipService = service.GroupApp.RadioServiceGroup.VipService + analyticsService = service.GroupApp.RadioServiceGroup.AnalyticsService ) diff --git a/docs/docs.go b/docs/docs.go index 0ec548e..7ae00a2 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { "post": { "produces": [ diff --git a/docs/swagger.json b/docs/swagger.json index 14e2b62..fe422e0 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { "post": { "produces": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b625ad4..9a4959b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1575,6 +1575,169 @@ paths: summary: 支付 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: post: parameters: diff --git a/initialize/gorm.go b/initialize/gorm.go index f2804d2..4dcff26 100644 --- a/initialize/gorm.go +++ b/initialize/gorm.go @@ -51,6 +51,7 @@ func MigrateTable() { radio.RadioHistory{}, radio.RadioFavorite{}, radio.RadioLike{}, + radio.RadioListenLog{}, ) if err != nil { global.Logger.Error("Migrate table failed,err:", zap.Error(err)) diff --git a/initialize/router.go b/initialize/router.go index 05410ae..3b9a105 100644 --- a/initialize/router.go +++ b/initialize/router.go @@ -55,6 +55,7 @@ func Routers() { radioRouter.InitSubscriptionRouter(NeedAuthGroup) //订阅相关 radioRouter.InitPayRouter(NeedAuthGroup, PublicGroup) //支付和回调 radioRouter.InitInteractionRouter(NeedAuthGroup) //用户互动相关 + radioRouter.InitAnalyticsRouter(NeedAuthGroup) //数据分析相关 } address := fmt.Sprintf(":%d", global.Config.System.Addr) diff --git a/model/radio/radio_listen_log.go b/model/radio/radio_listen_log.go new file mode 100644 index 0000000..1e42fa3 --- /dev/null +++ b/model/radio/radio_listen_log.go @@ -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" +} diff --git a/model/radio/radio_pay_notify.go b/model/radio/radio_pay_notify.go index 9cd5179..cf67733 100644 --- a/model/radio/radio_pay_notify.go +++ b/model/radio/radio_pay_notify.go @@ -13,7 +13,7 @@ type PayNotify struct { BankType string `json:"bank_type" gorm:"column:bank_type"` MchId string `json:"mchId" gorm:"column:mch_id"` 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"` TradeState string `json:"trade_state" gorm:"column:trade_state"` TradeStateDesc string `json:"trade_state_desc" gorm:"column:trade_state_desc"` diff --git a/model/radio/request/interaction.go b/model/radio/request/interaction.go index 7aa497b..c45b481 100644 --- a/model/radio/request/interaction.go +++ b/model/radio/request/interaction.go @@ -75,3 +75,10 @@ type GetCommentList struct { type GetSubscriptionList struct { 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"` // 可选,按频道筛选 +} diff --git a/model/radio/response/analytics.go b/model/radio/response/analytics.go new file mode 100644 index 0000000..1b81400 --- /dev/null +++ b/model/radio/response/analytics.go @@ -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"` +} diff --git a/router/radio/analytics_router.go b/router/radio/analytics_router.go new file mode 100644 index 0000000..403f461 --- /dev/null +++ b/router/radio/analytics_router.go @@ -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) + } +} diff --git a/router/radio/enter.go b/router/radio/enter.go index 9c16289..e267cfd 100644 --- a/router/radio/enter.go +++ b/router/radio/enter.go @@ -10,6 +10,7 @@ type RadioRouterGroup struct { InteractionRouter PayRouter VipRouter + AnalyticsRouter } var GroupApp = new(RadioRouterGroup) @@ -22,4 +23,5 @@ var ( interactionApi = v1.ApiGroupApp.RadioApiGroup.InteractionApi payApi = v1.ApiGroupApp.RadioApiGroup.PayApi vipApi = v1.ApiGroupApp.RadioApiGroup.VipApi + analyticsApi = v1.ApiGroupApp.RadioApiGroup.AnalyticsApi ) diff --git a/service/radio/analytics_service.go b/service/radio/analytics_service.go new file mode 100644 index 0000000..9d6ef35 --- /dev/null +++ b/service/radio/analytics_service.go @@ -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 +} diff --git a/service/radio/enter.go b/service/radio/enter.go index 933952e..f7d5689 100644 --- a/service/radio/enter.go +++ b/service/radio/enter.go @@ -10,6 +10,7 @@ type ServiceGroup struct { OrderService VipService TTSService + AnalyticsService } var GroupApp = new(ServiceGroup) diff --git a/service/radio/interaction_service.go b/service/radio/interaction_service.go index 9baac5c..84ad05e 100644 --- a/service/radio/interaction_service.go +++ b/service/radio/interaction_service.go @@ -15,30 +15,55 @@ var InteractionServiceApp = new(InteractionService) // AddHistory 添加收听历史 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 err := global.DB.Where("user_id = ? AND program_id = ?", userId, req.ProgramId).First(&history).Error if errors.Is(err, gorm.ErrRecordNotFound) { - // 不存在,创建新记录 history = radio.RadioHistory{ UserId: userId, ProgramId: req.ProgramId, Progress: req.Progress, Duration: req.Duration, } - return global.DB.Create(&history).Error - } - - if err != nil { + if err := global.DB.Create(&history).Error; err != nil { + return err + } + } else 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 global.DB.Model(&history).Updates(map[string]interface{}{ - "progress": req.Progress, - "duration": req.Duration, - }).Error + // 3. 贪婪学习:如果节目表时长为0,且前端传回了有效时长,则自动补全元数据 + if req.Duration > 0 && program.Duration == 0 { + global.DB.Model(&radio.RadioProgram{}).Where("id = ?", req.ProgramId).Update("duration", req.Duration) + } + + // 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 获取收听历史列表 diff --git a/service/radio/pay_service.go b/service/radio/pay_service.go index f509586..d363f72 100644 --- a/service/radio/pay_service.go +++ b/service/radio/pay_service.go @@ -147,16 +147,13 @@ func (s *PayService) PayCallback(c *gin.Context) error { TradeType: *transaction.TradeType, TransactionId: *transaction.TransactionId, } - err = global.DB.Create(&payNotify).Error - if err != nil { + if err := tx.Create(&payNotify).Error; err != nil { global.Logger.Error("wxPay回调-存储数据异常:", zap.Error(err)) + return err } if payNotify.TradeState == "SUCCESS" { return OrderServiceApp.ExecuteOrderUnlock(tx, *transaction.OutTradeNo) } - if err != nil { - return err - } return nil })