feat(appleIAP): 实现苹果应用内购买通知处理逻辑
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m36s
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m36s
添加苹果IAP通知处理功能,包括解析和验证JWS签名、处理交易状态变更 新增订单号字段用于关联订单处理 实现交易记录的创建和更新逻辑 处理订阅状态的变更和过期时间计算
This commit is contained in:
parent
1f5eb2784d
commit
72400ae054
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
78
internal/logic/notify/appleIAPNotifyLogic.go
Normal file
78
internal/logic/notify/appleIAPNotifyLogic.go
Normal file
@ -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
|
||||
})
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
59
pkg/iap/apple/notification.go
Normal file
59
pkg/iap/apple/notification.go
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user