feat: 弃用腾讯tts,该用火山引擎tts
This commit is contained in:
@@ -176,3 +176,22 @@ func (a *AnalyticsApi) GetPreferenceAnalysis(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OkWithData(data, c)
|
response.OkWithData(data, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetVipStats 获取VIP统计数据
|
||||||
|
// @Tags 数据分析
|
||||||
|
// @Summary 获取VIP统计数据
|
||||||
|
// @Param startDate query string false "开始日期"
|
||||||
|
// @Param endDate query string false "结束日期"
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /radio/analytics/vip-stats [get]
|
||||||
|
func (a *AnalyticsApi) GetVipStats(c *gin.Context) {
|
||||||
|
var req request.TrendQuery
|
||||||
|
c.ShouldBindQuery(&req)
|
||||||
|
data, err := analyticsService.GetVipStats(req.StartDate, req.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
global.Logger.Error("获取VIP统计数据失败!", zap.Error(err))
|
||||||
|
response.FailWithMsg(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OkWithData(data, c)
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ func (a *ProgramApi) DeleteProgram(c *gin.Context) {
|
|||||||
// @Summary 生成TTS语音
|
// @Summary 生成TTS语音
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id query string true "节目ID"
|
// @Param id query string true "节目ID"
|
||||||
|
// @Param speaker query string false "音色(默认 zh_male_dayi_uranus_bigtts)"
|
||||||
// @Success 200 {object} response.Response
|
// @Success 200 {object} response.Response
|
||||||
// @Router /radio/program/generate-tts [get]
|
// @Router /radio/program/generate-tts [get]
|
||||||
func (a *ProgramApi) GenerateTTS(c *gin.Context) {
|
func (a *ProgramApi) GenerateTTS(c *gin.Context) {
|
||||||
@@ -158,8 +159,9 @@ func (a *ProgramApi) GenerateTTS(c *gin.Context) {
|
|||||||
response.FailWithMsg("参数错误: id不能为空", c)
|
response.FailWithMsg("参数错误: id不能为空", c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
speaker := c.Query("speaker")
|
||||||
|
|
||||||
if err := programService.GenerateTTS(id); err != nil {
|
if err := programService.GenerateTTS(id, speaker); err != nil {
|
||||||
global.Logger.Error("生成TTS语音失败!", zap.Error(err))
|
global.Logger.Error("生成TTS语音失败!", zap.Error(err))
|
||||||
response.FailWithMsg(err.Error(), c)
|
response.FailWithMsg(err.Error(), c)
|
||||||
return
|
return
|
||||||
|
|||||||
+1
-1
@@ -15,5 +15,5 @@ type Config struct {
|
|||||||
|
|
||||||
MiniProgram MiniProgram `mapstructure:"mini-program" json:"mini-program" yaml:"mini-program"` //小程序
|
MiniProgram MiniProgram `mapstructure:"mini-program" json:"mini-program" yaml:"mini-program"` //小程序
|
||||||
WechatPay WechatPay `mapstructure:"wechat-pay" json:"wechat-pay" yaml:"wechat-pay"` //微信支付
|
WechatPay WechatPay `mapstructure:"wechat-pay" json:"wechat-pay" yaml:"wechat-pay"` //微信支付
|
||||||
TTS TTS `mapstructure:"tencent-tts" json:"tencent-tts" yaml:"tencent-tts"` //腾讯文字转语音
|
TTS TTS `mapstructure:"tts" json:"tts" yaml:"tts"` //统一TTS服务配置(火山引擎)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
|
// TTS 统一TTS配置 (目前对接火山引擎长文本异步合成)
|
||||||
type TTS struct {
|
type TTS struct {
|
||||||
AppId string `mapstructure:"app-id" json:"app-id" yaml:"app-id"`
|
AppId string `mapstructure:"app-id" json:"app-id" yaml:"app-id"` // 火山 AppID
|
||||||
SecretId string `mapstructure:"secret-id" json:"secret-id" yaml:"secret-id"`
|
ResourceId string `mapstructure:"resource-id" json:"resource-id" yaml:"resource-id"` // 火山 Cluster/资源ID
|
||||||
SecretKey string `mapstructure:"secret-key" json:"secret-key" yaml:"secret-key"`
|
AccessKey string `mapstructure:"access-key" json:"access-key" yaml:"access-key"` // 火山 Token/SecretId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,16 @@ func AuthMiddleware() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set("claims", claims)
|
c.Set("claims", claims)
|
||||||
|
// 检查token是否即将过期,如果是则续签token
|
||||||
if claims.ExpiresAt.Unix()-time.Now().Unix() < claims.BufferTime {
|
if claims.ExpiresAt.Unix()-time.Now().Unix() < claims.BufferTime {
|
||||||
dr, _ := timer.ParseDuration(global.Config.JWT.ExpiresTime)
|
dr, _ := timer.ParseDuration(global.Config.JWT.ExpiresTime)
|
||||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(dr))
|
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(dr))
|
||||||
|
// 生成新的token并返回给客户端
|
||||||
|
newToken, err := j.CreateToken(*claims)
|
||||||
|
if err == nil && newToken != "" {
|
||||||
|
// 将新token写入响应头
|
||||||
|
c.Header("Authorization", "Bearer "+newToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type RadioProgram struct {
|
|||||||
Cover string `gorm:"size:100" json:"cover"` // 封面图emoji
|
Cover string `gorm:"size:100" json:"cover"` // 封面图emoji
|
||||||
AudioId string `gorm:"size:50" json:"audioId"` // 音频OSS ID
|
AudioId string `gorm:"size:50" json:"audioId"` // 音频OSS ID
|
||||||
Audio *system.Oss `gorm:"foreignKey:AudioId" json:"audio"` // 音频OSS
|
Audio *system.Oss `gorm:"foreignKey:AudioId" json:"audio"` // 音频OSS
|
||||||
|
AudioStatus int `gorm:"default:0" json:"audioStatus"` // 音频生成状态 0:无音频 1:正在生成音频 2:音频就绪
|
||||||
Duration int `gorm:"default:0" json:"duration"` // 时长(秒)
|
Duration int `gorm:"default:0" json:"duration"` // 时长(秒)
|
||||||
Tags string `gorm:"size:255" json:"tags"` // 标签,逗号分隔
|
Tags string `gorm:"size:255" json:"tags"` // 标签,逗号分隔
|
||||||
PlayCount int `gorm:"default:0" json:"playCount"` // 播放次数
|
PlayCount int `gorm:"default:0" json:"playCount"` // 播放次数
|
||||||
|
|||||||
@@ -36,3 +36,16 @@ type UpdateProgram struct {
|
|||||||
Tags string `json:"tags"` // 标签
|
Tags string `json:"tags"` // 标签
|
||||||
Status int `json:"status"` // 状态
|
Status int `json:"status"` // 状态
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VolcengineTTSRequest 火山引擎语音合成请求
|
||||||
|
type VolcengineTTSRequest struct {
|
||||||
|
ProgramId string `json:"programId" binding:"required"` // 节目ID
|
||||||
|
Text string `json:"text" binding:"required"` // 要合成的文本
|
||||||
|
VoiceType string `json:"voiceType"` // 声音类型
|
||||||
|
Speed int `json:"speed"` // 语速 -6到6
|
||||||
|
Pitch int `json:"pitch"` // 音调 -8到8
|
||||||
|
Volume int `json:"volume"` // 音量 0到10
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVoiceTypeListRequest 获取声音类型列表请求
|
||||||
|
type GetVoiceTypeListRequest struct{}
|
||||||
|
|||||||
@@ -68,3 +68,10 @@ type CategoryContribution struct {
|
|||||||
type PreferenceAnalysisResponse struct {
|
type PreferenceAnalysisResponse struct {
|
||||||
List []CategoryContribution `json:"list"`
|
List []CategoryContribution `json:"list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VipStatsResponse VIP统计数据响应
|
||||||
|
type VipStatsResponse struct {
|
||||||
|
ActiveVipUsers int64 `json:"activeVipUsers"`
|
||||||
|
VipRevenue int64 `json:"vipRevenue"`
|
||||||
|
NewVipOrders int64 `json:"newVipOrders"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ func (r *AnalyticsRouter) InitAnalyticsRouter(Router *gin.RouterGroup) {
|
|||||||
analyticsRouter.GET("user-stickiness", analyticsApi.GetUserStickiness)
|
analyticsRouter.GET("user-stickiness", analyticsApi.GetUserStickiness)
|
||||||
analyticsRouter.GET("business-conversion", analyticsApi.GetBusinessConversion)
|
analyticsRouter.GET("business-conversion", analyticsApi.GetBusinessConversion)
|
||||||
analyticsRouter.GET("preference", analyticsApi.GetPreferenceAnalysis)
|
analyticsRouter.GET("preference", analyticsApi.GetPreferenceAnalysis)
|
||||||
|
analyticsRouter.GET("vip-stats", analyticsApi.GetVipStats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,37 +3,16 @@ package radio
|
|||||||
import (
|
import (
|
||||||
"sundynix-go/global"
|
"sundynix-go/global"
|
||||||
"sundynix-go/model/radio/response"
|
"sundynix-go/model/radio/response"
|
||||||
|
"sundynix-go/utils/timer"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnalyticsService struct{}
|
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)
|
// GetListeningTrend 获取收听趋势 (使用持久化的 ListenLog)
|
||||||
func (s *AnalyticsService) GetListeningTrend(startDate, endDate, channelId string) (response.ListeningTrendResponse, error) {
|
func (s *AnalyticsService) GetListeningTrend(startDate, endDate, channelId string) (response.ListeningTrendResponse, error) {
|
||||||
var resp response.ListeningTrendResponse
|
var resp response.ListeningTrendResponse
|
||||||
start, end := parseDateRange(startDate, endDate)
|
start, end := timer.ParseDateRange(startDate, endDate)
|
||||||
|
|
||||||
db := global.DB.Table("sundynix_radio_listen_log")
|
db := global.DB.Table("sundynix_radio_listen_log")
|
||||||
if channelId != "" {
|
if channelId != "" {
|
||||||
@@ -60,7 +39,7 @@ func (s *AnalyticsService) GetListeningTrend(startDate, endDate, channelId strin
|
|||||||
// GetSubscriptionTrend 获取新增订阅趋势 (使用永久 Order 记录)
|
// GetSubscriptionTrend 获取新增订阅趋势 (使用永久 Order 记录)
|
||||||
func (s *AnalyticsService) GetSubscriptionTrend(startDate, endDate, channelId string) (response.SubscriptionTrendResponse, error) {
|
func (s *AnalyticsService) GetSubscriptionTrend(startDate, endDate, channelId string) (response.SubscriptionTrendResponse, error) {
|
||||||
var resp response.SubscriptionTrendResponse
|
var resp response.SubscriptionTrendResponse
|
||||||
start, end := parseDateRange(startDate, endDate)
|
start, end := timer.ParseDateRange(startDate, endDate)
|
||||||
|
|
||||||
// 订阅趋势 = 首次购买该频道成功的订单
|
// 订阅趋势 = 首次购买该频道成功的订单
|
||||||
// 通过子查询找到每个 (user_id, channel_id) 的最小成功订单日期
|
// 通过子查询找到每个 (user_id, channel_id) 的最小成功订单日期
|
||||||
@@ -89,7 +68,7 @@ func (s *AnalyticsService) GetSubscriptionTrend(startDate, endDate, channelId st
|
|||||||
// GetRenewalTrend 获取续费趋势 (使用永久 Order 记录)
|
// GetRenewalTrend 获取续费趋势 (使用永久 Order 记录)
|
||||||
func (s *AnalyticsService) GetRenewalTrend(startDate, endDate, channelId string) (response.RenewalTrendResponse, error) {
|
func (s *AnalyticsService) GetRenewalTrend(startDate, endDate, channelId string) (response.RenewalTrendResponse, error) {
|
||||||
var resp response.RenewalTrendResponse
|
var resp response.RenewalTrendResponse
|
||||||
start, end := parseDateRange(startDate, endDate)
|
start, end := timer.ParseDateRange(startDate, endDate)
|
||||||
|
|
||||||
// 续费 = 成功支付的订阅订单,且不是该用户对该频道的首笔订单
|
// 续费 = 成功支付的订阅订单,且不是该用户对该频道的首笔订单
|
||||||
db := global.DB.Table("sundynix_order AS o").
|
db := global.DB.Table("sundynix_order AS o").
|
||||||
@@ -118,7 +97,7 @@ func (s *AnalyticsService) GetRenewalTrend(startDate, endDate, channelId string)
|
|||||||
// GetSubscriberStats 获取订阅用户统计 (混合实时 Subscription 与历史 Order)
|
// GetSubscriberStats 获取订阅用户统计 (混合实时 Subscription 与历史 Order)
|
||||||
func (s *AnalyticsService) GetSubscriberStats(startDate, endDate, channelId string) (response.SubscriberStatsResponse, error) {
|
func (s *AnalyticsService) GetSubscriberStats(startDate, endDate, channelId string) (response.SubscriberStatsResponse, error) {
|
||||||
var resp response.SubscriberStatsResponse
|
var resp response.SubscriberStatsResponse
|
||||||
start, end := parseDateRange(startDate, endDate)
|
start, end := timer.ParseDateRange(startDate, endDate)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// 1. 当前有效订阅用户数 (实时表)
|
// 1. 当前有效订阅用户数 (实时表)
|
||||||
@@ -190,7 +169,7 @@ func (s *AnalyticsService) GetContentQuality(channelId string) ([]response.Compl
|
|||||||
// GetUserStickiness 用户黏性分析:留存分析 (Cohort Analysis)
|
// GetUserStickiness 用户黏性分析:留存分析 (Cohort Analysis)
|
||||||
func (s *AnalyticsService) GetUserStickiness(startDate, endDate string) ([]response.RetentionResponse, error) {
|
func (s *AnalyticsService) GetUserStickiness(startDate, endDate string) ([]response.RetentionResponse, error) {
|
||||||
var list []response.RetentionResponse
|
var list []response.RetentionResponse
|
||||||
start, end := parseDateRange(startDate, endDate)
|
start, end := timer.ParseDateRange(startDate, endDate)
|
||||||
|
|
||||||
// 获取时间范围内的每日新增活跃用户
|
// 获取时间范围内的每日新增活跃用户
|
||||||
var dailyNewUsers []struct {
|
var dailyNewUsers []struct {
|
||||||
@@ -239,7 +218,7 @@ func (s *AnalyticsService) GetUserStickiness(startDate, endDate string) ([]respo
|
|||||||
// GetBusinessConversion 商业转化分析:漏斗与 LTV
|
// GetBusinessConversion 商业转化分析:漏斗与 LTV
|
||||||
func (s *AnalyticsService) GetBusinessConversion(startDate, endDate string) (response.FunnelResponse, error) {
|
func (s *AnalyticsService) GetBusinessConversion(startDate, endDate string) (response.FunnelResponse, error) {
|
||||||
var resp response.FunnelResponse
|
var resp response.FunnelResponse
|
||||||
start, end := parseDateRange(startDate, endDate)
|
start, end := timer.ParseDateRange(startDate, endDate)
|
||||||
|
|
||||||
// 1. 活跃收听用户数 (Top of Funnel)
|
// 1. 活跃收听用户数 (Top of Funnel)
|
||||||
global.DB.Table("sundynix_radio_listen_log").
|
global.DB.Table("sundynix_radio_listen_log").
|
||||||
@@ -297,3 +276,30 @@ func (s *AnalyticsService) GetPreferenceAnalysis() (response.PreferenceAnalysisR
|
|||||||
|
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetVipStats 获取VIP用户和营收统计
|
||||||
|
func (s *AnalyticsService) GetVipStats(startDate, endDate string) (response.VipStatsResponse, error) {
|
||||||
|
var resp response.VipStatsResponse
|
||||||
|
start, end := timer.ParseDateRange(startDate, endDate)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 1. 获取当前有效VIP用户数
|
||||||
|
global.DB.Table("sundynix_user").
|
||||||
|
Where("is_vip = 1 AND vip_expire_at >= ?", now).
|
||||||
|
Count(&resp.ActiveVipUsers)
|
||||||
|
|
||||||
|
// 2. 获取期间内VIP总营收 (Type = 2 为 VIP 支付)
|
||||||
|
global.DB.Table("sundynix_order").
|
||||||
|
Where("type = 2 AND status = 1 AND deleted_at IS NULL").
|
||||||
|
Where("updated_at BETWEEN ? AND ?", start, end).
|
||||||
|
Select("COALESCE(SUM(amount), 0)").
|
||||||
|
Scan(&resp.VipRevenue)
|
||||||
|
|
||||||
|
// 3. 获取期间内新增VIP订单数
|
||||||
|
global.DB.Table("sundynix_order").
|
||||||
|
Where("type = 2 AND status = 1 AND deleted_at IS NULL").
|
||||||
|
Where("updated_at BETWEEN ? AND ?", start, end).
|
||||||
|
Count(&resp.NewVipOrders)
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ func (s *ProgramService) IncrementPlayCount(id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GenerateTTS 生成TTS语音并更新节目 (异步)
|
// GenerateTTS 生成TTS语音并更新节目 (异步)
|
||||||
func (s *ProgramService) GenerateTTS(programId string) error {
|
func (s *ProgramService) GenerateTTS(programId string, speaker string) error {
|
||||||
// 1. 获取节目内容
|
// 1. 获取节目内容
|
||||||
var program radio.RadioProgram
|
var program radio.RadioProgram
|
||||||
if err := global.DB.Where("id = ?", programId).First(&program).Error; err != nil {
|
if err := global.DB.Where("id = ?", programId).First(&program).Error; err != nil {
|
||||||
@@ -125,17 +125,22 @@ func (s *ProgramService) GenerateTTS(programId string) error {
|
|||||||
return fmt.Errorf("节目内容为空")
|
return fmt.Errorf("节目内容为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提前将状态标记为: 1正在生成音频
|
||||||
|
if err := global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 1).Error; err != nil {
|
||||||
|
global.Logger.Error(fmt.Sprintf("更新节目[%s]音频状态为正在生成失败", programId))
|
||||||
|
// 容错处理: 虽然状态更新失败,但可以继续生成流程
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 调用TTS提交任务 (异步,后台处理)
|
// 2. 调用TTS提交任务 (异步,后台处理)
|
||||||
ttsReq := TTSTextToSpeechRequest{
|
ttsReq := TTSRequest{
|
||||||
Text: program.Content,
|
Text: program.Content,
|
||||||
//VoiceType: 101001, // 智瑜 情感女声
|
Speaker: speaker, // 如果为空,底层接口会默认赋予 zh_male_dayi_uranus_bigtts
|
||||||
VoiceType: 101013, // 智辉 新闻男声
|
|
||||||
Speed: 0, // 正常语速
|
|
||||||
Volume: 0, // 正常音量
|
|
||||||
ProgramId: programId,
|
ProgramId: programId,
|
||||||
}
|
}
|
||||||
_, err := TTSServiceApp.SubmitTTSTask(ttsReq)
|
_, err := TTSServiceApp.SubmitTTSTask(ttsReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 提交失败,恢复原本状态 0:无音频
|
||||||
|
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+170
-133
@@ -1,7 +1,9 @@
|
|||||||
package radio
|
package radio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,228 +13,263 @@ import (
|
|||||||
"sundynix-go/global"
|
"sundynix-go/global"
|
||||||
"sundynix-go/model/radio"
|
"sundynix-go/model/radio"
|
||||||
"sundynix-go/model/system"
|
"sundynix-go/model/system"
|
||||||
|
"sundynix-go/pkg/httpclient"
|
||||||
"sundynix-go/utils/upload"
|
"sundynix-go/utils/upload"
|
||||||
|
|
||||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
"github.com/google/uuid"
|
||||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
|
||||||
tts "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts/v20190823"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TTSService TTS服务
|
// TTSRequest TTS请求参数
|
||||||
type TTSService struct{}
|
type TTSRequest struct {
|
||||||
|
|
||||||
// TTSTextToSpeechRequest TTS请求参数
|
|
||||||
type TTSTextToSpeechRequest struct {
|
|
||||||
Text string // 要转换的文本
|
Text string // 要转换的文本
|
||||||
VoiceType int // 声音类型
|
Speaker string // 声音类型
|
||||||
Speed int // 语速 -2到6
|
|
||||||
Volume int // 音量 -10到10
|
|
||||||
ProgramId string // 节目ID
|
ProgramId string // 节目ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// TTSResult TTS任务结果
|
|
||||||
type TTSResult struct {
|
|
||||||
TaskId string // 任务ID
|
|
||||||
ProgramId string // 节目ID
|
|
||||||
Status int // 0:处理中 1:成功 2:失败
|
|
||||||
StatusMsg string // 状态描述
|
|
||||||
AudioId string // OSS文件ID
|
|
||||||
AudioUrl string // OSS文件URL
|
|
||||||
CreatedAt time.Time // 创建时间
|
|
||||||
}
|
|
||||||
|
|
||||||
var TTSServiceApp = new(TTSService)
|
|
||||||
|
|
||||||
// newTTSClient 创建腾讯云TTS客户端
|
|
||||||
func (t *TTSService) newTTSClient() (*tts.Client, error) {
|
|
||||||
credential := common.NewCredential(
|
|
||||||
global.Config.TTS.SecretId,
|
|
||||||
global.Config.TTS.SecretKey,
|
|
||||||
)
|
|
||||||
cpf := profile.NewClientProfile()
|
|
||||||
cpf.HttpProfile.Endpoint = "tts.tencentcloudapi.com"
|
|
||||||
return tts.NewClient(credential, "ap-guangzhou", cpf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubmitTTSTask 提交长文本TTS任务 (异步)
|
// SubmitTTSTask 提交长文本TTS任务 (异步)
|
||||||
func (t *TTSService) SubmitTTSTask(req TTSTextToSpeechRequest) (string, error) {
|
func (t *TTSService) SubmitTTSTask(req TTSRequest) (string, error) {
|
||||||
if req.Text == "" {
|
if req.Text == "" {
|
||||||
return "", fmt.Errorf("文本内容不能为空")
|
return "", fmt.Errorf("文本内容不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交长文本TTS任务
|
|
||||||
taskId, err := t.doSubmitTTSTask(req)
|
taskId, err := t.doSubmitTTSTask(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("提交TTS任务失败: %v", err)
|
return "", fmt.Errorf("提交TTS任务失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 异步处理结果
|
|
||||||
go t.asyncProcessResult(req.ProgramId, taskId)
|
go t.asyncProcessResult(req.ProgramId, taskId)
|
||||||
|
|
||||||
return taskId, nil
|
return taskId, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doSubmitTTSTask 提交任务
|
||||||
|
func (t *TTSService) doSubmitTTSTask(req TTSRequest) (string, error) {
|
||||||
|
url := "https://openspeech.bytedance.com/api/v3/tts/submit"
|
||||||
|
appID := global.Config.TTS.AppId
|
||||||
|
accessKey := global.Config.TTS.AccessKey
|
||||||
|
resourceID := global.Config.TTS.ResourceId
|
||||||
|
if resourceID == "" {
|
||||||
|
resourceID = "seed-tts-2.0"
|
||||||
|
}
|
||||||
|
speaker := req.Speaker
|
||||||
|
if speaker == "" {
|
||||||
|
speaker = "zh_male_dayi_uranus_bigtts"
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyData := map[string]interface{}{
|
||||||
|
"user": map[string]interface{}{"uid": "123123"},
|
||||||
|
"unique_id": uuid.New().String(),
|
||||||
|
"req_params": map[string]interface{}{
|
||||||
|
"text": req.Text,
|
||||||
|
"speaker": speaker,
|
||||||
|
"audio_params": map[string]interface{}{
|
||||||
|
"format": "mp3",
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"enable_timestamp": true,
|
||||||
|
},
|
||||||
|
"additions": "{}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(bodyData)
|
||||||
|
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("X-Api-App-Id", appID)
|
||||||
|
httpReq.Header.Set("X-Api-Access-Key", accessKey)
|
||||||
|
httpReq.Header.Set("X-Api-Resource-Id", resourceID)
|
||||||
|
httpReq.Header.Set("X-Api-Request-Id", uuid.New().String())
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
resp, err := httpclient.GetClient().Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("请求失败, 状态码: %d, 返回: %s", resp.StatusCode, string(respBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Data struct {
|
||||||
|
TaskId string `json:"task_id"`
|
||||||
|
} `json:"data"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.Data.TaskId == "" {
|
||||||
|
return "", fmt.Errorf("未能获取任务ID: %s", result.Message)
|
||||||
|
}
|
||||||
|
return result.Data.TaskId, nil
|
||||||
|
}
|
||||||
|
|
||||||
// asyncProcessResult 异步处理TTS结果
|
// asyncProcessResult 异步处理TTS结果
|
||||||
func (t *TTSService) asyncProcessResult(programId, taskId string) {
|
func (t *TTSService) asyncProcessResult(programId, taskId string) {
|
||||||
// 轮询查询任务结果,获取音频下载URL
|
|
||||||
resultUrl, err := t.waitForResult(taskId)
|
resultUrl, err := t.waitForResult(taskId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
global.Logger.Error(fmt.Sprintf("TTS任务失败, TaskId: %s, Error: %v", taskId, err))
|
global.Logger.Error(fmt.Sprintf("TTS任务失败, TaskId: %s, Error: %v", taskId, err))
|
||||||
|
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从ResultUrl下载音频数据
|
|
||||||
audioData, err := t.downloadAudio(resultUrl)
|
audioData, err := t.downloadAudio(resultUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
global.Logger.Error(fmt.Sprintf("下载音频失败, TaskId: %s, Error: %v", taskId, err))
|
global.Logger.Error(fmt.Sprintf("下载音频失败, TaskId: %s, Error: %v", taskId, err))
|
||||||
|
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传到OSS
|
|
||||||
audioId, err := t.uploadToOSS(audioData, programId)
|
audioId, err := t.uploadToOSS(audioData, programId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
global.Logger.Error(fmt.Sprintf("上传OSS失败, TaskId: %s, Error: %v", taskId, err))
|
global.Logger.Error(fmt.Sprintf("上传OSS失败, TaskId: %s, Error: %v", taskId, err))
|
||||||
|
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新节目的audio_id
|
|
||||||
if err := global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).
|
if err := global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).
|
||||||
Update("audio_id", audioId).Error; err != nil {
|
Updates(map[string]interface{}{
|
||||||
|
"audio_id": audioId,
|
||||||
|
"audio_status": 2, // 音频就绪
|
||||||
|
}).Error; err != nil {
|
||||||
global.Logger.Error(fmt.Sprintf("更新节目音频ID失败, TaskId: %s, Error: %v", taskId, err))
|
global.Logger.Error(fmt.Sprintf("更新节目音频ID失败, TaskId: %s, Error: %v", taskId, err))
|
||||||
|
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
global.Logger.Info(fmt.Sprintf("TTS任务完成, TaskId: %s, ProgramId: %s, AudioId: %s", taskId, programId, audioId))
|
global.Logger.Info(fmt.Sprintf("TTS任务完成, TaskId: %s, ProgramId: %s, AudioId: %s", taskId, programId, audioId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubmitTTSTaskOnly 仅提交任务,返回TaskId (供外部调用)
|
|
||||||
func (t *TTSService) SubmitTTSTaskOnly(req TTSTextToSpeechRequest) (string, error) {
|
|
||||||
if req.Text == "" {
|
|
||||||
return "", fmt.Errorf("文本内容不能为空")
|
|
||||||
}
|
|
||||||
return t.doSubmitTTSTask(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryTTSTask 查询任务状态
|
|
||||||
func (t *TTSService) QueryTTSTask(taskId string) (int, string, error) {
|
|
||||||
_, status, statusMsg, err := t.queryTaskResult(taskId)
|
|
||||||
return status, statusMsg, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAudioByTaskId 根据TaskId获取音频下载URL
|
|
||||||
func (t *TTSService) GetAudioByTaskId(taskId string) ([]byte, error) {
|
|
||||||
resultUrl, status, _, err := t.queryTaskResult(taskId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if status != 1 {
|
|
||||||
return nil, fmt.Errorf("任务未完成或失败")
|
|
||||||
}
|
|
||||||
return t.downloadAudio(resultUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doSubmitTTSTask 使用官方SDK提交长文本TTS任务
|
|
||||||
func (t *TTSService) doSubmitTTSTask(req TTSTextToSpeechRequest) (string, error) {
|
|
||||||
client, err := t.newTTSClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("创建TTS客户端失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
request := tts.NewCreateTtsTaskRequest()
|
|
||||||
request.Text = common.StringPtr(req.Text)
|
|
||||||
request.VoiceType = common.Int64Ptr(int64(req.VoiceType))
|
|
||||||
request.Speed = common.Float64Ptr(float64(req.Speed))
|
|
||||||
request.Volume = common.Float64Ptr(float64(req.Volume))
|
|
||||||
request.Codec = common.StringPtr("mp3")
|
|
||||||
request.ModelType = common.Int64Ptr(1)
|
|
||||||
|
|
||||||
response, err := client.CreateTtsTask(request)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("调用CreateTtsTask失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.Response == nil || response.Response.Data == nil || response.Response.Data.TaskId == nil {
|
|
||||||
return "", fmt.Errorf("未获取到TaskId")
|
|
||||||
}
|
|
||||||
|
|
||||||
return *response.Response.Data.TaskId, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForResult 轮询查询任务结果,返回音频下载URL
|
// waitForResult 轮询查询任务结果,返回音频下载URL
|
||||||
func (t *TTSService) waitForResult(taskId string) (string, error) {
|
func (t *TTSService) waitForResult(taskId string) (string, error) {
|
||||||
maxRetries := 30
|
maxRetries := 10
|
||||||
interval := 15 * time.Second
|
interval := 30 * time.Second
|
||||||
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
for i := 0; i < maxRetries; i++ {
|
||||||
time.Sleep(interval)
|
time.Sleep(interval)
|
||||||
|
|
||||||
resultUrl, status, statusMsg, err := t.queryTaskResult(taskId)
|
resultUrl, status, statusMsg, err := t.queryTaskResult(taskId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
switch status {
|
switch status {
|
||||||
case 1: // 成功
|
case 1:
|
||||||
return resultUrl, nil
|
return resultUrl, nil
|
||||||
case 2: // 失败
|
case 2:
|
||||||
return "", fmt.Errorf("TTS合成失败: %s", statusMsg)
|
return "", fmt.Errorf("TTS合成失败: %s", statusMsg)
|
||||||
default:
|
default:
|
||||||
global.Logger.Debug(fmt.Sprintf("TTS任务处理中, TaskId: %s, 重试次数: %d", taskId, i+1))
|
global.Logger.Debug(fmt.Sprintf("TTS任务处理中, TaskId: %s, 重试次数: %d", taskId, i+1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("TTS任务超时, TaskId: %s", taskId)
|
return "", fmt.Errorf("TTS任务超时, TaskId: %s", taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryTaskResult 使用官方SDK查询任务结果
|
// queryTaskResult 查询任务状态
|
||||||
// 返回: resultUrl, status, statusMsg, error
|
|
||||||
// status: 0-等待中 1-成功 2-失败 3-执行中
|
|
||||||
func (t *TTSService) queryTaskResult(taskId string) (string, int, string, error) {
|
func (t *TTSService) queryTaskResult(taskId string) (string, int, string, error) {
|
||||||
client, err := t.newTTSClient()
|
url := "https://openspeech.bytedance.com/api/v3/tts/query"
|
||||||
|
appID := global.Config.TTS.AppId
|
||||||
|
accessKey := global.Config.TTS.AccessKey
|
||||||
|
resourceID := global.Config.TTS.ResourceId
|
||||||
|
if resourceID == "" {
|
||||||
|
resourceID = "seed-tts-2.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyData := map[string]interface{}{"task_id": taskId}
|
||||||
|
jsonBody, _ := json.Marshal(bodyData)
|
||||||
|
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, "", fmt.Errorf("创建TTS客户端失败: %v", err)
|
return "", 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
request := tts.NewDescribeTtsTaskStatusRequest()
|
httpReq.Header.Set("X-Api-App-Id", appID)
|
||||||
request.TaskId = common.StringPtr(taskId)
|
httpReq.Header.Set("X-Api-Access-Key", accessKey)
|
||||||
|
httpReq.Header.Set("X-Api-Resource-Id", resourceID)
|
||||||
|
httpReq.Header.Set("X-Api-Request-Id", uuid.New().String())
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Connection", "keep-alive")
|
||||||
|
|
||||||
response, err := client.DescribeTtsTaskStatus(request)
|
resp, err := httpclient.GetClient().Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, "", fmt.Errorf("调用DescribeTtsTaskStatus失败: %v", err)
|
return "", 0, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", 0, "", fmt.Errorf("查询任务失败, 状态码: %d, 返回: %s", resp.StatusCode, string(respBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.Response == nil || response.Response.Data == nil {
|
respBytes, _ := io.ReadAll(resp.Body)
|
||||||
return "", 0, "", fmt.Errorf("查询任务状态返回为空")
|
global.Logger.Info(fmt.Sprintf("火山查询原始结果: %s", string(respBytes)))
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBytes, &result); err != nil {
|
||||||
|
return "", 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
data := response.Response.Data
|
|
||||||
|
|
||||||
// 将SDK的StatusStr转换为数字状态
|
|
||||||
status := 0
|
status := 0
|
||||||
statusMsg := ""
|
audioUrl := ""
|
||||||
if data.StatusStr != nil {
|
|
||||||
statusMsg = *data.StatusStr
|
if result.Data != nil {
|
||||||
switch *data.StatusStr {
|
// 官方文档定义:
|
||||||
case "success":
|
// 1: Running (正在处理)
|
||||||
|
// 2: Success (处理成功)
|
||||||
|
// 3: Failure (处理失败)
|
||||||
|
|
||||||
|
volcStatus := 0
|
||||||
|
if statusVal, ok := result.Data["task_status"].(float64); ok {
|
||||||
|
volcStatus = int(statusVal)
|
||||||
|
} else if statusStr, ok := result.Data["task_status"].(string); ok {
|
||||||
|
if statusStr == "1" {
|
||||||
|
volcStatus = 1
|
||||||
|
} else if statusStr == "2" || statusStr == "success" || statusStr == "done" {
|
||||||
|
volcStatus = 2
|
||||||
|
} else if statusStr == "3" || statusStr == "failed" || statusStr == "error" {
|
||||||
|
volcStatus = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 映射到内部状态: 0: 处理中, 1: 成功, 2: 失败
|
||||||
|
if volcStatus == 1 {
|
||||||
|
status = 0 // Running
|
||||||
|
} else if volcStatus == 2 {
|
||||||
|
status = 1 // Success
|
||||||
|
} else if volcStatus == 3 {
|
||||||
|
status = 2 // Failure
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := result.Data["audio_url"].(string); ok {
|
||||||
|
audioUrl = val
|
||||||
|
} else if val, ok := result.Data["audio"].(string); ok {
|
||||||
|
audioUrl = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if audioUrl != "" {
|
||||||
status = 1
|
status = 1
|
||||||
case "failed":
|
}
|
||||||
status = 2
|
|
||||||
case "waiting", "doing":
|
if status == 1 && audioUrl == "" {
|
||||||
|
// 任务状态为1时如果没有url,继续等待(轮询)避免直接返回空URL使下载失败
|
||||||
status = 0
|
status = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return audioUrl, status, result.Message, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resultUrl := ""
|
// TTSService TTS服务
|
||||||
if data.ResultUrl != nil {
|
type TTSService struct{}
|
||||||
resultUrl = *data.ResultUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultUrl, status, statusMsg, nil
|
var TTSServiceApp = new(TTSService)
|
||||||
}
|
|
||||||
|
|
||||||
// downloadAudio 从URL下载音频数据
|
// downloadAudio 从URL下载音频数据
|
||||||
func (t *TTSService) downloadAudio(audioUrl string) ([]byte, error) {
|
func (t *TTSService) downloadAudio(audioUrl string) ([]byte, error) {
|
||||||
resp, err := http.Get(audioUrl)
|
resp, err := httpclient.GetClient().Get(audioUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("下载音频失败: %v", err)
|
return nil, fmt.Errorf("下载音频失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,3 +54,25 @@ func GetMaxTime() time.Time {
|
|||||||
maxTime := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, time.Local)
|
maxTime := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, time.Local)
|
||||||
return maxTime
|
return maxTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseDateRange 解析日期范围,未传则默认最近30天
|
||||||
|
func ParseDateRange(startDate, endDate string) (time.Time, time.Time) {
|
||||||
|
now := time.Now()
|
||||||
|
layout := "2006-01-02"
|
||||||
|
|
||||||
|
end, err := time.Parse(layout, endDate)
|
||||||
|
if err != nil {
|
||||||
|
end = now
|
||||||
|
}
|
||||||
|
// 结束日期取当天 23:59:59
|
||||||
|
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, time.Local)
|
||||||
|
|
||||||
|
start, err := time.Parse(layout, startDate)
|
||||||
|
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, time.Local)
|
||||||
|
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user