From f4bfe2d609e7d3fb2c968b074e0c4cdc91dbd2ff Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 20 Mar 2026 17:06:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=83=E7=94=A8=E8=85=BE=E8=AE=AFtts?= =?UTF-8?q?=EF=BC=8C=E8=AF=A5=E7=94=A8=E7=81=AB=E5=B1=B1=E5=BC=95=E6=93=8E?= =?UTF-8?q?tts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/v1/radio/analytics.go | 19 ++ api/v1/radio/program.go | 4 +- config/config.go | 2 +- config/tts_config.go | 7 +- middleware/auth.go | 7 + model/radio/radio_program.go | 1 + model/radio/request/program.go | 13 ++ model/radio/response/analytics.go | 7 + router/radio/analytics_router.go | 1 + service/radio/analytics_service.go | 62 +++--- service/radio/program_service.go | 19 +- service/radio/tts_service.go | 303 ++++++++++++++++------------- utils/timer/interval.go | 22 +++ 13 files changed, 294 insertions(+), 173 deletions(-) diff --git a/api/v1/radio/analytics.go b/api/v1/radio/analytics.go index 4f99646..904c2a3 100644 --- a/api/v1/radio/analytics.go +++ b/api/v1/radio/analytics.go @@ -176,3 +176,22 @@ func (a *AnalyticsApi) GetPreferenceAnalysis(c *gin.Context) { } 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) +} diff --git a/api/v1/radio/program.go b/api/v1/radio/program.go index a613fce..72a70c3 100644 --- a/api/v1/radio/program.go +++ b/api/v1/radio/program.go @@ -150,6 +150,7 @@ func (a *ProgramApi) DeleteProgram(c *gin.Context) { // @Summary 生成TTS语音 // @Produce json // @Param id query string true "节目ID" +// @Param speaker query string false "音色(默认 zh_male_dayi_uranus_bigtts)" // @Success 200 {object} response.Response // @Router /radio/program/generate-tts [get] func (a *ProgramApi) GenerateTTS(c *gin.Context) { @@ -158,8 +159,9 @@ func (a *ProgramApi) GenerateTTS(c *gin.Context) { response.FailWithMsg("参数错误: id不能为空", c) 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)) response.FailWithMsg(err.Error(), c) return diff --git a/config/config.go b/config/config.go index 055f4dd..7850e38 100644 --- a/config/config.go +++ b/config/config.go @@ -15,5 +15,5 @@ type Config struct { MiniProgram MiniProgram `mapstructure:"mini-program" json:"mini-program" yaml:"mini-program"` //小程序 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服务配置(火山引擎) } diff --git a/config/tts_config.go b/config/tts_config.go index 314e09b..5bdd3f7 100644 --- a/config/tts_config.go +++ b/config/tts_config.go @@ -1,7 +1,8 @@ package config +// TTS 统一TTS配置 (目前对接火山引擎长文本异步合成) type TTS struct { - AppId string `mapstructure:"app-id" json:"app-id" yaml:"app-id"` - SecretId string `mapstructure:"secret-id" json:"secret-id" yaml:"secret-id"` - SecretKey string `mapstructure:"secret-key" json:"secret-key" yaml:"secret-key"` + AppId string `mapstructure:"app-id" json:"app-id" yaml:"app-id"` // 火山 AppID + ResourceId string `mapstructure:"resource-id" json:"resource-id" yaml:"resource-id"` // 火山 Cluster/资源ID + AccessKey string `mapstructure:"access-key" json:"access-key" yaml:"access-key"` // 火山 Token/SecretId } diff --git a/middleware/auth.go b/middleware/auth.go index ec24fde..9a7354c 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -47,9 +47,16 @@ func AuthMiddleware() gin.HandlerFunc { return } c.Set("claims", claims) + // 检查token是否即将过期,如果是则续签token if claims.ExpiresAt.Unix()-time.Now().Unix() < claims.BufferTime { dr, _ := timer.ParseDuration(global.Config.JWT.ExpiresTime) 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() } diff --git a/model/radio/radio_program.go b/model/radio/radio_program.go index 6d9c835..7e9973d 100644 --- a/model/radio/radio_program.go +++ b/model/radio/radio_program.go @@ -15,6 +15,7 @@ type RadioProgram struct { Cover string `gorm:"size:100" json:"cover"` // 封面图emoji AudioId string `gorm:"size:50" json:"audioId"` // 音频OSS ID 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"` // 时长(秒) Tags string `gorm:"size:255" json:"tags"` // 标签,逗号分隔 PlayCount int `gorm:"default:0" json:"playCount"` // 播放次数 diff --git a/model/radio/request/program.go b/model/radio/request/program.go index 86001ba..d5d6e0f 100644 --- a/model/radio/request/program.go +++ b/model/radio/request/program.go @@ -36,3 +36,16 @@ type UpdateProgram struct { Tags string `json:"tags"` // 标签 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{} diff --git a/model/radio/response/analytics.go b/model/radio/response/analytics.go index 1b81400..975531d 100644 --- a/model/radio/response/analytics.go +++ b/model/radio/response/analytics.go @@ -68,3 +68,10 @@ type CategoryContribution struct { type PreferenceAnalysisResponse struct { List []CategoryContribution `json:"list"` } + +// VipStatsResponse VIP统计数据响应 +type VipStatsResponse struct { + ActiveVipUsers int64 `json:"activeVipUsers"` + VipRevenue int64 `json:"vipRevenue"` + NewVipOrders int64 `json:"newVipOrders"` +} diff --git a/router/radio/analytics_router.go b/router/radio/analytics_router.go index 403f461..602f1d6 100644 --- a/router/radio/analytics_router.go +++ b/router/radio/analytics_router.go @@ -17,5 +17,6 @@ func (r *AnalyticsRouter) InitAnalyticsRouter(Router *gin.RouterGroup) { analyticsRouter.GET("user-stickiness", analyticsApi.GetUserStickiness) analyticsRouter.GET("business-conversion", analyticsApi.GetBusinessConversion) analyticsRouter.GET("preference", analyticsApi.GetPreferenceAnalysis) + analyticsRouter.GET("vip-stats", analyticsApi.GetVipStats) } } diff --git a/service/radio/analytics_service.go b/service/radio/analytics_service.go index 9d6ef35..bcac968 100644 --- a/service/radio/analytics_service.go +++ b/service/radio/analytics_service.go @@ -3,37 +3,16 @@ package radio import ( "sundynix-go/global" "sundynix-go/model/radio/response" + "sundynix-go/utils/timer" "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) + start, end := timer.ParseDateRange(startDate, endDate) db := global.DB.Table("sundynix_radio_listen_log") if channelId != "" { @@ -60,7 +39,7 @@ func (s *AnalyticsService) GetListeningTrend(startDate, endDate, channelId strin // GetSubscriptionTrend 获取新增订阅趋势 (使用永久 Order 记录) func (s *AnalyticsService) GetSubscriptionTrend(startDate, endDate, channelId string) (response.SubscriptionTrendResponse, error) { var resp response.SubscriptionTrendResponse - start, end := parseDateRange(startDate, endDate) + start, end := timer.ParseDateRange(startDate, endDate) // 订阅趋势 = 首次购买该频道成功的订单 // 通过子查询找到每个 (user_id, channel_id) 的最小成功订单日期 @@ -89,7 +68,7 @@ func (s *AnalyticsService) GetSubscriptionTrend(startDate, endDate, channelId st // GetRenewalTrend 获取续费趋势 (使用永久 Order 记录) func (s *AnalyticsService) GetRenewalTrend(startDate, endDate, channelId string) (response.RenewalTrendResponse, error) { var resp response.RenewalTrendResponse - start, end := parseDateRange(startDate, endDate) + start, end := timer.ParseDateRange(startDate, endDate) // 续费 = 成功支付的订阅订单,且不是该用户对该频道的首笔订单 db := global.DB.Table("sundynix_order AS o"). @@ -118,7 +97,7 @@ func (s *AnalyticsService) GetRenewalTrend(startDate, endDate, channelId string) // GetSubscriberStats 获取订阅用户统计 (混合实时 Subscription 与历史 Order) func (s *AnalyticsService) GetSubscriberStats(startDate, endDate, channelId string) (response.SubscriberStatsResponse, error) { var resp response.SubscriberStatsResponse - start, end := parseDateRange(startDate, endDate) + start, end := timer.ParseDateRange(startDate, endDate) now := time.Now() // 1. 当前有效订阅用户数 (实时表) @@ -190,7 +169,7 @@ func (s *AnalyticsService) GetContentQuality(channelId string) ([]response.Compl // GetUserStickiness 用户黏性分析:留存分析 (Cohort Analysis) func (s *AnalyticsService) GetUserStickiness(startDate, endDate string) ([]response.RetentionResponse, error) { var list []response.RetentionResponse - start, end := parseDateRange(startDate, endDate) + start, end := timer.ParseDateRange(startDate, endDate) // 获取时间范围内的每日新增活跃用户 var dailyNewUsers []struct { @@ -239,7 +218,7 @@ func (s *AnalyticsService) GetUserStickiness(startDate, endDate string) ([]respo // GetBusinessConversion 商业转化分析:漏斗与 LTV func (s *AnalyticsService) GetBusinessConversion(startDate, endDate string) (response.FunnelResponse, error) { var resp response.FunnelResponse - start, end := parseDateRange(startDate, endDate) + start, end := timer.ParseDateRange(startDate, endDate) // 1. 活跃收听用户数 (Top of Funnel) global.DB.Table("sundynix_radio_listen_log"). @@ -297,3 +276,30 @@ func (s *AnalyticsService) GetPreferenceAnalysis() (response.PreferenceAnalysisR 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 +} diff --git a/service/radio/program_service.go b/service/radio/program_service.go index 6014613..c17d117 100644 --- a/service/radio/program_service.go +++ b/service/radio/program_service.go @@ -114,7 +114,7 @@ func (s *ProgramService) IncrementPlayCount(id string) error { } // GenerateTTS 生成TTS语音并更新节目 (异步) -func (s *ProgramService) GenerateTTS(programId string) error { +func (s *ProgramService) GenerateTTS(programId string, speaker string) error { // 1. 获取节目内容 var program radio.RadioProgram 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("节目内容为空") } + // 提前将状态标记为: 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提交任务 (异步,后台处理) - ttsReq := TTSTextToSpeechRequest{ - Text: program.Content, - //VoiceType: 101001, // 智瑜 情感女声 - VoiceType: 101013, // 智辉 新闻男声 - Speed: 0, // 正常语速 - Volume: 0, // 正常音量 + ttsReq := TTSRequest{ + Text: program.Content, + Speaker: speaker, // 如果为空,底层接口会默认赋予 zh_male_dayi_uranus_bigtts ProgramId: programId, } _, err := TTSServiceApp.SubmitTTSTask(ttsReq) if err != nil { + // 提交失败,恢复原本状态 0:无音频 + global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0) return err } diff --git a/service/radio/tts_service.go b/service/radio/tts_service.go index 3db15c8..48190ae 100644 --- a/service/radio/tts_service.go +++ b/service/radio/tts_service.go @@ -1,7 +1,9 @@ package radio import ( + "bytes" "crypto/md5" + "encoding/json" "fmt" "io" "net/http" @@ -11,228 +13,263 @@ import ( "sundynix-go/global" "sundynix-go/model/radio" "sundynix-go/model/system" + "sundynix-go/pkg/httpclient" "sundynix-go/utils/upload" - "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" - "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" - tts "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts/v20190823" + "github.com/google/uuid" ) -// TTSService TTS服务 -type TTSService struct{} - -// TTSTextToSpeechRequest TTS请求参数 -type TTSTextToSpeechRequest struct { +// TTSRequest TTS请求参数 +type TTSRequest struct { Text string // 要转换的文本 - VoiceType int // 声音类型 - Speed int // 语速 -2到6 - Volume int // 音量 -10到10 + Speaker string // 声音类型 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任务 (异步) -func (t *TTSService) SubmitTTSTask(req TTSTextToSpeechRequest) (string, error) { +func (t *TTSService) SubmitTTSTask(req TTSRequest) (string, error) { if req.Text == "" { return "", fmt.Errorf("文本内容不能为空") } - // 提交长文本TTS任务 taskId, err := t.doSubmitTTSTask(req) if err != nil { return "", fmt.Errorf("提交TTS任务失败: %v", err) } - // 异步处理结果 go t.asyncProcessResult(req.ProgramId, taskId) - 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结果 func (t *TTSService) asyncProcessResult(programId, taskId string) { - // 轮询查询任务结果,获取音频下载URL resultUrl, err := t.waitForResult(taskId) if err != nil { 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 } - // 从ResultUrl下载音频数据 audioData, err := t.downloadAudio(resultUrl) if err != nil { global.Logger.Error(fmt.Sprintf("下载音频失败, TaskId: %s, Error: %v", taskId, err)) + global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0) return } - // 上传到OSS audioId, err := t.uploadToOSS(audioData, programId) if err != nil { 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 } - // 更新节目的audio_id 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.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0) return } 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 func (t *TTSService) waitForResult(taskId string) (string, error) { - maxRetries := 30 - interval := 15 * time.Second + maxRetries := 10 + interval := 30 * time.Second for i := 0; i < maxRetries; i++ { time.Sleep(interval) - resultUrl, status, statusMsg, err := t.queryTaskResult(taskId) if err != nil { return "", err } - switch status { - case 1: // 成功 + case 1: return resultUrl, nil - case 2: // 失败 + case 2: return "", fmt.Errorf("TTS合成失败: %s", statusMsg) default: global.Logger.Debug(fmt.Sprintf("TTS任务处理中, TaskId: %s, 重试次数: %d", taskId, i+1)) } } - return "", fmt.Errorf("TTS任务超时, TaskId: %s", taskId) } -// queryTaskResult 使用官方SDK查询任务结果 -// 返回: resultUrl, status, statusMsg, error -// status: 0-等待中 1-成功 2-失败 3-执行中 +// queryTaskResult 查询任务状态 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 { - return "", 0, "", fmt.Errorf("创建TTS客户端失败: %v", err) + return "", 0, "", err } - request := tts.NewDescribeTtsTaskStatusRequest() - request.TaskId = common.StringPtr(taskId) + 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") - response, err := client.DescribeTtsTaskStatus(request) + resp, err := httpclient.GetClient().Do(httpReq) 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 { - return "", 0, "", fmt.Errorf("查询任务状态返回为空") + respBytes, _ := io.ReadAll(resp.Body) + 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 - statusMsg := "" - if data.StatusStr != nil { - statusMsg = *data.StatusStr - switch *data.StatusStr { - case "success": - status = 1 - case "failed": - status = 2 - case "waiting", "doing": - status = 0 + audioUrl := "" + + if result.Data != nil { + // 官方文档定义: + // 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 } } - resultUrl := "" - if data.ResultUrl != nil { - resultUrl = *data.ResultUrl + if audioUrl != "" { + status = 1 } - return resultUrl, status, statusMsg, nil + if status == 1 && audioUrl == "" { + // 任务状态为1时如果没有url,继续等待(轮询)避免直接返回空URL使下载失败 + status = 0 + } + + return audioUrl, status, result.Message, nil } +// TTSService TTS服务 +type TTSService struct{} + +var TTSServiceApp = new(TTSService) + // downloadAudio 从URL下载音频数据 func (t *TTSService) downloadAudio(audioUrl string) ([]byte, error) { - resp, err := http.Get(audioUrl) + resp, err := httpclient.GetClient().Get(audioUrl) if err != nil { return nil, fmt.Errorf("下载音频失败: %v", err) } diff --git a/utils/timer/interval.go b/utils/timer/interval.go index fda5ff2..5e2bb53 100644 --- a/utils/timer/interval.go +++ b/utils/timer/interval.go @@ -54,3 +54,25 @@ func GetMaxTime() time.Time { maxTime := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, time.Local) 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 +}