diff --git a/api/v1/radio/enter.go b/api/v1/radio/enter.go index acdf051..aec691f 100644 --- a/api/v1/radio/enter.go +++ b/api/v1/radio/enter.go @@ -8,6 +8,7 @@ type ApiGroup struct { ProgramApi SubscriptionApi InteractionApi + PayApi } var ApiGroupApp = new(ApiGroup) @@ -18,4 +19,5 @@ var ( programService = service.GroupApp.RadioServiceGroup.ProgramService subscriptionService = service.GroupApp.RadioServiceGroup.SubscriptionService interactionService = service.GroupApp.RadioServiceGroup.InteractionService + payService = service.GroupApp.RadioServiceGroup.PayService ) diff --git a/api/v1/radio/pay.go b/api/v1/radio/pay.go new file mode 100644 index 0000000..4419017 --- /dev/null +++ b/api/v1/radio/pay.go @@ -0,0 +1,66 @@ +package radio + +import ( + "sundynix-go/global" + "sundynix-go/model/commom/response" + "sundynix-go/utils/auth" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type PayApi struct{} + +// PrePay 预支付 +// @Tags 微信支付 +// @Summary 支付 +// @Security ApiKeyAuth +// @Accept application/json +// @Produce application/json +// @Param orderId query string true "支付" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"支付成功"}" +// @Router /pay/prePay [get] +func (a *PayApi) PrePay(c *gin.Context) { + orderId := c.Query("orderId") + userId := auth.GetUserId(c) + res, err := payService.PrePay(orderId, userId) + if err != nil { + global.Logger.Error("支付失败", zap.Error(err)) + response.FailWithMsg("支付失败:"+err.Error(), c) + return + } + response.OkWithData(res, c) +} + +// QueryPay 查询支付 +// @Tags 微信支付 +// @Summary 支付 +// @Security BasicAuth +// @Produce application/json +// @Param orderId query string true "支付" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"支付成功"}" +// @Router /pay/query [get] +func (a *PayApi) QueryPay(c *gin.Context) { + outTradeNo := c.Query("outTradeNo") + userId := auth.GetUserId(c) + res, err := payService.QueryPay(outTradeNo, userId) + if err != nil { + global.Logger.Error("支付失败", zap.Error(err)) + response.FailWithMsg("支付失败:"+err.Error(), c) + return + } + response.OkWithData(res, c) +} + +// PayCallback 支付回调 +// @Tags 微信支付 +// @Summary 支付回调 +func (a *PayApi) PayCallback(c *gin.Context) { + err := payService.PayCallback(c) + if err != nil { + global.Logger.Error("支付回调失败", zap.Error(err)) + response.FailWithMsg("支付回调失败:"+err.Error(), c) + return + } + response.OkWithMsg("支付回调成功", c) +} diff --git a/api/v1/radio/subscription.go b/api/v1/radio/subscription.go index a824ce7..a8c7bd6 100644 --- a/api/v1/radio/subscription.go +++ b/api/v1/radio/subscription.go @@ -5,6 +5,7 @@ import ( common "sundynix-go/model/commom/request" "sundynix-go/model/commom/response" "sundynix-go/model/radio/request" + radioRes "sundynix-go/model/radio/response" "sundynix-go/utils/auth" "github.com/gin-gonic/gin" @@ -29,7 +30,6 @@ func (a *SubscriptionApi) GetSubscriptionList(c *gin.Context) { response.FailWithMsg("参数错误: "+err.Error(), c) return } - list, total, err := subscriptionService.GetUserSubscription(userId, req) if err != nil { global.Logger.Error("获取订阅列表失败!", zap.Error(err)) @@ -44,91 +44,30 @@ func (a *SubscriptionApi) GetSubscriptionList(c *gin.Context) { }, c) } -// CanSubscribe 检查是否可以订阅 +// UnlockChannel 解锁频道(拉起支付) // @Tags 订阅管理 -// @Summary 检查是否可以订阅 +// @Summary 解锁频道 +// @Param id query string true "id" +// @Param eventType query string true "eventType" // @Produce application/json -// @Param data body request.SubscribeChannel true "频道ID" // @Success 200 {object} response.Response -// @Router /radio/subscription/can-subscribe [post] -func (a *SubscriptionApi) CanSubscribe(c *gin.Context) { - userId := auth.GetUserId(c) - var req request.SubscribeChannel +// @Router /radio/subscription/unlock [post] +func (a *SubscriptionApi) UnlockChannel(c *gin.Context) { + var req request.UnlockChannel err := c.ShouldBindJSON(&req) if err != nil { response.FailWithMsg("参数错误: "+err.Error(), c) return } - - can, _, err := subscriptionService.CanSubscribe(userId, req.ChannelId) - if err != nil { - global.Logger.Error("检查订阅权限失败!", zap.Error(err)) - response.FailWithMsg(err.Error(), c) - return - } - - response.OkWithData(can, c) -} - -// Subscribe 订阅频道 -// @Tags 订阅管理 -// @Summary 订阅频道 -// @Produce application/json -// @Param data body request.SubscribeChannel true "频道ID" -// @Success 200 {object} response.Response -// @Router /radio/subscription/subscribe [post] -func (a *SubscriptionApi) Subscribe(c *gin.Context) { userId := auth.GetUserId(c) - var req request.SubscribeChannel - err := c.ShouldBindJSON(&req) + res, no, err := subscriptionService.UnlockChannel(userId, req) if err != nil { - response.FailWithMsg("参数错误: "+err.Error(), c) - return - } - - can, reason, err := subscriptionService.CanSubscribe(userId, req.ChannelId) - if err != nil { - global.Logger.Error("订阅失败!", zap.Error(err)) + global.Logger.Error("解锁频道失败!", zap.Error(err)) response.FailWithMsg(err.Error(), c) return } - - if !can { - response.FailWithMsg(reason, c) - return - } - - // 订阅类型 1:免费 - if err := subscriptionService.Subscribe(userId, req.ChannelId, 1); err != nil { - global.Logger.Error("订阅失败!", zap.Error(err)) - response.FailWithMsg(err.Error(), c) - return - } - - response.OkWithMsg("订阅成功", c) -} - -// Unsubscribe 退订频道 -// @Tags 订阅管理 -// @Summary 退订频道 -// @Produce application/json -// @Param data body request.UnsubscribeChannel true "频道ID" -// @Success 200 {object} response.Response -// @Router /radio/subscription/unsubscribe [post] -func (a *SubscriptionApi) Unsubscribe(c *gin.Context) { - userId := auth.GetUserId(c) - var req request.UnsubscribeChannel - err := c.ShouldBindJSON(&req) - if err != nil { - response.FailWithMsg("参数错误: "+err.Error(), c) - return - } - - if err := subscriptionService.Unsubscribe(userId, req.ChannelId); err != nil { - global.Logger.Error("退订失败!", zap.Error(err)) - response.FailWithMsg(err.Error(), c) - return - } - - response.OkWithMsg("退订成功", c) + response.OkWithData(radioRes.PrePayResult{ + Payments: res, + OutTradeNo: no, + }, c) } diff --git a/config-dev.yaml b/config-dev.yaml index 513f982..70a2fc1 100644 --- a/config-dev.yaml +++ b/config-dev.yaml @@ -12,7 +12,12 @@ jwt: issuer: sundynix signing-key: 9149f2eb-d517-4a50-a03a-231dbcf0d872 -# 电台微信小程序 +# 植趣微信小程序 用于测试支付 +#mini-program: +# app-id: wxb463820bf36dd5d6 +# app-secret: 731784a74c76c6d31fa00bb847af2c7d + +## 电台微信小程序 mini-program: app-id: wx52dfc635739a9c19 app-secret: 84c6ddab1f24d0222da57bedb681c81f @@ -25,7 +30,7 @@ wechat-pay: mch-api-v3-key: a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6 # 商户APIv3密钥 private-key-path: /Users/blizzard/privateFolder/cert/apiclient_key.pem # 商户APIv3密钥对应的私钥 public-key-path: /Users/blizzard/privateFolder/cert/pub_key.pem # 商户APIv3密钥对应的公钥 - notify-url: https://prod.sundynix.cn/api/wechatpay/notify # 微信支付结果通知回调地址 + notify-url: https://radio.sundynix.cn/api/wechatpay/notify # 微信支付结果通知回调地址 # MinIO 对象存储 (音频存储) minio: diff --git a/docs/docs.go b/docs/docs.go index 657d669..5fbe23b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1022,6 +1022,75 @@ const docTemplate = `{ } } }, + "/pay/prePay": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "微信支付" + ], + "summary": "支付", + "parameters": [ + { + "type": "string", + "description": "支付", + "name": "orderId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "{\"success\":true,\"data\":{},\"msg\":\"支付成功\"}", + "schema": { + "type": "string" + } + } + } + } + }, + "/pay/query": { + "get": { + "security": [ + { + "BasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "微信支付" + ], + "summary": "支付", + "parameters": [ + { + "type": "string", + "description": "支付", + "name": "orderId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "{\"success\":true,\"data\":{},\"msg\":\"支付成功\"}", + "schema": { + "type": "string" + } + } + } + } + }, "/radio/category/delete": { "post": { "produces": [ @@ -1274,6 +1343,39 @@ const docTemplate = `{ } } }, + "/radio/channel/freeList": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "频道管理" + ], + "summary": "获取频道列表", + "parameters": [ + { + "description": "分页查询", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.GetChannelList" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, "/radio/channel/list": { "post": { "consumes": [ @@ -1797,36 +1899,6 @@ const docTemplate = `{ } } }, - "/radio/subscription/can-subscribe": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "订阅管理" - ], - "summary": "检查是否可以订阅", - "parameters": [ - { - "description": "频道ID", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.SubscribeChannel" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, "/radio/subscription/list": { "post": { "consumes": [ @@ -1860,54 +1932,29 @@ const docTemplate = `{ } } }, - "/radio/subscription/subscribe": { - "post": { + "/radio/subscription/unlock": { + "get": { "produces": [ "application/json" ], "tags": [ "订阅管理" ], - "summary": "订阅频道", + "summary": "解锁频道", "parameters": [ { - "description": "频道ID", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.SubscribeChannel" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/radio/subscription/unsubscribe": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "订阅管理" - ], - "summary": "退订频道", - "parameters": [ + "type": "string", + "description": "id", + "name": "id", + "in": "query", + "required": true + }, { - "description": "频道ID", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.UnsubscribeChannel" - } + "type": "string", + "description": "eventType", + "name": "eventType", + "in": "query", + "required": true } ], "responses": { @@ -3057,18 +3104,6 @@ const docTemplate = `{ } } }, - "request.SubscribeChannel": { - "type": "object", - "required": [ - "channelId" - ], - "properties": { - "channelId": { - "description": "频道ID", - "type": "string" - } - } - }, "request.ToggleLike": { "type": "object", "required": [ @@ -3081,18 +3116,6 @@ const docTemplate = `{ } } }, - "request.UnsubscribeChannel": { - "type": "object", - "required": [ - "channelId" - ], - "properties": { - "channelId": { - "description": "频道ID", - "type": "string" - } - } - }, "request.UpdateCategory": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 60f8bf8..c752a3c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1015,6 +1015,75 @@ } } }, + "/pay/prePay": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "微信支付" + ], + "summary": "支付", + "parameters": [ + { + "type": "string", + "description": "支付", + "name": "orderId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "{\"success\":true,\"data\":{},\"msg\":\"支付成功\"}", + "schema": { + "type": "string" + } + } + } + } + }, + "/pay/query": { + "get": { + "security": [ + { + "BasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "微信支付" + ], + "summary": "支付", + "parameters": [ + { + "type": "string", + "description": "支付", + "name": "orderId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "{\"success\":true,\"data\":{},\"msg\":\"支付成功\"}", + "schema": { + "type": "string" + } + } + } + } + }, "/radio/category/delete": { "post": { "produces": [ @@ -1267,6 +1336,39 @@ } } }, + "/radio/channel/freeList": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "频道管理" + ], + "summary": "获取频道列表", + "parameters": [ + { + "description": "分页查询", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/request.GetChannelList" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Response" + } + } + } + } + }, "/radio/channel/list": { "post": { "consumes": [ @@ -1790,36 +1892,6 @@ } } }, - "/radio/subscription/can-subscribe": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "订阅管理" - ], - "summary": "检查是否可以订阅", - "parameters": [ - { - "description": "频道ID", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.SubscribeChannel" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, "/radio/subscription/list": { "post": { "consumes": [ @@ -1853,54 +1925,29 @@ } } }, - "/radio/subscription/subscribe": { - "post": { + "/radio/subscription/unlock": { + "get": { "produces": [ "application/json" ], "tags": [ "订阅管理" ], - "summary": "订阅频道", + "summary": "解锁频道", "parameters": [ { - "description": "频道ID", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.SubscribeChannel" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/radio/subscription/unsubscribe": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "订阅管理" - ], - "summary": "退订频道", - "parameters": [ + "type": "string", + "description": "id", + "name": "id", + "in": "query", + "required": true + }, { - "description": "频道ID", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.UnsubscribeChannel" - } + "type": "string", + "description": "eventType", + "name": "eventType", + "in": "query", + "required": true } ], "responses": { @@ -3050,18 +3097,6 @@ } } }, - "request.SubscribeChannel": { - "type": "object", - "required": [ - "channelId" - ], - "properties": { - "channelId": { - "description": "频道ID", - "type": "string" - } - } - }, "request.ToggleLike": { "type": "object", "required": [ @@ -3074,18 +3109,6 @@ } } }, - "request.UnsubscribeChannel": { - "type": "object", - "required": [ - "channelId" - ], - "properties": { - "channelId": { - "description": "频道ID", - "type": "string" - } - } - }, "request.UpdateCategory": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a562405..9b0cf4b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -381,14 +381,6 @@ definitions: - channelId - title type: object - request.SubscribeChannel: - properties: - channelId: - description: 频道ID - type: string - required: - - channelId - type: object request.ToggleLike: properties: programId: @@ -397,14 +389,6 @@ definitions: required: - programId type: object - request.UnsubscribeChannel: - properties: - channelId: - description: 频道ID - type: string - required: - - channelId - type: object request.UpdateCategory: properties: coverId: @@ -1261,6 +1245,48 @@ paths: summary: 文件上传 tags: - 文件相关 + /pay/prePay: + get: + consumes: + - application/json + parameters: + - description: 支付 + in: query + name: orderId + required: true + type: string + produces: + - application/json + responses: + "200": + description: '{"success":true,"data":{},"msg":"支付成功"}' + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 支付 + tags: + - 微信支付 + /pay/query: + get: + parameters: + - description: 支付 + in: query + name: orderId + required: true + type: string + produces: + - application/json + responses: + "200": + description: '{"success":true,"data":{},"msg":"支付成功"}' + schema: + type: string + security: + - BasicAuth: [] + summary: 支付 + tags: + - 微信支付 /radio/category/delete: post: parameters: @@ -1422,6 +1448,27 @@ paths: summary: 获取频道详情 tags: - 频道管理 + /radio/channel/freeList: + post: + consumes: + - application/json + parameters: + - description: 分页查询 + in: body + name: data + required: true + schema: + $ref: '#/definitions/request.GetChannelList' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Response' + summary: 获取频道列表 + tags: + - 频道管理 /radio/channel/list: post: consumes: @@ -1754,25 +1801,6 @@ paths: summary: 更新节目 tags: - 节目管理 - /radio/subscription/can-subscribe: - post: - parameters: - - description: 频道ID - in: body - name: data - required: true - schema: - $ref: '#/definitions/request.SubscribeChannel' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - summary: 检查是否可以订阅 - tags: - - 订阅管理 /radio/subscription/list: post: consumes: @@ -1794,15 +1822,19 @@ paths: summary: 获取我的订阅列表 tags: - 订阅管理 - /radio/subscription/subscribe: - post: + /radio/subscription/unlock: + get: parameters: - - description: 频道ID - in: body - name: data + - description: id + in: query + name: id required: true - schema: - $ref: '#/definitions/request.SubscribeChannel' + type: string + - description: eventType + in: query + name: eventType + required: true + type: string produces: - application/json responses: @@ -1810,26 +1842,7 @@ paths: description: OK schema: $ref: '#/definitions/response.Response' - summary: 订阅频道 - tags: - - 订阅管理 - /radio/subscription/unsubscribe: - post: - parameters: - - description: 频道ID - in: body - name: data - required: true - schema: - $ref: '#/definitions/request.UnsubscribeChannel' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - summary: 退订频道 + summary: 解锁频道 tags: - 订阅管理 /role/delete: diff --git a/initialize/gorm.go b/initialize/gorm.go index 0c61c15..3058a53 100644 --- a/initialize/gorm.go +++ b/initialize/gorm.go @@ -44,6 +44,8 @@ func MigrateTable() { radio.RadioProgram{}, radio.RadioSubscription{}, radio.RadioUser{}, + radio.Order{}, + radio.PayNotify{}, ) if err != nil { global.Logger.Error("Migrate table failed,err:", zap.Error(err)) diff --git a/initialize/router.go b/initialize/router.go index 65c9f76..0da9065 100644 --- a/initialize/router.go +++ b/initialize/router.go @@ -48,11 +48,12 @@ func Routers() { systemRouter.InitOssRouter(NeedAuthGroup) //OSS相关 // Radio模块路由 - radioRouter.InitCategoryRouter(NeedAuthGroup) //分类相关 - radioRouter.InitChannelRouter(NeedAuthGroup) //频道相关 - radioRouter.InitProgramRouter(NeedAuthGroup) //节目相关 - radioRouter.InitSubscriptionRouter(NeedAuthGroup) //订阅相关 - radioRouter.InitInteractionRouter(NeedAuthGroup) //用户互动相关 + radioRouter.InitCategoryRouter(NeedAuthGroup) //分类相关 + radioRouter.InitChannelRouter(NeedAuthGroup) //频道相关 + radioRouter.InitProgramRouter(NeedAuthGroup) //节目相关 + radioRouter.InitSubscriptionRouter(NeedAuthGroup) //订阅相关 + radioRouter.InitPayRouter(NeedAuthGroup, PublicGroup) //支付和回调 + radioRouter.InitInteractionRouter(NeedAuthGroup) //用户互动相关 } address := fmt.Sprintf(":%d", global.Config.System.Addr) diff --git a/model/radio/radio_category.go b/model/radio/radio_category.go index 4cb89da..466016d 100644 --- a/model/radio/radio_category.go +++ b/model/radio/radio_category.go @@ -2,20 +2,15 @@ package radio import ( "sundynix-go/global" - "sundynix-go/model/system" ) // RadioCategory 电台分类表 type RadioCategory struct { global.BaseModel - Name string `gorm:"size:50" json:"name"` // 分类名称 - Description string `gorm:"size:255" json:"description"` // 分类描述 - IconId string `gorm:"size:50" json:"iconId"` // 图标OSS ID - Icon *system.Oss `gorm:"foreignKey:IconId" json:"icon"` // 图标OSS - CoverId string `gorm:"size:50" json:"coverId"` // 封面图OSS ID - Cover *system.Oss `gorm:"foreignKey:CoverId" json:"cover"` // 封面图OSS - Sort int `gorm:"default:0" json:"sort"` // 排序 - Status int `gorm:"default:1" json:"status"` // 状态 0:禁用 1:启用 + Name string `gorm:"size:50" json:"name"` // 分类名称 + Description string `gorm:"size:255" json:"description"` // 分类描述 + Sort int `gorm:"default:0" json:"sort"` // 排序 + Status int `gorm:"default:1" json:"status"` // 状态 0:禁用 1:启用 Channels []*RadioChannel `gorm:"foreignKey:CategoryId" json:"channels"` } diff --git a/model/radio/radio_channel.go b/model/radio/radio_channel.go index 76730d7..b394119 100644 --- a/model/radio/radio_channel.go +++ b/model/radio/radio_channel.go @@ -2,7 +2,7 @@ package radio import ( "sundynix-go/global" - "sundynix-go/model/system" + "time" ) // RadioChannel 电台频道表 @@ -16,12 +16,12 @@ type RadioChannel struct { MonthlyPrice int `gorm:"default:0;comment:价格,单位,分 " json:"monthlyPrice"` //包月价格 单位:分 QuarterlyPrice int `gorm:"default:0;comment:价格,单位,分 " json:"quarterlyPrice"` //包季度价格 单位:分 AnnualPrice int `gorm:"default:0;comment:价格,单位,分 " json:"annualPrice"` //包年价格 单位:分 - CoverId string `gorm:"size:50" json:"coverId"` // 封面图OSS ID - Cover *system.Oss `gorm:"foreignKey:CoverId" json:"cover"` // 封面图OSS + Cover string `gorm:"size:100" json:"cover"` // 封面emoji Tags string `gorm:"size:255" json:"tags"` // 标签,逗号分隔 Sort int `gorm:"default:0" json:"sort"` // 排序 Status int `gorm:"default:1" json:"status"` // 状态 0:禁用 1:启用 HasSubscribed int `gorm:"-" json:"hasSubscribed"` // 状态 0:未订阅 1:已订阅 + ExpiredAt *time.Time `gorm:"-" json:"expiredAt"` // 新增:过期时间,使用指针方便处理 null Programs []*RadioProgram `gorm:"foreignKey:ChannelId" json:"programs"` //频道下的节目 } diff --git a/model/radio/radio_pay_notify.go b/model/radio/radio_pay_notify.go new file mode 100644 index 0000000..9cd5179 --- /dev/null +++ b/model/radio/radio_pay_notify.go @@ -0,0 +1,22 @@ +package radio + +import "sundynix-go/global" + +type PayNotify struct { + global.BaseModel + Amount int64 `json:"amount" gorm:"column:amount"` + Currency string `json:"currency" gorm:"column:currency"` + PayerCurrency string `json:"payer_currency" gorm:"column:payer_currency"` + PayerTotal int64 `json:"payer_total" gorm:"column:payer_total"` + Appid string `json:"appId" gorm:"column:app_id"` + Attach string `json:"attach" gorm:"column:attach"` + BankType string `json:"bank_type" gorm:"column:bank_type"` + MchId string `json:"mchId" gorm:"column:mch_id"` + OutTradeNo string `json:"out_trade_no" gorm:"column:out_trade_no"` + Payer string `json:"payer" gorm:"column:out_trade_no"` + SuccessTime string `json:"success_time" gorm:"column:success_time"` + TradeState string `json:"trade_state" gorm:"column:trade_state"` + TradeStateDesc string `json:"trade_state_desc" gorm:"column:trade_state_desc"` + TradeType string `json:"trade_type" gorm:"column:trade_type"` + TransactionId string `json:"transaction_id" gorm:"column:transaction_id"` +} diff --git a/model/radio/radio_program.go b/model/radio/radio_program.go index 9ffdeaf..218f8bc 100644 --- a/model/radio/radio_program.go +++ b/model/radio/radio_program.go @@ -12,8 +12,7 @@ type RadioProgram struct { Title string `gorm:"size:100" json:"title"` // 节目标题 Description string `gorm:"size:500" json:"description"` // 节目描述 Content string `gorm:"type:text" json:"content"` - CoverId string `gorm:"size:50" json:"coverId"` // 封面图OSS ID - Cover *system.Oss `gorm:"foreignKey:CoverId" json:"cover"` // 封面图OSS + 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 Duration int `gorm:"default:0" json:"duration"` // 时长(秒) diff --git a/model/radio/radio_subscription.go b/model/radio/radio_subscription.go index dfb943c..d1b7d58 100644 --- a/model/radio/radio_subscription.go +++ b/model/radio/radio_subscription.go @@ -2,14 +2,16 @@ package radio import ( "sundynix-go/global" + "time" ) // RadioSubscription 用户订阅表 type RadioSubscription struct { global.BaseModel - UserId string `gorm:"size:50;index;not null;uniqueIndex:idx_user_channel" json:"userId"` // 用户ID - ChannelId string `gorm:"size:50;index;uniqueIndex:idx_user_channel" json:"channelId"` // 频道ID - Status int `gorm:"type:tinyint;default:1"` //1-订阅中,2-已取消 + UserId string `gorm:"size:50;index;not null;" json:"userId"` // 用户ID + ChannelId string `gorm:"size:50;index;" json:"channelId"` // 频道ID + ExpiredAt time.Time `gorm:"index"` + Status int `gorm:"type:tinyint;default:1"` //1-订阅中,2-已到期 Channel *RadioChannel `gorm:"foreignKey:ChannelId" json:"channel"` } diff --git a/model/radio/radio_subscription_order.go b/model/radio/radio_subscription_order.go new file mode 100644 index 0000000..53b985e --- /dev/null +++ b/model/radio/radio_subscription_order.go @@ -0,0 +1,19 @@ +package radio + +import ( + "sundynix-go/global" + "sundynix-go/model/system" +) + +type Order struct { + global.BaseModel + OutTradeNo string `json:"outTradeNo" gorm:"index;column:out_trade_no;comment:商户订单号"` + UserId string `json:"userId" form:"userId" gorm:"index;column:user_id;comment:用户id"` + ChannelId string `gorm:"column:channel_id"` + SubscriptionType string `gorm:"column:sub_type;comment:1:月,2:季,3:年"` + Name string `json:"name" gorm:"column:name;comment:订单名称"` + Amount int `json:"amount" gorm:"column:amount;comment:金额分"` + Status int `json:"status" gorm:"column:status;comment:订单状态"` // 0:待支付 1:已支付 2:已关闭 + PayStatus string `json:"payStatus" gorm:"column:pay_status;comment:支付状态"` + User *system.User `json:"user" gorm:"foreignKey:UserId"` +} diff --git a/model/radio/request/category.go b/model/radio/request/category.go index 1636ee7..8b809c1 100644 --- a/model/radio/request/category.go +++ b/model/radio/request/category.go @@ -13,8 +13,6 @@ type GetCategoryList struct { type SaveCategory struct { Name string `json:"name" binding:"required"` // 分类名称 Description string `json:"description"` // 分类描述 - IconId string `json:"iconId"` // 图标URL - CoverId string `json:"coverId"` // 封面图URL Sort int `json:"sort"` // 排序 Status int `json:"status"` // 状态 } @@ -24,8 +22,6 @@ type UpdateCategory struct { Id string `json:"id" binding:"required"` // 分类ID Name string `json:"name"` // 分类名称 Description string `json:"description"` // 分类描述 - IconId string `json:"iconId"` // 图标URL - CoverId string `json:"coverId"` // 封面图URL Sort int `json:"sort"` // 排序 Status int `json:"status"` // 状态 } diff --git a/model/radio/request/channel.go b/model/radio/request/channel.go index 925a243..7c78346 100644 --- a/model/radio/request/channel.go +++ b/model/radio/request/channel.go @@ -15,7 +15,7 @@ type SaveChannel struct { CategoryId string `json:"categoryId" binding:"required"` // 分类ID Name string `json:"name" binding:"required"` // 频道名称 Description string `json:"description"` // 频道描述 - CoverId string `json:"coverId"` // 封面图URL + Cover string `json:"cover"` // 封面图URL Tags string `json:"tags"` // 标签 IsFree int `json:"isFree"` //是否永久免费 IsVipOnly int `json:"isVipOnly"` //是否vip专享 @@ -32,7 +32,7 @@ type UpdateChannel struct { CategoryId string `json:"categoryId"` // 分类ID Name string `json:"name"` // 频道名称 Description string `json:"description"` // 频道描述 - CoverId string `json:"coverId"` // 封面图URL + Cover string `json:"cover"` // 封面图URL Tags string `json:"tags"` // 标签 IsFree int `json:"isFree"` IsVipOnly int `json:"isVipOnly"` //是否vip专享 @@ -42,3 +42,8 @@ type UpdateChannel struct { Sort int `json:"sort"` // 排序 Status int `json:"status"` // 状态 } + +type UnlockChannel struct { + ChannelId string `json:"channelId" binding:"required"` + EventType string `json:"type" binding:"required"` // 1 月 2 季 3 年 +} diff --git a/model/radio/request/program.go b/model/radio/request/program.go index 8d538a9..43eeb42 100644 --- a/model/radio/request/program.go +++ b/model/radio/request/program.go @@ -16,7 +16,7 @@ type SaveProgram struct { Title string `json:"title" binding:"required"` // 节目标题 Description string `json:"description"` // 节目描述 Content string `json:"content"` - CoverId string `json:"coverId"` // 封面图URL + Cover string `json:"cover"` // 封面图URL AudioId string `json:"audioId"` // 音频URL Duration int `json:"duration"` // 时长(秒) Tags string `json:"tags"` // 标签 @@ -30,7 +30,7 @@ type UpdateProgram struct { Title string `json:"title"` // 节目标题 Description string `json:"description"` // 节目描述 Content string `json:"content"` - CoverId string `json:"coverId"` // 封面图URL + Cover string `json:"cover"` // 封面图URL AudioId string `json:"audioId"` // 音频URL Duration int `json:"duration"` // 时长(秒) Tags string `json:"tags"` // 标签 diff --git a/model/radio/response/category.go b/model/radio/response/category.go index 2e0cf4d..1dffcf3 100644 --- a/model/radio/response/category.go +++ b/model/radio/response/category.go @@ -1,7 +1,5 @@ package response -import "sundynix-go/model/radio" - // CategoryResponse 分类响应 type CategoryResponse struct { Id string `json:"id"` @@ -23,21 +21,3 @@ type CategoryDetailResponse struct { Sort int `json:"sort"` Status int `json:"status"` } - -// ToCategoryResponse 转换为分类响应 -func ToCategoryResponse(category *radio.RadioCategory) CategoryResponse { - resp := CategoryResponse{ - Id: category.Id, - Name: category.Name, - Description: category.Description, - Sort: category.Sort, - Status: category.Status, - } - if category.Icon != nil { - resp.Icon = category.Icon.Url - } - if category.Cover != nil { - resp.CoverUrl = category.Cover.Url - } - return resp -} diff --git a/model/radio/response/pay.go b/model/radio/response/pay.go new file mode 100644 index 0000000..578f516 --- /dev/null +++ b/model/radio/response/pay.go @@ -0,0 +1,8 @@ +package response + +import "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" + +type PrePayResult struct { + Payments *jsapi.PrepayWithRequestPaymentResponse `json:"payments"` + OutTradeNo string `json:"outTradeNo"` +} diff --git a/router/radio/enter.go b/router/radio/enter.go index b046952..ffdcca4 100644 --- a/router/radio/enter.go +++ b/router/radio/enter.go @@ -8,6 +8,7 @@ type RadioRouterGroup struct { ProgramRouter SubscriptionRouter InteractionRouter + PayRouter } var GroupApp = new(RadioRouterGroup) @@ -18,4 +19,5 @@ var ( programApi = v1.ApiGroupApp.RadioApiGroup.ProgramApi subscriptionApi = v1.ApiGroupApp.RadioApiGroup.SubscriptionApi interactionApi = v1.ApiGroupApp.RadioApiGroup.InteractionApi + payApi = v1.ApiGroupApp.RadioApiGroup.PayApi ) diff --git a/router/radio/pay_router.go b/router/radio/pay_router.go new file mode 100644 index 0000000..17d5fc0 --- /dev/null +++ b/router/radio/pay_router.go @@ -0,0 +1,16 @@ +package radio + +import "github.com/gin-gonic/gin" + +type PayRouter struct{} + +func (c *PayRouter) InitPayRouter(PrivateRouter *gin.RouterGroup, PublicRouter *gin.RouterGroup) { + payRouter := PrivateRouter.Group("pay") + payCallbackRouter := PublicRouter.Group("wechatpay") + { + payRouter.GET("prePay", payApi.PrePay) + payRouter.GET("query", payApi.QueryPay) + payCallbackRouter.POST("notify", payApi.PayCallback) //支付结果回调 + } + +} diff --git a/router/radio/subscription_router.go b/router/radio/subscription_router.go index e0d6d64..ca321d7 100644 --- a/router/radio/subscription_router.go +++ b/router/radio/subscription_router.go @@ -10,8 +10,6 @@ func (r *SubscriptionRouter) InitSubscriptionRouter(Router *gin.RouterGroup) { subscriptionRouter := Router.Group("radio/subscription") { subscriptionRouter.POST("list", subscriptionApi.GetSubscriptionList) - subscriptionRouter.POST("can-subscribe", subscriptionApi.CanSubscribe) - subscriptionRouter.POST("subscribe", subscriptionApi.Subscribe) - subscriptionRouter.POST("unsubscribe", subscriptionApi.Unsubscribe) + subscriptionRouter.POST("unlock", subscriptionApi.UnlockChannel) } } diff --git a/service/radio/category_service.go b/service/radio/category_service.go index 8522160..969acfd 100644 --- a/service/radio/category_service.go +++ b/service/radio/category_service.go @@ -69,8 +69,6 @@ func (s *CategoryService) SaveCategory(req radioReq.SaveCategory) error { category := radio.RadioCategory{ Name: req.Name, Description: req.Description, - IconId: req.IconId, - CoverId: req.CoverId, Sort: req.Sort, Status: req.Status, } @@ -82,8 +80,6 @@ func (s *CategoryService) UpdateCategory(req radioReq.UpdateCategory) error { updates := map[string]interface{}{ "name": req.Name, "description": req.Description, - "icon_id": req.IconId, - "cover_id": req.CoverId, "sort": req.Sort, "status": req.Status, } diff --git a/service/radio/channel_service.go b/service/radio/channel_service.go index e3ca290..5f6d627 100644 --- a/service/radio/channel_service.go +++ b/service/radio/channel_service.go @@ -6,7 +6,9 @@ import ( common "sundynix-go/model/commom/request" "sundynix-go/model/radio" radioReq "sundynix-go/model/radio/request" + "time" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -55,22 +57,56 @@ func (s *ChannelService) GetChannelList(userId string, info radioReq.GetChannelL if err != nil { return nil, 0, err } + //查询用户的订阅频道 + now := time.Now() + var subIds []string + err = global.DB.Model(&radio.RadioSubscription{}). + Where("user_id = ?", userId). + Where("status = ?", 1). // 建议将常量也参数化,提高安全性 + Where("expired_at > ?", now). + Pluck("channel_id", &subIds). + Error + // 使用map + subMap := make(map[string]bool) + for _, id := range subIds { + subMap[id] = true + } + for i := range list { + list[i].HasSubscribed = 0 + if subMap[list[i].Id] { + list[i].HasSubscribed = 1 + } + } return list, total, nil } -func (s *ChannelService) GetAllChannelList(categoryId, userId string) ([]radio.RadioChannel, error) { - var res []radio.RadioChannel - err := global.DB.Where("category_id = ?", categoryId).Find(&res).Preload("Cover").Error - return res, err -} - // GetChannelById 获取频道详情 func (s *ChannelService) GetChannelById(userId, id string) (radio.RadioChannel, error) { var channel radio.RadioChannel - err := global.DB.Where("id = ?", id).Preload("Cover").First(&channel).Error + err := global.DB.Where("id = ?", id).First(&channel).Error if err != nil { return channel, err } + if channel.IsFree == 1 { + return channel, nil + } + channel.HasSubscribed = 0 + if userId != "" { + var sub radio.RadioSubscription + err = global.DB.Model(&radio.RadioSubscription{}). + Where("user_id = ?", userId). + Where("channel_id = ?", id). + Where("status = ?", 1). + Where("expired_at > ?", time.Now()). + First(&sub).Error + if err != nil { + // 记录日志但不返回错误,避免影响主流程 + global.Logger.Warn("query subscription status failed", zap.Error(err)) + return channel, nil + } + channel.HasSubscribed = 1 + channel.ExpiredAt = &sub.ExpiredAt + } return channel, nil } @@ -80,7 +116,7 @@ func (s *ChannelService) SaveChannel(req radioReq.SaveChannel) error { CategoryId: req.CategoryId, Name: req.Name, Description: req.Description, - CoverId: req.CoverId, + Cover: req.Cover, Tags: req.Tags, IsVipOnly: req.IsVipOnly, MonthlyPrice: req.MonthlyPrice, @@ -98,7 +134,7 @@ func (s *ChannelService) UpdateChannel(req radioReq.UpdateChannel) error { "category_id": req.CategoryId, "name": req.Name, "description": req.Description, - "cover_id": req.CoverId, + "cover": req.Cover, "tags": req.Tags, "is_vip_only": req.IsVipOnly, "monthly_price": req.MonthlyPrice, diff --git a/service/radio/enter.go b/service/radio/enter.go index a5d9df8..5291b7b 100644 --- a/service/radio/enter.go +++ b/service/radio/enter.go @@ -6,6 +6,8 @@ type ServiceGroup struct { ProgramService SubscriptionService InteractionService + PayService + OrderService } var GroupApp = new(ServiceGroup) diff --git a/service/radio/order_service.go b/service/radio/order_service.go new file mode 100644 index 0000000..e500171 --- /dev/null +++ b/service/radio/order_service.go @@ -0,0 +1,72 @@ +package radio + +import ( + "errors" + "sundynix-go/model/radio" + "time" + + "gorm.io/gorm" +) + +type OrderService struct{} + +var OrderServiceApp = new(OrderService) + +// ExecuteOrderUnlock 核心原子操作:解锁权限 +func (s *OrderService) ExecuteOrderUnlock(tx *gorm.DB, outTradeNo string) error { + var order radio.Order + // 1. 锁住订单行,防止回调和主动查询并发导致时长翻倍 + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("out_trade_no = ?", outTradeNo).First(&order).Error; err != nil { + return err + } + + // 2. 幂等检查 + if order.Status == 1 { + return nil // 已处理,直接返回 + } + + // 3. 更新订单状态 + if err := tx.Model(&order).Updates(map[string]interface{}{ + "status": 1, + "pay_status": "SUCCESS", + }).Error; err != nil { + return err + } + + // 4. 根据订单中的 sub_type 决定增加几个月 + var months int + switch order.SubscriptionType { + case "1": + months = 1 // 月 + case "2": + months = 3 // 季 + case "3": + months = 12 // 年 + } + + // 5. 更新或创建订阅权限 + var sub radio.RadioSubscription + now := time.Now() + err := tx.Where("user_id = ? AND channel_id = ?", order.UserId, order.ChannelId).First(&sub).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + // 首次订阅 + return tx.Create(&radio.RadioSubscription{ + UserId: order.UserId, + ChannelId: order.ChannelId, + ExpiredAt: now.AddDate(0, months, 0), + Status: 1, + }).Error + } else { + // 续费逻辑 + newExpiredAt := sub.ExpiredAt + if sub.ExpiredAt.Before(now) { + newExpiredAt = now // 已过期,从现在开始往后加 + } + return tx.Model(&sub).Updates(map[string]interface{}{ + "expired_at": newExpiredAt.AddDate(0, months, 0), + "status": 1, + }).Error + } +} diff --git a/service/radio/pay_service.go b/service/radio/pay_service.go new file mode 100644 index 0000000..1d0039a --- /dev/null +++ b/service/radio/pay_service.go @@ -0,0 +1,204 @@ +package radio + +import ( + "context" + "net/http" + "sundynix-go/global" + "sundynix-go/model/radio" + "sundynix-go/model/system" + "sundynix-go/utils/wechat" + + "github.com/gin-gonic/gin" + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" + "github.com/wechatpay-apiv3/wechatpay-go/utils" + "go.uber.org/zap" + "gorm.io/gorm" +) + +type PayService struct{} + +var PayServiceApp = new(PayService) + +// PrePay 预支付 +func (s *PayService) PrePay(orderId, userId string) (resp *jsapi.PrepayWithRequestPaymentResponse, err error) { + //1.查询订单和 用户 + var order radio.Order + err = global.DB.Where("id = ?", orderId).First(&order).Error + if err != nil { + return nil, err + } + var user system.User + err = global.DB.Where("id = ?", userId).First(&user).Error + if err != nil { + return nil, err + } + payClient, err := wechat.GetWxPayClient() + if err != nil { + return nil, err + } + + svc := jsapi.JsapiApiService{Client: payClient} + result, _, err := svc.PrepayWithRequestPayment(context.Background(), + jsapi.PrepayRequest{ + Appid: core.String(global.Config.MiniProgram.AppId), + Mchid: core.String(global.Config.WechatPay.MchId), + Description: core.String(order.Name), + OutTradeNo: core.String(order.OutTradeNo), + //TimeExpire: core.Time(time.Now()), //选填 + //Attach: core.String("自定义数据说明"), //选填 + NotifyUrl: core.String(global.Config.WechatPay.NotifyUrl), + //GoodsTag: core.String("WXG"), //选填 + //SupportFapiao: core.Bool(false), //选填 + Amount: &jsapi.Amount{ + Currency: core.String("CNY"), + Total: core.Int64(int64(order.Amount)), + }, + Payer: &jsapi.Payer{ + Openid: core.String(user.MiniOpenId), + }, + //Detail: &jsapi.Detail{ + // CostPrice: core.Int64(608800), + // GoodsDetail: []jsapi.GoodsDetail{jsapi.GoodsDetail{ + // GoodsName: core.String("iPhoneX 256G"), + // MerchantGoodsId: core.String("ABC"), + // Quantity: core.Int64(1), + // UnitPrice: core.Int64(828800), + // WechatpayGoodsId: core.String("1001"), + // }}, + // InvoiceId: core.String("wx123"), + //}, //选填 + //SceneInfo: &jsapi.SceneInfo{ + // DeviceId: core.String("013467007045764"), + // PayerClientIp: core.String("14.23.150.211"), + // StoreInfo: &jsapi.StoreInfo{ + // Address: core.String("广东省深圳市南山区科技中一道10000号"), + // AreaCode: core.String("440305"), + // Id: core.String("0001"), + // Name: core.String("腾讯大厦分店"), + // }, + //}, + //SettleInfo: &jsapi.SettleInfo{ + // ProfitSharing: core.Bool(false), + //}, //选填 + }) + if err != nil { + return nil, err + } + + return result, nil + +} + +// PayCallback 支付回调 +func (s *PayService) PayCallback(c *gin.Context) error { + //1.加载共钥 + mchPublicKeyPath := global.Config.WechatPay.PublicKeyPath + mchPublicKey, err := utils.LoadPublicKeyWithPath(mchPublicKeyPath) + if err != nil { + return err + } + ctx := context.Background() + //2.创建客户端 + handler, err := notify.NewRSANotifyHandler(global.Config.WechatPay.MchAPIv3Key, + verifiers.NewSHA256WithRSAPubkeyVerifier(global.Config.WechatPay.PublicKeyId, *mchPublicKey)) + if err != nil { + return err + } + //3.验签 解密 + //将支付回调通知中的内容,解析为 payments.Transaction。 + transaction := new(payments.Transaction) + notifyReq, err := handler.ParseNotifyRequest(ctx, c.Request, transaction) + // 4.如果验签未通过,或者解密失败 + if err != nil { + //应答微信 + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "code": "FAIL", + "message": "失败", + }) + global.Logger.Error("wxPay回调-验签或解密:", zap.Error(err)) + return err + } + //5.应答微信 + c.Status(http.StatusOK) + // 6.处理通知内容 + global.Logger.Info("wxPay回调-成功:", zap.Any("notifyReq", notifyReq.Summary)) + //7. 异步处理数据 + go func() { + err = global.DB.Transaction(func(tx *gorm.DB) error { + //7.1 回调记录 + payNotify := radio.PayNotify{ + Amount: *transaction.Amount.Total, + Currency: *transaction.Amount.Currency, + PayerCurrency: *transaction.Amount.PayerCurrency, + PayerTotal: *transaction.Amount.PayerTotal, + Appid: *transaction.Appid, + MchId: *transaction.Mchid, + OutTradeNo: *transaction.OutTradeNo, + Attach: *transaction.Attach, + BankType: *transaction.BankType, + Payer: *transaction.Payer.Openid, + SuccessTime: *transaction.SuccessTime, + TradeState: *transaction.TradeState, + TradeStateDesc: *transaction.TradeStateDesc, + TradeType: *transaction.TradeType, + TransactionId: *transaction.TransactionId, + } + err = global.DB.Create(&payNotify).Error + if err != nil { + global.Logger.Error("wxPay回调-存储数据异常:", zap.Error(err)) + } + if payNotify.TradeState == "SUCCESS" { + return OrderServiceApp.ExecuteOrderUnlock(tx, *transaction.OutTradeNo) + } + if err != nil { + return err + } + return nil + }) + + }() + return nil + +} + +// QueryPay 根据商户订单号查询订单状态 +func (s *PayService) QueryPay(no string, userId string) (bool, error) { + var order radio.Order + if err := global.DB.Where("out_trade_no = ?", no).First(&order).Error; err != nil { + return false, err + } + // 1. 如果本地已经成功,直接返回成功 + if order.Status == 1 { + return true, nil + } + //2.本地还是待支付,主动调用微信 API 查询 + payClient, err := wechat.GetWxPayClient() + if err != nil { + return false, err + } + svc := jsapi.JsapiApiService{Client: payClient} + resp, _, err := svc.QueryOrderByOutTradeNo(context.Background(), jsapi.QueryOrderByOutTradeNoRequest{ + OutTradeNo: core.String(no), + Mchid: core.String(global.Config.WechatPay.MchId), + }) + if err != nil { + return false, err + } + global.Logger.Info("查询订单状态:", zap.Any("resp", resp)) + state := *resp.TradeState + outTradeNo := *resp.OutTradeNo + if state == "SUCCESS" { + // 3. 微信那边付过了,本地还没解锁,立即执行事务 + err = global.DB.Transaction(func(tx *gorm.DB) error { + return OrderServiceApp.ExecuteOrderUnlock(tx, outTradeNo) + }) + if err != nil { + return false, err + } + } + return true, nil +} diff --git a/service/radio/program_service.go b/service/radio/program_service.go index 0693121..a4c93fc 100644 --- a/service/radio/program_service.go +++ b/service/radio/program_service.go @@ -12,7 +12,7 @@ type ProgramService struct{} // GetProgramList 获取节目列表 func (s *ProgramService) GetProgramList(info radioReq.GetProgramList) ([]radio.RadioProgram, int64, error) { - db := global.DB.Model(&radio.RadioProgram{}).Preload("Cover").Preload("Audio") + db := global.DB.Model(&radio.RadioProgram{}).Preload("Audio") var list []radio.RadioProgram var total int64 @@ -39,18 +39,22 @@ func (s *ProgramService) GetProgramList(info radioReq.GetProgramList) ([]radio.R // GetProgramById 获取节目详情 func (s *ProgramService) GetProgramById(id string) (*radio.RadioProgram, error) { var program radio.RadioProgram - err := global.DB.Where("id = ?", id).Preload("Cover").Preload("Audio").First(&program).Error + err := global.DB.Where("id = ?", id).Preload("Audio").First(&program).Error return &program, err } // SaveProgram 保存节目 func (s *ProgramService) SaveProgram(req radioReq.SaveProgram) error { + var channel radio.RadioChannel + if err := global.DB.Where("id = ?", req.ChannelId).First(&channel).Error; err != nil { + return err + } program := radio.RadioProgram{ ChannelId: req.ChannelId, Title: req.Title, Description: req.Description, Content: req.Content, - CoverId: req.CoverId, + Cover: channel.Cover, AudioId: req.AudioId, Duration: req.Duration, Tags: req.Tags, @@ -66,7 +70,7 @@ func (s *ProgramService) UpdateProgram(req radioReq.UpdateProgram) error { "title": req.Title, "description": req.Description, "content": req.Content, - "cover_id": req.CoverId, + "cover": req.Cover, "audio_id": req.AudioId, "duration": req.Duration, "tags": req.Tags, diff --git a/service/radio/subscription_service.go b/service/radio/subscription_service.go index 7ef511e..0ec9083 100644 --- a/service/radio/subscription_service.go +++ b/service/radio/subscription_service.go @@ -1,19 +1,22 @@ package radio import ( + "context" "errors" "sundynix-go/global" common "sundynix-go/model/commom/request" "sundynix-go/model/radio" - "time" + "sundynix-go/model/radio/request" + "sundynix-go/utils/uniqueid" + "sundynix-go/utils/wechat" + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" "gorm.io/gorm" ) type SubscriptionService struct{} -const MaxFreeSubscription = 2 - // GetUserSubscription 获取用户订阅列表 func (s *SubscriptionService) GetUserSubscription(userId string, info common.PageInfo) ([]radio.RadioSubscription, int64, error) { var subscriptions []radio.RadioSubscription @@ -34,7 +37,6 @@ func (s *SubscriptionService) GetUserSubscription(userId string, info common.Pag Order("created_at DESC"). // 级联加载频道及其封面 Preload("Channel"). - Preload("Channel.Cover"). // 关键:子查询过滤——只预加载每个频道 ID 最大的那一条节目 Preload("Channel.Programs", func(db *gorm.DB) *gorm.DB { // 子查询:找到每个频道下 ID 最大的节目(通常 ID 越大代表越新,也可以用 CreatedAt) @@ -44,9 +46,7 @@ func (s *SubscriptionService) GetUserSubscription(userId string, info common.Pag Where("created_at = (SELECT MAX(created_at) FROM sundynix_radio_program AS rp WHERE rp.channel_id = sundynix_radio_program.channel_id AND rp.status = 1)") // 嵌套预加载:节目里的音频和封面也一并带出来 - return db.Where("id IN (?)", subQuery). - Preload("Cover"). - Preload("Audio") + return db.Where("id IN (?)", subQuery).Preload("Audio") }). Find(&subscriptions).Error @@ -56,115 +56,76 @@ func (s *SubscriptionService) GetUserSubscription(userId string, info common.Pag return subscriptions, total, err } -// GetUserSubscriptionHistory 获取用户历史订阅过的频道ID列表 -func (s *SubscriptionService) GetUserSubscriptionHistory(userId string) ([]string, error) { - var channelIds []string - err := global.DB.Model(&radio.RadioSubscription{}). - Where("user_id = ?", userId). - Pluck("channel_id", &channelIds).Error - return channelIds, err -} - -// HasEverSubscribed 检查用户是否曾经订阅过该频道(包括已取消的) -func (s *SubscriptionService) HasEverSubscribed(userId, channelId string) (bool, error) { - var count int64 - // 使用Unscoped查询包括已软删除的记录 - err := global.DB.Unscoped().Model(&radio.RadioSubscription{}). - Where("user_id = ? AND channel_id = ?", userId, channelId). - Count(&count).Error - return count > 0, err -} - -// HasSubscription 检查用户当前是否订阅该频道(未取消的) -func (s *SubscriptionService) HasSubscription(userId, channelId string) (bool, error) { - var count int64 - err := global.DB.Model(&radio.RadioSubscription{}). - Where("user_id = ? AND channel_id = ?", userId, channelId). - Count(&count).Error - return count > 0, err -} - -// CanSubscribe 检查是否可以订阅 -// 规则: -// 1. 如果用户是VIP且未过期,可以订阅任意频道 -// 2. 如果用户曾经订阅过该频道(取消后再订阅),可以免费订阅 -// 3. 否则检查当前有效订阅数量是否达到上限(2个) -func (s *SubscriptionService) CanSubscribe(userId, channelId string) (bool, string, error) { - // 检查是否已经是订阅用户(未取消的订阅) - var existing radio.RadioSubscription - err := global.DB.Where("user_id = ? AND channel_id = ?", userId, channelId).First(&existing).Error - if err == nil { - return false, "您已订阅该频道", nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return false, "", err - } - - // 获取radio_user检查VIP状态 - var radioUser radio.RadioUser - err = global.DB.Where("user_id = ?", userId).First(&radioUser).Error - if err == nil && radioUser.IsVip == 1 { - // 检查VIP是否过期 - if radioUser.VipExpireAt != nil && *radioUser.VipExpireAt > time.Now().Unix() { - return true, "", nil - } - } - - // 检查用户是否曾经订阅过该频道(取消后又订阅的情况) - hasEverSubscribed, err := s.HasEverSubscribed(userId, channelId) +// UnlockChannel 解锁频道 +func (s *SubscriptionService) UnlockChannel(userId string, req request.UnlockChannel) (resp *jsapi.PrepayWithRequestPaymentResponse, no string, err error) { + //1.查询频道 + var channel radio.RadioChannel + err = global.DB.Where("id = ?", req.ChannelId).First(&channel).Error if err != nil { - return false, "", err + return nil, "", err } - if hasEverSubscribed { - // 曾今订阅过,可以免费再次订阅 - return true, "", nil + //2.创建一个订单 根据eventType 创建不同的订单 + var price int + var orderName string + switch req.EventType { + case "1": + price = channel.MonthlyPrice //包月 + orderName = channel.Name + " - 月度订阅" + case "2": + price = channel.QuarterlyPrice //包季 + orderName = channel.Name + " - 季度订阅" + case "3": + price = channel.AnnualPrice //包年 + orderName = channel.Name + " - 年度订阅" + default: + return nil, "", errors.New("无效的订阅类型") } - - // 非VIP用户,检查当前有效订阅数量(排除已取消的) - var count int64 - err = global.DB.Model(&radio.RadioSubscription{}).Where("user_id = ?", userId).Count(&count).Error + order := radio.Order{ + UserId: userId, + OutTradeNo: uniqueid.GenOrderNo(), + ChannelId: req.ChannelId, + SubscriptionType: req.EventType, + Amount: price, + Name: orderName, + } + err = global.DB.Create(&order).Error if err != nil { - return false, "", err + return nil, "", err } - if count >= MaxFreeSubscription { - return false, "免费订阅数量已达上限(2个),请开通VIP或订阅付费频道", nil - } - - return true, "", nil -} - -// Subscribe 订阅频道 -func (s *SubscriptionService) Subscribe(userId, channelId string, subType int) error { - subscription := radio.RadioSubscription{ - UserId: userId, - ChannelId: channelId, - } - return global.DB.Create(&subscription).Error -} - -// Unsubscribe 退订频道(逻辑删除,更新删除时间表示已取消) -func (s *SubscriptionService) Unsubscribe(userId, channelId string) error { - // 软删除:将DeletedAt设置为当前时间,表示已取消订阅 - // 这样用户可以再次免费订阅该频道 - return global.DB.Model(&radio.RadioSubscription{}). - Where("user_id = ? AND channel_id = ?", userId, channelId). - Update("deleted_at", time.Now()).Error -} - -// GetVipStatus 获取VIP状态 -func (s *SubscriptionService) GetVipStatus(userId string) (bool, int64, error) { - var radioUser radio.RadioUser - err := global.DB.Where("user_id = ?", userId).First(&radioUser).Error + //4.调用微信api 拉起支付 + var user radio.RadioUser + err = global.DB.Where("user_id = ?", userId).First(&user).Error if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, 0, nil - } - return false, 0, err + return nil, "", err + } + payClient, err := wechat.GetWxPayClient() + if err != nil { + return nil, "", err } - if radioUser.IsVip == 1 && radioUser.VipExpireAt != nil && *radioUser.VipExpireAt > time.Now().Unix() { - return true, *radioUser.VipExpireAt, nil + svc := jsapi.JsapiApiService{Client: payClient} + result, _, err := svc.PrepayWithRequestPayment(context.Background(), + jsapi.PrepayRequest{ + Appid: core.String(global.Config.MiniProgram.AppId), + Mchid: core.String(global.Config.WechatPay.MchId), + Description: core.String(order.Name), + OutTradeNo: core.String(order.OutTradeNo), + //TimeExpire: core.Time(time.Now()), //选填 + //Attach: core.String("自定义数据说明"), //选填 + NotifyUrl: core.String(global.Config.WechatPay.NotifyUrl), + //GoodsTag: core.String("WXG"), //选填 + //SupportFapiao: core.Bool(false), //选填 + Amount: &jsapi.Amount{ + Currency: core.String("CNY"), + Total: core.Int64(int64(price)), + }, + Payer: &jsapi.Payer{ + Openid: core.String(user.OpenId), + }, + }) + if err != nil { + return nil, "", err } - return false, 0, nil + return result, order.OutTradeNo, nil } diff --git a/utils/timer/interval.go b/utils/timer/interval.go index 3951722..fda5ff2 100644 --- a/utils/timer/interval.go +++ b/utils/timer/interval.go @@ -46,3 +46,11 @@ func GetZeroTime() time.Time { zeroTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) return zeroTime } + +// GetMaxTime 获取当天最大时间 +func GetMaxTime() time.Time { + now := time.Now() + // 2. 使用当天的年月日,将时分秒纳秒设为0,并保留原时区(Location) + maxTime := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, time.Local) + return maxTime +}