From 4748bb0cfb7fb0d09b29c5647915525a675f7ada Mon Sep 17 00:00:00 2001 From: liangzai <2440983361@qq.com> Date: Wed, 27 Nov 2024 01:58:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(user):=20=E6=96=B0=E5=A2=9Eiap=E6=94=AF?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/user/cmd/api/desc/pay.api | 3 + app/user/cmd/api/desc/pay/pay.api | 11 +- .../api/etc/merchant/AuthKey_LAY65829DQ.p8 | 6 + app/user/cmd/api/etc/user.dev.yaml | 8 + app/user/cmd/api/etc/user.yaml | 8 + app/user/cmd/api/internal/config/config.go | 10 ++ .../handler/pay/iapcallbackhandler.go | 29 +++ app/user/cmd/api/internal/handler/routes.go | 5 + .../internal/logic/pay/iapcallbacklogic.go | 81 +++++++++ .../api/internal/logic/pay/paymentlogic.go | 5 +- .../api/internal/service/applepayService.go | 168 ++++++++++++++++++ .../cmd/api/internal/svc/servicecontext.go | 2 + app/user/cmd/api/internal/types/types.go | 5 + deploy/sql/order.sql | 2 +- 14 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 app/user/cmd/api/etc/merchant/AuthKey_LAY65829DQ.p8 create mode 100644 app/user/cmd/api/internal/handler/pay/iapcallbackhandler.go create mode 100644 app/user/cmd/api/internal/logic/pay/iapcallbacklogic.go create mode 100644 app/user/cmd/api/internal/service/applepayService.go diff --git a/app/user/cmd/api/desc/pay.api b/app/user/cmd/api/desc/pay.api index 6d46a33..854c254 100644 --- a/app/user/cmd/api/desc/pay.api +++ b/app/user/cmd/api/desc/pay.api @@ -38,4 +38,7 @@ service main { // 支付 @handler Payment post /pay/payment (PaymentReq) returns (PaymentResp) + + @handler IapCallback + post /pay/iap_callback (IapCallbackReq) } \ No newline at end of file diff --git a/app/user/cmd/api/desc/pay/pay.api b/app/user/cmd/api/desc/pay/pay.api index 9d5cb3c..aa20738 100644 --- a/app/user/cmd/api/desc/pay/pay.api +++ b/app/user/cmd/api/desc/pay/pay.api @@ -9,14 +9,19 @@ info ( type ( PaymentReq { - Id string `json:"id"` + Id string `json:"id"` PayMethod string `json:"pay_method"` } PaymentResp { prepayID string `json:"prepay_id"` - OrderID int64 `json:"order_id"` + OrderID int64 `json:"order_id"` } ) - +type ( + IapCallbackReq { + OrderID int64 `json:"order_id" validate:"required"` + TransactionReceipt string `json:"transaction_receipt" validate:"required"` + } +) diff --git a/app/user/cmd/api/etc/merchant/AuthKey_LAY65829DQ.p8 b/app/user/cmd/api/etc/merchant/AuthKey_LAY65829DQ.p8 new file mode 100644 index 0000000..b448586 --- /dev/null +++ b/app/user/cmd/api/etc/merchant/AuthKey_LAY65829DQ.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgkidSHV1OeJN84sDD +xWLGIVjTyhn6sAQDyHfqKW6lxnGgCgYIKoZIzj0DAQehRANCAAQSAlAcuuuRNFqk +aMPVpXxsiR/pwhyM62tFhdFsbULq1C7MItQxKVMKCiwz3r5rZZy7HcbkqL47LPZ1 +q6V8Wyop +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/app/user/cmd/api/etc/user.dev.yaml b/app/user/cmd/api/etc/user.dev.yaml index 8750319..b7c0336 100644 --- a/app/user/cmd/api/etc/user.dev.yaml +++ b/app/user/cmd/api/etc/user.dev.yaml @@ -38,5 +38,13 @@ Wxpay: MchPrivateKeyPath: "etc/merchant/apiclient_key.pem" NotifyUrl: "https://6m4685017o.goho.co/api/v1/pay/wechat/callback" RefundNotifyUrl: "https://6m4685017o.goho.co/api/v1/wechat/refund_callback" +Applepay: + ProductionVerifyURL: "https://api.storekit.itunes.apple.com/inApps/v1/transactions/receipt" + SandboxVerifyURL: "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/receipt" + Sandbox: false + BundleID: "com.allinone.check" + IssuerID: "bf828d85-5269-4914-9660-c066e09cd6ef" + KeyID: "LAY65829DQ" + LoadPrivateKeyPath: "etc/merchant/AuthKey_LAY65829DQ.p8" Ali: Code: "d55b58829efb41c8aa8e86769cba4844" \ No newline at end of file diff --git a/app/user/cmd/api/etc/user.yaml b/app/user/cmd/api/etc/user.yaml index 9156b72..7ea2aff 100644 --- a/app/user/cmd/api/etc/user.yaml +++ b/app/user/cmd/api/etc/user.yaml @@ -39,5 +39,13 @@ Wxpay: MchPrivateKeyPath: "etc/merchant/apiclient_key.pem" NotifyUrl: "https://app.quannengcha.com/api/v1/pay/wechat/callback" RefundNotifyUrl: "https://app.quannengcha.com/api/v1/wechat/refund_callback" +Applepay: + ProductionVerifyURL: "https://api.storekit.itunes.apple.com/inApps/v1/transactions/receipt" + SandboxVerifyURL: "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/receipt" + Sandbox: true + BundleID: "com.allinone.check" + IssuerID: "bf828d85-5269-4914-9660-c066e09cd6ef" + KeyID: "LAY65829DQ" + LoadPrivateKeyPath: "etc/merchant/AuthKey_LAY65829DQ.p8" Ali: Code: "d55b58829efb41c8aa8e86769cba4844" \ No newline at end of file diff --git a/app/user/cmd/api/internal/config/config.go b/app/user/cmd/api/internal/config/config.go index c7f0e79..620ee7d 100644 --- a/app/user/cmd/api/internal/config/config.go +++ b/app/user/cmd/api/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { Encrypt Encrypt Alipay AlipayConfig Wxpay WxpayConfig + Applepay ApplepayConfig Ali AliConfig WestConfig WestConfig } @@ -55,6 +56,15 @@ type WxpayConfig struct { type AliConfig struct { Code string } +type ApplepayConfig struct { + ProductionVerifyURL string + SandboxVerifyURL string // 沙盒环境的验证 URL + Sandbox bool + BundleID string + IssuerID string + KeyID string + LoadPrivateKeyPath string +} type WestConfig struct { Url string Key string diff --git a/app/user/cmd/api/internal/handler/pay/iapcallbackhandler.go b/app/user/cmd/api/internal/handler/pay/iapcallbackhandler.go new file mode 100644 index 0000000..4b96a1a --- /dev/null +++ b/app/user/cmd/api/internal/handler/pay/iapcallbackhandler.go @@ -0,0 +1,29 @@ +package pay + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "qnc-server/app/user/cmd/api/internal/logic/pay" + "qnc-server/app/user/cmd/api/internal/svc" + "qnc-server/app/user/cmd/api/internal/types" + "qnc-server/common/result" + "qnc-server/pkg/lzkit/validator" +) + +func IapCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.IapCallbackReq + if err := httpx.Parse(r, &req); err != nil { + result.ParamErrorResult(r, w, err) + return + } + if err := validator.Validate(req); err != nil { + result.ParamValidateErrorResult(r, w, err) + return + } + l := pay.NewIapCallbackLogic(r.Context(), svcCtx) + err := l.IapCallback(&req) + result.HttpResult(r, w, nil, err) + } +} diff --git a/app/user/cmd/api/internal/handler/routes.go b/app/user/cmd/api/internal/handler/routes.go index 01666aa..cb37eaf 100644 --- a/app/user/cmd/api/internal/handler/routes.go +++ b/app/user/cmd/api/internal/handler/routes.go @@ -50,6 +50,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( []rest.Route{ + { + Method: http.MethodPost, + Path: "/pay/iap_callback", + Handler: pay.IapCallbackHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/pay/payment", diff --git a/app/user/cmd/api/internal/logic/pay/iapcallbacklogic.go b/app/user/cmd/api/internal/logic/pay/iapcallbacklogic.go new file mode 100644 index 0000000..a2960ab --- /dev/null +++ b/app/user/cmd/api/internal/logic/pay/iapcallbacklogic.go @@ -0,0 +1,81 @@ +package pay + +import ( + "context" + "github.com/pkg/errors" + "qnc-server/app/user/cmd/api/internal/svc" + "qnc-server/app/user/cmd/api/internal/types" + "qnc-server/common/xerr" + "qnc-server/pkg/lzkit/lzUtils" + "time" + + "github.com/zeromicro/go-zero/core/logx" +) + +type IapCallbackLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewIapCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *IapCallbackLogic { + return &IapCallbackLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *IapCallbackLogic) IapCallback(req *types.IapCallbackReq) error { + // Step 1: 查找订单 + order, findOrderErr := l.svcCtx.OrderModel.FindOne(l.ctx, req.OrderID) + if findOrderErr != nil { + logx.Errorf("苹果内购支付回调,查找订单失败: %+v", findOrderErr) + return nil + } + + // Step 2: 验证订单状态 + if order.Status != "pending" { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "苹果内购支付回调, 订单状态异常: %+v", order) + } + + // Step 3: 调用 VerifyReceipt 验证苹果支付凭证 + //receipt := req.TransactionReceipt // 从请求中获取支付凭证 + //verifyResponse, verifyErr := l.svcCtx.ApplePayService.VerifyReceipt(l.ctx, receipt) + //if verifyErr != nil { + // return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "苹果内购支付回调, 验证订单异常: %+v", verifyErr) + //} + + // Step 4: 验证订单 + //product, findProductErr := l.svcCtx.ProductModel.FindOne(l.ctx, order.Id) + //if findProductErr != nil { + // return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "苹果内购支付回调, 获取订单相关商品失败: %+v", findProductErr) + //} + //isProductMatched := false + //appleProductID := l.svcCtx.ApplePayService.GetIappayAppID(product.ProductEn) + //for _, item := range verifyResponse.Receipt.InApp { + // if item.ProductID == appleProductID { + // isProductMatched = true + // order.PlatformOrderId = lzUtils.StringToNullString(item.TransactionID) // 记录交易 ID + // break + // } + //} + //if !isProductMatched { + // return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "苹果内购支付回调, 商品 ID 不匹配,订单 ID: %d, 回调苹果商品 ID: %s", order.Id, verifyResponse.Receipt.InApp[0].ProductID) + //} + + // Step 5: 更新订单状态 mm + order.Status = "paid" + order.PayTime = lzUtils.TimeToNullTime(time.Now()) + + // 更新订单到数据库 + if updateErr := l.svcCtx.OrderModel.UpdateWithVersion(l.ctx, nil, order); updateErr != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "苹果内购支付回调, 修改订单信息失败: %+v", updateErr) + } + + // Step 6: 处理订单完成后的逻辑 + if asyncErr := l.svcCtx.AsynqService.SendQueryTask(order.Id); asyncErr != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "苹果内购支付回调,异步任务调度失败: %v", asyncErr) + } + return nil +} diff --git a/app/user/cmd/api/internal/logic/pay/paymentlogic.go b/app/user/cmd/api/internal/logic/pay/paymentlogic.go index 88de5a6..f74c43a 100644 --- a/app/user/cmd/api/internal/logic/pay/paymentlogic.go +++ b/app/user/cmd/api/internal/logic/pay/paymentlogic.go @@ -66,9 +66,12 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp, if req.PayMethod == "wechatpay" { outTradeNo = l.svcCtx.WechatPayService.GenerateOutTradeNo() prepayID, createOrderErr = l.svcCtx.WechatPayService.CreateWechatAppOrder(l.ctx, product.SellPrice, product.Description, outTradeNo) - } else { + } else if req.PayMethod == "alipay" { outTradeNo = l.svcCtx.AlipayService.GenerateOutTradeNo() prepayID, createOrderErr = l.svcCtx.AlipayService.CreateAlipayAppOrder(product.SellPrice, product.Description, outTradeNo) + } else if req.PayMethod == "appleiap" { + outTradeNo = l.svcCtx.ApplePayService.GenerateOutTradeNo() + prepayID = l.svcCtx.ApplePayService.GetIappayAppID(product.ProductEn) } if createOrderErr != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "生成订单, 创建支付订单失败: %+v", createOrderErr) diff --git a/app/user/cmd/api/internal/service/applepayService.go b/app/user/cmd/api/internal/service/applepayService.go new file mode 100644 index 0000000..cb45030 --- /dev/null +++ b/app/user/cmd/api/internal/service/applepayService.go @@ -0,0 +1,168 @@ +package service + +import ( + "context" + "crypto/ecdsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "github.com/golang-jwt/jwt/v4" + "io/ioutil" + "net/http" + "qnc-server/app/user/cmd/api/internal/config" + "strconv" + "time" +) + +// ApplePayService 是 Apple IAP 支付服务的结构体 +type ApplePayService struct { + config config.ApplepayConfig // 配置项 +} + +// NewApplePayService 是一个构造函数,用于初始化 ApplePayService +func NewApplePayService(c config.Config) *ApplePayService { + return &ApplePayService{ + config: c.Applepay, + } +} +func (a *ApplePayService) GetIappayAppID(productName string) string { + return fmt.Sprintf("%s.%s", a.config.BundleID, productName) +} + +// VerifyReceipt 验证苹果支付凭证 +func (a *ApplePayService) VerifyReceipt(ctx context.Context, receipt string) (*AppleVerifyResponse, error) { + var reqUrl string + if a.config.Sandbox { + reqUrl = a.config.SandboxVerifyURL + } else { + reqUrl = a.config.ProductionVerifyURL + } + + // 读取私钥 + privateKey, err := loadPrivateKey(a.config.LoadPrivateKeyPath) + if err != nil { + return nil, fmt.Errorf("加载私钥失败:%v", err) + } + + // 生成 JWT + token, err := generateJWT(privateKey, a.config.KeyID, a.config.IssuerID) + if err != nil { + return nil, fmt.Errorf("生成JWT失败:%v", err) + } + + // 构造查询参数 + queryParams := fmt.Sprintf("?receipt-data=%s", receipt) + fullUrl := reqUrl + queryParams + + // 构建 HTTP GET 请求 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil) + if err != nil { + return nil, fmt.Errorf("创建 HTTP 请求失败:%v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + // 发送请求 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("请求苹果验证接口失败:%v", err) + } + defer resp.Body.Close() + + // 解析响应 + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应体失败:%v", err) + } + + var verifyResponse AppleVerifyResponse + err = json.Unmarshal(body, &verifyResponse) + if err != nil { + return nil, fmt.Errorf("解析响应体失败:%v", err) + } + + // 根据实际响应处理逻辑 + if verifyResponse.Status != 0 { + return nil, fmt.Errorf("验证失败,状态码:%d", verifyResponse.Status) + } + + return &verifyResponse, nil +} + +func loadPrivateKey(path string) (*ecdsa.PrivateKey, error) { + data, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil || block.Type != "PRIVATE KEY" { + return nil, fmt.Errorf("无效的私钥数据") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + ecdsaKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("私钥类型错误") + } + return ecdsaKey, nil +} + +func generateJWT(privateKey *ecdsa.PrivateKey, keyID, issuerID string) (string, error) { + now := time.Now() + claims := jwt.RegisteredClaims{ + Issuer: issuerID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)), + Audience: jwt.ClaimStrings{"appstoreconnect-v1"}, + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = keyID + tokenString, err := token.SignedString(privateKey) + if err != nil { + return "", err + } + return tokenString, nil +} + +// GenerateOutTradeNo 生成唯一订单号 +func (a *ApplePayService) GenerateOutTradeNo() string { + length := 16 + timestamp := time.Now().UnixNano() + timeStr := strconv.FormatInt(timestamp, 10) + randomPart := strconv.Itoa(int(timestamp % 1e6)) + combined := timeStr + randomPart + + if len(combined) >= length { + return combined[:length] + } + + for len(combined) < length { + combined += strconv.Itoa(int(timestamp % 10)) + } + + return combined +} + +// AppleVerifyResponse 定义苹果验证接口的响应结构 +type AppleVerifyResponse struct { + Status int `json:"status"` // 验证状态码:0 表示收据有效 + Receipt *Receipt `json:"receipt"` // 收据信息 +} + +// Receipt 定义收据的精简结构 +type Receipt struct { + BundleID string `json:"bundle_id"` // 应用的 Bundle ID + InApp []InAppItem `json:"in_app"` // 应用内购买记录 +} + +// InAppItem 定义单条交易记录 +type InAppItem struct { + ProductID string `json:"product_id"` // 商品 ID + TransactionID string `json:"transaction_id"` // 交易 ID + PurchaseDate string `json:"purchase_date"` // 购买日期 (ISO 8601) + OriginalTransID string `json:"original_transaction_id"` // 原始交易 ID +} diff --git a/app/user/cmd/api/internal/svc/servicecontext.go b/app/user/cmd/api/internal/svc/servicecontext.go index 453fd6a..d8e61c7 100644 --- a/app/user/cmd/api/internal/svc/servicecontext.go +++ b/app/user/cmd/api/internal/svc/servicecontext.go @@ -22,6 +22,7 @@ type ServiceContext struct { QueryModel model.QueryModel AlipayService *service.AliPayService WechatPayService *service.WechatPayService + ApplePayService *service.ApplePayService WestDexService *service.WestDexService AsynqServer *asynq.Server // 服务端 AsynqService *service.AsynqService // 客户端 @@ -51,6 +52,7 @@ func NewServiceContext(c config.Config) *ServiceContext { Redis: redis.MustNewRedis(redisConf), AlipayService: service.NewAliPayService(c), WechatPayService: service.NewWechatPayService(c), + ApplePayService: service.NewApplePayService(c), WestDexService: westDexService, VerificationService: service.NewVerificationService(c, westDexService), AsynqServer: asynqServer, diff --git a/app/user/cmd/api/internal/types/types.go b/app/user/cmd/api/internal/types/types.go index d15bc06..43fc4fb 100644 --- a/app/user/cmd/api/internal/types/types.go +++ b/app/user/cmd/api/internal/types/types.go @@ -15,6 +15,11 @@ type GetProductByIDRequest struct { Id int64 `path:"id"` } +type IapCallbackReq struct { + OrderID int64 `json:"order_id" validate:"required"` + TransactionReceipt string `json:"transaction_receipt" validate:"required"` +} + type MobileCodeLoginReq struct { Mobile string `json:"mobile"` Code string `json:"code" validate:"required"` diff --git a/deploy/sql/order.sql b/deploy/sql/order.sql index 8824ce2..19976ec 100644 --- a/deploy/sql/order.sql +++ b/deploy/sql/order.sql @@ -3,7 +3,7 @@ CREATE TABLE `order` ( `order_no` varchar(32) NOT NULL COMMENT '自生成的订单号', `user_id` bigint NOT NULL COMMENT '用户ID', `product_id` bigint NOT NULL COMMENT '产品ID(软关联到产品表)', - `payment_platform` enum('alipay', 'wechat', 'other') NOT NULL COMMENT '支付平台(支付宝、微信、其他)', + `payment_platform` enum('alipay', 'wechat', 'appleiap','other') NOT NULL COMMENT '支付平台(支付宝、微信、苹果内购、其他)', `payment_scene` enum('app', 'h5', 'mini_program', 'public_account') NOT NULL COMMENT '支付场景(App、H5、微信小程序、公众号)', `platform_order_id` varchar(64) DEFAULT NULL COMMENT '支付平台订单号', `amount` decimal(10, 2) NOT NULL COMMENT '支付金额',