feat(user): 新增iap支付

This commit is contained in:
liangzai 2024-11-27 01:58:05 +08:00
parent d1c897e55e
commit 4748bb0cfb
14 changed files with 338 additions and 5 deletions

View File

@ -38,4 +38,7 @@ service main {
// 支付 // 支付
@handler Payment @handler Payment
post /pay/payment (PaymentReq) returns (PaymentResp) post /pay/payment (PaymentReq) returns (PaymentResp)
@handler IapCallback
post /pay/iap_callback (IapCallbackReq)
} }

View File

@ -9,14 +9,19 @@ info (
type ( type (
PaymentReq { PaymentReq {
Id string `json:"id"` Id string `json:"id"`
PayMethod string `json:"pay_method"` PayMethod string `json:"pay_method"`
} }
PaymentResp { PaymentResp {
prepayID string `json:"prepay_id"` 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"`
}
)

View File

@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgkidSHV1OeJN84sDD
xWLGIVjTyhn6sAQDyHfqKW6lxnGgCgYIKoZIzj0DAQehRANCAAQSAlAcuuuRNFqk
aMPVpXxsiR/pwhyM62tFhdFsbULq1C7MItQxKVMKCiwz3r5rZZy7HcbkqL47LPZ1
q6V8Wyop
-----END PRIVATE KEY-----

View File

@ -38,5 +38,13 @@ Wxpay:
MchPrivateKeyPath: "etc/merchant/apiclient_key.pem" MchPrivateKeyPath: "etc/merchant/apiclient_key.pem"
NotifyUrl: "https://6m4685017o.goho.co/api/v1/pay/wechat/callback" NotifyUrl: "https://6m4685017o.goho.co/api/v1/pay/wechat/callback"
RefundNotifyUrl: "https://6m4685017o.goho.co/api/v1/wechat/refund_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: Ali:
Code: "d55b58829efb41c8aa8e86769cba4844" Code: "d55b58829efb41c8aa8e86769cba4844"

View File

@ -39,5 +39,13 @@ Wxpay:
MchPrivateKeyPath: "etc/merchant/apiclient_key.pem" MchPrivateKeyPath: "etc/merchant/apiclient_key.pem"
NotifyUrl: "https://app.quannengcha.com/api/v1/pay/wechat/callback" NotifyUrl: "https://app.quannengcha.com/api/v1/pay/wechat/callback"
RefundNotifyUrl: "https://app.quannengcha.com/api/v1/wechat/refund_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: Ali:
Code: "d55b58829efb41c8aa8e86769cba4844" Code: "d55b58829efb41c8aa8e86769cba4844"

View File

@ -14,6 +14,7 @@ type Config struct {
Encrypt Encrypt Encrypt Encrypt
Alipay AlipayConfig Alipay AlipayConfig
Wxpay WxpayConfig Wxpay WxpayConfig
Applepay ApplepayConfig
Ali AliConfig Ali AliConfig
WestConfig WestConfig WestConfig WestConfig
} }
@ -55,6 +56,15 @@ type WxpayConfig struct {
type AliConfig struct { type AliConfig struct {
Code string Code string
} }
type ApplepayConfig struct {
ProductionVerifyURL string
SandboxVerifyURL string // 沙盒环境的验证 URL
Sandbox bool
BundleID string
IssuerID string
KeyID string
LoadPrivateKeyPath string
}
type WestConfig struct { type WestConfig struct {
Url string Url string
Key string Key string

View File

@ -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)
}
}

View File

@ -50,6 +50,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes( server.AddRoutes(
[]rest.Route{ []rest.Route{
{
Method: http.MethodPost,
Path: "/pay/iap_callback",
Handler: pay.IapCallbackHandler(serverCtx),
},
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/pay/payment", Path: "/pay/payment",

View File

@ -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
}

View File

@ -66,9 +66,12 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp,
if req.PayMethod == "wechatpay" { if req.PayMethod == "wechatpay" {
outTradeNo = l.svcCtx.WechatPayService.GenerateOutTradeNo() outTradeNo = l.svcCtx.WechatPayService.GenerateOutTradeNo()
prepayID, createOrderErr = l.svcCtx.WechatPayService.CreateWechatAppOrder(l.ctx, product.SellPrice, product.Description, outTradeNo) prepayID, createOrderErr = l.svcCtx.WechatPayService.CreateWechatAppOrder(l.ctx, product.SellPrice, product.Description, outTradeNo)
} else { } else if req.PayMethod == "alipay" {
outTradeNo = l.svcCtx.AlipayService.GenerateOutTradeNo() outTradeNo = l.svcCtx.AlipayService.GenerateOutTradeNo()
prepayID, createOrderErr = l.svcCtx.AlipayService.CreateAlipayAppOrder(product.SellPrice, product.Description, outTradeNo) 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 { if createOrderErr != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "生成订单, 创建支付订单失败: %+v", createOrderErr) return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "生成订单, 创建支付订单失败: %+v", createOrderErr)

View File

@ -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
}

View File

@ -22,6 +22,7 @@ type ServiceContext struct {
QueryModel model.QueryModel QueryModel model.QueryModel
AlipayService *service.AliPayService AlipayService *service.AliPayService
WechatPayService *service.WechatPayService WechatPayService *service.WechatPayService
ApplePayService *service.ApplePayService
WestDexService *service.WestDexService WestDexService *service.WestDexService
AsynqServer *asynq.Server // 服务端 AsynqServer *asynq.Server // 服务端
AsynqService *service.AsynqService // 客户端 AsynqService *service.AsynqService // 客户端
@ -51,6 +52,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
Redis: redis.MustNewRedis(redisConf), Redis: redis.MustNewRedis(redisConf),
AlipayService: service.NewAliPayService(c), AlipayService: service.NewAliPayService(c),
WechatPayService: service.NewWechatPayService(c), WechatPayService: service.NewWechatPayService(c),
ApplePayService: service.NewApplePayService(c),
WestDexService: westDexService, WestDexService: westDexService,
VerificationService: service.NewVerificationService(c, westDexService), VerificationService: service.NewVerificationService(c, westDexService),
AsynqServer: asynqServer, AsynqServer: asynqServer,

View File

@ -15,6 +15,11 @@ type GetProductByIDRequest struct {
Id int64 `path:"id"` Id int64 `path:"id"`
} }
type IapCallbackReq struct {
OrderID int64 `json:"order_id" validate:"required"`
TransactionReceipt string `json:"transaction_receipt" validate:"required"`
}
type MobileCodeLoginReq struct { type MobileCodeLoginReq struct {
Mobile string `json:"mobile"` Mobile string `json:"mobile"`
Code string `json:"code" validate:"required"` Code string `json:"code" validate:"required"`

View File

@ -3,7 +3,7 @@ CREATE TABLE `order` (
`order_no` varchar(32) NOT NULL COMMENT '自生成的订单号', `order_no` varchar(32) NOT NULL COMMENT '自生成的订单号',
`user_id` bigint NOT NULL COMMENT '用户ID', `user_id` bigint NOT NULL COMMENT '用户ID',
`product_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、微信小程序、公众号', `payment_scene` enum('app', 'h5', 'mini_program', 'public_account') NOT NULL COMMENT '支付场景App、H5、微信小程序、公众号',
`platform_order_id` varchar(64) DEFAULT NULL COMMENT '支付平台订单号', `platform_order_id` varchar(64) DEFAULT NULL COMMENT '支付平台订单号',
`amount` decimal(10, 2) NOT NULL COMMENT '支付金额', `amount` decimal(10, 2) NOT NULL COMMENT '支付金额',