From 72400ae054066da928ed5c1a165bc07ce21acbd6 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Mon, 15 Dec 2025 17:49:16 -0800 Subject: [PATCH] =?UTF-8?q?feat(appleIAP):=20=E5=AE=9E=E7=8E=B0=E8=8B=B9?= =?UTF-8?q?=E6=9E=9C=E5=BA=94=E7=94=A8=E5=86=85=E8=B4=AD=E4=B9=B0=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加苹果IAP通知处理功能,包括解析和验证JWS签名、处理交易状态变更 新增订单号字段用于关联订单处理 实现交易记录的创建和更新逻辑 处理订阅状态的变更和过期时间计算 --- .../handler/notify/appleIAPNotifyHandler.go | 11 ++- internal/logic/notify/appleIAPNotifyLogic.go | 78 +++++++++++++++++++ .../iap/apple/attachTransactionLogic.go | 42 ++++++++-- internal/types/types.go | 1 + pkg/iap/apple/jws.go | 55 +++++++++++++ pkg/iap/apple/notification.go | 59 ++++++++++++++ 6 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 internal/logic/notify/appleIAPNotifyLogic.go create mode 100644 pkg/iap/apple/notification.go diff --git a/internal/handler/notify/appleIAPNotifyHandler.go b/internal/handler/notify/appleIAPNotifyHandler.go index 34b9abf..005e147 100644 --- a/internal/handler/notify/appleIAPNotifyHandler.go +++ b/internal/handler/notify/appleIAPNotifyHandler.go @@ -2,12 +2,21 @@ package notify import ( "github.com/gin-gonic/gin" + "io" + "encoding/json" + "github.com/perfect-panel/server/internal/logic/notify" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/pkg/result" ) func AppleIAPNotifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { - result.HttpResult(c, map[string]bool{"success": true}, nil) + raw, _ := io.ReadAll(c.Request.Body) + var body map[string]interface{} + _ = json.Unmarshal(raw, &body) + sp, _ := body["signedPayload"].(string) + l := notify.NewAppleIAPNotifyLogic(c.Request.Context(), svcCtx) + err := l.Handle(sp) + result.HttpResult(c, map[string]bool{"success": err == nil}, err) } } diff --git a/internal/logic/notify/appleIAPNotifyLogic.go b/internal/logic/notify/appleIAPNotifyLogic.go new file mode 100644 index 0000000..3c5450e --- /dev/null +++ b/internal/logic/notify/appleIAPNotifyLogic.go @@ -0,0 +1,78 @@ +package notify + +import ( + "context" + + iapmodel "github.com/perfect-panel/server/internal/model/iap/apple" + "github.com/perfect-panel/server/internal/svc" + iapapple "github.com/perfect-panel/server/pkg/iap/apple" + "github.com/perfect-panel/server/pkg/logger" + "gorm.io/gorm" +) + +type AppleIAPNotifyLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAppleIAPNotifyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AppleIAPNotifyLogic { + return &AppleIAPNotifyLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error { + txPayload, _, err := iapapple.VerifyNotificationSignedPayload(signedPayload) + if err != nil { + return err + } + return l.svcCtx.DB.Transaction(func(db *gorm.DB) error { + var existing *iapmodel.Transaction + existing, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId) + if existing == nil || existing.Id == 0 { + rec := &iapmodel.Transaction{ + UserId: 0, + OriginalTransactionId: txPayload.OriginalTransactionId, + TransactionId: txPayload.TransactionId, + ProductId: txPayload.ProductId, + PurchaseAt: txPayload.PurchaseDate, + RevocationAt: txPayload.RevocationDate, + JWSHash: "", + } + if e := db.Model(&iapmodel.Transaction{}).Create(rec).Error; e != nil { + return e + } + } else { + if txPayload.RevocationDate != nil { + if e := db.Model(&iapmodel.Transaction{}). + Where("original_transaction_id = ?", txPayload.OriginalTransactionId). + Update("revocation_at", txPayload.RevocationDate).Error; e != nil { + return e + } + } + } + pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData) + m := pm.Items[txPayload.ProductId] + token := "iap:" + txPayload.OriginalTransactionId + sub, e := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token) + if e == nil && sub != nil && sub.Id != 0 { + if txPayload.RevocationDate != nil { + sub.Status = 3 + t := *txPayload.RevocationDate + sub.FinishedAt = &t + sub.ExpireTime = t + } else if m.DurationDays > 0 { + exp := iapapple.CalcExpire(txPayload.PurchaseDate, m.DurationDays) + sub.ExpireTime = exp + sub.Status = 1 + } + if e := l.svcCtx.UserModel.UpdateSubscribe(l.ctx, sub, db); e != nil { + return e + } + } + return nil + }) +} diff --git a/internal/logic/public/iap/apple/attachTransactionLogic.go b/internal/logic/public/iap/apple/attachTransactionLogic.go index 0edcc1f..614df2c 100644 --- a/internal/logic/public/iap/apple/attachTransactionLogic.go +++ b/internal/logic/public/iap/apple/attachTransactionLogic.go @@ -4,9 +4,12 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "time" + "github.com/google/uuid" + "github.com/hibiken/asynq" iapmodel "github.com/perfect-panel/server/internal/model/iap/apple" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -15,9 +18,9 @@ import ( iapapple "github.com/perfect-panel/server/pkg/iap/apple" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" + queueType "github.com/perfect-panel/server/queue/types" "github.com/pkg/errors" "gorm.io/gorm" - "github.com/google/uuid" ) type AttachTransactionLogic struct { @@ -39,10 +42,13 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest if !ok || u == nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access") } - txPayload, err := iapapple.ParseTransactionJWS(req.SignedTransactionJWS) + txPayload, err := iapapple.VerifyTransactionJWS(req.SignedTransactionJWS) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid jws") } + // idempotency: check existing transaction by original id + var existTx *iapmodel.Transaction + existTx, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId) pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData) m, ok := pm.Items[txPayload.ProductId] var duration int64 @@ -73,8 +79,10 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest JWSHash: jwsHash, } err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { - if e := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; e != nil { - return e + if existTx == nil || existTx.Id == 0 { + if e := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; e != nil { + return e + } } // insert user_subscribe userSub := user.Subscribe{ @@ -89,7 +97,31 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest UUID: uuid.New().String(), Status: 1, } - return l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx) + if e := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); e != nil { + return e + } + // optional: mark related order as paid and enqueue activation + if req.OrderNo != "" { + orderInfo, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo) + if e != nil { + // do not fail transaction if order not found; just continue + return nil + } + if orderInfo.Status == 1 { + if e := l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OrderNo, 2, tx); e != nil { + return e + } + } + // enqueue activation regardless (idempotent handler downstream) + payload := queueType.ForthwithActivateOrderPayload{OrderNo: req.OrderNo} + bytes, _ := json.Marshal(payload) + task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes) + if _, e := l.svcCtx.Queue.EnqueueContext(l.ctx, task); e != nil { + // non-fatal + l.Errorw("enqueue activate task error", logger.Field("error", e.Error())) + } + } + return nil }) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert error: %v", err.Error()) diff --git a/internal/types/types.go b/internal/types/types.go index c0c162e..5a41544 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2865,6 +2865,7 @@ type AttachAppleTransactionRequest struct { DurationDays int64 `json:"duration_days,omitempty"` Tier string `json:"tier,omitempty"` SubscribeId int64 `json:"subscribe_id,omitempty"` + OrderNo string `json:"order_no,omitempty"` } type AttachAppleTransactionResponse struct { diff --git a/pkg/iap/apple/jws.go b/pkg/iap/apple/jws.go index 00eca98..66d3492 100644 --- a/pkg/iap/apple/jws.go +++ b/pkg/iap/apple/jws.go @@ -1,6 +1,9 @@ package apple import ( + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" "encoding/base64" "encoding/json" "strings" @@ -53,3 +56,55 @@ func ParseTransactionJWS(jws string) (*TransactionPayload, error) { return &resp, nil } +type jwsHeader struct { + Alg string `json:"alg"` + X5c []string `json:"x5c"` + Kid string `json:"kid"` +} + +func VerifyTransactionJWS(jws string) (*TransactionPayload, error) { + parts := strings.Split(jws, ".") + if len(parts) != 3 { + return nil, ErrInvalidJWS + } + hdrB64 := parts[0] + switch len(hdrB64) % 4 { + case 2: + hdrB64 += "==" + case 3: + hdrB64 += "=" + } + var hdr jwsHeader + hdrBytes, err := base64.RawURLEncoding.DecodeString(hdrB64) + if err != nil { + return nil, err + } + if err = json.Unmarshal(hdrBytes, &hdr); err != nil { + return nil, err + } + if len(hdr.X5c) == 0 { + return nil, ErrInvalidJWS + } + certDer, err := base64.StdEncoding.DecodeString(hdr.X5c[0]) + if err != nil { + return nil, err + } + cert, err := x509.ParseCertificate(certDer) + if err != nil { + return nil, err + } + pub, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + return nil, ErrInvalidJWS + } + signingInput := parts[0] + "." + parts[1] + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, err + } + d := sha256.Sum256([]byte(signingInput)) + if !ecdsa.VerifyASN1(pub, d[:], sig) { + return nil, ErrInvalidJWS + } + return ParseTransactionJWS(jws) +} diff --git a/pkg/iap/apple/notification.go b/pkg/iap/apple/notification.go new file mode 100644 index 0000000..7649aa2 --- /dev/null +++ b/pkg/iap/apple/notification.go @@ -0,0 +1,59 @@ +package apple + +import ( + "encoding/base64" + "encoding/json" + "strings" +) + +type NotificationEnvelope struct { + NotificationType string + TransactionJWS string +} + +func ParseNotificationSignedPayload(jws string) (*NotificationEnvelope, error) { + parts := strings.Split(jws, ".") + if len(parts) != 3 { + return nil, ErrInvalidJWS + } + payloadB64 := parts[1] + switch len(payloadB64) % 4 { + case 2: + payloadB64 += "==" + case 3: + payloadB64 += "=" + } + data, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return nil, err + } + var raw map[string]interface{} + if err = json.Unmarshal(data, &raw); err != nil { + return nil, err + } + env := &NotificationEnvelope{} + if v, ok := raw["notificationType"].(string); ok { + env.NotificationType = v + } + if d, ok := raw["data"].(map[string]interface{}); ok { + if sjws, ok := d["signedTransactionInfo"].(string); ok { + env.TransactionJWS = sjws + } + } + return env, nil +} + +func VerifyNotificationSignedPayload(jws string) (*TransactionPayload, string, error) { + env, err := ParseNotificationSignedPayload(jws) + if err != nil { + return nil, "", err + } + if env.TransactionJWS == "" { + return nil, env.NotificationType, ErrInvalidJWS + } + tx, err := VerifyTransactionJWS(env.TransactionJWS) + if err != nil { + return nil, env.NotificationType, err + } + return tx, env.NotificationType, nil +}