diff --git a/app/constants/common/common.go b/app/constants/common/common.go index 0f7a798..1f9eaeb 100644 --- a/app/constants/common/common.go +++ b/app/constants/common/common.go @@ -5,10 +5,13 @@ const ( TOKEN_Admin = "Admin_token_" ADMIN_V1 = "/pay/admin/api/v1" FRONT_V1 = "/pay/front/api/v1" + FRONT_V2 = "/pay/front/api/v2" - WXCodeRedirectAddress = FRONT_V1 + "/wx/payurl" // 微信支付,授权code后 重定向地址 + WXCodeRedirectAddress = FRONT_V1 + "/wx/payurl" // 微信支付,授权code后 重定向地址 + PayPageAddress = FRONT_V1 + "/payPage" // 收银台页面地址 + PayPageChannelList = FRONT_V1 + "/payPage/list" // 收银台支付方式列表地址 - // 支付渠道枚举,1微信JSAPI,2微信H5,3微信app,4微信Native,5微信小程序,6支付宝网页&移动应用,7支付宝小程序,8支付宝JSAPI + // 支付渠道枚举,1微信JSAPI,2微信H5,3微信app,4微信Native,5微信小程序,6支付宝网页&移动应用,7支付宝小程序,8支付宝JSAPI,9支付宝电脑网站支付 PAY_CHANNEL_UNKNOWN = 0 PAY_CHANNEL_WECHAT_JSAPI = 1 PAY_CHANNEL_WECHAT_H5 = 2 @@ -18,6 +21,7 @@ const ( PAY_CHANNEL_ALIPAY_WEB = 6 PAY_CHANNEL_ALIPAY_MINI = 7 PAY_CHANNEL_ALIPAY_JSAPI = 8 + PAY_CHANNEL_ALIPAY_PC = 9 // 统一登陆信息 ADMIN_USER_ID = "User-Id" @@ -75,3 +79,18 @@ var OrderStatusMap = map[int]string{ ORDER_STATUS_FAILED: "执行失败", ORDER_STATUS_CLOSE: "订单关闭", } + +// 客户端环境 +const ( + OpenInUnknown int = iota // 未知 + OpenInWeChat // 微信客户端 + OpenInMobile // 手机客户端 + OpenInWindows // PC端 +) + +// 客户端环境和支付渠道的映射关系 +var OpenInPayChannelMap = map[int][]int{ + OpenInWeChat: {PAY_CHANNEL_WECHAT_JSAPI}, + OpenInMobile: {PAY_CHANNEL_WECHAT_H5, PAY_CHANNEL_ALIPAY_WEB}, + OpenInWindows: {PAY_CHANNEL_WECHAT_NATIVE, PAY_CHANNEL_ALIPAY_PC}, +} diff --git a/app/constants/errorcode/error_code.go b/app/constants/errorcode/error_code.go index 53294ed..500db7c 100644 --- a/app/constants/errorcode/error_code.go +++ b/app/constants/errorcode/error_code.go @@ -101,6 +101,8 @@ const ( // 微信授权 WechatAuthFail = 1901 WechatAuthSignFail = 1902 + + ClientEnvErr = 2000 ) var MsgEN = map[int]string{ @@ -187,6 +189,7 @@ var MsgZH = map[int]string{ WechatAuthFail: "微信授权失败", WechatAuthSignFail: "微信签名失败", + ClientEnvErr: "支付环境错误", } var MsgMap map[string]map[int]string = map[string]map[int]string{"en": MsgZH} diff --git a/app/http/controllers/front/pay_page.go b/app/http/controllers/front/pay_page.go new file mode 100644 index 0000000..924b161 --- /dev/null +++ b/app/http/controllers/front/pay_page.go @@ -0,0 +1,57 @@ +package front + +import ( + "PaymentCenter/app/http/controllers" + "PaymentCenter/app/http/entities/front" + "PaymentCenter/app/models/paychannelmodel" + "PaymentCenter/app/services" + "PaymentCenter/app/services/thirdpay" + "github.com/ahmetb/go-linq/v3" + "github.com/gin-gonic/gin" + "net/http" +) + +// 预支付接口V2, 返回收银台页面 +func PayUrlV2(c *gin.Context) { + var res front.ApiResponse + req := controllers.GetRequest(c).(*front.PayReqsV2) + + appCheckInfo := controllers.GetAppCheckInfo(c).(*services.AppCheck) + + result, code := services.NewPayUrl(*req). + WithApp(appCheckInfo). + WithClientIp(c.ClientIP()). + PayUrlV2Service() + + if result.Order != nil { + res.Order = thirdpay.NewOrdersResp(result.Order) + res.Url = result.Url + } + controllers.ApiRes(c, res, code) + return +} + +// V2 下单接口 +// 收银台页面 +func PayPage(c *gin.Context) { + c.HTML(http.StatusOK, "payPage.html", gin.H{ + //"host": config.GetConf().PayService.Host + common.PayPageChannelList, + }) +} + +// 收银台获取支付渠道列表 +func PayChannelList(c *gin.Context) { + req, _ := controllers.GetRequest(c).(*front.PayChannelListRequest) + req.UserAgent = c.Request.UserAgent() + data, code := services.PayPageChannelList(*req) + + result := []front.PayChannelListResponse{} + linq.From(data).SelectT(func(payChannel paychannelmodel.PayChannel) front.PayChannelListResponse { + return front.PayChannelListResponse{ + ChannelType: payChannel.ChannelType, + PayName: payChannel.PayName, + } + }).ToSlice(&result) + + controllers.HandCodeRes(c, result, code) +} diff --git a/app/http/entities/front/pay.go b/app/http/entities/front/pay.go index 291cdf0..e504db0 100644 --- a/app/http/entities/front/pay.go +++ b/app/http/entities/front/pay.go @@ -1,5 +1,9 @@ package front +import ( + "PaymentCenter/app/models/ordersmodel" +) + type ApiCommonBody struct { AppId int64 `json:"app_id" validate:"required"` Timestamp int64 `json:"timestamp" validate:"required"` @@ -56,3 +60,33 @@ type ApiResponse struct { Order interface{} `json:"order,omitempty"` Url string `json:"url,omitempty"` } + +type PayChannelListRequest struct { + OrderId string `json:"id" form:"id" validate:"required"` + UserAgent string +} + +type PayChannelListResponse struct { + PayName string `json:"pay_name"` + ChannelType int `json:"channel_type"` +} + +type PayReqsV2 struct { + AppId int64 `json:"app_id" validate:"required"` + Timestamp int64 `json:"timestamp" validate:"required"` + Amount int `json:"amount" validate:"required" label:"支付金额,单位分"` + OutTradeNo string `json:"out_trade_no" validate:"required" label:"外侧商户订单号"` + + ReturnUrl string `json:"return_url" validate:"max=1024" label:"支付成功后跳转的地址"` + Desc string `json:"desc" validate:"max=100" label:"商品描述"` + ExtJson string `json:"ext_json" label:"扩展参数"` + OpenId string `json:"open_id" label:"用户openid"` // 微信公众号支付时需要,如果没有传,可走支付中心的授权获取openid +} + +type PayReqsV2Response struct { + RelationOrder *ordersmodel.Orders + Order *ordersmodel.Orders + PayCode int + Url string + ThirdMsg string +} diff --git a/app/http/requestmapping/front.go b/app/http/requestmapping/front.go index 2eab3e2..c98fe23 100644 --- a/app/http/requestmapping/front.go +++ b/app/http/requestmapping/front.go @@ -8,6 +8,7 @@ import ( var FrontRequestMap = map[string]func() (validForm interface{}, isSaveLog bool){ common.FRONT_V1 + "/pay/url": func() (interface{}, bool) { return new(front.PayReqs), true }, + common.FRONT_V2 + "/pay/url": func() (interface{}, bool) { return new(front.PayReqsV2), true }, common.FRONT_V1 + "/pay/refund": func() (interface{}, bool) { return new(front.RefundReqs), true }, common.FRONT_V1 + "/pay/query": func() (interface{}, bool) { return new(front.QueryReqs), false }, common.FRONT_V1 + "/pay/close": func() (interface{}, bool) { return new(front.CloseReqs), true }, @@ -17,6 +18,7 @@ var FrontRequestMap = map[string]func() (validForm interface{}, isSaveLog bool){ var FrontRequestMapBeforeDecrypt = map[string]func() interface{}{ common.FRONT_V1 + "/pay/url": func() interface{} { return new(front.RequestBody) }, + common.FRONT_V2 + "/pay/url": func() interface{} { return new(front.RequestBody) }, common.FRONT_V1 + "/pay/refund": func() interface{} { return new(front.RequestBody) }, common.FRONT_V1 + "/pay/query": func() interface{} { return new(front.RequestBody) }, common.FRONT_V1 + "/pay/close": func() interface{} { return new(front.CloseReqs) }, @@ -25,5 +27,7 @@ var FrontRequestMapBeforeDecrypt = map[string]func() interface{}{ common.FRONT_V1 + "/wx/getWxAuth": func() interface{} { return new(front.GetWxAuthRequest) }, common.FRONT_V1 + "/wx/getCode": func() interface{} { return new(front.GetWxAuthRequest) }, + common.FRONT_V1 + "/payPage/list": func() interface{} { return new(front.PayChannelListRequest) }, + common.WXCodeRedirectAddress: func() interface{} { return new(front.WxJsApiPayRequest) }, } diff --git a/app/http/routes/route.go b/app/http/routes/route.go index 3d0ed73..9951313 100644 --- a/app/http/routes/route.go +++ b/app/http/routes/route.go @@ -79,6 +79,20 @@ func RegisterRoute(router *gin.Engine) { // 微信jsapi支付链接 router.GET(common.WXCodeRedirectAddress, middlewares.ValidateRequest(), front.WxJsApiPay) + v2 := router.Group(common.FRONT_V2) + { + pay := v2.Group("/pay", middlewares.ValidatePayRequest()) + pay.POST("/url", front.PayUrlV2) // 下单 + } + + //收银台 + { + // 收银台地址 + router.GET(common.PayPageAddress, front.PayPage) + // 收银台支付方式 + router.POST(common.PayPageChannelList, middlewares.ValidateRequest(), front.PayChannelList) + } + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } diff --git a/app/services/order.go b/app/services/order.go index 15e8589..1f1b52f 100644 --- a/app/services/order.go +++ b/app/services/order.go @@ -137,7 +137,7 @@ func OrderFindOne(order *ordersmodel.Orders, conn builder.Cond, col ...string) ( if err == sql.ErrNoRows { return nil, errorcode.OrdersNotFound } - return nil, errorcode.SystemError + return nil, handErr(err) } return orderInfo, errorcode.Success } diff --git a/app/services/pay_page.go b/app/services/pay_page.go new file mode 100644 index 0000000..f132b3b --- /dev/null +++ b/app/services/pay_page.go @@ -0,0 +1,189 @@ +package services + +import ( + "PaymentCenter/app/constants/common" + "PaymentCenter/app/constants/errorcode" + "PaymentCenter/app/http/entities" + "PaymentCenter/app/http/entities/backend" + "PaymentCenter/app/http/entities/front" + "PaymentCenter/app/models/ordersmodel" + "PaymentCenter/app/models/paychannelmodel" + "PaymentCenter/app/utils" + "PaymentCenter/config" + "fmt" + "strconv" + "time" + "xorm.io/builder" +) + +// 收银台支付渠道列表 +func PayPageChannelList(reqParam front.PayChannelListRequest) (resultPayChannelList []paychannelmodel.PayChannel, code int) { + + orderId, err := strconv.ParseInt(reqParam.OrderId, 10, 64) + if err != nil { + code = errorcode.ParamError + return + } + + // 订单检查 + orderInfo := &ordersmodel.Orders{Id: orderId} + orderInfo, code = OrderFindOne(orderInfo, builder.Eq{"id": orderId}) + if code != errorcode.Success { + return + } + if !(orderInfo.Status == common.ORDER_STATUS_PAYING || orderInfo.Status == common.ORDER_STATUS_WAITPAY) { + code = errorcode.OrderStatusErr + return + } + + // 商户拥有的支付渠道检查 + req := backend.PayChannelListRequest{ + MerchantId: orderInfo.MerchantId, + PageRequest: entities.PageRequest{}, + } + payList := make([]paychannelmodel.PayChannel, 0) + payList, _, code = PayChannelList(req) + if code != errorcode.Success { + return + } + if len(payList) == 0 { + code = errorcode.PayChannelNotFound + return + } + merchantPayChannelMap := make(map[int]paychannelmodel.PayChannel, 0) + for _, pay := range payList { + merchantPayChannelMap[pay.ChannelType] = pay + } + + // 客户端环境检查 + openType := ClientEnvCheck(reqParam.UserAgent) + if channels, ok := common.OpenInPayChannelMap[openType]; !ok { + code = errorcode.ClientEnvErr + return + } else { + for _, channel := range channels { + if _, ok = merchantPayChannelMap[channel]; ok { + resultPayChannelList = append(resultPayChannelList, merchantPayChannelMap[channel]) + } + } + } + + return resultPayChannelList, code +} + +// 客户端环境检查 +func ClientEnvCheck(ua string) int { + if utils.IsWeChatClient(ua) { + return common.OpenInWeChat + } else if utils.IsPC(ua) { + return common.OpenInWindows + } else if utils.IsMobile(ua) { + return common.OpenInMobile + } + + return common.OpenInUnknown +} + +// 预支付 +type payUrl struct { + ClientIp string + param front.PayReqsV2 + app *AppCheck + result front.PayReqsV2Response +} + +func NewPayUrl(param front.PayReqsV2) *payUrl { + return &payUrl{ + param: param, + } +} +func (this *payUrl) WithApp(app *AppCheck) *payUrl { + this.app = app + return this +} +func (this *payUrl) WithClientIp(ip string) *payUrl { + this.ClientIp = ip + return this +} + +// 订单存在 +func (this *payUrl) orderExist() { + switch this.result.Order.Status { + case common.ORDER_STATUS_CLOSE: + this.result.PayCode = errorcode.OrderClosed + case common.ORDER_STATUS_PAYED: + this.result.PayCode = errorcode.OrderPayed + case common.ORDER_STATUS_FAILED: + this.result.PayCode = errorcode.OrderFailed + case common.ORDER_STATUS_WAITPAY, common.ORDER_STATUS_PAYING: + this.result.PayCode = errorcode.Success + default: + this.result.PayCode = errorcode.OrderStatusErr + } +} + +func (this *payUrl) saveOrder() { + order := &ordersmodel.Orders{ + MerchantId: this.app.App.MerchantId, + //PayChannelId: w.PayParam.Channel.Id, + AppId: this.app.App.Id, + OutTradeNo: this.param.OutTradeNo, + OrderType: common.ORDER_TYPE_PAY, + Amount: this.param.Amount, + ExtJson: this.param.ExtJson, + Desc: this.param.Desc, + Status: common.ORDER_STATUS_WAITPAY, + } + this.result.Order, this.result.PayCode = OrderCreate(order) +} + +func (this *payUrl) PayUrlV2Service() (result front.PayReqsV2Response, code int) { + var ( + channelList []paychannelmodel.PayChannel + order = new(ordersmodel.Orders) + ) + + // redis 分布式锁 订单号幂等 + key := utils.GetRealKey("payUrl:" + this.param.OutTradeNo) + lockValue := fmt.Sprintf("%d", time.Now().UnixNano()) + ok, err := utils.AcquireLock(key, lockValue, time.Second*time.Duration(3)) + if ok { + defer utils.ReleaseLock(key, lockValue) + } else { + if err != nil { + utils.Log(nil, "", "PayUrlV2Service,获取分布式锁失败", fmt.Sprintf("错误原因:%s", err.Error())) + } + return + } + + // 商户是否有配置支付渠道 + channelList, _, code = PayChannelList(backend.PayChannelListRequest{MerchantId: this.app.App.MerchantId}) + if code != errorcode.Success { + return + } + if len(channelList) == 0 { + code = errorcode.PayChannelNotFound + return + } + // 订单是否存在 + conn := builder.NewCond() + conn = conn.And(builder.Eq{"out_trade_no": this.param.OutTradeNo}) + conn = conn.And(builder.Eq{"app_id": this.param.AppId}) + order, code = OrderFindOne(order, conn) + if code == errorcode.Success { + // 订单存在 + this.result.Order = order + this.orderExist() + } else if code == errorcode.OrdersNotFound { + // 订单不存在 + this.saveOrder() + } else { + return + } + code = this.result.PayCode + if code == errorcode.Success { + this.result.Url = config.GetConf().PayService.Host + common.PayPageAddress + "?id=" + strconv.FormatInt(this.result.Order.Id, 10) + } + + return this.result, code +} diff --git a/app/utils/useragent.go b/app/utils/useragent.go new file mode 100644 index 0000000..916cec2 --- /dev/null +++ b/app/utils/useragent.go @@ -0,0 +1,29 @@ +// Copyright (c) [2022] [巴拉迪维 BaratSemet] +// [ohUrlShortener] is licensed under Mulan PSL v2. +// You can use this software according to the terms and conditions of the Mulan PSL v2. +// You may obtain a copy of Mulan PSL v2 at: +// http://license.coscl.org.cn/MulanPSL2 +// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +// See the Mulan PSL v2 for more details. + +package utils + +import ( + "strings" +) + +func IsWeChatClient(ua string) bool { + // 检查 User-Agent 中是否包含微信客户端的标识 + return strings.Contains(ua, "MicroMessenger") +} + +func IsMobile(ua string) bool { + // 检查 User-Agent 中是否包含移动设备的标识 + return strings.Contains(ua, "Mobile") && !IsWeChatClient(ua) +} + +func IsPC(userAgent string) bool { + // 检查 User-Agent 中是否包含 PC 浏览器的标识 + t := strings.Contains(userAgent, "Windows NT") || strings.Contains(userAgent, "Macintosh") || strings.Contains(userAgent, "Linux") + return t +} diff --git a/app/utils/useragent_test.go b/app/utils/useragent_test.go new file mode 100644 index 0000000..09f26ab --- /dev/null +++ b/app/utils/useragent_test.go @@ -0,0 +1,8 @@ +package utils + +import "testing" + +func TestIsPC(t *testing.T) { + userAgent := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + t.Log(IsPC(userAgent)) +} diff --git a/app/utils/util.go b/app/utils/util.go index 93e524b..34b8995 100644 --- a/app/utils/util.go +++ b/app/utils/util.go @@ -483,3 +483,28 @@ func KeyExistsOrSet(key string, expire int) bool { } return false } + +// AcquireLock 尝试获取分布式锁,localValue用于检测是否是同一个客户端持有的锁锁 +func AcquireLock(key, lockValue string, expired time.Duration) (bool, error) { + ctx := context.Background() + rd := redis.GetRedis() + result := rd.SetNX(ctx, key, lockValue, expired) + return result.Val(), result.Err() +} + +// 释放锁 +func ReleaseLock(key string, lockValue string) (bool, error) { + rd := redis.GetRedis() + result := rd.Eval(context.Background(), "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", []string{key}, lockValue) + return result.Val() == int64(1), result.Err() +} + +// 判断切片是否包含指定 +func Contains[T comparable](s T, slice []T) bool { + for _, v := range slice { + if s == v { + return true + } + } + return false +}