feat(支付): 新增Apple IAP支付支持
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled

实现Apple应用内购支付功能,包括:
1. 新增AppleIAP和ApplePay支付平台枚举
2. 添加IAP验证接口/v1/public/iap/verify处理初购验证
3. 实现Apple服务器通知处理逻辑/v1/iap/notifications
4. 新增JWS验签和JWKS公钥缓存功能
5. 复用现有订单系统处理IAP支付订单

相关文档已更新,包含接入方案和实现细节
This commit is contained in:
shanshanzhong 2025-12-09 00:53:25 -08:00
parent 4b6fcb338e
commit d95911d6bd
15 changed files with 654 additions and 34 deletions

View File

@ -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` 验证与“合成订单”生成;
- 第三步:通知验签与事件映射;
- 第四步:沙盒联调,确认队列赋权与续费延长。

View File

@ -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`
* 产品映射:`productIdApp Store ↔ internal subscribeId`,保证同一权益统一计费
## 回调与安全
* JWS 验签:缓存 Apple JWKS 公钥、短生命周期缓存
* App Store Server API使用 App Store Connect API KeyES256发起请求区分生产/沙盒
* 幂等:按 `notificationId/transactionId` 去重
## 测试与上线
* 沙盒测试:购买、续期、失败、宽限期、退款全链路;注意沙盒元数据生效可能需 \~1 小时
* 监控通知处理失败、验签失败、API 调用异常报警
## 交付物(最小实现)
1. 平台枚举新增 `AppleIAP`
2. `POST /apple/iap/verify``POST /apple/iap/notifications` 路由与逻辑骨架
3. App Store Server API 客户端封装(校验、交易历史、订阅状态)
4. 订单合成与入队赋权打通(复用 `OrderStatusPaid``ProcessTask`
5. 数据表字段扩展与迁移脚本

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

87
pkg/appleiap/jwks.go Normal file
View File

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

29
pkg/appleiap/jws.go Normal file
View File

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

View File

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