05ee5b9d2e
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>
75 lines
2.1 KiB
Go
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
|
|
}
|