This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
iapLogic "github.com/perfect-panel/server/queue/logic/iap"
|
||||
orderLogic "github.com/perfect-panel/server/queue/logic/order"
|
||||
smslogic "github.com/perfect-panel/server/queue/logic/sms"
|
||||
"github.com/perfect-panel/server/queue/logic/subscription"
|
||||
@@ -43,4 +44,8 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
|
||||
|
||||
// ForthwithQuotaTask
|
||||
mux.Handle(types.ForthwithQuotaTask, task.NewQuotaTaskLogic(serverCtx))
|
||||
|
||||
// Apple IAP 对账(第二层:5min 扫描 + 第三层:日终全量)
|
||||
mux.Handle(types.SchedulerIAPReconcile, iapLogic.NewReconcileLogic(serverCtx))
|
||||
mux.Handle(types.SchedulerIAPDailyReconcile, iapLogic.NewDailyReconcileLogic(serverCtx))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package iap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
// DailyReconcileLogic 第三层:日终全量对账(每天 02:00)
|
||||
// 扫描过去 48h 内所有未支付的 IAP 订单(含 trade_no 为空的极端情况)
|
||||
type DailyReconcileLogic struct {
|
||||
inner *ReconcileLogic
|
||||
}
|
||||
|
||||
func NewDailyReconcileLogic(svc *svc.ServiceContext) *DailyReconcileLogic {
|
||||
return &DailyReconcileLogic{inner: &ReconcileLogic{svc: svc}}
|
||||
}
|
||||
|
||||
func (l *DailyReconcileLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error {
|
||||
logger.Infof("[IAPDailyReconcile] start at %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
// 第三层:扫描 1min ~ 48h,trade_no 非空(trade_no 为空说明客户端从未上传过 token,服务端无法主动补单)
|
||||
// 实际上与第二层的区别是时间窗口:覆盖全天的遗漏
|
||||
return l.inner.reconcile(ctx, 1*time.Minute, 48*time.Hour, true)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package iap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"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, true)
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
continue
|
||||
}
|
||||
// 用 originalTransactionId(即 trade_no)向 Apple Server API 查询交易详情
|
||||
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)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
// 解析 JWS(不校验证书链,只读取 payload)
|
||||
txPayload, e := iapapple.ParseTransactionJWS(jws)
|
||||
if e != nil {
|
||||
logger.Errorf("[IAPReconcile] ParseTransactionJWS error: orderNo=%s err=%v", ord.OrderNo, e)
|
||||
continue
|
||||
}
|
||||
if txPayload.RevocationDate != nil {
|
||||
// 苹果已撤销交易 → 关闭订单
|
||||
logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo)
|
||||
_ = l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3)
|
||||
continue
|
||||
}
|
||||
// 正常已付款交易 → enqueue 激活(activateOrderLogic 内部有幂等保护)
|
||||
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)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond) // 避免 Apple API 限频
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package types
|
||||
|
||||
const (
|
||||
SchedulerCheckSubscription = "scheduler:check:subscription"
|
||||
SchedulerTotalServerData = "scheduler:total:server"
|
||||
SchedulerResetTraffic = "scheduler:reset:traffic"
|
||||
SchedulerTrafficStat = "scheduler:traffic:stat"
|
||||
SchedulerCheckSubscription = "scheduler:check:subscription"
|
||||
SchedulerTotalServerData = "scheduler:total:server"
|
||||
SchedulerResetTraffic = "scheduler:reset:traffic"
|
||||
SchedulerTrafficStat = "scheduler:traffic:stat"
|
||||
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
|
||||
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user