Files
hi-server/queue/logic/order/newUserEligibility.go
T
shanshanzhong147 0bd7560b64 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>
2026-05-25 02:18:19 -07:00

75 lines
2.1 KiB
Go

package orderLogic
import (
"context"
"encoding/json"
"fmt"
"time"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/subscribe"
internaltypes "github.com/perfect-panel/server/internal/types"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
func validateNewUserOnlyEligibilityAtActivation(
ctx context.Context,
db *gorm.DB,
rdb *redis.Client,
orderInfo *order.Order,
sub *subscribe.Subscribe,
) error {
if orderInfo == nil || sub == nil || orderInfo.Type != OrderTypeSubscribe || sub.Discount == "" {
return nil
}
var discounts []internaltypes.SubscribeDiscount
if err := json.Unmarshal([]byte(sub.Discount), &discounts); err != nil {
return nil
}
if !isNewUserOnlyForQuantity(discounts, orderInfo.Quantity) {
return nil
}
// Acquire a per-user distributed lock so concurrent new-user-only activations
// for the same account are serialised. Without this, two workers can both read
// historyCount=0 and both pass the check before either has written the order.
lockKey := fmt.Sprintf("new_user_only_activate:%d", orderInfo.UserId)
const lockTTL = 30 * time.Second
acquired, lockErr := rdb.SetNX(ctx, lockKey, orderInfo.OrderNo, lockTTL).Result()
if lockErr != nil {
return fmt.Errorf("new user only: acquire lock error: %w", lockErr)
}
if !acquired {
return fmt.Errorf("new user only: another activation is in progress for user %d", orderInfo.UserId)
}
defer rdb.Del(ctx, lockKey)
eligibility, err := commonLogic.ResolveNewUserEligibility(ctx, db, orderInfo.UserId)
if err != nil {
return err
}
if !eligibility.IsNewUserAt(time.Now()) {
return fmt.Errorf("new user only: user %d is not a new user", orderInfo.UserId)
}
historyCount, err := commonLogic.CountScopedSubscribePurchaseOrders(
ctx,
db,
eligibility.ScopeUserIDs,
0,
[]int64{OrderStatusFinished},
orderInfo.OrderNo,
)
if err != nil {
return fmt.Errorf("new user only: check history error: %w", err)
}
if historyCount >= 1 {
return fmt.Errorf("new user only: user %d already activated an order", orderInfo.UserId)
}
return nil
}