feat(iap/apple): 实现苹果IAP非续期订阅功能
Build docker and publish / build (20.15.1) (push) Successful in 6m37s

新增苹果IAP相关接口与逻辑,包括产品列表查询、交易绑定、状态查询和恢复购买功能。移除旧的IAP验证逻辑,重构订阅系统以支持苹果IAP交易记录存储和权益计算。

- 新增/pkg/iap/apple包处理JWS解析和产品映射
- 实现GET /products、POST /attach、POST /restore和GET /status接口
- 新增apple_iap_transactions表存储交易记录
- 更新文档说明配置方式和接口规范
- 移除旧的AppleIAP验证和通知处理逻辑
This commit is contained in:
2025-12-13 20:54:50 -08:00
parent a80d6af035
commit 62186ca672
41 changed files with 1715 additions and 474 deletions
@@ -1,134 +0,0 @@
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
}
@@ -0,0 +1,101 @@
package apple
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
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"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/google/uuid"
)
type AttachTransactionLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewAttachTransactionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AttachTransactionLogic {
return &AttachTransactionLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest) (*types.AttachAppleTransactionResponse, error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok || u == nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
txPayload, err := iapapple.ParseTransactionJWS(req.SignedTransactionJWS)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid jws")
}
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
m, ok := pm.Items[txPayload.ProductId]
var duration int64
var tier string
var subscribeId int64
if ok {
duration = m.DurationDays
tier = m.Tier
subscribeId = m.SubscribeId
} else {
if req.DurationDays <= 0 || req.SubscribeId <= 0 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "unknown product")
}
duration = req.DurationDays
tier = req.Tier
subscribeId = req.SubscribeId
}
exp := iapapple.CalcExpire(txPayload.PurchaseDate, duration)
sum := sha256.Sum256([]byte(req.SignedTransactionJWS))
jwsHash := hex.EncodeToString(sum[:])
iapTx := &iapmodel.Transaction{
UserId: u.Id,
OriginalTransactionId: txPayload.OriginalTransactionId,
TransactionId: txPayload.TransactionId,
ProductId: txPayload.ProductId,
PurchaseAt: txPayload.PurchaseDate,
RevocationAt: txPayload.RevocationDate,
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
}
// insert user_subscribe
userSub := user.Subscribe{
UserId: u.Id,
SubscribeId: subscribeId,
StartTime: time.Now(),
ExpireTime: exp,
Traffic: 0,
Download: 0,
Upload: 0,
Token: fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId),
UUID: uuid.New().String(),
Status: 1,
}
return l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx)
})
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert error: %v", err.Error())
}
return &types.AttachAppleTransactionResponse{
ExpiresAt: exp.Unix(),
Tier: tier,
}, nil
}
@@ -0,0 +1,42 @@
package apple
import (
"context"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
)
type GetProductsLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetProductsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetProductsLogic {
return &GetProductsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetProductsLogic) GetProducts() (*types.GetAppleProductsResponse, error) {
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
resp := &types.GetAppleProductsResponse{
List: make([]types.AppleProduct, 0, len(pm.Items)),
}
for pid, m := range pm.Items {
resp.List = append(resp.List, types.AppleProduct{
ProductId: pid,
Description: m.Description,
PriceText: m.PriceText,
DurationDays: m.DurationDays,
Tier: m.Tier,
SubscribeId: m.SubscribeId,
})
}
return resp, nil
}
@@ -0,0 +1,62 @@
package apple
import (
"context"
"time"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/constant"
"github.com/pkg/errors"
)
type GetStatusLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetStatusLogic {
return &GetStatusLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetStatusLogic) GetStatus() (*types.GetAppleStatusResponse, error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*struct{ Id int64 })
if !ok || u == nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
var latest *iapmodel.Transaction
var err error
for pid := range pm.Items {
item, e := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByUserAndProduct(l.ctx, u.Id, pid)
if e == nil && item != nil && item.Id != 0 {
if latest == nil || item.PurchaseAt.After(latest.PurchaseAt) {
latest = item
}
}
}
if latest == nil {
return &types.GetAppleStatusResponse{
Active: false,
ExpiresAt: 0,
Tier: "",
}, nil
}
m := pm.Items[latest.ProductId]
exp := iapapple.CalcExpire(latest.PurchaseAt, m.DurationDays).Unix()
active := latest.RevocationAt == nil && (exp == 0 || exp > time.Now().Unix())
return &types.GetAppleStatusResponse{
Active: active,
ExpiresAt: exp,
Tier: m.Tier,
}, err
}
@@ -0,0 +1,86 @@
package apple
import (
"context"
"time"
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"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/google/uuid"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type RestoreLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRestoreLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RestoreLogic {
return &RestoreLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RestoreLogic) Restore(req *types.RestoreAppleTransactionsRequest) error {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok || u == nil {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
for _, j := range req.Transactions {
txp, err := iapapple.ParseTransactionJWS(j)
if err != nil {
continue
}
m, ok := pm.Items[txp.ProductId]
if !ok {
continue
}
_, e := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txp.OriginalTransactionId)
if e == nil {
continue
}
iapTx := &iapmodel.Transaction{
UserId: u.Id,
OriginalTransactionId: txp.OriginalTransactionId,
TransactionId: txp.TransactionId,
ProductId: txp.ProductId,
PurchaseAt: txp.PurchaseDate,
RevocationAt: txp.RevocationDate,
JWSHash: "",
}
if err := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; err != nil {
return err
}
exp := iapapple.CalcExpire(txp.PurchaseDate, m.DurationDays)
userSub := user.Subscribe{
UserId: u.Id,
SubscribeId: m.SubscribeId,
StartTime: time.Now(),
ExpireTime: exp,
Traffic: 0,
Download: 0,
Upload: 0,
Token: txp.OriginalTransactionId,
UUID: uuid.New().String(),
Status: 1,
}
if err := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); err != nil {
return err
}
}
return nil
})
}
-104
View File
@@ -1,104 +0,0 @@
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
}
@@ -5,6 +5,7 @@ import (
"encoding/json"
"math"
"strconv"
"strings"
"time"
"github.com/perfect-panel/server/internal/model/log"
@@ -224,8 +225,22 @@ func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order,
WebhookSecret: stripeConfig.WebhookSecret,
})
// Convert order amount to CNY using current exchange rate
amount, err := l.queryExchangeRate("CNY", info.Amount)
currency := "USD"
sysCurrency, _ := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
if sysCurrency != nil {
configs := struct {
CurrencyUnit string
CurrencySymbol string
AccessKey string
}{}
tool.SystemConfigSliceReflectToStruct(sysCurrency, &configs)
if configs.CurrencyUnit != "" {
currency = configs.CurrencyUnit
}
}
// Convert order amount to configured currency using current exchange rate
amount, err := l.queryExchangeRate(strings.ToUpper(currency), info.Amount)
if err != nil {
l.Errorw("[PurchaseCheckout] queryExchangeRate error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "queryExchangeRate error: %s", err.Error())
@@ -235,6 +250,7 @@ func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order,
logger.Field("src_cents", info.Amount),
logger.Field("decimal", amount),
logger.Field("cents", convertAmount),
logger.Field("currency", currency),
)
// Create Stripe payment sheet for client-side processing
@@ -247,7 +263,7 @@ func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order,
OrderNo: info.OrderNo,
Subscribe: strconv.FormatInt(info.SubscribeId, 10),
Amount: convertAmount,
Currency: "cny",
Currency: strings.ToLower(currency),
Payment: paymentMethod,
}
usr := &stripe.User{Email: identifier}