hi-server/queue/logic/iap/reconcileLogic.go
shanshanzhong d78ec194af
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m16s
feat: IAP 对账逻辑在激活订单前显式更新订单状态为已支付。
2026-03-10 21:53:14 -07:00

217 lines
8.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package iap
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/payment"
"github.com/perfect-panel/server/internal/svc"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
pkgpayment "github.com/perfect-panel/server/pkg/payment"
queueTypes "github.com/perfect-panel/server/queue/types"
)
// ReconcileLogic 第二层:每 5 分钟扫描 trade_no 非空的待支付 IAP 订单
type ReconcileLogic struct {
svc *svc.ServiceContext
}
func NewReconcileLogic(svc *svc.ServiceContext) *ReconcileLogic {
return &ReconcileLogic{svc: svc}
}
func (l *ReconcileLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error {
logger.Infof("[IAPReconcile] start at %s", time.Now().Format("2006-01-02 15:04:05"))
return l.reconcile(ctx, 5*time.Minute, 48*time.Hour, false)
}
// reconcile 核心对账逻辑,被第二层和第三层共享
func (l *ReconcileLogic) reconcile(ctx context.Context, minAge, maxAge time.Duration, requireTradeNo bool) error {
apiCfg, err := l.loadAppleAPIConfig(ctx)
if err != nil {
logger.Errorf("[IAPReconcile] load apple config error: %v", err)
return nil
}
if apiCfg == nil {
logger.Infof("[IAPReconcile] no enabled apple iap payment found, skip")
return nil
}
orders, err := l.svc.OrderModel.FindPendingIAPOrders(ctx, minAge, maxAge, requireTradeNo)
if err != nil {
logger.Errorf("[IAPReconcile] find pending orders error: %v", err)
return nil
}
if len(orders) == 0 {
return nil
}
logger.Infof("[IAPReconcile] found %d pending IAP orders (requireTradeNo=%v)", len(orders), requireTradeNo)
for _, ord := range orders {
if ord.TradeNo != "" {
// 有 trade_no直接用 transactionId 查询 Apple Server API
l.reconcileByTradeNo(ctx, apiCfg, ord)
} else if ord.AppAccountToken != "" {
// trade_no 为空但有 app_account_token兜底查询
l.reconcileByAppAccountToken(ctx, apiCfg, ord)
}
time.Sleep(100 * time.Millisecond) // 避免 Apple API 限频
}
return nil
}
// reconcileByTradeNo 通过 trade_no (originalTransactionId) 直接查询 Apple 交易状态
func (l *ReconcileLogic) reconcileByTradeNo(ctx context.Context, apiCfg *iapapple.ServerAPIConfig, ord *order.Order) {
jws, e := iapapple.GetTransactionInfo(*apiCfg, ord.TradeNo)
if e != nil {
logger.Errorf("[IAPReconcile] GetTransactionInfo error: orderNo=%s tradeNo=%s err=%v",
ord.OrderNo, ord.TradeNo, e)
return
}
txPayload, e := iapapple.ParseTransactionJWS(jws)
if e != nil {
logger.Errorf("[IAPReconcile] ParseTransactionJWS error: orderNo=%s err=%v", ord.OrderNo, e)
return
}
if txPayload.RevocationDate != nil {
logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo)
if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil {
logger.Errorf("[IAPReconcile] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr)
}
return
}
// 正常已付款交易 → 先将状态改为已支付,再 enqueue 激活
if statusErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 2); statusErr != nil {
logger.Errorf("[IAPReconcile] update order status to paid error: orderNo=%s err=%v", ord.OrderNo, statusErr)
return
}
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: ord.OrderNo})
task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)
if _, e = l.svc.Queue.EnqueueContext(ctx, task); e != nil {
logger.Errorf("[IAPReconcile] enqueue activate error: orderNo=%s err=%v", ord.OrderNo, e)
} else {
logger.Infof("[IAPReconcile] enqueued activate: %s", ord.OrderNo)
}
}
// reconcileByAppAccountToken 兜底trade_no 为空但有 app_account_token
// 通过用户历史交易 ID 查询 Apple 交易历史,匹配 appAccountToken 找到对应交易
func (l *ReconcileLogic) reconcileByAppAccountToken(ctx context.Context, apiCfg *iapapple.ServerAPIConfig, ord *order.Order) {
// 1. 获取该用户最近的已知交易 ID
lastTx, err := l.svc.IAPAppleTransactionModel.FindLatestByUserId(ctx, ord.UserId)
if err != nil || lastTx == nil || lastTx.OriginalTransactionId == "" {
logger.Infof("[IAPReconcile-Fallback] no historical transaction found for user %d, orderNo=%s, skip",
ord.UserId, ord.OrderNo)
return
}
logger.Infof("[IAPReconcile-Fallback] searching Apple history for orderNo=%s appAccountToken=%s using transactionId=%s",
ord.OrderNo, ord.AppAccountToken, lastTx.OriginalTransactionId)
// 2. 查询 Apple 交易历史
jwsList, err := iapapple.GetTransactionHistory(*apiCfg, lastTx.OriginalTransactionId)
if err != nil {
logger.Errorf("[IAPReconcile-Fallback] GetTransactionHistory error: orderNo=%s err=%v", ord.OrderNo, err)
return
}
// 3. 在交易历史中查找匹配 appAccountToken 的交易
for _, jws := range jwsList {
txPayload, e := iapapple.ParseTransactionJWS(jws)
if e != nil {
continue
}
if txPayload.AppAccountToken != ord.AppAccountToken {
continue
}
// 匹配成功!
logger.Infof("[IAPReconcile-Fallback] MATCHED! orderNo=%s appAccountToken=%s transactionId=%s",
ord.OrderNo, ord.AppAccountToken, txPayload.TransactionId)
if txPayload.RevocationDate != nil {
logger.Infof("[IAPReconcile-Fallback] transaction revoked, closing order: %s", ord.OrderNo)
if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil {
logger.Errorf("[IAPReconcile-Fallback] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr)
}
return
}
// 更新 trade_no 为找到的 originalTransactionId
if updateErr := l.svc.DB.Model(&order.Order{}).
Where("order_no = ?", ord.OrderNo).
Update("trade_no", txPayload.OriginalTransactionId).Error; updateErr != nil {
logger.Errorf("[IAPReconcile-Fallback] update trade_no error: orderNo=%s err=%v", ord.OrderNo, updateErr)
}
// 先将订单状态改为已支付status=2activateOrderLogic 要求 status=2 才会处理
if statusErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 2); statusErr != nil {
logger.Errorf("[IAPReconcile-Fallback] update order status to paid error: orderNo=%s err=%v", ord.OrderNo, statusErr)
return
}
logger.Infof("[IAPReconcile-Fallback] order status updated to paid: %s", ord.OrderNo)
// enqueue 激活
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: ord.OrderNo})
task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)
if _, e := l.svc.Queue.EnqueueContext(ctx, task); e != nil {
logger.Errorf("[IAPReconcile-Fallback] enqueue activate error: orderNo=%s err=%v", ord.OrderNo, e)
} else {
logger.Infof("[IAPReconcile-Fallback] enqueued activate: %s", ord.OrderNo)
}
return
}
logger.Infof("[IAPReconcile-Fallback] no matching transaction found for orderNo=%s appAccountToken=%s (checked %d transactions)",
ord.OrderNo, ord.AppAccountToken, len(jwsList))
}
// loadAppleAPIConfig 从 payment 表读取第一个启用的 Apple IAP 支付方式配置
func (l *ReconcileLogic) loadAppleAPIConfig(ctx context.Context) (*iapapple.ServerAPIConfig, error) {
pays, err := l.svc.PaymentModel.FindListByPlatform(ctx, pkgpayment.AppleIAP.String())
if err != nil {
return nil, err
}
for _, pay := range pays {
if pay.Enable == nil || !*pay.Enable || pay.Config == "" {
continue
}
var cfg payment.AppleIAPConfig
if e := cfg.Unmarshal([]byte(pay.Config)); e != nil {
continue
}
if cfg.KeyID == "" || cfg.IssuerID == "" || cfg.PrivateKey == "" {
continue
}
apiCfg := &iapapple.ServerAPIConfig{
KeyID: cfg.KeyID,
IssuerID: cfg.IssuerID,
PrivateKey: fixPEM(cfg.PrivateKey),
Sandbox: cfg.Sandbox,
}
// 从 site custom data 读取 BundleID
var customData struct {
IapBundleId string `json:"iapBundleId"`
}
if l.svc.Config.Site.CustomData != "" {
_ = json.Unmarshal([]byte(l.svc.Config.Site.CustomData), &customData)
apiCfg.BundleID = customData.IapBundleId
}
return apiCfg, nil
}
return nil, nil
}
func fixPEM(key string) string {
if !strings.Contains(key, "\n") && strings.Contains(key, "BEGIN PRIVATE KEY") {
key = strings.ReplaceAll(key, " ", "\n")
key = strings.ReplaceAll(key, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----")
key = strings.ReplaceAll(key, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----")
}
return key
}