feat(server/pay): 优惠套餐(Promo SKU)每账号限购一次——已付过再下单 409 PROMO_ALREADY_USED
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 17s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 18s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 6s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 35s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 32s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 9s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m40s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 21s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 17s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 18s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 6s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 35s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 32s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 9s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m40s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user