feat: ¥1 首月特惠套餐(新店专享,每店限购一次,官网+App 同步上线)
- 后端:payPlans 新增 promo_first_month(¥1/30 天/标准版权益);下单强校验每店限购一次(仅 paid 计数,弃单可重下),已享用返回 409;新增 GET /license/promo-status 供前端置灰 - 官网:checkout 套餐段第二位插「🔥 首月特惠 ¥1」+ 推广横幅 + 划线原价 ¥299;首页价格区第二位插特惠卡(5 列);已享用自动置灰 - App:购买弹窗套餐段改三档(标准/特惠/高级),特惠选中显示推广横幅与划线价,已享用置灰并禁用提交 - 实付金额以 pay 侧为准,pay 需 seed promo_first_month 产品后生效 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -43,6 +43,8 @@ func (h *PayHandler) Purchase(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrUnknownPlan):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrPromoUsed):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("[pay] purchase failed shop=%d biz_code=%s: %v", middleware.GetShopID(c), req.BizCode, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "下单失败,请稍后重试"})
|
||||
@@ -66,6 +68,16 @@ func (h *PayHandler) PurchaseStatus(c *gin.Context) {
|
||||
util.RespondSuccess(c, st)
|
||||
}
|
||||
|
||||
// PromoStatus GET /api/v1/license/promo-status — 本店首月特惠是否已享用(前端据此置灰特惠档)。
|
||||
func (h *PayHandler) PromoStatus(c *gin.Context) {
|
||||
used, err := h.svc.PromoUsed(middleware.GetShopID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{"used": used})
|
||||
}
|
||||
|
||||
// Callback POST /api/v1/pay/callback — pay 支付成功 webhook(公开路由,HMAC 验签)。
|
||||
// 契约:验签失败回 401;受理成功回 200 + {"code":"SUCCESS"},否则 pay 每 60s 重试 24h。
|
||||
func (h *PayHandler) Callback(c *gin.Context) {
|
||||
|
||||
@@ -113,6 +113,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
// 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
|
||||
license.POST("/purchase", payH.Purchase)
|
||||
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
|
||||
license.GET("/promo-status", payH.PromoStatus)
|
||||
}
|
||||
|
||||
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
|
||||
|
||||
@@ -40,8 +40,12 @@ var (
|
||||
ErrPaySignature = errors.New("签名校验失败")
|
||||
ErrPayAmount = errors.New("回调金额与订单不符")
|
||||
ErrPurchaseNotFound = errors.New("购买记录不存在")
|
||||
ErrPromoUsed = errors.New("首月特惠每个门店限购一次,本店已享受过")
|
||||
)
|
||||
|
||||
// PromoBizCode 新店首月特惠(¥1/30 天标准版),每个门店仅可购买一次。
|
||||
const PromoBizCode = "promo_first_month"
|
||||
|
||||
// payPlan biz_code → 权益映射(与 pay 侧 seed 的套餐一一对应,金额权威在 pay,此处 price 仅作前端展示核对)。
|
||||
type payPlan struct {
|
||||
Days int
|
||||
@@ -52,6 +56,8 @@ type payPlan struct {
|
||||
}
|
||||
|
||||
var payPlans = map[string]payPlan{
|
||||
PromoBizCode: {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
|
||||
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
|
||||
"monthly_standard": {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
|
||||
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
|
||||
"annual_standard": {Days: 365, Tier: "standard", Type: "annual", MaxDevices: 2,
|
||||
@@ -91,6 +97,15 @@ func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode string) (*Pur
|
||||
if _, ok := payPlans[bizCode]; !ok {
|
||||
return nil, ErrUnknownPlan
|
||||
}
|
||||
if bizCode == PromoBizCode {
|
||||
used, err := s.PromoUsed(shopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if used {
|
||||
return nil, ErrPromoUsed
|
||||
}
|
||||
}
|
||||
|
||||
productID, err := s.productID(bizCode)
|
||||
if err != nil {
|
||||
@@ -394,6 +409,15 @@ func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus,
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// PromoUsed 返回本店是否已享受过首月特惠(已支付的特惠单存在即视为已用)。
|
||||
func (s *PayService) PromoUsed(shopID uint64) (bool, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&model.LicensePurchase{}).
|
||||
Where("shop_id = ? AND product_biz_code = ? AND status = ?", shopID, PromoBizCode, "paid").
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// ---------- ④ 查单兜底 ----------
|
||||
|
||||
// StartPayReconcile 后台每 60s 对 pending 超 5 分钟的购买单主动查 pay 对账,
|
||||
|
||||
@@ -275,6 +275,42 @@ func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrPayNotConfigured)
|
||||
}
|
||||
|
||||
// ---------- 首月特惠限购 ----------
|
||||
|
||||
func TestCreatePurchase_PromoOncePerShop(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PAY009")
|
||||
other := testutil.CreateTestShop(db, "PAY010")
|
||||
svc := newTestPaySvc(db, "http://pay.invalid")
|
||||
|
||||
// 未买过:不触发限购(pay 地址无效会走到下单失败,但不是 ErrPromoUsed)
|
||||
used, err := svc.PromoUsed(shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, used)
|
||||
_, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode)
|
||||
assert.NotErrorIs(t, err, ErrPromoUsed)
|
||||
|
||||
// pending 单不算已享用(可能弃单),仍可重新下单
|
||||
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-0")
|
||||
used, err = svc.PromoUsed(shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, used, "pending 不算已享用")
|
||||
|
||||
// 已支付的特惠单存在 → 已享用,再购直接拒绝
|
||||
p := createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-1")
|
||||
require.NoError(t, db.Model(p).Update("status", "paid").Error)
|
||||
used, err = svc.PromoUsed(shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, used)
|
||||
_, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode)
|
||||
assert.ErrorIs(t, err, ErrPromoUsed)
|
||||
|
||||
// 多租户隔离:别家买过不影响本店
|
||||
used, err = svc.PromoUsed(other.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, used)
|
||||
}
|
||||
|
||||
// ---------- Status ----------
|
||||
|
||||
func TestStatus_ScopedToShop(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user