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:
wangjia
2026-07-03 22:25:07 +08:00
parent c7de728c18
commit b3c9d98eae
9 changed files with 255 additions and 30 deletions
+12
View File
@@ -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) {