fix: P1 activation path hardening - Bug 4-9

Bug 4: resolveRenewalActivationSubscription - add fallback by user_id+subscribe_id
  with SELECT FOR UPDATE when token lookup fails

Bug 5: appleIAPNotifyLogic - return error on product ID mapping failure instead of
  silently dropping the notification

Bug 6: NewPurchase fallback query - wrap in transaction with SELECT FOR UPDATE to
  prevent concurrent duplicate subscription creation

Bug 7: appleIAPNotifyLogic - fix UserId=0 by reverse-lookup from original purchase
  order; create renewal audit order record for DID_RENEW/SUBSCRIBED notifications

Bug 8: UpdateOrderStatus - pre-delete cache before DB write (double-delete) to
  close TOCTOU window between DB update and cache invalidation

Bug 9: validateNewUserOnlyEligibilityAtActivation - add Redis distributed lock on
  user_id to serialise concurrent new-user-only order activations

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-25 00:35:54 -07:00
parent a0f2d8a7b8
commit 0bd7560b64
4 changed files with 153 additions and 10 deletions
+86 -1
View File
@@ -3,17 +3,20 @@ package notify
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"gorm.io/gorm"
)
@@ -84,6 +87,7 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
l.Errorw("iap notify insert transaction error", logger.Field("error", e.Error()), logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
return e
}
existing = rec
} else {
if txPayload.RevocationDate != nil {
// 撤销场景:更新 revocation_at
@@ -96,6 +100,32 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
}
}
}
// Fix Bug 7: resolve userId when the IAP transaction record has no associated user.
// This can happen if the first notification for a subscription arrived before the
// in-app purchase flow created the order (race) or the order was made in a previous
// build that did not write user_id to the IAP transaction table.
if existing != nil && existing.UserId == 0 {
var origOrder order.Order
if lookupErr := db.Model(&order.Order{}).
Where("trade_no = ? AND method = ?", txPayload.OriginalTransactionId, "apple_iap").
Order("id ASC").
First(&origOrder).Error; lookupErr == nil && origOrder.UserId > 0 {
existing.UserId = origOrder.UserId
_ = db.Model(&iapmodel.Transaction{}).
Where("id = ?", existing.Id).
Update("user_id", origOrder.UserId).Error
l.Infow("iap notify resolved zero userId from original purchase order",
logger.Field("userId", origOrder.UserId),
logger.Field("originalTransactionId", txPayload.OriginalTransactionId),
)
} else {
l.Errorw("CRITICAL: iap notify UserId=0 and cannot resolve from order, notification dropped",
logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
return fmt.Errorf("iap notify: UserId=0 and cannot resolve from order for original_transaction_id=%s", txPayload.OriginalTransactionId)
}
}
var days int64
{
pid := strings.ToLower(txPayload.ProductId)
@@ -169,7 +199,12 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
}
}
if days == 0 {
l.Errorw("iap notify product mapping missing", logger.Field("productId", txPayload.ProductId))
// Both string-parse and DB fallback failed to map the product to days.
// Return an error so Apple retries the notification; silently ignoring
// this would cause the subscriber's renewal to be lost.
l.Errorw("CRITICAL: iap notify product mapping missing, returning error to trigger retry",
logger.Field("productId", txPayload.ProductId))
return fmt.Errorf("iap product id %s could not be mapped to subscription days", txPayload.ProductId)
}
token := "iap:" + txPayload.OriginalTransactionId
sub, e := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
@@ -216,6 +251,11 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
logger.Field("product_id", txPayload.ProductId),
)...,
)
// Create audit order record for renewal notifications (Bug 7)
if err := l.createIAPRenewalAuditOrder(db, ntype, txPayload.TransactionId, candidate.UserId, candidate.SubscribeId, candidate.Token); err != nil {
l.Errorw("iap notify fallback create renewal order error", logger.Field("error", err.Error()))
// Non-fatal: subscription already updated; order creation failure is logged only
}
break
}
}
@@ -248,7 +288,52 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
logger.Field("product_id", txPayload.ProductId),
)...,
)
// Create audit order record for renewal notifications (Bug 7)
if err := l.createIAPRenewalAuditOrder(db, ntype, txPayload.TransactionId, sub.UserId, sub.SubscribeId, sub.Token); err != nil {
l.Errorw("iap notify create renewal order error", logger.Field("error", err.Error()))
// Non-fatal: subscription already updated; order creation failure is logged only
}
}
return nil
})
}
// createIAPRenewalAuditOrder creates a finished renewal order record for DID_RENEW and SUBSCRIBED
// Apple SSNS notifications so that the order table has a complete financial audit trail.
// The operation is idempotent: if an order with the same trade_no already exists it is skipped.
func (l *AppleIAPNotifyLogic) createIAPRenewalAuditOrder(db *gorm.DB, ntype, transactionId string, userId, subscribeId int64, subscribeToken string) error {
if ntype != "DID_RENEW" && ntype != "SUBSCRIBED" {
return nil
}
// Idempotency check
var count int64
if err := db.Model(&order.Order{}).
Where("trade_no = ? AND method = ?", transactionId, "apple_iap").
Count(&count).Error; err != nil {
return fmt.Errorf("check existing iap renewal order: %w", err)
}
if count > 0 {
return nil
}
rec := &order.Order{
UserId: userId,
OrderNo: tool.GenerateTradeNo(),
Type: 2, // OrderTypeRenewal
Status: 5, // OrderStatusFinished
Method: "apple_iap",
TradeNo: transactionId,
SubscribeId: subscribeId,
SubscribeToken: subscribeToken,
Quantity: 1,
IsNew: false,
}
if err := db.Model(&order.Order{}).Create(rec).Error; err != nil {
return fmt.Errorf("create iap renewal audit order: %w", err)
}
l.Infow("iap notify created renewal audit order",
logger.Field("orderNo", rec.OrderNo),
logger.Field("transactionId", transactionId),
logger.Field("userId", userId),
)
return nil
}
+5 -1
View File
@@ -110,6 +110,10 @@ func (m *customOrderModel) UpdateOrderStatus(ctx context.Context, orderNo string
if err != nil {
return err
}
keys := m.getCacheKeys(orderInfo)
// Pre-delete: evict cache before the DB write so concurrent reads during the update
// window go to DB instead of getting a stale cached status (double-delete pattern).
_ = m.DelCacheCtx(ctx, keys...)
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
@@ -123,7 +127,7 @@ func (m *customOrderModel) UpdateOrderStatus(ctx context.Context, orderNo string
return nil
}
return nil
}, m.getCacheKeys(orderInfo)...)
}, keys...)
}
// FindOneDetailsByOrderNo Find order details by order number