feat(appleIAP): 实现苹果应用内购买通知处理逻辑
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m36s

添加苹果IAP通知处理功能,包括解析和验证JWS签名、处理交易状态变更
新增订单号字段用于关联订单处理
实现交易记录的创建和更新逻辑
处理订阅状态的变更和过期时间计算
This commit is contained in:
shanshanzhong 2025-12-15 17:49:16 -08:00
parent 1f5eb2784d
commit 72400ae054
6 changed files with 240 additions and 6 deletions

View File

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

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

View File

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

View File

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

View File

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

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