diff --git a/.idea/morning-radio-backend.iml b/.idea/morning-radio-backend.iml index 5e764c4..de67c24 100644 --- a/.idea/morning-radio-backend.iml +++ b/.idea/morning-radio-backend.iml @@ -2,7 +2,12 @@ - + + + + + + diff --git a/api/v1/radio/program.go b/api/v1/radio/program.go index 3439262..a613fce 100644 --- a/api/v1/radio/program.go +++ b/api/v1/radio/program.go @@ -144,3 +144,26 @@ func (a *ProgramApi) DeleteProgram(c *gin.Context) { response.OkWithMsg("删除成功", c) } + +// GenerateTTS 生成TTS语音 +// @Tags 节目管理 +// @Summary 生成TTS语音 +// @Produce json +// @Param id query string true "节目ID" +// @Success 200 {object} response.Response +// @Router /radio/program/generate-tts [get] +func (a *ProgramApi) GenerateTTS(c *gin.Context) { + id := c.Query("id") + if id == "" { + response.FailWithMsg("参数错误: id不能为空", c) + return + } + + if err := programService.GenerateTTS(id); err != nil { + global.Logger.Error("生成TTS语音失败!", zap.Error(err)) + response.FailWithMsg(err.Error(), c) + return + } + + response.OkWithMsg("TTS生成成功", c) +} diff --git a/api/v1/radio/vip.go b/api/v1/radio/vip.go index f1d15e5..0ab0275 100644 --- a/api/v1/radio/vip.go +++ b/api/v1/radio/vip.go @@ -40,7 +40,7 @@ func (a *VipApi) UpdateVipConfig(c *gin.Context) { // @Produce application/json // @Param id query string true "id" // @Success 200 {object} response.Response -// @Router /vip/config/detail [get] +// @Router /vip/config/detail [post] func (a *VipApi) VipConfigDetail(c *gin.Context) { vipConfig, err := vipService.VipConfigDetail() if err != nil { diff --git a/api/v1/system/auth.go b/api/v1/system/auth.go index a28b70a..f9f6607 100644 --- a/api/v1/system/auth.go +++ b/api/v1/system/auth.go @@ -122,7 +122,8 @@ func (a *AuthApi) GetToken(c *gin.Context, user system.User) { // @Router /auth/miniLogin [get] func (a *AuthApi) MiniLogin(c *gin.Context) { jsCode := c.Query("code") - user, err := userService.MiniLogin(jsCode) + ip := c.ClientIP() + user, err := userService.MiniLogin(jsCode, ip) if err != nil { global.Logger.Error("登录失败!", zap.Error(err)) response.FailWithMsg("登录失败", c) diff --git a/config/config.go b/config/config.go index d131e8c..055f4dd 100644 --- a/config/config.go +++ b/config/config.go @@ -15,4 +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"` //腾讯文字转语音 } diff --git a/config/tts_config.go b/config/tts_config.go new file mode 100644 index 0000000..314e09b --- /dev/null +++ b/config/tts_config.go @@ -0,0 +1,7 @@ +package config + +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"` +} diff --git a/docs/docs.go b/docs/docs.go index dd172d9..0c8d5f9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -676,6 +676,36 @@ const docTemplate = `{ } } }, + "/favorite/removeAll": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "用户互动" + ], + "summary": "清空所有收藏", + "parameters": [ + { + "description": "节目ID", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.RemoveFavorite" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, "/history/add": { "post": { "produces": [ @@ -706,6 +736,53 @@ const docTemplate = `{ } } }, + "/history/delete": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "用户互动" + ], + "summary": "删除收听历史", + "parameters": [ + { + "type": "string", + "description": "节目ID", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, + "/history/deleteAllHistory": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "用户互动" + ], + "summary": "删除所有收听历史", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, "/history/list": { "post": { "produces": [ @@ -2557,6 +2634,86 @@ const docTemplate = `{ } } } + }, + "/vip/config/detail": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "VIP管理" + ], + "summary": "获取VIP配置详情", + "parameters": [ + { + "type": "string", + "description": "id", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, + "/vip/config/update": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VIP管理" + ], + "summary": "更新VIP配置", + "parameters": [ + { + "description": "VIP配置信息", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.UpdateVipConfig" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, + "/vip/vip": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "VIP管理" + ], + "summary": "开通vip", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } } }, "definitions": { @@ -3242,6 +3399,27 @@ const docTemplate = `{ } } }, + "request.UpdateVipConfig": { + "type": "object", + "required": [ + "id", + "price" + ], + "properties": { + "discountedPrice": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "price": { + "type": "integer" + }, + "remark": { + "type": "string" + } + } + }, "response.CaptchaRes": { "type": "object", "properties": { @@ -3462,20 +3640,45 @@ const docTemplate = `{ "avatarId": { "type": "string" }, + "city": { + "description": "城市", + "type": "string" + }, "clientId": { "type": "string" }, + "country": { + "description": "国家", + "type": "string" + }, "createdAt": { "type": "string" }, "createdAtStr": { "type": "string" }, + "gender": { + "description": "性别 0:未知 1:男 2:女", + "type": "integer" + }, "id": { "description": "主键ID", "type": "string" }, - "miniOpenId": { + "isVip": { + "description": "是否VIP 0:否 1:是", + "type": "integer" + }, + "language": { + "description": "语言", + "type": "string" + }, + "lastLoginAt": { + "description": "最后登录时间", + "type": "string" + }, + "lastLoginIp": { + "description": "最后登录IP", "type": "string" }, "name": { @@ -3484,10 +3687,14 @@ const docTemplate = `{ "nickName": { "type": "string" }, + "openId": { + "type": "string" + }, "phone": { "type": "string" }, - "saOpenId": { + "province": { + "description": "省份", "type": "string" }, "sessionKey": { @@ -3501,6 +3708,10 @@ const docTemplate = `{ }, "updatedAt": { "type": "string" + }, + "vipExpireAt": { + "description": "VIP过期时间", + "type": "string" } } } diff --git a/docs/swagger.json b/docs/swagger.json index 137de74..08ada91 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -669,6 +669,36 @@ } } }, + "/favorite/removeAll": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "用户互动" + ], + "summary": "清空所有收藏", + "parameters": [ + { + "description": "节目ID", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.RemoveFavorite" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, "/history/add": { "post": { "produces": [ @@ -699,6 +729,53 @@ } } }, + "/history/delete": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "用户互动" + ], + "summary": "删除收听历史", + "parameters": [ + { + "type": "string", + "description": "节目ID", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, + "/history/deleteAllHistory": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "用户互动" + ], + "summary": "删除所有收听历史", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, "/history/list": { "post": { "produces": [ @@ -2550,6 +2627,86 @@ } } } + }, + "/vip/config/detail": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "VIP管理" + ], + "summary": "获取VIP配置详情", + "parameters": [ + { + "type": "string", + "description": "id", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, + "/vip/config/update": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VIP管理" + ], + "summary": "更新VIP配置", + "parameters": [ + { + "description": "VIP配置信息", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.UpdateVipConfig" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, + "/vip/vip": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "VIP管理" + ], + "summary": "开通vip", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } } }, "definitions": { @@ -3235,6 +3392,27 @@ } } }, + "request.UpdateVipConfig": { + "type": "object", + "required": [ + "id", + "price" + ], + "properties": { + "discountedPrice": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "price": { + "type": "integer" + }, + "remark": { + "type": "string" + } + } + }, "response.CaptchaRes": { "type": "object", "properties": { @@ -3455,20 +3633,45 @@ "avatarId": { "type": "string" }, + "city": { + "description": "城市", + "type": "string" + }, "clientId": { "type": "string" }, + "country": { + "description": "国家", + "type": "string" + }, "createdAt": { "type": "string" }, "createdAtStr": { "type": "string" }, + "gender": { + "description": "性别 0:未知 1:男 2:女", + "type": "integer" + }, "id": { "description": "主键ID", "type": "string" }, - "miniOpenId": { + "isVip": { + "description": "是否VIP 0:否 1:是", + "type": "integer" + }, + "language": { + "description": "语言", + "type": "string" + }, + "lastLoginAt": { + "description": "最后登录时间", + "type": "string" + }, + "lastLoginIp": { + "description": "最后登录IP", "type": "string" }, "name": { @@ -3477,10 +3680,14 @@ "nickName": { "type": "string" }, + "openId": { + "type": "string" + }, "phone": { "type": "string" }, - "saOpenId": { + "province": { + "description": "省份", "type": "string" }, "sessionKey": { @@ -3494,6 +3701,10 @@ }, "updatedAt": { "type": "string" + }, + "vipExpireAt": { + "description": "VIP过期时间", + "type": "string" } } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1108cb9..41f97e1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -480,6 +480,20 @@ definitions: required: - id type: object + request.UpdateVipConfig: + properties: + discountedPrice: + type: integer + id: + type: string + price: + type: integer + remark: + type: string + required: + - id + - price + type: object response.CaptchaRes: properties: captcha: @@ -626,24 +640,46 @@ definitions: $ref: '#/definitions/system.Oss' avatarId: type: string + city: + description: 城市 + type: string clientId: type: string + country: + description: 国家 + type: string createdAt: type: string createdAtStr: type: string + gender: + description: 性别 0:未知 1:男 2:女 + type: integer id: description: 主键ID type: string - miniOpenId: + isVip: + description: 是否VIP 0:否 1:是 + type: integer + language: + description: 语言 + type: string + lastLoginAt: + description: 最后登录时间 + type: string + lastLoginIp: + description: 最后登录IP type: string name: type: string nickName: type: string + openId: + type: string phone: type: string - saOpenId: + province: + description: 省份 type: string sessionKey: type: string @@ -653,6 +689,9 @@ definitions: type: string updatedAt: type: string + vipExpireAt: + description: VIP过期时间 + type: string type: object info: contact: {} @@ -1053,6 +1092,25 @@ paths: summary: 取消收藏 tags: - 用户互动 + /favorite/removeAll: + get: + parameters: + - description: 节目ID + in: body + name: data + required: true + schema: + $ref: '#/definitions/request.RemoveFavorite' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Response' + summary: 清空所有收藏 + tags: + - 用户互动 /history/add: post: parameters: @@ -1072,6 +1130,36 @@ paths: summary: 添加收听历史 tags: - 用户互动 + /history/delete: + post: + parameters: + - description: 节目ID + in: query + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Response' + summary: 删除收听历史 + tags: + - 用户互动 + /history/deleteAllHistory: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Response' + summary: 删除所有收听历史 + tags: + - 用户互动 /history/list: post: parameters: @@ -2178,6 +2266,57 @@ paths: summary: 更新用户 tags: - 用户管理 + /vip/config/detail: + get: + parameters: + - description: id + in: query + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Response' + summary: 获取VIP配置详情 + tags: + - VIP管理 + /vip/config/update: + post: + consumes: + - application/json + parameters: + - description: VIP配置信息 + in: body + name: data + required: true + schema: + $ref: '#/definitions/request.UpdateVipConfig' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Response' + summary: 更新VIP配置 + tags: + - VIP管理 + /vip/vip: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Response' + summary: 开通vip + tags: + - VIP管理 securityDefinitions: BearerAuth: type: basic diff --git a/go.mod b/go.mod index 798a7b8..af25fab 100644 --- a/go.mod +++ b/go.mod @@ -97,6 +97,8 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.51 // indirect + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts v1.3.43 // indirect github.com/tinylib/msgp v1.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect diff --git a/go.sum b/go.sum index 2cd1e9c..ea93160 100644 --- a/go.sum +++ b/go.sum @@ -205,7 +205,12 @@ github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxL github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.43/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.51 h1:yuvTAokQAdxbCr06NGOdPJpgO3z46IDinINBM0r9R9I= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.51/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts v1.3.43 h1:BE8/iU1JruJoxFyYqCoeTmD42TlDmShf6BSIOeBo8+c= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts v1.3.43/go.mod h1:LospHTzrMXwZxzdu8rJi8ODtjGn64tVcwVRg6uMsGjc= github.com/tencentyun/cos-go-sdk-v5 v0.7.70 h1:gkBkSfrDvUg4ZIjwYAfjbNCCclen9LCRNHhBNz+yjEQ= github.com/tencentyun/cos-go-sdk-v5 v0.7.70/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg= github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw= diff --git a/log/2026-02-28/error.log b/log/2026-02-28/error.log deleted file mode 100644 index 844b199..0000000 --- a/log/2026-02-28/error.log +++ /dev/null @@ -1,11 +0,0 @@ -[sundynix-radio-server]2026-02-28 15:39:45 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:43 Redis connect ping failed,err: {"name": "", "error": "dial tcp 127.0.0.1:6379: connect: connection refused"} -[sundynix-radio-server]2026-02-28 15:39:45 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:17 Redis connect failed,err: {"error": "dial tcp 127.0.0.1:6379: connect: connection refused"} -[sundynix-radio-server]2026-02-28 15:39:45 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/internal/gorm_logger_writer.go:29 [/Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:34 {{ 0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC {0001-01-01 00:00:00 +0000 UTC %!s(bool=false)} } %!s(*system.Oss=) %!s(*system.Oss=) %!s(int=0) %!s(int=0)} invalid field found for struct sundynix-go/model/radio.RadioCategory's field Cover: define a valid foreign key for relations or implement the Valuer/Scanner interface] -[error] failed to parse value %!v(MISSING), got error %!v(MISSING) -[sundynix-radio-server]2026-02-28 15:39:51 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/internal/gorm_logger_writer.go:29 [/Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:34 {{ 0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC {0001-01-01 00:00:00 +0000 UTC %!s(bool=false)} } %!s(*system.Oss=) %!s(*system.Oss=) %!s(int=0) %!s(int=0)} invalid field found for struct sundynix-go/model/radio.RadioCategory's field Cover: define a valid foreign key for relations or implement the Valuer/Scanner interface] -[error] failed to parse value %!v(MISSING), got error %!v(MISSING) -[sundynix-radio-server]2026-02-28 15:39:51 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:49 Migrate table failed,err: {"error": "invalid field found for struct sundynix-go/model/radio.RadioCategory's field Cover: define a valid foreign key for relations or implement the Valuer/Scanner interface"} -[sundynix-radio-server]2026-02-28 15:40:52 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:43 Redis connect ping failed,err: {"name": "", "error": "dial tcp 127.0.0.1:6379: connect: connection refused"} -[sundynix-radio-server]2026-02-28 15:40:52 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:17 Redis connect failed,err: {"error": "dial tcp 127.0.0.1:6379: connect: connection refused"} -[sundynix-radio-server]2026-02-28 16:03:16 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:43 Redis connect ping failed,err: {"name": "", "error": "dial tcp 127.0.0.1:6379: connect: connection refused"} -[sundynix-radio-server]2026-02-28 16:03:16 error /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:17 Redis connect failed,err: {"error": "dial tcp 127.0.0.1:6379: connect: connection refused"} diff --git a/log/2026-02-28/info.log b/log/2026-02-28/info.log deleted file mode 100644 index 0ef0310..0000000 --- a/log/2026-02-28/info.log +++ /dev/null @@ -1,8 +0,0 @@ -[sundynix-radio-server]2026-02-28 15:39:45 info /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm_mysql.go:40 Mysql connect success -[sundynix-radio-server]2026-02-28 15:40:52 info /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm_mysql.go:40 Mysql connect success -[sundynix-radio-server]2026-02-28 15:41:00 info /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:52 Migrate table success -[sundynix-radio-server]2026-02-28 16:03:16 info /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm_mysql.go:40 Mysql connect success -[sundynix-radio-server]2026-02-28 16:03:30 info /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:52 Migrate table success -[sundynix-radio-server]2026-02-28 16:03:45 info /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm_mysql.go:40 Mysql connect success -[sundynix-radio-server]2026-02-28 16:03:45 info /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:46 Redis connect ping response: {"name": "", "pong": "PONG"} -[sundynix-radio-server]2026-02-28 16:03:59 info /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:52 Migrate table success diff --git a/model/radio/request/program.go b/model/radio/request/program.go index 43eeb42..86001ba 100644 --- a/model/radio/request/program.go +++ b/model/radio/request/program.go @@ -5,9 +5,9 @@ import common "sundynix-go/model/commom/request" // GetProgramList 获取节目列表请求 type GetProgramList struct { common.PageInfo - ChannelId string `json:"channelId" binding:"required" form:"channelId"` // 频道ID - Title string `json:"title" form:"title"` // 节目标题 - Status int `json:"status" form:"status"` // 状态 + ChannelId string `json:"channelId" form:"channelId"` // 频道ID + Title string `json:"title" form:"title"` // 节目标题 + Status int `json:"status" form:"status"` // 状态 } // SaveProgram 保存节目请求 diff --git a/model/system/sys_user.go b/model/system/sys_user.go index 6c1b508..8b9c900 100644 --- a/model/system/sys_user.go +++ b/model/system/sys_user.go @@ -24,13 +24,15 @@ type User struct { OpenId string `gorm:"size:80;column:open_id" json:"openId" form:"openId"` AvatarId string `gorm:"size:50;column:avatar_id" json:"avatarId"` Avatar *Oss `gorm:"foreignKey:AvatarId" json:"avatar"` - Gender int `gorm:"default:0" json:"gender"` // 性别 0:未知 1:男 2:女 - Country string `gorm:"size:50" json:"country"` // 国家 - Province string `gorm:"size:50" json:"province"` // 省份 - City string `gorm:"size:50" json:"city"` // 城市 - Language string `gorm:"size:20" json:"language"` // 语言 - IsVip int `gorm:"default:0" json:"isVip"` // 是否VIP 0:否 1:是 - VipExpireAt *time.Time `gorm:"column:vip_expire_at" json:"vipExpireAt"` // VIP过期时间 + Gender int `gorm:"default:0" json:"gender"` // 性别 0:未知 1:男 2:女 + Country string `gorm:"size:50" json:"country"` // 国家 + Province string `gorm:"size:50" json:"province"` // 省份 + City string `gorm:"size:50" json:"city"` // 城市 + Language string `gorm:"size:20" json:"language"` // 语言 + IsVip int `gorm:"default:0" json:"isVip"` // 是否VIP 0:否 1:是 + VipExpireAt *time.Time `gorm:"column:vip_expire_at" json:"vipExpireAt"` // VIP过期时间 + LastLoginIp string `gorm:"size:20;column:last_login_ip" json:"lastLoginIp"` // 最后登录IP + LastLoginAt *time.Time `gorm:"column:last_login_at" json:"lastLoginAt"` // 最后登录时间 } func (u *User) GetAccount() string { diff --git a/router/radio/program_router.go b/router/radio/program_router.go index 7658d7f..3e150e7 100644 --- a/router/radio/program_router.go +++ b/router/radio/program_router.go @@ -14,5 +14,6 @@ func (r *ProgramRouter) InitProgramRouter(Router *gin.RouterGroup) { programRouter.POST("save", programApi.SaveProgram) programRouter.POST("update", programApi.UpdateProgram) programRouter.POST("delete", programApi.DeleteProgram) + programRouter.GET("generate-tts", programApi.GenerateTTS) } } diff --git a/service/radio/category_service.go b/service/radio/category_service.go index 969acfd..8851b66 100644 --- a/service/radio/category_service.go +++ b/service/radio/category_service.go @@ -13,7 +13,7 @@ type CategoryService struct{} // GetCategoryList 获取分类列表 func (s *CategoryService) GetCategoryList(info radioReq.GetCategoryList) ([]radio.RadioCategory, int64, error) { - db := global.DB.Model(&radio.RadioCategory{}).Preload("Icon").Preload("Cover") + db := global.DB.Model(&radio.RadioCategory{}) var list []radio.RadioCategory var total int64 @@ -43,9 +43,6 @@ func (s *CategoryService) GetCategoryTree() ([]radio.RadioCategory, error) { // Preload("Icon") 和 Preload("Cover") 用于加载 OSS 信息 err := global.DB.Model(&radio.RadioCategory{}). Preload("Channels", "status = ?", 1). // 只加载启用的频道 - Preload("Channels.Cover"). // 级联加载频道的封面 - Preload("Icon"). - Preload("Cover"). Order("sort desc"). Find(&res).Error return res, err @@ -53,14 +50,14 @@ func (s *CategoryService) GetCategoryTree() ([]radio.RadioCategory, error) { func (s *CategoryService) GetAllCategory() ([]radio.RadioCategory, error) { var res []radio.RadioCategory - err := global.DB.Find(&res).Preload("Icon").Preload("Cover").Error + err := global.DB.Find(&res).Error return res, err } // GetCategoryById 获取分类详情 func (s *CategoryService) GetCategoryById(id string) (*radio.RadioCategory, error) { var category radio.RadioCategory - err := global.DB.Where("id = ?", id).Preload("Icon").Preload("Cover").First(&category).Error + err := global.DB.Where("id = ?", id).First(&category).Error return &category, err } diff --git a/service/radio/channel_service.go b/service/radio/channel_service.go index b7d1ea5..27337bd 100644 --- a/service/radio/channel_service.go +++ b/service/radio/channel_service.go @@ -136,6 +136,7 @@ func (s *ChannelService) UpdateChannel(req radioReq.UpdateChannel) error { "description": req.Description, "cover": req.Cover, "tags": req.Tags, + "is_free": req.IsFree, "is_vip_only": req.IsVipOnly, "monthly_price": req.MonthlyPrice, "quarterly_price": req.QuarterlyPrice, diff --git a/service/radio/enter.go b/service/radio/enter.go index 0042d9d..933952e 100644 --- a/service/radio/enter.go +++ b/service/radio/enter.go @@ -9,6 +9,7 @@ type ServiceGroup struct { PayService OrderService VipService + TTSService } var GroupApp = new(ServiceGroup) diff --git a/service/radio/program_service.go b/service/radio/program_service.go index ce0e7c3..0777135 100644 --- a/service/radio/program_service.go +++ b/service/radio/program_service.go @@ -1,6 +1,7 @@ package radio import ( + "fmt" "sundynix-go/global" "sundynix-go/model/radio" radioReq "sundynix-go/model/radio/request" @@ -111,3 +112,32 @@ func (s *ProgramService) IncrementPlayCount(id string) error { return global.DB.Model(&radio.RadioProgram{}).Where("id = ?", id). UpdateColumn("play_count", gorm.Expr("play_count + ?", 1)).Error } + +// GenerateTTS 生成TTS语音并更新节目 (异步) +func (s *ProgramService) GenerateTTS(programId string) error { + // 1. 获取节目内容 + var program radio.RadioProgram + if err := global.DB.Where("id = ?", programId).First(&program).Error; err != nil { + return err + } + + if program.Content == "" { + return fmt.Errorf("节目内容为空") + } + + // 2. 调用TTS提交任务 (异步,后台处理) + ttsReq := TTSTextToSpeechRequest{ + Text: program.Content, + VoiceType: 101021, // 亲和女声 + Speed: 0, // 正常语速 + Volume: 0, // 正常音量 + ProgramId: programId, + } + _, err := TTSServiceApp.SubmitTTSTask(ttsReq) + if err != nil { + return err + } + + // 任务已提交,异步处理中 + return nil +} diff --git a/service/radio/tts_service.go b/service/radio/tts_service.go new file mode 100644 index 0000000..5468a32 --- /dev/null +++ b/service/radio/tts_service.go @@ -0,0 +1,283 @@ +package radio + +import ( + "crypto/md5" + "fmt" + "io" + "net/http" + "time" + + "sundynix-go/global" + "sundynix-go/model/radio" + "sundynix-go/model/system" + "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" +) + +// TTSService TTS服务 +type TTSService struct{} + +// TTSTextToSpeechRequest TTS请求参数 +type TTSTextToSpeechRequest struct { + Text string // 要转换的文本 + VoiceType int // 声音类型 + Speed int // 语速 -2到6 + Volume int // 音量 -10到10 + 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) { + 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 +} + +// 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)) + return + } + + // 从ResultUrl下载音频数据 + audioData, err := t.downloadAudio(resultUrl) + if err != nil { + global.Logger.Error(fmt.Sprintf("下载音频失败, TaskId: %s, Error: %v", taskId, err)) + return + } + + // 上传到OSS + audioId, err := t.uploadToOSS(audioData, programId) + if err != nil { + global.Logger.Error(fmt.Sprintf("上传OSS失败, TaskId: %s, Error: %v", taskId, err)) + return + } + + // 更新节目的audio_id + if err := global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId). + Update("audio_id", audioId).Error; err != nil { + global.Logger.Error(fmt.Sprintf("更新节目音频ID失败, TaskId: %s, Error: %v", taskId, err)) + 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 := 5 * 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: // 成功 + return resultUrl, nil + 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-执行中 +func (t *TTSService) queryTaskResult(taskId string) (string, int, string, error) { + client, err := t.newTTSClient() + if err != nil { + return "", 0, "", fmt.Errorf("创建TTS客户端失败: %v", err) + } + + request := tts.NewDescribeTtsTaskStatusRequest() + request.TaskId = common.StringPtr(taskId) + + response, err := client.DescribeTtsTaskStatus(request) + if err != nil { + return "", 0, "", fmt.Errorf("调用DescribeTtsTaskStatus失败: %v", err) + } + + if response.Response == nil || response.Response.Data == nil { + return "", 0, "", fmt.Errorf("查询任务状态返回为空") + } + + 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 + } + } + + resultUrl := "" + if data.ResultUrl != nil { + resultUrl = *data.ResultUrl + } + + return resultUrl, status, statusMsg, nil +} + +// downloadAudio 从URL下载音频数据 +func (t *TTSService) downloadAudio(audioUrl string) ([]byte, error) { + resp, err := http.Get(audioUrl) + if err != nil { + return nil, fmt.Errorf("下载音频失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("下载音频HTTP错误: %d", resp.StatusCode) + } + + audioData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取音频数据失败: %v", err) + } + + return audioData, nil +} + +// uploadToOSS 上传音频到OSS并保存到数据库 +func (t *TTSService) uploadToOSS(audioData []byte, programId string) (string, error) { + instance := upload.OssInstance() + minioClient, ok := instance.(*upload.Minio) + if !ok { + return "", fmt.Errorf("获取MinIO客户端失败") + } + + key := fmt.Sprintf("audio/%s/%s.mp3", time.Now().Format("2006-01-02"), programId) + filename := fmt.Sprintf("program-%s.mp3", programId) + + fileURL, err := minioClient.UploadBytes(audioData, key, "audio/mpeg") + if err != nil { + return "", fmt.Errorf("上传文件到OSS失败: %v", err) + } + + hashStr := fmt.Sprintf("%x", md5.Sum(audioData)) + + oss := system.Oss{ + Name: filename, + Url: fileURL, + Key: key, + Suffix: "mp3", + Tag: "mp3", + MD5: hashStr, + } + if err := global.DB.Create(&oss).Error; err != nil { + return "", fmt.Errorf("保存文件记录失败: %v", err) + } + + return oss.Id, nil +} diff --git a/service/radio/vip.go b/service/radio/vip.go index eac61cd..71ac231 100644 --- a/service/radio/vip.go +++ b/service/radio/vip.go @@ -17,9 +17,9 @@ type VipService struct{} func (s *VipService) UpdateVipConfig(req request.UpdateVipConfig) error { updateData := map[string]interface{}{ - "price": req.Price, - "discountedPrice": req.DiscountedPrice, - "remark": req.Remark, + "price": req.Price, + "discounted_price": req.DiscountedPrice, + "remark": req.Remark, } err := global.DB.Model(&radio.Vip{}).Where("id = ?", req.Id).Updates(updateData).Error return err diff --git a/service/system/sys_user.go b/service/system/sys_user.go index fc26cd3..e8b0162 100644 --- a/service/system/sys_user.go +++ b/service/system/sys_user.go @@ -100,7 +100,7 @@ func (userService *UserService) ChangePassword(id string, pwd string) (err error return global.DB.Model(&system.User{}).Where("id = ?", id).Update("password", utils.BcryptHash(pwd)).Error } -func (userService *UserService) MiniLogin(code string) (result *system.User, err error) { +func (userService *UserService) MiniLogin(code, ip string) (result *system.User, err error) { //构建参数 params := url2.Values{} params.Set("appid", global.Config.MiniProgram.AppId) @@ -150,9 +150,10 @@ func (userService *UserService) MiniLogin(code string) (result *system.User, err err = global.DB.Transaction(func(tx *gorm.DB) error { // 创建新用户 newUser := system.User{ - Name: uniqueid.GenerateRadioUsername(), - OpenId: wxResp.Openid, - SessionKey: wxResp.SessionKey, + Name: uniqueid.GenerateRadioUsername(), + OpenId: wxResp.Openid, + SessionKey: wxResp.SessionKey, + LastLoginIp: ip, } if err := tx.Create(&newUser).Error; err != nil { return err @@ -170,7 +171,11 @@ func (userService *UserService) MiniLogin(code string) (result *system.User, err } if err == nil && user.Id != "" { // UpdateColumn:只更新字段,不触发模型钩子,比Update更高效 - if err = global.DB.Model(&user).UpdateColumn("session_key", wxResp.SessionKey).Error; err != nil { + updateData := map[string]interface{}{ + "session_key": wxResp.SessionKey, + "last_login_ip": ip, + } + if err = global.DB.Model(&user).Updates(updateData).Error; err != nil { global.Logger.Error("更新session_key失败", zap.Error(err)) return nil, fmt.Errorf("更新session_key失败: %w", err) } diff --git a/utils/upload/minio_oss.go b/utils/upload/minio_oss.go index 07ae159..b901807 100644 --- a/utils/upload/minio_oss.go +++ b/utils/upload/minio_oss.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "mime/multipart" "path/filepath" @@ -104,3 +105,19 @@ func (m *Minio) DeleteFile(key string) error { err := m.Client.RemoveObject(ctx, m.bucket, key, minio.RemoveObjectOptions{}) return err } + +// UploadBytes 上传字节数据 +func (m *Minio) UploadBytes(data []byte, key, contentType string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*10) + defer cancel() + + buffer := bytes.NewReader(data) + info, err := m.Client.PutObject(ctx, m.bucket, key, buffer, int64(len(data)), minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return "", fmt.Errorf("上传文件到minio失败: %v", err) + } + + return fmt.Sprintf("%s/%s", global.Config.Minio.BucketUrl, info.Key), nil +}