diff --git a/server/internal/pay/handler.go b/server/internal/pay/handler.go index bdd32db..93d6fb6 100644 --- a/server/internal/pay/handler.go +++ b/server/internal/pay/handler.go @@ -125,6 +125,21 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { return } + // 优惠档(Promo SKU)每账号限购一次:该用户已有该 SKU 的 paid 台账则拒单, + // 不打上游(真机实测发现用户可反复购买优惠档)。Retry/Cancel 不受限——恢复原单不受限。 + if item.Promo { + used, err := h.store.HasPaidPurchase(ctx, uid, req.SKU) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if used { + apierr.WriteJSON(w, http.StatusConflict, + apierr.New("PROMO_ALREADY_USED", "优惠套餐每个账号限购一次,你已购买过", "This promotional plan is limited to one per account and you have already purchased it")) + return + } + } + res, err := h.client.CreateOrder(ctx, req.SKU, req.Method, bizRef, filterMetadata(req.Metadata)) if err != nil { writePayErr(w, err) diff --git a/server/internal/pay/handler_test.go b/server/internal/pay/handler_test.go index 99f1ff6..cfa7d67 100644 --- a/server/internal/pay/handler_test.go +++ b/server/internal/pay/handler_test.go @@ -278,6 +278,84 @@ func TestGetOrder_EnrichedFields(t *testing.T) { } } +// 优惠档(Promo SKU)限购一次:已有 paid 台账 → 下单 409 PROMO_ALREADY_USED, +// 且不打上游(fake pay client 一旦被调用即 t.Fatal)。 +func TestCreateOrder_PromoAlreadyUsed409(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("已购过优惠档,不该打到 pay 上游") + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month_promo", "promo-old", "alipay", 600, "CNY"); err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(context.Background(), + `UPDATE pay_purchases SET status = 'paid' WHERE out_trade_no = 'promo-old'`); err != nil { + t.Fatal(err) + } + body := []byte(`{"sku":"pro_month_promo","method":"alipay"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)) + if w.Code != http.StatusConflict { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } + var e struct { + Code string `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &e); err != nil { + t.Fatal(err) + } + if e.Code != "PROMO_ALREADY_USED" { + t.Fatalf("code = %q, want PROMO_ALREADY_USED", e.Code) + } +} + +// 优惠档历史只有 created/canceled(未 paid)→ 不受限,正常放行下单。 +func TestCreateOrder_PromoNotYetPaidAllowed(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"order_no":"promo-new","session":{ + "render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`)) + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month_promo", "promo-created", "alipay", 600, "CNY"); err != nil { + t.Fatal(err) + } + if err := st.MarkCanceled(context.Background(), 1, "promo-created"); err != nil { + t.Fatal(err) + } + body := []byte(`{"sku":"pro_month_promo","method":"alipay"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } + row, err := st.GetForUser(context.Background(), 1, "promo-new") + if err != nil { + t.Fatalf("台账未落: %v", err) + } + if row.SKU != "pro_month_promo" || row.Status != "created" { + t.Fatalf("台账行不符: %+v", row) + } +} + +// 非 promo SKU 即使已 paid 也不受限购约束(限购只针对 Promo==true 的 SKU)。 +func TestCreateOrder_NonPromoSKUUnlimited(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"order_no":"month-2","session":{ + "render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`)) + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "month-1", "alipay", 2999, "CNY"); err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(context.Background(), + `UPDATE pay_purchases SET status = 'paid' WHERE out_trade_no = 'month-1'`); err != nil { + t.Fatal(err) + } + body := []byte(`{"sku":"pro_month","method":"alipay"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } +} + func TestCatalog(t *testing.T) { router, _ := newHandlerRig(t, nil) w := httptest.NewRecorder() diff --git a/server/internal/pay/store.go b/server/internal/pay/store.go index 59641fe..699b493 100644 --- a/server/internal/pay/store.go +++ b/server/internal/pay/store.go @@ -158,6 +158,19 @@ func (s *Store) MarkCanceled(ctx context.Context, userID int64, outTradeNo strin return nil } +// HasPaidPurchase 该用户是否已有该 SKU 的 paid 台账——供优惠档(Promo SKU)限购一次校验用 +// (CreateOrder 在调上游前查,已购则 409 拒单,不打上游)。 +func (s *Store) HasPaidPurchase(ctx context.Context, userID int64, sku string) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM pay_purchases WHERE user_id = ? AND sku = ? AND status = 'paid'`, + userID, sku).Scan(&n) + if err != nil { + return false, fmt.Errorf("pay.Store.HasPaidPurchase: %w", err) + } + return n > 0, nil +} + // SubscriptionExpiry 查开通行的到期时间(查单响应回带给客户端)。 func (s *Store) SubscriptionExpiry(ctx context.Context, subID int64) (time.Time, error) { var exp time.Time