diff --git a/backend/internal/handler/pay.go b/backend/internal/handler/pay.go index d5c9887..f585dd1 100644 --- a/backend/internal/handler/pay.go +++ b/backend/internal/handler/pay.go @@ -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) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2ef8604..3371bab 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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(过期只读/锁定拦截写操作) diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 90909d1..7a7aa7b 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -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 对账, diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index 555106f..c432452 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -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) { diff --git a/client/lib/core/config/license_plans.dart b/client/lib/core/config/license_plans.dart index 9c23fd5..32a48f2 100644 --- a/client/lib/core/config/license_plans.dart +++ b/client/lib/core/config/license_plans.dart @@ -23,6 +23,17 @@ class LicensePlanGroup { } class LicensePlans { + /// 新店首月特惠:¥1 体验 30 天标准版全部功能,每个门店限购一次 + /// (服务端强校验,前端据 GET /license/promo-status 置灰)。 + static const promo = LicensePlan('promo_first_month', 1, 30); + static const promoOriginalPrice = 299; // 划线原价(=标准版月付) + static const promoFeats = [ + '标准版全部功能,一分不少', + '单门店 · 单仓库 · 2 台客户端', + '1,000 张商品图片分享', + '每个门店限购一次', + ]; + static const standard = LicensePlanGroup( name: '标准版', feats: ['单门店 · 单仓库', '2 台客户端同时使用', '1,000 张商品图片分享'], diff --git a/client/lib/repositories/license_repository.dart b/client/lib/repositories/license_repository.dart index f21a2e5..c8b1b28 100644 --- a/client/lib/repositories/license_repository.dart +++ b/client/lib/repositories/license_repository.dart @@ -68,6 +68,18 @@ class LicenseRepository { } } + /// 本店首月特惠是否已享用(购买弹窗据此置灰特惠档)。 + Future promoUsed() async { + try { + final resp = await _client.get('/license/promo-status'); + final data = resp.data['data'] as Map?; + return data?['used'] as bool? ?? false; + } on DioException { + // 查询失败不阻塞弹窗:按未享用展示,下单时服务端仍会强校验 + return false; + } + } + /// Deactivate (unbind) this device from its license. Future deactivate() async { final deviceId = await DeviceId.get(); diff --git a/client/lib/screens/settings/purchase_dialog.dart b/client/lib/screens/settings/purchase_dialog.dart index db11fea..48fe8f6 100644 --- a/client/lib/screens/settings/purchase_dialog.dart +++ b/client/lib/screens/settings/purchase_dialog.dart @@ -42,19 +42,32 @@ class _PurchaseDialog extends ConsumerStatefulWidget { class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { _Phase _phase = _Phase.pick; - int _groupIdx = 0; // 0=标准版 1=高级版 + int _tabIdx = 0; // 0=标准版 1=首月特惠 2=高级版 int _cycleIdx = 0; // 0=年付 1=月付 bool _submitting = false; bool _checking = false; + bool _promoUsed = false; // 首月特惠每店限一次,已享用则置灰(服务端仍强校验) PurchaseOrder? _order; DateTime? _newExpiresAt; Timer? _pollTimer; - LicensePlanGroup get _group => LicensePlans.groups[_groupIdx]; - LicensePlan get _plan => _cycleIdx == 0 ? _group.annual : _group.monthly; + bool get _isPromo => _tabIdx == 1; + LicensePlanGroup get _group => + _tabIdx == 0 ? LicensePlans.standard : LicensePlans.pro; + LicensePlan get _plan => _isPromo + ? LicensePlans.promo + : (_cycleIdx == 0 ? _group.annual : _group.monthly); String get _cycleLabel => _cycleIdx == 0 ? '年付' : '月付'; + @override + void initState() { + super.initState(); + ref.read(licenseRepositoryProvider).promoUsed().then((used) { + if (mounted && used) setState(() => _promoUsed = true); + }); + } + @override void dispose() { _pollTimer?.cancel(); @@ -133,9 +146,15 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { DsButton('取消', onPressed: _submitting ? null : () => Navigator.of(context).pop()), - DsButton(_submitting ? '正在创建订单…' : '提交订单 · ¥${_fmt(_plan.price)}', + DsButton( + _submitting + ? '正在创建订单…' + : _isPromo && _promoUsed + ? '本店已享受过' + : '提交订单 · ¥${_fmt(_plan.price)}', variant: DsBtnVariant.primary, - onPressed: _submitting ? null : _submit), + onPressed: + _submitting || (_isPromo && _promoUsed) ? null : _submit), ], _Phase.waiting => [ DsButton('稍后再说', onPressed: () => Navigator.of(context).pop()), @@ -153,24 +172,60 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { } Widget _buildPick(AppTokens t) { + final promoGrayed = _isPromo && _promoUsed; + final feats = _isPromo ? LicensePlans.promoFeats : _group.feats; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ + Wrap(spacing: 10, runSpacing: 8, children: [ DsSeg( - items: [for (final g in LicensePlans.groups) g.name], - index: _groupIdx, - onChanged: (i) => setState(() => _groupIdx = i), - ), - const SizedBox(width: 10), - DsSeg( - items: const ['年付', '月付'], - index: _cycleIdx, - onChanged: (i) => setState(() => _cycleIdx = i), + items: [ + '标准版', + _promoUsed ? '首月特惠(已享受)' : '🔥 首月特惠 ¥1', + '高级版', + ], + index: _tabIdx, + onChanged: (i) => setState(() => _tabIdx = i), ), + if (!_isPromo) + DsSeg( + items: const ['年付', '月付'], + index: _cycleIdx, + onChanged: (i) => setState(() => _cycleIdx = i), + ), ]), const SizedBox(height: 14), + if (_isPromo) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: promoGrayed ? t.bg : t.warnBg, + border: Border.all(color: promoGrayed ? t.border : t.warn), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(promoGrayed ? '本店已享受过首月特惠' : '新店专享 · 仅 ¥1 体验 30 天', + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w700, + color: promoGrayed ? t.faint : t.warn)), + const SizedBox(height: 3), + Text( + promoGrayed + ? '首月特惠每个门店限购一次,可选择标准版或高级版继续续费。' + : '解锁标准版全部功能,原价 ¥299,每个门店限购一次——试过才知道多省心。', + style: TextStyle( + fontSize: AppDims.fsSm, + color: promoGrayed ? t.faint : t.text)), + ], + ), + ), + const SizedBox(height: 10), + ], Container( width: double.infinity, padding: const EdgeInsets.all(14), @@ -182,16 +237,18 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final f in _group.feats) + for (final f in feats) Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row(children: [ - Icon(LucideIcons.check, size: 14, color: t.success), + Icon(LucideIcons.check, + size: 14, color: promoGrayed ? t.faint : t.success), const SizedBox(width: 8), Expanded( child: Text(f, style: TextStyle( - fontSize: AppDims.fsSm, color: t.text))), + fontSize: AppDims.fsSm, + color: promoGrayed ? t.faint : t.text))), ]), ), ], @@ -202,14 +259,31 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: Text('${_group.name} · $_cycleLabel · ${_plan.days} 天授权', + child: Text( + _isPromo + ? '首月特惠 · ${_plan.days} 天授权' + : '${_group.name} · $_cycleLabel · ${_plan.days} 天授权', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ), + if (_isPromo) ...[ + Text('¥${_fmt(LicensePlans.promoOriginalPrice)}', + style: TextStyle( + fontSize: AppDims.fsSm, + color: t.faint, + decoration: TextDecoration.lineThrough, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)), + const SizedBox(width: 8), + ], Text('¥${_fmt(_plan.price)}', style: TextStyle( fontSize: 24, fontWeight: FontWeight.w700, - color: t.heading, + color: promoGrayed + ? t.faint + : _isPromo + ? t.warn + : t.heading, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ], diff --git a/web/checkout.njk b/web/checkout.njk index 5416391..c4bdda4 100644 --- a/web/checkout.njk +++ b/web/checkout.njk @@ -16,10 +16,15 @@ permalink: /checkout/

选择套餐

+
+
    -
    计费周期
    +
    计费周期
    @@ -54,7 +59,7 @@ permalink: /checkout/

    费用汇总

    标准版 · 年付¥2,999
    -
    优惠− ¥0
    +
    优惠− ¥0
    应付总额¥2,999
    @@ -80,6 +85,11 @@ permalink: /checkout/ monthly: { biz: 'monthly_standard', price: 299 }, annual: { biz: 'annual_standard', price: 2999 }, }, + promo: { + name: '首月特惠', + feats: ['标准版全部功能,一分不少', '单门店 · 单仓库 · 2 台客户端', '1,000 张商品图片分享', '每个门店限购一次'], + single: { biz: 'promo_first_month', price: 1, orig: 299 }, + }, pro: { name: '高级版', feats: ['单门店 · 多仓库', '5 台客户端同时使用', '10,000 张商品图片分享', '免费 AI 周度 / 月度商业数据分析'], @@ -92,29 +102,51 @@ permalink: /checkout/ var cycle = 'annual'; function fmt(n) { return '¥' + n.toLocaleString('zh-CN'); } + function selected() { return plan === 'promo' ? PLANS.promo.single : PLANS[plan][cycle]; } function render() { - var p = PLANS[plan], c = p[cycle]; - var days = cycle === 'annual' ? 365 : 30; + var p = PLANS[plan], c = selected(), isPromo = plan === 'promo'; + var days = isPromo || cycle === 'monthly' ? 30 : 365; + var cyc = isPromo ? '仅 ¥1 · 30 天' : (cycle === 'annual' ? '年付' : '月付'); + $('promoBanner').style.display = isPromo ? 'block' : 'none'; + $('cycleLabel').style.display = isPromo ? 'none' : ''; + $('cycleSeg').style.display = isPromo ? 'none' : ''; $('planFeats').innerHTML = p.feats.map(function (f) { return '
  • ' + f + '
  • '; }).join(''); - $('itName').textContent = p.name + '授权 · ' + (cycle === 'annual' ? '年付' : '月付'); + $('itName').textContent = p.name + '授权 · ' + cyc; $('itDur').textContent = days + ' 天授权 · 到期时间自动叠加'; $('itAmt').textContent = fmt(c.price); - $('sumName').textContent = p.name + ' · ' + (cycle === 'annual' ? '年付' : '月付'); - $('sumBase').textContent = fmt(c.price); + $('sumName').textContent = p.name + (isPromo ? '(原价 ¥299/月)' : ' · ' + cyc); + $('sumBase').textContent = fmt(isPromo ? c.orig : c.price); + $('sumOff').textContent = '− ' + fmt(isPromo ? c.orig - c.price : 0); $('sumTotal').textContent = fmt(c.price); document.querySelectorAll('#planSeg .seg-btn').forEach(function (b) { b.classList.toggle('on', b.dataset.v === plan); }); document.querySelectorAll('#cycleSeg .seg-btn').forEach(function (b) { b.classList.toggle('on', b.dataset.v === cycle); }); } document.querySelectorAll('#planSeg .seg-btn').forEach(function (b) { - b.addEventListener('click', function () { plan = b.dataset.v; render(); }); + b.addEventListener('click', function () { + if (b.disabled) return; + plan = b.dataset.v; render(); + }); }); document.querySelectorAll('#cycleSeg .seg-btn').forEach(function (b) { b.addEventListener('click', function () { cycle = b.dataset.v; render(); }); }); render(); + // 首月特惠每店限一次:已享用则该档置灰不可选 + jiuAuth.authFetch(API, '/api/v1/license/promo-status') + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (j) { + if (!j || !j.data || !j.data.used) return; + var b = $('promoSegBtn'); + b.disabled = true; + b.classList.add('used'); + b.innerHTML = '首月特惠(已享受)'; + if (plan === 'promo') { plan = 'standard'; render(); } + }) + .catch(function () {}); + function showErr(msg) { var el = $('coErr'); el.textContent = msg; el.classList.add('show'); } $('coBtn').addEventListener('click', function () { $('coErr').classList.remove('show'); @@ -122,7 +154,7 @@ permalink: /checkout/ jiuAuth.authFetch(API, '/api/v1/license/purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ biz_code: PLANS[plan][cycle].biz }), + body: JSON.stringify({ biz_code: selected().biz }), }) .then(function (r) { // authFetch 已自动用 refresh_token 续签重试过,仍 401 才是真过期 diff --git a/web/index.njk b/web/index.njk index 7e8cb0c..992b374 100644 --- a/web/index.njk +++ b/web/index.njk @@ -190,7 +190,7 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱

    按门店付费,明码标价

    先免费试用,再按需选档;时长兑换券续期,无隐藏费用、无自动扣款。

    -
    +
    免费版
    ¥0 / 30 天
    @@ -202,6 +202,17 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱 立即试用
    +
    +
    首月特惠
    +
    ¥1¥299 / 30 天
    +
    一块钱用一个月,每店限一次
    +
      +
    • 标准版全部功能,一分不少
    • +
    • 2 台客户端 · 1,000 张图片
    • +
    • 试过才知道多省心
    • +
    + ¥1 立即体验 +
    标准版
    ¥299 / 月
    @@ -277,15 +288,27 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱 标准/高级按钮 → 「购买 / 续费」进 /checkout/;免费版按钮 → 「进入系统」。 */ (function () { if (!window.jiuAuth || !jiuAuth.loggedIn()) return; + var API = '{{ site.appBaseUrl }}'; document.querySelectorAll('[data-plan]').forEach(function (a) { var plan = a.dataset.plan; if (plan === 'free') { a.textContent = '进入系统'; a.href = '/app/'; + } else if (plan === 'promo') { + a.href = '/checkout/?plan=promo'; } else { a.textContent = '购买 / 续费'; a.href = '/checkout/?plan=' + plan; } }); + // 首月特惠每店限一次:已享用则置灰 + jiuAuth.authFetch(API, '/api/v1/license/promo-status') + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (j) { + if (!j || !j.data || !j.data.used) return; + var b = document.getElementById('promoPlanBtn'); + if (b) { b.textContent = '已享受过'; b.classList.remove('primary'); b.classList.add('ghost', 'disabled'); b.removeAttribute('href'); } + }) + .catch(function () {}); })();