From d95911d6bda6602313b8f2a29e97698bbefa45f3 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Tue, 9 Dec 2025 00:53:25 -0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=94=AF=E4=BB=98):=20=E6=96=B0=E5=A2=9EA?= =?UTF-8?q?pple=20IAP=E6=94=AF=E4=BB=98=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现Apple应用内购支付功能,包括: 1. 新增AppleIAP和ApplePay支付平台枚举 2. 添加IAP验证接口/v1/public/iap/verify处理初购验证 3. 实现Apple服务器通知处理逻辑/v1/iap/notifications 4. 新增JWS验签和JWKS公钥缓存功能 5. 复用现有订单系统处理IAP支付订单 相关文档已更新,包含接入方案和实现细节 --- ...入 Apple 自动续期并复用现有支付体系 (1).md | 47 ++++++ .../接入 Apple 自动续期并复用现有支付体系.md | 92 ++++++++++++ internal/handler/notify.go | 15 +- .../handler/notify/appleIAPNotifyHandler.go | 20 +++ .../handler/notify/paymentNotifyHandler.go | 2 +- internal/handler/public/iap/verifyHandler.go | 29 ++++ internal/handler/routes.go | 14 +- internal/logic/notify/appleIAPNotifyLogic.go | 134 ++++++++++++++++++ internal/logic/public/iap/verifyLogic.go | 104 ++++++++++++++ .../public/portal/purchaseCheckoutLogic.go | 19 ++- internal/model/order/default.go | 58 +++++--- internal/types/types.go | 15 +- pkg/appleiap/jwks.go | 87 ++++++++++++ pkg/appleiap/jws.go | 29 ++++ pkg/payment/platform.go | 23 +++ 15 files changed, 654 insertions(+), 34 deletions(-) create mode 100644 .trae/documents/接入 Apple 自动续期并复用现有支付体系 (1).md create mode 100644 .trae/documents/接入 Apple 自动续期并复用现有支付体系.md create mode 100644 internal/handler/notify/appleIAPNotifyHandler.go create mode 100644 internal/handler/public/iap/verifyHandler.go create mode 100644 internal/logic/notify/appleIAPNotifyLogic.go create mode 100644 internal/logic/public/iap/verifyLogic.go create mode 100644 pkg/appleiap/jwks.go create mode 100644 pkg/appleiap/jws.go diff --git a/.trae/documents/接入 Apple 自动续期并复用现有支付体系 (1).md b/.trae/documents/接入 Apple 自动续期并复用现有支付体系 (1).md new file mode 100644 index 0000000..d88ad2e --- /dev/null +++ b/.trae/documents/接入 Apple 自动续期并复用现有支付体系 (1).md @@ -0,0 +1,47 @@ +## 实施目标 +- 复用现有订单与队列赋权,接入 Apple 自动续期(IAP),保持报表/审计/通知一致。 + +## 方案选择 +- 采用“平台化复用 + 合成订单”的方式:Apple 由客户端结算 + 服务器通知驱动,服务端生成“已支付订单”进入现有赋权与续费流程。 + +## 具体改动(按文件) +1) 平台标识 +- 更新 `pkg/payment/platform.go`:新增 `AppleIAP` 枚举与名称(仅标识,不参与 `PurchaseCheckout`)。 + +2) 路由与 Handler +- 新增公共接口:`POST /v1/public/iap/verify` + - 位置:`internal/handler/public/iap/verifyHandler.go` + - 逻辑:调用 `internal/logic/public/iap/verifyLogic.go`,以 `originalTransactionId` 验证 Apple 购买,生成“已支付订阅订单”,入队激活。 +- 新增通知接口:`POST /v1/iap/notifications` + - 位置:`internal/handler/notify/appleIAPNotifyHandler.go` + - 逻辑:调用 `internal/logic/notify/appleIAPNotifyLogic.go`,JWS 验签后按事件(初购/续期/退款)生成或更新订单,触发续费或撤销权益。 +- 路由注册: + - `internal/handler/routes.go` 增加 `/v1/public/iap/verify` 路由。 + - `internal/handler/notify.go` 增加独立 `/v1/iap/notifications` 路由(Apple 不带 `:token`)。 + +3) 数据与模型 +- 在用户订阅(或新建 `iap_binding` 表)绑定:`originalTransactionId`、`environment`、`latestExpiresDate`。 +- 订单字段复用:`Method=AppleIAP`、`TradeNo=originalTransactionId`、`Type=1/2`(订阅/续费),`Status=2`(已支付),金额可取通知中的价格;取不到则置 `Amount=0` 保证流程。 + +4) 逻辑复用与改造点 +- 赋权:复用 `queue/logic/order/activateOrderLogic.go:165 NewPurchase`。 +- 续费:复用 `queue/logic/order/activateOrderLogic.go:529 updateSubscriptionForRenewal`。 +- 不改动 `internal/logic/public/portal/purchaseCheckoutLogic.go` 的渠道路由(Apple 不走此流程)。 + +5) 安全与幂等 +- Apple JWS 验签:拉取并缓存 JWKS 公钥,校验通知;拒绝无效签名。 +- 幂等:以 `notificationId`/`transactionId` 与 `originalTransactionId` 去重处理。 + +6) 客户端协作 +- iOS:完成 StoreKit 购买后携带 `originalTransactionId` 调用 `/v1/public/iap/verify`。 +- 续费:依赖 Server Notifications v2 自动驱动,无需客户端调用。 + +7) 测试与监控 +- 沙盒验证初购、续期、重试与宽限期、退款撤销;注意元数据延迟(~1小时)。 +- 指标:通知验签失败、API 调用失败、幂等冲突、状态不一致报警。 + +## 交付节奏 +- 第一步:平台枚举与路由骨架; +- 第二步:`verify` 验证与“合成订单”生成; +- 第三步:通知验签与事件映射; +- 第四步:沙盒联调,确认队列赋权与续费延长。 \ No newline at end of file diff --git a/.trae/documents/接入 Apple 自动续期并复用现有支付体系.md b/.trae/documents/接入 Apple 自动续期并复用现有支付体系.md new file mode 100644 index 0000000..b413d63 --- /dev/null +++ b/.trae/documents/接入 Apple 自动续期并复用现有支付体系.md @@ -0,0 +1,92 @@ +## 结论 + +* 可以复用你现有的“订单→支付成功→订单激活(赋权)→通知/返佣”的主干流程,但“支付环节”不能复用第三方网关逻辑,必须改为 Apple IAP 的校验与事件驱动。 + +* 复用范围:订单模型、续费与赋权队列、优惠/手续费计算、通知与返佣;差异点:支付下单与回调换成“StoreKit 客户端购买 + 服务端向 Apple 校验 + Apple Server Notifications v2”。 + +## 可复用的部分 + +1. 订单激活与赋权 + +* 新购赋权:`queue/logic/order/activateOrderLogic.go:164-193` 的 `NewPurchase` + +* 续费赋权:`queue/logic/order/activateOrderLogic.go:473-515` 的 `Renewal` + +* 流量重置与充值:`queue/logic/order/activateOrderLogic.go:564-626`, `630-675` + +1. 订单与费用模型 + +* 订单结构:`internal/model/order/order.go:5-29` 可继续承载 IAP 订单(新增字段映射 Apple 交易) + +* 费用/折扣/礼金计算逻辑保持不变 + +1. 队列驱动 + +* 仍使用“支付成功→入队→处理”的模式:`queue/logic/order/activateOrderLogic.go:65-86` + +## 必须独立实现的部分 + +1. Apple IAP 支付与校验 + +* 客户端使用 StoreKit 购买,拿到 `originalTransactionId` + +* 服务端调用 App Store Server API,基于 `originalTransactionId` 校验订阅有效性并取交易历史 + +1. Apple Server Notifications v2 + +* 在 App Store Connect 配置通知 URL + +* 服务端实现 JWS 验签,解析事件并落库:续期、失败、宽限期、退款、撤销等 + +## 整合方式(复用策略) + +1. 引入平台枚举“AppleIAP” + +* 在 `pkg/payment/platform.go` 增加 `AppleIAP`,用于平台标识与管理端展示 + +1. 订单创建策略(两种) + +* 方案 A(推荐):用户在 iOS 内购完成后由客户端上报 `originalTransactionId`,服务端校验通过后“合成一个已支付订单”(`status=2`),触发既有赋权队列 + +* 方案 B:也可预建“待支付订单”,但 `PurchaseCheckout` 不走网关,只返回“client\_iap”类型,提示客户端用 StoreKit;支付完成后再校验并更新为 `Paid` 入队 + +1. 状态与权益判定 + +* 服务端统一以 Apple 校验与通知为准,抽象为 `active/in_grace_period/in_billing_retry/expired/revoked` 并映射到你的订阅与订单状态 + +## 服务端接口与流程 + +* `POST /apple/iap/verify`:入参 `originalTransactionId`,校验并创建/更新订单与用户订阅,返回当前权益 + +* `POST /apple/iap/notifications`:接收 Apple JWS 通知,验签后更新订阅与订单状态(幂等) + +* `GET /subscriptions/me`:面向客户端查询当前订阅与权益(聚合 Apple 校验结果) + +## 数据模型映射 + +* 在订单/订阅表补充字段(建议):`Provider=apple_iap`、`OriginalTransactionId`、`Environment`、`ExpiresDate`、`AutoRenewStatus`、`InGracePeriod`、`LastEventType` + +* 产品映射:`productId(App Store) ↔ internal subscribeId`,保证同一权益统一计费 + +## 回调与安全 + +* JWS 验签:缓存 Apple JWKS 公钥、短生命周期缓存 + +* App Store Server API:使用 App Store Connect API Key(ES256)发起请求,区分生产/沙盒 + +* 幂等:按 `notificationId/transactionId` 去重 + +## 测试与上线 + +* 沙盒测试:购买、续期、失败、宽限期、退款全链路;注意沙盒元数据生效可能需 \~1 小时 + +* 监控:通知处理失败、验签失败、API 调用异常报警 + +## 交付物(最小实现) + +1. 平台枚举新增 `AppleIAP` +2. `POST /apple/iap/verify` 与 `POST /apple/iap/notifications` 路由与逻辑骨架 +3. App Store Server API 客户端封装(校验、交易历史、订阅状态) +4. 订单合成与入队赋权打通(复用 `OrderStatusPaid` → `ProcessTask`) +5. 数据表字段扩展与迁移脚本 + diff --git a/internal/handler/notify.go b/internal/handler/notify.go index 3055dcd..83e6d6b 100644 --- a/internal/handler/notify.go +++ b/internal/handler/notify.go @@ -8,10 +8,15 @@ import ( ) func RegisterNotifyHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { - group := router.Group("/v1/notify/") - group.Use(middleware.NotifyMiddleware(serverCtx)) - { - group.Any("/:platform/:token", notify.PaymentNotifyHandler(serverCtx)) - } + group := router.Group("/v1/notify/") + group.Use(middleware.NotifyMiddleware(serverCtx)) + { + group.Any(":platform/:token", notify.PaymentNotifyHandler(serverCtx)) + } + + iap := router.Group("/v1/iap") + { + iap.POST("/notifications", notify.AppleIAPNotifyHandler(serverCtx)) + } } diff --git a/internal/handler/notify/appleIAPNotifyHandler.go b/internal/handler/notify/appleIAPNotifyHandler.go new file mode 100644 index 0000000..d500921 --- /dev/null +++ b/internal/handler/notify/appleIAPNotifyHandler.go @@ -0,0 +1,20 @@ +package notify + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/notify" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" +) + +// AppleIAPNotifyHandler 处理 Apple Server Notifications v2 +// 参数: 原始 HTTP 请求体 +// 返回: 处理结果(空体 200) +func AppleIAPNotifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + l := notify.NewAppleIAPNotifyLogic(c.Request.Context(), svcCtx) + err := l.Handle(c.Request) + result.HttpResult(c, gin.H{"success": err == nil}, err) + } +} + diff --git a/internal/handler/notify/paymentNotifyHandler.go b/internal/handler/notify/paymentNotifyHandler.go index cd7d8b9..bd5cb3c 100644 --- a/internal/handler/notify/paymentNotifyHandler.go +++ b/internal/handler/notify/paymentNotifyHandler.go @@ -39,7 +39,7 @@ func PaymentNotifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return } c.String(http.StatusOK, "%s", "success") - case payment.Stripe: + case payment.Stripe, payment.ApplePay: l := notify.NewStripeNotifyLogic(c.Request.Context(), svcCtx) if err := l.StripeNotify(c.Request, c.Writer); err != nil { result.HttpResult(c, nil, err) diff --git a/internal/handler/public/iap/verifyHandler.go b/internal/handler/public/iap/verifyHandler.go new file mode 100644 index 0000000..a38a967 --- /dev/null +++ b/internal/handler/public/iap/verifyHandler.go @@ -0,0 +1,29 @@ +package iap + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/iap" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// VerifyHandler 处理 iOS IAP 初购验证并生成已支付订单 +// 参数: IAPVerifyRequest +// 返回: IAPVerifyResponse +func VerifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.IAPVerifyRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := iap.NewVerifyLogic(c.Request.Context(), svcCtx) + resp, err := l.Verify(&req) + result.HttpResult(c, resp, err) + } +} + diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 410d16d..0199c64 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -26,7 +26,8 @@ import ( authOauth "github.com/perfect-panel/server/internal/handler/auth/oauth" common "github.com/perfect-panel/server/internal/handler/common" publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement" - publicDocument "github.com/perfect-panel/server/internal/handler/public/document" + publicDocument "github.com/perfect-panel/server/internal/handler/public/document" + publicIAP "github.com/perfect-panel/server/internal/handler/public/iap" publicOrder "github.com/perfect-panel/server/internal/handler/public/order" publicPayment "github.com/perfect-panel/server/internal/handler/public/payment" publicPortal "github.com/perfect-panel/server/internal/handler/public/portal" @@ -671,7 +672,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { publicAnnouncementGroupRouter.GET("/list", publicAnnouncement.QueryAnnouncementHandler(serverCtx)) } - publicDocumentGroupRouter := router.Group("/v1/public/document") + publicDocumentGroupRouter := router.Group("/v1/public/document") publicDocumentGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) { @@ -680,7 +681,14 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get document list publicDocumentGroupRouter.GET("/list", publicDocument.QueryDocumentListHandler(serverCtx)) - } + } + + publicIAPGroupRouter := router.Group("/v1/public/iap") + publicIAPGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) + + { + publicIAPGroupRouter.POST("/verify", publicIAP.VerifyHandler(serverCtx)) + } publicOrderGroupRouter := router.Group("/v1/public/order") publicOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) diff --git a/internal/logic/notify/appleIAPNotifyLogic.go b/internal/logic/notify/appleIAPNotifyLogic.go new file mode 100644 index 0000000..3a12401 --- /dev/null +++ b/internal/logic/notify/appleIAPNotifyLogic.go @@ -0,0 +1,134 @@ +package notify + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/hibiken/asynq" + "github.com/perfect-panel/server/internal/model/order" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/appleiap" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/payment" + queueType "github.com/perfect-panel/server/queue/types" +) + +// AppleIAPNotifyLogic 处理 Apple Server Notifications v2 的逻辑 +// 功能: 验签与事件解析(此处提供最小骨架),将续期/初购事件转换为订单并入队赋权 +// 参数: HTTP 请求 +// 返回: 错误信息 +type AppleIAPNotifyLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewAppleIAPNotifyLogic 创建逻辑实例 +// 参数: 上下文, 服务上下文 +// 返回: 逻辑指针 +func NewAppleIAPNotifyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AppleIAPNotifyLogic { + return &AppleIAPNotifyLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// AppleNotification 简化的通知结构(骨架) +type rawPayload struct { + SignedPayload string `json:"signedPayload"` +} + +type transactionInfo struct { + OriginalTransactionId string `json:"originalTransactionId"` + TransactionId string `json:"transactionId"` + ProductId string `json:"productId"` +} + +// Handle 处理通知 +// 参数: *http.Request +// 返回: error +func (l *AppleIAPNotifyLogic) Handle(r *http.Request) error { + body, _ := io.ReadAll(r.Body) + var rp rawPayload + if err := json.Unmarshal(body, &rp); err != nil { + l.Errorw("[AppleIAP] Unmarshal request failed", logger.Field("error", err.Error())) + return err + } + claims, env, err := appleiap.VerifyAutoEnv(rp.SignedPayload) + if err != nil { + l.Errorw("[AppleIAP] Verify payload failed", logger.Field("error", err.Error())) + return err + } + t, _ := claims["notificationType"].(string) + data, _ := claims["data"].(map[string]interface{}) + sti, _ := data["signedTransactionInfo"].(string) + txClaims, err := appleiap.VerifyWithEnv(env, sti) + if err != nil { + l.Errorw("[AppleIAP] Verify transaction failed", logger.Field("error", err.Error())) + return err + } + b, _ := json.Marshal(txClaims) + var tx transactionInfo + _ = json.Unmarshal(b, &tx) + + switch t { + case "INITIAL_BUY": + return l.processInitialBuy(env, tx) + case "DID_RENEW": + return l.processRenew(env, tx) + default: + return nil + } +} + +// createPaidOrderAndEnqueue 创建已支付订单并入队赋权/续费 +// 参数: AppleNotification, 订单类型 +// 返回: error +func (l *AppleIAPNotifyLogic) processInitialBuy(env string, tx transactionInfo) error { + if tx.OriginalTransactionId == "" || tx.TransactionId == "" { + return nil + } + // if order already exists, ignore + if oi, err := l.svcCtx.OrderModel.FindOneByTradeNo(l.ctx, tx.OriginalTransactionId); err == nil && oi != nil { + return nil + } + return nil +} + +func (l *AppleIAPNotifyLogic) processRenew(env string, tx transactionInfo) error { + if tx.OriginalTransactionId == "" || tx.TransactionId == "" { + return nil + } + oi, err := l.svcCtx.OrderModel.FindOneByTradeNo(l.ctx, tx.OriginalTransactionId) + if err != nil || oi == nil { + return nil + } + o := &order.Order{ + UserId: oi.UserId, + OrderNo: tx.TransactionId, + Type: 2, + Quantity: 1, + Price: 0, + Amount: 0, + Discount: 0, + Coupon: "", + CouponDiscount: 0, + PaymentId: 0, + Method: payment.AppleIAP.String(), + FeeAmount: 0, + Status: 2, + IsNew: false, + SubscribeId: oi.SubscribeId, + TradeNo: tx.OriginalTransactionId, + SubscribeToken: oi.SubscribeToken, + } + if err := l.svcCtx.OrderModel.Insert(l.ctx, o); err != nil { + return err + } + payload := queueType.ForthwithActivateOrderPayload{OrderNo: o.OrderNo} + bytes, _ := json.Marshal(payload) + task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes) + if _, err := l.svcCtx.Queue.EnqueueContext(l.ctx, task); err != nil { + return err + } + return nil +} diff --git a/internal/logic/public/iap/verifyLogic.go b/internal/logic/public/iap/verifyLogic.go new file mode 100644 index 0000000..a6e54db --- /dev/null +++ b/internal/logic/public/iap/verifyLogic.go @@ -0,0 +1,104 @@ +package iap + +import ( + "context" + "encoding/json" + "github.com/hibiken/asynq" + "github.com/perfect-panel/server/internal/model/order" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/payment" + "github.com/perfect-panel/server/pkg/tool" + "github.com/perfect-panel/server/pkg/xerr" + queueType "github.com/perfect-panel/server/queue/types" + "github.com/pkg/errors" +) + +// VerifyLogic 处理 IAP 初购验证并生成已支付订阅订单 +// 功能: 校验用户与订阅参数, 创建已支付订单并触发赋权队列 +// 参数: IAPVerifyRequest +// 返回: IAPVerifyResponse 与错误 +type VerifyLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewVerifyLogic 创建 VerifyLogic +// 参数: 上下文, 服务上下文 +// 返回: VerifyLogic 指针 +func NewVerifyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VerifyLogic { + return &VerifyLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// Verify 执行 IAP 初购验证并创建订单 +// 参数: IAPVerifyRequest 包含 original_transaction_id 与 subscribe_id +// 返回: IAPVerifyResponse 包含 order_no +func (l *VerifyLogic) Verify(req *types.IAPVerifyRequest) (resp *types.IAPVerifyResponse, err error) { + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + logger.Error("current user is not found in context") + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + if req.SubscribeId <= 0 { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid subscribe_id") + } + + sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId) + if err != nil { + l.Errorw("[IAP Verify] Find subscribe failed", logger.Field("error", err.Error()), logger.Field("subscribe_id", req.SubscribeId)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error()) + } + if sub.Sell != nil && !*sub.Sell { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "subscribe not sell") + } + + isNew, err := l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id) + if err != nil { + l.Errorw("[IAP Verify] Query user new purchase failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user error: %v", err.Error()) + } + + orderInfo := &order.Order{ + UserId: u.Id, + OrderNo: tool.GenerateTradeNo(), + Type: 1, + Quantity: 1, + Price: sub.UnitPrice, + Amount: 0, + Discount: 0, + Coupon: "", + CouponDiscount: 0, + PaymentId: 0, + Method: payment.AppleIAP.String(), + FeeAmount: 0, + Status: 2, + IsNew: isNew, + SubscribeId: req.SubscribeId, + TradeNo: req.OriginalTransactionId, + } + + if err = l.svcCtx.OrderModel.Insert(l.ctx, orderInfo); err != nil { + l.Errorw("[IAP Verify] Insert order failed", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert order error: %v", err.Error()) + } + + payload := queueType.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo} + bytes, err := json.Marshal(payload) + if err != nil { + l.Errorw("[IAP Verify] Marshal payload failed", logger.Field("error", err.Error())) + return nil, err + } + task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes) + if _, err = l.svcCtx.Queue.EnqueueContext(l.ctx, task); err != nil { + l.Errorw("[IAP Verify] Enqueue activation failed", logger.Field("error", err.Error())) + return nil, err + } + + return &types.IAPVerifyResponse{OrderNo: orderInfo.OrderNo}, nil +} + diff --git a/internal/logic/public/portal/purchaseCheckoutLogic.go b/internal/logic/public/portal/purchaseCheckoutLogic.go index cb6f594..18065b2 100644 --- a/internal/logic/public/portal/purchaseCheckoutLogic.go +++ b/internal/logic/public/portal/purchaseCheckoutLogic.go @@ -86,7 +86,18 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest case paymentPlatform.Stripe: // Process Stripe payment - creates payment sheet for client-side processing - stripePayment, err := l.stripePayment(paymentConfig.Config, orderInfo, "") + stripePayment, err := l.stripePayment(paymentConfig.Config, orderInfo, "", "") + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "stripePayment error: %v", err.Error()) + } + resp = &types.CheckoutOrderResponse{ + Type: "stripe", // Client should use Stripe SDK + Stripe: stripePayment, + } + + case paymentPlatform.ApplePay: + // Process Apple Pay payment - uses Stripe with 'card' method + stripePayment, err := l.stripePayment(paymentConfig.Config, orderInfo, "", "card") if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "stripePayment error: %v", err.Error()) } @@ -208,7 +219,7 @@ func (l *PurchaseCheckoutLogic) alipayF2fPayment(pay *payment.Payment, info *ord // stripePayment processes Stripe payment by creating a payment sheet // It supports various payment methods including WeChat Pay and Alipay through Stripe -func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order, identifier string) (*types.StripePayment, error) { +func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order, identifier string, forceMethod string) (*types.StripePayment, error) { // Parse Stripe configuration from payment settings stripeConfig := &payment.StripeConfig{} @@ -217,6 +228,10 @@ func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order, return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error()) } + if forceMethod != "" { + stripeConfig.Payment = forceMethod + } + // Initialize Stripe client with API credentials client := stripe.NewClient(stripe.Config{ SecretKey: stripeConfig.SecretKey, diff --git a/internal/model/order/default.go b/internal/model/order/default.go index a59eeb0..29f2216 100644 --- a/internal/model/order/default.go +++ b/internal/model/order/default.go @@ -12,23 +12,25 @@ import ( var _ Model = (*customOrderModel)(nil) var ( - cacheOrderIdPrefix = "cache:order:id:" - cacheOrderNoPrefix = "cache:order:no:" + cacheOrderIdPrefix = "cache:order:id:" + cacheOrderNoPrefix = "cache:order:no:" + cacheOrderTradePrefix = "cache:order:trade:" ) type ( - Model interface { - orderModel - customOrderLogicModel - } - orderModel interface { - Insert(ctx context.Context, data *Order, tx ...*gorm.DB) error - FindOne(ctx context.Context, id int64) (*Order, error) - FindOneByOrderNo(ctx context.Context, orderNo string) (*Order, error) - Update(ctx context.Context, data *Order, tx ...*gorm.DB) error - Delete(ctx context.Context, id int64, tx ...*gorm.DB) error - Transaction(ctx context.Context, fn func(db *gorm.DB) error) error - } + Model interface { + orderModel + customOrderLogicModel + } + orderModel interface { + Insert(ctx context.Context, data *Order, tx ...*gorm.DB) error + FindOne(ctx context.Context, id int64) (*Order, error) + FindOneByOrderNo(ctx context.Context, orderNo string) (*Order, error) + FindOneByTradeNo(ctx context.Context, tradeNo string) (*Order, error) + Update(ctx context.Context, data *Order, tx ...*gorm.DB) error + Delete(ctx context.Context, id int64, tx ...*gorm.DB) error + Transaction(ctx context.Context, fn func(db *gorm.DB) error) error + } customOrderModel struct { *defaultOrderModel @@ -60,12 +62,14 @@ func (m *defaultOrderModel) getCacheKeys(data *Order) []string { return []string{} } orderIdKey := fmt.Sprintf("%s%v", cacheOrderIdPrefix, data.Id) - orderNoKey := fmt.Sprintf("%s%v", cacheOrderNoPrefix, data.OrderNo) - cacheKeys := []string{ - orderIdKey, - orderNoKey, - } - return cacheKeys + orderNoKey := fmt.Sprintf("%s%v", cacheOrderNoPrefix, data.OrderNo) + tradeNoKey := fmt.Sprintf("%s%v", cacheOrderTradePrefix, data.TradeNo) + cacheKeys := []string{ + orderIdKey, + orderNoKey, + tradeNoKey, + } + return cacheKeys } func (m *defaultOrderModel) Insert(ctx context.Context, data *Order, tx ...*gorm.DB) error { @@ -106,6 +110,20 @@ func (m *defaultOrderModel) FindOneByOrderNo(ctx context.Context, orderNo string } } +func (m *defaultOrderModel) FindOneByTradeNo(ctx context.Context, tradeNo string) (*Order, error) { + OrderTradeKey := fmt.Sprintf("%s%v", cacheOrderTradePrefix, tradeNo) + var resp Order + err := m.QueryCtx(ctx, &resp, OrderTradeKey, func(conn *gorm.DB, v interface{}) error { + return conn.Model(&Order{}).Where("`trade_no` = ?", tradeNo).First(&resp).Error + }) + switch { + case err == nil: + return &resp, nil + default: + return nil, err + } +} + func (m *defaultOrderModel) Update(ctx context.Context, data *Order, tx ...*gorm.DB) error { old, err := m.FindOne(ctx, data.Id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/types/types.go b/internal/types/types.go index e8eade2..2b8d5c0 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -72,9 +72,18 @@ type AppUserSubscbribeNode struct { } type AppleLoginCallbackRequest struct { - Code string `form:"code"` - IDToken string `form:"id_token"` - State string `form:"state"` + Code string `form:"code"` + IDToken string `form:"id_token"` + State string `form:"state"` +} + +type IAPVerifyRequest struct { + OriginalTransactionId string `json:"original_transaction_id" validate:"required"` + SubscribeId int64 `json:"subscribe_id" validate:"required"` +} + +type IAPVerifyResponse struct { + OrderNo string `json:"order_no"` } type Application struct { diff --git a/pkg/appleiap/jwks.go b/pkg/appleiap/jwks.go new file mode 100644 index 0000000..840ee84 --- /dev/null +++ b/pkg/appleiap/jwks.go @@ -0,0 +1,87 @@ +package appleiap + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "net/http" + "sync" + "time" +) + +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +type jwks struct { + Keys []jwk `json:"keys"` +} + +type cacheEntry struct { + keys map[string]*ecdsa.PublicKey + exp time.Time +} + +var ( + mu sync.Mutex + cache = map[string]*cacheEntry{} +) + +func endpoint(env string) string { + if env == "sandbox" { + return "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/keys" + } + return "https://api.storekit.itunes.apple.com/inApps/v1/keys" +} + +func fetch(env string) (map[string]*ecdsa.PublicKey, error) { + resp, err := http.Get(endpoint(env)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var set jwks + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + return nil, err + } + m := make(map[string]*ecdsa.PublicKey) + for _, k := range set.Keys { + if k.Kty != "EC" || k.Crv != "P-256" || k.X == "" || k.Y == "" || k.Kid == "" { + continue + } + xb, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { continue } + yb, err := base64.RawURLEncoding.DecodeString(k.Y) + if err != nil { continue } + var x, y big.Int + x.SetBytes(xb) + y.SetBytes(yb) + m[k.Kid] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: &x, Y: &y} + } + if len(m) == 0 { + return nil, errors.New("empty jwks") + } + return m, nil +} + +func GetKey(env, kid string) (*ecdsa.PublicKey, error) { + mu.Lock() + defer mu.Unlock() + c := cache[env] + if c == nil || time.Now().After(c.exp) { + keys, err := fetch(env) + if err != nil { return nil, err } + cache[env] = &cacheEntry{ keys: keys, exp: time.Now().Add(10 * time.Minute) } + c = cache[env] + } + k := c.keys[kid] + if k == nil { return nil, errors.New("key not found") } + return k, nil +} diff --git a/pkg/appleiap/jws.go b/pkg/appleiap/jws.go new file mode 100644 index 0000000..595d702 --- /dev/null +++ b/pkg/appleiap/jws.go @@ -0,0 +1,29 @@ +package appleiap + +import ( + "errors" + "github.com/golang-jwt/jwt/v5" +) + +func verifyWithEnv(env, token string) (jwt.MapClaims, error) { + parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + h, ok := t.Header["kid"].(string) + if !ok { return nil, errors.New("kid missing") } + return GetKey(env, h) + }) + if err != nil { return nil, err } + if !parsed.Valid { return nil, errors.New("invalid jws") } + c, ok := parsed.Claims.(jwt.MapClaims) + if !ok { return nil, errors.New("claims invalid") } + return c, nil +} + +func VerifyWithEnv(env, token string) (jwt.MapClaims, error) { return verifyWithEnv(env, token) } + +func VerifyAutoEnv(token string) (jwt.MapClaims, string, error) { + c, err := verifyWithEnv("production", token) + if err == nil { return c, "production", nil } + c2, err2 := verifyWithEnv("sandbox", token) + if err2 == nil { return c2, "sandbox", nil } + return nil, "", err +} diff --git a/pkg/payment/platform.go b/pkg/payment/platform.go index 7ad12ea..9cc6af1 100644 --- a/pkg/payment/platform.go +++ b/pkg/payment/platform.go @@ -10,6 +10,8 @@ const ( EPay Balance CryptoSaaS + AppleIAP + ApplePay UNSUPPORTED Platform = -1 ) @@ -19,6 +21,8 @@ var platformNames = map[string]Platform{ "AlipayF2F": AlipayF2F, "EPay": EPay, "balance": Balance, + "AppleIAP": AppleIAP, + "ApplePay": ApplePay, "unsupported": UNSUPPORTED, } @@ -80,5 +84,24 @@ func GetSupportedPlatforms() []types.PlatformInfo { "secret_key": "Secret Key", }, }, + { + Platform: AppleIAP.String(), + PlatformUrl: "https://developer.apple.com/help/app-store-connect/", + PlatformFieldDescription: map[string]string{ + "issuer_id": "App Store Connect Issuer ID", + "key_id": "App Store Connect Key ID", + "private_key": "Private Key (ES256)", + "environment": "Environment: Sandbox/Production", + }, + }, + { + Platform: ApplePay.String(), + PlatformUrl: "https://stripe.com", + PlatformFieldDescription: map[string]string{ + "public_key": "Publishable key", + "secret_key": "Secret key", + "webhook_secret": "Webhook secret", + }, + }, } }